diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..dcee850e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Pre-push gate for zk-coins/node. +# +# Activation (one-time per clone): +# git config core.hooksPath .githooks +# +# This hook catches lint regressions in seconds — there is no point +# waiting for CI to flag a missing import or a misformatted file. +# +# The authoritative test + coverage gate runs in CI on a self-hosted +# M3 Ultra runner (issue #40, .github/workflows/ci.yaml). The hook +# does *not* re-run those — locally on a laptop they take 60-90 min, +# and a developer waiting that long on every push is exactly what +# issue #40 removed. +# +# Wall budgets (warm cache, M3 Ultra) — kept in sync with the matching +# table in CONTRIBUTING.md § Setup: +# - cold cache: < 2 min +# - warm cache: < 30 s +# +# Bypass: `git push --no-verify` works. CI is the real gate, so a +# bypassed lint failure surfaces at the PR check level instead. +set -euo pipefail + +echo "[pre-push] cargo fmt --all --check" +cargo fmt --all --check + +echo "[pre-push] cargo clippy -p node -p shared (MVP feature set)" +cargo clippy -p node -p shared -- -D warnings + +echo "[pre-push] cargo clippy -p node --all-features (self-host opt-in build)" +cargo clippy -p node --all-features -- -D warnings + +echo "[pre-push] cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --lib" +cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --lib -- -D warnings + +echo "[pre-push] cargo check --workspace --all-features" +cargo check --workspace --all-features + +echo "[pre-push] all checks passed." diff --git a/.github/workflows/auto-release-pr.yaml b/.github/workflows/auto-release-pr.yaml index d7c452fc..44d1feba 100644 --- a/.github/workflows/auto-release-pr.yaml +++ b/.github/workflows/auto-release-pr.yaml @@ -8,6 +8,7 @@ on: permissions: contents: read pull-requests: write + issues: write # required by `gh label create` concurrency: group: auto-release-pr @@ -58,8 +59,18 @@ jobs: "- [ ] Merge when ready for production" \ > /tmp/pr-body.md + # `ci:full` opts the PR into the heavy M3 Ultra test + coverage + # jobs (see ci.yaml). Release PRs are exactly when we want the + # authoritative gate, so apply it on creation rather than + # relying on a human to remember the click. + gh label create ci:full \ + --color FFA500 \ + --description "Run heavy M3 Ultra test + coverage jobs on this PR" \ + 2>/dev/null || true + gh pr create \ --base main \ --head develop \ --title "Release: develop -> main" \ + --label ci:full \ --body-file /tmp/pr-body.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e6ea9e6c..791cbfa1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,20 +1,66 @@ name: CI on: - push: - branches: [develop] - # Only trigger on PRs targeting develop (feature → develop). The - # release PR (develop → main) is opened automatically and would - # otherwise fire a second CI run for every push to develop — those - # duplicate runs surfaced as "fail" entries on the release PR's - # check list whenever the concurrency block cancelled the older - # one. The push event already covers develop, and its run is - # associated with the same SHA on the release PR. + # 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. + # + # `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. + # (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 jobs + # on demand — see the `node-tests` job below. pull_request: - branches: [develop] + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] concurrency: - group: ci-${{ github.event.pull_request.head.sha || github.sha }} + # Group by PR number so a new push to the same PR cancels the + # in-flight Heavy run on the outdated commit. The m3-ultra pool + # (6 runner agents on dfx01) 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 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 + # 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: @@ -22,20 +68,25 @@ permissions: env: CARGO_TERM_COLOR: always - # Force Esplora broadcasts to fail fast in CI. Some unit tests - # exercise the commit pipeline that ends in a real HTTP broadcast; - # without this, the runs against the public Mutinynet API can take - # >60 s per test and tip the job over the timeout. - ESPLORA_URL: "http://127.0.0.1:1/api" - # Force the SP1 mock prover for every test in this workflow. The - # default prover targets real Groth16/Plonk circuits and a single - # send_coin/receive_coin test then takes ~20+ minutes on an x86_64 - # runner. Mock proofs return instantly and exercise the same plumbing. - SP1_PROVER: mock +# `lint-and-build` catches what GitHub-hosted Linux can cheaply catch: +# cross-platform compile bitrot and lint regressions. +# +# `node-tests` + `coverage` are the authoritative test + coverage gate. +# They run on the m3-ultra self-hosted runner pool (label `m3-ultra`, +# 6 agents on dfx01) — the documented hardware target (CONTRIBUTING.md +# § "Working on the Plonky2 Migration"). 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 on draft PRs; downstream `needs:` jobs inherit the skip. + if: github.event_name == 'push' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -62,107 +113,276 @@ jobs: - name: Check formatting run: cargo fmt --all --check - - name: Run clippy (server + shared, MVP feature set) - run: cargo clippy -p server -p shared -- -D warnings + - name: Run clippy (node + shared, MVP feature set) + run: cargo clippy -p node -p shared -- -D warnings - - name: Run clippy (server, all features) - run: cargo clippy -p server --all-features -- -D warnings + - name: Run clippy (node, all features) + run: cargo clippy -p node --all-features -- -D warnings - - name: Run clippy (program lib) - run: cargo clippy -p zkcoins-program --lib -- -D warnings + - name: Run clippy (program + prover libs) + run: cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --lib -- -D warnings - - name: Build server (MVP feature set — the PRD image) - run: cargo build -p server + # 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). + - 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) + 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 server (all features — the DEV image) - run: cargo build -p server --all-features + - name: Build node (MVP feature set — the DEV + PRD image) + run: cargo build -p node - tests: - name: Tests - runs-on: ubuntu-latest - timeout-minutes: 30 + - name: Build node (all features — self-host opt-in build) + run: cargo build -p node --all-features + + node-tests: + name: Node + Shared Tests (M3 Ultra) + # Heavy job (~60-90 min on a self-hosted M3 Ultra runner — one of + # 6 agents on dfx01 sharing the host's 96 GB / 28 cores). Gated + # behind the `ci:full` label so we don't burn runner time on every + # speculative PR — apply the label when the PR is ready for the + # authoritative test+coverage gate. The Release PR + # (`develop -> main`) gets the label applied automatically by + # auto-release-pr.yaml. (See `coverage` job below for why the same + # guard is repeated there.) + if: contains(github.event.pull_request.labels.*.name, 'ci:full') + needs: lint-and-build + runs-on: [self-hosted, m3-ultra] + timeout-minutes: 120 + env: + # Force Esplora broadcasts to fail fast. Some unit tests exercise + # the commit pipeline that ends in a real HTTP broadcast; without + # this, runs against the public Mutinynet API can take >60 s per + # test. Mirrors the pre-push hook. + ESPLORA_URL: http://127.0.0.1:1/api + # `USERNAME_DOMAIN` is required by the server 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 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 in the "require PUBLISHER_KEY on every network" + # hardening. The value below is a syntactically valid 32-byte + # hex placeholder (`0000…0001`) chosen so a future grep for the + # burned `1234…` key returns empty across the repo + CI config; + # it is NOT a secret and MUST NEVER be reused on any chain that + # holds value. The same value is hard-coded in the test mocks at + # `node/src/router_tests.rs` so the wiremock'd publisher address + # path matches the lazy_static-derived `PUBLISHER_ADDRESS`. + PUBLISHER_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + # `db_tests` use the `testcontainers` crate, which talks to the local + # Docker daemon. The self-hosted runner runs Colima (not Docker + # Desktop), whose socket lives under the runner user's home + # directory. `testcontainers` defaults to `/var/run/docker.sock`, + # which does not exist on Colima, so the `Set DOCKER_HOST` step + # below points it at the real socket via `$HOME` — same value the + # `docker info` step picks up implicitly via the default `docker` + # context. + # `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 — the speedup is + # biggest for PR pushes that re-touch the same dependency set. + RUSTC_WRAPPER: sccache + # Bump 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 from this value. + SCCACHE_CACHE_SIZE: "50G" steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.81.0 - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.81.0" + # The launchd-spawned runner agent inherits a minimal PATH that + # includes /opt/homebrew/bin (where a stable Rust lives) but not + # ~/.cargo/bin (where rustup proxies live). Without this step, + # `cargo` resolves to Homebrew's stable cargo, the rust-toolchain + # file pinning nightly is ignored, and dependencies that need + # `#![feature(...)]` (e.g. plonky2_field) fail to compile. Prepend + # ~/.cargo/bin so the rustup proxy is found first and reads the + # workspace rust-toolchain. + - name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust) + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - 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- + # 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 (not the static `env:` block) 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" + + # `sccache` (compile cache) and `cargo-nextest` (test runner) are + # installed once per runner via Homebrew. Re-running on a host + # where they already exist is a no-op. Start sccache's server + # explicitly so the first compile step has a warm cache daemon + # and print stats up-front for visibility in the run log. + # + # If a server is already running with a different cap than the + # requested SCCACHE_CACHE_SIZE (e.g. carried over from a previous + # workflow version), 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 + run: | + command -v sccache >/dev/null || brew install sccache + command -v cargo-nextest >/dev/null || brew install cargo-nextest + 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 `db_tests` added in PR-A1 use testcontainers to spin up a + # real Postgres 17 per test. The runner host has Docker (via + # Colima) available on PATH; fail fast with a readable error + # if it ever goes away, instead of letting the test suite die + # 5 minutes into the run with a hard-to-read bollard error. + - name: Verify Docker is reachable (testcontainers dependency) + run: docker info > /dev/null - # `--test-threads=1` is mandatory: multiple test binaries each load the - # SP1 mock prover ELF (~1.5 GB resident) and running them in parallel - # on a 7 GB GitHub-hosted runner OOM-kills the job (exit 143). The - # account_server::tests group runs the real SP1 prover and is skipped - # here — it is only exercised in the coverage job, which is also - # single-threaded. - - name: Run tests (server + shared, all features, skip slow SP1 prover tests) - run: cargo test -p server -p shared --all-features -- --test-threads=1 --skip account_server::tests + # `cargo nextest` replaces `cargo test`: process-per-test isolation + # plus smart scheduling (slow tests start first). `--test-threads 1` + # is preserved — the repo invariant is that tests run serially to + # avoid testcontainers port races and shared-state pollution. + # `api_remote` is the live-DEV-server verification integration test + # (node/tests/api_remote.rs). It targets `https://dev-api.zkcoins.app` + # by default and is meant to run AFTER a deploy, from the `api-e2e` + # job in deploy-dev.yaml — not against whatever DEV currently runs + # while a PR is still open. Excluding it here keeps `node-tests` + # hermetic: only unit + non-remote integration tests run; remote + # verification fires post-deploy as the merge-then-deploy gate. + - name: Run node + shared tests (release, all features) + run: cargo nextest run -p node -p shared --release --all-features --test-threads 1 -E 'not binary(api_remote)' - - name: Run tests (program lib) - run: cargo test -p zkcoins-program --lib -- --test-threads=1 + - name: sccache stats (post-build) + if: always() + run: sccache --show-stats coverage: - name: Coverage (MVP scope) - runs-on: ubuntu-latest - timeout-minutes: 30 + name: Coverage Gate (100% lines + functions) + # Runs in parallel with `node-tests` (not after) — both jobs + # exercise the same suite (nextest vs. nextest-under-llvm-cov), so + # serializing them only doubled wall-clock on every Release PR. + # The `ci:full` label gate is duplicated explicitly here because the + # chain through `node-tests` (which carried the guard) is broken. + if: contains(github.event.pull_request.labels.*.name, 'ci:full') + needs: lint-and-build + runs-on: [self-hosted, m3-ultra] + timeout-minutes: 90 + env: + ESPLORA_URL: http://127.0.0.1:1/api + USERNAME_DOMAIN: test.zkcoins.local + # `PUBLISHER_KEY` is required on every network (no default — see + # `node/src/lib.rs`); the value mirrors `node-tests` above and is + # a syntactically valid 32-byte hex placeholder, NOT a secret. + # MUST match `node/src/router_tests.rs` and the `node-tests` env + # block — the test mocks derive the wiremock'd publisher address + # from this key. + PUBLISHER_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + # `db_tests` use the `testcontainers` crate; see `node-tests` + # above for the rationale. `DOCKER_HOST` is set in a step below + # so the Colima socket path resolves from `$HOME` at runtime. + # Same sccache wrapper as `node-tests`; reuses the same on-disk + # cache populated by the previous job in the same workflow run. + RUSTC_WRAPPER: sccache + # See `node-tests` env block above for the 50-GiB rationale. + SCCACHE_CACHE_SIZE: "50G" steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.81.0 - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.81.0" - components: llvm-tools-preview + - name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust) + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Cache cargo registry and build - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-llvm-cov-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-llvm-cov- + # See `node-tests` job above for the rationale; resolves the + # Colima socket path from `$HOME` at runtime. + - name: Set DOCKER_HOST for Colima socket + run: echo "DOCKER_HOST=unix://$HOME/.colima/default/docker.sock" >> "$GITHUB_ENV" - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov + # Same install gate as `node-tests`. Idempotent: no-op on a + # warm runner where both tools already exist. See `node-tests` + # for why we conditionally restart the sccache server. + - name: Ensure sccache + cargo-nextest are installed + run: | + command -v sccache >/dev/null || brew install sccache + command -v cargo-nextest >/dev/null || brew install cargo-nextest + 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 + + # Coverage runs the same `db_tests` as `node-tests` and so + # needs Docker reachable for testcontainers. See the matching + # check in the `node-tests` job for the rationale. + - name: Verify Docker is reachable (testcontainers dependency) + run: docker info > /dev/null - # Coverage is measured on the MVP build only: no Cargo features - # enabled. Code behind a Cargo feature (address-list / faucet / - # usernames / lnurl) is excluded from the binary at compile time - # and is therefore not part of the measured surface. + # `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 execution share a + # single binary run (same as the old `cargo llvm-cov -- ...` form). # - # main.rs (runtime bootstrap) and publisher.rs (Bitcoin commit / - # reveal broadcasting that needs a signet/regtest node) are - # genuinely not exercisable in unit tests and are excluded at the - # file level via --ignore-filename-regex. - # Threshold is the current MVP baseline with main.rs (bootstrap) - # and publisher.rs (Bitcoin commit/reveal broadcasting that needs a - # signet/regtest node) excluded. The goal is 100% on this scope; - # each lifting PR ratchets the threshold upward. - # All tests must run for the coverage measurement to reflect the - # true exercised production surface — account_server tests are slow - # under SP1=mock but exercise large parts of the file. - - name: Run cargo-llvm-cov (MVP scope, regression guard) + # The `api_remote` integration test (node/tests/api_remote.rs) + # is excluded for the same reason as in `node-tests` above: it + # targets the live DEV server and belongs in the post-deploy + # `api-e2e` job, not the hermetic coverage 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 (MVP scope, 100% line + function gate) run: | - cargo llvm-cov -p server --show-missing-lines \ - --ignore-filename-regex 'main\.rs|publisher\.rs|server_runtime\.rs|scanner_runtime\.rs|_tests\.rs$' \ + cargo llvm-cov nextest --release -p node --show-missing-lines \ + --ignore-filename-regex 'main\.rs|lib\.rs|publisher\.rs|runtime\.rs|scanner_runtime\.rs|scanner_ws\.rs|_tests\.rs$|bin/.*\.rs$' \ --fail-under-lines 100 \ --fail-under-functions 100 \ - -- --test-threads=1 + --test-threads 1 \ + -E 'not binary(api_remote)' + + - name: sccache stats (post-build) + if: always() + run: sccache --show-stats + + # Telegram alert on workflow failure. 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 (node-tests / coverage on a non-ci:full PR, + # or all jobs on a draft PR) and manual cancellation stay silent. + notify-failure: + name: Telegram alert on failure + needs: [lint-and-build, node-tests, 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/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml index d0c772f6..12224d04 100644 --- a/.github/workflows/deploy-dev.yaml +++ b/.github/workflows/deploy-dev.yaml @@ -11,8 +11,20 @@ on: type: boolean default: false +# Serialize DEV deploys per branch. Multiple develop pushes in quick +# succession (e.g. three PRs merged back-to-back) used to fire three +# parallel deploys that raced on `docker compose recreate` on the +# host and left the zkcoins-node container half-renamed in +# `Created` state, blocking the next `up -d` with a name conflict. +# `cancel-in-progress: true` keeps the newest commit's deploy; the +# older deploy is irrelevant the moment its commit is no longer the +# branch tip. +concurrency: + group: deploy-dev + cancel-in-progress: true + env: - DOCKER_TAGS: zkcoin/server:beta + DOCKER_TAGS: zkcoins/node:beta permissions: contents: read @@ -41,8 +53,18 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 - build-args: | - FEATURES=address-list,faucet,usernames,lnurl + # Registry-backed buildx cache. Same `zkcoins/node:buildcache` + # tag is reused by Deploy PRD — DEV and PRD compile the same + # Rust workspace so cache hits cross-deploy. `type=registry` + # over `type=gha` because GHA cache caps at 10 GB with LRU + # eviction; Docker Hub holds the tag indefinitely. + # Caveat: DEV's `cancel-in-progress: true` (above) can interrupt + # a concurrent DEV deploy mid-push to the cache manifest. + # BuildKit's `cache-from` tolerates partial manifests (falls back + # to a from-scratch build with a warning) so the race is + # self-healing on the next deploy. + cache-from: type=registry,ref=zkcoins/node:buildcache + cache-to: type=registry,ref=zkcoins/node:buildcache,mode=max - name: Install cloudflared run: | @@ -56,12 +78,167 @@ jobs: chmod 600 ~/.ssh/deploy_key echo "${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts - DEPLOY_CMD="zkcoins-server" + # The deploy host runs a forced-command restricted shell that only + # accepts whitelisted command names — arbitrary inline shell is + # rejected. Both branches must resolve to a single allowlisted + # command; the reset variant is implemented host-side. + DEPLOY_CMD="zkcoins-node" if [ "${{ inputs.reset_state }}" == "true" ]; then - DEPLOY_CMD="cd ~/zkcoins && docker compose stop zkcoins-server && docker compose rm -f zkcoins-server && docker volume rm zkcoins_server-data 2>/dev/null; zkcoins-server" + DEPLOY_CMD="reset-zkcoins-node" fi + # ServerAlive* keep the session alive across long-running + # `docker compose recreate` steps where the remote command + # produces no stdout for >60s. Without keepalive the + # cloudflared tunnel (and the OpenSSH client) drop the + # session and exit 255 even though the host-side deploy + # script keeps running — observed on the PR #111 merge + # (run 26419696840). 30s interval × 8 retries = 4 min of + # network silence tolerated before the session is killed. ssh -i ~/.ssh/deploy_key \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=8 \ -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_DEV_HOST }}" \ ${{ 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. + - name: Smoke test public endpoint + run: | + set -euo pipefail + URL="https://dev-api.zkcoins.app/api/info" + 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 + echo + exit 0 + fi + echo "[$i/30] $URL -> ${code} (waiting 10 s)" + sleep 10 + done + echo "::error::DEV /api/info never returned 200 within ~5 min after deploy" + exit 1 + + # Functional verification of the deployed DEV server. + # + # The smoke test in `build-and-deploy` only proves the HTTP listener + # is bound; this job exercises all 15 routes end-to-end (read-only, + # negative-path, full mint→send→commit and username-claim roundtrips + # against the live server). Runs on the same self-hosted M3 Ultra + # runner as `node-tests` / `coverage`, so sccache hits the warm + # cache populated by previous runs and the build itself stays + # well under a minute on a hot cache. + api-e2e: + name: API E2E against DEV + needs: build-and-deploy + runs-on: [self-hosted, m3-ultra] + timeout-minutes: 30 + env: + RUSTC_WRAPPER: sccache + ZKCOINS_API_URL: https://dev-api.zkcoins.app + # The bootstrap `lazy_static`s panic if these are unset; the + # integration test only talks to the deployed server but the + # lib's panic-on-load behaviour is unconditional. Values are + # placeholders — nothing in the test path reads them. + USERNAME_DOMAIN: dev.zkcoins.app + ESPLORA_URL: http://127.0.0.1:1/api + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Self-hosted runner inherits a minimal PATH that hides rustup; + # see the matching step in `node-tests` for the rationale. + - name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust) + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Ensure sccache + cargo-nextest are installed + run: | + command -v sccache >/dev/null || brew install sccache + command -v cargo-nextest >/dev/null || brew install cargo-nextest + sccache --start-server >/dev/null 2>&1 || true + sccache --show-stats + + # Operational preflight: hit /health/ready and /health/publisher + # BEFORE running the API E2E suite, so an empty publisher wallet + # or a non-ready DB fails THIS step with a clear "top up the + # publisher" / "DB not ready" message instead of cascading + # through the test suite as opaque 503s. + # + # Historically a green E2E run masked an empty publisher wallet + # because the suite silently dev_skip!()'d 5xx errors; PR + # "test: harden suite" (this PR) removed the masking and added + # this preflight as the load-bearing operational gate. + # + # 50_000 sats is a conservative floor: a single inscription + # commit + reveal pair at typical Mutinynet fee rates needs + # ~1_500 sats; 50_000 buys ~30 mints before the next top-up. + # Adjust upward if the suite grows. + - name: Ensure jq is installed (preflight dependency) + run: command -v jq >/dev/null || brew install jq + + - name: Preflight — publisher wallet has UTXOs + env: + DEV_API: https://dev-api.zkcoins.app + run: | + set -euo pipefail + ready=$(curl -sS --max-time 10 "$DEV_API/health/ready") + if ! echo "$ready" | jq -e '.ready == true' > /dev/null; then + echo "::error::/health/ready not ready: $ready" + exit 1 + fi + pub=$(curl -sS --max-time 15 -w '|%{http_code}' "$DEV_API/health/publisher") + code="${pub##*|}" + body="${pub%|*}" + if [ "$code" != "200" ]; then + echo "::error::/health/publisher returned $code: $body" + exit 1 + fi + utxos=$(echo "$body" | jq -r '.utxo_count') + sats=$(echo "$body" | jq -r '.total_sats') + if [ "$utxos" -lt 1 ] || [ "$sats" -lt 50000 ]; then + echo "::error::publisher wallet too low (utxos=$utxos, sats=$sats) — top up before re-running" + exit 1 + fi + echo "publisher OK: utxos=$utxos, sats=$sats" + + - name: Run API E2E suite against DEV + env: + # DEV image is MVP-only by policy (see Dockerfile FEATURES + # arg); the gated address-list/lnurl tests skip cleanly + # instead of panicking the CI canary. + ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER: "true" + run: cargo test -p node --release --all-features --test api_remote -- --test-threads=1 --nocapture + + - name: sccache stats (post-build) + if: always() + run: sccache --show-stats + + # Telegram alert on workflow failure. Separate job (not an inline step) + # so job-level failures — timeout, OOM, runner crash — still fire the + # alert; runs on the cheapest runner since the curl never needs to touch + # the self-hosted M3 Ultra. See ci.yaml > notify-failure for the + # firing-matrix rationale. + notify-failure: + name: Telegram alert on failure + needs: [build-and-deploy, api-e2e] + 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/.github/workflows/deploy-prd.yaml b/.github/workflows/deploy-prd.yaml index d23ee45e..8af19ffe 100644 --- a/.github/workflows/deploy-prd.yaml +++ b/.github/workflows/deploy-prd.yaml @@ -5,8 +5,17 @@ on: branches: [main] workflow_dispatch: +# Serialize PRD deploys. Unlike Deploy DEV (cancel-in-progress: true, +# "newest commit wins") production deploys must NEVER be killed mid- +# flight: cancelling halfway through `docker compose recreate` is +# exactly what produced the half-renamed Created-state container +# that took DEV down. Queue subsequent deploys instead. +concurrency: + group: deploy-prd + cancel-in-progress: false + env: - DOCKER_TAGS: zkcoin/server:latest + DOCKER_TAGS: zkcoins/node:latest permissions: contents: read @@ -35,6 +44,13 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 + # Registry-backed buildx cache. Same `zkcoins/node:buildcache` + # tag is shared with Deploy DEV — DEV and PRD compile the same + # Rust workspace so cache hits cross-deploy. `type=registry` + # over `type=gha` because GHA cache caps at 10 GB with LRU + # eviction; Docker Hub holds the tag indefinitely. + cache-from: type=registry,ref=zkcoins/node:buildcache + cache-to: type=registry,ref=zkcoins/node:buildcache,mode=max - name: Install cloudflared run: | @@ -47,7 +63,103 @@ jobs: echo "${{ secrets.DEPLOY_PRD_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key echo "${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + # ServerAlive* keep the session alive across long-running + # `docker compose recreate` steps where the remote command + # produces no stdout for >60s. Mirrors deploy-dev.yaml; see + # the comment there for the failure mode that motivated this + # (PR #111 merge run 26419696840 — SSH dropped mid-recreate, + # exit 255, container actually came up server-side). ssh -i ~/.ssh/deploy_key \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=8 \ -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_PRD_HOST }}" \ ${{ secrets.DEPLOY_PRD_USER }}@${{ secrets.DEPLOY_PRD_HOST }} \ - "zkcoins-server" + "zkcoins-node" + + # Post-deploy smoke test: hit the public PRD endpoint until + # /api/info answers 200 or we give up. Mirrors the Deploy DEV + # post-deploy probe. Without this a runtime-bootstrap panic + # leaves the container Up-but-unresponsive on PRD while the + # workflow reports success — the exact failure mode that took + # DEV down silently before the Plonky2 migration fix. + - name: Smoke test public PRD endpoint + run: | + set -euo pipefail + URL="https://api.zkcoins.app/api/info" + 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 "PRD /api/info responded 200 after ${i} attempt(s):" + cat /tmp/info.json + echo + exit 0 + fi + echo "[$i/30] $URL -> ${code} (waiting 10 s)" + sleep 10 + done + echo "::error::PRD /api/info never returned 200 within ~5 min after deploy" + exit 1 + + # Functional verification of the deployed PRD server. Mirrors the + # Deploy DEV api-e2e job, but excludes the three roundtrip tests — + # they would consume real publisher UTXOs and write coins into the + # production SMT/MMR. `--skip _roundtrip_` is a substring match; the + # only test names matching are the three mint/send-commit/username + # roundtrips (verified via grep against the suite). + api-e2e: + name: API E2E against PRD (non-mutating subset) + needs: build-and-deploy + runs-on: [self-hosted, m3-ultra] + timeout-minutes: 30 + env: + RUSTC_WRAPPER: sccache + ZKCOINS_API_URL: https://api.zkcoins.app + # The bootstrap `lazy_static`s panic if these are unset; the + # integration test only talks to the deployed server but the + # lib's panic-on-load behaviour is unconditional. Values are + # placeholders — nothing in the read-only test path reads them. + USERNAME_DOMAIN: zkcoins.app + ESPLORA_URL: http://127.0.0.1:1/api + 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" + + - name: Ensure sccache + cargo-nextest are installed + run: | + command -v sccache >/dev/null || brew install sccache + command -v cargo-nextest >/dev/null || brew install cargo-nextest + sccache --start-server >/dev/null 2>&1 || true + sccache --show-stats + + - name: Run API E2E suite against PRD (skip roundtrips) + run: cargo test -p node --release --all-features --test api_remote -- --test-threads=1 --nocapture --skip _roundtrip_ + + - name: sccache stats (post-build) + if: always() + run: sccache --show-stats + + # Telegram alert on workflow failure. Separate job (not an inline step) + # so job-level failures — timeout, OOM, runner crash — still fire the + # alert; runs on the cheapest runner since the curl never needs to touch + # the self-hosted M3 Ultra. See ci.yaml > notify-failure for the + # firing-matrix rationale. + notify-failure: + name: Telegram alert on failure + needs: [build-and-deploy, api-e2e] + 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 79ec20af..07d8e1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ target/ .env *.pem *.bin -!server/minting_secret.bin +!node/minting_secret.bin .DS_Store # accidentally-tracked tmp file diff --git a/ARKADE_INTEGRATION.md b/ARKADE_INTEGRATION.md new file mode 100644 index 00000000..d41207db --- /dev/null +++ b/ARKADE_INTEGRATION.md @@ -0,0 +1,1114 @@ +# Arkade × zkCoins Integration — Design Document + +**Status:** Design draft. No code yet. Companion to +[`SPEC.md`](./SPEC.md), [`MULTI_ASSET.md`](./MULTI_ASSET.md), +[`BRIDGE_MVP.md`](./BRIDGE_MVP.md), +[`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md), and +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md). + +**Authoritative source for:** how Arkade (Ark protocol) and zkCoins +(Shielded CSV protocol) compose; which integration paths are +realistic on which horizons; the canonical Arkade ↔ zkCoins atomic-swap +construction. + +**Audience:** Engineers and architects evaluating cross-protocol +integration with Arkade. Presupposes [`SPEC.md`](./SPEC.md), the +swap-design pattern in [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md), +the bridge model in [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md), and the +multi-asset extension in [`MULTI_ASSET.md`](./MULTI_ASSET.md). Familiarity +with the Ark litepaper (Argentieri, Avarikioti, Camilleri, Keer, +Maffei — Ark Labs / TU Wien) and the Shielded CSV ePrint 2025/068 +(Nick, Eagen, Linus) is assumed. + +--- + +## 0. Status + +Design draft only. The project today has no Arkade integration — +zkCoins runs as documented in [`SPEC.md`](./SPEC.md); Arkade runs as +documented at `docs.arkadeos.com`. The two systems coexist on Bitcoin +L1 without interaction. + +[`MULTI_ASSET.md`](./MULTI_ASSET.md) §12.9 names cross-asset trading as +out-of-protocol and points to the BitVM2 bridge +([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)) and the Lightning atomic-swap +layer ([`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md)) as the +"canonical out-of-protocol paths." This document adds the **third** such +path — Ark/Arkade — and analyses where the integration is real +engineering, where it is research, and where it is wiring. + +This is not an implementation spec. It is an architectural map. +Implementation specs for individual integration paths (e.g., the HTLC +atomic swap of §7) live in follow-up documents once a path is locked +in the ROADMAP. + +--- + +## 1. Scope + +This document covers: + +- Protocol-mechanics comparison between Arkade VTXOs and zkCoins + coins (§5). +- Six integration paths, arranged by maturity (§6). +- The canonical HTLC atomic-swap construction between an Arkade VTXO + and a zkCoins shared account, with full protocol steps and + failure-mode analysis (§7). +- Pipeline use — BTC onboarding via Arkade boarding, transacting + inside zkCoins, exit via Arkade settlement (§6.3). +- Bridge convergence — sharing federation infrastructure between the + zkCoins BitVM2 bridge and an Arkade operator (§6.4). +- Confidential VTXOs as open research (§6.5). +- Cross-asset DEX (Arkade Assets ↔ zkCoins Assets) as the first + Bitcoin-native cross-protocol multi-asset swap (§6.6). +- Trust-model stacking analysis (§8). +- Honest 6-month / 2-year / research-only assessment (§9). + +It does **not** cover: + +- Modifications to the zkCoins protocol or circuit. None of the + integration paths in this document require a divergence from + [`SPEC.md`](./SPEC.md) §15. +- Modifications to the Ark protocol. The HTLC atomic-swap path uses + Arkade Script primitives that already ship in `arkade-os/compiler`. +- Implementation in any specific code base. Once a path is locked, + its implementation spec is a separate sibling document (mirroring + the relationship of [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) to + [`BRIDGE_MVP.md`](./BRIDGE_MVP.md)). +- Generic cross-chain bridges (Liquid, RSK, sidechains). Different + trust model, different document. + +--- + +## 2. Executive Summary + +The most realistic short-term Arkade × zkCoins integration is a +**trustless HTLC atomic swap** between an Arkade VTXO and a zkCoins +2-of-2 shared account. The construction is a direct adaptation of +the Shielded CSV §A.1.2 atomic-swap pattern (also the basis of +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md)) with the +Bitcoin/Lightning side replaced by an Arkade VTXO carrying an +HTLC script-path. Arkade's compiler ships HTLC as a built-in primitive. +Both halves of the construction exist today; what is missing is +wiring. + +Three structural facts shape every other path in this document: + +1. **Arkade is a Bitcoin-script L2.** A VTXO *is* a presigned + Bitcoin output with a Taproot lock; only the broadcasting + is deferred (Ark §4 Definition 4.1). Any Bitcoin-script + construction — HTLC, escrow, DLC, payment channel — composes + onto a VTXO with the single constraint that timelocks must + fit inside the batch expiry `T_e` (Ark §6). +2. **Shielded CSV is not L2 in the same sense.** A zkCoins coin + has no script, no on-chain UTXO, no spending condition beyond + `coin.recipient == self.owner` (Shielded CSV §4.2; + `program/src/lib.rs::apply_coin`). The chain stores only + 64-byte aggregate nullifiers as an availability bulletin + board. Atomicity cannot live on the coin layer — this is + load-bearing for the protocol's "64 bytes per tx" property + and locked at [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §5. +3. **The two protocols share an institutional orbit but no + documented unified roadmap.** Robin Linus, Liam Eagen, Jonas + Nick (Shielded CSV authors) and Zeta Avarikioti, Matteo Maffei + (Ark co-authors) overlap on adjacent work — BitVM, Glock, Argo — + but neither paper mentions the other. Integration is implicit + in the personnel, not declared in the literature. Frame + accordingly in §9. + +The combined stack inherits the union of both protocols' trust +assumptions. Today: Arkade rational-operator + zkCoins federation +(Phase 1). 2026-2028 horizon: Arkade multi-operator + zkCoins BitVM2 +bridge (Phase 2). Neither protocol's headline trust-minimisation is +production yet; the combined stack is bottlenecked on whichever +reaches its Phase 2 last. + +--- + +## 3. Decisions (locked) + +The decisions below are fixed for this design document. Reversing +any of them is a design-level rethink, not a tweak. + +| # | Decision | Consequence | +| - | -------- | ----------- | +| **A1** | **First integration target is the HTLC atomic swap** (§6.2, §7). Hash-Time-Locked Contract preimage swap between an Arkade VTXO and a zkCoins 2-of-2 shared account. | This is the smallest construction that demonstrably uses both protocols for what they are good at, requires no new cryptography, and inherits independent trust assumptions in each leg. Pipeline use (§6.3) is a wallet-side convenience on top; it does not need its own primitive. | +| **A2** | **No protocol changes to zkCoins or Arkade for A1.** The atomic-swap construction uses primitives both papers already specify: Shielded CSV §5.1 (shared accounts), §A.1.1 (time-locked nullifiers), §A.1.2 (atomic swap); Arkade Script HTLC template (`arkade-os/compiler`, `docs.arkadeos.com/learn/smart-contracts/hash-time-locked-contract`). | No 12th divergence to track in [`SPEC.md`](./SPEC.md) §15. No deviation from the Ark whitepaper. The integration adds wiring, not protocol changes. | +| **A3** | **Arkade operator and zkCoins federation remain independent trust domains.** A user holding a VTXO trusts the Arkade operator's rationality (Ark §5 Table 1). A user holding a zkCoins coin pegged to BTC trusts the zkCoins bridge (Phase 1 federation or Phase 2 BitVM2 setup). The two assumptions do not collapse into one; an atomic-swap counterparty may simultaneously occupy both roles, but the trust analyses stay separate. | Operating both an Arkade `arkd` instance and a zkCoins bridge node in the same datacentre is permitted; the security argument tracks each role independently. §8 is the canonical reference for which assumption applies where. | +| **A4** | **No confidential-VTXO work in the integration roadmap.** Bringing ZK privacy to Arkade VTXOs (§6.5) is genuine open research — Pedersen commitments + range proofs + redesigned forfeit mechanism + a PCD-style ZK validity proof per Arkade batch. Estimated 1–2 year paper-stage work; no existing protocol or implementation. | This document records confidential VTXOs as a research direction worth tracking but explicitly out-of-scope for any near-term zkCoins effort. If Arkade ships such a feature upstream, this section becomes a re-evaluation gate. | +| **A5** | **Pipeline use (§6.3) is layered on top of A1, not a separate primitive.** "BTC → Arkade → zkCoins → Arkade → BTC" decomposes into: Arkade boarding (Ark §4.5), an HTLC swap into zkCoins (A1), zkCoins-internal transfers, an HTLC swap back out, Arkade exit. Each step is independently specified and the pipeline composes them. | No new design work for the pipeline as long as A1 lands. The wallet-side UX of routing a user through the pipeline is `zk-coins/app` work, not a server-side primitive. | +| **A6** | **Cross-asset DEX (§6.6) is a v2 follow-up to A1.** A swap between an Arkade Asset (Arkade Labs' native-asset proposal) and a zkCoins asset is structurally identical to A1 with two field substitutions on each side. It does not require new crypto, but it does require the zkCoins multi-asset shared-account semantics from [`MULTI_ASSET.md`](./MULTI_ASSET.md) to be live, and Arkade Assets to be in production beyond beta. | Tracked as a v2 milestone; not in the initial A1 implementation scope. The first integration ships before chasing this. | + +These mirror the lockedness pattern of [`MULTI_ASSET.md`](./MULTI_ASSET.md) §2 +(decisions M1–M6) and [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) §3 (Bridge +locked technical decisions). Each is testable to the extent the +integration is built; today most are documentation-level decisions +that fix the design space. + +--- + +## 4. Glossary additions + +Extends [`SPEC.md`](./SPEC.md) § Glossary and +[`MULTI_ASSET.md`](./MULTI_ASSET.md) § Glossary additions. + +| Term | Expansion | Meaning | +| ---- | --------- | ------- | +| **VTXO** | Virtual UTXO | Ark's atomic ownership unit: a presigned Bitcoin tx output `(value, vtxoLockScript)` held off-chain by a VTXO holder, encumbered by a Taproot script with at least one collaborative path (`checkSig(pkO ⊕ pkA)`, user + operator MuSig2) and one unilateral exit path (`checkSig(pkA) ∧ relTimelock(t_v)`). Ark §4 Definition 4.1. | +| **Arkade operator** | — | The coordinating party in an Ark instance. Provides liquidity (its own BTC funds commitments), batches user activity into `commitment_tx`, cosigns Ark transactions and VTXT virtual transactions. Single operator per Arkade instance today (Ark §7). | +| **`commitment_tx`** | Commitment transaction | The single on-chain Bitcoin tx per Arkade batch that anchors a `batch` Taproot output (sweep path after `T_e`, unroll path enforcing the VTXT) and a `connector` Taproot output for the chain of anchor outputs used by forfeit transactions. Ark §4.4, Definition 4.9. | +| **`forfeit_tx`** | Forfeit transaction | Ark batch-swap atomicity primitive: user-signed transaction with SIGHASH_ALL over `(old_vtxo, connector_anchor_ε)`, valid only if the `commitment_tx` containing the connector confirms. Lets the operator claim the old VTXO if the user double-spends. Ark §4.3, Transaction 4. | +| **Batch expiry `T_e`** | — | Ark batch expiration time. After `T_e` the operator may sweep the batch output. Every script-level construction inside a VTXO (HTLC, escrow, DLC, channel) must use timelocks strictly shorter than `T_e` for the cooperative spending path to remain usable. Ark §6 caveat. | +| **Arkade Script** | — | High-level language ([`arkade-os/compiler`](https://github.com/arkade-os/compiler)) compiling to an extended Bitcoin Script targeting Arkade VM. Supports `checkSig`, `checkMultiSig`, `sha256` preimage check, CLTV / CSV, transaction introspection, and automatic generation of cooperative + unilateral exit script paths. Ships HTLC, Escrow, Spilman channel, Dryja-Poon channel, Lightning channel/swap templates. | +| **Arkade Asset** | — | Arkade Labs' native-asset proposal for issuing non-BTC tokens on Bitcoin via Ark batching. Encoded as TLV in `OP_RETURN` (`OP_RETURN <0x00> `); asset identifier is `(genesis_txid, group_index)`; transferred through VTXOs with operator awareness. Arkade Labs blog: *Native Assets on Bitcoin: Introducing Arkade Assets* (Oct 2025). | +| **Confidential VTXO** | — | Hypothetical Arkade extension in which the operator cosigns commitments to amounts and recipients rather than plaintext, with a ZK proof of batch correctness. Open research as of 2026-05; no published proposal. See §6.5. | +| **A1 – A6** | — | Locked design decisions for the Arkade integration (this document, §3). Mirrors the M1–M6 / D1–D11 numbering scheme of [`MULTI_ASSET.md`](./MULTI_ASSET.md) and [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md). | + +--- + +## 5. Protocol-mechanics comparison + +The two protocols solve adjacent problems with structurally different +primitives. This section is the side-by-side reference used throughout +the rest of the document. + +### 5.1 Atomic unit + +| Aspect | Ark / Arkade | Shielded CSV / zkCoins | +| ------ | ------------ | ---------------------- | +| Unit | **VTXO** — `(value, vtxoLockScript)` (Ark §4 Definition 4.1). Mechanically a real Bitcoin output, Taproot-locked, key path unspendable, at least one collaborative + one unilateral exit script path. | **Coin** — `(CoinEssence{address, amount, idx}, tx_hash, nullifier_location, accumulator_value)` (Shielded CSV §4.2). No script, no UTXO, no on-chain output. | +| Where it lives | Off-chain. Realisable on-chain via the unilateral exit script path. | Entirely off-chain. Chain stores only nullifiers (Schnorr half-aggregate, ~64 bytes/tx). | +| Spending condition | Arbitrary Bitcoin Script via the Taproot script paths. Today's MuSig2 cosigning emulates a covenant (Ark §3.2). | None. `apply_coin`'s `coin.recipient == self.owner` is the only check ([`program/src/lib.rs:154`](./program-plonky2/src/circuit/main.rs)). | +| Privacy from external observer | Operator-visible by construction (Ark §2.2). Amounts and recipients exposed to the operator and to anyone who sees the VTXT. | Hidden from everyone except sender and recipient (Shielded CSV §1.1, "Privacy"). PCD proof is zero-knowledge; only `(nullifier_pubkey, signature)` on-chain. | + +### 5.2 On-chain artifacts + +Per Arkade batch (Ark §4.4, Definition 4.9): + +- **`commitment_tx`** — one Bitcoin tx. Inputs: operator funds + any + boarding txs. Outputs: `batch` (Taproot — sweep after `T_e`, unroll + enforcing the VTXT), `connector` (Taproot enforcing the anchor-output + chain), optional outputs for users leaving the Ark. +- **`forfeit_tx`** (off-chain unless needed) — signed by user with + SIGHASH_ALL over `(old_vtxo, connector_anchor_ε)`; valid only if the + `commitment_tx` confirms. +- **Cadence** — operator-controlled. Whitepaper does not fix a number; + current Arkade deployments use sub-second preconfirmations with + periodic anchoring (typically minutes-to-hours). + +Per zkCoins transaction (Shielded CSV §4.2): + +- **One aggregate nullifier**: `(nullifier_pubkeys[], NISSHAC + half-aggregate signature, publisher_address)`. With Schnorr + half-aggregation, ~64 bytes per transaction regardless of input + count (Shielded CSV §1.1, Table 1). +- **MVP implementation** wraps this in a Taproot inscription with + txid prefix `4242` carrying a `Commitment` payload over + `H(asth ‖ ocr)` ([`SPEC.md`](./SPEC.md) §11). The paper specifies + raw nullifiers; the wrapping is a deliberate divergence + ([`SPEC.md`](./SPEC.md) §15). + +| Artifact | Arkade | Shielded CSV | +| -------- | ------ | ------------ | +| Per-batch on-chain footprint | 1 `commitment_tx` (constant in #VTXOs in the optimistic case) | n × 64-byte aggregate nullifiers (one per transaction; publisher batches multiple senders' nullifiers into one inscription) | +| Settlement cadence | Operator-controlled batch interval | Per transaction; bounded by aggregator's publication cadence | +| Worst-case exit | `O(log t)` virtual txs for unilateral exit from a VTXT of `t` leaves (Ark §2.3, §4.1) | N/A — no exit, no per-coin on-chain footprint | +| Bitcoin TPS ceiling | Bounded by `commitment_tx` size and frequency | ~100 TPS at current Bitcoin block-size limit (Shielded CSV §1.1) | + +### 5.3 Roles and trust + +| Role | Arkade operator | zkCoins publisher | zkCoins bridge | +| ---- | --------------- | ----------------- | -------------- | +| What they do | Liquidity provision, batching, MuSig2 cosigning per VTXO holder (Ark §2.2) | Collects nullifiers, half-aggregates, posts the aggregate as a Taproot inscription, claims fees (Shielded CSV §1.1, "Trustless Publishing"). **Permissionless** — anyone can be a publisher. | Custodies BTC against zkCoins-side credits. Phase 1: M-of-N federation multisig ([`BRIDGE_MVP.md`](./BRIDGE_MVP.md)). Phase 2: 1-of-N honesty BitVM2 setup ([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)). | +| Centralisation | Single operator today (Ark §7, "Centralisation of Ark Operator" — explicitly named as a future-work axis) | None — anyone with a Bitcoin wallet can publish | Phase 1: M-of-N trusted. Phase 2: 1-of-N honesty at setup ceremony. | +| Liveness assumption | Operator online ⇒ batch swaps and collaborative exits work. Operator offline ⇒ unilateral exit only. | Publisher offline ⇒ another publisher can take the same nullifier. No single point of failure. | Bridge stalls if no operator is willing to front a payout; the user keeps their zkCoins balance. | +| Custody | **Never.** VTXOs are user + operator MuSig2; unilateral exit always available (Ark §2.3). | **Never.** Publisher sees nullifier data only, never plaintext coin data. | **Yes** in Phase 1 (federation holds BTC). **No** in Phase 2 (vault in N-of-N MuSig with pre-signed paths). | + +**Critical security property of Arkade:** Ark §5 Table 1 names six +properties under "rational" vs. "malicious" operator. Under a +*malicious* operator the protocol still satisfies onramp liveness +(NL) and offramp liveness (FL); violations of safety properties (NS, +AS, FS) "come only at the cost of the operator, not of users +following the protocol." A malicious Arkade operator cannot steal +user funds; they can only burn their own funds while users still +exit. + +**Critical security property of Shielded CSV:** §1.1 ("Permissionless") +— "the protocol does not rely on any trusted party for transaction +execution. All necessary data is directly written to, and retrieved +from, the blockchain." Censorship resistance reduces to Bitcoin's own +censorship resistance. The single trust assumption is the bridging +component, not the protocol. + +### 5.4 The fundamental asymmetry + +The point worth repeating: **Arkade is a Bitcoin-script L2** in the +strong sense — VTXOs *are* Bitcoin outputs with locking scripts, just +not yet broadcast. **Shielded CSV is not L2 in the same sense** — +coins have no script and no on-chain footprint; the chain is a notary +for ordering and uniqueness, nothing more. + +Every integration in §6 is shaped by this asymmetry. The Arkade side +can carry arbitrary Bitcoin Script (HTLC, DLC, channels), and the +zkCoins side cannot. Atomicity always lives on the Arkade VTXO or on +the Bitcoin funding tx of the zkCoins inscription — +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §5 derives +this for Lightning; the same logic applies here. + +--- + +## 6. Integration paths + +Six paths, layered by maturity. Layer 0 is "today, no work." Layer 1 +is "this design doc's headline target — 6-12 months engineering." +Layer 2 splits into three independent research directions of varying +maturity. + +### 6.1 Layer 0 — independent systems + +A user holds an Arkade wallet pointing at some Arkade instance and a +zkCoins wallet pointing at a zkCoins server. The wallets do not +interoperate. The user manually converts between BTC and zkCoins via +the bridge ([`BRIDGE_MVP.md`](./BRIDGE_MVP.md) or +[`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)) and between BTC and Arkade VTXOs +via boarding/exit (Ark §4.5). + +**Cost:** zero engineering. Two wallets, manual juggling, two distinct +BTC custody contexts. + +**When it makes sense:** today, for power users who want both privacy +(zkCoins) and shared-UTXO economics (Arkade) without integration risk. + +**When it stops being enough:** as soon as a single user flow ("private +payment from a long-term BTC store") needs both protocols. The user +should not have to choose; the system should compose them. + +### 6.2 Layer 1 — HTLC atomic swap (the realistic short-term target) + +Direct preimage-based atomic swap between an Arkade VTXO carrying an +HTLC encumbrance and a zkCoins 2-of-2 shared account. This is decision +A1; it is detailed end-to-end in §7. + +**Why this is realistic in 6-12 months:** + +- Shielded CSV §A.1.2 already specifies the exact PTLC + 2-of-2 + shared-account construction for Shielded CSV ↔ Bitcoin atomic + swaps. The construction is documented, not novel. +- Arkade's compiler ships HTLC as a built-in primitive + (`arkade-os/compiler` README; `docs.arkadeos.com/learn/smart-contracts/hash-time-locked-contract`). + Hash-locked outputs on a VTXO are a one-template instantiation. +- Replacing "Bitcoin PTLC" in the Shielded CSV recipe with "Arkade + VTXO with HTLC script-path" is mechanically straightforward. +- Same engineering surface as [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md); + the lessons there apply with minimal adaptation. + +**What it ships:** a user who holds Arkade BTC can atomically convert +to zkCoins, and vice versa, without either side trusting the other to +honour the swap. The swap counterparty (a swap provider running both +an Arkade wallet and a zkCoins shared account) faces the same +incentive structure as a Boltz operator. + +**Failure modes** are exactly the failure modes in §7.5 — bounded by +the `htlc_timeout < T_e` constraint (every script construction on a +VTXO inherits batch expiry per Ark §6) and by the standard HTLC +timing-coordination story. + +Three variants of the atomic swap, in order of preference: + +1. **Direct two-leg HTLC swap (recommended).** Section 7 below. +2. **Federation-mediated swap.** A zkCoins federation node runs an + Arkade-watching service and credits zkCoins on observing specific + Arkade events. Strictly weaker than variant 1 (introduces + federation trust) without adding capability. Skip in v1. +3. **Lightning hop.** Arkade ↔ Lightning ↔ zkCoins via two HTLC + rounds. Arkade ships Lightning swap support + ([`blog.arklabs.xyz` — *Closing the Lightning loop*](https://blog.arklabs.xyz/closing-the-lightning-loop-bitcoins-missing-layer-secretly-goes-live/)); + zkCoins has its own LN design in + [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md). + Stacking them works but adds a hop. Useful if liquidity is on the + other side of the LN graph; otherwise variant 1 is one round + simpler. + +### 6.3 Pipeline use — BTC ↔ Arkade ↔ zkCoins ↔ Arkade ↔ BTC + +Composes Layer 1 with Arkade boarding and exit to give a full +end-to-end user flow: + +``` +User holds BTC on-chain. +↓ boarding_tx (Ark §4.5): Taproot(F, checkSig(pkO⊕pkA), checkSig(pkA)∧relTimelock(t_b)) +User holds a VTXO inside Arkade. +↓ Layer 1 HTLC atomic swap (§7): VTXO encumbered by HTLC, zkCoins-side 2-of-2 shared account +User holds shielded coins inside zkCoins. +... user transacts privately at scale inside zkCoins (per-tx ~64 bytes on-chain) ... +↓ Layer 1 HTLC atomic swap reversed: zkCoins burn → fresh Arkade VTXO +User holds a fresh Arkade VTXO. +↓ Arkade unilateral or collaborative exit (Ark §4.5, "Leaving the Ark") +User holds BTC on-chain. +``` + +**Why this is the killer combination:** + +- **Cheap onboarding.** Arkade's `boarding_tx` is a shared + Taproot output. The on-chain cost of one user's onboarding is + amortised across a batch. +- **Cheap per-tx scaling.** Inside zkCoins, every transaction + amortises to ~64 bytes on-chain regardless of value or input + count. +- **Cheap settlement.** Arkade's `commitment_tx` is one Bitcoin + tx per batch, and an exit (collaborative) is one transaction. + Pessimistic exit is `O(log t)` virtual txs. + +Neither protocol alone achieves both cheap onboarding and cheap +per-tx scaling. The combined pipeline does. This is the strongest +narrative motivation for the integration; A1 is the protocol step +that unlocks it. + +**On-chain footprint per pipeline traversal** (steady-state, ignoring +the initial boarding): + +| Step | Bitcoin txs | Notes | +| ---- | ----------- | ----- | +| Boarding (once) | 1 (`boarding_tx`) | Shared, amortised | +| Arkade Ark transaction | 0 | Lives inside Arkade until next `commitment_tx` | +| Arkade `commitment_tx` (periodic) | 1 per batch, amortised across all batch members | — | +| HTLC swap to zkCoins | 0 (uses existing Arkade primitives) + 1 zkCoins nullifier inscription (~64 bytes) | The HTLC sits inside the VTXO; the swap reveals the preimage but does not add an on-chain artifact beyond what zkCoins already publishes | +| zkCoins-internal transaction | ~64 bytes nullifier (per-tx, batched by publisher) | — | +| HTLC swap back to Arkade | 1 zkCoins nullifier (burn) + Arkade VTXO transfer (0 additional) | — | +| Arkade exit (collaborative) | 1 collaborative exit tx via `commitment_tx` add-output (Ark §4.5) | — | +| Arkade exit (unilateral) | `O(log t)` virtual txs | Only if operator stalls | + +**Trust assumptions per step:** + +- Onboarding / Arkade transfers / Arkade exit: Arkade rational + operator + 1-of-n MuSig honesty (Ark §5 Table 1). +- HTLC swaps in either direction: standard HTLC trust model + (no custody handoff possible without preimage reveal), bounded by + `T_e` on the Arkade side and the publisher's nullifier-publication + cadence on the zkCoins side. +- zkCoins-internal transfers: per [`SPEC.md`](./SPEC.md) — server-side + compute correctness + Schnorr signature security. + +§8 has the full trust-stacking analysis. + +### 6.4 Layer 2a — Ark-aware BitVM bridge (1-2 years) + +**[SPEC]** Speculative architectural sketch. Not in any roadmap as of +2026-05. + +zkCoins Phase 2 ([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)) uses BitVM2 + +Groth16 verification to prove "this operator's payout tx is included +in a finalized Bitcoin chain" and authorise zkCoins-side mints from a +Bitcoin Light Client gadget. Mechanically, the same federation +infrastructure can also operate an Arkade instance: + +- The same N-of-N MuSig2 vault key construction works for any + custody role. +- The same Bitcoin Light Client gadget that verifies "BTC is locked + in vault" can equally verify "the Arkade `commitment_tx` confirmed + with batch β." +- An Arkade operator's liquidity-provision role overlaps with the + BitVM2 operator's "front BTC, get reimbursed later" role. + +The integration insight: peg-in becomes an Arkade boarding (cheap, +amortised) instead of a direct BTC tx. Peg-out frontruns an Arkade +VTXO transfer; user can unilateral-exit if the operator stalls. The +bridge's on-chain footprint reduces; the trust model does not change. + +**Security model overlap.** Ark's rational-operator assumption gives +onramp safety (NS), Ark safety (AS), offramp safety (FS) without users +losing funds even under malice (Ark §5 Table 1). BitVM2's 1-of-N +setup honesty gives "no operator coalition can spend the vault +outside pre-signed paths" ([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) §3.2). +These are **independent** assumptions — Ark's holds for Ark, BitVM2's +holds for the peg. A federation that fails one role does not +compromise the other unless the same key material is at risk. + +**Realistic horizon:** 1-2 years, gated on (a) BitVM2 production +maturity and Glock/Argo cost reductions making it economical at scale, +(b) Arkade multi-operator support reducing the operator-side +centralisation risk, (c) demand exceeding what a Layer 1 + Layer 2 +bridge can serve. None of these are in zkCoins' control; this is a +"keep an eye on" path, not a sprint candidate. + +### 6.5 Layer 2b — Confidential VTXOs (research, 1-2+ years) + +**Open research, not engineering.** [SPEC]-grade content. + +Arkade VTXOs are operator-visible by construction. The operator sees +plaintext amounts and recipient pubkeys to construct the VTXT, cosign +batches, and manage liquidity. End-to-end-encrypted communication +channels protect against passive observers but not the operator. + +The question this section explores: could the operator be reduced to +cosigning *commitments* to amounts and recipients, with a ZK proof of +batch correctness? + +A confidential-VTXO scheme would need: + +1. **Pedersen commitments (or equivalent) on VTXO amounts.** Mature + crypto; standard. +2. **Range proofs per VTXO.** Bulletproofs ~700 bytes/VTXO, or + SNARK-compressed via the same PCD/Plonky2 stack zkCoins already + uses (Shielded CSV §6.3). +3. **A ZK proof of correctness of the operator's signed batch.** + "Sum of input commitments = sum of output commitments + fee" and + "each output commitment is well-formed". The operator signs a + circuit proof, not plaintext. Mathematically, this is exactly the + PCD compliance predicate Shielded CSV uses for coins, lifted to + batches. +4. **A redesigned forfeit mechanism.** The operator must be able to + claim on double-spend without knowing the amount. This needs + either a deterministic binding (commit-to-spend) or a separate + amount-revelation in the forfeit-claim path. Genuinely new + cryptography; no existing template. + +**SP1 as the proving stack** would be the natural choice (zkCoins' +predecessor used SP1, locked at v4.1.2 per institutional memory; +current zkCoins uses Plonky2 per +[`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 5). A zkCoins-style +PCD layer over Arkade's batching is mathematically sensible — PCD is +the right abstraction for "validity proof composes over a DAG-shaped +state machine," which is exactly what Ark's VTXT is. + +**Realistic assessment:** + +- Without a Bitcoin soft fork (no Confidential Assets opcode, no + Mimblewimble in Bitcoin Script) the privacy is *off-chain in Ark* + but the on-chain `commitment_tx` still exposes the batch's input + totals. +- The forfeit-mechanism redesign is paper-worthy new cryptography. +- 1-2 year research project. The Shielded CSV authors sit in + exactly the right ecosystem to attack this; no public proposal as + of 2026-05. + +**This section is descriptive, not prescriptive.** zkCoins does not +take responsibility for confidential VTXOs; if Arkade or an external +research group ships them, the design space in §7 and §6.6 changes +favourably. We track the direction; we do not invest in it. + +### 6.6 Layer 2c — Cross-asset DEX (12+ months, engineering not research) + +Arkade Labs has launched **Arkade Assets** +([blog.arklabs.xyz — *Native Assets on Bitcoin: Introducing Arkade +Assets*](https://blog.arklabs.xyz/native-assets-on-bitcoin-introducing-arkade-assets/), +Oct 2025): TLV-encoded native assets in `OP_RETURN`, asset identifier +`(genesis_txid, group_index)`, transferred through VTXOs with operator +awareness. zkCoins is becoming permissionless multi-asset via +[`MULTI_ASSET.md`](./MULTI_ASSET.md) — anyone mints a token, identifier +is a Poseidon digest of genesis pre-image, transferred privately. + +A swap between Arkade Asset X and zkCoins Asset Y is structurally +**A1 with two field substitutions**: + +- The Arkade side encumbers an Arkade Asset (not bare BTC) with an + HTLC. The Arkade compiler supports asset-flow validation + (transaction introspection), so the HTLC enforces "send `v` units + of `asset_id_A` to receiver on preimage reveal." +- The zkCoins side uses a 2-of-2 shared account holding `asset_id_B`. + Multi-asset shared-account machinery works unchanged from the + single-asset case ([`MULTI_ASSET.md`](./MULTI_ASSET.md) §4.4 — every + state transition is single-asset, but shared accounts can hold any + asset). + +**Why this is novel** as a Bitcoin-native primitive: + +- First publicly-described BTC-L1-only cross-asset swap involving a + privacy-preserving asset (zkCoins-asset, hidden amount + sender + + recipient) and an operator-visible asset (Arkade Asset). +- Composable: any Arkade Asset, any zkCoins asset. The matching + engine sits off-protocol. +- A natural first cross-protocol DEX primitive for the + "Bitcoin-native trustless DeFi" thesis. + +**Honest framing.** This is **engineering, not research.** The crypto +already exists, the templates exist; what is missing is wiring + +a matching engine. Realistic in ~12 months of focused work after A1 +ships and [`MULTI_ASSET.md`](./MULTI_ASSET.md) reaches steady state. +Tracked as decision A6. + +--- + +## 7. Detailed Flow: HTLC Atomic Swap (Arkade BTC ↔ zkCoins) + +This section is the implementation-grade specification of decision A1. +It mirrors the structure of +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §8: detailed +flow, failure modes, trust argument. + +### 7.1 Parties and pre-conditions + +- **User (Alice):** Arkade wallet pointing at some Arkade instance, + zkCoins wallet pointing at a zkCoins server, an existing zkCoins + account. +- **Counterparty (Bob, "swap provider"):** Arkade wallet with VTXO + inventory, zkCoins server with sufficient inventory in some operator + account. May be the same operator that runs the Arkade instance and + the zkCoins server, or a third party; the protocol does not require + it. +- **Pre-agreed parameters:** swap amount `A`, provider fee `F`, the + on-Arkade HTLC timeout `T_htlc`, the zkCoins-side recovery timeout + `T_recovery` with `T_htlc < T_recovery`, both strictly less than the + Arkade batch expiry `T_e`. + +### 7.2 The asymmetry to resolve + +Section 5.4 framed it; this section operationalises it. + +An Arkade VTXO can encode an arbitrary Bitcoin Script — it is a +Taproot output with at minimum a cooperative path +(`checkSig(pkO ⊕ pkA)`), a unilateral exit path +(`checkSig(pkA) ∧ relTimelock(t_v)`), and any number of additional +script paths. The Arkade compiler ships an HTLC template natively +(`arkade-os/compiler` README): + +```text +contract HTLC(pubkey sender, pubkey receiver, bytes hash, int refundTime) { + function claim(signature receiverSig, bytes preimage) { + require(checkSig(receiverSig, receiver)); + require(sha256(preimage) == hash); + } +} +``` + +The HTLC compiles into a Taproot script-path. The VTXO retains its +operator + user collaborative path (so the operator can sign Alice's +spend cooperatively if she reveals the preimage in-protocol) and its +unilateral exit path (so Alice can take it on-chain if the operator +stalls). + +A zkCoins coin **cannot** encode any spending condition. There is no +`script` field on `Coin`; the recipient check is hard-coded +([`program/src/lib.rs::apply_coin`](./program-plonky2/src/circuit/main.rs)). +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §5.1–5.3 +derives why this is load-bearing for the protocol; the conclusion +ports here unchanged. + +### 7.3 Where atomicity lives + +Per Shielded CSV §A.1.2 and [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) +§5.4, atomicity for a zkCoins side participant must come from either: + +1. **A 2-of-2 shared zkCoins account** with a pre-signed time-locked + recovery to the original owner. Shielded CSV §5.1 (Shared Accounts) + + §A.1.1 (Time-locked Transactions) provide the primitives. +2. **The Bitcoin funding transaction of the zkCoins inscription** + carrying a script lock. + +For Arkade ↔ zkCoins, **option 1 is the canonical choice**: it +mirrors the construction Shielded CSV §A.1.2 uses for Shielded-CSV ↔ +Bitcoin/L2 atomic swaps, and it does not couple atomicity to the +publisher's inscription mechanics (which would force coordination +between the swap counterparty and the publisher). + +Option 2 is preferred for Lightning swaps in +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §6 because the +on-chain side there is bare Bitcoin without any other lever. For +Arkade swaps the Arkade VTXO is itself the script-bearing side; the +zkCoins side does not need to carry the HTLC. + +### 7.4 Protocol steps + +**Direction A — Alice has zkCoins, wants Arkade BTC. Bob has Arkade +BTC, wants zkCoins.** Alice generates the preimage. + +``` +Step 1. Alice generates preimage x ←$ {0,1}^256, computes H = SHA256(x). + Alice sends to Bob: + - H + - alice_arkade_recipient_pubkey (for the VTXO claim) + - amount A + - alice_zkcoins_account_pubkey (for the 2-of-2 shared account) + +Step 2. Alice and Bob set up the 2-of-2 zkCoins shared account: + - Construct MuSig2 aggregate pubkey pkA⊕pkB + - Alice prepares recovery_tx (zkCoins nullifier publication + that returns the shared account's balance to Alice after + block height h_recovery = current + T_recovery) + - Alice signs her half of recovery_tx, sends to Bob + - Bob signs his half (MuSig2 partial), aggregates + - Alice now holds a valid recovery_tx she can publish after + T_recovery + +Step 3. Alice publishes the funding nullifier: + - zkCoins transaction Alice → 2-of-2(pkA⊕pkB), amount A + - Publisher batches the nullifier; coins land in the shared + account on next inscription + +Step 4. Bob constructs an Arkade VTXO with an HTLC encumbrance: + - contract HTLC(sender=Bob, receiver=Alice, hash=H, + refundTime=current + T_htlc) + - Cooperative-path: pkO⊕pkB (Bob can cooperate with operator + to refund after T_htlc, or to honour an + early settle) + - Unilateral-path: pkB ∧ relTimelock(t_v) (standard Arkade + exit) + - HTLC script-path (per Arkade Script template above) is the + new addition + - Bob boards the VTXO collaboratively with the Arkade operator + +Step 5. Alice verifies the VTXO: + - VTXO is in Arkade, value = A + - HTLC script-path matches: H, refundTime, alice's pubkey as receiver + - T_htlc < T_recovery (so Bob cannot refund the Arkade side + after Alice has lost the recovery option) + - T_htlc < T_e (so the cooperative-path stays live; if T_htlc + ≥ T_e the operator's sweep fires first and the + HTLC is moot) + + If any check fails, Alice aborts. Alice's funds are in the + 2-of-2 shared account; recovery_tx returns them after + T_recovery. No loss to Alice. + +Step 6. Alice claims the Arkade VTXO by revealing x: + Option (a) — cooperative claim: + - Alice asks the operator to cosign an Arkade transaction + spending the VTXO via the HTLC script-path: input witness + includes + - Operator validates the script-path satisfaction (sha256(x) + == H), cosigns + - New VTXO with Alice's pubkey as cooperative-path key + + Option (b) — unilateral claim (if operator stalls): + - Alice publishes the unilateral chain of Ark transactions + (O(log t) txs from the batch root to her VTXO leaf) + - Then publishes a Bitcoin tx spending her leaf VTXO via + the HTLC script-path + + Either way, x is now public — on the Arkade transcript (option + a, visible to the operator and any party watching Arkade) or + on-chain (option b). + +Step 7. Bob learns x. Bob uses x to take control of the 2-of-2 zkCoins + shared account before T_recovery: + - Bob constructs a zkCoins transaction that nullifies the + shared account's balance to Bob's own zkCoins account + - Requires MuSig2 signature with both pkA and pkB; Bob + already has both pkA's contribution because the + shared-account setup pre-shared signing material with the + preimage-bound condition (this mirrors Shielded CSV §A.1.2's + "Bob learns x, uses it as one factor in the MuSig2 + cooperative signature path") + +Step 8. Bob's transaction publishes the nullifier. Shared account + empty. Swap complete. +``` + +**Symmetric flow** for direction B (Bob has zkCoins, wants Arkade +BTC) inverts roles — Bob generates the preimage. The construction is +otherwise identical. + +### 7.5 Failure modes + +| Failure | Who has what | Recovery | +| ------- | ------------ | -------- | +| Alice aborts at Step 5 | Alice has shielded coins in 2-of-2 shared account; Bob has a VTXO encumbered by HTLC | Alice waits `T_recovery` and publishes `recovery_tx`. Bob's VTXO refunds via Arkade HTLC `refundTime`. Both made whole; small fees lost. | +| Bob never boards the HTLC-encumbered VTXO (Step 4) | Alice has funds in shared account, Bob has nothing | Same as above: Alice's `recovery_tx` after `T_recovery`. Bob has nothing to refund. | +| Operator refuses cooperative claim at Step 6(a) | Alice cannot get cooperative settlement | Alice falls back to unilateral claim (Step 6(b)), `O(log t)` virtual txs published on-chain. Preimage `x` becomes public. Bob still proceeds to Step 7. Higher cost to Alice. | +| Alice never claims the VTXO (Step 6 not executed) | Bob has VTXO locked in HTLC; Alice has shielded coins | Bob waits `T_htlc`, refunds the VTXO via Arkade HTLC `refundTime` path (cooperative with operator). Alice waits `T_recovery > T_htlc`, recovers shielded coins via `recovery_tx`. Both whole. | +| Bob never executes Step 7 (refuses to claim shared account after seeing `x`) | Alice has Arkade BTC, Bob has nothing on the zkCoins side; shared account still holds A | Alice's `recovery_tx` after `T_recovery` returns shielded coins to Alice. **Net: Alice has both A worth of Arkade BTC and A worth of shielded coins** — Bob's loss. Asymmetric incentive: Bob has no reason to do this. Documented as provider-side discipline. | +| Bob claims shared account via Step 7 but Alice never sent the VTXO claim | Cannot happen — Step 7 requires `x`, which only becomes public after Step 6 | — | +| Arkade operator goes offline between Step 4 and Step 6 | Same as "Operator refuses cooperative claim" — Alice unilateral-exits | Same recovery. | +| `commitment_tx` carrying the HTLC-VTXO does not confirm before `T_e` | The Arkade batch expires; operator sweeps; HTLC is moot | This is the canonical `htlc_timeout < T_e` constraint from Ark §6. Step 5 verifies it. If misconfigured, Alice's preimage-reveal becomes useless because there's nothing left to claim; she falls back to her zkCoins recovery_tx. | +| Both parties' refund txs race for the same block | Standard fee-management concern | Pre-sign with sufficient fee bumping; not a trust issue. | + +### 7.6 Trust assumptions + +At no point does either party transfer custody of an asset to the +other party where the other party can withhold reciprocation: + +- Alice's funds in the 2-of-2 shared account are recoverable via + `recovery_tx` after `T_recovery` — Bob cannot block this. +- Bob's VTXO encumbered by HTLC is recoverable via `refundTime` + after `T_htlc` (cooperative with operator, or unilateral exit) — + Alice cannot block this. +- `T_htlc < T_recovery` ensures Bob's refund window closes before + Alice's recovery window opens, so the swap is timing-safe: if Alice + claims, Bob has time to learn `x` and execute Step 7 before + `T_recovery`; if Bob refunds, Alice has not yet given up her recovery. + +**The trust assumptions are independent in each leg.** Alice trusts +the Arkade operator's rationality for the cooperative-claim path +(falls back to unilateral exit if violated). Alice trusts the zkCoins +publisher's liveness for the inscription publication (falls back to a +different publisher; any party can publish). Alice trusts neither Bob +nor the operator with custody — preimage-bound timeouts enforce +correctness. + +### 7.7 Latency and costs + +**Latency (happy path, cooperative claim):** + +- Step 1–2 (shared-account setup): one round of MuSig2 messages + (sub-second over the wire). +- Step 3 (funding nullifier): one Schnorr-signed inscription, + bounded by zkCoins publisher cadence + Bitcoin confirmation depth + needed for the swap timing model (typically 1–6 confirmations). +- Step 4 (VTXO with HTLC): one Arkade boarding round, bounded by + Arkade operator's batch cadence. +- Step 6(a) (cooperative claim): one Arkade transaction, sub-second + preconfirmation. +- Step 7 (shared-account claim): one zkCoins inscription, bounded by + publisher cadence. + +**Total wall-clock for happy path:** dominated by zkCoins inscription +confirmation. Per [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) +§14 the conservative envelope is on the order of an hour for +end-to-end Bitcoin-confirmation safety; pre-D7 the same envelope +applies here. + +**Costs (per swap):** + +- Arkade side: one VTXO worth of liquidity locked for `T_htlc`; + Arkade transaction fees (typically negligible inside Arkade). +- zkCoins side: two inscriptions (funding + claim), each ~64 bytes + amortised plus the publisher's overhead. +- Counterparty fee `F`: market-set, comparable to Boltz fees. + +**Pessimistic path** (unilateral exit, dispute) costs an extra +`O(log t)` virtual transactions on the Arkade side. This is the +standard Ark exit cost (Ark §2.3) and is borne by whoever invokes the +unilateral path. + +--- + +## 8. Trust-model stacking + +The combined stack inherits the union of both protocols' trust +assumptions. Understanding what depends on what is the key to +reasoning about real-world security. + +### 8.1 Independent assumptions + +| Component | Assumption | Effect of violation | +| --------- | ---------- | ------------------- | +| Arkade operator (rational) | Operator follows protocol | Operator loses their own funds, not users'; users still exit (Ark §5 Table 1) | +| Arkade operator (malicious) | Operator deviates | NL, FL still hold; NS, AS, FS violations cost the operator, not users | +| Arkade MuSig2 covenant emulation | 1-of-n VTXO holders + operator follow signing protocol | VTXT well-formed (Ark §3.2, §4 Remark 4.5) | +| zkCoins server-side compute | Server runs the published Plonky2 circuit honestly | Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 1 + invariant 2; closed test environment today, in-circuit verification long-term | +| zkCoins Schnorr signatures | BIP-340 / secp256k1 secure | Standard Bitcoin cryptographic assumption | +| zkCoins publisher liveness | Some publisher willing to inscribe | Permissionless — alternative publishers can take the nullifier | +| zkCoins bridge Phase 1 (federation) | M-of-N federation honesty ([`BRIDGE_MVP.md`](./BRIDGE_MVP.md)) | M+ colluders can steal BTC reserves; zkCoins-side internal transfers unaffected | +| zkCoins bridge Phase 2 (BitVM2) | 1-of-N setup honesty ([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)) | If all N are malicious at setup, vault parameters can be compromised; once setup completes, peg-out paths are public and trustless | +| Bitcoin L1 | Bitcoin's PoW + censorship resistance | Catastrophic for both protocols; outside the design space | + +### 8.2 Composition for §7's HTLC swap + +The HTLC atomic swap of §7 requires: + +- Arkade rational operator (so cooperative claim works; unilateral + fallback if violated). +- Bitcoin L1 (for confirmation of the inscriptions and any unilateral + Arkade exit). +- zkCoins server-side compute (so the publisher accepts and processes + the nullifier). +- BIP-340 Schnorr security (for both sides' signatures). + +It does **not** require: + +- A zkCoins bridge to be running. The swap is BTC-pegged on the + Arkade side and uses zkCoins-internal coins on the other side; the + bridge only matters if one party wants to convert between zkCoins + shielded coins and real BTC outside the swap. + +### 8.3 Composition for §6.3's pipeline + +The pipeline composes: + +- Arkade onboarding → Arkade rational operator + Bitcoin L1 +- §7 HTLC swap into zkCoins → as in §8.2 +- zkCoins-internal transfers → zkCoins server-side compute + Schnorr +- §7 HTLC swap out of zkCoins → as in §8.2 +- Arkade exit → Arkade rational operator (cooperative) or pure Bitcoin + L1 (unilateral) + +Each step's failure mode is independent; nothing chains a failure +into a worse failure downstream. The pipeline is no less secure than +its weakest leg. + +### 8.4 Composition for §6.4's Ark-aware BitVM bridge + +If the same federation operates the BitVM2 bridge and an Arkade +instance, both assumptions still apply independently: + +- Federation as Arkade operator: rational-operator assumption (Ark + §5). +- Federation as BitVM2 bridge: 1-of-N setup honesty ([`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) + §3.2). + +A federation that defects on its Arkade role (steals from itself, since +Ark §5 says the operator can only harm itself under malice) does not +compromise its BitVM2 role unless the same key material is involved. +The design discipline is to keep the key material separate. With +discipline, the trust assumptions do not collapse. + +--- + +## 9. Personnel and ecosystem signal + +The author overlap between the two protocol families is real and +load-bearing for the "designed to interlock" hypothesis. Worth +naming explicitly so the implication is not over-claimed. + +**Shielded CSV (ePrint 2025/068):** Jonas Nick (Blockstream), Liam +Eagen (Alpen Labs), Robin Linus (ZeroSync; BitVM creator). + +**BitVM / BitVM2:** Robin Linus (lead), Lukas Aumayr, Zeta Avarikioti, +Matteo Maffei, Andrea Pelosi, Christos Stefo, Alexei Zamyatin (cited +as ref [1] in Ark whitepaper itself). + +**Ark whitepaper:** Marco Argentieri, Zeta Avarikioti, Andrew +Camilleri, Pim Keer, Matteo Maffei (Ark Labs + TU Wien). **Zeta +Avarikioti and Matteo Maffei co-author both the BitVM eprint and the +Ark litepaper.** TU Wien is the institutional connector. + +**Glock (Jan 2026):** Robin Linus + Liam Eagen + others (Alpen Labs). +~430× cost reduction over BitVM2. + +**Argo (Jan 2026):** Robin Linus, Liam Eagen, Ying Tong Lai. ~2000× +cost reduction over BitVM3. + +**Translation.** The same ~5 people — Linus, Eagen, Nick, Avarikioti, +Maffei — are simultaneously authoring the BitVM bridge tech (which +zkCoins Phase 2 depends on), the Shielded CSV protocol (which zkCoins +implements), the Ark batching layer (which Arkade implements), and the +next-generation bridge tech (Glock, Argo) that obsoletes BitVM2 in +1-2 years. They are deliberately building an interlocking stack. + +**Public statements explicitly combining Arkade and zkCoins**: none +found as of 2026-05. + +- Robin Linus' widely-cited quote — *"Shielded CSV is the most + interesting thing you can do with BitVM"* — signals the bridge-via-BitVM + intent that [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) is built on. It + does not mention Ark. +- Ark whitepaper §6 lists "escrows, DLCs, payment channels" as Ark + applications. It does not mention Shielded CSV. +- Shielded CSV paper does not mention Ark. +- Both papers cite each other's adjacent ecosystem work (Lightning, + BitVM) but not each other. + +**The signal is institutional, not textual.** The same labs and people +are shipping both stacks within ~1–2 years of each other; the +integration is implicit in the personnel and the layered protocol +design, not declared in the literature. Frame accordingly: a high +prior that integration tooling will emerge from the same ecosystem, +**not** a documented unified roadmap to cite. + +--- + +## 10. Open Questions + +### 10.1 PTLC vs. HTLC for the swap (§7) + +§7 uses HTLC (SHA256 preimage). PTLC (point time-locked contract, +Schnorr adaptor signature) would give better on-chain privacy by +making the swap claim indistinguishable from a single-sig spend. + +- **Choice in doc:** HTLC. Production-ready toolchain, Arkade compiler + ships it, identical trustlessness, identical timing logic. +- **Alternative:** PTLC. Better privacy on the Arkade side; requires + adaptor-signature support in the Arkade compiler (an SDK feature, + not a Bitcoin Script change). +- **Trade-off:** PTLC reduces the on-chain analysability of swap + claims but does not change the security argument. Mirror of the + HTLC-vs-PTLC discussion in [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) + §7.3. PTLC is a v2 upgrade once Arkade's compiler ships adaptor + signatures; not a v1 dependency. + +### 10.2 Timing parameter selection (`T_htlc`, `T_recovery`, `T_e`) + +§7.1 prescribes `T_htlc < T_recovery < T_e`. Concrete values are +deployment-dependent. + +- **Choice in doc:** the inequalities are protocol-required; the + numeric values are operational. +- **Trade-offs:** longer windows give users more time to act before + refund/recovery fires (good UX, more fee-bump headroom); shorter + windows reduce capital-lockup costs for swap counterparties (better + liquidity efficiency). Arkade's `T_e` is operator-set (Ark §4.4); + the swap design must adapt to whatever the chosen Arkade instance + uses. Recommended starting points: `T_e` = 1 week (typical Arkade + operator default), `T_recovery` = 24 hours, `T_htlc` = 12 hours. + Operators should publish their chosen values and update wallets + via capabilities flag. + +### 10.3 Counterparty discovery / matching engine + +§7 assumes Alice and Bob found each other. In practice, swap +counterparties need a matching engine. + +- **Choice in doc:** out of scope for this design doc. Treat as a + separate piece of infrastructure (analogous to Boltz' role for + submarine swaps). +- **Trade-off:** centralised matching engines (a website that lists + liquidity providers) are operationally trivial but introduce a + liveness dependency. Decentralised matching (DHT-based or LN-routing-style) + is research. For v1, centralised matching is the obvious choice. + +### 10.4 Cooperative vs. unilateral default at Step 6 + +§7.4 Step 6 distinguishes (a) cooperative Arkade claim via the +operator and (b) unilateral on-chain claim. Cooperative is sub-second +and cheap; unilateral is slow and costs `O(log t)` virtual txs. + +- **Choice in doc:** wallet defaults to cooperative, falls back to + unilateral on operator timeout. +- **Trade-off:** the cooperative path leaks the preimage to the + Arkade operator (operator sees the script-path satisfaction during + cosigning); the unilateral path leaks it on-chain to any observer. + Either way the preimage becomes public, which is what enables Step 7 + — there is no privacy-preserving variant short of PTLC. + +### 10.5 Pipeline `recovery_tx` lifecycle + +In §6.3's pipeline, the user has a `recovery_tx` pre-signed for each +HTLC swap into and out of zkCoins. These accumulate as the user moves +between systems. + +- **Open:** wallet-side hygiene. Should the wallet auto-execute + `recovery_tx` when it observes the corresponding swap completed + successfully on the other side? Auto-nullify the recovery to free + the shared account? +- **Recommendation:** track as `zk-coins/app` wallet UX issue once + A1 lands; not a server-side concern. + +### 10.6 Multi-asset semantics in A1 (vs. A6) + +A1 explicitly scopes to BTC-pegged swaps. A6 generalises to Arkade +Asset ↔ zkCoins Asset. + +- **Open:** is there a clean upgrade path from A1 to A6, or does the + multi-asset variant want different swap mechanics? +- **Speculation:** the §7 construction generalises straightforwardly + if both sides agree on the asset_id mapping out-of-band. The + matching engine (§10.3) becomes the natural place to declare + "Arkade Asset X ↔ zkCoins Asset Y" pairs. Confirm during A6 design. + +### 10.7 D7 reorg safety dependency + +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) §15 names +D7 reorg safety as a zkCoins-side blocker that lengthens swap +wall-clock time. The same dependency applies to the §7 HTLC swap. + +- **Choice in doc:** until D7 lands, the swap design adds Bitcoin + confirmation-depth requirements before either party considers an + inscription settled. Tracked as a cross-document dependency; not a + blocker for the integration design. + +--- + +## 11. Implementation Order + +Phased rollout, mapped to discrete milestones. Effort estimates per +the convention in [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) §12.1 (S = small, +M = medium, L = large, XL = extra large). All phases assume A1 has +been locked in this document and a separate implementation spec has +been opened. + +| Phase | Scope | Effort | Risk | +| ----- | ----- | ------ | ---- | +| **P0 — Approval of this design** | Maintainer locks A1–A6; this document moves from "draft" to "approved". | **S** | None | +| **P1 — Implementation spec for §7 HTLC swap** | New sibling doc `ARKADE_HTLC_SWAP.md` (or extension to this document) specifying: zkCoins wire-protocol for shared-account funding, Arkade compiler HTLC parameterisation, swap-counterparty API, recovery-tx persistence model, wallet UX. Mirror of the relationship between [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) and [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md). | **M** | Low | +| **P2 — zkCoins shared-account primitive** | Implement 2-of-2 MuSig2 shared accounts in `zk-coins/node` (a prerequisite that does not exist today; [`SPEC.md`](./SPEC.md) §3 single-account-per-pubkey model needs extension). Shielded CSV §5.1 has the protocol-level construction. Persistence, recovery-tx pre-signing, capabilities-flag gating. | **L** | Medium — touches account-state model | +| **P3 — Arkade swap-counterparty service** | Off-protocol service (likely a separate small Rust crate) that runs as a liquidity provider: monitors Arkade for HTLC-encumbered VTXOs matching swap requests, drives the §7 protocol, signs MuSig2 partials, executes claims. Could be merged into `arkd` upstream or live as a separate binary. | **L** | Medium — coordination across two systems | +| **P4 — Wallet integration** | `zk-coins/app` wallet learns the swap UX: pick direction, see liquidity, monitor swap status, auto-execute recovery if needed. Mirror of pattern for [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) wallet integration. | **L** | Medium — UX-heavy | +| **P5 — End-to-end test suite** | Mutinynet + Arkade testnet integration tests, single-counterparty happy path + all failure modes from §7.5. Coverage gate per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 4. | **M** | Low | +| **P6 — Pipeline orchestration (§6.3)** | Wallet-side multi-step flow combining Arkade boarding + swap-in + swap-out + Arkade exit. UX work, no new protocol. | **M** | Low | +| **P7 — A6 multi-asset variant** | Generalise the §7 construction to Arkade Asset ↔ zkCoins Asset. Depends on [`MULTI_ASSET.md`](./MULTI_ASSET.md) reaching steady state and Arkade Assets being beyond beta. | **L** | Medium — combinatorial test surface | +| **P8 — A5 BitVM bridge convergence (optional)** | Design + implementation of the Ark-aware BitVM bridge sketched in §6.4. Depends on Phase 2 BitVM bridge being live and Arkade multi-operator support. | **XL** | High — multi-protocol surgery | + +**Aggregate effort for P1–P6 (the A1 implementation path): S + M + L ++ L + L + M + M ≈ 4-6 person-months at focused effort.** P7 and P8 +are explicitly post-A1 and gated on external dependencies. + +Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 4, every phase +ships with 100% test coverage on the activated surface. Negative +tests — every failure-mode row in §7.5 must be reproducible in +integration tests — are mandatory. + +--- + +## 12. Non-Goals (Restated) + +So nobody scope-creeps: + +- **Modifying the Arkade protocol** — not in scope. The integration + uses Arkade as it ships. +- **Modifying the Shielded CSV protocol or zkCoins circuit** — not + in scope (decision A2). No 12th divergence in [`SPEC.md`](./SPEC.md) + §15. +- **Confidential VTXOs** — not in scope (decision A4). Research + direction tracked; no zkCoins-side investment. +- **Building a decentralised swap-counterparty matching engine** — + not in scope (§10.3). Centralised matching is fine for v1. +- **PTLC-based swap variant** — not in v1 (§10.1). HTLC ships first; + PTLC is an upgrade. +- **Federation operating both Arkade and BitVM2 bridge** — not in + scope as an A1 deliverable (decision A5 + §6.4). Tracked as a + potential 1-2 year roadmap item, depends on Arkade multi-operator + maturity. +- **Generic cross-chain swaps** (Liquid, RSK, sidechains) — out of + scope. Different trust model, different document. + +--- + +## 13. References + +**Papers:** + +- Argentieri, Avarikioti, Camilleri, Keer, Maffei. *Ark: A UTXO-based + Transaction Batching Protocol.* Ark Labs & TU Wien, 2024. + Local: `research/upstream/` or + [`assets.arklabs.xyz/ark-protocol.pdf`](https://assets.arklabs.xyz/ark-protocol.pdf). + Cited sections: §2 (overview), §3.2 (covenants), §4 (Ark + construction; Definition 4.1 VTXO, Definition 4.9 commitment + transaction), §4.3 (batch swaps, forfeit transactions), §4.4 + (commitment transactions), §4.5 (boarding and leaving), §5 + (security; Table 1), §6 (applications and HTLC/DLC/channel caveat), + §7 (discussion: centralisation, preconfirmation, liquidity). +- Nick, Eagen, Linus. *Shielded CSV: Private and Efficient Client-Side + Validation.* ePrint 2025/068. + Local: `research/shieldedcsv-paper.pdf`. + Cited sections: §1.1 (privacy, blockchain efficiency, trustless + publishing), §4.2 (CoinEssence, accumulator value), §5.1 (shared + accounts), §6 (discussion), §A.1.1 (time-locked transactions), + §A.1.2 (atomic swap with Bitcoin/L2), §A.1.3 (multi-asset). + +**Sibling design docs (this branch):** + +- [`SPEC.md`](./SPEC.md) — single-asset zkCoins protocol specification +- [`MULTI_ASSET.md`](./MULTI_ASSET.md) — permissionless multi-asset + extension (decision M5 defers cross-asset trading; this document is + one of the three out-of-protocol DEX layers) +- [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) — Phase 1 federation bridge +- [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) — Phase 2 BitVM2 trustless + bridge +- [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) — Lightning + atomic-swap layer (closest structural sibling to this document) +- [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) — Plonky2 migration + rationale; §5 (locked decisions) and §7 (lessons learned) supply the + decision-recipe pattern used in §3 here +- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — project invariants, + pre-push checklist + +**External references:** + +- Arkade Labs blog — [*Press Start — Arkade Goes Live*](https://blog.arklabs.xyz/press-start-arkade-goes-live/) +- Arkade Labs blog — [*Native Assets on Bitcoin: Introducing Arkade + Assets*](https://blog.arklabs.xyz/native-assets-on-bitcoin-introducing-arkade-assets/) +- Arkade Labs blog — [*Closing the Lightning Loop*](https://blog.arklabs.xyz/closing-the-lightning-loop-bitcoins-missing-layer-secretly-goes-live/) +- Arkade docs — `docs.arkadeos.com` (HTLC template, Escrow, Spilman + channel, Dryja-Poon channel, Lightning swaps, Arkade Script) +- Arkade compiler — [arkade-os/compiler](https://github.com/arkade-os/compiler) +- Arkade daemon — [arkade-os/arkd](https://github.com/arkade-os/arkd) +- BitVM bridge whitepaper — [bitvm.org/bitvm_bridge.pdf](https://bitvm.org/bitvm_bridge.pdf) +- Shielded CSV publishing site — [shieldedcsv.org](https://shieldedcsv.org) + +--- + +## 14. Change Log + +| Date | Change | +| ---- | ------ | +| 2026-05-23 | Initial draft. Locked decisions A1–A6; HTLC atomic-swap protocol of §7; pipeline use of §6.3; trust-model stacking of §8. | diff --git a/BITVM_BRIDGE.md b/BITVM_BRIDGE.md new file mode 100644 index 00000000..88555feb --- /dev/null +++ b/BITVM_BRIDGE.md @@ -0,0 +1,1125 @@ +# BitVM Bridge — Trustless Mint/Burn for zkCoins + +**Status:** Design draft. No code yet. Companion to `SPEC.md` +(specifically D11), `MIGRATION_RESEARCH.md`, `ROADMAP.md`, and +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md). + +**Authoritative source for:** how zkCoins removes the operator-controlled +mint (D11) by binding mint operations to provable BTC custody on Bitcoin +L1 via a BitVM2-style bridge. + +**Audience:** Engineers and stakeholders evaluating zkCoins's path from +MVP-with-trusted-issuer to mainnet-with-cryptographic-issuance. + +> **Branch note.** This document presupposes the Plonky2 migration +> currently on `feat/plonky2-migration` (PR #17). `SPEC.md`, +> `MIGRATION_RESEARCH.md`, and `ROADMAP.md` live on that branch and +> will resolve on `develop` only after PR #17 lands. Until then, view +> cross-references against `feat/plonky2-migration`. + +--- + +## 1. Scope + +This document specifies what it would take to make zkCoins coin issuance +**trustless** by replacing the hard-coded `MINTING_ADDRESS` with a +BitVM2-bridge-anchored mint mechanism. Concretely: + +- The exact trust model of BitVM2 bridges as deployed by Citrea + (Clementine) and others as of 2026-05 +- How a BitVM2 bridge would integrate with the zkCoins state-transition + circuit +- What new circuit branch (`IssuanceProof` per Shielded CSV paper) needs + to exist +- The federation setup, trusted setup ceremony, and operational burden +- The peg-in (BTC → zkCoin) and peg-out (zkCoin → BTC) flows +- Trust assumptions in plain terms (where 1-of-N suffices, where N-of-N + is required, where the user trusts no one) +- Open issues, cost estimates, and what it does *not* solve + +It does **not** cover: + +- BitVM1 (superseded by BitVM2 for bridges) +- BitVM3 (research-stage, not production-ready as of 2026-05) +- Non-bridge BitVM use cases (general computation) +- Lightning swap layer — that lives in `LIGHTNING_ATOMIC_SWAP.md` + +--- + +## 2. The Problem Restated + +### 2.1 D11 today + +Per `program/src/lib.rs:70-73` and `program/src/main.rs:78-83`, the +`InitialProof` branch of the state-transition circuit contains: + +```rust +ProofType::InitialProof => { + if account_state.owner != MINTING_ADDRESS { + assert_eq!(account_state.balance, 0, "Starting balance has to be 0.") + } + DEFAULT_HASHES[0] +} +``` + +Anyone holding the private key to the public key whose hash is +`MINTING_ADDRESS` can produce an `InitialProof` with arbitrary starting +balance — effectively unlimited mint authority. There is no on-chain +binding, no cap, no audit constraint. + +In the closed-test environment (`feedback_zkcoins_closed_test_env`) and +under the MVP-publisher self-issuance model (`MIGRATION_RESEARCH.md` +§5.6) this is acceptable. It is **not** acceptable for any mainnet +launch that claims trust-minimised properties over the issued asset. + +### 2.2 What "trustless mint" means here + +The user of a zkCoin must be able to verify, without trusting any +single party, that **the total supply of zkCoins outstanding does not +exceed the BTC locked in publicly verifiable on-chain custody**. + +Equivalently: every coin in circulation must trace its provenance back +to a BTC peg-in on Bitcoin L1, and the protocol must prevent +inflationary mints. + +### 2.3 What BitVM2 provides + +BitVM2 (specifically the Clementine bridge architecture as deployed by +Citrea) provides exactly this binding: a Bitcoin-L1-anchored mechanism +where: + +- BTC enters the bridge via deposit into an N-of-N MuSig Taproot vault +- A side-system mint is authorised only when a Bitcoin Light Client + proof shows the deposit is final +- Withdrawals back to Bitcoin require fronting by operators and are + optimistically verified, with on-chain disproof via Groth16 SNARK + verification baked into Bitcoin script + +Trust model: **1-of-N honesty per role**. As long as one signer deletes +their key honestly at setup, one operator advances payouts honestly, +and one challenger watches for fraud, the bridge holds. + +--- + +## 3. BitVM2 / Clementine — Architecture in Detail + +This section is a precise read of the Citrea Clementine implementation +as of 2026-05. References at the end. + +> **2026 context** (added 2026-05-17): BitVM2 is currently the only +> trustless-bridge construction with a live mainnet deployment (Citrea +> launched 2026-01-27). Three credible successors have emerged in +> 2025–2026 — BitVM3-RSA (withdrawn after security flaw), Glock by +> Alpen Labs (research/testnet-stage), and Mosaic by Eagen et al. +> (research-stage, full Rust implementation). All three use garbled +> circuits + cut-and-choose + adaptor signatures to push BitVM2's +> on-chain Assert footprint down by 100–1000×. See §12 for a survey +> of these alternatives and what it means for zkCoins's bridge choice. +> The fundamentals of §3 (peg-in/peg-out flow, roles, 1-of-N honesty +> assumption) remain identical across all BitVM-family bridges; the +> innovations target the fraud-proof step specifically. + +### 3.1 Roles + +| Role | Function | Quorum | +| ---- | -------- | ------ | +| **User** | Initiates peg-in (locks BTC) or peg-out (burns side-chain asset) | — | +| **Signers** | Pre-sign every spending path of every UTXO in the bridge graph at setup. Must delete keys after presigning. | N-of-N MuSig (all participate) | +| **Operators** | Front BTC payouts to peg-out users from their own funds; later reimbursed from the vault | 1-of-N — any operator can serve any payout | +| **Watchtowers** | Monitor Bitcoin chain and bridge state; publish header-chain proofs during disputes | 1-of-N | +| **Challengers** | Permissionless — anyone can detect and challenge fraudulent operator claims | Permissionless | + +Hierarchy: every Signer is also an Operator and Watchtower; Challengers +can be anyone (no membership required). + +### 3.2 Setup ceremony — N-of-N MuSig + +Once per bridge deployment, the N signers must: + +1. Generate fresh Schnorr keypairs +2. Aggregate to a MuSig2 vault key +3. Construct the **entire transaction graph** of allowed spending + paths: peg-in `MovetoVault`, peg-out `Payout`, `KickOff`, + `Challenge`, `Assert`, `Disprove`, `Take1`, `Take2`, `Burn`, + timeout refunds +4. Pre-sign all of these with the N-of-N MuSig +5. **Delete the per-signer private keys** + +The deletion step is the security crux. As long as **at least one +signer actually deletes**, no future coalition can spend the vault +outside the pre-signed paths. This is the **"1-of-N honesty" +assumption**. + +### 3.3 Groth16 verifier on Bitcoin + +For fraud-proof verification, BitVM2 implements a **Groth16 verifier in +Bitcoin script**, split into sub-programs each small enough to fit in +a Bitcoin block. When an operator's claim is challenged, the operator +must commit to intermediate computation states on-chain. A challenger +who detects a wrong intermediate state executes the corresponding +sub-program on-chain to disprove the operator's claim. + +This requires: + +- A **trusted setup ceremony** for the Groth16 SRS. Citrea ran theirs + with 63 contributors from RiscZero, StarkWare, Aztec, Celestia, + Babylon, Nansen, etc. — `MIGRATION_RESEARCH.md`-grade table. +- The proven statement: the operator's payout transaction is included + in a finalized Bitcoin chain with accumulated work greater than the + watchtower's submitted header chain. + +### 3.4 Peg-in flow (BTC → bridged asset) + +``` +Step 1. User deposit: User sends BTC to a Taproot address with two + leaves: + - Bridge leaf: spendable by the N-of-N MuSig signature, + with witness binding to the user's side-chain receiving + address + - Refund leaf: spendable by user after 200 blocks (CSV) + +Step 2. Vault transfer: Signers cooperatively spend the deposit into + the operational vault UTXO using the pre-signed MovetoVault + transaction. The pre-signature binds the spend to exactly + the right operational vault path — no signer can divert. + +Step 3. Confirmation wait: Vault transfer needs 6+ Bitcoin confirms. + +Step 4. Mint authorization: The side-chain (zkCoins in our case) + reads the Bitcoin Light Client proof and confirms the + MovetoVault tx is in the finalized chain. The mint amount + equals the deposit amount minus fees, credited to the user's + side-chain address. +``` + +The refund path protects the user against bridge stalls — if the +signers fail to advance the deposit within 200 blocks, the user +recovers via the refund leaf. + +### 3.5 Peg-out flow (bridged asset → BTC) + +``` +Step 1. Burn: User invokes the side-chain's burn function. On Citrea + this is `safeWithdraw` on a contract; for zkCoins it would be + a coin-send to a designated BURN_ADDRESS or — paper-aligned — + a dedicated BurnProof predicate branch (§6.3). + +Step 2. Payout request: User submits a Payout transaction template + signed with SIGHASH_SINGLE|ANYONECANPAY, identifying their + BTC receiving address and amount. + +Step 3a. Optimistic path: If all N Signers are online and cooperative, + they collectively sign the Payout, releasing BTC from the + vault directly to the user. ~12 hours typical wait for signer + convening. + +Step 3b. Operator-fronted path: If signers don't convene in 12 hours, + an Operator funds the user out of their own wallet by adding + their own input to the Payout template and broadcasting. + User gets BTC immediately. + +Step 4. Operator reimbursement (only path 3b): + - Operator posts KickOff tx claiming reimbursement from + the vault, citing the peg-out they fronted + - Challenge window opens (1.5 days) + - Watchtowers + permissionless challengers can verify the + KickOff against the actual chain + - If no valid challenge: Operator posts NoChallenge tx + after timelock, retrieves reimbursement from vault + - If valid challenge: BitVM2 Groth16 verifier runs on-chain; + operator must produce ZK Light Client Proof showing their + committed chain has greater cumulative work AND includes + the legitimate payout + - Honest operator wins; malicious operator's entire bond is + slashed and they're ejected from the set + +Step 5. The same Payout template cannot be used twice — anti-replay. +``` + +### 3.6 Key timeouts and security parameters + +| Parameter | Value | Why | +| --------- | ----- | --- | +| Peg-in refund timeout | 200 Bitcoin blocks (~33 hours) | User recovers funds if bridge stalls | +| Optimistic payout convening window | 12 hours | Signer assembly time before operator-fronted path activates | +| Challenge window | 1.5 days (~216 blocks) | Permissionless dispute initiation | +| Security analysis horizon | 2 weeks | Maximum reorg attempt window | +| Hash rate adversary cap | < 45% | Below which the chain proof remains correct | + +### 3.7 Trust assumptions in plain terms + +A user holding bridged BTC trusts that: + +- **At least one of N signers deleted their keys** at setup (after + pre-signing). With Citrea's federation of ~20 members from + competing organisations, the probability of zero honest deletions + is extremely low but non-zero — this is the residual trust. +- **At least one operator** is willing to advance peg-outs (else + liveness — funds are not stolen but become inaccessible until any + operator returns). +- **At least one watchtower or challenger** is monitoring (else + fraudulent operator claims can succeed unchallenged). +- **Bitcoin's < 45% adversary assumption** holds for the 2-week + challenge horizon (standard Bitcoin assumption). + +These are weaker assumptions than any federated bridge (Liquid, RSK) +and stronger than any client-side-verifying chain (which has no bridge +at all). + +--- + +## 4. What Changes in zkCoins + +### 4.1 Circuit changes (`program/`, `program-plonky2/`) + +A new `ProofType` variant, paper-aligned with the Shielded CSV +`issuance(IssuanceProof)` branch: + +```rust +pub enum ProofType { + InitialProof, + AccountUpdateProof, + IssuanceProof, // NEW + BurnProof, // NEW — counterpart for peg-out +} +``` + +The `IssuanceProof` branch replaces the current `MINTING_ADDRESS` +bypass. Instead of trusting `owner == MINTING_ADDRESS`, the circuit +verifies a **Bitcoin Light Client Proof (LCP)** witnessing that: + +- A specific peg-in UTXO (identified by txid and vout) has been + confirmed at depth ≥ 6 in the Bitcoin chain +- The peg-in UTXO's amount equals the issuance amount +- The peg-in UTXO has not been used as the basis of any prior + `IssuanceProof` (uniqueness — tracked in a new + `peg_in_consumed_smt`) +- The peg-in UTXO's witness data binds to the recipient zkCoins + address (so only the intended recipient can mint against that + deposit) + +The `BurnProof` branch handles the peg-out side: + +- A coin is "consumed" by producing a `BurnProof` against it +- The proof emits a public output containing + `(burn_amount, btc_recipient, withdrawal_nonce)` that the bridge + operator picks up to construct the Bitcoin Payout transaction +- The burned coin's identifier is added to a `burned_coins_smt` so + it cannot be double-burned + +### 4.2 New state structures (`node/src/state.rs`) + +Three additions to the global state: + +```rust +struct State { + // ... existing fields (smt, mmr, prev_mmr_root, root_indices) + + // NEW: peg-ins that have been consumed by an IssuanceProof + peg_in_consumed_smt: SparseMerkleTree, + + // NEW: coins that have been burned (peg-out initiated) + burned_coins_smt: SparseMerkleTree, + + // NEW: pending peg-outs waiting for operator fronting + pending_payouts: Map, +} +``` + +### 4.3 New off-circuit responsibilities + +The scanner gains: + +- Watching the bridge vault UTXO and any deposits to it +- Maintaining a local Bitcoin Light Client (header chain + cumulative + work) — likely implemented via SP1's `bitcoin-spv` precompile or an + equivalent in Plonky2 +- Detecting peg-out completion (operator broadcasts Payout tx), + marking pending payouts as completed + +### 4.4 Federation participation + +This is the heaviest organisational change. zkCoins becomes a **member +of a BitVM2 federation**, which requires: + +- Coordinating with N-1 other federation members at setup +- Participating in the trusted setup ceremony for the Groth16 verifier +- Continuously running a signer node, operator node, watchtower node +- Maintaining operator collateral (BTC bond) + +Realistically, zkCoins cannot operate a single-member "federation" of +size 1 and call itself trustless. The minimum credible size is ~5–7 +members from independent organisations. Citrea uses ~20. + +### 4.5 What does NOT change + +- The zkCoins coin model itself (`Coin { identifier, recipient, + amount }`) — D11 fix does not require D2 fix +- The Schnorr/SHA256 boundary at the wallet (BIP-340 still off-circuit) +- The SMT/MMR scanner architecture for normal sends +- The Lightning atomic swap design — `LIGHTNING_ATOMIC_SWAP.md` + remains correct, and a swap liquidity provider becomes anyone + with bridge deposit/withdraw capability instead of relying on a + single sole minter + +--- + +## 5. Detailed Flow A: Peg-In (BTC → zkCoin) + +### 5.1 Pre-conditions + +- User has BTC on Bitcoin L1 +- User has a zkCoins account (knows their `recipient = H(initial_pubkey)`) +- Bridge federation is operational, vault UTXO exists, all + pre-signatures in place + +### 5.2 Protocol steps + +``` +Step 1. User constructs a deposit tx with a Taproot output containing + two leaves: + - Bridge leaf: vault_musig_pubkey, with witness commitment + to user's zkcoins recipient address + - Refund leaf: user_pubkey + 200-block CSV + User broadcasts. + +Step 2. Bridge federation observes the deposit. Signers cooperatively + spend it into the operational vault UTXO using the pre-signed + MovetoVault transaction (the pre-signature is parameterised + on the user's zkcoins address, embedded in the deposit's + witness commitment). + +Step 3. MovetoVault tx confirms (≥6 confirms). At this point the + peg-in is finalized on Bitcoin. + +Step 4. User (or their wallet, or any helper service) generates a + Bitcoin Light Client Proof showing MovetoVault is in the + canonical chain at depth ≥ 6. + +Step 5. User submits to a zkCoins server an IssuanceProof request: + - Their account state (initial, balance = 0) + - The Bitcoin LCP for MovetoVault + - The peg-in UTXO outpoint + - The non-inclusion proof against peg_in_consumed_smt + +Step 6. zkCoins server (or the user's own prover, in a more + decentralised future) generates the IssuanceProof: + - Verifies the Bitcoin LCP + - Verifies the deposit amount equals the requested mint + - Verifies the witness commitment binds the deposit to + this account + - Verifies non-inclusion in peg_in_consumed_smt and inserts + - Emits ProofData with the user's new account state + (balance = deposit_amount − bridge_fee) and the standard + commitment_history / coin_history fields + +Step 7. User signs the Schnorr commitment H(asth ‖ ocr) (same as any + send). User or their operator publishes the inscription. + Scanner picks up, state updates. + +Step 8. User now has zkCoins backed by the locked BTC. Total supply + increased by exactly the deposit amount. +``` + +### 5.3 Refund path + +If Step 2 doesn't happen within 200 blocks (e.g., federation offline +or unwilling to process this deposit), the user spends the deposit +back to themselves via the refund leaf. No interaction with zkCoins +needed. + +### 5.4 Failure modes + +| Failure | Recovery | +| ------- | -------- | +| Federation refuses to MovetoVault | Refund leaf after 200 blocks | +| Vault sweeps multiple deposits without proper mint authorisation | Pre-signing prevents this (vault can only spend via pre-signed paths) | +| User's LCP is forged or stale | Circuit re-verifies LCP from headers; forgery requires breaking PoW | +| Bitcoin reorg removes MovetoVault | LCP becomes invalid; user retries after deeper confirmation | +| zkCoins server malicious — refuses to generate IssuanceProof | User goes to another zkCoins server (server-side compute is replicable; any party with the protocol can mint). This requires multiple zkCoins servers to exist; currently single-server. | + +### 5.5 The "user pays an operator to mint" alternative + +The above puts proof generation on the user side (or their chosen +zkCoins server). A simpler MVP variant: the federation includes +zkCoins-server operators who automatically generate the IssuanceProof +when they see a confirmed MovetoVault. This is more centralised but +operationally simpler. Trade-off documented as open question §10. + +--- + +## 6. Detailed Flow B: Peg-Out (zkCoin → BTC) + +### 6.1 Pre-conditions + +- User has zkCoins they wish to redeem for BTC +- Vault has sufficient BTC inventory to fund the payout +- At least one operator is online and has sufficient liquid BTC to + front the payout + +### 6.2 Protocol steps + +``` +Step 1. User produces a BurnProof against their coin(s): + - Inputs: coin(s) to burn, valid inclusion proofs from + their source proofs + - Public outputs: ProofData { burn_amount, btc_recipient, + withdrawal_nonce, ... } + - The burn registers each coin in burned_coins_smt + +Step 2. User publishes the burn inscription (same `4242`-prefix + Taproot mechanism as a regular send). Scanner picks up, state + updates burned_coins_smt and registers the pending payout in + the bridge's pending_payouts queue. + +Step 3. User signs a Payout transaction template: + - Output: btc_recipient gets burn_amount − fees + - Input slot: SIGHASH_SINGLE|ANYONECANPAY, signed by user; + requires an operator to add their own funding input + User submits this template to the bridge. + +Step 4. Optimistic path (12-hour signer convening): + - Signers verify the BurnProof landed and pending_payouts + has the corresponding entry + - Signers collectively sign the Payout against the vault + - User receives BTC; vault is reduced + +Step 5. Operator-fronted path (if optimistic path stalls): + - An operator adds their UTXO as input, signs, broadcasts + - User receives BTC immediately + - Operator initiates reimbursement via KickOff + - Challenge window 1.5 days + - If no challenge: operator claims reimbursement from + vault + - If challenged: BitVM2 game decides; honest operator + wins, malicious one is slashed + +Step 6. Bridge marks the pending_payout as completed; the same + BurnProof cannot trigger another payout (replay protection + via withdrawal_nonce uniqueness in pending_payouts). +``` + +### 6.3 The BurnProof — circuit specifics + +The `BurnProof` branch in the circuit: + +- Asserts at least one input coin +- Asserts no output coins (or only a "change" output coin for the + amount minus burn) +- Asserts `burn_amount > 0` and `burn_amount ≤ sum_inputs` +- Asserts each burned coin's identifier is inserted into + `burned_coins_smt` +- Asserts `withdrawal_nonce` is a fresh value (e.g., random + field-element committed at burn time, never seen before in + `withdrawal_nonces_smt`) +- Emits `btc_recipient` as 20- or 32-byte Bitcoin address as a public + output field + +### 6.4 Failure modes + +| Failure | Recovery | +| ------- | -------- | +| User burns but signers/operators refuse to pay | Fraud — the BurnProof is on-chain (in zkCoins state), the user has a permanent record. After protocol-defined dispute window, governance recourse via federation slashing. Recommended: hard timeout — if 30 days without payout, the burn entry expires and can be re-issued as a fresh mint to the user (requires extra circuit branch, not in v1) | +| Operator double-claims reimbursement | KickOff replay protection — same Payout template can't be used twice; BitVM2 enforces | +| Operator fronts and is slashed for fraud | User already received their BTC (the Payout completed before challenge window); operator loses bond. Bridge is intact. | +| Vault doesn't have enough BTC | Pre-condition failure; bridge must reject burn requests above vault capacity, or queue them | + +--- + +## 7. Sequencing — What Comes Before What + +A realistic implementation sequence: + +| Phase | Item | Effort | Dependencies | +| ----- | ---- | ------ | ------------ | +| 0 | Plonky2 cutover complete (`feat/plonky2-migration` merged) | Already in progress | — | +| 0 | D2/D10 (hiding recipient) and D7 (reorg safety) closed | Pre-mainnet hardening, 2–3 weeks | — | +| 1 | Decide bridge model: BitVM2 vs Liquid-style federation | Strategy decision | — | +| 2a | Federation recruitment — ~5–7 independent organisations agree to participate | Org-level — months | Decision in Phase 1 | +| 2b | Trusted setup ceremony for Groth16 | 2–4 weeks elapsed, ~63 contributor invitations | 2a | +| 3 | Bitcoin Light Client gadget in circuit | 2–3 weeks | Phase 0 | +| 4 | `IssuanceProof` circuit branch | 2 weeks | Phase 0, Phase 3 | +| 5 | `BurnProof` circuit branch | 1–2 weeks | Phase 0 | +| 6 | Bridge server-side state (peg_in_consumed_smt, burned_coins_smt, pending_payouts) | 1 week | Phase 4, Phase 5 | +| 7 | Federation node software (signer + operator + watchtower roles) | 4–6 weeks | Phase 2a, Phase 6 | +| 8 | Integration testing with all federation members on signet | 2–4 weeks | Phase 7 | +| 9 | Mainnet launch | TBD | Phase 8 | + +**Aggregate effort:** 4–6 months engineering for the zkCoins-specific +code (Phases 3–6), plus 2–6 months for federation coordination and +trusted setup (Phases 2a–2b). Realistically 6–9 months elapsed time +to a credible mainnet bridge. + +This is **substantial** — comparable to Citrea's bridge timeline. It +also fundamentally changes zkCoins from a single-operator MVP into a +multi-party federated infrastructure project. + +--- + +## 8. Realistic Alternatives at Lower Cost + +Not every product needs full BitVM2. Three lower-cost alternatives, +ordered from most to least trust-minimised: + +### 8.1 Liquid-style federation (Liquid Network, Blockstream) + +A k-of-n multisig federation holds the BTC. Mints are authorised by +the federation's signing. No on-chain fraud proofs; trust is "honest +majority of federation". + +- **Trust model:** k-of-n (typically 11-of-15 for Liquid) +- **Effort:** weeks (just multisig + a side-chain mint authorisation + flow) +- **Trade-off:** explicitly trusts the federation majority; if k + members collude, BTC can be stolen + +This is **what a single-organisation issuer could realistically run +today** with existing infrastructure. It is **not** trustless in the +BitVM2 sense, but it is trust-distributed and well-understood by the +market. + +### 8.2 Optimistic bridge with permissionless challenge (no SNARK on Bitcoin) + +A 1-of-n optimistic bridge where withdrawals can be challenged for +a window, but the challenge mechanism is off-chain (challenger +publishes a fact and the federation slashes operators by +governance), not via Bitcoin script SNARK verification. + +- **Trust model:** 1-of-n honesty assumption, but recourse is + governance not cryptography +- **Effort:** 2–4 months +- **Trade-off:** cheaper than BitVM2 but legally/socially harder to + enforce slashing + +### 8.3 Federated peg with hardware-secured signers + +The k-of-n federation runs HSMs that enforce policy in firmware (e.g., +"only sign payouts that match a corresponding burn observed in the +side-chain state"). Adds hardware-level enforcement to 8.1. + +- **Trust model:** k-of-n federation + HSM vendor + firmware +- **Effort:** 1–3 months +- **Trade-off:** depends on HSM security, vendor trust + +### 8.4 Recommendation + +For a single-organisation-led zkCoins launch, **8.1 (Liquid-style) +is the realistic short-term path**. BitVM2 is the long-term +aspiration but requires federation recruitment and trusted setup +ceremony coordination that do not fit a self-funded single-org +timeline. + +The migration path is clean: a Liquid-style bridge in v2 can be +upgraded to a BitVM2 bridge in v3 by replacing the trust model at +the federation layer without changing the circuit's `IssuanceProof` +contract. + +--- + +## 9. Privacy Implications + +### 9.1 Peg-in observability + +The user's deposit on Bitcoin L1 is visible. Anyone watching the +bridge vault UTXO sees: + +- The deposit amount +- The user's Bitcoin address(es) used to fund +- The MovetoVault tx and its timing +- Eventually, the corresponding inscription on Bitcoin (via the + `4242` prefix) — even if the recipient address inside is hidden + (post-D2/D10), the temporal correlation of "deposit X confirmed + at time T, inscription Y appeared at time T+δ" is observable. + +This is **a privacy regression compared to a fully off-chain mint** +where the user could mint without Bitcoin L1 exposure. It is **a +privacy improvement compared to L1 BTC** (after the mint, all +subsequent zkCoins transfers are private off-chain). + +### 9.2 Peg-out observability + +Symmetric. The user's BTC withdrawal address is on L1. The temporal +correlation of "burn at time T, BTC arrives at user's address at time +T+δ" links the on-chain zkCoins burn with the destination address. + +### 9.3 Mitigations + +- **Stealth peg-in:** the witness commitment to the recipient address + in the deposit's Taproot leaf can use a hiding commitment with + per-deposit randomness. Bridge federation sees the commitment but + not the actual recipient address. This is a privacy gain only if + the recipient address is also hidden in the issued coin (i.e., D2 + is fixed). +- **Per-deposit fresh addresses:** the user uses a fresh Bitcoin + address for each deposit. Standard hygiene. +- **Coinjoin on peg-out:** the user mixes their burned BTC payout + with others via a separate coinjoin step after withdrawal. Adds + latency but breaks the on-chain link. + +### 9.4 Net assessment + +zkCoins-with-bridge has **less privacy than zkCoins-without-bridge** +(the bridge adds L1 touch points), but **more privacy than any other +BTC L2 with a bridge** because intra-zkCoins transfers remain fully +private off-chain. The privacy story is "BTC enters the shielded +zone, moves privately, BTC exits the shielded zone" — comparable to +Zcash's t/z address model. + +--- + +## 10. Open Questions + +1. **Who pays for proof generation in Phase 4–5?** Server-side + (zkCoins operator) is operationally simpler; user-side + (decentralised) is more trustless. Default: server-side for v1 + with a clear migration path to user-side later. + +2. **Federation size and composition.** Minimum credible: 5 + independent orgs. Target: 15+ for parity with Liquid. Who? Other + Swiss-regulated crypto entities, exchanges, custody providers, + academic institutions. This is mostly a business-development + question, not engineering. + +3. **Trusted setup ceremony logistics.** Coordinate with the BitVM + community for a shared SRS, or run a zkCoins-specific ceremony? + Citrea ran theirs because their predicate (RiscZero → Groth16) is + specific. zkCoins's predicate is also specific (Plonky2 verifier + wrapper → Groth16), so likely a dedicated ceremony — but the + ceremony tooling itself is reusable from Citrea's open-source + release. + +4. **Liquidity bootstrapping.** Operators need BTC inventory to front + peg-outs. Where does it come from? Self-funded by federation + members, with fee compensation. The initiating operator can + plausibly bootstrap with reasonable inventory before recruiting + further federation members. + +5. **Fee model.** Bridge fees per peg-in and peg-out. Should match + market rates (Liquid is 0% currently; Citrea has small fees). + Trade-off between user adoption and federation sustainability. + +6. **Audit-friendly accounting.** The bridge needs a public, real-time + view of "total BTC in vault" vs "total zkCoins outstanding" so any + user can verify the bridge is solvent. This is a side-chain + indexer feature, not a protocol feature, but it should ship at + launch to avoid trust-by-default concerns. + +7. **Plonky2 → Groth16 wrapping.** The BitVM2 verifier is Groth16. + The zkCoins predicate runs in Plonky2. There must be a wrapping + step: prove the Plonky2 verifier in Groth16, so Bitcoin can + verify the wrapped Groth16 proof via BitVM2. This wrapping step + is the same pattern Citrea uses (RiscZero → Groth16). Tooling + from `chainwayxyz/bitvm-zk-verifier` is the starting point. + +8. **What does "trustless" mean to our users?** The legal/compliance + framing matters. Even BitVM2 is "1-of-N honest" — not + "cryptographically impossible to cheat". Marketing-correctness + requires care. + +9. **Interaction with Lightning swap layer.** Once a bridge exists, + the swap design in `LIGHTNING_ATOMIC_SWAP.md` can be enhanced: + instead of an operator providing zkCoins liquidity from their own + inventory, the operator could trigger a fresh peg-in within the + swap flow. This reduces operator capital requirements but + increases per-swap latency (peg-in takes 33h refund window). + Likely worth modelling but not implementing. + +--- + +## 11. Comparison Tables + +### 11.1 Trust models compared + +| Model | Trust assumption | Slashing | Compute-on-Bitcoin | +| ----- | ---------------- | -------- | ------------------ | +| Today (D11) | 100% trust in the single operator-minter | None | None | +| Liquid-style federation | k-of-n federation honest majority | Off-chain governance | None | +| Optimistic + governance dispute | 1-of-n + governance recourse | Off-chain | None | +| BitVM2 / Clementine | 1-of-n setup honesty + 1-of-n watchtower | On-chain via Bitcoin Groth16 verifier (~2.6 MB Assert) | Yes (Groth16) | +| BitVM3 (cut-and-choose) | Same as BitVM2 + cut-and-choose security | On-chain via Garbled-Circuit Disprove (~60 kB Assert, ~200 B Disprove) | Yes (DV-SNARK / GC) | +| Glock (Alpen Labs) | Same as BitVM2 + cut-and-choose | On-chain DV-SNARK based Disprove (~5 kB Assert, 430–550× cheaper than BitVM2) | Yes (DV-SNARK / GC) | +| Mosaic (Eagen et al.) | Same as BitVM2 + cut-and-choose | On-chain footprint **independent of N** (cut-and-choose copies) via polynomial label correlation + adaptor sigs | Yes (DV-SNARK / GC) | +| Native Bitcoin (theoretical) | 0 trust | n/a | n/a | + +### 11.2 BitVM family + competing GC-based verifiers (state as of 2026-05) + +| Construction | Year | Status | Onchain dispute cost | Bridge deployed where | +| ------------ | ---- | ------ | -------------------- | --------------------- | +| BitVM1 | 2023-10 | Superseded | Very high (interactive multi-round) | Theoretical only | +| BitVM2 | 2024-08 | **Mainnet production** | ~2.6 MB Assert tx | Citrea Clementine (mainnet since 2026-01-27); GOAT (testnet V3 since 2026-01-28); Alpen Strata (signet, 10 BTC fixed denomination) | +| BitVM3-RSA | 2025-07 | **Withdrawn** — security flaw found by Eagen / Fairgate | ~60 kB Assert, ~200 B Disprove | None | +| BitVM3-CC (cut-and-choose) | 2026 | Research / early demo | ~$10.91 dispute on mainnet (BOB) | BOB roadmap | +| Glock (Alpen Labs) | 2025-08 | Research → testnet | 430–550× cheaper than BitVM2 (DV-SNARK based) | Strata bridge transition planned; Starknet partnership announced | +| Mosaic (Eagen et al.) | 2026-04 | Research, full protocol spec + Rust impl | On-chain footprint **independent of N copies** (polynomial label correlation) | None yet | + +**Reading guide:** + +- **For a launch today** (zkCoins or any other side-system): BitVM2 is + the only choice with a live, production-tested implementation + (Clementine). Citrea has been in mainnet since 2026-01-27. Tooling, + trusted setup ceremony output, and operational documentation all + exist. +- **For a launch in 6–12 months**: Glock and Mosaic both have credible + implementations and academic peer review going. Either could mature + to production status by then. Both are 100–1000× cheaper on-chain + than BitVM2 and use the same 1-of-N honesty trust model with + cut-and-choose security. +- **Avoid**: BitVM3-RSA (broken). Plain garbled-circuit constructions + without cut-and-choose (not malicious-secure). + +### 11.3 Realistic timelines + +| Target | Effort | Realistic launch | +| ------ | ------ | --------------- | +| Liquid-style federated bridge | 2–3 months | Q3–Q4 2026 | +| BitVM2 bridge (zkCoins-only federation) | 6–9 months | Q1 2027 | +| BitVM2 bridge (multi-org federation) | 9–18 months | Late 2027 | +| Glock-based bridge | depends on Glock production-readiness | Q2–Q4 2027 (if Glock stabilises) | +| Mosaic-based bridge | depends on Mosaic production-readiness | Q3 2027+ (still in research, full Rust impl exists) | + +--- + +## 12. Beyond BitVM2 — The 2026 Verification Landscape + +This section was added after the initial draft. It documents the +post-BitVM2 alternatives that emerged in 2025–2026 and explains why +the strategic recommendation in §13 (Bottom Line) still defaults to +BitVM2 today despite the alternatives being more efficient. + +### 12.1 What changed since BitVM2 + +BitVM2 (Linus et al., 2024-08) shipped as a Bitcoin-script Groth16 +verifier split into sub-programs small enough to fit individual +Bitcoin transactions. The Assert transaction — the on-chain message +where the operator commits to the intermediate computation states — +is roughly 2.6 MB. At Bitcoin's economic block space cost, this is +expensive but not prohibitive for high-value bridges where peg-out +volume can absorb the fee. + +Three follow-up constructions in 2025–2026 attack the Assert size +specifically by replacing the on-chain Groth16 verifier with a +garbled-circuit-based fraud-proof mechanism. The garbled circuit +itself is too large to put on Bitcoin directly, so the constructions +post commitments and use cut-and-choose + adaptor signatures to +ensure that revealing the on-chain signature also reveals enough +information to disprove a fraudulent claim. + +### 12.2 BitVM3 — RSA construction (2025-07) — **withdrawn** + +The first attempt to use garbled circuits on Bitcoin for bridges. The +original BitVM3 paper by Robin Linus proposed an RSA-based binding +between garbled-circuit labels and Bitcoin signatures. Achieved ~60 kB +Assert and ~200 B Disprove on paper. + +**Status:** withdrawn. Liam Eagen (later author of Glock) and Fairgate +Labs identified core security flaws in the RSA construction. The +paper was retracted. **Do not build on this.** + +Subsequent work continues under the BitVM3 banner using cut-and-choose +rather than the broken RSA binding — see BitVM3-CC by BOB and others. + +### 12.3 BitVM3-CC (cut-and-choose) — BOB implementation + +BOB's engineering team implemented a cut-and-choose variant of BitVM3 +on Bitcoin mainnet, using Verifiable Secret Sharing Schemes (VSSS) +and adaptor signatures. Demonstrates a ~$10.91 dispute cost on +mainnet, with the Assert transaction ~87% cheaper than BitVM2. + +The construction is essentially a more careful cousin of the original +BitVM3-RSA, replacing the broken RSA binding with multi-copy +cut-and-choose where the prover commits to N garbled-circuit copies +and the verifier opens t of them to check honest garbling. + +**Status:** demonstrated on mainnet by BOB, but the broader +"BitVM3-CC" research line is still maturing. Reasonable to track as +a credible 2027 alternative but too early to commit to. + +### 12.4 Glock — Garbled Locks for Bitcoin (Alpen Labs, 2025-08) + +**Paper:** [eprint 2025/1485](https://eprint.iacr.org/2025/1485), +Liam Eagen, Alpen Labs. + +**Core idea.** Combine garbled circuits with a novel and very compact +**designated-verifier SNARK (DV-SNARK)** to bind the garbled circuit's +input/output labels directly to Bitcoin lock data. The DV-SNARK is +constructed over binary elliptic curves, which Eagen argues have +"nice synergies" with the garbled-circuit scheme. + +**Why it's smaller than BitVM2.** BitVM2 verifies Groth16, which is a +universal SNARK and requires a verifier circuit of fixed substantial +size when reduced to Bitcoin script. Glock's DV-SNARK is designed for +a single specified verifier (the bridge contract), so it's +specialised and much smaller. Combined with garbled-circuit fraud +proofs, total on-chain footprint per dispute is in the few-kB range +rather than MB. + +**Architecture sketch.** + +- **Setup:** federation runs cut-and-choose for honesty of N garbled + copies. t copies are opened publicly to prove honesty; the + remaining N-t are used for actual fraud-proof evaluation. +- **Operator commits to a peg-out claim** by posting a signature + whose adaptor binds to a label of the garbled circuit. +- **If the claim is wrong:** the corresponding garbled-circuit + evaluation reveals a secret that lets anyone reconstruct a "fraud + signature" — punishing the operator on-chain. +- **If the claim is right:** no further on-chain interaction needed; + the operator's adaptor signature settles after timelock. + +**Efficiency.** Claimed 430–550× on-chain efficiency over BitVM2. +Concretely: instead of a 2.6 MB Assert transaction, Glock's +disprove-related on-chain footprint is in the few-kB range. + +**Status (2026-05).** + +- Paper published August 2025 +- Alpen Labs is building Glock into their Strata bridge as the + successor to the current BitVM2-based Strata bridge implementation +- Starknet announced a strategic partnership with Alpen Labs in + October 2025 to use Glock as Starknet's BTC bridge primitive +- **No mainnet deployment yet.** Strata's BitVM2 bridge runs on + Bitcoin signet only as of 2026-05; Glock transition is on the + roadmap, not live. +- Research is active and the academic peer-review pipeline + is moving — multiple follow-up papers (Mosaic, Argo) build on or + refine Glock's primitives. + +**What this means for zkCoins.** Glock is the **most attractive 2026 +alternative** to BitVM2 if zkCoins is willing to wait. Its 1-of-N +trust model is identical to BitVM2's; its on-chain cost is 100–1000× +lower; and the construction is by the same team that wrote the +Shielded CSV paper (Eagen, Linus). The fit is essentially perfect. + +The risk: it has not yet been deployed on mainnet by anyone. Glock +**does require a circuit-specific trusted setup** — its DV-SNARK is +instantiated with Pari (Eagen et al., eprint 2024/1245), and the +Pari paper states explicitly: *"Pari requires a circuit-specific +trusted setup, but the relevant prior work (namely, Groth16) also +requires such a setup."* So the setup-coordination burden is +comparable to BitVM2/Groth16, not eliminated. The advantage of +Glock over BitVM2 is on-chain efficiency and proof size (Pari is +the smallest known SNARK at 160 bytes), not setup transparency. + +### 12.5 Mosaic — Practical Malicious Security for Garbled Circuits on Bitcoin (Eagen et al., 2026-04) + +**Paper:** [eprint 2026/812](https://eprint.iacr.org/2026/812), +Khambhati, Tiwari, Bajracharya, Bista, Eagen, Lewe, Feickert. + +**Core idea.** Where Glock uses DV-SNARKs to achieve compactness, +Mosaic stays with traditional Groth16 verifier circuit but achieves +malicious security via **cut-and-choose with polynomial label +correlation**. The trick: labels across all N garbled copies are +arranged as evaluations of a degree-t polynomial. The t shares +revealed during cut-and-choose fall one short of the reconstruction +threshold. Adaptor signatures ensure that the prover's on-chain +witness commitment reveals the missing share as a byproduct. The +evaluator can then reconstruct labels for all unchallenged copies by +interpolation. + +**Killer feature.** The on-chain footprint is **independent of N** +(the number of garbled copies used for cut-and-choose). Other +cut-and-choose constructions need to post per-copy data on-chain +that scales with N. Mosaic eliminates this scaling. + +**Practical.** Full protocol specification, Rust implementation, +instantiated for trust-minimized Bitcoin bridging with a Groth16 +verifier circuit. + +**Status (2026-05).** + +- Paper published April 2026 +- Rust implementation exists (open-source per paper) +- No production deployment yet +- Same author family as Glock and Shielded CSV (Eagen) +- Cleanly compatible with the existing Groth16-verifier ecosystem + (Plonky2 → Groth16 wrapping pipeline that Citrea uses works + unchanged) + +**What this means for zkCoins.** Mosaic is **the cleanest drop-in +replacement** for BitVM2 because it keeps Groth16 as the verifier and +therefore reuses the entire BitVM2 toolchain (trusted setup ceremony, +Groth16 prover tools, `chainwayxyz/bitvm-zk-verifier`). It just cuts +the Assert transaction footprint by a large factor. + +The risk: it's the youngest of the three (April 2026 paper). Has not +seen the same testnet hours as Glock or production hours as BitVM2. + +### 12.6 Production state of major BitVM bridges (2026-05) + +| Bridge | Side-system | Construction | Status | +| ------ | ----------- | ------------ | ------ | +| Clementine | Citrea | BitVM2 | **Mainnet since 2026-01-27** | +| GOAT Network bridge | GOAT Network | BitVM2 variant | **Testnet V3 since 2026-01-28** (permissionless-exit-first design) | +| Strata bridge | Alpen | BitVM2 (Glock transition planned) | **Signet only**, 10 BTC fixed denomination, 64-block operator timeout, 36-block challenge | +| BOB bridge | BOB | BitVM3-CC | Mainnet demo (cost-reduction proof of concept) | +| Bitlayer bridge | Bitlayer | BitVM2 variant | Mainnet | + +**Reading guide.** As of May 2026, **only BitVM2 (and direct variants) +have any mainnet exposure**. Everything garbled-circuit-based — +BitVM3-CC, Glock, Mosaic — is at most demo or testnet. This will +likely change over Q3–Q4 2026 as Strata and BOB push their Glock / +BitVM3-CC bridges toward mainnet. + +### 12.7 Strategic implication for zkCoins + +If we were starting bridge implementation **today**: + +- BitVM2 / Clementine fork. Battle-tested, mainnet-proven, with + reusable trusted setup output. Trade-off: 2.6 MB Assert tx (~$60–200 + at common fee rates). + +If we were starting bridge implementation **in Q3–Q4 2026**: + +- Wait for Strata's Glock transition or BOB's BitVM3-CC mainnet + hardening, then fork from there. Trade-off: more time before + zkCoins has a bridge, much cheaper on-chain dispute resolution. + +If we want to **hedge**: + +- Implement against an abstract "garbled-bridge-verifier" trait, with + BitVM2 as the v1 implementation and Glock/Mosaic as drop-in + replacements when one of them stabilises. The circuit-side + `IssuanceProof` and `BurnProof` contracts (§4) are identical in + any case — only the off-circuit Bitcoin scripting changes. + +The hedge is probably the right answer if implementation does not +have to start this quarter. If implementation must start now and +mainnet within a year, BitVM2 is forced. + +### 12.8 The "BTC denomination" question + +A practical note often overlooked: BitVM-family bridges typically +require **fixed-denomination deposits** because the pre-signed +transaction graph is parameterised on the deposit amount. Strata +uses 10 BTC fixed denomination on testnet; Citrea uses similar +quantisation on mainnet. + +For zkCoins, this means peg-ins would come in fixed chunks (e.g., +0.1 BTC, 1 BTC, 10 BTC) rather than arbitrary amounts. Users wanting +smaller amounts would peg in 0.1 BTC and split internally; users +wanting larger amounts would peg in multiple chunks. + +This is a UX consideration, not a protocol constraint. The Lightning +swap design (`LIGHTNING_ATOMIC_SWAP.md`) is unaffected — it operates +on arbitrary amounts because it consumes/produces zkCoins state +which has no minimum increment. + +--- + +## 13. Bottom Line + +- **D11 is the biggest unaddressed trust gap in zkCoins.** It is more + significant than D2 (recipient hiding), D7 (reorg safety), or D8 + (per-coin nullifier) for an end-user-trust perspective. A user can + tolerate a small privacy gap or a small reorg-safety gap; they + cannot tolerate "the issuer can print unlimited supply". + +- **BitVM2 / Clementine is the only mainnet-deployed trustless bridge + as of 2026-05.** Citrea has been live since 2026-01-27. Tooling, + trusted setup ceremony output, and operational documentation all + exist. If a bridge must ship within 12 months, this is the only + feasible cryptographic option. + +- **Glock and Mosaic are the credible 2026 successors** (both authored + by the Eagen line of researchers, same family as Shielded CSV + itself). Glock is the 430–550× more efficient alternative using + DV-SNARKs (Alpen Labs, Strata bridge transition planned); Mosaic + keeps Groth16 but cuts on-chain footprint independently of N + cut-and-choose copies (April 2026 paper with Rust impl). Neither + has mainnet exposure yet. See §12 for the full landscape. + +- **BitVM3-RSA was withdrawn** after security flaws were identified + by Eagen / Fairgate. The "BitVM3" name continues under the BitVM3-CC + (cut-and-choose) variant, which is what BOB demonstrated on mainnet. + +- **The realistic short-term path is a Liquid-style federated + bridge.** It is implementable in months, provides meaningful + trust distribution, and can be upgraded to BitVM2 / Glock / Mosaic + later without protocol-layer changes — the `IssuanceProof` and + `BurnProof` circuit contracts (§4) are agnostic to the bridge + construction. + +- **The realistic 1-year cryptographic path is BitVM2.** Federation + recruitment and trusted setup ceremony coordination are the + bottleneck, not engineering. + +- **The realistic 2-year cryptographic path is Glock or Mosaic.** If + bridge implementation can wait into 2027, the on-chain efficiency + upgrade is worth the wait. The hedge: build the circuit side now, + pick the verifier construction when one of Glock/Mosaic has 6+ + months of testnet history. + +- **`LIGHTNING_ATOMIC_SWAP.md` is unaffected.** The swap design's + mathematical atomicity holds regardless of how mints work. What + changes is the supply-side honesty of the underlying asset. + +- **D11 fix belongs in the pre-mainnet hardening block of `ROADMAP.md`.** + Currently it is not listed there. This is a documentation gap that + should be corrected. + +- **Federation target: N=100 independent members.** The MVP runs with + N=3 (same data centre, all operated by a single organisation — + engineering correctness only, not real trust distribution). The + production target is + N=100, the practical upper bound of the BitVM2 framework today per + Bitlayer's analysis (*"in practice the value of n can be 100"*). + Strict 1-of-N honesty: 1 honest key deletion among 100 independent + setup members suffices. Going beyond N=100 is open research + (Bitlayer: *"It is necessary to research a permissionless + multi-party OP challenge protocol that could expand BitVM's + existing 1-of-n trust model to 1-of-N, where N is much larger + than n"*) and not a current goal. Federation-member recruitment + to N=100 is business-development, not engineering. Intermediate + milestones expected: N=10 → N=30 → N=100. See `BRIDGE_MVP.md` §2.2. + +--- + +## 14. References + +### BitVM2 and Clementine (production-grade) +- [BitVM2 paper (Linus, Aumayr, Avarikioti, Maffei, Moreno-Sanchez, eprint 2025/1158)](https://eprint.iacr.org/2025/1158.pdf) +- [BitVM2 site](https://bitvm.org/bitvm2.html) +- [Citrea Clementine bridge docs](https://docs.citrea.xyz/essentials/clementine-trust-minimized-bitcoin-bridge) +- [Citrea Risc0-to-BitVM Trusted Setup Ceremony announcement](https://www.blog.citrea.xyz/citrea-completes-the-first-ever-trusted-setup-ceremony-for-zk-proofs-used-in-bitvm/) +- [BitVM Groth16 Verifier Toolkit (chainwayxyz)](https://github.com/chainwayxyz/bitvm-zk-verifier) +- [BitVM GitHub org](https://github.com/BitVM/BitVM) +- [Fairgate review of BitVM2 Linus24 bridge](https://www.fairgate.io/post/3-a-review-of-the-the-bitvm2-based-linus24-bridge) +- [Bitlayer BitVM bridge analysis](https://blog.bitlayer.org/BitVM_Bridge_Becomes_Practical/) + +### BitVM3 and cut-and-choose successors +- [BitVM3 paper (eprint 2026/933)](https://eprint.iacr.org/2026/933.pdf) — includes both withdrawn RSA construction and cut-and-choose variants +- [BOB BitVM3 cut-and-choose announcement](https://www.gobob.xyz/blog/bob-lowers-onchain-costs-for-bitvm3) +- [Fairgate Computing on Bitcoin newsletter](https://www.fairgate.io/newsletter/) — ongoing coverage + +### Glock (Alpen Labs) +- [Glock: Garbled Locks for Bitcoin (Eagen, eprint 2025/1485)](https://eprint.iacr.org/2025/1485) +- [Glock paper PDF mirror (Alpen)](https://cdn.prod.website-files.com/67cfca80708eb505376820af/68a3e174eaff71d197ac4080_glock.pdf) +- [Glock: A new standard for verification on Bitcoin (Alpen blog)](https://www.alpenlabs.io/blog/glock-verification-on-bitcoin) +- [Efficient verifiable cut-and-choose for Glock (Alpen HackMD)](https://hackmd.io/@alpen/B1QfSSO5gg) +- [Starknet × Alpen partnership announcement (Glock as Starknet BTC bridge)](https://www.starknet.io/blog/starknet-alpen-bitcoin-glock/) +- [Strata bridge docs (currently BitVM2)](https://docs.alpenlabs.io/how-alpen-works/bitcoin-bridge) + +### Mosaic +- [Mosaic: Practical Malicious Security for Garbled Circuits on Bitcoin (eprint 2026/812)](https://eprint.iacr.org/2026/812) + +### Survey / market context +- [Bitcoin L2s in 2026: A Reality Check (hozk.io)](https://www.hozk.io/articles/bitcoin-l2s-in-2026-a-reality-check) +- [State of Bitcoin: BitVM3, Glock & Bitcoin Dollar (Bitfinity)](https://www.blog.bitfinity.network/state-of-bitcoin-bitvm3-glock-bitcoin-dollar/) + +### Shielded CSV / zkCoins context +- [Shielded CSV paper §"Issuance" predicate branch](https://eprint.iacr.org/2025/068) +- `SPEC.md` §15 D11 — this repo +- `MIGRATION_RESEARCH.md` §5.6 — self-funded MVP publisher + +--- + +## 15. Change Log + +| Date | Change | +| ---- | ------ | +| 2026-05-17 | Initial draft. | +| 2026-05-17 | Add §12 "Beyond BitVM2 — 2026 Verification Landscape" covering BitVM3-RSA withdrawal, BitVM3-CC (BOB), Glock (Alpen Labs), Mosaic (Eagen et al.). Update §3 with 2026-landscape note. Update §11.1 / §11.2 / §11.3 comparison tables. Update §13 Bottom Line with hedging strategy. Refactor references into themed groups. | +| 2026-05-17 | §13 Bottom Line: add explicit production federation target of N=100 (practical upper bound of BitVM2 framework per Bitlayer). Beyond N=100 noted as open research, not current goal. | +| 2026-05-17 | Consistency audit pass: §12.4 — correct the Glock trusted-setup claim (Glock's DV-SNARK is instantiated with Pari which requires a circuit-specific trusted setup, comparable to Groth16; the previous "the DV-SNARK might not require a setup" wording was wrong). Add a branch note at the top explaining that `SPEC.md` / `MIGRATION_RESEARCH.md` / `ROADMAP.md` currently live on `feat/plonky2-migration` only. | +| 2026-05-17 | Audit round 2: §6.2 Step 1 — fix proof-name inconsistency ("WithdrawalProof" was a one-off term; renamed to `BurnProof` consistent with §6.3 and §4.1) and correct the §5.2 cross-reference to §6.3. | +| 2026-05-17 | Audit round 3: harmonise header structure (Status / Authoritative source / Audience / Branch note). Remove organisation-specific "DFX" references in §4.5, §8.1, §8.4, §10.4, §11.1, and §13 — replaced with generic operator/issuer wording for consistency with the rest of the repo. | diff --git a/BRIDGE_MVP.md b/BRIDGE_MVP.md new file mode 100644 index 00000000..155bfcd5 --- /dev/null +++ b/BRIDGE_MVP.md @@ -0,0 +1,1011 @@ +# Bridge MVP — Engineering Spec + +**Status:** Engineering specification. No code yet. Companion to +[`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) (strategy / landscape) and +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) (LN swap layer). + +**Authoritative source for:** the MVP scope, the locked technical +decisions, the implementation order, the test plan, and the +non-goals. + +**Audience:** The engineers implementing the MVP. This is the +file-by-file, phase-by-phase plan; it presupposes the strategic +decisions made in `BITVM_BRIDGE.md` §12–§13. + +> **Branch note.** This document presupposes the Plonky2 migration +> currently on `feat/plonky2-migration` (PR #17). `SPEC.md`, +> `MIGRATION_RESEARCH.md`, and `ROADMAP.md` live on that branch and +> will resolve on `develop` only after PR #17 lands. Until then, view +> cross-references against `feat/plonky2-migration`. + +--- + +## 1. Scope + +This document specifies the **MVP engineering plan** for a trustless +BTC ↔ zkCoins bridge. It covers: + +- The MVP definition (what's in, what's deferred) +- Three locked technical decisions +- An eight-phase implementation plan, file-by-file +- The test plan per phase +- A risk register +- Open implementation questions + +**MVP goal:** the *technology* is built. The federation is initially +**3 nodes in the same data centre, all operated by a single +organisation**. This proves the cryptographic and protocol-level +correctness of the bridge mechanism. The same code, with a 5–15 +node federation of independent organisations, becomes a real +trustless bridge — that deployment is a separate operational +concern, not an engineering one. + +It does **not** cover: + +- Federation member recruitment (business-development; out of scope) +- Production hardening beyond the 100%-coverage MVP gate +- Operational runbooks for federation operators +- BitVM3 / Glock / Mosaic implementations (deferred per + `BITVM_BRIDGE.md` §13 hedging strategy) + +--- + +## 2. MVP Definition + +Per `feedback_zkcoins_mvp_definition`, MVP means **minimal feature +surface** AND **100% test coverage on the activated surface**, both +non-negotiable. + +### 2.1 In scope + +- **Peg-in flow:** user deposits BTC, receives a freshly minted + zkCoin to a specified `recipient` address +- **Peg-out flow:** user burns a zkCoin, receives BTC to a specified + L1 address, fronted by an operator with later reimbursement +- **N-of-N MuSig2 federation** with N=3 nodes (configurable; tested + with N=3 in MVP) +- **Cooperative key-path spending** for the funded vault UTXO when + all signers cooperate (most peg-ins) +- **Operator-fronted payouts** with KickOff / Challenge / + Assert / Disprove state machine +- **Bitcoin Light Client gadget** for verifying that a deposit is in + the canonical chain at depth ≥ 6 +- **Fraud-proof game** (BitVM2-style) — full implementation, even if + in MVP the only adversary is a test fixture +- **End-to-end integration test** on Bitcoin signet (preferable to + regtest because of more realistic block timing; regtest is + fallback) + +### 2.2 Deferred + +- Glock / Mosaic backends (after Plonky2 → Groth16 wrapping is solid + for BitVM2, swap is mechanical) +- BTC denomination flexibility (MVP: fixed denominations e.g. + 0.01 BTC, 0.1 BTC, 1 BTC) +- Watchtower payment incentives (MVP: watchtowers are part of the + 3-node federation, paid out-of-band) +- Multi-coin peg-outs in a single burn (MVP: one burn per peg-out) +- Production trusted setup ceremony (MVP: single-contributor SRS + marked "DO NOT USE IN PRODUCTION") +- **Federation scaling beyond N=3.** Target federation size for the + production bridge is **N=100 independent members** with a 1-of-N + setup-honesty assumption (1 honest key deletion suffices). N=100 + is the practical upper bound of BitVM2's framework today per + Bitlayer's analysis (*"in practice the value of n can be 100"*). + Beyond N=100 is open research and not a current goal. Intermediate + milestones expected: N=10 → N=30 → N=100. Each step is a separate + setup ceremony with all new members. Federation-member recruitment + is a business-development concern, not engineering, and out of MVP + scope. + +### 2.3 Out of scope (post-MVP, may need separate spec) + +- Liquid-style federated bridge as interim before BitVM2 +- Bridge upgrade to Glock or Mosaic +- Cross-bridge interoperability (peg-out from this bridge to peg-in + to another) +- Privacy upgrades for peg-in / peg-out (the user's L1 BTC address + is visible by construction; mitigations in `BITVM_BRIDGE.md` §9.3 + are out of MVP scope) + +--- + +## 3. Locked Technical Decisions + +These are fixed for v1. Reversing any of them means a non-trivial +re-design. + +### 3.1 Bridge construction: BitVM2 (Citrea-Clementine style) + +- Mainnet-deployed (Citrea since 2026-01-27) +- Reusable tooling (`chainwayxyz/bitvm-zk-verifier`) +- 1-of-N honesty trust model +- Trade-off: ~2.6 MB Assert transaction, vs. 5 kB with Glock + +Glock and Mosaic are **explicitly deferred** to a future bridge-version-2. +The MVP abstracts the verifier behind a trait so that switching is a +later config change. + +### 3.2 Bitcoin Light Client: recursive Plonky2 sub-proof + +A separate Plonky2 circuit verifies a chain of Bitcoin headers +(SHA256d + target-bits) and outputs `(tip_hash, cumulative_work)`. The +`IssuanceProof` branch then **recursively verifies** that +light-client proof and asserts that a specific UTXO (txid, vout, amount) +is in a block whose header is part of the verified chain at depth +≥ 6. + +This is preferred over inlining SHA256d directly into the `IssuanceProof` +circuit because: + +- SHA256d in Plonky2 ≈ 262k gates per hash; 6 confirms ≈ 3M gates + extra per IssuanceProof — sub-second budget broken +- Recursive verification cost is approximately constant once + warmed up +- The light-client sub-proof is reusable for other future use cases + (e.g., zkCoins-side observation of arbitrary Bitcoin events) + +### 3.3 Trusted setup for Groth16 wrapping: single-contributor SRS for MVP + +- The Plonky2 → Groth16 wrapper requires a Groth16 trusted setup +- For MVP with N=3 single-operator nodes, a single-contributor SRS + is acceptable: every node already trusts the others (same operator) +- The SRS file is committed to the repo with a clear marker: + ``` + ⚠️ DO NOT USE IN PRODUCTION + This SRS was generated by a single contributor for MVP testing. + Replace before any multi-organisation federation deployment. + ``` +- Replacement: ~30–60 contributor ceremony before the first real + federation deployment. Tooling reused from Citrea's open-source + ceremony software. + +--- + +## 4. Phase 1 — Circuit Extension (`IssuanceProof` + `BurnProof`) + +### 4.1 Goal + +Add two new `ProofType` variants to the state-transition circuit, +implementing the Shielded-CSV-paper-aligned `issuance(IssuanceProof)` +and the new `BurnProof` branches. + +### 4.2 Files touched + +| File | Change | +| ---- | ------ | +| `program-plonky2/src/types.rs` | Extend `ProofType` enum with `Issuance` and `Burn` variants; extend `ProofData` with optional fields for issuance/burn metadata | +| `program-plonky2/src/inputs.rs` | Extend `ProgramInputs` with `peg_in_witness: Option` and `burn_witness: Option` fields | +| `program-plonky2/src/circuit/issuance.rs` | **new** — `IssuanceProof` circuit branch | +| `program-plonky2/src/circuit/burn.rs` | **new** — `BurnProof` circuit branch | +| `program-plonky2/src/circuit/main.rs` | Extend `conditionally_verify_cyclic_proof_or_dummy` dispatch to handle Initial / AccountUpdate / Issuance / Burn | +| `program-plonky2/src/circuit/mod.rs` | Wire in new modules | + +### 4.3 IssuanceProof predicate + +The circuit asserts: + +``` +Given: + account_state: AccountState (new account, owner = recipient address) + peg_in_witness: PegInWitness { lcp_proof, utxo_outpoint, amount, recipient_commitment } + prev_peg_in_consumed_root: HashDigest + new_peg_in_consumed_root: HashDigest + non_inclusion_proof: NonInclusionProof of peg-in into peg_in_consumed_smt + +Asserts: + 1. lcp_proof.verify(verifier_data_bitcoin_lcp) — recursive Plonky2 verify + of the Bitcoin Light Client sub-proof + 2. utxo_outpoint is included in lcp_proof.confirmed_utxos at depth ≥ 6 + 3. peg_in_witness.amount equals the UTXO's amount + 4. peg_in_witness.recipient_commitment matches the user's intended + zkCoins address (binding: witness commitment in the Taproot leaf + of the deposit script hashes to recipient_commitment) + 5. account_state.balance == amount − bridge_fee_constant + 6. account_state.owner == recipient_commitment.address + 7. non_inclusion_proof.verify(utxo_outpoint, prev_peg_in_consumed_root) + 8. non_inclusion_proof.insert(utxo_outpoint) == new_peg_in_consumed_root + 9. Emit ProofData with new state and the new peg_in_consumed_root + +Result: a new account with the deposit amount minus fees, provably +backed by a confirmed on-chain UTXO that cannot be reused. +``` + +### 4.4 BurnProof predicate + +``` +Given: + account_state: AccountState (existing account, has coins) + in_coins: Vec (coins being burned; sum_amount = burn_amount) + in_coins_inclusion_proofs: inclusion proofs for each in_coin + in_coins_history_proofs: same as for normal AccountUpdate + burn_witness: BurnWitness { btc_recipient_address, withdrawal_nonce } + prev_burned_coins_root: HashDigest + new_burned_coins_root: HashDigest + burn_insert_proofs: NonInclusionProof per in_coin into burned_coins_smt + +Asserts: + 1. Each in_coin is verified the same way as in AccountUpdate + (source-proof inclusion, history-root containment, coin-history + non-inclusion + insert) + 2. account_state.balance is decremented by sum(in_coin.amount) using + checked_sub + 3. burn_witness.withdrawal_nonce is fresh (not in withdrawal_nonces_smt; + inserted as part of this proof — or alternatively: nonce is the + hash of the burn proof's public values, deterministic uniqueness) + 4. Each in_coin.identifier is inserted into burned_coins_smt via + burn_insert_proofs, producing new_burned_coins_root + 5. No new out_coins are created + 6. account_state.public_key is rotated to next_public_key (same as + normal send) + 7. Emit ProofData including burn_amount, btc_recipient, and + withdrawal_nonce as part of public values + +Result: the coins are consumed; the bridge can use the public output +to construct a Bitcoin Payout transaction to the burner. +``` + +### 4.5 New types + +```rust +// program-plonky2/src/types.rs additions + +pub enum ProofType { + InitialProof, + AccountUpdateProof, + IssuanceProof, // NEW + BurnProof, // NEW +} + +pub struct PegInWitness { + pub lcp_proof: Plonky2ProofTarget, // recursive LCP proof + pub utxo_txid: HashDigest, + pub utxo_vout: u32, + pub utxo_amount: u64, + pub recipient_commitment: RecipientCommitment, +} + +pub struct RecipientCommitment { + pub address: Address, // = H(initial_pubkey) + pub randomness: HashDigest, // hiding commitment randomness; even + // for plaintext-recipient MVP we + // carry this for forward-compat + // with D2/D10 +} + +pub struct BurnWitness { + pub btc_recipient_address: [u8; 32], // Bitcoin address (Taproot) + pub withdrawal_nonce: HashDigest, +} +``` + +### 4.6 Test plan (Phase 1) + +Per `feedback_zkcoins_mvp_definition`, 100% coverage gate applies. + +Positive: +- **IssuanceProof base case:** valid LCP, valid UTXO, fresh + non-inclusion → proof accepts; ProofData contains new state with + amount − fee. +- **IssuanceProof for second user:** second peg-in to a different + account with a different UTXO → still accepts, peg_in_consumed_smt + grows correctly. +- **BurnProof single coin:** burn one input coin → accepts; output + has zero out_coins; account.balance decremented; coin in + burned_coins_smt. +- **BurnProof multiple coins:** burn two input coins summing to + burn_amount → accepts; both in burned_coins_smt. +- **IssuanceProof then BurnProof for same account:** full mint → burn + cycle. + +Negative (each is a separate test, must assert `data.prove(pw).is_err()`): +- **IssuanceProof with invalid LCP:** rejected. +- **IssuanceProof with UTXO at depth < 6:** rejected. +- **IssuanceProof with amount mismatch:** account claims amount ≠ UTXO + amount → rejected. +- **IssuanceProof with recipient mismatch:** account.owner ≠ + recipient_commitment.address → rejected. +- **IssuanceProof reusing a peg-in:** second IssuanceProof with same + utxo_outpoint → non-inclusion check fails → rejected. +- **BurnProof with wrong coin source:** in_coin not in source's + out_coins_root → rejected. +- **BurnProof with double-burn:** burn the same coin twice → second + attempt's insert into burned_coins_smt fails → rejected. +- **BurnProof with wrong balance update:** account.balance not + decremented correctly → rejected. + +Estimated effort: **3–4 weeks**, risk medium (first time defining +new ProofType variants; recursive LCP verification needs Phase 2 +to be at least partially done). + +--- + +## 5. Phase 2 — Bitcoin Light Client Gadget + +### 5.1 Goal + +A Plonky2 circuit that, given a chain of Bitcoin block headers, +verifies that: + +- Each header's hash satisfies its target (proof-of-work valid) +- Each header chains correctly to the previous one (prev_block_hash + match) +- The cumulative work is computed correctly +- A claimed UTXO is included in a transaction in one of the headers + via Merkle proof against the header's `merkle_root` + +### 5.2 Files touched + +| File | Change | +| ---- | ------ | +| `program-plonky2/src/circuit/lcp/mod.rs` | **new** — light client proof module | +| `program-plonky2/src/circuit/lcp/header.rs` | **new** — single-header verify (SHA256d + target) | +| `program-plonky2/src/circuit/lcp/chain.rs` | **new** — multi-header chain verify with cumulative work | +| `program-plonky2/src/circuit/lcp/spv.rs` | **new** — SPV/Merkle inclusion of a tx in a block | +| `program-plonky2/src/circuit/lcp/main.rs` | **new** — top-level LCP circuit; outputs (tip_hash, cumulative_work, confirmed_utxos_root) | +| `program-plonky2/src/circuit/sha256.rs` | **new** — Plonky2 SHA256 gadget (or import from polymerdao/plonky2-sha256) | + +### 5.3 SHA256 gadget — buy or build + +**Option A: import [polymerdao/plonky2-sha256](https://github.com/polymerdao/plonky2-sha256).** + +- Pros: existing implementation, known working +- Cons: dependency on a third-party crate; older Plonky2 version + (0.2.0, our codebase is on 1.1.0); ~262k gates per hash +- Action: fork into our tree, upgrade to 1.1.0, vendor as a sub-module + +**Option B: write our own.** + +- Pros: full control, matches our coverage standards +- Cons: 1–2 weeks of high-precision arithmetic-circuit work; SHA256 + bit-twiddling is error-prone +- Action: only if Option A's upgrade to 1.1.0 turns out to be > 1 week + +→ **Default: Option A.** Fork to `program-plonky2/src/circuit/sha256/` + and upgrade in-place. + +### 5.4 LCP public output + +```rust +pub struct LCPPublicValues { + pub tip_block_hash: HashDigest, + pub cumulative_work: [u32; 8], // 256-bit big-int + pub starting_block_hash: HashDigest, // genesis or last-checkpoint + pub confirmed_utxos_root: HashDigest, // Merkle root of all UTXOs + // proven via SPV in this proof +} +``` + +The `confirmed_utxos_root` is the SMT root of all UTXOs the LCP claims +are confirmed. When the `IssuanceProof` recursively verifies the LCP, +it checks one specific UTXO's inclusion in this root. + +### 5.5 Block-batch sizing + +Naïve LCP verifies the full Bitcoin chain from genesis on every +issuance — infeasible (~750k blocks as of 2026). Real solutions: + +- **Checkpointed LCP:** the circuit starts from a hard-coded + checkpoint block hash, verifies only blocks since the checkpoint. + Checkpoint updated by federation governance periodically. +- **Recursive accumulating LCP:** each LCP proof verifies the previous + LCP proof and extends it. The "tip" of the chain advances as new + blocks come in. New peg-ins use the current LCP proof. + +→ **MVP: checkpointed LCP.** The checkpoint is updated weekly by +the bridge operator; this is acceptable because the bridge trusts +its own operator to advance the checkpoint, not for security but +for liveness. Security comes from the SHA256d/target verification +covering all post-checkpoint blocks. + +### 5.6 Test plan (Phase 2) + +Positive: +- **Single block:** verify one valid header → accepts; cumulative + work matches expected. +- **Chain of 6 blocks:** verify a sequence; tip_hash and + cumulative_work computed correctly. +- **SPV inclusion:** verify a tx is in a block's Merkle tree. +- **Recursive LCP:** prove LCP_1, then prove LCP_2 = LCP_1 + + extension; the recursive proof accepts. + +Negative: +- **Invalid PoW:** header hash > target → rejected. +- **Broken chain:** header[N].prev_block_hash ≠ hash(header[N−1]) → + rejected. +- **Wrong cumulative work:** off-by-one error in difficulty + accumulation → rejected. +- **Wrong SPV:** Merkle proof with wrong sibling → rejected. + +Estimated effort: **3–5 weeks**, risk **high** for two reasons: + +- SHA256d performance in Plonky2 — if proving time blows up despite + recursive sub-proofs, we may need to look at Plonky3 (Poseidon2 is + also faster but doesn't help with SHA256d; the only mitigation is + a smaller block batch per recursive step) +- First time integrating an external proof system component (SHA256 + gadget) — version compatibility risk + +--- + +## 6. Phase 3 — State Extension + +### 6.1 Goal + +Extend `node::state::State` to track peg-in consumption, +burn records, and pending payouts. + +### 6.2 Files touched + +| File | Change | +| ---- | ------ | +| `node/src/state.rs` | Add 3 new fields, persist/load, expose query methods | +| `node/src/state_tests.rs` | Tests for new state operations | + +### 6.3 New fields + +```rust +struct State { + // existing fields unchanged: smt, mmr, prev_mmr_root, root_indices + + pub peg_in_consumed_smt: SparseMerkleTree, // key = utxo_outpoint hash + // value = peg-in metadata hash + pub burned_coins_smt: SparseMerkleTree, // key = coin.identifier + // value = burn metadata hash + pub pending_payouts: BTreeMap, + // key = withdrawal_nonce +} + +struct PendingPayout { + pub burn_proof_id: ProofId, + pub btc_recipient: [u8; 32], + pub amount: u64, + pub status: PayoutStatus, + pub assigned_operator: Option, + pub created_block: u64, // signet block height at burn-inscription +} + +enum PayoutStatus { + PendingAssignment, + Assigned, + Fronted { payout_txid: HashDigest, kickoff_txid: Option }, + Completed, + TimedOut, // operator did not front within 64 blocks; ready for re-assignment + Disputed { challenge_txid: HashDigest }, + Slashed, +} +``` + +### 6.4 Persistence + +Follow the existing pattern in `node/src/state.rs`: bincode-serialised +binary files alongside `smt.bin` / `mmr.bin`. Names: + +- `peg_in_consumed_smt.bin` +- `burned_coins_smt.bin` +- `pending_payouts.bin` + +Per `feedback_zkcoins_closed_test_env`, no migration code is needed — +on first server start with this code, all three files are created +fresh. + +### 6.5 Test plan (Phase 3) + +Coverage on `State` extensions: + +- Insert into `peg_in_consumed_smt` → root advances; subsequent + non-inclusion proof for same utxo fails. +- Insert into `burned_coins_smt` → same. +- Add `pending_payouts` entry → retrievable by nonce. +- State transitions: PendingAssignment → Assigned → Fronted → + Completed. +- Persistence round-trip: write to disk, read back, equal state. + +Estimated effort: **1 week**, risk **low** (mechanical extension). + +--- + +## 7. Phase 4 — N-of-N MuSig2 Signer Node + +### 7.1 Goal + +A daemon that: + +- Participates in the federation's MuSig2 key aggregation at setup +- Pre-signs all spending paths of the bridge transaction graph +- Cooperatively signs vault outputs for peg-ins +- Provides signing services for cooperative peg-outs + +### 7.2 Where the code lives + +This is **not** in `zk-coins/node` directly — it's a separate +crate that the server binary depends on. Proposed: + +``` +zk-coins/node/ + crates/ + bridge-signer/ ← new crate + src/ + lib.rs + musig2.rs + pre_signing.rs + tx_graph.rs + signer_protocol.rs + Cargo.toml +``` + +(Alternative: separate repo `zk-coins/bridge-signer`. MVP: keep in +the server tree to avoid premature repo proliferation. Memory note: +zkCoins works in `zk-coins/*` org with direct-to-develop pushes +per `feedback_zkcoins_direct_develop`.) + +### 7.3 Library choices + +- **MuSig2:** [`secp256k1-musig2`](https://docs.rs/secp256k1/) once + it lands (Rust-Bitcoin community); or fork `rust-secp256k1`'s + experimental musig branch +- **Bitcoin tx construction:** `rust-bitcoin` (canonical) +- **PSBT manipulation:** `rust-bitcoin`'s PSBT support +- **Network:** simple TCP+protobuf or HTTP JSON, MVP doesn't need a + protocol-level standardisation + +### 7.4 The tx graph + +At federation setup, the signers pre-sign the following templates +for each peg-in denomination: + +1. **MovetoVault:** spends the user's deposit → operational vault + UTXO. Parameterised on (deposit_utxo, user_zkcoins_address). +2. **Payout:** spends vault → user_btc_recipient. Parameterised on + (burn_nonce, btc_recipient, amount). Uses + `SIGHASH_SINGLE|ANYONECANPAY` so any operator can add a fee input. +3. **KickOff:** operator's reimbursement claim. Spends operator's + bond UTXO + claims vault output. +4. **Challenge, Assert, Disprove:** BitVM2 fraud-proof state machine. +5. **Take1, Take2:** operator's eventual reimbursement paths after + challenge window or successful defence. +6. **Burn:** punitive tx that destroys operator's bond on a + successful disprove. + +For MVP with N=3 and a small set of denominations (say 0.01, 0.1, 1 +BTC), the total pre-signed transaction count is ~6 templates × 3 +denominations = ~18 base templates. Manageable. + +### 7.5 The setup ceremony (MVP version) + +1. All three signers generate fresh keypairs +2. MuSig2 key aggregation → `vault_aggregated_pubkey` +3. Each signer generates and exchanges nonce commitments for every + pre-signed transaction +4. Each signer signs every template; partial signatures aggregated +5. Each signer **deletes the per-signer private key** (MVP demo: + logs a "deleted" message; production: actually zeroes memory and + removes any persisted private-key file) +6. Aggregated signatures stored persistently + +### 7.6 Operations + +After setup, the signers participate in: + +- **MovetoVault signing:** when a user's deposit lands on Bitcoin, + signers cooperate to broadcast the pre-signed MovetoVault tx that + binds the deposit to the user's zkCoins address +- **Cooperative payout:** if all signers are online during a peg-out, + they cooperatively sign a direct vault→user Payout, bypassing the + operator-fronting path + +### 7.7 Test plan (Phase 4) + +Positive: +- 3-node MuSig2 setup: aggregated pubkey computed identically by all + 3 +- Pre-signing one template: all 3 produce valid partial sigs; + aggregation yields a valid BIP-340 sig +- Pre-signing all 18 templates: completes within reasonable time + (target: < 30s) +- MovetoVault cooperation: 3-node test signs and broadcasts on + regtest; transaction confirms + +Negative: +- One node refuses to sign: aggregation fails gracefully (returns + Error, not panic) +- One node provides a corrupt partial sig: detection via verification + before aggregation +- Replay of a pre-signed nonce: detected, rejected + +Estimated effort: **3–4 weeks**, risk **medium** (MuSig2 + Bitcoin +tx construction is well-understood territory but precise pre-signing +of a complex tx graph has been tricky historically; reference Citrea +Clementine's `signer` crate as starting point). + +--- + +## 8. Phase 5 — Operator + Watchtower Daemons + +### 8.1 Goal + +The **operator** daemon advances peg-outs from its own BTC balance +and claims reimbursement via KickOff. The **watchtower** daemon +monitors Bitcoin for fraudulent operator claims and posts challenges. + +In MVP, the same 3 nodes run both daemons. + +### 8.2 Files touched + +``` +zk-coins/node/ + crates/ + bridge-operator/ ← new crate + src/ + lib.rs + payout.rs + kickoff.rs + bond.rs + bridge-watchtower/ ← new crate + src/ + lib.rs + monitor.rs + challenge.rs + disprove.rs +``` + +### 8.3 Operator flow + +``` +1. Subscribe to `pending_payouts` events from server (see Phase 6) +2. On PendingAssignment with status changing to Assigned: + a. Verify the burn-proof landed (zkCoins state confirms) + b. Verify own BTC balance ≥ amount + fees + c. Construct the Payout tx (add own input as fee, sign) + d. Broadcast Payout tx to Bitcoin + e. Wait for confirmation + f. Update server: payout fulfilled (txid) +3. Submit KickOff tx claiming vault reimbursement +4. Wait for 36-block challenge window + a. If no challenge: post NoChallenge tx after timelock, retrieve + reimbursement + b. If challenged: enter BitVM2 dispute (Assert + Disprove) +``` + +### 8.4 Watchtower flow + +``` +1. Subscribe to Bitcoin chain (rust-bitcoin chain notifier) +2. On any KickOff tx detected: + a. Verify: does the corresponding pending_payout exist on zkCoins? + b. Verify: does the Payout tx claimed by KickOff actually exist + on Bitcoin? + c. If either check fails: this is a fraudulent KickOff. Post + Challenge tx within the challenge window. +3. On Assert tx (operator's response to Challenge): + a. Run our local Groth16 verifier on the asserted computation + b. If wrong: post Disprove tx, slashing operator's bond +``` + +### 8.5 Bonds + +For MVP with 3 trusted nodes, bonds can be dust (~10000 sat) — the +slashing is symbolic. Production-grade bonds match peg-out +denominations. + +### 8.6 Test plan (Phase 5) + +Positive: +- Happy path peg-out: user burns, operator pays, no challenge, kickoff + succeeds. +- Two parallel peg-outs: both operators advance; both reimbursements + complete. + +Negative (essential to validate the fraud-proof game works): +- **Malicious operator simulation:** operator posts KickOff for a + payout they did not fund → watchtower detects, posts Challenge → + operator cannot produce valid Assert → Disprove fires → bond + slashed. +- **Operator times out on fronting:** assigned operator does not + broadcast Payout within 64 blocks → server reassigns. +- **Network partition:** simulate Bitcoin node disconnect for an + operator during KickOff → operator retries on reconnect. + +Estimated effort: **3 weeks**, risk **medium** (state-machine +correctness, especially fraud-proof game; reference Citrea's +operator + watchtower implementations). + +--- + +## 9. Phase 6 — Bridge-Aware Server + +### 9.1 Goal + +Extend `zk-coins/node` HTTP API with peg-in and peg-out endpoints. + +### 9.2 Files touched + +| File | Change | +| ---- | ------ | +| `node/src/bridge.rs` | **new** — bridge module | +| `node/src/server.rs` | Add bridge endpoints to router | +| `node/src/runtime.rs` | Wire bridge state into runtime | + +### 9.3 Endpoints + +``` +GET /api/bridge/quote + Returns current peg-in and peg-out fees, denominations + supported, estimated wait times. + +POST /api/bridge/peg-in/initiate + Body: { recipient_zkcoins_address, denomination, refund_btc_pubkey } + Returns: { deposit_taproot_address, refund_timeout_block } + Server records the pending peg-in; user makes the Bitcoin deposit. + +POST /api/bridge/peg-in/finalize + Body: { deposit_txid, deposit_vout, lcp_proof_bytes } + Server verifies the LCP, runs the prover to generate + IssuanceProof, returns ProofId to user; user signs the + commitment and POSTs it back via the standard /api/commit. + +POST /api/bridge/peg-out/burn + Body: { source_coins[], btc_recipient_address } + Server runs the prover to generate BurnProof, returns ProofId + and withdrawal_nonce. + +GET /api/bridge/peg-out/status?nonce={nonce} + Returns current PayoutStatus. + +POST /api/bridge/peg-out/payout-template + (Operator-only.) Returns the unsigned Payout template ready + for fee-input addition. + +POST /api/bridge/peg-out/fronted + (Operator-only.) Notify that an operator broadcast a Payout + tx; server marks PendingPayout as Fronted. +``` + +### 9.4 Test plan (Phase 6) + +Per `feedback_zkcoins_mvp_definition`: 100% coverage on the activated +endpoints. + +- Each endpoint with happy-path input → correct response +- Each endpoint with malformed input → 400-class error, no state + change +- Each endpoint with operator/user role mismatch → 403 +- Race conditions: concurrent peg-out initiations on the same coin + set → second rejected with conflict + +Estimated effort: **2 weeks**, risk **low** (standard HTTP API +extension). + +--- + +## 10. Phase 7 — Plonky2 → Groth16 Wrapping + +### 10.1 Goal + +For BitVM2 to verify our state-transition proof on Bitcoin, the proof +needs to be in Groth16. Our circuit is Plonky2. The standard pattern +(Citrea, GOAT) is: prove the Plonky2 verifier circuit in Groth16, +then BitVM2 verifies the resulting Groth16 proof. + +### 10.2 Files touched + +| File | Change | +| ---- | ------ | +| `crates/bridge-groth16/` | **new crate** — Plonky2 → Groth16 wrapper | +| `crates/bridge-groth16/src/wrap.rs` | Implement Plonky2 verifier as a Groth16 circuit | +| `crates/bridge-groth16/src/srs.rs` | Trusted setup SRS loading / validation | +| `crates/bridge-groth16/srs/mvp_srs.bin` | The MVP single-contributor SRS — **DO NOT USE IN PRODUCTION** | + +### 10.3 Approach + +Two viable paths: + +**Path A: arkworks-based Plonky2 verifier in Groth16.** Implement the +Plonky2 verifier (Poseidon hashing, FRI proximity checks, etc.) as +an arkworks Groth16 circuit. Reuse and modify the gnark-style +verifier patterns Citrea uses for RiscZero → Groth16. + +**Path B: Aggregate via a STARK-friendly intermediate.** Plonky2 → +RiscZero → Groth16. Adds latency but reuses Citrea's exact toolchain. + +→ **MVP: Path A.** Direct wrap. Effort estimate is roughly comparable + to Path B and avoids an extra dependency. + +### 10.4 Trusted setup ceremony + +For MVP: single contributor (the lead dev). The SRS file is committed +to the repo with the warning marker (§3.3). + +Production replacement: run a ceremony with 30–60 contributors using +`chainwayxyz`'s ceremony software (open-sourced as part of Citrea's +Risc0-to-BitVM ceremony). Each contributor adds randomness; only one +honest contributor is needed for the resulting SRS to be secure. + +### 10.5 Test plan (Phase 7) + +- Wrap a small Plonky2 proof in Groth16 → wrapping completes; the + Groth16 proof verifies against the SRS. +- Wrap a state-transition proof from `IssuanceProof` → Groth16 proof + has the expected public values (asth, ocr, peg-in-consumed-root, + etc.). +- Negative: wrap a malformed Plonky2 proof → wrapping fails with + clear error. + +Estimated effort: **3–4 weeks**, risk **medium-high** (most novel +cryptographic engineering of the MVP; the Plonky2 verifier circuit +is non-trivial in Groth16; mitigation: study Citrea's open-sourced +Risc0-to-BitVM verifier). + +--- + +## 11. Phase 8 — Integration Test on Signet + +### 11.1 Goal + +3-node end-to-end run on Bitcoin signet (or regtest): peg-in, send +within zkCoins, peg-out. Demonstrate the full happy path and at least +one fraud-proof challenge. + +### 11.2 Setup + +- 3 Linux VMs, each running: + - Bitcoin signet node (synced) + - `zk-coins/node` instance configured for bridge mode + - `bridge-signer`, `bridge-operator`, `bridge-watchtower` daemons +- Shared regtest or signet Bitcoin network +- A test client that drives peg-ins and peg-outs + +### 11.3 Test scenarios + +1. **Happy peg-in:** test client deposits 0.1 BTC on signet → 3 nodes + cooperatively MovetoVault → LCP advances → IssuanceProof generated + → zkCoins minted. +2. **Happy peg-out:** test client burns 0.1 BTC worth of zkCoins → + operator fronts → KickOff → no challenge → operator reimbursed. +3. **Internal zkCoins send between two test users.** +4. **Adversarial peg-out:** simulate a malicious operator that posts + KickOff for a non-existent payout → watchtower posts Challenge → + Disprove succeeds → bond slashed → recoverable state. +5. **Cooperative peg-out (all 3 signers online):** bypass operator + fronting; direct vault → user payout. +6. **Refund path:** simulate federation outage; test client deposits, + federation fails to MovetoVault for 200 blocks → test client uses + refund leaf to recover deposit. + +### 11.4 Success criteria + +- All 6 scenarios complete on signet within reasonable timing +- No double-spends, no stuck funds, no unauthorised mints +- Each scenario covered by automated integration test in the CI + pipeline +- Coverage gate maintained on all touched server/bridge code + +Estimated effort: **3–4 weeks** integration + debugging, risk +**medium-high** (first full-stack run; expect timing and +state-machine bugs). + +--- + +## 12. Aggregate Effort and Risk Register + +### 12.1 Total effort + +| Phase | Effort | Risk | +| ----- | ------ | ---- | +| 1 — Circuit extension | 3–4 weeks | Medium | +| 2 — Bitcoin Light Client | 3–5 weeks | High | +| 3 — State extension | 1 week | Low | +| 4 — MuSig2 signer | 3–4 weeks | Medium | +| 5 — Operator + watchtower | 3 weeks | Medium | +| 6 — Bridge-aware server | 2 weeks | Low | +| 7 — Plonky2 → Groth16 | 3–4 weeks | Medium-high | +| 8 — Integration on signet | 3–4 weeks | Medium-high | +| **Total** | **21–28 weeks ≈ 5–7 months** | — | + +Assumes Plonky2 migration (PR #17) is complete before Phase 1 +starts. If parallelised carefully, Phases 1–3 can begin while PR #17 +finishes (since they don't depend on the server-side replace step). + +### 12.2 Risk register + +- **B1 — SHA256d in Plonky2 too slow.** Phase 2. + *Mitigation:* recursive sub-proofs with small batch sizes; + worst-case fall back to a STARK-friendly LCP (Risc0 / sp1) + externally verified. +- **B2 — Plonky2 → Groth16 wrapping cost.** Phase 7. + *Mitigation:* study Citrea's verifier; if it's too custom, fall + back to Path B (intermediate Risc0). +- **B3 — MuSig2 production-readiness.** Phase 4. + *Mitigation:* if `rust-secp256k1` MuSig2 is not stable, vendor + a known-good fork; reference Citrea's signer. +- **B4 — Fraud-proof game state-machine bugs.** Phases 5 + 8. + *Mitigation:* extensive negative testing (scenario 4 in Phase 8); + cross-reference Citrea's operator implementation. +- **B5 — Bitcoin tx fee market spikes.** Phase 8. + *Mitigation:* MVP uses signet (fees ≈ 0); production design + includes fee bump mechanisms (RBF, CPFP). Out of MVP scope. +- **B6 — Light Client checkpoint becomes stale.** Phase 2. + *Mitigation:* document checkpoint update procedure; out of MVP + automation scope. + +--- + +## 13. Open Implementation Questions + +1. **MVP denominations.** Three? Five? `BITVM_BRIDGE.md` §12.8 covers + the trade-off. Suggest: `{0.01, 0.1, 1.0} BTC` for MVP. + +2. **Refund timeout for peg-in.** Strata uses 200 blocks (~33h). + Match. + +3. **Challenge window for peg-out.** Strata uses 36 blocks (~6h). + Citrea Clementine uses 1.5 days. For MVP: 36 blocks to keep + testing fast. + +4. **Where does `bridge-signer` live?** In-tree under + `server/crates/` or separate repo? MVP: in-tree. + +5. **How is the LCP checkpoint advanced?** Manual operator commit + for MVP. Automation = post-MVP. + +6. **What happens on an LCP that hasn't been refreshed?** Reject the + IssuanceProof; user retries after operator refreshes the LCP. + Worst case: 1 day operator response time. + +7. **Auditability surface for "total BTC in vault vs zkCoins + outstanding".** Bridge dashboard endpoint. Useful but + out-of-MVP-scope for circuit correctness; add post-Phase 8. + +8. **What happens if Plonky2 step 5 (cyclic recursion plumbing, the + blocker on `feat/plonky2-migration`) hits issues?** This MVP + plan assumes step 5 lands cleanly. If it doesn't, the recursive + LCP architecture in Phase 2 cannot work either and we'd need to + rethink. Trigger: re-evaluate Phase 2 if step 5a's panic on + `circuit_digest` mismatch (`MIGRATION_RESEARCH.md` §7.12) + recurs at scale. + +--- + +## 14. Non-Goals (Restated) + +So nobody scope-creeps: + +- Federation diversity / multi-org recruitment — **not in MVP** +- BitVM3 / Glock / Mosaic — **not in MVP** +- Production trusted setup ceremony — **not in MVP** +- Real economic operator bonds — **not in MVP** +- Auditability dashboard — **post-MVP** +- Bridge → Bridge interoperability — **post-MVP** +- Privacy hardening of peg-in / peg-out — **post-MVP**, depends on + D2/D10 closure first + +--- + +## 15. References + +- [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) — strategic context, landscape, + why BitVM2 for v1 +- [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) — LN swap layer + that this bridge enables +- `SPEC.md` — protocol specification (D11 will close with this MVP). + Currently on `feat/plonky2-migration`. +- `MIGRATION_RESEARCH.md` — Plonky2 lessons (§7.12 cyclic-recursion + gotcha specifically relevant to Phase 2). Currently on + `feat/plonky2-migration`. +- `ROADMAP.md` — `feat/plonky2-migration` progress; this MVP starts + after step 9. Currently on `feat/plonky2-migration`. +- [Citrea Clementine bridge docs](https://docs.citrea.xyz/essentials/clementine-trust-minimized-bitcoin-bridge) +- [BitVM Groth16 Verifier Toolkit (chainwayxyz)](https://github.com/chainwayxyz/bitvm-zk-verifier) +- [polymerdao/plonky2-sha256](https://github.com/polymerdao/plonky2-sha256) +- [Strata bridge docs (BitVM2 reference impl)](https://docs.alpenlabs.io/how-alpen-works/bitcoin-bridge) + +--- + +## 16. Change Log + +| Date | Change | +| ---- | ------ | +| 2026-05-17 | Initial draft. | +| 2026-05-17 | §2.2: add "Federation scaling beyond N=3" as deferred item with production target N=100 (1-of-N strict, practical upper bound of BitVM2 framework). Beyond N=100 noted as open research, not current goal. | +| 2026-05-17 | Consistency audit pass: add branch note at the top explaining that `SPEC.md` / `MIGRATION_RESEARCH.md` / `ROADMAP.md` currently live on `feat/plonky2-migration` only; downgrade hyperlinks to those files to plain references (with branch annotation) in §15 References. | +| 2026-05-17 | Audit round 3: harmonise header structure (Status / Authoritative source / Audience / Branch note). Remove "DFX-operated" wording in §2.1 and §3.3 — replaced with generic "single-organisation" wording for consistency with the rest of the repo. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50b5dfa0..42af8c9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,316 @@ -# Contributing to zkCoins Server +# Contributing to zkCoins Node 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 server work. + +--- + +## Working on the Plonky2 Migration + +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 +documents in the order given below. + +### Reading order + +1. **This section** — invariants, decision recipe, gates. +2. **[`ROADMAP.md`](./ROADMAP.md)** — live status table, per-step plans, + effort, risk register, post-MVP Plonky3 path. +3. **[`SPEC.md`](./SPEC.md)** — what the protocol *does*. Glossary, + divergences from the paper (§15), full circuit spec. +4. **[`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md)** — why we + chose what we chose. §3 (11 divergences), §5 (6 locked-in design + decisions), **§7 Lessons Learned** (11 gotchas — required reading + before touching the affected code areas). +5. **[`program-plonky2/CONTRIBUTING.md`](./program-plonky2/CONTRIBUTING.md)** + — operational handoff for the migration crate: toolchain, + build/test/lint, coverage gate, gadget-authoring pattern. + +### No polling — events only + +Bitcoin / Esplora signals on the server's hot path are subscribed to, +never polled. The scanner consumes block events from the +mempool.space-compatible WebSocket stream (`scanner_ws.rs`, +`ESPLORA_WS_URL`, default `wss://mutinynet.com/api/v1/ws`); the +publisher waits for `track-tx` events between commit and reveal +broadcasts instead of sleeping a fixed propagation interval. The +previous 30-s tip-poll gated `/api/mint` and `/api/send` visibility +by up to a full block-time + poll-interval (issue #84); event-driven +ingestion brings that down to the WS round-trip. + +Where it applies: + +- `node/src/scanner.rs` — pure inscription parsing, no polling. +- `node/src/scanner_runtime.rs` — block-walk loop, drains the WS-fed channel. +- `node/src/scanner_ws.rs` — WS subscriber + reconnect-with-backoff. +- `node/src/scanner_ws_parse.rs` — pure WS frame parsers. +- `node/src/publisher.rs` — `track-tx` wait between commit and reveal. + +Where it does NOT apply: integration tests +(`node/tests/api_remote.rs`), health-readiness probes, and any +self-host operator code outside the four files above. + +CI enforces this with a `grep` step inside the `Lint & Build` job in +`.github/workflows/ci.yaml`: + +```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 \ + | grep -v 'scanner-polling-ok:' +``` + +Any match without the `scanner-polling-ok:` token on the same line +fails the build with a pointer to issue #84. The token is a plain +comment marker — not an `#[allow(...)]` attribute, which would have +been mistakable for a real lint suppression — and is the documented +per-line opt-out for genuinely justified exceptions (today: the +WS-reconnect backoff in `scanner_ws`, the inner `track-tx` +reconnect-with-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. New +uses require either changing the design or extending this section +with the rationale. + +### Project invariants (non-negotiable) + +The five constraints below are decided and apply across every PR on +`develop`. + +1. **Server-side compute architecture.** The server generates every ZK + proof, holds every Merkle tree, broadcasts every Taproot inscription. + The wallet holds only the user's private key and signs BIP-340 Schnorr + over `SHA256(serialize(asth) ‖ serialize(ocr))`. No in-browser + Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. +2. **Closed test environment** — DEV *and* PRD. No external users, no + real money, no migration of existing state. Step 7 of the ROADMAP + deleted the SP1 path outright; no Cargo feature flag, no dual + backend. At cutover (PR [#17](https://github.com/zk-coins/node/pull/17), 2026-05-18) the server state files + were wiped and the new Plonky2 server started fresh. +3. **Hardware target: Mac Studio M3 Ultra, 96 GB unified RAM, single + host.** All on-box compute resources are available (Performance + + Efficiency cores, the integrated Apple GPU reachable via Metal, + Neural Engine, AMX). **No external hardware** (no NVIDIA, no CUDA, + no GPU farms). **No external cloud proving services** (no Succinct + Prover Network, no AWS GPU, no Lambda Labs). Note: Plonky2 today + has no Metal backend, so the integrated GPU is effectively idle for + proving — that's a library property, not a constraint we imposed. + Performance budget: warm proof ≤ 5 s (target ≤ 1 s), cold-start + ≤ 30 s, memory peak < 64 GB. +4. **MVP = minimal feature surface + 100% test coverage.** Simultaneous, + not alternative. "Minimal" reduces the surface; "100%" keeps what + remains clean. Gate: `cargo llvm-cov --fail-under-lines 100 -- --test-threads=1` + from inside the affected crate. Current state on `program-plonky2`: + 100% lines / functions / regions, 115 default-run tests (+ 2 + `#[ignore]`d `recursion_shape_probe` diagnostics). The authoritative + coverage gate for `server` runs in CI on the self-hosted M3 Ultra + runner (`.github/workflows/ci.yaml`, `Coverage Gate` job, gated + behind the `ci:full` label on PRs). See `ROADMAP.md` § "Done" for + the live test count and breakdown. +5. **Plonky2 is bridge tech; Plonky3 is the long-term destination.** + But we do not preemptively adopt BabyBear / Poseidon2 inside this + migration — see `MIGRATION_RESEARCH.md` §5 (decisions) and ROADMAP + "Considered alternative". +6. **`num_pubkeys` only advances after on-chain broadcast — never + before.** The mint and commit flows must follow prepare → broadcast + → commit ordering: build the prover witness on a clone, attempt + the inscription broadcast first, and only on broadcast success + commit the bumped `minting_meta.num_pubkeys` (with an optimistic + `... WHERE num_pubkeys = $expected_prev` clause) together with the + mutated account snapshots in a single sqlx transaction. The + broadcast-then-commit ordering is load-bearing; any future + refactor that moves a `minting_meta` UPDATE, an `accounts` UPSERT, + or an in-memory `receive_coin` above the broadcast call re- + introduces the state-desync class fixed in + [zk-coins/node#89](https://github.com/zk-coins/node/issues/89). + Startup invariant check in `runtime::check_minting_state_invariant` + enforces the corollary at boot: every `pubkey_idx ∈ + 0..num_pubkeys` MUST have a commitment in the SMT, no flag + override — operator recovery is via the `reset_state` workflow. + +### Decision recipe — should this go in the MVP? + +Run this checklist in order on every proposed change. Stop at the +first "no". + +1. **Is X on the critical path for the one-shot user loop?** (create + account → mint → send → receive → balance) If no, defer to post-MVP. +2. **Does X compromise invariant 1 (server-side compute)?** If yes, + redesign so all heavy compute is server-side. +3. **Does X require external hardware or cloud services (invariant 3)?** + If yes, redesign. +4. **Does X assume migration logic (invariant 2)?** If yes, redesign + to "replace not migrate" or defer until mainnet launch. +5. **Can X be tested to 100% coverage including negative paths + (invariant 4)?** If not, refactor or gate behind a Cargo feature. +6. **Does X drift from the divergence list (`SPEC.md` §15)?** If yes, + updating the divergence list is part of the PR. + +If all six pass, X enters the MVP. Update `ROADMAP.md` Status-at-a-Glance +and the relevant `### Step N` section *in the same PR*. + +### Pre-push checklist + +The repo-level pre-push hook (`.githooks/pre-push`) runs `cargo fmt +--check`, `cargo clippy` (all three feature scopes), and `cargo +check --workspace --all-features` automatically. The full test + +coverage gate for `node` and `shared` runs in CI on the self-hosted +M3 Ultra runner pool — push and keep working, do not block the +terminal on the suite. + +When touching `program-plonky2/` specifically, also run the local +sweep + coverage gate **before** opening / updating the PR — the +cyclic-recursion sweep is not in CI yet (decision tracked in [issue #50](https://github.com/zk-coins/node/issues/50)): + +```bash +cd program-plonky2 +cargo test --release --lib -- --test-threads=1 +cargo llvm-cov --release --fail-under-lines 100 -- --test-threads=1 +``` + +After push, poll CI until it goes green; if red, investigate and +fix — never abandon a red CI run. + +### Branch hygiene + +- No force-pushes, even to side branches. +- No `--no-verify` on commits. +- No squashing by the agent — the maintainer squashes at merge time if needed. +- Maintainers merge PRs; agents open them as drafts. +- Doc-only commits to `ROADMAP.md` / `SPEC.md` / `MIGRATION_RESEARCH.md` + / `CONTRIBUTING.md` / `program-plonky2/CONTRIBUTING.md` that just + correct or extend these files are not individually listed in + `ROADMAP.md` "Done" — they're in `git log`. + +### Where to put new knowledge + +When you discover a new gotcha or take a new decision, the right home is: + +| Type of knowledge | Where | +| --- | --- | +| Protocol-level fact (circuit invariant, public-input change) | `SPEC.md` | +| Why we chose / didn't choose something | `MIGRATION_RESEARCH.md` §5 or §7 | +| New status / step / risk | `ROADMAP.md` | +| Toolchain or workflow detail for the migration crate | `program-plonky2/CONTRIBUTING.md` | +| Cross-cutting invariant for the whole project | This section | + +Don't duplicate prose across files — the second copy will drift. +Link from one to the other. + +### Common foot-guns (already encountered) + +Condensed pointers into [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) §7: + +1. Don't seed `DEFAULT_HASHES[TREE_DEPTH]` with `ZERO_HASH` in + Poseidon SMTs — structural collision (§7.1). +2. `pw.set_target(t, v)` returns `Result` in plonky2 1.x — must + handle (§7.3). +3. Pack 7 bytes per Goldilocks element, never 8 — modulus safety (§7.4). +4. Defensive bounds checks: use `Option::get().copied().unwrap_or(...)`, + not explicit `if/else` — keeps coverage at 100% (§7.9). +5. Every `#[cfg(test)] mod tests` needs `#[cfg_attr(coverage_nightly, coverage(off))]` (§7.10). +6. No external GPU / cloud assumption in performance plans — single + Mac Studio M3 Ultra (§7.11). +7. Kill orphan `cargo test` binaries after long circuit-test runs — + they leak 30+ GB of swap (§7.6). +8. `gh` in background tasks needs `--repo /` (§7.7). + +--- + ## Quick Start ```bash -git clone https://github.com/zk-coins/server.git -cd server -SP1_PROVER=mock cargo run -p server +git clone https://github.com/zk-coins/node.git +cd node +USERNAME_DOMAIN=test.zkcoins.local cargo run -p node # Server starts on http://0.0.0.0:4242 ``` +## Local Development with Postgres + +The Postgres state-layer added in PR-A1 expects a running PostgreSQL +instance to be reachable at `DATABASE_URL`. The module is not wired +into the bootstrap yet (PR-A2 + PR-A3 land that), so you can develop +without it — but to run the `db_tests` locally you do need either +Docker available (the tests spin up a Postgres 17 container via +`testcontainers-modules`) or a manually-started Postgres. + +Manual Postgres for ad-hoc query work: + +```bash +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 the migrations against the running instance: +cargo install sqlx-cli --no-default-features --features rustls,postgres +cd node +sqlx migrate run +``` + +Run the `db_tests` (Docker required, runs `postgres:17` per test): + +```bash +cargo test -p node db -- --test-threads=1 +``` + +The schema lives in `node/migrations/0001_initial.sql`. After +changing it, drop the local database (`docker rm -f zkcoins-pg`) and +re-run `sqlx migrate run` against a fresh instance — there is no +`down` migration in the MVP, the migration set is forward-only. + +## Setup + +After cloning, enable the repo's pre-push hook. The hook runs `cargo +fmt --check`, `cargo clippy` (all three feature scopes), and `cargo +check --workspace --all-features` — fast enough that it stays out of +the way (< 30 s warm, < 2 min cold) while still flagging lint and +type regressions before they reach a CI runner. + +```bash +git config core.hooksPath .githooks +``` + +The authoritative test + coverage gate runs in CI on a self-hosted +M3 Ultra runner pool (issue #40, `.github/workflows/ci.yaml`), not +in this hook. CI takes 60-90 min for a Rust change but does not +block your terminal — you push, you keep working, the pool reports +back via PR check status. + +Wall budgets on warm cache: + +| Stage | Wall | Where | +|--------------------------------|-----------|-----------| +| Pre-push hook (lint + check) | < 30 s | local | +| Node + shared tests | 60-90 min | CI runner | +| Coverage gate (100% scope) | + 60 min | CI runner | + +When preparing a release PR to `main`, run the circuit sweep manually +— only the `node` + `shared` test sweep is gated in CI (decision +on the cyclic sweep is tracked in [issue #50](https://github.com/zk-coins/node/issues/50)): + +```bash +cargo test -p zkcoins-program-plonky2 --release --lib -- --test-threads=1 +``` + +You can bypass the hook with `git push --no-verify` in genuine +emergencies. CI is the real gate, so a bypassed lint failure surfaces +at the PR check level instead — and `develop` must be 100% green +before any main-merge. + ## Prerequisites | Tool | Version | Purpose | |---|---|---| -| Rust | 1.81+ | Build toolchain (pinned via `rust-toolchain`) | +| Rust | nightly (pinned via `rust-toolchain`) | Required for Plonky2 (`feature(specialization)`) | | Bitcoin node | — | Required for blockchain scanning (or use Esplora API) | ## Project Structure @@ -26,24 +321,28 @@ server/ │ └── src/ │ ├── main.rs # Entry point, chain scanner, bind address │ ├── server.rs # REST endpoints (mint, send, balance, proof) -│ ├── account_server.rs # Account management, coin proofs, prover calls +│ ├── account_node.rs # Account management, coin proofs, prover calls │ ├── state.rs # Sparse Merkle Tree + Merkle Mountain Range │ ├── scanner.rs # Bitcoin block scanner (Taproot Inscriptions) +│ ├── scanner_ws.rs # Esplora WebSocket subscriber (event-driven, issue #84) │ └── publisher.rs # Inscription broadcaster (commit/reveal, prefix 4242) ├── shared/ # Shared types (Commitment, Invoice, ClientAccount) │ └── src/ │ ├── lib.rs # Types, key derivation, crypto helpers │ └── commitment.rs # Schnorr commitment (sign + verify) -├── program/ # SP1 zkVM circuit (Zero-Knowledge proof logic) +├── program-plonky2/ # Plonky2 + Poseidon cyclic-recursion state-transition circuit │ └── src/ -│ ├── lib.rs # Types: AccountState, Coin, ProofData, ProgramInputs -│ ├── main.rs # zkVM entrypoint (gated behind "zkvm" feature) -│ └── merkle/ # SMT + MMR implementations -├── script/ # Prover wrapper (stub for Docker, real SP1 for local) -│ └── src/lib.rs # Prover struct: create_account(), update_account() -├── Cargo.toml # Workspace root -├── Dockerfile # Multi-stage Rust build -└── rust-toolchain # Pinned Rust version (1.81.0) +│ ├── lib.rs # Prelude: F, C, D type aliases +│ ├── hash.rs # Poseidon HashDigest + byte conversions +│ ├── types.rs # AccountState, Coin, ProofData, MINTING_ADDRESS placeholder +│ ├── inputs.rs # ProgramInputs, CommitmentMerkleProofs +│ ├── merkle/ # Poseidon-based SMT + MMR +│ └── circuit/ # build_circuit + per-stage gadgets + aggregator +├── script-plonky2/ # Host-side Plonky2 prover wrapper (zkcoins-prover-plonky2) +│ └── src/lib.rs # Prover struct: prove_initial / prove_account_update +├── Cargo.toml # Workspace root (nightly toolchain, no SP1 patches) +├── Dockerfile # Multi-stage Rust build (linux/arm64, FEATURES build-arg) +└── rust-toolchain # Pinned nightly date (matches program-plonky2) ``` ## Git Workflow @@ -89,11 +388,11 @@ update | Item | Convention | Example | |---|---|---| -| Crate | kebab-case | `zkcoins-program` | -| Module | snake_case | `account_server` | +| 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_SERVER_ADDR` | +| Constant | SCREAMING_SNAKE | `ACCOUNT_NODE_ADDR` | ### Error Handling @@ -109,15 +408,15 @@ 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`) -- SP1 patches in `[patch.crates-io]` — only in the full workspace, removed in the Docker stub +- `plonky2 = "1.1.0"` from crates.io; no `[patch.crates-io]` entries ## Architecture ### Request Flow ``` -Client Request → Axum Router → server.rs (endpoint) → account_server.rs (logic) - ├── Prover (stub/SP1) +Client Request → Axum Router → server.rs (endpoint) → account_node.rs (logic) + ├── Prover (Plonky2) ├── State (SMT + MMR) └── Publisher (Bitcoin) ``` @@ -136,88 +435,126 @@ struct Account { } ``` -**Prover abstraction:** The `Prover` trait has two implementations: -- **Stub** (`script/src/lib.rs`) — returns mock proofs, compiles without SP1 toolchain -- **Real SP1** — requires the `succinct` Rust toolchain and SP1 SDK (not used in Docker) +**Prover:** `zkcoins_prover_plonky2::Prover` (in `script-plonky2/src/lib.rs`) +wraps the cyclic state-transition circuit. `Prover::new()` builds the +circuit once; `prove_initial` / `prove_account_update` (with their +`_with_in_coins` / `_with_in_and_out_coins_and_sources` variants) drive +individual transitions. No mock/stub backend — the only build is the +Plonky2 prover. ### Bitcoin Integration The server continuously scans the Bitcoin blockchain: -1. `scanner.rs` polls Esplora every 30 seconds -2. Filters transactions by prefix `4242` in Taproot witness +1. `scanner_ws.rs` subscribes to the mempool.space-compatible WebSocket + (`ESPLORA_WS_URL`) and pushes block events into a channel; no + chain-tip polling (issue #84, see "No polling — events only" above) +2. `scanner_runtime.rs` drains the channel and hands each block to + `scanner.rs`, which filters transactions by prefix `4242` in the + Taproot witness 3. Deserializes `Commitment` structs (Schnorr-signed) 4. `state.rs` inserts valid commitments into SMT, appends to MMR The publisher (`publisher.rs`) creates Taproot Inscriptions: - Commit/reveal pattern (two transactions) - Data split into 520-byte chunks (max push size) -- Broadcasts via Esplora API +- Broadcasts via Esplora API, then waits for the WS `track-tx` event + between commit and reveal instead of sleeping a fixed interval -### SP1 zkVM Circuit +### Plonky2 State-Transition Circuit -The `program/` crate defines the Zero-Knowledge proof logic. It compiles to two targets: - -| Target | Feature | Use | -|---|---|---| -| Native (x86/ARM) | default (no `zkvm`) | Library — types and Merkle trees used by server | -| RISC-V (SP1) | `zkvm` | zkVM binary — actual proof execution | - -The `zkvm` feature gates the SP1 entrypoint and all `sp1_zkvm::` calls. +The `program-plonky2/` crate defines the Zero-Knowledge proof logic. +The full SPEC §8 predicate (cyclic recursion, MMR + SMT inclusion, +in-coin source-side aggregator pattern from Stage 5d-next-5, out-coin +identifier derivation, pubkey rotation) lives in `circuit/main.rs`. +`MAX_IN_COINS = MAX_OUT_COINS = 8`. See +[`MIGRATION_RESEARCH.md` §7.22](./MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721) +for the architecture writeup and `program-plonky2/SESSION_STATE.md` +for the historical pickup record. ## Environment Variables +The node reads its configuration exclusively from environment variables; +no `.env` file is loaded by the process. The table below covers every +variable the server actually reads (`node/src/lib.rs`, `runtime.rs`, +`scanner_ws.rs`, `publisher.rs`). Required variables panic the bootstrap +on startup if unset — there is no silent fallback. + | Variable | Default | Description | |---|---|---| -| `SP1_PROVER` | `mock` | `mock` (no proof), `cpu`, `cuda`, or `network` | -| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora API endpoint (electrs or public) | -| `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet | -| `NETWORK_NAME` | `Mutinynet` | Human-readable network name (returned by `/api/info`) | -| `PUBLISHER_KEY` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** | -| `RUST_LOG` | `info` | Log level (`debug`, `info`, `warn`, `error`) | +| `DATABASE_URL` | _(required, no default)_ | Postgres connection string for the state-layer (e.g. `postgresql://zkcoins:@postgres:5432/zkcoins`). Server panics on startup if unset. | +| `PUBLISHER_KEY` | _(required, no default)_ | 32-byte hex private key for Taproot inscription publishing. **Required on every network — DEV, signet, and mainnet.** No fallback default exists: the previous `1234…` placeholder was a publicly-known test key that drainer bots swept within minutes of any on-chain top-up (4 historical drains confirmed). Server panics on startup if unset. Generate locally via `openssl rand -hex 32`. In any deployed environment, source it from your secret manager — **never commit a real key**. | +| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`; server panics on startup if unset (see PR [#36](https://github.com/zk-coins/node/pull/36) for the regression that introduced the global panic hook). | +| `POSTGRES_PASSWORD` | _(required, no default for the DB container)_ | Read by the Postgres container, not by the node process itself; the node's `DATABASE_URL` already embeds the password. Listed here because it is part of the local-dev bootstrap (see `Local Development with Postgres` below). | +| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora REST API endpoint (electrs or public). | +| `ESPLORA_WS_URL` | `wss://mutinynet.com/api/v1/ws` | Esplora WebSocket endpoint consumed by `scanner_ws` (issue #84). DEV/PRD override only when the upstream WS path changes. | +| `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet. | +| `NETWORK_NAME` | `Mutinynet` / `Mainnet` | Human-readable name returned by `/api/info`. | +| `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`. | +| `RUST_LOG` | `info` | Log level (`debug`, `info`, `warn`, `error`). | + +### Minimal local-dev env + +```bash +export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres" +export PUBLISHER_KEY="$(openssl rand -hex 32)" +export USERNAME_DOMAIN="test.zkcoins.local" +# Optional — defaults are fine for Mutinynet: +# export ESPLORA_URL="https://mutinynet.com/api" +# export IS_MAINNET="false" +cargo run -p node +``` + +For any deployed environment, the real values live in your secret manager +of choice and are passed into the node container as env vars at startup. ## Docker ```bash -docker build -t zkcoin/server . +docker build -t zkcoins/node . docker run -p 4242:4242 \ --network bitcoin \ - -e SP1_PROVER=mock \ -e ESPLORA_URL=http://electrs-mainnet:3000 \ - zkcoin/server + -e USERNAME_DOMAIN=zkcoins.app \ + zkcoins/node ``` -The pre-built ELF (`elf/zkcoins-program`) is committed to the repo, so Docker builds do not require the Succinct toolchain — only standard Rust. +Docker builds use nightly Rust auto-installed via the workspace `rust-toolchain` — no Succinct toolchain, no zkVM target. ## Persistent State -The server writes the following files under its data volume (`/data` in the container, `zkcoins_server-data` Docker volume on dfxdev/dfxprd). Together they define the recoverable state: +After the PR-A1/PR-A2/PR-A3 Postgres migration series, all persistent server state lives in a Postgres 17 database (`DATABASE_URL` env var). The only on-disk state remaining is the per-proof file store. The state-layer schema (`node/migrations/*.sql`) is applied idempotently on every boot by `db::connect_and_migrate`. -| File | Format | Purpose | -| -------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `smt.bin` | bincode `SparseMerkleTree` | Sparse Merkle Tree of every commitment ever processed (key = sha256(public_key), leaf = account_state_hash). | -| `mmr.bin` | bincode `MerkleMountainRange` | Append-only Merkle Mountain Range of `hash(smt_root ‖ prev_mmr_root)` leaves; one entry per processed commitment. | -| `mmr.bin.prev_root` | 32 bytes | The previous MMR root, kept separately so the SMT/MMR pair stays atomically consistent across restarts. | -| `latest_block.bin` | 32 bytes (block hash) | Last Bitcoin block whose inscriptions were fully processed and persisted. Scanner resumes from `latest_block + 1` after a restart. | -| `accounts.bin` | bincode `HashMap` | Server-side account ledger — per-address balance, coin_queue, coin_history (SMT), and latest proof. Includes the minting account. | -| `usernames.bin` | bincode `UsernameStore` | Gated by `usernames` Cargo feature. Bidirectional map of claimed usernames ↔ addresses. | -| `minting_num_pubkeys.bin` | 4 bytes LE u32 | Gated by `faucet`. Counter of how many mint commitments have been issued; **must** survive restart, otherwise the next mint sends a stale `prev_commitment_pubkey` and `send_coins` returns `prev_commitment_pubkey required for account update`. | -| `proofs/.bin` | bincode `CoinProof` | Individual per-send proof + commitment, indexed by `proof_id`. Append-only. | +| Location | Format | Purpose | +| --------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `smt_state` row (singleton, `id = 1`) | bincode `SparseMerkleTree` in a `BYTEA` column | Sparse Merkle Tree of every commitment ever processed (key = sha256(public_key), leaf = account_state_hash). | +| `mmr_state` row (singleton, `id = 1`) | bincode `MerkleMountainRange` in a `BYTEA` column | Append-only Merkle Mountain Range of `hash(smt_root ‖ prev_mmr_root)` leaves; one entry per processed commitment. | +| `latest_block` row (singleton, `id = 1`) | 32-byte block hash in a `BYTEA` column | Last Bitcoin block whose inscriptions were fully processed and persisted. Scanner resumes from `latest_block + 1` after a restart. Written in the same `BEGIN; UPSERT; UPSERT; UPSERT; COMMIT` transaction as the SMT and MMR (issue #11 fix). | +| `accounts` table (one row per address) | 32-byte `address` PRIMARY KEY + bincode `Account` `BYTEA` | Server-side account ledger — per-address balance, coin_queue, coin_history (SMT), and latest proof. Includes the minting account. Upserted per mutation by the send / receive / mint handlers. | +| `usernames` table (one row per name) | `TEXT` name PRIMARY KEY + 32-byte `address` `BYTEA` | Bidirectional map of claimed usernames ↔ addresses. Race-free claims via `INSERT … ON CONFLICT (name) DO NOTHING`. Always present — usernames are permanent MVP. | +| `minting_meta` row (singleton, `id = 1`) | `BIGINT` num_pubkeys | Counter of how many mint commitments have been issued; **must** survive restart, otherwise the next mint sends a stale `prev_commitment_pubkey` and `send_coins` returns `prev_commitment_pubkey required for account update`. Always present — mint is permanent MVP. | +| `proofs/.bin` (on-disk file) | bincode `CoinProof` | Individual per-send proof + commitment, indexed by `proof_id`. Append-only. **Not** in Postgres because the per-proof blobs are large Plonky2 proof bytes and the directory layout makes recovery trivial. Path configurable via `PROOFS_DIR` (default `./proofs`). | -`atomic_write` is used for every write (tempfile + rename). A crash between writes can still leave `latest_block.bin` lagging the SMT/MMR pair; the scanner is now tolerant of this — `state.update` errors are logged (see `main.rs::scan_for_inscriptions` callback) rather than propagated as panics. +Writes are atomic at the row / transaction level (`ON CONFLICT DO UPDATE` for singleton rows, the BEGIN/COMMIT block in `db::persist_state_tx` for the SMT/MMR/latest-block trio). Per-proof file writes still use a write-to-temp + rename pattern inside `ProofStore::persist_proof_bytes`. The pre-migration `smt.bin` / `mmr.bin` / `latest_block.bin` / `accounts.bin` / `usernames.bin` / `minting_num_pubkeys.bin` sibling files no longer exist, and the previous `main.rs::atomic_write` helper has been removed. ### DEV state recovery -If the DEV server gets into a bad state (panic loop, mint failures with `prev_commitment_pubkey required`, balance never rising after a successful mint, etc.), the recovery procedure is to wipe the data volume: +If the DEV server gets into a bad state (panic loop, mint failures with `prev_commitment_pubkey required`, balance never rising after a successful mint, etc.), the recovery procedure is to truncate the Postgres state-layer tables (and drop the on-disk proofs directory): ```bash -# On the host running the server (e.g. dfxdev): -docker stop zkcoins-server -docker run --rm -v zkcoins_server-data:/data alpine sh -c 'rm -f /data/*.bin /data/*.bin.prev_root' -docker start zkcoins-server +# On the host running the server (DEV or PRD): +docker stop zkcoins-node +# Truncate every state-layer table. _sqlx_migrations is intentionally +# left in place so connect_and_migrate skips re-applying the schema. +docker exec -i zkcoins-postgres psql -U zkcoins -d zkcoins -c \ + 'TRUNCATE accounts, usernames, smt_state, mmr_state, latest_block, minting_meta;' +# Drop the per-proof files (proof_id state resets at next boot). +docker run --rm -v zkcoins_server-data:/data alpine sh -c 'rm -rf /data/proofs' +docker start zkcoins-node ``` -The server starts from genesis on next boot: `Creating new State / No accounts file found / No saved block hash found / fetching latest from Esplora`. Past test wallets are abandoned on-chain (they're random) but the SMT is re-built from the chain tip onwards. This is **destructive** — never run it on PRD without a known-needed reason. +The server starts from genesis on next boot: `Loaded State from Postgres` (empty), `Loaded AccountNode from Postgres` (empty), `No saved block hash found, fetching latest from Esplora`. Past test wallets are abandoned on-chain (they're random) but the SMT is re-built from the chain tip onwards. This is **destructive** — never run it on PRD without a known-needed reason. The E2E regen workflow on the app repo wipes this state before every run as part of the per-PR cadence in `app/e2e/README.md § 11.3`. @@ -234,10 +571,33 @@ See [docs.zkcoins.app/infrastructure/backend](https://docs.zkcoins.app/infrastru | Workflow | Trigger | Action | |---|---|---| -| `deploy-dev.yaml` | Push to develop | Docker build (ARM64) → push `zkcoin/server:beta` → deploy to DEV | -| `deploy-prd.yaml` | Push to main | Docker build (ARM64) → push `zkcoin/server:latest` → deploy to PRD | +| `ci.yaml` (Lint & Build) | Ready PR → develop, push to develop | `cargo fmt --check`, clippy (MVP + all-features + program lib), build (MVP + all-features) on `ubuntu-latest`. | +| `ci.yaml` (Node + Shared Tests) | Ready PR → develop with `ci:full` label, push to develop | `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 (issue #40). | +| `ci.yaml` (Coverage Gate) | Ready PR → develop with `ci:full` label, push to develop | `cargo llvm-cov nextest` with the 100% line + function gate, MVP scope, on the same runner pool. | +| `deploy-dev.yaml` | Push to develop | Docker build (ARM64) → push `zkcoins/node:beta` → deploy to DEV | +| `deploy-prd.yaml` | Push to main | Docker build (ARM64) → push `zkcoins/node:latest` → deploy to PRD | | `auto-release-pr.yaml` | Push to develop | Creates Release PR (develop → main) | +**Draft PRs** skip every `ci.yaml` job — the workflow fires once the +PR is marked ready-for-review. + +**Heavy jobs** (`Node + Shared Tests`, `Coverage Gate`) additionally +require the `ci:full` label on a ready PR. Apply the label when the +PR is in shape to run against the authoritative ~60-90 min M3 Ultra +gate; remove it before the next push to keep an agent free for other +work. `Lint & Build` (fast, GitHub-hosted, free) keeps running on +every ready-PR push. + +`push to develop` always runs the full gate — the post-merge run on +`develop` is the source of truth, and `deploy-dev.yaml` consumes its +result via the auto-release PR's check rollup. + +To stop a Heavy run that is already executing, removing the `ci:full` +label is *not* enough — the workflow isolates label events into their +own concurrency group so an unrelated label toggle doesn't cancel an +in-flight 60-min run. If you need to free an agent immediately, use +`gh run cancel ` (the run id is on the PR's checks tab). + Build time is ~5 minutes (Rust compilation on ARM64). ## Related Repos diff --git a/Cargo.lock b/Cargo.lock index a767b637..f170096f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,50 +1,25 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - -[[package]] -name = "addchain" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2e69442aa5628ea6951fa33e24efe8313f4321a91bd729fc2f75bdfc858570" -dependencies = [ - "num-bigint 0.3.3", - "num-integer", - "num-traits", -] - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +version = 4 [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -56,3540 +31,2124 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "alloy-consensus" -version = "0.11.1" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e32ef5c74bbeb1733c37f4ac7f866f8c8af208b7b4265e21af609dcac5bd5e" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "alloy-trie", - "auto_impl", - "c-kzg", - "derive_more 1.0.0", - "serde", + "libc", ] [[package]] -name = "alloy-consensus-any" -version = "0.11.1" +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa13b7b1e1e3fedc42f0728103bfa3b4d566d3d42b606db449504d88dbdbdcf" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", "serde", + "serde_json", ] [[package]] -name = "alloy-eip2124" -version = "0.1.0" +name = "astral-tokio-tar" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675264c957689f0fd75f5993a73123c2cc3b5c235a38f5b9037fe6c826bfb2c0" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" dependencies = [ - "alloy-primitives", - "alloy-rlp", - "crc", - "thiserror 2.0.12", + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", ] [[package]] -name = "alloy-eip2930" -version = "0.1.0" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0069cf0642457f87a01a014f6dc29d5d893cd4fd8fddf0c3cdfad1bb3ebafc41" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "alloy-primitives", - "alloy-rlp", - "serde", + "async-stream-impl", + "futures-core", + "pin-project-lite", ] [[package]] -name = "alloy-eip7702" -version = "0.5.1" +name = "async-stream-impl" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b15b13d38b366d01e818fe8e710d4d702ef7499eacd44926a06171dd9585d0c" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ - "alloy-primitives", - "alloy-rlp", - "serde", - "thiserror 2.0.12", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "alloy-eips" -version = "0.11.1" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5591581ca2ab0b3e7226a4047f9a1bfcf431da1d0cce3752fda609fea3c27e37" -dependencies = [ - "alloy-eip2124", - "alloy-eip2930", - "alloy-eip7702", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "auto_impl", - "c-kzg", - "derive_more 1.0.0", - "once_cell", - "serde", - "sha2 0.10.8", +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "alloy-json-rpc" -version = "0.11.1" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "762414662d793d7aaa36ee3af6928b6be23227df1681ce9c039f6f11daadef64" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "serde", - "serde_json", - "thiserror 2.0.12", - "tracing", + "num-traits", ] [[package]] -name = "alloy-network" -version = "0.11.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be03f2ebc00cf88bd06d3c6caf387dceaa9c7e6b268216779fa68a9bf8ab4e6" -dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-json-rpc", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rpc-types-any", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", - "alloy-sol-types", - "async-trait", - "auto_impl", - "futures-utils-wasm", - "serde", - "serde_json", - "thiserror 2.0.12", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "alloy-network-primitives" -version = "0.11.1" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a00ce618ae2f78369918be0c20f620336381502c83b6ed62c2f7b2db27698b0" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-serde", - "serde", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "alloy-primitives" -version = "0.8.22" +name = "axum" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c66bb6715b7499ea755bde4c96223ae8eb74e05c014ab38b9db602879ffb825" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ - "alloy-rlp", + "async-trait", + "axum-core 0.4.5", "bytes", - "cfg-if", - "const-hex", - "derive_more 2.0.1", - "foldhash", - "hashbrown 0.15.2", - "indexmap 2.7.1", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", "itoa", - "k256", - "keccak-asm", - "paste", - "proptest", - "rand 0.8.6", - "ruint", - "rustc-hash 2.1.1", + "matchit 0.7.3", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", "serde", - "sha3", - "tiny-keccak", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "alloy-rlp" -version = "0.3.11" +name = "axum" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6c1d995bff8d011f7cd6c81820d51825e6e06d6db73914c1630ecf544d83d6" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "alloy-rlp-derive", - "arrayvec", + "axum-core 0.5.6", "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper 1.0.2", + "tower", + "tower-layer", + "tower-service", ] [[package]] -name = "alloy-rlp-derive" -version = "0.3.11" +name = "axum-core" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a40e1ef334153322fd878d07e86af7a529bcb86b2439525920a88eba87bcf943" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "alloy-rpc-types-any" -version = "0.11.1" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "318ae46dd12456df42527c3b94c1ae9001e1ceb707f7afe2c7807ac4e49ebad9" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "alloy-consensus-any", - "alloy-rpc-types-eth", - "alloy-serde", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", ] [[package]] -name = "alloy-rpc-types-eth" -version = "0.11.1" +name = "base58ck" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4dbee4d82f8a22dde18c28257bed759afeae7ba73da4a1479a039fd1445d04" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "alloy-sol-types", - "itertools 0.14.0", - "serde", - "serde_json", - "thiserror 2.0.12", + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", ] [[package]] -name = "alloy-serde" -version = "0.11.1" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8732058f5ca28c1d53d241e8504620b997ef670315d7c8afab856b3e3b80d945" -dependencies = [ - "alloy-primitives", - "serde", - "serde_json", -] +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] -name = "alloy-signer" -version = "0.11.1" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f96b3526fdd779a4bd0f37319cfb4172db52a7ac24cdbb8804b72091c18e1701" -dependencies = [ - "alloy-primitives", - "async-trait", - "auto_impl", - "either", - "elliptic-curve", - "k256", - "thiserror 2.0.12", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "alloy-signer-local" +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8f78cd6b7501c7e813a1eb4a087b72d23af51f5bb66d4e948dc840bdd207d8" -dependencies = [ - "alloy-consensus", - "alloy-network", - "alloy-primitives", - "alloy-signer", - "async-trait", - "k256", - "rand 0.8.6", - "thiserror 2.0.12", -] +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" [[package]] -name = "alloy-sol-macro" -version = "0.8.22" +name = "bincode" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f9c3c7bc1f4e334e5c5fc59ec8dac894973a71b11da09065affc6094025049" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "alloy-sol-macro-expander", - "alloy-sol-macro-input", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.100", + "serde", ] [[package]] -name = "alloy-sol-macro-expander" -version = "0.8.22" +name = "bitcoin" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46ff7aa715eb2404cb87fa94390d2c5d5addd70d9617e20b2398ee6f48cb21f0" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" dependencies = [ - "alloy-sol-macro-input", - "const-hex", - "heck 0.5.0", - "indexmap 2.7.1", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.100", - "syn-solidity", - "tiny-keccak", + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io 0.1.4", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", + "serde", ] [[package]] -name = "alloy-sol-macro-input" -version = "0.8.22" +name = "bitcoin-internals" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f105fa700140c0cc6e2c3377adef650c389ac57b8ead8318a2e6bd52f1ae841" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" dependencies = [ - "const-hex", - "dunce", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.100", - "syn-solidity", + "serde", ] [[package]] -name = "alloy-sol-types" -version = "0.8.22" +name = "bitcoin-internals" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f819635439ebb06aa13c96beac9b2e7360c259e90f5160a6848ae0d94d10452" -dependencies = [ - "alloy-primitives", - "alloy-sol-macro", - "const-hex", -] +checksum = "a90bbbfa552b49101a230fb2668f3f9ef968c81e6f83cf577e1d4b80f689e1aa" [[package]] -name = "alloy-trie" -version = "0.7.9" +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-io" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a94854e420f07e962f7807485856cde359ab99ab6413883e15235ad996e8b" +checksum = "26792cd2bf245069a1c5acb06aa7ad7abe1de69b507c90b490bca81e0665d0ee" dependencies = [ - "alloy-primitives", - "alloy-rlp", - "arrayvec", - "derive_more 1.0.0", - "nybbles", - "serde", - "smallvec", - "tracing", + "bitcoin-internals 0.4.2", ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "bitcoin-units" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals 0.3.0", + "serde", +] [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "bitcoin_hashes" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ - "libc", + "bitcoin-io 0.1.4", + "hex-conservative 0.2.2", + "serde", ] [[package]] -name = "ansi_term" -version = "0.12.1" +name = "bitcoin_hashes" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +checksum = "7e5d09f16329cd545d7e6008b2c6b2af3a90bc678cf41ac3d2f6755943301b16" dependencies = [ - "winapi", + "bitcoin-io 0.2.0", + "hex-conservative 0.3.2", ] [[package]] -name = "anstream" -version = "0.6.18" +name = "bitcoincore-zmq" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "81e38c7506e3278f65cf7c36eee4df9525d2ab9dddf24ed77999b085c5ab3a39" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "bitcoin", + "zmq", + "zmq-sys", ] [[package]] -name = "anstyle" -version = "1.0.10" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "anstyle-parse" -version = "0.2.6" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "utf8parse", + "serde_core", ] [[package]] -name = "anstyle-query" -version = "1.1.2" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "windows-sys 0.59.0", + "generic-array", ] [[package]] -name = "anstyle-wincon" -version = "3.0.7" +name = "bollard" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ - "anstyle", - "once_cell", - "windows-sys 0.59.0", + "async-stream", + "base64 0.22.1", + "bitflags 2.11.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", ] [[package]] -name = "anyhow" -version = "1.0.97" +name = "bollard-buildkit-proto" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] [[package]] -name = "ark-ff" -version = "0.3.0" +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ - "ark-ff-asm 0.3.0", - "ark-ff-macros 0.3.0", - "ark-serialize 0.3.0", - "ark-std 0.3.0", - "derivative", - "num-bigint 0.4.6", - "num-traits", - "paste", - "rustc_version 0.3.3", - "zeroize", + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", ] [[package]] -name = "ark-ff" -version = "0.4.2" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "ark-ff-asm 0.4.2", - "ark-ff-macros 0.4.2", - "ark-serialize 0.4.2", - "ark-std 0.4.0", - "derivative", - "digest 0.10.7", - "itertools 0.10.5", - "num-bigint 0.4.6", - "num-traits", - "paste", - "rustc_version 0.4.1", - "zeroize", + "tinyvec", ] [[package]] -name = "ark-ff-asm" -version = "0.3.0" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" -dependencies = [ - "quote", - "syn 1.0.109", -] +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "ark-ff-asm" -version = "0.4.2" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" -dependencies = [ - "quote", - "syn 1.0.109", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "ark-ff-macros" -version = "0.3.0" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" -dependencies = [ - "num-bigint 0.4.6", - "num-traits", - "quote", - "syn 1.0.109", -] +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "ark-ff-macros" -version = "0.4.2" +name = "cc" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ - "num-bigint 0.4.6", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "ark-serialize" -version = "0.3.0" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "ark-std 0.3.0", - "digest 0.9.0", + "smallvec", + "target-lexicon", ] [[package]] -name = "ark-serialize" -version = "0.4.2" +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "ark-std 0.4.0", - "digest 0.10.7", - "num-bigint 0.4.6", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "ark-std" -version = "0.3.0" +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ + "iana-time-zone", "num-traits", - "rand 0.8.6", + "serde", + "windows-link", ] [[package]] -name = "ark-std" -version = "0.4.0" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "num-traits", - "rand 0.8.6", + "crossbeam-utils", ] [[package]] -name = "arrayref" -version = "0.3.9" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "arrayvec" -version = "0.7.6" +name = "const-random" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ - "serde", + "const-random-macro", ] [[package]] -name = "async-stream" -version = "0.3.6" +name = "const-random-macro" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", ] [[package]] -name = "async-stream-impl" -version = "0.3.6" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "core-foundation-sys", + "libc", ] [[package]] -name = "async-trait" -version = "0.1.87" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "core-foundation-sys", + "libc", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "auto_impl" -version = "1.2.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12882f59de5360c748c4cbf569a042d5fb0eb515f7bea9c1f470b47f6ffbd73" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "libc", ] [[package]] -name = "autocfg" -version = "1.4.0" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] [[package]] -name = "axum" -version = "0.7.9" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.6.0", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", + "crc-catalog", ] [[package]] -name = "axum-core" -version = "0.4.5" +name = "crc-catalog" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", - "tower-layer", - "tower-service", - "tracing", -] +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] -name = "backoff" -version = "0.4.0" +name = "crossbeam" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" dependencies = [ - "futures-core", - "getrandom 0.2.15", - "instant", - "pin-project-lite", - "rand 0.8.6", - "tokio", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", ] [[package]] -name = "backtrace" -version = "0.3.74" +name = "crossbeam-channel" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "serde", - "windows-targets 0.52.6", + "crossbeam-utils", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] [[package]] -name = "base58ck" -version = "0.1.0" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.0", + "crossbeam-utils", ] [[package]] -name = "base64" -version = "0.12.3" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] [[package]] -name = "base64" -version = "0.21.7" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "base64" -version = "0.22.1" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - -[[package]] -name = "bech32" -version = "0.11.0" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] [[package]] -name = "bincode" -version = "1.3.3" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "serde", + "darling_core", + "darling_macro", ] [[package]] -name = "bindgen" -version = "0.70.1" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "bitflags 2.9.0", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "log", - "prettyplease", + "ident_case", "proc-macro2", "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.100", + "strsim", + "syn 2.0.117", ] [[package]] -name = "bit-set" -version = "0.8.0" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "bit-vec", + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "bitcoin" -version = "0.32.5" +name = "deadpool" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "base58ck", - "bech32", - "bitcoin-internals 0.3.0", - "bitcoin-io 0.1.3", - "bitcoin-units", - "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", - "hex_lit", - "secp256k1", - "serde", + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", ] [[package]] -name = "bitcoin-internals" -version = "0.3.0" +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" -dependencies = [ - "serde", -] +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "bitcoin-internals" -version = "0.4.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b854212e29b96c8f0fe04cab11d57586c8f3257de0d146c76cb3b42b3eb9118" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] [[package]] -name = "bitcoin-io" -version = "0.1.3" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] [[package]] -name = "bitcoin-io" -version = "0.2.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26792cd2bf245069a1c5acb06aa7ad7abe1de69b507c90b490bca81e0665d0ee" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "bitcoin-internals 0.4.0", + "block-buffer", + "const-oid", + "crypto-common", + "subtle", ] [[package]] -name = "bitcoin-units" -version = "0.1.2" +name = "dircpy" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +checksum = "ebcbec2b9a580ddee352ac38523d2ecd4dcaad53532957034394556909e27f4b" dependencies = [ - "bitcoin-internals 0.3.0", - "serde", + "jwalk", + "log", + "walkdir", ] [[package]] -name = "bitcoin_hashes" -version = "0.14.0" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "bitcoin-io 0.1.3", - "hex-conservative 0.2.1", - "serde", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "bitcoin_hashes" -version = "0.16.0" +name = "docker_credential" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e5d09f16329cd545d7e6008b2c6b2af3a90bc678cf41ac3d2f6755943301b16" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "bitcoin-io 0.2.0", - "hex-conservative 0.3.0", + "base64 0.22.1", + "serde", + "serde_json", ] [[package]] -name = "bitflags" -version = "1.3.2" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "bitflags" -version = "2.9.0" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "bitvec" -version = "1.0.1" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "serde", ] [[package]] -name = "blake2" -version = "0.10.6" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "digest 0.10.7", + "cfg-if", ] [[package]] -name = "blake2b_simd" -version = "1.0.3" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "generic-array 0.14.7", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "block-buffer" -version = "0.11.0-pre.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded684142010808eb980d9974ef794da2bcf97d13396143b1515e9f0fb4a10e" +name = "esplora-client" +version = "0.11.0" +source = "git+https://github.com/BitVM/rust-esplora-client?branch=master#a29ee89e6fa003655e179615405761b27e67b973" dependencies = [ - "crypto-common 0.2.0-pre.5", + "bitcoin", + "hex-conservative 0.2.2", + "log", + "minreq", + "reqwest 0.11.27", + "serde", + "serde_json", + "tokio", ] [[package]] -name = "bls12_381" -version = "0.7.1" +name = "etcetera" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c196a77437e7cc2fb515ce413a6401291578b5afc8ecb29a3c7ab957f05941" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "ff 0.12.1", - "group 0.12.1", - "pairing", - "rand_core 0.6.4", - "subtle", + "cfg-if", + "home", + "windows-sys 0.48.0", ] [[package]] -name = "blst" -version = "0.3.14" +name = "etcetera" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c79a94619fade3c0b887670333513a67ac28a6a7e653eb260bf0d4103db38d" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "cc", - "glob", - "threadpool", - "zeroize", + "cfg-if", + "windows-sys 0.61.2", ] [[package]] -name = "bumpalo" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" - -[[package]] -name = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - -[[package]] -name = "bytemuck" -version = "1.22.0" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "bytes" -version = "1.11.1" +name = "ferroid" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" dependencies = [ - "serde", + "portable-atomic", + "rand 0.10.1", + "web-time", ] [[package]] -name = "c-kzg" -version = "1.0.3" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0307f72feab3300336fb803a57134159f6e20139af1357f36c54cb90d8e8928" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "blst", - "cc", - "glob", - "hex", + "cfg-if", "libc", - "once_cell", - "serde", ] [[package]] -name = "camino" -version = "1.1.9" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" -dependencies = [ - "serde", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "cargo-platform" -version = "0.1.9" +name = "fixed-hash" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c" dependencies = [ - "serde", + "static_assertions", ] [[package]] -name = "cargo_metadata" -version = "0.18.1" +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ - "camino", - "cargo-platform", - "semver 1.0.26", - "serde", - "serde_json", - "thiserror 1.0.69", + "futures-core", + "futures-sink", + "spin", ] [[package]] -name = "cbindgen" -version = "0.27.0" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fce8dd7fcfcbf3a0a87d8f515194b49d6135acab73e18bd380d1d93bb1a15eb" -dependencies = [ - "clap", - "heck 0.4.1", - "indexmap 2.7.1", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 2.0.100", - "tempfile", - "toml", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "cc" -version = "1.2.16" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" -dependencies = [ - "shlex", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "cexpr" -version = "0.6.0" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "nom", + "foreign-types-shared", ] [[package]] -name = "cfg-if" -version = "1.0.0" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "chrono" -version = "0.4.40" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "windows-link", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "clang-sys" -version = "1.8.1" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "glob", - "libc", - "libloading", + "futures-core", + "futures-sink", ] [[package]] -name = "clap" -version = "4.5.31" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" -dependencies = [ - "clap_builder", - "clap_derive", -] +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "clap_builder" -version = "4.5.31" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "clap_derive" -version = "4.5.28" +name = "futures-intrusive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.100", + "futures-core", + "lock_api", + "parking_lot", ] [[package]] -name = "clap_lex" -version = "0.7.4" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - -[[package]] -name = "const-hex" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0485bab839b018a8f1723fc5391819fea5f8f0f32288ef8a735fd096b6160c" -dependencies = [ - "cfg-if", - "cpufeatures", - "hex", - "proptest", - "serde", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.0-pre.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e3352a27098ba6b09546e5f13b15165e6a88b5c2723afecb3ea9576b27e3ea" - -[[package]] -name = "const_format" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "unicode-xid", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", + "syn 2.0.117", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "crunchy" -version = "0.2.3" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "subtle", - "zeroize", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "generic-array 0.14.7", "typenum", + "version_check", ] [[package]] -name = "crypto-common" -version = "0.2.0-pre.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7aa2ec04f5120b830272a481e8d9d8ba4dda140d2cda59b0f1110d5eb93c38e" -dependencies = [ - "getrandom 0.2.15", - "hybrid-array", - "rand_core 0.6.4", -] - -[[package]] -name = "ctrlc" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" -dependencies = [ - "nix", - "windows-sys 0.59.0", -] - -[[package]] -name = "darling" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.10" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.100", + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", ] [[package]] -name = "darling_macro" -version = "0.20.10" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "darling_core", - "quote", - "syn 2.0.100", + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] -name = "dashu" +name = "getrandom" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b3e5ac1e23ff1995ef05b912e2b012a8784506987a2651552db2c73fb3d7e0" -dependencies = [ - "dashu-base", - "dashu-float", - "dashu-int", - "dashu-macros", - "dashu-ratio", - "rustversion", -] - -[[package]] -name = "dashu-base" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b80bf6b85aa68c58ffea2ddb040109943049ce3fbdf4385d0380aef08ef289" - -[[package]] -name = "dashu-float" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85078445a8dbd2e1bd21f04a816f352db8d333643f0c9b78ca7c3d1df71063e7" -dependencies = [ - "dashu-base", - "dashu-int", - "num-modular", - "num-order", - "rustversion", - "static_assertions", -] - -[[package]] -name = "dashu-int" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee99d08031ca34a4d044efbbb21dff9b8c54bb9d8c82a189187c0651ffdb9fbf" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "dashu-base", - "num-modular", - "num-order", - "rustversion", - "static_assertions", -] - -[[package]] -name = "dashu-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93381c3ef6366766f6e9ed9cf09e4ef9dec69499baf04f0c60e70d653cf0ab10" -dependencies = [ - "dashu-base", - "dashu-float", - "dashu-int", - "dashu-ratio", - "paste", - "proc-macro2", - "quote", - "rustversion", -] - -[[package]] -name = "dashu-ratio" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e33b04dd7ce1ccf8a02a69d3419e354f2bbfdf4eb911a0b7465487248764c9" -dependencies = [ - "dashu-base", - "dashu-float", - "dashu-int", - "num-modular", - "num-order", - "rustversion", -] - -[[package]] -name = "der" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", ] [[package]] -name = "derivative" -version = "2.2.0" +name = "h2" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "h2" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.100", -] - -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", -] - -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl 2.0.1", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.0-pre.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "065d93ead7c220b85d5b4be4795d8398eac4ff68b5ee63895de0a3c1fb6edf25" -dependencies = [ - "block-buffer 0.11.0-pre.5", - "const-oid 0.10.0-pre.2", - "crypto-common 0.2.0-pre.5", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "downloader" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac1e888d6830712d565b2f3a974be3200be9296bc1b03db8251a4cbf18a4a34" -dependencies = [ - "digest 0.10.7", - "futures", - "rand 0.8.6", - "reqwest 0.12.12", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elf" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff 0.13.1", - "generic-array 0.14.7", - "group 0.13.0", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "enum-map" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" -dependencies = [ - "enum-map-derive", - "serde", -] - -[[package]] -name = "enum-map-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "esplora-client" -version = "0.11.0" -source = "git+https://github.com/BitVM/rust-esplora-client?branch=master#7befb9147b69126edaad8b9dbd0b13259f2e9ea0" -dependencies = [ - "bitcoin", - "hex-conservative 0.2.1", - "log", - "minreq", - "reqwest 0.11.27", - "serde", - "tokio", -] - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fastrlp" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "fastrlp" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "bitvec", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "bitvec", - "byteorder", - "ff_derive", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "ff_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f10d12652036b0e99197587c6ba87a8fc3031986499973c030d8b44fcc151b60" -dependencies = [ - "addchain", - "num-bigint 0.3.3", - "num-integer", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.6", - "rustc-hex", - "static_assertions", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "futures-utils-wasm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" - -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "generic-array" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96512db27971c2c3eece70a1e106fbe6c87760234e31e8f7e5634912fe52794a" -dependencies = [ - "serde", - "typenum", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.13.3+wasi-0.2.2", - "wasm-bindgen", - "windows-targets 0.52.6", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff 0.12.1", - "memuse", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff 0.13.1", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "h2" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.7.1", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.2.0", - "indexmap 2.7.1", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "halo2" -version = "0.1.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a23c779b38253fe1538102da44ad5bd5378495a61d2c4ee18d64eaa61ae5995" -dependencies = [ - "halo2_proofs", -] - -[[package]] -name = "halo2_proofs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e925780549adee8364c7f2b685c753f6f3df23bde520c67416e93bf615933760" -dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "pasta_curves 0.4.1", - "rand_core 0.6.4", - "rayon", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", - "serde", -] - -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", - "serde", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -dependencies = [ - "serde", -] - -[[package]] -name = "hex-conservative" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex-conservative" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afe881d0527571892c4034822e59bb10c6c991cce6abe8199b6f5cf10766f55" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex_lit" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.2.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" -dependencies = [ - "bytes", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2 0.4.8", - "http 1.2.0", - "http-body 1.0.1", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" -dependencies = [ - "futures-util", - "http 1.2.0", - "hyper 1.6.0", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper 1.6.0", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "hyper-util" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ + "atomic-waker", "bytes", - "futures-channel", - "futures-util", - "http 1.2.0", - "http-body 1.0.1", - "hyper 1.6.0", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - -[[package]] -name = "icu_normalizer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" - -[[package]] -name = "icu_properties" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locid_transform", - "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" - -[[package]] -name = "icu_provider" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "indenter" -version = "0.3.3" +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] -name = "indexmap" -version = "1.9.3" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "ahash", + "rayon", + "serde", ] [[package]] -name = "indexmap" -version = "2.7.1" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "equivalent", - "hashbrown 0.15.2", - "serde", + "foldhash", ] [[package]] -name = "indicatif" -version = "0.17.11" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "instant" -version = "0.1.13" +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "cfg-if", + "hashbrown 0.15.5", ] [[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "itertools" -version = "0.10.5" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "itertools" -version = "0.12.1" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "itertools" -version = "0.13.0" +name = "hex-conservative" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ - "either", + "arrayvec", ] [[package]] -name = "itertools" -version = "0.14.0" +name = "hex-conservative" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" dependencies = [ - "either", + "arrayvec", ] [[package]] -name = "itoa" -version = "1.0.15" +name = "hex_lit" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" [[package]] -name = "js-sys" -version = "0.3.98" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "hmac", ] [[package]] -name = "jubjub" -version = "0.9.0" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a575df5f985fe1cd5b2b05664ff6accfc46559032b954529fd225a2168d27b0f" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "bitvec", - "bls12_381", - "ff 0.12.1", - "group 0.12.1", - "rand_core 0.6.4", - "subtle", + "digest", ] [[package]] -name = "k256" -version = "0.13.4" +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2 0.10.8", - "signature", + "windows-sys 0.61.2", ] [[package]] -name = "keccak" -version = "0.1.6" +name = "http" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "cpufeatures", + "bytes", + "fnv", + "itoa", ] [[package]] -name = "keccak-asm" -version = "0.1.4" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "digest 0.10.7", - "sha3-asm", + "bytes", + "itoa", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "http-body" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "spin", + "bytes", + "http 0.2.12", + "pin-project-lite", ] [[package]] -name = "libc" -version = "0.2.170" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" - -[[package]] -name = "libloading" -version = "0.8.6" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "cfg-if", - "windows-targets 0.48.5", + "bytes", + "http 1.4.0", ] [[package]] -name = "libm" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" - -[[package]] -name = "libredox" +name = "http-body-util" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "bitflags 2.9.0", - "libc", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", ] [[package]] -name = "linux-raw-sys" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" - -[[package]] -name = "litemap" -version = "0.7.5" +name = "http-range-header" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" [[package]] -name = "lock_api" -version = "0.4.12" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "log" -version = "0.4.26" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "lru" -version = "0.12.5" +name = "hyper" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "hashbrown 0.15.2", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", ] [[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" +name = "hyper" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ - "regex-automata", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "matchit" -version = "0.7.3" +name = "hyper-named-pipe" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] [[package]] -name = "memchr" -version = "2.7.4" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] [[package]] -name = "memuse" -version = "0.2.2" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] [[package]] -name = "mime" -version = "0.3.17" +name = "hyper-tls" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] [[package]] -name = "mime_guess" -version = "2.0.5" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "mime", - "unicase", + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.5" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ - "adler2", + "hex", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] -name = "minreq" -version = "2.13.2" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0c420feb01b9fb5061f8c8f452534361dd783756dcf38ec45191ce55e7a161" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "base64 0.12.3", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", "log", - "serde", - "serde_json", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "mio" -version = "1.0.3" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "cc", ] [[package]] -name = "multer" -version = "3.1.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.2.0", - "httparse", - "memchr", - "mime", - "spin", - "version_check", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "native-tls" -version = "0.2.14" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "nix" -version = "0.29.0" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "cfg_aliases", - "libc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "nohash-hasher" -version = "0.2.0" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "nom" -version = "7.1.3" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "memchr", - "minimal-lexical", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "ntapi" -version = "0.4.1" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" -dependencies = [ - "winapi", -] +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "windows-sys 0.59.0", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "num" -version = "0.4.3" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "num-bigint 0.4.6", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "num-bigint" -version = "0.3.3" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ - "num-integer", - "num-traits", + "autocfg", + "hashbrown 0.12.3", + "serde", ] [[package]] -name = "num-complex" -version = "0.4.6" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "num-traits", + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "num-conv" -version = "0.1.0" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "num-integer" -version = "0.1.46" +name = "itertools" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ - "num-traits", + "either", ] [[package]] -name = "num-iter" -version = "0.1.45" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "either", ] [[package]] -name = "num-modular" -version = "0.6.1" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "num-order" -version = "1.2.0" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "num-modular", + "getrandom 0.3.4", + "libc", ] [[package]] -name = "num-rational" -version = "0.4.2" +name = "js-sys" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ - "num-bigint 0.4.6", - "num-integer", - "num-traits", + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "jwalk" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" dependencies = [ - "autocfg", - "libm", + "crossbeam", + "rayon", ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "keccak-hash" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "ce2bd4c29270e724d3eaadf7bdc8700af4221fc0ed771b855eadcd1b98d52851" dependencies = [ - "hermit-abi", - "libc", + "primitive-types", + "tiny-keccak", ] [[package]] -name = "num_enum" -version = "0.5.11" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "num_enum_derive", + "spin", ] [[package]] -name = "num_enum_derive" -version = "0.5.11" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "number_prefix" -version = "0.4.0" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "nybbles" -version = "0.3.4" +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983bb634df7248924ee0c4c3a749609b5abcb082c28fffe3254b3eb3602b307" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "const-hex", - "serde", - "smallvec", + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", ] [[package]] -name = "object" -version = "0.36.7" +name = "libsqlite3-sys" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "memchr", + "pkg-config", + "vcpkg", ] [[package]] -name = "once_cell" -version = "1.20.3" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "openssl" -version = "0.10.79" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "scopeguard", ] [[package]] -name = "openssl-probe" -version = "0.1.6" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "openssl-sys" -version = "0.9.115" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] -name = "option-ext" -version = "0.2.0" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "p256" -version = "0.13.2" +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.8", + "cfg-if", + "digest", ] [[package]] -name = "p3-air" -version = "0.2.0-succinct" +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02634a874a2286b73f3e0a121e79d6774e92ccbec648c5568f4a7479a4830858" -dependencies = [ - "p3-field", - "p3-matrix", -] +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "p3-baby-bear" -version = "0.2.0-succinct" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080896e9d09e9761982febafe3b3da5cbf320e32f0c89b6e2e01e875129f4c2d" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "num-bigint 0.4.6", - "p3-field", - "p3-mds", - "p3-poseidon2", - "p3-symmetric", - "rand 0.8.6", - "serde", + "mime", + "unicase", ] [[package]] -name = "p3-bn254-fr" -version = "0.2.0-succinct" +name = "minreq" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c53da73873e24d751ec3bd9d8da034bb5f99c71f24f4903ff37190182bff10" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" dependencies = [ - "ff 0.13.1", - "num-bigint 0.4.6", - "p3-field", - "p3-poseidon2", - "p3-symmetric", - "rand 0.8.6", + "base64 0.22.1", "serde", + "serde_json", ] [[package]] -name = "p3-challenger" -version = "0.2.0-succinct" +name = "mio" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f5c497659a7d9a87882e30ee9a8d0e20c8dcd32cd10d432410e7d6f146ef103" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ - "p3-field", - "p3-maybe-rayon", - "p3-symmetric", - "p3-util", - "serde", - "tracing", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "p3-commit" -version = "0.2.0-succinct" +name = "multer" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec340c5cb17739a7b9ee189378bdac8f0e684b9b5ce539476c26e77cd6a27d" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" dependencies = [ - "itertools 0.12.1", - "p3-challenger", - "p3-field", - "p3-matrix", - "p3-util", - "serde", + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", ] [[package]] -name = "p3-dft" -version = "0.2.0-succinct" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "292e97d02d4c38d8b306c2b8c0428bf15f4d32a11a40bcf80018f675bf33267e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-util", - "tracing", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] -name = "p3-field" -version = "0.2.0-succinct" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91d8e5f9ede1171adafdb0b6a0df1827fbd4eb6a6217bfa36374e5d86248757" +name = "node" +version = "1.1.0" dependencies = [ - "itertools 0.12.1", - "num-bigint 0.4.6", - "num-traits", - "p3-util", + "anyhow", + "axum 0.7.9", + "bincode", + "bitcoin", + "bitcoin_hashes 0.16.0", + "bitcoincore-zmq", + "esplora-client", + "futures-util", + "hex", + "http-body-util", + "lazy_static", "rand 0.8.6", + "reqwest 0.12.28", "serde", + "serde_json", + "sha2", + "shared", + "sqlx", + "tempfile", + "testcontainers", + "testcontainers-modules", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http 0.5.2", + "wiremock", + "zkcoins-program-plonky2", + "zkcoins-prover-plonky2", ] [[package]] -name = "p3-fri" -version = "0.2.0-succinct" +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef838ff24d9b3de3d88d0ac984937d2aa2923bf25cb108ba9b2dc357e472197" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "itertools 0.12.1", - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-interpolation", - "p3-matrix", - "p3-maybe-rayon", - "p3-util", - "serde", - "tracing", + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] -name = "p3-interpolation" -version = "0.2.0-succinct" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c806c3afb8d6acf1d3a78f4be1e9e8b026f13c01b0cdd5ae2e068b70a3ba6d80" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "p3-field", - "p3-matrix", - "p3-util", + "num-integer", + "num-traits", + "rand 0.8.6", ] [[package]] -name = "p3-keccak-air" -version = "0.2.0-succinct" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b46cef7ee8ae1f7cb560e7b7c137e272f6ba75be98179b3aa18695705231e0fb" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "p3-air", - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-util", - "tracing", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] [[package]] -name = "p3-matrix" -version = "0.2.0-succinct" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98bf2c7680b8e906a5e147fe4ceb05a11cc9fa35678aa724333bcb35c72483c1" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "itertools 0.12.1", - "p3-field", - "p3-maybe-rayon", - "p3-util", + "num-traits", "rand 0.8.6", - "serde", - "tracing", ] [[package]] -name = "p3-maybe-rayon" -version = "0.2.0-succinct" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9ac6f1d11ad4d3c13cc496911109d6282315e64f851a666ed80ad4d77c0983" -dependencies = [ - "rayon", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "p3-mds" -version = "0.2.0-succinct" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706cea48976f54702dc68dffa512684c1304d1a3606cadea423cfe0b1ee25134" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "itertools 0.12.1", - "p3-dft", - "p3-field", - "p3-matrix", - "p3-symmetric", - "p3-util", - "rand 0.8.6", + "num-traits", ] [[package]] -name = "p3-merkle-tree" -version = "0.2.0-succinct" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ced385da80dd6b3fd830eaa452c9fa899f2dc3f6463aceba00620d5f071ec" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ - "itertools 0.12.1", - "p3-commit", - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-symmetric", - "p3-util", - "serde", - "tracing", + "autocfg", + "num-integer", + "num-traits", ] [[package]] -name = "p3-poseidon2" -version = "0.2.0-succinct" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ce5f5ec7f1ba3a233a671621029def7bd416e7c51218c9d1167d21602cf312" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "gcd", - "p3-field", - "p3-mds", - "p3-symmetric", - "rand 0.8.6", - "serde", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] -name = "p3-symmetric" -version = "0.2.0-succinct" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f29dc5bb6c99d3de75869d5c086874b64890280eeb7d3e068955f939e219253" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "itertools 0.12.1", - "p3-field", - "serde", + "autocfg", + "libm", ] [[package]] -name = "p3-uni-stark" -version = "0.2.0-succinct" +name = "num_cpus" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83ceaeef06b0bc97e5af2d220cd340b0b3a72bdf37e4584b73b3bc357cfc9ed3" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "itertools 0.12.1", - "p3-air", - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-matrix", - "p3-maybe-rayon", - "p3-util", - "serde", - "tracing", + "hermit-abi", + "libc", ] [[package]] -name = "p3-util" -version = "0.2.0-succinct" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b84d324cd4ac09194a9d0e8ab1834e67a0e47dec477c28fcf9d68b2824c1fe" -dependencies = [ - "serde", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "pairing" -version = "0.22.0" +name = "openssl" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135590d8bdba2b31346f9cd1fb2a912329f5135e832a4f422942eb6ead8b6b3b" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "group 0.12.1", + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", ] [[package]] -name = "parity-scale-codec" -version = "3.7.4" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9fde3d0718baf5bc92f577d652001da0f8d54cd03a7974e118d04fc888dc23d" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "parity-scale-codec-derive" -version = "3.7.4" +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581c837bb6b9541ce7faa9377c20616e4fb7650f6b0f68bc93c827ee504fb7b3" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.100", + "cc", + "libc", + "pkg-config", + "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -3597,59 +2156,42 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "pasta_curves" -version = "0.4.1" +name = "parse-display" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc65faf8e7313b4b1fbaa9f7ca917a0eed499a9663be71477f87993604341d8" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", + "parse-display-derive", + "regex", + "regex-syntax", ] [[package]] -name = "pasta_curves" -version = "0.5.1" +name = "parse-display-derive" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" dependencies = [ - "blake2b_simd", - "ff 0.13.1", - "group 0.13.0", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3661,52 +2203,46 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pest" -version = "2.7.15" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" -dependencies = [ - "memchr", - "thiserror 2.0.12", - "ucd-trie", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] [[package]] name = "pkcs8" @@ -3720,136 +2256,136 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "portable-atomic" -version = "1.11.0" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "plonky2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "b512f56329cfde01b7b5c49f092145ae4fbdbb9cd8742f57879315ed7a893d65" +dependencies = [ + "ahash", + "anyhow", + "getrandom 0.2.17", + "hashbrown 0.14.5", + "itertools 0.11.0", + "keccak-hash", + "log", + "num", + "plonky2_field", + "plonky2_maybe_rayon", + "plonky2_util", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "static_assertions", + "unroll", + "web-time", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "plonky2_field" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "b3ddfe8817d0c5c2d4557979c51c5253dab1b555ecc19833e5b7ea7dd86f39b8" dependencies = [ - "zerocopy 0.8.23", + "anyhow", + "itertools 0.11.0", + "num", + "plonky2_util", + "rand 0.8.6", + "serde", + "static_assertions", + "unroll", ] [[package]] -name = "prettyplease" -version = "0.2.30" +name = "plonky2_maybe_rayon" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1ccf34da56fc294e7d4ccf69a85992b7dfb826b7cf57bac6a70bba3494cc08a" +checksum = "9e1e554181dc95243b8d9948ae7bae5759c7fb2502fed28f671f95ef38079406" dependencies = [ - "proc-macro2", - "syn 2.0.100", + "rayon", ] [[package]] -name = "primeorder" -version = "0.13.6" +name = "plonky2_util" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] +checksum = "c32c137808ca984ab2458b612b7eb0462d853ee041a3136e83d54b96074c7610" [[package]] -name = "primitive-types" -version = "0.12.2" +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "uint", -] +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "once_cell", - "toml_edit 0.19.15", + "zerovec", ] [[package]] -name = "proc-macro-crate" -version = "3.3.0" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" -dependencies = [ - "toml_edit 0.22.24", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "proc-macro2", - "quote", + "zerocopy", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "proc-macro-error-attr2", "proc-macro2", - "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] -name = "proc-macro2" -version = "1.0.94" +name = "primitive-types" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373" dependencies = [ - "unicode-ident", + "fixed-hash", + "uint", ] [[package]] -name = "proptest" -version = "1.6.0" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "bit-set", - "bit-vec", - "bitflags 2.9.0", - "lazy_static", - "num-traits", - "rand 0.8.6", - "rand_chacha 0.3.1", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "unicode-ident", ] [[package]] name = "prost" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -3857,39 +2393,44 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "prost-types" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] [[package]] name = "quinn" -version = "0.11.6" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", - "socket2", - "thiserror 2.0.12", + "socket2 0.6.3", + "thiserror 2.0.18", "tokio", "tracing", + "web-time", ] [[package]] @@ -3899,15 +2440,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", - "getrandom 0.3.1", + "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3915,32 +2456,38 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.10" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46f3055866785f6b92bc6164b76be02ca8f2eb4b002c0354b28cf4c119e5944" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] name = "quote" -version = "1.0.39" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] -name = "radium" -version = "0.7.0" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -3951,7 +2498,6 @@ dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", - "serde", ] [[package]] @@ -3964,6 +2510,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3990,7 +2547,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] @@ -3999,23 +2556,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.1", + "getrandom 0.3.4", ] [[package]] -name = "rand_xorshift" -version = "0.3.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" -dependencies = [ - "rand_core 0.6.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -4023,48 +2577,57 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] -name = "rayon-scan" -version = "0.1.1" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f87cc11a0140b4b0da0ffc889885760c61b13672d80a908920b2c0df078fa14" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "rayon", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.5.10" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", ] [[package]] -name = "redox_users" -version = "0.4.6" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror 1.0.69", + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4074,9 +2637,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4085,9 +2648,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -4100,7 +2663,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -4113,7 +2676,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -4132,30 +2695,25 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", - "http 1.2.0", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-rustls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -4163,170 +2721,74 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls", - "tokio-util", - "tower 0.5.2", + "tower", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", - "webpki-roots", - "windows-registry", -] - -[[package]] -name = "reqwest-middleware" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562ceb5a604d3f7c885a792d42c199fd8af239d0a51b2fa6a78aafa092452b04" -dependencies = [ - "anyhow", - "async-trait", - "http 1.2.0", - "reqwest 0.12.12", - "serde", - "thiserror 1.0.69", - "tower-service", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", + "webpki-roots 1.0.7", ] [[package]] name = "ring" -version = "0.17.13" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rustc-hex", -] - -[[package]] -name = "rrs-succinct" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3372685893a9f67d18e98e792d690017287fd17379a83d798d958e517d380fa9" -dependencies = [ - "downcast-rs", - "num_enum", - "paste", -] - -[[package]] -name = "ruint" -version = "1.13.1" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "825df406ec217a8116bd7b06897c6cc8f65ffefc15d030ae2c9540acc9ed50b6" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "alloy-rlp", - "ark-ff 0.3.0", - "ark-ff 0.4.2", - "bytes", - "fastrlp 0.3.1", - "fastrlp 0.4.0", - "num-bigint 0.4.6", + "const-oid", + "digest", + "num-bigint-dig", "num-integer", "num-traits", - "parity-scale-codec", - "primitive-types", - "proptest", - "rand 0.8.6", - "rlp", - "ruint-macro", - "serde", - "valuable", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", "zeroize", ] -[[package]] -name = "ruint-macro" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - -[[package]] -name = "rustc_version" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" -dependencies = [ - "semver 0.11.0", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver 1.0.26", -] +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.0.1" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -4339,14 +2801,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework", ] [[package]] @@ -4358,15 +2820,6 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4390,68 +2843,56 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "rusty-fork" -version = "0.3.0" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "scale-info" -version = "2.11.6" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ - "cfg-if", - "derive_more 1.0.0", - "parity-scale-codec", - "scale-info-derive", + "winapi-util", ] [[package]] -name = "scale-info-derive" -version = "2.11.6" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.100", + "windows-sys 0.61.2", ] [[package]] -name = "scc" -version = "2.3.3" +name = "schemars" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ - "sdd", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] -name = "schannel" -version = "0.1.27" +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ - "windows-sys 0.59.0", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] @@ -4460,33 +2901,13 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array 0.14.7", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", "rand 0.8.6", "secp256k1-sys", "serde", @@ -4503,25 +2924,12 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.2.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.0", - "core-foundation 0.10.0", + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4529,9 +2937,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4539,191 +2947,148 @@ dependencies = [ [[package]] name = "semver" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" -dependencies = [ - "serde", -] +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "semver-parser" -version = "0.10.3" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "pest", + "serde_core", + "serde_derive", ] [[package]] -name = "serde" -version = "1.0.219" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" -dependencies = [ - "itoa", - "serde", -] - -[[package]] -name = "serde_spanned" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "form_urlencoded", "itoa", - "ryu", "serde", + "serde_core", ] [[package]] -name = "serial_test" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.2.0" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] -name = "server" -version = "1.1.0" -dependencies = [ - "anyhow", - "axum", - "bincode", - "bitcoin", - "bitcoin_hashes 0.16.0", - "esplora-client", - "hex", - "http-body-util", - "lazy_static", +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ "serde", - "serde_json", - "sha2 0.10.8", - "shared", - "tokio", - "tower 0.5.2", - "tower-http", - "zkcoins-program", - "zkcoins-prover", ] [[package]] -name = "sha2" -version = "0.10.8" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "sha2" -version = "0.11.0-pre.3" -source = "git+https://github.com/sp1-patches/RustCrypto-hashes#0b79171da599c1bd1b9d4bd45f537f217a2375df" +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.0-pre.8", + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "serde_with_macros" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "digest 0.10.7", - "keccak", + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "sha3-asm" -version = "0.1.4" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cc", "cfg-if", + "cpufeatures 0.2.17", + "digest", ] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "lazy_static", + "cfg-if", + "cpufeatures 0.2.17", + "digest", ] [[package]] @@ -4735,8 +3100,8 @@ dependencies = [ "hex", "lazy_static", "serde", - "sha2 0.10.8", - "zkcoins-program", + "sha2", + "zkcoins-program-plonky2", ] [[package]] @@ -4747,10 +3112,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -4760,524 +3126,255 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] -[[package]] -name = "size" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fed904c7fb2856d868b92464fc8fa597fce366edea1a9cbfaa8cb5fe080bd6d" - [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] -name = "snowbridge-amcl" -version = "1.0.2" +name = "socket2" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460a9ed63cdf03c1b9847e8a12a5f5ba19c4efd5869e4a737e05be25d7c427e5" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ - "parity-scale-codec", - "scale-info", + "libc", + "windows-sys 0.52.0", ] [[package]] name = "socket2" -version = "0.5.8" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "sp1-build" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ - "anyhow", - "cargo_metadata", - "chrono", - "clap", - "dirs", + "lock_api", ] [[package]] -name = "sp1-core-executor" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "bincode", - "bytemuck", - "clap", - "elf", - "enum-map", - "eyre", - "hashbrown 0.14.5", - "hex", - "itertools 0.13.0", - "log", - "nohash-hasher", - "num", - "p3-baby-bear", - "p3-field", - "p3-maybe-rayon", - "p3-util", - "rand 0.8.6", - "rrs-succinct", - "serde", - "serde_json", - "sp1-curves", - "sp1-primitives", - "sp1-stark", - "strum", - "strum_macros", - "subenum", - "thiserror 1.0.69", - "tiny-keccak", - "tracing", - "typenum", - "vec_map", + "base64ct", + "der", ] [[package]] -name = "sp1-core-machine" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "bincode", - "cbindgen", - "cc", - "cfg-if", - "elliptic-curve", - "generic-array 1.1.0", - "glob", - "hashbrown 0.14.5", - "hex", - "itertools 0.13.0", - "k256", - "log", - "num", - "num_cpus", - "p256", - "p3-air", - "p3-baby-bear", - "p3-challenger", - "p3-field", - "p3-keccak-air", - "p3-matrix", - "p3-maybe-rayon", - "p3-poseidon2", - "p3-symmetric", - "p3-uni-stark", - "p3-util", - "pathdiff", - "rand 0.8.6", - "rayon", - "rayon-scan", - "serde", - "serde_json", - "size", - "snowbridge-amcl", - "sp1-core-executor", - "sp1-curves", - "sp1-derive", - "sp1-primitives", - "sp1-stark", - "static_assertions", - "strum", - "strum_macros", - "tempfile", - "thiserror 1.0.69", - "tracing", - "tracing-forest", - "tracing-subscriber", - "typenum", - "web-time", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] -name = "sp1-cuda" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "bincode", - "ctrlc", - "prost", + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", "serde", - "sp1-core-machine", - "sp1-prover", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", "tokio", + "tokio-stream", "tracing", - "twirp-rs", -] - -[[package]] -name = "sp1-curves" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "cfg-if", - "dashu", - "elliptic-curve", - "generic-array 1.1.0", - "itertools 0.13.0", - "k256", - "num", - "p256", - "p3-field", - "serde", - "snowbridge-amcl", - "sp1-primitives", - "sp1-stark", - "typenum", + "url", + "webpki-roots 0.26.11", ] [[package]] -name = "sp1-derive" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ + "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "sp1-lib" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "bincode", - "serde", - "sp1-primitives", -] - -[[package]] -name = "sp1-primitives" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "bincode", - "hex", - "lazy_static", - "num-bigint 0.4.6", - "p3-baby-bear", - "p3-field", - "p3-poseidon2", - "p3-symmetric", - "serde", - "sha2 0.10.8", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", ] [[package]] -name = "sp1-prover" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ - "anyhow", - "bincode", - "clap", - "dirs", - "downloader", - "eyre", + "dotenvy", + "either", + "heck", "hex", - "itertools 0.13.0", - "lru", - "num-bigint 0.4.6", - "p3-baby-bear", - "p3-bn254-fr", - "p3-challenger", - "p3-commit", - "p3-field", - "p3-matrix", - "p3-symmetric", - "p3-util", - "rayon", - "serde", - "serde_json", - "serial_test", - "sha2 0.10.8", - "sp1-core-executor", - "sp1-core-machine", - "sp1-primitives", - "sp1-recursion-circuit", - "sp1-recursion-compiler", - "sp1-recursion-core", - "sp1-recursion-gnark-ffi", - "sp1-stark", - "thiserror 1.0.69", - "tracing", - "tracing-appender", - "tracing-subscriber", -] - -[[package]] -name = "sp1-recursion-circuit" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "hashbrown 0.14.5", - "itertools 0.13.0", - "num-traits", - "p3-air", - "p3-baby-bear", - "p3-bn254-fr", - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-fri", - "p3-matrix", - "p3-symmetric", - "p3-uni-stark", - "p3-util", - "rand 0.8.6", - "rayon", - "serde", - "sp1-core-executor", - "sp1-core-machine", - "sp1-derive", - "sp1-primitives", - "sp1-recursion-compiler", - "sp1-recursion-core", - "sp1-recursion-gnark-ffi", - "sp1-stark", - "tracing", -] - -[[package]] -name = "sp1-recursion-compiler" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "backtrace", - "itertools 0.13.0", - "p3-baby-bear", - "p3-bn254-fr", - "p3-field", - "p3-symmetric", - "serde", - "sp1-core-machine", - "sp1-primitives", - "sp1-recursion-core", - "sp1-recursion-derive", - "sp1-stark", - "tracing", - "vec_map", -] - -[[package]] -name = "sp1-recursion-core" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "backtrace", - "cbindgen", - "cc", - "cfg-if", - "ff 0.13.1", - "glob", - "hashbrown 0.14.5", - "itertools 0.13.0", - "num_cpus", - "p3-air", - "p3-baby-bear", - "p3-bn254-fr", - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-fri", - "p3-matrix", - "p3-maybe-rayon", - "p3-merkle-tree", - "p3-poseidon2", - "p3-symmetric", - "p3-util", - "pathdiff", - "rand 0.8.6", - "serde", - "sp1-core-machine", - "sp1-derive", - "sp1-primitives", - "sp1-stark", - "static_assertions", - "thiserror 1.0.69", - "tracing", - "vec_map", - "zkhash", -] - -[[package]] -name = "sp1-recursion-derive" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ + "once_cell", + "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "sp1-recursion-gnark-ffi" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "anyhow", - "bincode", - "bindgen", - "cc", - "cfg-if", - "hex", - "log", - "num-bigint 0.4.6", - "p3-baby-bear", - "p3-field", - "p3-symmetric", "serde", "serde_json", - "sha2 0.10.8", - "sp1-core-machine", - "sp1-recursion-compiler", - "sp1-stark", - "tempfile", + "sha2", + "sqlx-core", + "sqlx-postgres", + "syn 2.0.117", + "tokio", + "url", ] [[package]] -name = "sp1-sdk" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ - "alloy-primitives", - "alloy-signer", - "alloy-signer-local", - "alloy-sol-types", - "anyhow", - "async-trait", - "backoff", - "bincode", - "cfg-if", - "dirs", - "futures", - "hashbrown 0.14.5", + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", "hex", - "indicatif", - "itertools 0.13.0", + "hkdf", + "hmac", + "itoa", "log", - "p3-baby-bear", - "p3-field", - "p3-fri", - "prost", - "reqwest 0.12.12", - "reqwest-middleware", - "serde", - "serde_json", - "sp1-build", - "sp1-core-executor", - "sp1-core-machine", - "sp1-cuda", - "sp1-primitives", - "sp1-prover", - "sp1-stark", - "strum", - "strum_macros", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tonic", - "tracing", - "twirp-rs", -] - -[[package]] -name = "sp1-stark" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" -dependencies = [ - "arrayref", - "hashbrown 0.14.5", - "itertools 0.13.0", - "num-bigint 0.4.6", - "num-traits", - "p3-air", - "p3-baby-bear", - "p3-challenger", - "p3-commit", - "p3-dft", - "p3-field", - "p3-fri", - "p3-matrix", - "p3-maybe-rayon", - "p3-merkle-tree", - "p3-poseidon2", - "p3-symmetric", - "p3-uni-stark", - "p3-util", - "rayon-scan", - "serde", - "sp1-derive", - "sp1-primitives", - "strum", - "strum_macros", - "sysinfo", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", "tracing", + "whoami", ] [[package]] -name = "sp1-zkvm" -version = "4.1.2" -source = "git+https://github.com/succinctlabs/sp1?tag=v4.1.2#24e5bd8bd7d8c96f7d6f33582c4d0abc10aab863" +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ - "cfg-if", - "getrandom 0.2.15", - "lazy_static", - "libm", - "p3-baby-bear", - "p3-field", + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", "rand 0.8.6", - "sha2 0.10.8", - "sp1-lib", - "sp1-primitives", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", ] [[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "base64ct", - "der", + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -5286,43 +3383,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "strsim" -version = "0.11.1" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] [[package]] -name = "strum" -version = "0.26.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strum_macros" -version = "0.26.4" +name = "structmeta" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" dependencies = [ - "heck 0.5.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.100", + "structmeta-derive", + "syn 2.0.117", ] [[package]] -name = "subenum" -version = "1.1.2" +name = "structmeta-derive" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5d5dfb8556dd04017db5e318bbeac8ab2b0c67b76bf197bfb79e9b29f18ecf" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ - "heck 0.4.1", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5344,27 +3441,15 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "syn-solidity" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9f9798a84bca5cd4d1760db691075fda8f2c3a5d9647e8bfd29eb9b3fabb87" -dependencies = [ - "paste", - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "sync_wrapper" version = "0.1.2" @@ -5382,28 +3467,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", -] - -[[package]] -name = "sysinfo" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "ntapi", - "once_cell", - "rayon", - "windows", + "syn 2.0.117", ] [[package]] @@ -5428,23 +3498,75 @@ dependencies = [ ] [[package]] -name = "tap" -version = "1.0.1" +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.18.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.3.1", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "http 1.4.0", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", ] [[package]] @@ -5458,11 +3580,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -5473,65 +3595,46 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", -] - -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5548,9 +3651,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5558,9 +3661,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5573,31 +3676,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] @@ -5612,9 +3713,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5634,20 +3735,36 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -5658,113 +3775,92 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.24", + "toml_edit", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.7.1", - "toml_datetime", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.24" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.7.1", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", - "winnow 0.7.3", + "winnow", ] [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ - "async-stream", "async-trait", - "axum", + "axum 0.8.9", "base64 0.22.1", "bytes", - "h2 0.4.8", - "http 1.2.0", + "h2 0.4.14", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", - "prost", - "rustls-native-certs", - "rustls-pemfile 2.2.0", - "socket2", + "socket2 0.6.3", + "sync_wrapper 1.0.2", "tokio", - "tokio-rustls", "tokio-stream", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", ] [[package]] -name = "tower" -version = "0.4.13" +name = "tonic-prost" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.6", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", + "bytes", + "prost", + "tonic", ] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -5776,10 +3872,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "bytes", "futures-util", - "http 1.2.0", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "http-range-header", @@ -5795,6 +3891,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -5809,9 +3923,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -5819,203 +3933,182 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" -dependencies = [ - "crossbeam-channel", - "thiserror 1.0.69", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", ] [[package]] -name = "tracing-forest" -version = "0.1.6" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee40835db14ddd1e3ba414292272eddde9dad04d3d4b65509656414d1c42592f" -dependencies = [ - "ansi_term", - "smallvec", - "thiserror 1.0.69", - "tracing", - "tracing-subscriber", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tungstenite" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", "log", - "once_cell", - "tracing-core", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", ] [[package]] -name = "tracing-subscriber" -version = "0.3.20" +name = "typenum" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] -name = "try-lock" -version = "0.2.5" +name = "uint" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] [[package]] -name = "twirp-rs" -version = "0.13.0-succinct" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27dfcc06b8d9262bc2d4b8d1847c56af9971a52dd8a0076876de9db763227d0d" -dependencies = [ - "async-trait", - "axum", - "futures", - "http 1.2.0", - "http-body-util", - "hyper 1.6.0", - "prost", - "reqwest 0.12.12", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tower 0.5.2", - "url", -] +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] -name = "typenum" -version = "1.18.0" +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "ucd-trie" -version = "0.1.7" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "uint" -version = "0.9.5" +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", + "tinyvec", ] [[package]] -name = "unarray" +name = "unicode-properties" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "unicase" -version = "2.8.1" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "unicode-ident" -version = "1.0.18" +name = "unroll" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "5ad948c1cb799b1a70f836077721a92a35ac177d4daddf4c20a633786d4cf618" +dependencies = [ + "quote", + "syn 1.0.109", +] [[package]] -name = "unicode-width" -version = "0.2.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "ureq" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] [[package]] -name = "untrusted" -version = "0.9.0" +name = "ureq-proto" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", + "serde_derive", ] [[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" +name = "utf-8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] -name = "valuable" -version = "0.1.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "vcpkg" @@ -6024,13 +4117,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "vec_map" -version = "0.8.2" +name = "version-compare" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" -dependencies = [ - "serde", -] +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" @@ -6039,12 +4129,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "wait-timeout" -version = "0.2.1" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "libc", + "same-file", + "winapi-util", ] [[package]] @@ -6058,19 +4149,34 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -6086,14 +4192,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", "js-sys", "wasm-bindgen", - "web-sys", ] [[package]] @@ -6115,7 +4219,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -6129,23 +4233,44 @@ dependencies = [ ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -6163,13 +4288,32 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.8" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6186,6 +4330,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -6193,58 +4346,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.52.0" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-core", - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-core" -version = "0.52.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "windows-targets 0.52.6", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "windows-link" -version = "0.1.0" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "windows-registry" -version = "0.2.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" -dependencies = [ - "windows-result", - "windows-strings", - "windows-targets 0.52.6", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.2.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -6267,11 +4424,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -6397,18 +4554,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.3" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -6424,145 +4572,234 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "wiremock" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ - "bitflags 2.9.0", + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "writeable" -version = "0.5.5" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wyz" -version = "0.5.1" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "tap", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "yoke" -version = "0.7.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "yoke-derive" -version = "0.7.5" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", - "syn 2.0.100", - "synstructure", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "zerocopy" -version = "0.7.35" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "zerocopy-derive 0.7.35", + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "zerocopy" -version = "0.8.23" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "zerocopy-derive 0.8.23", + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "zerocopy-derive" -version = "0.7.35" +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.23" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zeromq-src" +version = "0.2.6+4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "fc120b771270365d5ed0dfb4baf1005f2243ae1ae83703265cb3504070f4160b" dependencies = [ - "zeroize_derive", + "cc", + "dircpy", ] [[package]] -name = "zeroize_derive" -version = "1.4.2" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -6571,60 +4808,58 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.117", ] [[package]] -name = "zkcoins-program" -version = "0.1.0" +name = "zkcoins-program-plonky2" +version = "0.0.1" dependencies = [ + "anyhow", "bincode", - "derive_builder", - "lazy_static", - "rand 0.8.6", + "plonky2", "serde", - "sha2 0.11.0-pre.3", - "sp1-zkvm", ] [[package]] -name = "zkcoins-prover" -version = "1.1.0" +name = "zkcoins-prover-plonky2" +version = "0.0.1" dependencies = [ - "sp1-sdk", - "tracing", - "zkcoins-program", + "anyhow", + "plonky2", + "zkcoins-program-plonky2", ] [[package]] -name = "zkhash" -version = "0.2.0" +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zmq" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4352d1081da6922701401cdd4cbf29a2723feb4cfabb5771f6fee8e9276da1c7" +checksum = "dd3091dd571fb84a9b3e5e5c6a807d186c411c812c8618786c3c30e5349234e7" dependencies = [ - "ark-ff 0.4.2", - "ark-std 0.4.0", - "bitvec", - "blake2", - "bls12_381", - "byteorder", - "cfg-if", - "group 0.12.1", - "group 0.13.0", - "halo2", - "hex", - "jubjub", - "lazy_static", - "pasta_curves 0.5.1", - "rand 0.8.6", - "serde", - "sha2 0.10.8", - "sha3", - "subtle", + "bitflags 1.3.2", + "libc", + "zmq-sys", +] + +[[package]] +name = "zmq-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8351dc72494b4d7f5652a681c33634063bbad58046c1689e75270908fdc864" +dependencies = [ + "libc", + "system-deps", + "zeromq-src", ] diff --git a/Cargo.toml b/Cargo.toml index 2c8ac0a7..39ee92e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [workspace] members = [ - "program", - "script", - "server", - "shared"] + "program-plonky2", + "script-plonky2", + "node", + "shared", +] resolver = "2" [workspace.dependencies] @@ -13,8 +14,7 @@ serde = { version = "1.0", features = ["derive"] } rand = "0.8" blake3 = "1.6.1" lazy_static = "1.5.0" -bitcoin = { version = "0.32.5", features = ["rand", "rand-std"] } -sp1-sdk = "4.0.0" +bitcoin = { version = "0.32.5", features = ["rand", "rand-std", "serde"] } [profile.dev] opt-level = 3 @@ -22,22 +22,3 @@ opt-level = 3 [workspace.package] version = "1.1.0" edition = "2021" - -[patch.crates-io] -sp1-zkvm = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-lib = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-primitives = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-sdk = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-build = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-core-executor = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-curves = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-stark = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-derive = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-core-machine = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-cuda = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-prover = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-recursion-circuit = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-recursion-compiler = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-recursion-core = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-recursion-derive = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } -sp1-recursion-gnark-ffi = { git = "https://github.com/succinctlabs/sp1", tag = "v4.1.2" } diff --git a/Dockerfile b/Dockerfile index 07c3d096..f70718f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,69 @@ -FROM rust:1.81-bookworm AS builder +# Multi-stage Docker build for the zkCoins node post Plonky2 migration. +# +# The Plonky2 toolchain pin is `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. +# +# Build: +# docker build -t zkcoins/node:latest . +# docker build -t zkcoins/node:beta . +# +# Both DEV (`:beta`) and PRD (`:latest`) ship the MVP-only binary +# (no Cargo features beyond the always-on mint and username routes). +# The `FEATURES` build-arg below stays in place as an opt-in escape +# hatch for self-hosters who want to compile non-MVP routes locally +# (e.g. `--build-arg FEATURES=address-list,lnurl`). +# Run: +# docker run -p 4242:4242 \ +# -e ESPLORA_URL=http://electrs:3000 \ +# -e PUBLISHER_KEY= \ +# -v zkcoins-data:/data \ +# zkcoins/node:latest + +FROM rust:bookworm AS builder WORKDIR /app + +# `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` / +# `sqlx::query_as`, so no `.sqlx/` offline cache is needed; setting +# `SQLX_OFFLINE=true` is defensive — if a future change introduces a +# compile-checked `sqlx::query!` macro, the build will surface the +# missing `.sqlx/` immediately rather than trying (and failing) to +# reach a live database from the builder. +ENV SQLX_OFFLINE=true + +# Copy just the toolchain file first so rustup can fetch the right +# channel before the slow source copy. Cuts a few seconds off cold +# builds; layer-caches well across source-only changes. +COPY rust-toolchain ./ +RUN rustup show + COPY . . -# Cargo features for non-MVP routes. Empty by default — the PRD image -# ships only the MVP feature set. The DEV image build passes a comma- -# separated list (e.g. `address-list,faucet,usernames,lnurl`). Features -# not listed here are excluded from the binary at compile time, so the -# disabled code cannot run, crash, or be exploited at runtime. +# Cargo features for non-MVP routes. Empty by default — both DEV and +# PRD images ship the MVP-only feature set so the two environments run +# the identical binary. Self-hosters who want to enable non-MVP routes +# in a local build can pass a comma-separated list +# (e.g. `--build-arg FEATURES=address-list,lnurl`). Features not listed +# 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 \ - cargo build --release -p server; \ + cargo build --release -p node; \ else \ - cargo build --release -p server --features "$FEATURES"; \ + cargo build --release -p node --features "$FEATURES"; \ fi FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates wget && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/server /usr/local/bin/zkcoins-server +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 ENV RUST_LOG=info WORKDIR /data EXPOSE 4242 -ENTRYPOINT ["zkcoins-server"] +ENTRYPOINT ["zkcoins-node"] diff --git a/LIGHTNING_ATOMIC_SWAP.md b/LIGHTNING_ATOMIC_SWAP.md new file mode 100644 index 00000000..d6d8c7b2 --- /dev/null +++ b/LIGHTNING_ATOMIC_SWAP.md @@ -0,0 +1,1216 @@ +# Lightning ↔ zkCoins Atomic Swap — Design Document + +**Status:** Design draft. No code yet. Companion to `SPEC.md`, +`MIGRATION_RESEARCH.md`, `ROADMAP.md`, and +[`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md). + +**Authoritative source for:** *how* trustless LN ↔ zkCoins swaps work +— not for the wider zkCoins protocol itself. + +**Audience:** Engineers picking up swap implementation. Assumes +familiarity with `SPEC.md` (account model, coin format, inscription +mechanics) and basic Bitcoin/Lightning HTLC mechanics. + +> **Branch note.** This document presupposes the Plonky2 migration +> currently on `feat/plonky2-migration` (PR #17). `SPEC.md`, +> `MIGRATION_RESEARCH.md`, and `ROADMAP.md` live on that branch and +> will resolve on `develop` only after PR #17 lands. Until then, view +> cross-references against `feat/plonky2-migration`. + +--- + +## 1. Scope + +This document specifies the design of **trustless atomic swaps** between +Lightning Network bitcoin and zkCoins. It covers: + +- Why the swap mechanism cannot live on the zkCoins coin layer +- Where the atomicity primitive actually lives (the Bitcoin funding tx of + the `4242`-prefix Taproot inscription) +- Two concrete swap directions (LN → zkCoins, zkCoins → LN) with full + step-by-step protocols +- Bitcoin script construction and timing coordination +- Failure-mode analysis and recovery paths +- Provider operational considerations +- Privacy analysis +- The single open zkCoins-side dependency (D7 reorg safety) that affects + swap timing but not swap design + +It does **not** cover: + +- Generic cross-chain swaps not involving Lightning +- BitVM-style federated bridges (different trust model, different + document) +- Implementation in any specific language or repository layout + +--- + +## 2. Executive Summary + +A trustless atomic swap between LN and zkCoins is **buildable with +today's Bitcoin/Lightning toolchain**, using a standard HTLC on the +Bitcoin funding tx of the zkCoins inscription. The construction is +isomorphic to a Boltz reverse-submarine swap with one twist: instead of +the on-chain side being a P2WSH that pays bitcoin to the user, it is a +P2WSH/P2TR whose spend includes the zkCoins inscription payload in its +witness data. + +The swap design is **orthogonal to the Plonky2 migration** (PR #17). The +24-hour LN CLTV budget dwarfs even SP1's minute-scale proof times by +three orders of magnitude; sub-second proofs are nice-to-have, not a +gating factor. + +The **only zkCoins-side blocker** is D7 (reorg safety, see `SPEC.md` §15, +`MIGRATION_RESEARCH.md` D7). Until D7 is fixed, the provider must wait +for deep Bitcoin confirmation of the inscription before settling the +Lightning side, lengthening the swap's wall-clock time but not affecting +correctness or trust. + +PTLCs (point time-locked contracts) would be an upgrade — better on-chain +privacy, fungibility with normal single-sig spends — but are not +required for trustlessness and not available in production Lightning +implementations as of 2026-05. + +--- + +## 3. Problem Statement + +A user wants to convert between Lightning bitcoin and a zkCoins coin +without trusting any single counterparty with custody of either asset at +any point during the swap. Equivalently: + +- If the user's funds leave Lightning, zkCoins must arrive in their + account, or the user can recover the Lightning funds via timeout. +- If the user's zkCoins leave their account, Lightning bitcoin must + arrive, or the user can recover the zkCoins via some refund path. + +Symmetrically for the swap provider. + +The "single counterparty" referred to is a swap provider (a liquidity +operator who runs both a zkCoins server and a Lightning node), analogous +to Boltz's role in BTC ↔ LN submarine swaps. + +--- + +## 4. zkCoins Architecture Recap (Constraints Relevant for Swaps) + +### 4.1 Coin model + +Per `SPEC.md` §3.2 and `program/src/lib.rs::Coin`: + +```rust +struct Coin { + identifier: HashDigest, // = H(sender_next_asth ‖ u32_be(idx)) + recipient: HashDigest, // = H(initial_pubkey) of the recipient account + amount: u64, +} +``` + +There are **no spending conditions, no scripts, no hash-locks, no +time-locks** on a zkCoins coin. The only constraint enforced at receive +time is `apply_coin`'s `coin.recipient == self.owner` check +(`program/src/lib.rs:154`). This matches the upstream Shielded CSV +paper's `CoinEssence` (pure value transfer) — see +`MIGRATION_RESEARCH.md` §2. + +**Implication:** a zkCoins coin cannot, by itself, carry HTLC semantics. +There is no protocol-level way to say "this coin can only be spent by +revealing preimage `x` such that `H(x) = H`". + +### 4.2 Send mechanics + +Per `SPEC.md` §5 and §11: + +1. The sender's server generates a state-transition proof (`ProofData`) + covering balance update, output coin creation, and history extension. +2. The sender's wallet signs `SHA256(serialize(asth) ‖ serialize(ocr))` + with BIP-340 Schnorr. Here `asth` is the account state hash and + `ocr` is the output coins root (the Merkle root of the SMT + containing the send's output coin identifiers); both abbreviations + match `SPEC.md`'s glossary. +3. The server (or any party with the signed `Commitment`) constructs a + Taproot commit-reveal pair where the commit tx's txid hex begins + with `4242`, and the reveal tx's witness contains the inscription + payload (signed `Commitment`). +4. Both txs are broadcast to Bitcoin. +5. The scanner picks up `4242`-prefix commit-txs, extracts inscription + content from the corresponding reveal-tx, deserialises as + `Commitment`, verifies the Schnorr signature, and inserts the + commitment into the global SMT. + +**Implication 1:** the inscription publication is a **plain Bitcoin +transaction**. It can have any standard Bitcoin script lock on its +inputs. + +**Implication 2:** the "moment of finality" for a zkCoins send is when +the scanner has processed the inscription. That is a function of (a) +the reveal-tx getting sufficient Bitcoin confirmations and (b) the +scanner running. Until then, the send has not happened from the +recipient's perspective. + +### 4.3 What the wallet knows vs. what the server knows + +- **Wallet:** holds the account commitment private key; signs the + Schnorr commitment over `SHA256(asth ‖ ocr)`. Holds no Poseidon + state, no SMT/MMR data. +- **Server:** holds the entire state (SMT + MMR), generates proofs, + holds the inscription-publishing Bitcoin wallet, runs the scanner. + +This split is locked by the server-side-compute architecture decision +(`MIGRATION_RESEARCH.md` §5; `feedback_zkcoins_server_side_compute`). + +For swap design this matters because: + +- Anything that requires "the wallet signs after seeing something" is + cheap (one round-trip to wallet). +- Anything that requires "the server constructs and signs a Bitcoin tx + that publishes the inscription" can be replaced with "the server + constructs the inscription payload and lets a different party + publish". + +--- + +## 5. Why Atomicity Cannot Live on the Coin Layer + +A naïve design would say: "extend the coin model to carry a hash-lock, +prove preimage knowledge in the circuit, atomic swap solved." This does +not work for three independent reasons. + +### 5.1 Protocol-level reason + +Adding spending conditions to the coin model would be a 12th divergence +from the published Shielded CSV protocol. The protocol's coin model is +intentionally minimal — `CoinEssence { address, amount, idx }` (see +`ShieldedCSV/ShieldedCSV/src/lib.rs:24`). Departing from this is +appropriate for the MVP only when the divergence has been triaged and +documented (D1–D11). A 12th divergence to enable swaps would need to be +designed alongside D2/D10 (recipient hiding) because both touch the +recipient-side spending check. + +### 5.2 Cost reason + +Lightning HTLCs use SHA256 preimages. A coin-level hash-lock would +require either: + +- **SHA256 in-circuit:** ~262k gates in Plonky2 per hash (see + [Plonky2 SHA256 benchmarks](https://hackmd.io/@clientsideproving/Plonky2MobileBench)). + Poseidon-2 hashing two field elements costs ~150–200 constraints. + Adding SHA256-preimage proof to every send would inflate proof costs + by ~3 orders of magnitude and destroy the sub-second performance + target. +- **Poseidon hash-lock:** cheap in-circuit, but Lightning HTLCs are + SHA256. To bridge them would need a hash-translation provider (a + trusted party who unlocks the SHA256 HTLC and locks a Poseidon HTLC), + which negates trustlessness. + +### 5.3 Architectural reason + +The only on-chain anchor zkCoins has is the Taproot inscription with +txid prefix `4242`. There is no on-chain UTXO representing an individual +coin. Even if a coin had spending conditions in the circuit, enforcement +of those conditions on-chain would require a separate mechanism the +protocol does not have. + +### 5.4 Conclusion + +Atomicity must come from somewhere else. That somewhere is the **Bitcoin +funding transaction of the inscription reveal**, which is an ordinary +Bitcoin tx and can carry any standard script lock. + +--- + +## 6. Where Atomicity Lives: The Inscription Funding Tx + +Every zkCoins send currently requires the publisher to broadcast a +Taproot commit-reveal pair. The commit tx has txid prefix `4242`, the +reveal tx carries the inscription payload (signed `Commitment`) in its +Taproot script-path witness. + +**Key observation:** the commit tx's input(s) come from a Bitcoin UTXO +the publisher controls. If that UTXO is locked with an HTLC script, then +the reveal tx is only broadcastable by whoever can satisfy the HTLC's +spending condition. + +This is the lever. The swap design rests entirely on coupling the +inscription publication to a Bitcoin script lock that, in turn, is +coupled (via preimage or adapter sig) to a Lightning HTLC/PTLC. + +### 6.1 The funding-utxo lock + +For an LN → zkCoins reverse submarine swap, the provider locks a UTXO +with a standard reverse-submarine-swap script. The script has two +spending paths: + +- **Claim path (recipient):** `user_pubkey + preimage(H)` +- **Refund path (provider):** `provider_pubkey + on_chain_timeout` + +The user spends the UTXO via the claim path to publish the inscription; +the provider can recover via the refund path if the user does not claim +in time. + +### 6.2 Who broadcasts what + +| Action | Pre-swap | Lock confirmed | User claims | Provider claims LN | +| ----- | -------- | -------------- | ----------- | ------------------ | +| LN payment | — | User → Provider HTLC | — | Provider claims, preimage now on LN-side | +| On-chain funding UTXO | Provider creates locked UTXO | UTXO confirmed | User spends with preimage; tx contains inscription | — | +| Inscription | — | — | Published via user's spend tx | Already published in previous step | +| Scanner state | unchanged | unchanged | Updated to include user's new coin | unchanged | + +The non-obvious bit is row 3: the user is the one who publishes the +inscription, *not* the provider. The provider has prepared everything +(send proof, inscription payload, Schnorr signature on +`H(asth ‖ ocr)`), but the act of broadcasting is the user's, and that +broadcast is gated on knowledge of the preimage. + +--- + +## 7. Atomicity Primitives — HTLC vs PTLC + +### 7.1 HTLC (Hash Time-Locked Contract) + +The classical Bitcoin/Lightning primitive. Two parties agree on +`H = SHA256(x)` where `x` is a 32-byte preimage known initially to one +party (the one initiating the swap or the one receiving funds, depending +on direction). The lock is satisfied by revealing `x` such that +`SHA256(x) == H` in the witness; revealing `x` on-chain or via a +Lightning hop's HTLC settlement makes `x` observable to the other +party. + +- **Availability:** standard since 2017, supported everywhere. +- **On-chain footprint:** P2WSH with `OP_SHA256 OP_EQUALVERIFY ...` + or Taproot script path with equivalent semantics. Hash is visible + on-chain. +- **Privacy:** lookups across chains can correlate by hash. A single + hash appearing on Bitcoin L1 (in a swap claim) and within a + Lightning channel state (visible to the channel counterparty) is a + known privacy leak. + +### 7.2 PTLC (Point Time-Locked Contract) + +Schnorr-era replacement for HTLC. Two parties agree on a curve point +`Y = y·G` where `y` is a discrete log known initially to one party. The +lock is "satisfied" not by revealing `y` in a witness but by completing +a Schnorr signature whose adaptor was committed to `Y`: the resulting +on-chain signature, combined with the adaptor signature `s'`, reveals +`y = s − s'` to anyone who sees both. + +- **Availability:** Bitcoin-side fine (BIP-340 Schnorr is standard + since Taproot). Lightning-side blocked on widespread PTLC support + (`lightning-dev` mailing list, ongoing as of 2026-05). +- **On-chain footprint:** indistinguishable from a normal single-sig + Taproot key-path spend. No script revealed, no hash exposed. +- **Privacy:** strong — neither the swap's existence nor the linkage + between LN payment and on-chain spend is observable on Bitcoin L1. + +### 7.3 Which one to build first + +HTLC. Three reasons: + +1. Production-ready toolchain (Boltz backend, BOLT-11 invoices, all + wallets support it). +2. Trustlessness is identical to PTLC for this design — the on-chain + privacy upgrade does not change the security argument. +3. PTLCs over Lightning depend on third-party progress (LDK, CLN + maintainers, Lightning Labs roadmap). Building the LN-side ourselves + is out of scope. + +PTLC is a future upgrade tracked as an open item, not a v1 dependency. + +--- + +## 8. Detailed Flow A: LN → zkCoins (User Buys zkCoins with LN Bitcoin) + +This is the **reverse submarine** direction by Boltz nomenclature: the +user holds the off-chain asset (LN bitcoin) and wants the on-chain-anchored +asset (zkCoins). The user generates the preimage, the provider locks +the on-chain side. + +### 8.1 Parties and pre-conditions + +- **User:** Lightning node, zkCoins wallet, has an existing zkCoins + account (so `recipient = H(initial_pubkey)` is known to them and the + provider). +- **Provider:** Lightning node with inbound liquidity from the user, + zkCoins server with sufficient inventory in some operator account, + Bitcoin wallet for funding UTXO. +- **Pre-agreed:** swap amount `A` (in sats), provider fee `F`, swap + timeout parameters (`T_lock` for on-chain CLTV, `T_ln` for + Lightning CLTV-delta — see §12). + +### 8.2 Protocol steps + +``` +Step 1. User generates preimage x ←$ {0,1}^256. Computes H = SHA256(x). + User sends to provider: + - H + - user_zkcoins_recipient_address (an Address = H(pubkey)) + - amount A + - user_btc_refund_pubkey for the funding UTXO + +Step 2. Provider's zkCoins server prepares the send: + - Loads the operator account state + - Builds out_coins with one entry: { identifier, recipient = + user_zkcoins_recipient_address, amount = A } + - Generates the send proof (SP1 or Plonky2 post-cutover) + - Computes asth, ocr + - Provider's wallet signs H(asth ‖ ocr) with the operator + account's commitment pubkey, producing Schnorr signature σ + - Assembles full inscription payload P = + Commitment { public_key, signature: σ, message: asth‖ocr } + +Step 3. Provider's Bitcoin wallet creates a funding UTXO with script: + + OP_IF + OP_SHA256 OP_EQUALVERIFY + OP_CHECKSIG + OP_ELSE + OP_CHECKLOCKTIMEVERIFY OP_DROP + OP_CHECKSIG + OP_ENDIF + + funded with exactly (fee_to_pay_for_reveal_tx + + dust_threshold). Call this UTXO U_lock. + +Step 4. Provider constructs the unsigned commit-reveal pair for the + inscription: + - Commit tx: spends U_lock + any provider fee inputs, has + one Taproot output committing to the inscription script + tree, and a vanity-grind on (input set, output amounts, + change scripts) to ensure txid prefix = "4242". + - Reveal tx: spends the commit tx's Taproot output via the + script path, the script path witness containing inscription + payload P. + + The commit tx's spend of U_lock requires the IF-branch + (preimage). Provider hands the user: + - Unsigned commit tx + - Reveal tx (unsigned, will be signed by the inscription + script path which is part of the Taproot output) + - Provider's pre-signature on the OP_ELSE refund path + (so the user can verify the refund script is well-formed, + though the user will never need to use it) + +Step 5. User verifies: + - U_lock is on-chain and matches the script in Step 3 with + the correct H, T_lock, and pubkeys + - The unsigned commit-reveal pair, once the user adds their + preimage + signature to the commit tx's input, would + broadcast a tx with txid prefix "4242" whose reveal tx + publishes inscription payload P + - Inscription payload P contains a Schnorr signature on + H(asth ‖ ocr) that verifies against the operator's + commitment pubkey + - The asth and ocr values, opened by P, are consistent with + a send proof that creates a coin to user_zkcoins_recipient_address + of amount A + + If any check fails, the user aborts. No funds at risk — + nothing has been sent on the LN side yet. + +Step 6. User pays the Lightning HTLC: + - User → Provider, hash H, amount A + F, CLTV-delta T_ln + +Step 7. User waits for U_lock to reach the agreed confirmation depth + (see §12 and §16). Then user broadcasts the commit tx: + - Witness for U_lock spend: , IF-branch + - Commit tx now in mempool + +Step 8. Commit tx confirms. User broadcasts the reveal tx, which + publishes inscription P on-chain. + +Step 9. zkCoins scanner picks up the `4242`-prefix commit tx, follows + through to the reveal tx, extracts P, verifies the Schnorr + signature, calls State::update([P]). The user's + zkcoins_recipient_address now holds the new coin. + +Step 10. The user's preimage x is now visible on-chain (in the witness + of the commit tx's spend of U_lock). The provider's Lightning + node either: + - Observes the preimage on-chain and uses it to claim the + LN HTLC (preimage-watch pattern) + - Or the user explicitly reveals x via off-band channel; the + user has every incentive to do so since the swap is now + complete from their perspective and reveal-then-settle + reduces both parties' channel risk + +Step 11. Provider settles the LN HTLC, capturing A + F. Swap complete. +``` + +### 8.3 What can go wrong + +| Failure | Who has what | Recovery | +| ------- | ------------ | -------- | +| User aborts at Step 5 | Provider has funded U_lock; nothing else moved | Provider refunds U_lock at T_lock (Step 3 ELSE branch). Cost: on-chain fee for U_lock creation. | +| User pays LN (Step 6) but never broadcasts commit (Step 7) | Provider has incoming LN HTLC, U_lock still locked | LN HTLC times out at T_ln, user gets LN funds back. Provider refunds U_lock at T_lock. Both whole. | +| User broadcasts commit but it doesn't confirm before T_lock | User has paid LN, U_lock is being refunded by provider; user's tx might or might not eventually confirm | This is the race condition T_lock is designed to prevent. See §12. With margin, this should not happen; if it does, provider claims U_lock refund and user claims LN refund. Provider has zkCoins still in inventory (no send actually happened since inscription never landed). | +| Provider's server crashes between Step 2 and Step 4 | User has H, has not paid anything | User aborts, no loss. | +| Provider's Bitcoin wallet runs out of funds for U_lock | Pre-condition failure | Provider rejects swap initiation. No loss. | +| Provider refuses to settle LN at Step 11 despite preimage visible | Provider has zkCoins inventory still committed, user has zkCoins (Step 9 succeeded), preimage on-chain | LN HTLC will time out and refund to user. User keeps zkCoins **and** gets LN funds back. **Net: provider loses A+F to itself.** This is asymmetric — provider has no incentive to do this. Documented as provider-side discipline. | + +### 8.4 Why this is trustless + +At no point does either party transfer custody of an asset to the other +party where the other party can withhold reciprocation: + +- User commits LN payment **after** seeing the funded U_lock with the + correct script. +- User claims zkCoins-side **before** revealing preimage (preimage is + in the spend witness, so revealing happens at the moment of + on-chain publication). +- Provider's refund path is gated on T_lock, which is shorter than + T_ln, so provider cannot get U_lock back via timeout while + simultaneously claiming LN. + +The only scenarios where someone loses funds are (a) the user pays LN +and then never claims on-chain, in which case both sides time out and +both are made whole, or (b) one party broadcasts a refund tx with a +fee too low to confirm, which is a fee-management concern not a trust +concern. + +--- + +## 9. Detailed Flow B: zkCoins → LN (User Sells zkCoins for LN Bitcoin) + +This is the **forward submarine** direction: the user holds the on-chain +asset (zkCoins) and wants the off-chain asset (LN bitcoin). The +direction matters because the user is the one initiating the +zkCoins-side send, which means the user controls the inscription +publication — flipping who broadcasts what. + +### 9.1 The role inversion + +In Flow A the user was the inscription broadcaster (Step 7–8). In Flow +B the user is the inscription *originator* (they own the source coins) +but the provider is the LN payer. The naïve "provider generates the +preimage" construction (mirroring Boltz forward submarine swaps) +introduces a non-trustless gap when applied to inscription publication +— see §9.3 for why. The recommended construction is a direct mirror +of Flow A with the swap roles reversed; the preimage generator stays +on the on-chain-asset-acquirer's side. This is detailed in §9.2. + +### 9.2 Recommended pattern: mirror of Flow A + +``` +Step 1. Provider generates preimage x ←$ {0,1}^256. Computes + H = SHA256(x). Provider sends to user: + - H + - provider_zkcoins_recipient_address + - amount A + - provider's LN invoice for amount A − F (standard, not hold) + +Step 2. User's zkCoins server prepares the send proof to + provider_zkcoins_recipient_address with amount A. User signs + Schnorr σ over H(asth ‖ ocr) with their commitment pubkey. + +Step 3. User funds a Bitcoin UTXO U_lock' from their own wallet with + the same Taproot two-leaf construction as Flow A: + + IF-branch (claim): + + ELSE-branch (refund): after T_lock + + User constructs the unsigned commit-reveal pair such that + the commit tx spends U_lock' via the IF-branch and the + reveal tx publishes the inscription containing σ. + +Step 4. User hands provider: + - (asth, ocr, σ) + - U_lock' outpoint + - Unsigned commit-reveal pair + +Step 5. Provider verifies: + - σ verifies against user's commitment pubkey + - asth + ocr describe a send to provider's address of + amount A + - U_lock' is on-chain with the correct script + - Commit tx spends U_lock' and has txid prefix 4242 + +Step 6. Provider pays the Lightning HTLC to user with hash H, + amount A − F. + +Step 7. User claims the LN HTLC. The settlement reveals x to + provider via the LN channel mechanics (preimage-watch + pattern, or explicit reveal off-band). + +Step 8. Provider broadcasts the commit tx with witness + (IF-branch satisfied). + +Step 9. Commit tx confirms. Provider broadcasts reveal tx; + inscription publishes on-chain; zkCoins scanner picks up + and credits provider's address. + +Step 10. Swap complete. +``` + +#### Failure modes for Flow B (Pattern 9.2) + +| Failure | Who has what | Recovery | +| ------- | ------------ | -------- | +| Provider does not pay LN | U_lock' is locked; nothing else moved | User refunds U_lock' at T_lock. Cost: on-chain fee for U_lock' creation. | +| Provider pays LN, user claims, provider broadcasts | Happy path | Swap completes. | +| User claims LN but provider does not broadcast commit tx | Provider has x and own signature; they can broadcast any time before T_lock. If they don't, U_lock' refunds to user. User keeps LN funds; provider keeps zkCoins inventory (no inscription landed). | Provider has no incentive to withhold — they would forgo the zkCoins inflow they already paid for in LN. Documented as provider-side discipline. | +| User funds U_lock' but never sends provider the commit-reveal pair | Pre-condition failure | User can refund U_lock' at T_lock. No LN payment was made. | +| Commit tx stuck in mempool past T_lock | Race condition | Avoided by the ordering constraint of §12.2; if exhausted, U_lock' refunds to user and provider keeps LN funds. Provider must factor this risk into fee pricing. | + +The last failure mode of the table is worth flagging in code: if the +inscription never lands, the zkCoins state never updates. The user's +server-side state shows the send as "prepared" but not "committed", +because the corresponding `Commitment` was never broadcast. The +swap-aware server must release the prepared state if it observes that +the corresponding U_lock' has been refunded, so the user can re-use +those coins for another swap or send. + +### 9.3 Why we rejected the "provider generates preimage" pattern + +A pattern that more closely mirrors Boltz forward submarine swaps — +where the provider generates the preimage and the user constructs the +locked UTXO — does not yield trustlessness for inscription +publication. The reason is structural: + +- If the commit tx is spendable by ` `, then after + provider claims LN (and learns x), the user cannot broadcast the + commit tx on the provider's behalf when provider stalls — only + provider has the signature. T_lock expires, U_lock' refunds, but + the LN payment was already settled, so the user is out A − F. +- If the commit tx is spendable by ` ` instead, the user + can broadcast at any time after learning x — but x is generated by + provider, so the user only learns it after LN settlement. Same + asymmetry, flipped: provider could broadcast a fake LN payment + flow and steal the zkCoins. +- A 2-of-2 IF-branch (` `) lets either + party grief: the preimage reveal alone is no longer sufficient to + unilaterally publish. + +A patch using an **LN hold invoice** to make the user the LN +settlement-controller also fails to close the gap cleanly, because +the user's reveal of x to settle the hold invoice and the provider's +broadcast of the commit tx remain two separate events with no +on-chain coupling between them. + +Pattern 9.2 avoids all of this by having the same party (provider) +control both the LN claim and the on-chain broadcast — the preimage +reveal through LN settlement directly enables that party to broadcast. + +--- + +## 10. Bitcoin Script Construction + +### 10.1 Script template (legacy P2WSH for clarity) + +``` +OP_IF + OP_SHA256 ; H = SHA256(preimage) + OP_EQUALVERIFY + ; whoever can claim via preimage + OP_CHECKSIG +OP_ELSE + ; absolute or relative timeout + OP_CHECKLOCKTIMEVERIFY ; CLTV (absolute) or CSV (relative) + OP_DROP + ; whoever can refund after timeout + OP_CHECKSIG +OP_ENDIF +``` + +Bytes: ~83 (claim + refund) for compressed-pubkey + 32-byte hash. + +### 10.2 Taproot variant (recommended for production) + +Use a Taproot output with two leaves: + +- **Leaf A (claim):** `OP_SHA256 OP_EQUALVERIFY + OP_CHECKSIGVERIFY` +- **Leaf B (refund):** ` OP_CHECKLOCKTIMEVERIFY OP_DROP + OP_CHECKSIGVERIFY` + +Internal key: NUMS point (provably-unknown discrete log) or a +2-of-2 MuSig of claim+refund keys (allows cooperative key-path spend +that hides the script entirely — Boltz's V2 swap design does this). + +Cooperative key-path spending makes successful swaps look like normal +single-sig Taproot spends, improving fungibility. Script-path is the +fallback for non-cooperative resolution. + +### 10.3 Vanity-grinding txid prefix `4242` + +The commit tx of the inscription pair must have txid hex starting with +`4242`. This is a 2-byte prefix, so on average 65k brute-force attempts +to find a matching nonce. zkCoins's existing publisher +(`node/src/publisher.rs`) handles this by varying the commit tx's +output amount (sat-level) until the prefix matches. + +For the swap design, the variable that can be ground is the commit +tx's change output amount (the difference between U_lock + fee-input +and the Taproot commit output amount, sent back to a change address +controlled by whoever is broadcasting). Either the provider (Flow A +pre-construction) or the user (Flow A Step 7 broadcast time, if the +commit tx is finalised then) handles the grind. + +Caveat: changing the change-amount changes the tx hash, but it also +slightly changes the fee, which is fine in mempool. Standardness rules +to watch: the change output must remain ≥ dust threshold (~330 sat for +Taproot). + +### 10.4 Funding the U_lock UTXO + +In Flow A, the provider funds U_lock from their own Bitcoin wallet. +The amount is just enough to cover the commit tx fee + dust threshold +for the commit tx's outputs. The reveal tx pays for itself from the +Taproot output. + +The actual zkCoins coin value (A) is not transferred via Bitcoin — +zkCoins state lives entirely off-chain in the SMT/MMR. The on-chain +piece is the inscription, which is essentially a 64-byte signature +plus envelope overhead. Total on-chain Bitcoin cost per swap is +roughly the same as a Boltz swap minus the actual L1 payout: ~250 +sats at current fee rates. + +### 10.5 Pubkey choices + +- **claim_pubkey:** the user's Bitcoin spending pubkey for Flow A, or + the provider's for Flow B. Should be a fresh key per swap for + unlinkability. +- **refund_pubkey:** the counterparty's. Same fresh-key recommendation. + +In a Taproot internal-key construction, the cooperative key is a MuSig +of (claim_pubkey, refund_pubkey). + +--- + +## 11. The Inscription Reveal Tx — Anatomy + +For completeness, the reveal tx that ultimately publishes the +`Commitment` payload: + +- **Input:** the commit tx's Taproot output. +- **Witness:** Taproot script-path spend, providing + - The inscription script (Ordinals-style envelope: `OP_FALSE OP_IF + "ord" OP_ENDIF`, with `` being the serialised + `Commitment` plus zkCoins-specific envelope tag) + - The internal pubkey + - The control block proving the script is in the Taproot script tree +- **Output:** a P2WPKH or P2TR output of dust value going back to the + publisher (the reveal tx is a "burn the inscription" tx; the output + is just there because every tx needs an output). + +This is unchanged from the current zkCoins publisher implementation; +the only thing the swap design touches is the commit tx's input +(U_lock), not the reveal tx itself. + +--- + +## 12. Timing Coordination (CLTV Deltas) + +### 12.1 The two timeouts + +- **`T_lock`:** absolute Bitcoin block height at which the on-chain + U_lock UTXO becomes refundable to the provider (Flow A) or user + (Flow B). Set at swap creation time. +- **`T_ln`:** the CLTV-delta of the Lightning HTLC, in blocks. The LN + payment is refundable to the payer after the HTLC's expiry block, + which is the most recently locked-in block height + `T_ln`. + +### 12.2 The ordering constraint + +The fundamental requirement for trustlessness: + +``` +T_lock < (current_height + T_ln) - safety_margin +``` + +Equivalently: the on-chain refund path must mature *before* the LN +refund path matures. + +Why: imagine the alternative, `T_lock > current_height + T_ln`. Then +LN refunds first. Suppose the user pays LN, never claims on-chain. LN +refunds the user at `T_ln`. Provider's U_lock is still locked until +`T_lock`. But by then, the user has their LN funds back AND can still +broadcast the commit tx (they have the preimage they generated, plus +their claim signature). User publishes inscription, scanner credits +user, user has both LN-refunded funds and new zkCoins. Provider loses +inventory. + +With `T_lock < current_height + T_ln − safety_margin`, the order is: +T_lock fires first → provider refunds U_lock → user can no longer +claim → LN refunds at `T_ln` later. Both whole. + +### 12.3 Typical values + +- LN CLTV-delta: most modern nodes use 40 blocks final + up to 144 per + hop. End-to-end on a single-hop swap (user ↔ provider direct + channel) typically ~144 blocks ≈ 24 hours. +- On-chain `T_lock`: should be ~24h or less from now to leave a clear + margin. Typical Boltz value: 144 blocks from creation. +- Safety margin: at least 6 blocks (~1 hour) to allow for confirmation + delays at the boundary. Boltz uses ~12-block margin. + +### 12.4 Required confirmation depth for U_lock + +Before the user broadcasts the claim tx (Flow A Step 7), U_lock must +be confirmed to a depth where the provider cannot RBF or double-spend +it. Standard recommendation: 1 confirmation is sufficient if U_lock's +funding tx is below RBF threshold and confirmed in a non-reorg-prone +context; 2-3 confirmations for higher-value swaps. This is independent +of the D7 reorg-safety question, which concerns confirmation depth of +the *inscription publication*, not U_lock. + +### 12.5 The proof-time question + +Provider's send proof generation (zkCoins server side): + +- SP1 today: tens of seconds to a few minutes warm. +- Plonky2 post-cutover target: ≤1 second warm. + +This happens between Step 1 (user requests swap) and Step 4 (provider +hands user the commit-reveal pair). Even with SP1, the proof time +is negligible compared to the 24-hour swap window. **Plonky2 is not +a swap dependency.** + +(The proof time *would* matter for some hypothetical +ultra-low-latency swap product — pay LN, get zkCoins balance within +3 seconds. Such a product is not on the roadmap and would require +solving D7 at the same time anyway.) + +--- + +## 13. Failure Modes Matrix (Both Flows) + +Summary of all scenarios. "User" and "Provider" refer to the swap +counterparties regardless of direction. + +| Scenario | Who lost what | Recovery mechanism | +| -------- | ------------- | ------------------ | +| Both parties cooperate, all txs confirm | Nothing lost; everyone gets expected outcome | Happy path | +| User aborts before LN payment | Provider has funded U_lock + spent proof time | U_lock refund at T_lock; proof time is a sunk cost (~free) | +| LN payment fails to route | No state change | LN-layer retry or refund | +| LN payment succeeds, user fails to claim on-chain (Flow A) | Provider has LN HTLC pending, user has paid LN | LN HTLC times out at T_ln, user refunded; U_lock refunds at T_lock | +| User claims on-chain but commit tx stuck in mempool past T_lock | Race condition | Avoided by §12.2 ordering constraint with margin; if margin exhausted, both refund — provider via U_lock refund, user via LN refund (assuming commit tx also evicted from mempool) | +| Provider's Bitcoin wallet outage between Step 3 and broadcast | Pre-condition failure | Swap not initiated; no loss | +| Bitcoin reorg removes the confirmed commit tx | See §16 (D7 dependency) | Provider waits ≥6 confirms before claiming LN | +| zkCoins scanner is offline | Inscription is on-chain but state lags | Scanner catches up on restart; no swap-mechanism impact | +| Provider claims LN but withholds inscription broadcast (Flow B) | Provider has LN, has not delivered zkCoins | Provider has no incentive — they would forgo the zkCoins inflow they already paid for in LN. If they do withhold past T_lock, U_lock' refunds to user; user keeps LN funds. See §9.2 failure-mode table. | +| Provider sets up Sybil swaps to grief | None directly | DoS mitigation: rate-limit, optionally require small upfront fee or deposit | + +--- + +## 14. Provider Operational Considerations + +### 14.1 Liquidity management + +The provider needs two inventories simultaneously: + +- **LN liquidity (outbound + inbound):** outbound for Flow B (paying + user), inbound for Flow A (receiving user's payment). Standard LN + channel management. Boltz publishes inbound/outbound LP rates + dynamically. +- **zkCoins inventory:** one or more operator accounts with sufficient + balance in zkCoins to honour Flow A swaps. Inventory rebalances: + Flow B replenishes the operator account (user sends zkCoins to + provider's address); Flow A depletes it. Net flows over time should + be matched by an out-of-band rebalancing flow (provider mints new + zkCoins by depositing BTC, or burns zkCoins for BTC, via whatever + L1-zkCoins bridge mechanism is in place). + +zkCoins does not currently have a published bridge mechanism. The +MVP-era assumption is that the provider is also the minter (the +holder of `MINTING_ADDRESS`), which trivially provides inventory. +Once the protocol has a real bridge (BitVM-style or otherwise), the +provider can be any party with that bridge's deposit/withdraw +capability. + +### 14.2 Fee model + +Three components, mirroring Boltz: + +- **On-chain fee:** the actual Bitcoin tx fee for the commit-reveal + pair. Paid out of U_lock funding amount; the user effectively pays + this since they are the asset-acquirer in Flow A. +- **Routing fee:** LN routing cost on the provider's payment in Flow B, + or absorbed if Flow A receives a direct payment. +- **Provider margin:** a percentage of swap amount, the actual revenue + source for the provider. + +Typical Boltz total fees: 0.1–0.5% of swap amount + ~250 sat on-chain. + +### 14.3 Inventory locked during swap + +Between Step 2 (provider prepares send) and Step 9 (inscription +confirms), the provider's zkCoins inventory is committed but +not-yet-published. The provider must not initiate another swap that +would also commit the same balance — server-side concurrency control +required. + +Concretely, the operator account's "soft balance" must reflect: +`balance − Σ(pending_swap_amounts)`, where `pending_swap_amounts` +includes all amounts for prepared-but-not-confirmed sends. + +This is the "stuck inventory" problem of any submarine swap provider; +Boltz solves it with parallel HTLC tracking. zkCoins-side it requires +the swap-aware server to track prepared swaps until inscription +confirms (or refund completes). + +### 14.4 Watching the chain + +The provider's Bitcoin watcher must monitor: + +- U_lock UTXOs they have created (for refund-at-T_lock) +- Commit txs spending U_lock UTXOs (to extract preimages and claim LN + in Flow A, or to confirm completion in Flow B) +- Reveal txs (to confirm scanner-pickup) +- Bitcoin reorgs affecting any of the above + +LND's `chainntfn` or BTCD's notification API are the standard tools. +Boltz's backend repo (`BoltzExchange/boltz-backend`) has a battle-tested +watcher implementation that could be forked. + +### 14.5 The grind for `4242` prefix + +The vanity-grind (§10.3) takes time — at 65k attempts average, a +modern CPU can grind a single 4242-prefix tx in ~1 second. Not a +bottleneck, but should be parallelised if the provider expects high +swap volume. Easy to GPU-accelerate; not necessary for v1. + +--- + +## 15. Privacy Analysis + +### 15.1 What the provider learns + +- **Recipient zkCoins address** (Flow A) or sender's zkCoins address + (Flow B). The full `Address = H(initial_pubkey)`. Acceptable for + regulated providers who already perform KYC on swap counterparties. +- **Amount.** Necessarily, since it's the swap amount. +- **The user's Bitcoin pubkey** (claim/refund pubkey on U_lock). + Recommend fresh key per swap. +- **The user's LN node identity** for the LN payment. Single-hop direct + channel reveals; multi-hop preserves payer anonymity to the same + extent any LN payment does. + +### 15.2 What is on-chain + +- The funded U_lock UTXO (a 2-leaf Taproot output). +- The commit tx spending U_lock (Taproot output to inscription, with + txid prefix `4242`). +- The reveal tx with inscription payload in witness. +- If swap fails: a refund tx spending U_lock via the ELSE branch. + +A chain observer sees: +- A Taproot input being spent with either script path (failure case) + or — if cooperative key-path is used (§10.2) — what looks like a + normal single-sig Taproot spend +- A subsequent commit tx with txid prefix `4242`, which is + zkCoins-protocol-specific and identifies the spend as a zkCoins + send + +So the swap, on the Bitcoin side, is publicly identifiable as a zkCoins +send. Whether it's a *swap* (vs. a direct user-initiated send) is +inferable from the U_lock script structure if non-cooperative. With +cooperative key-path resolution, the swap looks identical to a direct +zkCoins send. + +### 15.3 What is in Lightning + +A standard Lightning HTLC of amount A ± F with hash H. Same privacy +properties as any LN payment of similar size. If the LN counterparty +is the provider directly, the provider sees both ends; if routed +through hops, intermediate hops see the hash and amounts (standard LN +payment privacy). + +### 15.4 What PTLCs would change + +PTLCs would eliminate (a) the on-chain hash visibility and (b) the LN +hash → on-chain hash correlation. The on-chain spend would be +indistinguishable from any single-sig Taproot key-path spend, and the +LN payment would use a point lock that does not appear on Bitcoin L1 +in plaintext. + +This is purely an upgrade; HTLC v1 is already trustless. + +### 15.5 zkCoins-internal privacy: D2/D10 + +D2 (plaintext recipient) is a pre-mainnet blocker for general zkCoins +privacy, but for the swap design it does not introduce any new +linkability — the provider already knows the recipient address by +construction (the user told them in Step 1). When D2/D10 are fixed +with hiding commitments, the swap protocol must include the per-coin +randomness in the Step 1 user-to-provider message so the provider can +build a coin opening to the hidden recipient. This is a minor protocol +update, not a redesign. + +--- + +## 16. D7 Reorg Safety — The Open Dependency + +### 16.1 What D7 is + +From `SPEC.md` §15 and `MIGRATION_RESEARCH.md` §3, D7: + +> No conditional-noop path. Paper supports `conditional_nav` — if the +> claimed nullifier-accum is no longer a prefix of the chain's, the tx +> becomes a no-op. + +In zkCoins-as-implemented, when the scanner processes an inscription +and updates the SMT, that update is taken as final. If Bitcoin reorgs +and the inscription tx is reorganised out, the scanner has no graceful +way to undo the SMT update. The protocol "trusts" the scanner's +view of the chain. + +### 16.2 What this means for swaps + +For Flow A, between Step 8 (commit tx confirms) and Step 11 (provider +settles LN), there is a window where: + +- Inscription is on-chain at depth `d` (where `d` is small immediately + after confirmation) +- Provider sees preimage on-chain +- If provider settles LN now and Bitcoin reorgs at depth ≥ d, the + inscription is no longer in the chain — but the scanner already + ingested it. zkCoins state has the new coin (assigned to user) but + the chain does not. + +This is a soundness problem for zkCoins (D7), not for the swap. The +swap-level mitigation is: **provider waits for sufficient confirmation +depth before settling LN**. + +### 16.3 Required confirmation depth + +This is the operationally interesting question. Options: + +- **Same as Boltz BTC ↔ LN swaps:** Boltz settles after ~3 BTC + confirmations. The argument is that 3 confirmations is sufficient + against routine reorgs; deeper reorgs are rare-enough events that + the residual risk is absorbed by the provider as part of operational + cost. +- **More conservative:** wait for 6 confirmations (Bitcoin's + traditional "confirmed" threshold) to align with bitcoin custodial + practice. +- **Most conservative:** wait for `CONFIRMS_TO_FINALITY` set by + zkCoins protocol parameters; could be 6 or 100 depending on threat + model. + +A regulated provider should default to **6 confirmations** (~1 hour +wait) until D7 is fixed. After D7 is fixed (the scanner can gracefully +handle inscription reorg by rolling back state and re-inserting), the +depth can drop back to 3 or even 1 with appropriate scanner logic. + +### 16.4 LN CLTV must accommodate this wait + +The LN-side `T_ln` must comfortably exceed the wait time. With +6-confirm depth (~1 hour) + safety margin + variable Bitcoin block +times (could be 2x mean), an LN CLTV of 144 blocks (~24h) is more +than sufficient. + +### 16.5 D7 fix is tracked separately + +D7 is in the Pre-Mainnet Hardening block (`ROADMAP.md`), estimated +4–5 days of work. It is independent of the swap design and required +for mainnet regardless. + +The dependency for the swap launch is: **swap can ship before D7 is +fixed, with conservative confirmation-depth gating**. D7 fix later +just allows lower latency. + +--- + +## 17. Plonky2 Relevance — Orthogonal to the Swap Design + +The PR #17 Plonky2 migration is **not a blocker** for swap +implementation. Specifically: + +- **Performance:** SP1 minute-scale proofs fit comfortably in the + 24-hour LN CLTV window. Plonky2 sub-second proofs reduce + provider-side inventory-locked-time from minutes to seconds, which + is a per-swap operational improvement, not a correctness condition. +- **Hash function (Poseidon vs SHA256):** does not touch the swap + mechanism. SHA256 is used by Lightning (HTLC preimage) and BIP-340 + Schnorr (commitment signature). Poseidon is used internally for + Merkle structures. The swap construction is hash-agnostic. +- **Coin model:** unchanged by Plonky2. The swap design's core insight + (atomicity on the Bitcoin funding tx, not the coin layer) is forced + by the coin model and persists across proof-system migrations. +- **Schnorr signing:** unchanged. The signature on H(asth ‖ ocr) is + BIP-340 over secp256k1, exactly the signature that goes into the + inscription payload, exactly the signature the scanner verifies. + +Implementation can therefore run in parallel to PR #17 without +contention. The swap code touches `server/` (new endpoints) and adds a +new operational component (Bitcoin script construction, LN node +integration). Neither touches `program-plonky2/` or `program/`. + +If swap implementation starts before PR #17 lands, it should be done +behind feature flags or in a side-branch to be merged after the +Plonky2 cutover; this avoids dealing with two simultaneous major +refactors. + +--- + +## 18. Comparison Tables + +### 18.1 vs. Boltz BTC ↔ LN + +| Property | Boltz BTC ↔ LN | This (LN ↔ zkCoins) | +| -------- | -------------- | ------------------- | +| Trust model | Trustless | Trustless | +| On-chain side primitive | P2WSH/P2TR HTLC | P2WSH/P2TR HTLC gating inscription publication | +| What's swapped on-chain side | Native BTC value | zkCoins coin (off-chain state update triggered by inscription) | +| On-chain footprint per swap | ~250 sat fees | ~250 sat fees | +| LN side | Standard HTLC | Standard HTLC | +| Wait for confirmation depth | ~3 confirms | ~6 confirms (D7 mitigation, until fixed) | +| Provider role | Liquidity provider, custodian of *neither* side | Same | +| PTLC upgrade path | Boltz V3 (announced) | Trivial mirror once LN PTLC matures | + +### 18.2 vs. Taproot Assets atomic swaps + +| Property | Taproot Assets | This (LN ↔ zkCoins) | +| -------- | -------------- | ------------------- | +| Asset locked on Bitcoin L1 | Yes (in Taproot leaves) | No (zkCoins state is off-chain) | +| Asset issuance | On-chain proofs | Off-chain proofs (PCD) | +| Swap primitive | PSBT-based, atomic | HTLC on inscription funding tx | +| Cross-chain step | None needed (asset lives on BTC) | The "chain" boundary is Bitcoin (LN funds + inscription) ↔ zkCoins state | +| RFQ-style quote mechanism | Yes, native | Easy to add as out-of-band layer | + +### 18.3 vs. naïve "trusted swap service" + +| Property | Trusted custodial service | Trustless HTLC | +| -------- | ------------------------- | --------------- | +| Trust assumption | The custodian honours its claims | None (cryptographic) | +| Bitcoin-script complexity | None | Standard P2TR with 2 leaves | +| Build effort | Low (just an exchange API) | Medium (Boltz-backend fork + zkCoins integration) | +| Risk if provider compromised | User funds at risk | None — cryptographic atomicity | +| Suitable for production | Yes, with appropriate insurance / disclosures | Yes | + +--- + +## 19. Implementation Roadmap + +A draft sequence; not a commitment. + +### 19.1 Phase 0: prerequisites + +- D7 reorg fix in zkCoins (pre-mainnet hardening block; can be deferred + if conservative confirm-depth gating is used) +- Operator account funded with sufficient zkCoins inventory +- Provider Bitcoin wallet with Lightning channel(s) +- LND or CLN node running (standard HTLC support sufficient; hold + invoices not required by the recommended Pattern 9.2) + +### 19.2 Phase 1: swap engine + +- Bitcoin script construction module (P2WSH + P2TR variants, both + flows) +- Watcher: monitor U_lock UTXOs, commit txs, reveal txs, refund-window +- Vanity-grinder for `4242` prefix (or reuse existing + `node/src/publisher.rs` logic if it can be extracted) +- Inscription payload generator that can produce a `Commitment` for a + *specified* recipient and amount, signed by the operator key, + *without* publishing on-chain — Step 2 of Flow A + +### 19.3 Phase 2: API surface + +- `POST /api/swap/quote` — user requests quote, provider returns + amount + fee + expected timeouts +- `POST /api/swap/initiate` (Flow A) — user submits H + recipient + address + amount + refund pubkey, gets back commit-reveal pair + + U_lock funded outpoint +- `POST /api/swap/lock` (Flow B) — provider gives user the H and + provider's claim pubkey; user constructs their side and notifies +- `GET /api/swap/{id}` — status (waiting-for-confirms, settled, + refunded, etc.) +- WebSocket for live status updates + +### 19.4 Phase 3: LN integration + +- Hook the swap engine into LND/CLN's HTLC settlement +- Configure routing fee thresholds, channel rebalancing alerts +- Define the rate-card (provider margin) + +### 19.5 Phase 4: production hardening + +- Rate limits per IP / per user +- Sybil resistance: optional small upfront fee +- Monitoring + alerting (Grafana board for in-flight swaps, alert on + stuck/expiring swaps) +- Recovery tooling for stuck swaps (manual operator intervention if + watcher fails) + +### 19.6 Estimated effort + +- Phase 1: 2–3 weeks +- Phase 2: 1 week +- Phase 3: 1 week +- Phase 4: 1–2 weeks +- Total: 5–7 weeks for a production-grade implementation, assuming + Boltz-backend code can be partially reused for watcher/grinder + +--- + +## 20. Open Questions + +1. **Required confirmation depth for inscription.** Set initially to + 6 confirms (~1 hour wait); re-evaluate after D7 fix lands. + +2. **Cooperative key-path for U_lock Taproot internal key.** MuSig of + (claim_pubkey, refund_pubkey) gives best on-chain privacy but adds + protocol complexity (round of MuSig key aggregation per swap). For + v1, recommend NUMS internal key (cheaper, less private). Revisit + for v2 alongside PTLC. + +3. **Where does the operator account's privkey live?** The Schnorr + signature on H(asth ‖ ocr) (Step 2 of Flow A) needs to happen + server-side, because the operator is the sender. This means the + operator account's commitment key is server-resident. Same + architectural assumption as for any operator-issued zkCoins coin; + should be documented in ops runbook. + +4. **Cross-swap correlation.** If a single operator account is reused + for many swaps, all those swaps' inscriptions chain through the + same account state. A chain analyst can correlate them. Mitigation: + rotate operator accounts periodically. Not a blocker. + +5. **D7 fix interaction.** Once D7 lands with `conditional_nav`-style + logic, the scanner can roll back. The swap design's confirm-depth + parameter should drop, and the swap engine should subscribe to + reorg notifications. Sketch the rollback-aware swap state machine + when D7 is implemented; not now. + +6. **Fee market integration.** Should swap quotes include a + user-selected fee tier (fast/slow Bitcoin confirmation, expected + wait time)? Boltz does this. Adds UI but not protocol complexity. + +7. **Maximum swap size.** Bounded by (a) operator zkCoins inventory, + (b) operator LN inbound liquidity. Define soft and hard limits. + Boltz publishes these on an info endpoint. + +--- + +## 21. References + +- [Shielded CSV paper (Nick, Eagen, Linus)](https://eprint.iacr.org/2025/068) +- [Shielded CSV reference implementation](https://github.com/ShieldedCSV/ShieldedCSV) +- [Boltz backend (HTLC-based submarine swap reference implementation)](https://github.com/BoltzExchange/boltz-backend) +- [Boltz lifecycle docs](https://github.com/BoltzExchange/boltz-backend/blob/master/docs/lifecycle.md) +- [Boltz blog: Lightning ↔ Liquid via submarine swaps](https://bitcoinmagazine.com/business/between-bitcoin-layers-boltz-builds-trustless-transfers) +- [Submarine Swaps — Lightning Engineering Builder's Guide](https://docs.lightning.engineering/the-lightning-network/multihop-payments/understanding-submarine-swaps) +- [Multi-Party Submarine Swaps (conduition.io)](https://conduition.io/scriptless/multi-party-submarine-swaps/) +- [PTLCs — Bitcoin Optech](https://bitcoinops.org/en/topics/ptlc/) +- [Adaptor signatures — Bitcoin Optech](https://bitcoinops.org/en/topics/adaptor-signatures/) +- [Scriptless Scripts multi-hop locks (BlockstreamResearch)](https://github.com/BlockstreamResearch/scriptless-scripts/blob/master/md/multi-hop-locks.md) +- [Multichain Taprootized Atomic Swaps (Distributed Lab, arXiv 2402.16735)](https://arxiv.org/abs/2402.16735) +- [comit-network/xmr-btc-swap (adaptor-sig atomic swap reference)](https://github.com/comit-network/xmr-btc-swap) +- [Taproot Assets Trustless Swap (Lightning Labs)](https://docs.lightning.engineering/the-lightning-network/taproot-assets/trustless-swap) +- [Taproot Assets RFQ protocol](https://docs.lightning.engineering/lightning-network-tools/taproot-assets/rfq) +- [Plonky2 SHA256 benchmarks](https://hackmd.io/@clientsideproving/Plonky2MobileBench) +- [BIP-340 Schnorr signatures](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- [BIP-341 Taproot](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) +- [BIP-65 OP_CHECKLOCKTIMEVERIFY](https://github.com/bitcoin/bips/blob/master/bip-0065.mediawiki) + +--- + +## 22. Change Log + +| Date | Change | +| ---------- | ------ | +| 2026-05-17 | Initial draft. | +| 2026-05-17 | Consistency audit pass: add branch note at the top explaining that `SPEC.md` / `MIGRATION_RESEARCH.md` / `ROADMAP.md` currently live on `feat/plonky2-migration` only. | +| 2026-05-17 | Audit round 2: restructure §9 from a stream-of-consciousness exploration of four candidate patterns to a single recommended construction (§9.2 mirror of Flow A) plus a brief §9.3 explaining why the alternatives were rejected. Promote §9.2 to the canonical Flow B; remove §9.3 (LN hold invoice) and §9.4 (renamed to §9.2) as numbered alternatives. Fix four broken internal cross-references (§10/§15 corrected to §12/§16). Renumber open-questions list to drop the gap left after removing the pattern-choice question. | +| 2026-05-17 | Audit round 3: harmonise header structure across all three bridge docs (Status / Authoritative source / Audience / Branch note, in that order). Remove organisation-specific references ("DFX", a personal name) — replace with generic operator/issuer wording, consistent with the rest of the repo where the same convention is followed (`MIGRATION_RESEARCH.md` is the single exception with one such mention). Define `asth` / `ocr` at first use in §4.2. Tighten §17 heading. | diff --git a/MIGRATION_RESEARCH.md b/MIGRATION_RESEARCH.md new file mode 100644 index 00000000..c05d124a --- /dev/null +++ b/MIGRATION_RESEARCH.md @@ -0,0 +1,1333 @@ +# Migration Research: References and Adoption Decisions + +Companion document to [`SPEC.md`](./SPEC.md). Summarises what we can take from the upstream references, and — more importantly — flags where our current implementation has diverged from the published Shielded CSV protocol. Read this before writing any Plonky2 code. + +> **Fresh session?** Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md) +> § "Working on the Plonky2 Migration" first for the project invariants +> and reading order. This file's §7 (Lessons Learned) is the *required +> reading before touching the affected code areas*. + +--- + +## TL;DR + +1. **`BitVM/zkCoins` is a 182-LOC IVC toy, not a zkCoins prototype.** It gives us a Plonky2 version pin and a cyclic-recursion code recipe, nothing more. +2. **The real normative reference is `ShieldedCSV/ShieldedCSV`** — a non-circuit Rust implementation of the paper's PCD predicate. +3. **Our current SP1 implementation has departed from the published protocol in 11 distinct ways.** Some are simplifications (Schnorr commitment on a Taproot inscription instead of half-aggregate nullifier publication), some are arguably regressions (recipient is plaintext `Address`, linkable across coins), some are missing features (fee output, conditional-noop on reorg). +4. **Decision point for the maintainers:** Are we implementing _Shielded CSV as published_, or are we shipping a zkCoins MVP that intentionally diverges? Both are defensible; we just need to pick before we re-implement the circuit in Plonky2, otherwise we lock in design choices that aren't reviewable against any spec. + +--- + +## 1. `BitVM/zkCoins` Plonky2 Prototype + +**Location (local clone):** `~/Documents/GitHub/zkcoins/BitVM-zkCoins-reference/` +**Upstream:** https://github.com/BitVM/zkCoins +**Size:** 1 crate, 1 file, 182 LOC, 10 commits, last commit `bd8a8c2 "Recursive proving kinda works"` — WIP/abandoned. + +### What it is + +A Plonky2 IVC skeleton (`fn main()` with `println!` demos, no tests) that: +- Pins `plonky2 = "0.2.0"`, `D = 2`, `PoseidonGoldilocksConfig`, `CircuitConfig::standard_recursion_config()`. +- Uses `conditionally_verify_cyclic_proof_or_dummy` to verify two recursive proofs against the same circuit digest. +- Has a placeholder `mul_add` payload (computes a running sum). +- Demonstrates `add_verifier_data_public_inputs` for circuit-digest pinning. + +### What it isn't + +Despite the repo name, it contains **none** of: SMT, MMR, AccountState, Coin, ProofData, Schnorr verification, recipient model, Bitcoin link, tests, server, scanner. The single `main.rs` is a Plonky2 tutorial-grade IVC demo with no zkCoins semantics. + +### Adoption decisions + +| Aspect | Decision | Why | +| --- | --- | --- | +| `plonky2 = "0.2.0"` version pin | **Adopt** | Same version as the upstream `BitVM/zkCoins` reference; ecosystem-current. | +| `PoseidonGoldilocksConfig`, `D = 2` | **Adopt** | Standard Plonky2 recursion setup. Matches SPEC §12.1. | +| `standard_recursion_config()` | **Adopt as starting point** | Re-evaluate gate budget once we know our N-coin fanout. | +| `common_data_for_recursion()` two-pass build pattern | **Adopt with adaptation** | Plonky2 idiom to stabilise public-input count under cyclic recursion. Need to extend to our (prev account proof + N coin proofs) fanout. | +| `conditionally_verify_cyclic_proof_or_dummy` for Initial vs. Update branch | **Adapt** | Correct shape but only 2 verification slots in the demo; we need 1 + max_in_coins. | +| `add_verifier_data_public_inputs` | **Adopt** | Direct realisation of SPEC §10's "same-circuit" assertion. | +| Balance logic (commit `60e9d94`) | **Discard** | Toy `mul_add`, no relation to our model. | +| Everything else | **Write from scratch, basing on our SP1 modules** | The reference doesn't have it. | + +**Bottom line:** the BitVM repo saves us maybe 20-30 lines of Plonky2 boilerplate. It does not give us the SMT, MMR, AccountState, or Coin logic for free — those have to be ported from our SP1 modules to Plonky2 constraints by hand. + +--- + +## 2. Shielded CSV Paper (eprint 2025/068) + +### Sources used + +The eprint PDF returned HTTP 403 to automated fetches; instead the analysis relied on: +- **`github.com/ShieldedCSV/ShieldedCSV`** — the **upstream reference implementation** of the PCD compliance predicate, by Nick/Eagen/Linus. This is the normative source. +- Blockstream blog ("Bitcoin's Shielded CSV Protocol Explained") +- Bitcoin Magazine technical article on Shielded CSV +- Bitcoindev mailing-list summary +- Independent analyses (Fairgate newsletter, eliel.nfinic.com) + +Items below cite **[REF-IMPL]** when the source is the upstream Rust code, **[SECONDARY]** when from blogs/list posts. + +### Protocol primitives the paper actually uses + +From `ShieldedCSV/ShieldedCSV/lib.rs`: + +```rust +pub struct AggregateNullifier { + pub pks: Vec, // each pk = one account update + pub sig: Signature, // half-aggregate BIP-340 Schnorr + pub fee_acct_comm: Commitment, // hiding commitment to publisher's acct +} + +pub struct CoinEssence { + pub address: Commitment, // HIDING commit(acct_id, rand) — not a plain Address + pub amount: u64, + pub idx: [u8; 2], // 2-byte coin index in tx + // FEE_IDX = [0xff, 0xff] +} + +type CoinID = [u8; 34]; // tx_hash(32) || idx(2) +type CoinIDOnChain = [u8; 8]; // blockchain_loc(6) || idx(2) + // 21 bits block height + 22 bits in-block idx +``` + +And from `primitives.rs`: + +- **`AccM` (strong A-SEC accumulator)** for spent coins, keyed by `CoinIDOnChain`, **lexicographically ordered = creation-order ordered**, supports `verify_non_membership_and_insert`. Order matters because it lets managers prune historical subtrees. +- **`ToSAcc` (tuple-of-sets accumulator)** for the on-chain nullifier history, holding `(pk, sig_commitment, blockchain_location, fee_acct_comm)` tuples, supporting `append_set`, `prove_union_membership`, `prove_is_prefix`, `prove_distinct_element`. +- `Commitment` (Pedersen-style, hiding+binding) wraps every recipient address with per-coin randomness for unlinkability. + +### Hash function and field choice + +The reference implementation leaves `hash` and `Commitment::commit` as **unimplemented stubs** — the paper is hash-agnostic, requires only CRH/RO behaviour for `hash` and hiding+binding for `Commitment`. Only BIP-340 Schnorr (secp256k1) is mandatory, because Bitcoin verifies it. **Conclusion:** Poseidon over Goldilocks is within the paper's allowed instantiation space; SHA256 was not normative either. ✓ + +### Recursion + +Paper uses PCD (Proof-Carrying Data) as the abstraction — explicitly **agnostic between recursive SNARKs and folding schemes (Nova-style)**. No mandated recursion-depth bound. **Conclusion:** Plonky2 cyclic recursion is fine. ✓ + +### Account model + +`AcctStateEssence { id: PublicKey, balance: u64, nullifier_pk: PublicKey }` — matches our `AccountState { owner, balance, public_key }` structurally, with two differences: + +- The paper's `id` is itself a `PublicKey` (XOnlyPublicKey), **not** `H(initial_pk)`. We added the extra hash; the paper doesn't. +- Each `AcctState` carries both `spent_accum` (≈ our `coin_history_root`) **and** a claimed `nullifier_accum` snapshot — we carry only the former, which is one of the divergences below. + +--- + +## 3. The 11 Divergences (Our SPEC vs. the Paper) + +Numbered D1–D11. Each is a concrete protocol-level departure. Some are deliberate MVP simplifications, some are accidental, some have security implications. We need to triage them explicitly. + +| # | Our SPEC says | Paper says | Severity | +| --- | --- | --- | --- | +| **D1** | `identifier = H(asth ‖ u32_be(idx))` (32 B), tied to sender's next account-state hash | `CoinID = tx_hash ‖ idx_2B` (34 B); `CoinIDOnChain = blockchain_loc(6 B) ‖ idx_2B` (8 B) for accumulator efficiency. | **Protocol-level**: paper IDs are short on purpose. | +| **D2** | `Coin { recipient: Address = H(initial_pk) }` — plaintext recipient | `coin.essence.address = Commitment::commit(acct_id, rand)` — **hiding** commit, per-coin random. | **Privacy regression**: without `rand`, multiple coins to the same recipient are trivially linkable. | +| **D3** | Single Schnorr commitment over `H(asth ‖ ocr)` posted as Taproot inscription, txid prefix `4242` | `AggregateNullifier` — **half-aggregate BIP-340 Schnorr** posted by third-party publishers, no inscription envelope mandate, no `H(asth ‖ ocr)` message. | **Architectural**: we replaced the paper's publisher layer with self-publishing. | +| **D4** | Global state = SMT keyed by `H(pk)`, value `H(asth ‖ ocr)`; MMR over `H(smt_root ‖ prev_mmr_root)` | Global state = `ToSAcc` over `(pk, sig_comm, blockchain_loc, fee_acct_comm)` tuples, with prefix and union-membership proofs. | **Protocol-level**: coin proofs in the paper prefix-prove against `ToSAcc`; our SMT/MMR shape doesn't expose the prefix interface. | +| **D5** | SMT depth 256, hash-keyed (uniform random) | `AccM` is lex-ordered by `CoinIDOnChain` — explicitly to enable pruning old subtrees. | **Scalability**: uniform hash-keyed SMT cannot prune. | +| **D6** | No fee field; no fee output | `fee: u64` field; `FEE_IDX = 0xffff` reserved index; `payment_finalize_fee` mints exactly one coin to the publisher. | **Missing feature**: our circuit cannot produce a fee output. | +| **D7** | No conditional-noop path | Paper supports `conditional_nav` — if the claimed nullifier-accum is no longer a prefix of the chain's, the tx becomes a no-op. | **Reorg safety**: our impl doesn't degrade gracefully under reorgs. | +| **D8** | `Coin` doesn't carry a `nullifier_accum` snapshot | Paper's `Coin` carries the `nullifier_accum` it was minted under; receiver checks this is in their local nullifier-accum history. | **Soundness**: without this snapshot, recipients trust the proof's history-root rather than verifying it independently. | +| **D9** | No range/uniqueness checks on `coin_index` | `idx` is strictly increasing within a tx; `idx == FEE_IDX` reserved. | **Soundness**: malformed coins not rejected. | +| **D10** | `apply_coin` checks `coin.recipient == self.owner` against plaintext owner | Paper opens `Commitment::commit(acct_id, rand)` with per-coin `acct_comm_rand` provided as witness. | **Tied to D2**: same hiding-commit issue. | +| **D11** | `MINTING_ADDRESS` hard-coded; one allowed minter | Paper has explicit `issuance(IssuanceProof)` predicate branch (currently stub in upstream); `payment_init_newacct` starts fresh accounts at `balance = 0, nullifier_pk = acct_id`. | **Architectural**: the minting model is left more open in the paper. | + +### Triage recommendation + +For a Plonky2 MVP shipping in weeks-not-months: + +- **Keep as deliberate simplifications (document in README + this file):** D1, D3, D5, D6, D11. These trade flexibility for shipping speed; explicitly call them out so reviewers know. +- **Should-fix before mainnet:** D2 + D10 (privacy regression — recipient unlinkability is a stated zkCoins selling point), D7 (reorg safety — Bitcoin reorgs happen), D8 (soundness — receivers should be able to verify coin age locally). +- **Open / discuss with the maintainers:** D4 (does the SMT+MMR scanner model actually give the same security properties as `ToSAcc` for our threat model?), D9 (cheap to add). + +--- + +## 4. Combined Adoption Decisions + +### From `BitVM/zkCoins` +- Cargo manifest: `plonky2 = "0.2.0"`, no other deps from there. +- IVC scaffolding: `common_data_for_recursion`, `conditionally_verify_cyclic_proof_or_dummy`, `add_verifier_data_public_inputs`. +- Public-input-count stabilisation pattern (the two-pass `builder.print_gate_counts(0)` / build / discard / re-build trick). + +### From `ShieldedCSV/ShieldedCSV` (paper reference impl) +- **Data-type shapes** for `CoinEssence`, `AcctStateEssence`, `AggregateNullifier`. Even if we stick with our simpler publisher model (D3), align field names + types so cross-reading is possible. +- The `verify_non_membership_and_insert` accumulator API as the canonical SMT operation signature. +- The PCD predicate as the canonical list of asserts. Even if our circuit is structurally different, the **set of facts proven** should be a superset. +- The `payment_init_newacct` flow as the basis for a real (non-hard-coded) account-creation path (addresses D11 long-term). +- Test cases: copy/port their predicate tests as a soundness baseline. + +### From our existing SP1 code (`program/src/`) +- The current `SparseMerkleTree` / `MerkleMountainRange` algorithms (modulo hash swap to Poseidon and lex-ordering for AccM if we go that route). +- The `AccountState`, `Coin`, `Invoice` data shapes (modulo D1, D2 fixes). +- The Account → coin_queue → send flow in `node/src/account_node.rs` — this is host-side glue, no circuit changes here except wiring to the new Plonky2 prover. +- The 12 tests in `program/src/merkle/sparse_merkle_tree.rs::tests` — survive as-is once `hash_concat` is Poseidon-backed. + +### Newly required work (no upstream donor) +- Plonky2 circuit gadgets for: Poseidon-SMT membership/non-membership/insert, Poseidon-MMR append+prove, Schnorr verification or — if we keep BIP-340 — an in-circuit SHA256 gadget over the Schnorr message (cheap because the message is exactly 64 bytes). +- Range checks on coin indices, balances (u64), and amounts. +- Domain-separation tags as field-element prefixes for leaf/node/identifier/MMR-leaf hashes (cheap with Poseidon, fixes the implicit-tagging issue called out in SPEC §10.5). +- Fixed-shape padding for variable-length input vectors (`in_coins` becomes `[Coin; MAX_IN_COINS]` with no-op slots). + +--- + +## 5. Design Decisions (locked for v1) + +The following decisions are taken. Each is reversible but reversing them means a full circuit rebuild — they will not be re-litigated within v1. + +1. **Paper-fidelity vs. zkCoins variant** → **zkCoins MVP variant for v1.** Paper fidelity (`ToSAcc`, half-aggregate publishers, fee economics, hiding recipient commitments) is deferred to v2. SPEC.md §15 documents the divergences D1–D11. + +2. **Max input coins per send** → **8.** Plonky2 circuits are fixed-shape; the bound has to be a constant. 8 covers >99% of real wallet sends (most are 1–2 in-coins). Coin slots beyond the actual count are filled with `amount = 0` dummies; the circuit treats those as no-ops. + +3. **Hash function** → **Poseidon over Goldilocks (`PoseidonGoldilocksConfig`, `D = 2`)** everywhere in the protocol's Merkle structures — both in-circuit *and* in the scanner state (SMT + MMR). Aligns with the Plonky2 ecosystem default and the BitVM reference config. + +4. **Schnorr message hash** → **BIP-340 secp256k1 stays unchanged.** The wallet signs `SHA256(serialize(asth) ‖ serialize(ocr))` where `asth` and `ocr` are 4-element Poseidon outputs serialised big-endian to 32 bytes each. SHA256 lives only at this boundary; everything inside the circuit is Poseidon. No in-circuit SHA256 gadget is needed because the circuit never verifies the BIP-340 signature itself — that happens off-circuit in the scanner. + +5. **Privacy (D2/D10)** → **deferred to v2.** Plaintext recipient addresses for v1. Linkability across multiple coins to the same recipient is a known limitation, called out as a mainnet blocker in SPEC §15. + +6. **Fee model (D6)** → **no fee in v1.** We are the publisher (DFX/zkCoins-operated server), so there is no publisher to compensate. Self-funded operation. + +Hash-function boundary visualisation: + +``` + in-circuit (Poseidon) off-circuit (BIP-340 secp256k1) + ---------------------- -------------------------------- + ProofData wallet derives x-only privkey + ┌──────────┐ wallet computes + │ asth │ ────────┐ msg = SHA256(asth_bytes || ocr_bytes) + │ ocr │ ────────┼──→ sig = schnorr_sign(privkey, msg) + └──────────┘ │ scanner verifies sig + │ scanner inserts (pk, msg) into Poseidon-SMT + └─── serialize each field elt big-endian → 32 B +``` + +--- + +## 6. Sequencing — moved to ROADMAP.md + +The original 9-step strategic outline that lived here was superseded by +the detailed 16-row breakdown in [`ROADMAP.md`](./ROADMAP.md) once +implementation started. The ROADMAP is now authoritative for the +execution plan (status, effort, files, risks). + +Key adjustments made since the original outline: + +- **Step ordering of gadgets** (was: hash → SMT non-inclusion+insert → MMR-append → SHA256). Actual: MMR inclusion → SMT inclusion → SMT non-inclusion verify. The original list mentioned an MMR-append and a SHA256 gadget which turned out to not be needed (MMR is built off-circuit by the scanner; SHA256 lives at the Bitcoin-signing boundary, not in-circuit — see §5.4). +- **No Cargo feature flag for dual backend.** The closed-test-environment decision means step 7 replaces SP1 with Plonky2 outright (see ROADMAP step 7). +- **Server scanner + state DO change** (Poseidon SMT/MMR, not SHA256). Only the on-chain commitment *format* — a single Schnorr inscription with txid prefix `4242` — stays unchanged. + +--- + +## 7. Lessons Learned (during implementation) + +Gotchas, design discoveries, and "would have been nice to know" findings +that emerged while porting steps 1–4d. Each entry includes what it +costs (concrete: a regression test, a comment, a constraint) so a later +contributor can verify the lesson is still load-bearing. + +### 7.1 Poseidon zero-state collision in SMT defaults — **HIGH severity** + +**Discovered:** SMT port (commit `6215009`), failing test +`test_verify_non_inclusion_proofs` at iter=1 (2 leaves). + +**Symptom:** `debug_assert!(node_1 == *parent || node_0 == *parent)` in +the chase loop of `generate_non_inclusion_proof` failed. Investigation +showed the chase had silently diverged from the inserted leaf's path +because *both* children at some level appeared equal to `parent`. + +**Root cause:** Plonky2's Poseidon sponge with state width 12 and zero +capacity init has the property that +`PoseidonHash::hash_no_pad(&[F::ZERO])`, +`PoseidonHash::hash_no_pad(&[F::ZERO, F::ZERO])`, +`PoseidonHash::two_to_one(ZERO_HASH, ZERO_HASH)`, and any other +absorption that leaves the state at all-zeros before permutation all +produce **the same output** — call it `Z = Poseidon(0)`. + +If `DEFAULT_HASHES[TREE_DEPTH] = ZERO_HASH`, then `DEFAULT_HASHES[L]` +for every `L < TREE_DEPTH` is `Z` (after sufficient self-concatenation, +this stabilises in two steps). Any leaf whose value+key are themselves +hashes of zero-derived inputs (very common in tests, but also possible +for real Poseidon-derived keys hitting that exact image) collides with +`DEFAULT_HASHES[TREE_DEPTH - 1]`. The chase loop then sees both default +sibling and propagated leaf-hash as equal and picks the wrong path. + +**Fix:** seed `DEFAULT_HASHES[TREE_DEPTH]` with a domain-separated +non-zero value (verbatim from `program-plonky2/src/merkle/sparse_merkle_tree.rs`): + +```rust +const EMPTY_LEAF_TAG: &[u8] = b"zkcoins:smt:empty-leaf:v1"; + +pub static DEFAULT_HASHES: LazyLock> = LazyLock::new(|| { + let depth = TREE_DEPTH; + let empty_leaf = hash_bytes(EMPTY_LEAF_TAG); + let mut default_hashes = vec![empty_leaf; depth + 1]; + for level in (0..depth).rev() { + default_hashes[level] = hash_concat(&default_hashes[level + 1], &default_hashes[level + 1]); + } + default_hashes +}); +``` + +**Regression guard:** `leaf_hash_never_collides_with_defaults` in +`sparse_merkle_tree.rs` iterates 50 sample keys × values and asserts +none collides with any `DEFAULT_HASHES[L]`. + +**Generalisation for future gadgets:** any time the protocol uses +"zero" as a sentinel inside a Poseidon hash chain, sanity-check that +the resulting sentinel isn't also a natural image of zero-derived +input. Domain separators are cheap insurance. + +### 7.2 Variable vs. fixed depth in SMT proofs — **MEDIUM severity, decision pending** + +**Discovered:** when porting `verify_smt_non_inclusion` and writing +`verify_and_insert` plans (steps 4c, 4c+). + +**Tension:** the off-circuit SMT uses **path compression**. A single-leaf +subtree at level L stores `leaf_hash` rather than a real `hash_concat` +of children, and `generate_inclusion_proof` / `generate_non_inclusion_proof` +break early when they detect this pattern. The resulting proof has +variable length `K ≤ TREE_DEPTH`. + +Plonky2 circuits are **fixed-shape**: a gadget that processes a path +must commit to its length at circuit-build time. The current gadgets +accept any `path.len()` at *test* time, but the monolithic circuit +(step 5) needs one fixed depth. + +**Two options for step 5:** + +1. **Remove path compression off-circuit.** Every leaf path is hashed + up the full TREE_DEPTH; proofs are uniformly TREE_DEPTH siblings + long. Pros: trivial in-circuit logic; uniform. Cons: changes + `tree.root()` semantics (root is no longer leaf-hash for single-leaf + trees); we'd need to retrofit the test suite and any host code + reading the root. +2. **Keep path compression off-circuit, pre-pad for circuit consumption.** + The host produces a "padded" proof of length TREE_DEPTH where + levels below path compression are filled with computed + `hash_concat(leaf_h, default)` values at each level. Pros: keeps + off-circuit `tree.root()` semantics. Cons: host code complexity; + the padding must be computed correctly (subtle). + +**Status:** unresolved. Decision deferred to step 5 (monolithic circuit). +The risk register R6 flags this; the ROADMAP's 4c+ entry notes the plan +is option 2 unless we hit issues. + +**Concrete cost so far:** the verify gadget accepts variable depth and +works for tests, but the insert gadget hasn't been written yet +precisely because the depth question is unsettled. + +### 7.3 `pw.set_target` returns `Result` in plonky2 1.x — **LOW severity** + +**Discovered:** smoke test for `program-plonky2/src/lib.rs` (commit +`984580f`). + +**Surprise:** the BitVM reference uses plonky2 0.2.0 where +`pw.set_target(target, value)` returns `()`. In plonky2 1.x it returns +`Result<(), anyhow::Error>` and clippy's `unused_must_use` rejects the +old call shape. + +**Fix:** always `.unwrap()` (or properly handle) the result. The error +case shouldn't fire in correctly-written code; the Result is there for +target-overwrite detection. + +```rust +// 0.2.0: pw.set_target(t, v); +// 1.x: pw.set_target(t, v).unwrap(); +``` + +### 7.4 Field-element packing conventions (canonical-reduction safety) — **MEDIUM, codified** + +**Discovered:** during `hash.rs` design. + +**Constraint:** Goldilocks modulus is `p = 2^64 - 2^32 + 1 ≈ 2^64`. A +u64 value just below `2^64` exceeds `p` and `F::from_canonical_u64` +panics in debug builds (release: silent reduction). + +**Packing rules** used throughout this crate: + +| Operation | Bytes per field elt | Why | +| ---------------------------------- | ------------------- | ------------------------------- | +| `hash_bytes` | **7** (LE) | 7*8 = 56 bits, safe ceiling. | +| `digest_to_bytes` / `from_bytes` | **8** (BE) | Only works because Poseidon outputs are canonical (< p). Asserted by the protocol invariant; if a user-supplied byte string is fed through `digest_from_bytes`, it MUST come from a prior `digest_to_bytes` of a real digest. | +| `u64_to_limbs` (balance / amount) | **4** (2 limbs) | u32 chunks, never exceeds p. | +| `pubkey_to_limbs` (33-byte pubkey) | **7** (5 limbs LE) | Same as `hash_bytes`. | + +**Invariant to enforce in any future packing function:** input chunks +that fill a Goldilocks element must be ≤ 56 bits unless the value's +canonical reduction is independently guaranteed. + +### 7.5 The Schnorr / Poseidon boundary lives at byte serialisation — **codified** + +**Discovered:** §5.4 decision, then refined while writing +`CommitmentMerkleProofs::verify_commitment`. + +**Rule:** the wallet signs `SHA256(serialize(asth) ‖ serialize(ocr))` +where `serialize` is `digest_to_bytes` (32 bytes big-endian per field +element). The scanner verifies the BIP-340 signature and then inserts +the 32-byte message into the global SMT keyed by `H(serialize(pubkey))` +(Poseidon hash of compressed pubkey bytes, then taken as a 32-byte +SMT key). + +There is **no in-circuit SHA256**, **no in-circuit Schnorr verify**. +The boundary is enforced entirely off-circuit, and the proof's public +output (`ProofData`'s `account_state_hash` + `output_coins_root`) +provides the values that the wallet signs. + +**Consequence for D2/D10 fix (privacy):** if we later add hiding +recipient commitments, the commitment construction lives off-circuit +too. The wallet computes `Commitment::commit(acct_id, rand)` and the +randomness is a regular witness — no in-circuit Pedersen needed unless +we're verifying commitment openings inside the predicate. + +### 7.6 Tests serialised, memory-resident binaries linger — **LOW, but operationally costly** + +**Discovered:** orphan `server-f8087395d1b79585` process consuming 35 GB +of swap reservation hours after `cargo test` finished. + +**Cause:** when a background `cargo test` is aborted (or completes but +its child test binary doesn't terminate cleanly), the test binary +keeps its allocated arenas in memory and shows up as a giant resident +process in Activity Monitor. + +**Mitigation:** see `program-plonky2/CONTRIBUTING.md` § "Test runtime +characteristics" and the `feedback_cleanup_test_binaries` memory entry. +After long test runs: + +```bash +pgrep -f "target/debug/deps/zkcoins_program_plonky2" +# If any output: kill -TERM +``` + +### 7.7 `gh` needs `--repo` in background tasks — **LOW, operational** + +**Discovered:** while running a CI watcher via `Bash` with +`run_in_background: true`. Background processes lose cwd-read +permission in this sandbox, so `cd ... && gh ...` fails with "Unable +to read current working directory: Operation not permitted". + +**Mitigation:** always pass `--repo zk-coins/node` explicitly to gh +commands run in background contexts. Captured in memory as +`feedback_ci_monitor_after_push`. + +### 7.8 Reference repos: BitVM/zkCoins is a 182-LOC toy, ShieldedCSV/ShieldedCSV is the real one — **codified** + +**Re-stated for emphasis:** the upstream `BitVM/zkCoins` reference +repo is a Plonky2 IVC scaffold (182 LOC, no SMT/MMR/AccountState/Coin/ +Schnorr/tests). The actual normative reference implementation is +`github.com/ShieldedCSV/ShieldedCSV`. Our implementation diverges from +the paper in 11 ways (see §3 of this doc / SPEC.md §15). + +§3 is authoritative for "what does the paper say"; §3's divergence +table D1–D11 is authoritative for "where do we differ and why". + +### 7.9 Defensive bounds checks collapse coverage regions — **codified** + +**Discovered:** while pushing `program-plonky2` from 96.43% to 100% +line coverage (commit `e14d9df`). + +**Symptom:** the MMR's `append` and `get_proof` had explicit +`if 2*idx+1 < len { levels[level][2*idx+1] } else { ZERO_HASH }` +defensive branches. The `else` arm is unreachable in correctly- +maintained state (the capacity-doubling guarantees `len` is always a +power of two ≥ `2*idx+2`), but llvm-cov sees it as an uncovered +region — perpetually below 100%. + +**Fix:** rewrite as +`self.levels[level].get(idx).copied().unwrap_or(ZERO_HASH)`. + +`Option::unwrap_or` is hashed as a single region by llvm-cov — the +"unreachable" path shares the region of the success path. The safety +fallback is preserved (`ZERO_HASH` returned if `get` ever fires the +`None`), but the branch no longer carries its own coverage debt. + +**Generalisation for future code:** when you have a defensive +`if in_bounds { container[i] } else { sentinel }` pattern, prefer +`container.get(i).copied().unwrap_or(sentinel)`. The semantics are +identical and the coverage shape is cleaner. + +### 7.10 Coverage-on-tests: annotate `#[cfg(test)] mod tests` with `coverage(off)` — **codified** + +**Discovered:** same context as 7.9. After closing all genuine +production-side coverage gaps, the crate still measured ~99% lines +because llvm-cov tracks the panic-message-evaluation region inside +`assert!(cond, "msg")`, `assert_eq!`, `assert_ne!`, `should_panic` +macros as a separate region from the success path. Inside a passing +test the `"msg"` region is never executed, so it counts as uncovered. + +**Fix:** add `#[cfg_attr(coverage_nightly, coverage(off))]` to every +test module (i.e. every `#[cfg(test)] mod tests { … }`). This requires +two prerequisites: + +1. `src/lib.rs` declares the feature gate: + `#![cfg_attr(coverage_nightly, feature(coverage_attribute))]`. + The crate must be built on a nightly toolchain that supports the + `coverage_attribute` feature (we're on `nightly-2025-04-15`). +2. `Cargo.toml` registers the cfg key so the compiler doesn't warn + when building outside the coverage tool: + + ```toml + [lints.rust] + unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage_nightly)"] } + ``` + +The `coverage_nightly` cfg is set automatically by `cargo-llvm-cov` +when it instruments the build; in normal `cargo build` / `cargo test` +runs the attribute is a no-op. + +**Generalisation:** test modules SHOULD always carry the +`coverage(off)` annotation in this codebase; production module-level +docs should not need it. New modules added in the future must include +this annotation if they ship a `#[cfg(test)] mod tests` block — see +`program-plonky2/CONTRIBUTING.md` § "Coverage gate" for the rule. + +### 7.11 Hardware target is a Mac Studio M3 Ultra, single host — **codified** + +**Discovered:** explicit architecture decision (commit `79bd39e`, +clarified shortly after). + +**Constraint:** zkCoins runs on a single Mac Studio M3 Ultra (96 GB +unified RAM). On-box compute includes Performance + Efficiency cores, +the integrated Apple Silicon GPU (reachable via Metal), Neural Engine, +and AMX. **External** hardware (NVIDIA, CUDA, GPU farms) and external +cloud proving services (Succinct Prover Network, AWS GPU, Lambda Labs) +are **not** available. If a design overshoots the performance budget, +the design changes; we do not add external hardware. + +**Important caveat about "GPU":** the M3 Ultra has a substantial +integrated GPU (60- or 80-core depending on bin) usable via Metal. +That GPU is on-box and would be fair game *if our prover library +supported it*. Plonky2 currently ships only CPU and CUDA backends — +no Metal — so the GPU sits idle for proving. This is a library +property, not a constraint we imposed. If a Plonky2 Metal backend +becomes available (or we port to Plonky3 which has more options), we +may use the GPU. + +**Implications for design choices made earlier in this document:** + +- §5.3 (Hash function): Poseidon-Goldilocks performance must be + acceptable on the M3 Ultra. Today that's CPU performance, since + Plonky2 has no Metal backend. +- §5.4 (Schnorr boundary): unchanged — boundary lives at byte + serialisation, no in-circuit secp256k1. +- §6 sequencing: step 9's performance budget (`ROADMAP.md` step 9) is + explicitly M3-Ultra-warm-proof ≤ 5 s, ideal ≤ 1 s, memory peak + < 64 GB. If missed, knobs are design-level (reduce `MAX_IN_COINS`, + drop in-coin recursion, switch to folding) — never external hardware. + +**Implication for the Plonky3 post-MVP path** (`ROADMAP.md`): +BabyBear's GPU-friendliness in the broader literature usually means +CUDA-friendliness, which doesn't help us on Apple Silicon. The +motivation for switching to Plonky3 reduces to "matches SP1-era field +choice / Plonky3-native ecosystem". A separate question is whether +Plonky3's GPU paths might include Metal — if so, that would change +the calculation. + +### 7.12 BitVM's `common_data_for_recursion` is broken under Plonky2 1.1.0 — **codified** + +**Discovered:** building the stage-5a cyclic-recursion PoC (commit +`83fa0c1`). + +**Symptom:** copying BitVM/zkCoins's `common_data_for_recursion` +verbatim into `circuit/main.rs` and calling +`builder.build::()` on the outer cyclic circuit panics with +`Failed to build circuit` at `plonky2/src/plonk/circuit_builder.rs:1067`. +No useful error message; the panic comes from a shape-mismatch deep +in the verifier-data wiring. + +**Root cause:** BitVM is pinned to **Plonky2 0.2.0**. In that version +the canonical `common_data_for_recursion` is **two `verify_proof` +calls in pass 2 and three in pass 3, plus a `ConstantGate` added to +the gate set**. Plonky2 1.1.0's +`conditionally_verify_cyclic_proof_or_dummy` produces a different +gate set and public-input shape, so the BitVM-shaped common-data is +no longer a fixed point. The library's outer build then rejects the +mismatch. + +**Fix:** port Plonky2 1.1.0's own canonical +`recursion::cyclic_recursion::tests::common_data_for_recursion` +verbatim — **one `verify_proof` call per pass plus `NoopGate` +padding to `1 << 12` gates**. See +`program-plonky2/src/circuit/main.rs::common_data_for_recursion_c` +for the working implementation with full source comments. + +**Why we keep both versions in mind:** if anyone later restores +BitVM's three-pass shape (e.g., on the theory that "more verifies = +more robust"), the build will fail again. The 1.1.0 canonical shape +is the only one that works with 1.1.0's `conditionally_verify_*` +machinery; this is not a stylistic preference. + +**Ordering subtlety:** the BitVM reference order is +`add_virtual_public_input` → `add_verifier_data_public_inputs` → +`common_data_for_recursion` → `common_data.num_public_inputs = …`. +Plonky2 1.1.0's own canonical test orders it +`add_virtual_public_input` → `common_data_for_recursion` → +`add_verifier_data_public_inputs` → `common_data.num_public_inputs = …` +instead. The `common_data_for_recursion` function is stateless w.r.t. +the outer builder, so logically the order shouldn't matter — but +match the canonical order to avoid surprises. + +### 7.13 Coverage debt from unreachable Plonky2 `Result<()>` calls — **codified** + +**Discovered:** stage-5a (`83fa0c1`) initial draft used `?` to +propagate the `Result` of +`conditionally_verify_cyclic_proof_or_dummy`. `cargo llvm-cov` flagged +the `Err` arm as uncovered, dropping line coverage below the 100 % +gate. + +**The pattern:** Plonky2 library functions like +`conditionally_verify_cyclic_proof_or_dummy`, +`pw.set_target`, `pw.set_proof_with_pis_target`, +`pw.set_verifier_data_target` all return `Result<…>` even though, in +correct usage, they only return `Err` under invariants we control by +construction (e.g., "common_data well-formed", "target not already +set"). These are unreachable error paths in our code, but `llvm-cov` +counts the branch. + +**Fix recipe — analogous to §7.9 (Option-based defensive checks):** +- For functions that exist only for error propagation (like + `build_cyclic_circuit`), make the function infallible by `.expect`-ing + the unreachable `Err` and dropping `Result<…>` from the signature. + The `expect` message documents the invariant that makes `Err` impossible. +- For witness-population calls inside helpers that already return + `Result<…>` for other reasons (e.g. `data.prove`), keep `.unwrap()` + inline; the surrounding `Result` covers the rest of the contract. + +**Why this is *not* a fallback** (per `feedback_no_fallbacks`): +`.expect` doesn't replace bad output with default output — it +*panics* if the invariant ever breaks. The function's contract is +"this never returns Err under our usage"; making that explicit via +`.expect("…")` is documentation, not silent recovery. If the +invariant later breaks (e.g., library API changes), tests will catch +it via the panic, not a wrong-result soft failure. + +**Residual region not covered:** the `.expect` itself still produces +one llvm-cov region for the panic branch (the `.unwrap_or_else(panic)` +expansion). That's 1 missed region per call. For the line-based MVP +gate (`cargo llvm-cov --fail-under-lines 100`) this is fine; for the +region-coverage stretch it's the unavoidable cost of unreachable +defensive paths in `Result`-returning library APIs. + +### 7.14 Path-compressed SMTs are incompatible with cyclic recursion — **codified** + +**Discovered:** stage-5c+ work in progress. The SMT shipped in +`6cf949c` used path compression — a single-leaf subtree at level *K* +had its level-*K* root equal to the leaf hash directly (no hashing +through default siblings down to depth `TREE_DEPTH`). Off-circuit +proofs had variable length *K* ≤ 256. + +**Why it broke:** Plonky2 cyclic recursion requires a stable +`circuit_digest` across builds. The verifier shape — including the +number of hash levels processed by the SMT-inclusion gadget — must +be fixed at build time. Variable-length proofs would have produced +a circuit with `circuit_digest` depending on proof shape, breaking +the recursion fixed-point. + +**Fix:** rewrite the off-circuit SMT to produce always-`TREE_DEPTH` +sibling proofs (`refactor: SMT to uncompressed fixed-256-depth +paths`). Empty subtrees contribute `DEFAULT_HASHES[level + 1]` +siblings, so the on-the-wire proof is 256 × 32 B = 8 KiB regardless +of sparsity. The off-circuit `insert` removes the `current != leaf_h +&& sibling == default → skip hash` short-circuit. Case A/B logic in +`NonInclusionProof` is gone too — non-inclusion is now a proof that +the depth-256 slot holds `DEFAULT_HASHES[TREE_DEPTH]`, full stop. + +**Operational consequence:** roots produced by the new `insert` +differ from the pre-refactor compressed roots. The closed-test-env +strategy (`feedback_zkcoins_closed_test_env`) makes this a free +choice — no on-the-wire compatibility to preserve. + +**Lesson for future merkle structures:** if a structure will be +verified inside a cyclic-recursive circuit, build the off-circuit +proof generator to emit *fixed-shape* proofs from day one. Path +compression and similar size-saving tricks save bytes off-chain but +cost a redesign once you need ZK over the same data. + +### 7.15 Conditional constraints via `select_hash` masking — **codified** + +**Discovered:** stage-5c+ added SPEC §8 (c)(d)(e) checks that fire +only on the AccountUpdate branch (`condition = true`). The +`verify_smt_inclusion` / `verify_mmr_inclusion` gadgets internally do +`connect_hashes(computed, expected_root)`, which is unconditional — +they cannot be "switched off" by a guard. + +**Fix recipe:** expose a "compute-only" variant of each verify +gadget (`smt_inclusion_root`, `mmr_inclusion_root`) that returns the +reconstructed root *without* asserting equality. The caller then +constructs the masked target via + +```rust +let target = select_hash(builder, condition, expected_witness, computed); +builder.connect_hashes(computed, target); +``` + +When `condition = false`, `select_hash` collapses to `computed` and +the resulting constraint `connect_hashes(computed, computed)` is +trivially satisfied. When `condition = true`, `target = expected_witness` +and the honest check fires. + +**Why not skip-via-builder-condition:** Plonky2's `CircuitBuilder` +doesn't have a "conditional region" primitive — every gate fires. +Masking via `select` over the *target value* is the standard pattern +(used by Plonky2's own `conditionally_verify_cyclic_proof_or_dummy`, +the cyclic recursion machinery, etc.). + +**Witness-population implication:** the masked-off branch still needs +*some* witness in the placeholders. Stage-5c+ uses a `dummy_cmp()` +helper that constructs a syntactically valid but semantically empty +`CommitmentMerkleProofs` (all `ZERO_HASH`, all-zero indices). The +masked equality constraints accept any witness when `condition = false`. + +### 7.16 MMR root_extended / extend_to for fixed-depth verification — **codified** + +**Discovered:** stage-5c+ needed the in-circuit MMR-inclusion gadget +to run at a fixed depth (`MMR_PROOF_PATH_LEN = MMR_MAX_DEPTH - 1 = 31`), +but the off-circuit `MerkleMountainRange` uses capacity-doubling and +produces variable-depth proofs (typically much shorter — `log2(N)` +for a tree with `N` leaves). + +**Fix:** keep the MMR's natural shape (capacity doubles on demand) +but add two helpers: +- `MerkleMountainRange::root_extended(target_path_len)` — start from + the natural root, then walk up additional levels of + `hash_concat(current, ZERO_HASH)` until the path reaches + `target_path_len`. This is what the in-circuit gadget compares + against. +- `MMRProof::extend_to(target_path_len)` — pad the proof's + `path` with `ZERO_HASH` siblings to `target_path_len`. The padded + proof verifies against `root_extended(target_path_len)`. + +The MMR root committed at the protocol boundary (e.g. inside +`ProofData::commitment_history_root`) is always the extended root at +the chosen `MMR_MAX_DEPTH`; everyone — off-circuit MMR users and the +in-circuit verifier — agrees on the same value. + +**Why this beats redesigning the MMR:** the off-circuit MMR's +capacity-doubling shape is convenient for incremental appends +(O(log N) updates). A fixed-shape rewrite would re-allocate the full +tree up front. The `_extended` / `extend_to` helpers preserve the +fast off-circuit path while making the value the in-circuit verifier +needs trivially derivable. + +### 7.17 Per-slot `active`-bit masking for variable-count loops — **codified** + +**Discovered:** stage-5d needed to support a per-account state +transition processing 0..`MAX_IN_COINS` input coins, but the circuit +shape must be fixed (otherwise `circuit_digest` changes per +transaction → cyclic recursion breaks). + +**Pattern:** declare a constant `MAX_IN_COINS` slot count at the +circuit-builder level. Each slot reserves witness targets including +an `active: BoolTarget`. The slot's predicate is wrapped so that +`active = false` makes every constraint trivially satisfied: + +- Equality / hash-match checks: `connect_hashes(computed, select_hash(active, expected, computed))`. +- Value-update accumulators: `running = select_hash(active, new_value, running)`. + +This is the same `select_hash` masking pattern from §7.15, scaled +out across a fixed list of slots. The off-circuit prover decides at +runtime how many slots are active — the unused ones get a dummy +witness (zeroed coin id, zero-filled proof path) that the masked +constraints accept. + +**Caller ergonomics:** for the common case where all slots are +inactive (e.g. Init proofs without in-coins), provide a thin wrapper +`prove_*(args)` that delegates to the explicit +`prove_*_with_in_coins(args, &inactive_dummies)`. The explicit +variant remains available for tests and callers that need to control +slot activity directly. + +**Performance cost:** each masked slot adds the *full* gate count of +the underlying predicate (the masking doesn't save gates — it only +makes the result vacuously satisfied). For stage 5d's SMT +non-inclusion + insert this is ~512 Poseidon hashes per slot at +`TREE_DEPTH = 256`. Bumping `MAX_IN_COINS` from 1 to 8 grows the +circuit by ~3500 hashes — measure before committing to a target. + +### 7.18 `add_virtual_target` requires explicit witnessing; prefer `split_le` — **codified** + +**Discovered:** stage-5d-next initially implemented the balance +overflow check by declaring `new_lo`, `new_hi`, `carry`, `overflow` +as `add_virtual_target()` / `add_virtual_bool_target_safe()` +targets, range-checking them, and `connect()`ing the recomposed +value to the precomputed `sum`. The test failed at proof generation +with `22 generators weren't run` — Plonky2 had no way to fill the +virtual targets. + +**Root cause:** `add_virtual_*` reserves a witness slot but does NOT +attach a generator. The prover must explicitly populate every +virtual target via `pw.set_target` / `pw.set_bool_target`. If the +target's value is determined by other witnesses, the prover would +have to recompute it off-circuit and supply it manually — fragile +and error-prone. + +**Fix:** use `builder.split_le(t, n_bits)`. It internally adds a +`BaseSumGate` whose generator decomposes `t` into `n_bits` bits at +prove time, and constrains each bit to be `{0, 1}` plus the +recomposition `t == Σ bit[i] * 2^i`. The bits come back as +`BoolTarget`s the caller can use, but no explicit witnessing is +needed — given `t`, the bits are uniquely determined. + +For the balance check, `sum_lo ∈ [0, 2^33)` decomposes into 33 bits; +`bits[32]` is the carry; `new_lo = sum_lo - 2^32 * carry` is the +low 32 bits and stays in range by construction. Same pattern for +the hi limb with an `assert_zero(overflow)` at the top. + +**Rule of thumb:** if a target's value is *uniquely determined* by +other targets (low/high decomposition, range checks, comparisons), +look for a Plonky2 gate that ships its own generator +(`split_le`, `range_check`, `add_many`, `arithmetic` family). +Reserve `add_virtual_*` for prover-driven witnesses (e.g. real +secret-key inputs, side channels, off-circuit results that you must +trust the prover for). + +### 7.19 `account_state.hash` lifecycle inside a transition — **codified** + +**Discovered:** stage 5d-next-3 (out-coins). The same +`AccountState::hash` value plays three different roles inside the +SPEC §8 state-transition predicate, and conflating them broke a +positive test with a cryptic "Partition was set twice with different +values" Plonky2 error. + +**The three hashes:** + +| Role | Inputs | Used by | +| --- | --- | --- | +| `initial_account_state_hash` | `owner` + INITIAL balance + INITIAL pubkey | SPEC §8 (b) state continuity, (c) commitment-witness check | +| `interim_account_state_hash` | `owner` + POST-in-coins-AND-out-coins balance + INITIAL pubkey | Out-coin identifier derivation: `out_coin.identifier == H(interim_asth || index)` | +| `final_account_state_hash` | `owner` + POST-in-coins-AND-out-coins balance + NEW pubkey | Public output `ProofData.account_state_hash` | + +**Why three not one:** +- The in-coin loop mutates the running balance via `apply_coin`. +- The out-coin loop further mutates it via `send_coins`. +- The pubkey is rotated *after* identifier derivation, *before* the + final commit. + +So: +- (b) and (c) compare against `prev.account_state_hash` and + `mp.commitment_account_state_hash`, both of which witness the + state at *start* of the transition. Use INITIAL balance + INITIAL + pubkey. +- The out-coin identifier `H(account_hash || index)` is computed + *after* subtractions per SPEC §8 step 3. Use POST-subtraction + balance + INITIAL pubkey (rotation happens *after* the loop). +- The committed public output is the state at the *end* of the + transition. Use POST-subtraction balance + NEW pubkey. + +**Common test mistake:** computing the off-circuit expected +identifier `H(account_hash || index)` using the INITIAL balance. +The in-circuit identifier-equality check then fails with a wire +conflict because the prover-supplied identifier doesn't match the +in-circuit `H(interim_asth || index)`. Catch: when writing the +out-coin test fixture, always pre-compute the interim balance from +`initial - out_coin_amount` before hashing. + +### 7.21 Stage 5d-next-4 source-side verification blocked on Plonky2 1.1.0 — **resolved in §7.22** + +**Discovered:** when attempting Stage 5d-next-4 — adding per-in-coin +recursive verification of the source state-transition proof per +SPEC §8 step 2 — two distinct Plonky2 1.1.0 limitations made the +full implementation infeasible for MVP timeline. + +#### Attempted approach A: 8 cyclic verifies in outer circuit + +Added `MAX_IN_COINS = 8` additional `conditionally_verify_cyclic_proof_or_dummy::` +calls inside `build_circuit` (one per slot) plus an extended +`common_data_for_recursion_c` with `N_RECURSIVE_VERIFIES = 9` +`verify_proof` calls in pass 3 (1 prev_account + 8 sources). + +The outer's gate count crossed the per-gate-config constants budget +and Plonky2 emitted `ConstantGate { num_consts: 2 }` in the +`common_data.gates` list. But Plonky2's `dummy_circuit` (called from +`dummy_proof_and_vk` inside `_or_dummy`) rebuilds a circuit with just +NoopGate + `add_gate_to_gate_set`, so its `circuit.common.gates` +excludes `ConstantGate`. The `assert_eq!` in `dummy_circuit.rs:116` +fires: + +``` +assertion `left == right` failed + left: CommonCircuitData { gates: [NoopGate, ConstantGate { num_consts: 2 }, ...] } + right: CommonCircuitData { gates: [NoopGate, PoseidonMdsGate, ...] } +``` + +Both `cyclic_base_proof` AND `conditionally_verify_cyclic_proof_or_dummy` +trigger this assertion. So in Plonky2 1.1.0, **circuits that emit +`ConstantGate` are limited to exactly ONE `_or_dummy` call per outer +build**. + +#### Attempted approach B: in-circuit data-only source check (no cyclic verify) + +Dropped the recursive verify; kept only the SMT inclusion of the +coin in the witnessed `source_output_coins_root` + SPEC §8 (c)(d)(e) +chain for the source's commitment in `history_root`. Idea: the +"source is a valid prior transition" property is enforced by the +trusted server only folding validly-proved commitments into the +history MMR — sufficient for server-heavy MVP. + +The outer build then failed with a different error: the cyclic +fixed-point check `goal_data != common` failed at `circuit_builder.rs:1067` +("Failed to build circuit"). The added source-side gates (SMT +inclusion path of 256 levels + CMP chain per slot) pushed outer's +gate count from ~10 k (Stage 5d-next-3) to ~30 k, but the resulting +`CommonCircuitData` shape didn't exactly match what +`common_data_for_recursion_c`'s pass 3 produced — multiple +`INNER_PAD_BITS` values (14, 15, 16, 17) all triggered the mismatch +because the gate-set composition (selector groups, constant counts) +diverged in ways that NoopGate padding alone cannot reconcile. + +#### Decision + +**Defer to Stage 5d-next-5 (post-MVP).** For the zkCoins server-heavy +MVP architecture (server generates all proofs, wallet holds only +private key, single trusted server), the security property "in-coin +came from a valid prior transition" can be enforced **off-circuit**: +the server only folds commitments of validly-proved transitions into +the history MMR. So in-circuit SMT inclusion of the coin in the +witnessed `source_output_coins_root` + CMP chain for the source's +commitment in `history_root` would be sufficient — but even that +hit the build-time `goal_data != common` mismatch. + +Stage 5d-next-3 already implements: +- Prev-account cyclic recursion (1 verify, `condition` selects Init vs Update). +- Full coin-history-side in-coin predicate (SMT non-inclusion + insert, + apply_coin with recipient + balance-overflow). +- Full out-coin processing (SMT non-inclusion + insert, balance + subtraction with underflow, identifier derivation, pubkey rotation). +- SPEC §8 (c)(d)(e) chain for the **prev_account**'s commitment. +- All 10 of 11 SPEC §13 negatives covered (only "source-not-in-history" + is deferred). + +This is sufficient for shipping the MVP. Stage 5d-next-5 paths +forward when revisited: +1. **Aggregator pattern**: separate non-cyclic aggregator circuit + bundling N source verifies, outer verifies one aggregator proof. + Avoids the multi-`_or_dummy` issue. +2. **Plonky2 patch**: upstream fix to make `dummy_circuit` reproduce + `ConstantGate`-containing `common_data` shapes. Significant work. +3. **Single-source build constraints**: rebuild outer so its + `common_data` matches pass-3's exactly even with the additional + source-side gates. Requires understanding Plonky2's selector + group formation. + +**Rule of thumb:** for `conditionally_verify_cyclic_proof_or_dummy` +to work, the outer's actual `common_data` after build must EXACTLY +match the `common_data` you passed in. Adding constraints / constants +to the outer changes selector groups and can break the match +unrecoverably even with NoopGate padding. Test minor circuit +additions iteratively against the smoke test, not in one big batch. + +--- + +### 7.20 Speed up panic tests via `cyclic_base_proof` short-circuit — **codified** + +**Discovered:** stage-5d-next-3 added panic tests like +`stage_5d_next_3_prove_account_update_panics_on_wrong_in_slot_count` +to cover the `assert_eq!`-message lines in +`prove_account_update_with_in_and_out_coins`. The first draft +called `prove_initial(...)` to construct a real prev proof before +invoking the function — paying **~13 min wall clock** per "panic" +test at `MAX_IN_COINS = MAX_OUT_COINS = 8`. Multiply by N panic +tests and the test sweep balloons. + +**The trick:** the slot-count `assert_eq!`s fire at the **top** of +the function, before any witness setting, before `prove`. The +`prev: &ProofWithPublicInputs` parameter is never consumed +in the panic path. Substitute a `cyclic_base_proof(common_data, +verifier_only, empty_pis)` dummy — type-equivalent, ~10 ms to +construct, panic short-circuits before it's touched. + +```rust +let dummy_inner_pis = std::iter::empty::<(usize, F)>().collect(); +let dummy_prev = cyclic_base_proof( + &circuit.common_data, + &circuit.data.verifier_only, + dummy_inner_pis, +); +let _ = prove_account_update_with_in_and_out_coins( + &circuit, &account_state, ZERO_HASH, &dummy_prev, &dummy_cmp(), + &[], // wrong slot count — assert_eq! fires here + &out_coins, &account_state.public_key, +); +``` + +Net savings on stage 5d-next-3: ~25 min wall per full test sweep +(2 account-update panic tests × ~13 min each). Pattern generalises +to any `should_panic` test whose target's expensive arguments are +only consumed *after* the panic point. + +**Rule of thumb:** when writing a `should_panic` test for a +function with expensive arguments, look at where the panic fires +in the function body — if the arguments aren't accessed before +that point, substitute dummies. + +--- + +### 7.22 Stage 5d-next-5 source-side verification via aggregator pattern — **codified (resolves §7.21)** + +**Discovered:** §7.21 deferred source-side verification because both +attempted paths failed at Plonky2 1.1.0's recursion seams. The +resolution combined two empirical fixes — `ConstantGate::new(2)` +injection in the helper, and the `helper_degree = pad_bits + 1` +relation — with an aggregator-pattern restructure that bundles all +`MAX_IN_COINS` source verifies into a single non-cyclic aggregator +proof. The outer then performs exactly **one** additional verify (the +aggregator), staying under the "one `_or_dummy` per outer" budget +that broke approach A in §7.21. + +#### Final architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SourceAggregatorCircuit (NON-CYCLIC) [PHASE 1] │ +│ │ +│ For each slot i in 0..MAX_IN_COINS: │ +│ active[i]: BoolTarget │ +│ real_proof[i]: ProofWithPublicInputsTarget │ +│ dummy_proof[i]: ProofWithPublicInputsTarget │ +│ conditionally_verify_proof::( │ +│ active[i], │ +│ real_proof[i], st_verifier_data, ← shared │ +│ dummy_proof[i], dummy_vd_target, ← constant │ +│ st_common, │ +│ ) │ +│ │ +│ PIs: │ +│ [i*17 .. i*17 + 16]: source ProofData │ +│ [i*17 + 16]: active bit │ +│ [MAX_IN_COINS*17 .. + 4]: st verifier_data digest │ +│ [MAX_IN_COINS*17 + 4 ..]: st verifier_data sigmas_cap │ +└─────────────────────────────────────────────────────────────┘ + │ + │ aggregator_proof + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Outer StateTransitionCircuit (CYCLIC) [PHASE 2a+2b] │ +│ │ +│ verify_proof::( ← hoisted above in-coin loop │ +│ aggregator_proof, │ +│ aggregator_verifier_data, ← constant_verifier_data │ +│ aggregator_common, │ +│ ) │ +│ │ +│ connect_hashes(claimed_st_digest, outer_vd.digest) │ +│ connect_hashes(claimed_st_cap, outer_vd.cap) │ +│ │ +│ Per in-coin slot i (Phase 2b): │ +│ connect(slot.active, aggregator.slot[i].active_pi) │ +│ SMT inclusion of coin_identifier in │ +│ source.output_coins_root (masked by .active) │ +│ Coupling: source.output_coins_root == │ +│ source_cmp.commitment_out_coins_root │ +│ SPEC §8 (c)(d)(e) chain for source.commitment in │ +│ outer's history_root │ +│ │ +│ conditionally_verify_cyclic_proof_or_dummy( │ +│ condition, prev_account_proof, common_data, │ +│ ) │ +│ │ +│ builder.add_gate(ConstantGate::new(2), [0, 0]) ← shape │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Two empirical insights pinned by `recursion_shape_probe` + +**Insight 1 — `ConstantGate::new(2)` injection (probe-verified).** +`common_data_for_recursion_c_inner` calls two `verify_proof`s in pass +2 and 3 (one cyclic, one against the aggregator). Pass-3's +`ArithmeticGate` instances absorb every routed constant — no +standalone `ConstantGate` ever gets allocated by `builder.build::()`. +But `dummy_circuit`'s rebuild always emits one (its hard-coded `- 2` +NoopGate reservation reserves a row for `PublicInputGate + +ConstantGate`). The `assert_eq!(&circuit.common, common_data)` at +`plonky2-1.1.0/src/recursion/dummy_circuit.rs:116` then panics. + +Probe data (`recursion_shape_probe::dump_pass_3_gates_lists_for_inspection`): + +| Helper variant | `gates.len()` | `ConstantGate`? | `dummy_circuit` | +|---|---:|---|---| +| Stage 5d-next-3 baseline (1 verify, pad 14) | 13 | ✓ | **OK** | +| 2 verify, pad 14, no injection | 12 | ✗ | **PANIC** | +| 2 verify + 1/4/16/64/256 forced constants via `mul(c, zero)` | 12 | ✗ | **PANIC** | +| **2 verify + explicit `ConstantGate::new(2)` injection, pad 14** | **13** | **✓** | **OK** | + +Fix lives in `common_data_for_recursion_c_inner`'s pass 3 — see the +function's in-source comment for the injection rationale. + +**Insight 2 — `INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` (sweep-verified).** +Once `dummy_circuit` accepts the gate-set, the cyclic fixed-point +check at `plonk/circuit_builder.rs:1067` (`goal_data != common`) is +still strict: it requires `outer.common == helper-pass-3 common` +field-by-field. The `build_minimal_outer_for_diagnostic` plus +field-diff exercise isolated the only diverging axis to +`fri_params.degree_bits`, exposing the empirical relation: + +> `helper_degree = pad_bits + 1` + +The helper's pad-bits must therefore equal `outer_degree - 1` to +converge: + +| Stage | outer gate count (approx) | outer_degree | required `pad_bits` | +|---|---:|---:|---:| +| 5d-next-3 (1 verify, no source-side) | ~10 k | 14 | 13 | +| 5d-next-5 Phase 2a (2 verify, no source-side gates) | ~30 k | 15 | **14** | +| 5d-next-5 Phase 2b (2 verify + 8 source slots × {SMT + CMP}) | ~50 k | 16 | **15** | +| Hypothetical future stage crossing 2^16 | > 65 k | 17 | 16 | + +`INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` makes `helper_degree = 16` match +the full outer's `degree_bits = 16`. + +If any future change crosses a power-of-two gate-count threshold, +rerun the sweep and bump `pad_bits`: + +```bash +cd program-plonky2 +cargo test --release --lib \ + circuit::recursion_shape_probe::dump_phase_2a_pad_bits_sweep \ + -- --ignored --nocapture +``` + +The sweep uses a MINIMAL outer (no real Stage 5d-next-3 / 5d-next-5 +constraints); it establishes the `helper_degree = pad_bits + 1` +relation. The full outer's degree must then be measured directly via +`circuit.data.common.fri_params.degree_bits` and compared. + +#### Phase 2b per-slot constraints + +For slot `i ∈ 0..MAX_IN_COINS`, in `build_circuit`'s in-coin loop: + +1. Extract source `ProofData` from aggregator PIs at offset + `i * PER_SLOT_PIS` — `account_state_hash`, `output_coins_root`, + `commitment_history_root` (`coin_history_root` is unused for + SPEC §8 step 2). +2. **Active-bit binding** — `builder.connect(slot.active.target, + aggregator.slot[i].active_pi)`. Strict equality: there is no way + to consume an in-coin without a verified source proof. +3. **SMT inclusion** of `coin.identifier` in `source.output_coins_root`. + Leaf value = `h(coin.identifier || coin.identifier)` (set-membership + convention, matching the source's own out-coin SMT insertion at + `hash_up_full_path(new_leaf = h(id || id), id_bits, nip_path)`). + Uses `hash_up_full_path` directly — NOT `smt_inclusion_root`, which + would add an extra `smt_leaf_hash` step and break the binding. +4. **Coupling** — `source.output_coins_root == + source_cmp.commitment_out_coins_root`, masked element-wise + (`mul(active, diff) → assert_zero`). +5. **SPEC §8 (c)** — `source.account_state_hash == + source_cmp.commitment_account_state_hash`, masked. +6. **SPEC §8 (d), first half** — SMT inclusion of `commitment = + h(commitment_account_state_hash || commitment_out_coins_root)` at + `source_cmp.smt_key` in `source_cmp.commitment_root`, masked. +7. **SPEC §8 (d), second half** — MMR inclusion of + `h(source_cmp.commitment_root || source_cmp.commitment_root_mmr_sibling)` + at `source_cmp.mmr_a_index` in the outer's `history_root`, masked. +8. **SPEC §8 (e)** — MMR inclusion of `h(source_cmp.prev_smt_in_mmr_leaf + || source.commitment_history_root)` at `source_cmp.mmr_b_index` in + the outer's `history_root`, masked. + +#### Public API extensions + +```rust +pub struct InCoinSourceWitness<'a> { + pub source_proof: &'a ProofWithPublicInputs, + pub source_inclusion: &'a InclusionProof, + pub source_cmp: &'a CommitmentMerkleProofs, +} + +pub fn prove_initial_with_in_and_out_coins_and_sources( + circuit, account_state, history_root, + in_coins, out_coins, next_public_key, + sources: &[Option], // MAX_IN_COINS entries +) -> Result>; + +pub fn prove_account_update_with_in_and_out_coins_and_sources( + circuit, account_state, history_root, prev, cmp, + in_coins, out_coins, next_public_key, + sources: &[Option], +) -> Result>; +``` + +The legacy all-inactive `prove_*_with_in_and_out_coins` entry points +delegate with `&[None; MAX_IN_COINS]`. Callers with active in-coin +slots **must** use the `_and_sources` variants — the active-bit +binding constraint enforces this at prove time. + +#### Multi-leaf MMR test fixture insight + +`build_test_source_witness` (1-leaf MMR, Phase 2b Initial smoke) and +`build_test_source_and_prev_witnesses` (2-leaf MMR, Phase 2b +AccountUpdate smoke) both ship with the implementation. The 2-leaf +fixture is nontrivial: with BOTH the consumer-prev proof AND the +source proof having `commitment_history_root = ZERO_HASH` (bootstrap), +only ONE of them can use the bootstrap-shaped (e) leaf +`h(? || ZERO_HASH)` at its own MMR index. The fixture resolves this +by folding consumer-prev FIRST (so consumer's leaf is the unique +`h(? || ZERO_HASH)`-shaped leaf at index 0) and source SECOND at +index 1, then having source's (e) "borrow" consumer's bootstrap leaf +at index 0 via `source_cmp.prev_smt_in_mmr_leaf = consumer_smt_root` +and `source_cmp.previous_root_history_proof.1 = consumer_mmr_proof`. +This is a TEST-FIXTURE peculiarity; production producers proving +against a non-empty history don't hit it because they have richer +non-bootstrap MMR shapes available. + +#### Test coverage matrix + +Positives (5 integration tests, all green): + +| Case | Test | +|---|---| +| Init, all-inactive in-coins | `stage_5c_plus_initial_non_mint_zero_balance_accepted` | +| Init, 1 active in-coin + real source proof | `stage_5d_next_5_phase_2b_initial_with_one_active_in_coin_and_source` | +| Init, in-coin + out-coin + source | `stage_5d_next_5_phase_2b_initial_combined_in_and_out_coin_with_source` | +| Update, all-inactive in-coins | `stage_5c_plus_initial_then_account_update_with_commitment_proofs` | +| Update, 1 active in-coin + real source proof | `stage_5d_next_5_phase_2b_account_update_combined_in_and_out_coin_with_source` | + +SPEC §13 source-side negatives (3 cases, all green): + +| Attack | Constraint that catches it | Test | +|---|---|---| +| Source's commitment not in `history_root` (tamper MMR-(e) path) | masked `connect_hashes(mmr_b_computed, history_root)` | `stage_5d_next_5_phase_3_source_not_in_history_rejected` | +| Coin identifier not in source's `output_coins_root` (tamper SMT path) | masked `connect_hashes(source_inclusion_computed, source_output_coins_root)` | `stage_5d_next_5_phase_3_coin_not_in_source_ocr_rejected` | +| Wrong `st_verifier_data` witnessed in aggregator | `connect_hashes(claimed_st_digest, outer_vd.circuit_digest)` | `stage_5d_next_5_phase_3_wrong_st_vk_on_aggregator_rejected` | + +The wrong-vk negative is non-trivial to construct because the +aggregator's `conditionally_verify_proof` would normally reject a +wrong-vk source proof at aggregator prove-time. The test exploits the +all-inactive case: with no slot active, the aggregator never actually +uses the witnessed `st_verifier_data` for verification (only the +constant-baked `dummy_vd_target` for the dummy branch), so the +aggregator can be proved with a LYING `st_verifier_data`. The lie +then surfaces at the outer's `connect_hashes`. + +#### Benchmark (M3, 24 GB, single-threaded `cargo test --release --lib …`) + +- `stage_5c_plus_initial_non_mint_zero_balance_accepted` (all-inactive + Phase 2b smoke): **~40 s** wall. +- `stage_5c_plus_initial_then_account_update_with_commitment_proofs` + (init → update chain, all-inactive in-coins): **~53 s** wall. +- `stage_5d_next_5_phase_2b_initial_with_one_active_in_coin_and_source` + (Init + 1 active in-coin from source): **~99 s** wall (Init for the + source ~40 s + consumer Init ~50 s). +- `stage_5d_next_5_phase_2b_account_update_combined_in_and_out_coin_with_source` + (Update + in-coin + out-coin + source, 2-leaf MMR): **~154 s** wall + (source Init + consumer prev Init + consumer Update). +- Phase 3 negatives: each ~50–55 s wall (one source Init + one + consumer prove, except the wrong-vk negative which skips the source + build entirely via the all-inactive shortcut). +- `dump_phase_2a_pad_bits_sweep` (`#[ignore]`d diagnostic, 4 rebuilds + of aggregator + minimal outer): **~138 s** wall. + +#### Verification runbook + +```bash +cd program-plonky2 + +# 1. Phase 2a probe (no Phase 2b dependencies). +cargo test --release --lib \ + circuit::recursion_shape_probe::dump_pass_3_gates_lists_for_inspection \ + -- --nocapture +# Expect: baseline_ok=true, 2v_14=false, 2v_14_with_constant_gate=true + +cargo test --release --lib \ + circuit::recursion_shape_probe::dump_phase_2a_pad_bits_sweep \ + -- --ignored --nocapture +# Expect: pad_bits=N → helper_degree=N+1 for N in {14, 15, 16, 17} + +# 2. Phase 2a smokes (all-inactive in-coins; Stage 5d-next-3 regression). +cargo test --release --lib \ + stage_5c_plus_initial_non_mint_zero_balance_accepted \ + -- --nocapture +cargo test --release --lib \ + stage_5c_plus_initial_then_account_update_with_commitment_proofs \ + -- --nocapture + +# 3. Phase 2b positives (active in-coin slots + real source proofs). +cargo test --release --lib stage_5d_next_5_phase_2b -- --nocapture --test-threads=2 + +# 4. Phase 3 negatives. +cargo test --release --lib stage_5d_next_5_phase_3 -- --nocapture --test-threads=2 + +# 5. Aggregator regression (Phase 1). +cargo test --release --lib circuit::source_aggregator::tests:: +``` + +**Rule of thumb:** when a Plonky2 1.1.0 outer circuit needs more than +one `verify_proof`, factor the additional verifies into a non-cyclic +aggregator and verify the aggregator (a single proof) from the outer. +Per outer build, exactly one `_or_dummy` plus one or more +non-`_or_dummy` `verify_proof`s. The aggregator must be built before +the outer (its `verifier_data` is a circuit constant in the outer); +the fixed-point iteration in `common_data_for_recursion_c_inner` then +needs `ConstantGate::new(2)` injection in pass 3 and +`pad_bits = outer_degree - 1` to converge. + +### 7.23 `MINTING_ADDRESS` panic in `tokio::spawn`-ed task swallows server bootstrap — **MEDIUM, codified** + +**Discovered:** first auto-deploy of `zkcoins/node:beta` on the DEV +host post-PR [#17](https://github.com/zk-coins/node/pull/17). The +container started, the REST server bound `0.0.0.0:4242`, but +`https://dev-api.zkcoins.app/health` returned Cloudflare 502 for hours. +`docker compose ps` showed the container as `Up (unhealthy)` — the +tokio worker that owned the HTTP listener panicked on every cold boot +after the Plonky2 migration, while the block-scanner worker kept +processing blocks. No restart, no monitor, no visible failure in +`docker logs`. + +**Root cause:** the Plonky2 migration moved `MINTING_ADDRESS` to a +well-known constant (`hash_bytes(b"zkcoins:minting-address:placeholder:v1")` +in `program-plonky2/src/types.rs`). The SP1-era `ClientAccount::new` +in `server` still derived `address` from the privkey's first child +pubkey; the `assert_eq!` in `start_rest_node` between the two could +never hold again. **And** a panic inside a `tokio::spawn`-ed task by +default only kills the task — the process happily continued in zombie +state for 8 h with the listener dead and the scanner alive. + +**Fix (PR [#36](https://github.com/zk-coins/node/pull/36)):** + +1. **Explicit `MINTING_ADDRESS` override** applied in + `runtime.rs::start_rest_node`: after constructing the + minting `ClientAccount` from `minting_secret.bin`, the code + overwrites `minting_client.address = *MINTING_ADDRESS` so the + on-chain identity matches the well-known constant that the Plonky2 + circuit uses, replacing the failing `assert_eq!`. Matches the + pattern already used in `router_tests.rs::TestAccountData::new_minting_account`. +2. **Global panic hook** installed at the top of `main.rs::main` that + runs the default reporter and then `exit(1)`. Any future tokio + worker panic now crash-loops the container via `restart: + unless-stopped` instead of becoming a silent zombie. +3. **Integration smoke test** (`start_rest_node_binds_and_serves_health`) + that spawns `start_rest_node` against an ephemeral port and probes + `/health` over real TCP. `runtime.rs` was excluded from the + coverage scope, so the bootstrap path that exploded had no test at + all. ~22 s warm; runs in the standard test sweep. +4. **deploy-dev post-curl-retry** in `.github/workflows/deploy-dev.yaml`: + up to 30 × 10 s polls of `https://dev-api.zkcoins.app/api/info` after + the ssh deploy. A green "Build and deploy to DEV" with a broken + upstream is no longer possible — the workflow fails, the auto-release + PR loses its green check, and the regression surfaces immediately + instead of hours later. Mirrored to deploy-prd in PR [#51](https://github.com/zk-coins/node/pull/51). + +**Lesson:** in async server code, NEVER let a spawned task panic +silently. Either install a global panic hook (the cheap fix taken +here) or wrap every spawned future in a `Result`-returning closure +that explicitly propagates the panic to the main task via a watcher +channel. The deploy workflow must also probe the public health +endpoint before declaring success — `docker compose up -d` exiting 0 +is a build-time signal, not a runtime-readiness signal. + +**Regression guard:** the smoke test fires on every test sweep; the +deploy-dev post-curl-retry fires on every DEV deploy. A regression +that brings back the silent-panic shape fails one or both gates. + +--- + +## 8. Local Artifacts + +- BitVM/zkCoins reference (cloned): `~/Documents/GitHub/zkcoins/BitVM-zkCoins-reference/` +- Shielded CSV reference implementation files (downloaded by the research agent): `/tmp/shielded_csv_lib.rs`, `/tmp/shielded_csv_primitives.rs`, `/tmp/shielded_csv_node.rs`. **TODO:** clone the full `ShieldedCSV/ShieldedCSV` repo to `~/Documents/GitHub/zkcoins/ShieldedCSV-reference/` if we decide to make it the normative reference (see §5.1). + +--- + +## 9. References + +- Shielded CSV paper: https://eprint.iacr.org/2025/068 +- Shielded CSV reference implementation: https://github.com/ShieldedCSV/ShieldedCSV +- BitVM/zkCoins Plonky2 prototype: https://github.com/BitVM/zkCoins +- Blockstream blog: https://blog.blockstream.com/bitcoins-shielded-csv-protocol-explained/ +- Bitcoin Magazine: https://bitcoinmagazine.com/technical/shielded-csv-protocol +- Plonky2: https://github.com/0xPolygonZero/plonky2 diff --git a/MULTI_ASSET.md b/MULTI_ASSET.md new file mode 100644 index 00000000..cbacba7f --- /dev/null +++ b/MULTI_ASSET.md @@ -0,0 +1,1198 @@ +# Multi-Asset zkCoins Design + +**Status:** Design draft. No code yet. Companion to +[`SPEC.md`](./SPEC.md), [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md), +and [`ROADMAP.md`](./ROADMAP.md). Sibling design docs: +[`BRIDGE_MVP.md`](./BRIDGE_MVP.md), +[`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md), +[`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md). + +**Authoritative source for:** the multi-asset protocol extension — +scope, locked decisions, circuit and state-layer changes, API shape, +phased rollout, non-goals. + +**Audience:** Engineers implementing the multi-asset upgrade. +Presupposes `SPEC.md` (single-asset protocol), the project +invariants in [`CONTRIBUTING.md`](./CONTRIBUTING.md) § "Working on +the Plonky2 Migration", and the `MAX_IN_COINS`/`MAX_OUT_COINS` +fixed-shape fanout of the current circuit. + +--- + +## 0. Status + +Design draft only. The current protocol is single-asset: `Invoice { +amount, recipient }`, `Account { balance: u64, … }`, no `asset_id` +anywhere. This document specifies the extension to a permissionless +multi-asset system — anyone mints a token by name, the creator keeps +ongoing mint authority, transactions stay single-asset, asset +metadata is name + decimals. Implementation tracking lands in +[`ROADMAP.md`](./ROADMAP.md) once the maintainer approves this draft. + +--- + +## 1. Motivation + +zkCoins today serves one asset: the faucet-minted unit returned by +`/api/mint`. The minting account is hard-coded (`MINTING_ADDRESS`, +see [`SPEC.md`](./SPEC.md) §8 "Note on the minting account"), the +`Invoice` and `Coin` types carry only `amount + recipient`, and the +account-server's `balance: u64` is a single scalar. + +Multi-asset opens this to any user: anyone mints a new token under a +chosen name, distributes it, and retains the right to issue more. +The shielded-CSV mechanics (per-account history SMT, global +commitment MMR, BIP-340 Schnorr inscription on Bitcoin) carry over +unchanged; the asset identity rides as an extra field on coins, on +invoices, and on the SMT-leaf pre-image. + +Two design pressures pull in opposite directions: + +- **Privacy** — separate per-asset anonymity pools maximise + unlinkability across assets but multiply state and circuit cost. +- **Simplicity** — a single SMT with `asset_id` as a public field on + each commitment keeps the circuit shape unchanged (the only new + in-circuit constraint is "all coins in this transition share the + same `asset_id`") and the prover cost roughly flat. + +This document picks **simplicity**. The privacy trade-off is +explicit: an outside observer learns which asset moved per +transaction; the sender, recipient, and amount stay private as +before. Per-asset privacy pools are deferred (see §12.10). + +The decision space matches `MIGRATION_RESEARCH.md` §5's pattern: +each constraint below is locked for v1 and reversible only at the +cost of a circuit redesign. + +--- + +## 2. Decisions (locked) + +The six decisions below are fixed for v1. Reversing any of them +means a non-trivial protocol-level change. + +| # | Decision | Consequence | +| - | -------- | ----------- | +| **M1** | **Token creation is permissionless.** Any account can call `/api/asset/create` and mint a new asset. No whitelist, no admin gate, no fee gate. | The server is a pass-through registrar. Spam pressure is handled by the on-chain inscription fee on the genesis transaction's `Commitment`, not by the server. | +| **M2** | **Creator retains ongoing mint authority.** The asset's genesis transaction pins a `mint_authority_pubkey` (the creator's compressed secp256k1 pubkey). Subsequent `/api/mint` calls require a fresh Schnorr signature verifiable against that pubkey. No fixed-supply rule. | No "burn the key after genesis" mode. Total supply is open-ended; trust in the asset is trust in the creator not to over-issue. Key rotation is out of scope (see §11, §12.7). | +| **M3** | **Asset namespace is first-come-first-served on `name`.** The first genesis transaction binding a given `name` wins; later attempts return `409 Conflict`. Normalisation is `name.to_lowercase()` to remove the cheapest look-alike attacks; the trade-off is documented in §10. | `assets.name UNIQUE` at the SQL layer is the enforcement point. No retroactive renaming, no namespace governance. | +| **M4** | **Privacy pool is a single shared SMT.** `asset_id` is a public field on each coin commitment and a public input on each state-transition proof. Anonymity-set is per-asset (all `asset_id = X` traffic mixes; `asset_id = Y` is a separate pool). | Circuit complexity unchanged modulo one extra public input + one cross-coin equality constraint. Per-asset trees and per-asset MMRs are deferred. | +| **M5** | **Cross-asset transfers are out of protocol.** Every state transition moves exactly one `asset_id`; no atomic A↔B swap inside zkCoins. A↔B trading is a separate DEX layer (out of scope: BitVM2 bridge, Lightning atomic swap, off-protocol order-book). | The in-circuit invariant is simple: all input coins and all output coins in a transition carry the same `asset_id`. Multi-leg trades are wallet-side UX over multiple proofs, or an external swap protocol. | +| **M6** | **On-chain asset metadata is `name + decimals` only.** `name` is UTF-8, ≤ 32 bytes after normalisation; `decimals` is `u8` (0-18). No logo, URI, description, supply cap, or other fields. | Richer metadata (logo, links, social) lives off-chain — a separate registry the wallet may consult by `asset_id`. The on-chain genesis stays small and immutable; see §6.2. `decimals` is pure UX (no on-chain math change). | + +These mirror the lockedness of `MIGRATION_RESEARCH.md` §5 (Plonky2 +locked-in decisions) and `BRIDGE_MVP.md` §3 (Bridge locked technical +decisions). Each is testable at 100% coverage per invariant 4 of +[`CONTRIBUTING.md`](./CONTRIBUTING.md). + +--- + +## 3. Glossary additions + +Extends `SPEC.md` § Glossary. Terms below are referenced throughout +this document. + +| Term | Expansion | Meaning | +| ---- | --------- | ------- | +| **AssetId** | — | `HashDigest`. Deterministic Poseidon digest derived from the genesis pre-image (see §4.2). Public field on every coin commitment and every state-transition proof under the multi-asset extension. | +| **AssetGenesis** | — | The genesis transaction that creates a new asset. Carries `name`, `decimals`, `mint_authority_pubkey`, `initial_supply`, `creator_signature`. Persisted in the `assets` table; published on-chain via the same Schnorr-inscription path as a regular send. | +| **AssetMeta** | — | Off-circuit record holding `(asset_id, name, decimals, mint_authority_pubkey, creator_address, created_at)`. One row per asset in the `assets` table; never mutated after insert (immutable post-genesis). | +| **MintAuthorityKey** | — | The compressed secp256k1 pubkey pinned at genesis. Every subsequent `/api/mint` call for this asset must carry a fresh BIP-340 Schnorr signature verifiable against it. | +| **M1 – M6** | — | Locked design decisions for multi-asset (this document, §2). Mirrors `MIGRATION_RESEARCH.md`'s `D1–D11` numbering scheme. | + +--- + +## 4. Protocol changes + +### 4.1 Data structures + +The new shape of the core types. Field additions are highlighted in +the diffs below; existing fields keep their semantics from +`SPEC.md`. + +```rust +// shared/src/lib.rs + +pub struct Invoice { + pub amount: Amount, + pub recipient: Address, + pub asset_id: AssetId, // NEW +} + +// program-plonky2/src/types.rs + +pub struct Coin { + pub identifier: HashDigest, + pub recipient: Address, + pub amount: Amount, + pub asset_id: AssetId, // NEW +} + +pub struct CoinTemplate { + pub recipient: Address, + pub amount: Amount, + pub asset_id: AssetId, // NEW +} +``` + +`Account` (in `node/src/account_node.rs`) gains a per-asset +balance map; the old `balance: u64` collapses to "balance of the +default asset" only for the migration window (see §6.3 — there is +no migration window because state is wiped at cutover, so the field +is replaced outright). + +```rust +// node/src/account_node.rs + +pub struct Account { + pub proof: Option, + pub coin_queue: Vec, + pub coin_history: SparseMerkleTree, + pub balances: BTreeMap, // REPLACES `balance: u64` +} +``` + +New record type for the asset registry: + +```rust +// shared/src/lib.rs + +pub struct AssetMeta { + pub asset_id: AssetId, + pub name: String, // normalised, ≤ 32 bytes UTF-8 + pub decimals: u8, // 0-18 + pub mint_authority_pubkey: bitcoin::PublicKey, + pub creator_address: Address, + pub created_at: u64, // unix seconds + pub initial_supply: u64, +} +``` + +The Plonky2 `AccountState` carried inside the circuit — see +`program-plonky2/src/types.rs::AccountState` — stays single-balance +per-proof: each state-transition proof concerns exactly one +`asset_id` (decision **M5**), so `AccountState.balance` is the +balance of *that* asset for the duration of *this* proof. The +per-asset book-keeping for an account lives off-circuit in +`Account.balances`; the prover witnesses only the balance for the +asset being moved. + +This keeps the in-circuit `AccountState` layout (`[owner_limbs(4), +balance_lo, balance_hi, pubkey_x_limbs(4), pubkey_parity]` — see +`SPEC.md` §12.3) almost unchanged. The minimal addition is one new +public input: `asset_id` (4 field elements). + +### 4.2 Asset genesis (creation) + +An asset genesis is a state-transition proof of a new variant — +call it `AssetGenesisProof` — that mints `initial_supply` units to +the creator's account, binds the asset's `name`, `decimals`, and +`mint_authority_pubkey` into the asset registry, and publishes the +same Schnorr-signed `Commitment` as a regular send. + +`AssetId` derivation: + +``` +asset_id := Poseidon( + DOMAIN_TAG_ASSET_GENESIS, + creator_pubkey_limbs(5), + name_limbs(N), + decimals, + timestamp, +) +``` + +`DOMAIN_TAG_ASSET_GENESIS` is a fixed Goldilocks field element +constant (e.g. `hash_bytes(b"zkcoins:asset-genesis:v1")` taken as a +field element). `timestamp` is the genesis request's unix-seconds +value, included so the AssetId is content-addressed: two creators +who pick the same `(creator_pubkey, name, decimals)` (e.g. on a +state-wiped DEV that allows name reuse, or after a future asset +deletion mechanism) still get distinct `asset_id`s. Note that +`assets.name UNIQUE` already prevents production name collisions +on a single instance — the timestamp is belt-and-braces, plus a +provenance marker for off-chain registries. See §12.1 for the +open question on whether to drop it. + +The genesis carries five things into the world: + +1. **`name`** — normalised (`to_lowercase()`, validated UTF-8, ≤ 32 + bytes after normalisation). Uniqueness is enforced at the SQL + layer via the `assets.name UNIQUE` constraint (§6.2). The first + genesis to commit wins; concurrent attempts return `409 + Conflict` (§10). +2. **`decimals`** — `u8`, 0-18. UX-only; no on-chain math depends on + it. +3. **`mint_authority_pubkey`** — compressed secp256k1, pinned for + the life of the asset. +4. **`initial_supply`** — `u64`, minted to the creator's address at + genesis. May be 0 (the creator can choose to mint later via + `/api/mint`). +5. **`creator_signature`** — BIP-340 Schnorr over + `H("zkcoins:asset-genesis" || asset_id || initial_supply_le || + timestamp_le)`, verifiable against `mint_authority_pubkey`. This + binds the genesis transaction to the same key that will sign + future mints, preventing a separate party from claiming the + asset's name. + +### 4.3 Mint (subsequent issuance) + +After genesis, the asset creator may issue further units by calling +`/api/mint { asset_id, recipient, amount, signature, timestamp }`. +The server: + +1. Looks up `AssetMeta` by `asset_id`. Rejects if unknown. +2. Verifies the BIP-340 Schnorr signature over + `H("zkcoins:mint" || asset_id || recipient || amount_le || + timestamp_le)` against the asset's stored + `mint_authority_pubkey`. +3. Rejects if the timestamp is older than 300 s or in the future — + matches the existing replay window in + `verify_send_signature` (`node/src/server.rs`). +4. Runs the prover to produce a state-transition proof that moves + `amount` units of `asset_id` from the asset's mint-authority + account into a fresh coin for `recipient`. The same circuit + shape as a normal send; the only branch difference is that the + in-circuit signature gate fires against `mint_authority_pubkey` + instead of the sender's commitment pubkey (see §5). + +The current `/api/mint` is permissioned only by the server's +faucet config (`feature = "faucet"`, `MINTING_ADDRESS` hard-coded); +under multi-asset it becomes a signed request from any creator for +their own asset. + +### 4.4 Send + +`/api/send` keeps its current shape, with `asset_id` added to the +`Invoice` and the existing Schnorr signature widened to cover it +under a new domain-prefix tag: + +``` +H("zkcoins:send" + || account_address + || recipient + || amount_le + || asset_id + || timestamp_le) +``` + +Existing wallets sign over `SHA256(account_address || recipient +|| amount_le || timestamp_le)` with **no** domain prefix — see +`verify_send_signature` in `node/src/server.rs`. The multi-asset +upgrade does two things to this hash: + +1. **Adds `asset_id`** between `amount_le` and `timestamp_le`. + This is the necessary part — the signature must commit to + which asset is moving. +2. **Prepends `"zkcoins:send"`** as a domain-separation tag. + This is a deliberate defense-in-depth addition, not a passive + widening: it future-proofs against a `/api/mint` or + `/api/asset/create` message hash being reused as a send + signature once those endpoints share the same secp256k1 key + material (the wallet's account key signs both). The mint and + genesis hashes already carry their own `"zkcoins:mint"` and + `"zkcoins:asset-genesis"` prefixes (§4.2, §4.3); adding + `"zkcoins:send"` here normalises the convention across all + three message types. See §12.5 for the open question on + whether the prefix is strictly required given invariant 2. + +Both changes are breaking for the wallet signature shape; bump +`Capabilities.multi_asset` (§7) so wallets know to include them. + +**Single-asset invariant.** In a single transition, all input coins +and all output coins share the same `asset_id`. This is enforced +twice — defense in depth, matching the pattern in +`node/src/account_node.rs::send_coins` (off-circuit pre-check) +and `program-plonky2/src/circuit/main.rs` (in-circuit constraint): + +- **Off-circuit (server pre-check):** before paying prove cost, + iterate `account.coin_queue` and `invoices`, assert every + `asset_id` equals the transition's claimed `asset_id`. Reject + with `400 Mixed assets in single transition` on mismatch. +- **In-circuit (ZK constraint):** see §5.2. + +### 4.5 Balance + +`/api/balance` returns a map of `{ asset_id_hex: amount }` instead +of a single `balance: u64`. Single-asset clients see a one-entry +map under the well-known "default" asset id; multi-asset clients +iterate. + +```json +{ + "address": "ab12…", + "balances": [ + { "asset_id": "00112233…", "amount": 42 }, + { "asset_id": "deadbeef…", "amount": 1000 } + ] +} +``` + +Because the response shape changes, bump +`Capabilities.multi_asset = true` so single-asset clients can fall +back gracefully. See §7 for the full API delta. + +--- + +## 5. ZK-circuit changes (Plonky2) + +The state-transition circuit lives in +`program-plonky2/src/circuit/main.rs`. The multi-asset extension is +additive: one new public input, one new cross-coin equality +constraint per active in-coin and out-coin slot, no shape change to +the cyclic-recursion plumbing. + +### 5.1 New public input + +`ProofData` gains an `asset_id` field. Public-input layout becomes: + +| slot range | meaning | +| ---------- | ------------------------ | +| 0..4 | account_state_hash | +| 4..8 | output_coins_root | +| 8..12 | commitment_history_root | +| 12..16 | coin_history_root | +| **16..20** | **asset_id (new)** | + +`N_PROOF_DATA_PUBLIC_INPUTS` increases from 16 to 20. Knock-on +effects: + +- `ProofData::to_field_elements` (`program-plonky2/src/types.rs`) + and `ProofData::from_field_elements` extend by one + `HashDigest`. +- `state_transition_num_pis()` in `circuit/main.rs` recomputes + to `20 + 4 + 4 * cap_elements`. +- The cyclic-recursion `common_data_for_recursion_c_inner` rebuild + picks up the new PI count automatically once + `N_PROOF_DATA_PUBLIC_INPUTS` is bumped; no manual padding tweak + required, but the `INNER_PAD_BITS_STAGE_5D_NEXT_5` constant + should be re-verified by `recursion_shape_probe::dump_*` per the + procedure in `MIGRATION_RESEARCH.md` §7.22 to confirm the + helper-degree → outer-degree match still holds at the new PI + count. + +### 5.2 New in-circuit constraints + +The single-asset invariant (M5) is enforced as a fan-in equality +gate: every active in-coin slot's `coin.asset_id` and every active +out-coin slot's `out_coin.asset_id` is connected to the +transition's `asset_id` public input. Inactive slots are masked by +their `active` bit, identical to the existing balance / recipient +gates in `program-plonky2/src/circuit/main.rs`. + +```rust +// Pseudo-code, fits next to the existing per-slot recipient + amount checks +// in the in-coin and out-coin loops in circuit/main.rs. + +for slot in in_coin_slots { + // Existing: `slot.active * (slot.recipient - account.owner) == 0` + // New: + // `slot.active * (slot.asset_id - transition_asset_id) == 0` + connect_hashes_masked(&mut builder, slot.active, slot.asset_id, transition_asset_id); +} + +for slot in out_coin_slots { + connect_hashes_masked(&mut builder, slot.active, slot.asset_id, transition_asset_id); +} +``` + +Coin identifier derivation (`calculate_coin_identifier` in +`program-plonky2/src/types.rs`) extends to include `asset_id` so +that the same recipient/amount pair on two different assets +produces distinct identifiers: + +``` +identifier := Poseidon(account_state_hash, asset_id, u32(coin_index)) +``` + +The SMT leaf pre-image for the coin-history SMT +(`SparseMerkleTree::insert(key, value)` keyed by +`coin.identifier`) automatically inherits the new identifier +shape; no SMT-layer change is required. + +### 5.3 Mint-branch signature constraint + +The current circuit handles the faucet mint via the +`MINTING_ADDRESS` exception (`SPEC.md` §8 "Note on the minting +account"). Under multi-asset this generalises: the genesis and the +ongoing mint paths take the `AssetGenesisProof` / +`AssetMintProof` branches in `ProofType`, and the in-circuit +constraint becomes "the request is signed by the asset's +`mint_authority_pubkey`". + +Two viable architectures, mirroring the recurring trade-off in +`SPEC.md` §12.6: + +1. **Off-circuit Schnorr verify (preferred for v1).** The server + verifies the BIP-340 Schnorr signature with the existing + `secp.verify_schnorr` call (the same path used by + `verify_send_signature` in `node/src/server.rs`), and the + in-circuit branch only enforces that the proof's + `mint_authority_pubkey` public input matches the + asset-registry-stored value. The asset registry is server state, + not on-chain state — the mainnet hardening track decides whether + this is acceptable (it is for the closed test environment per + invariant 2 of [`CONTRIBUTING.md`](./CONTRIBUTING.md)). +2. **In-circuit Schnorr verify.** Add a BIP-340 Schnorr gadget to + the circuit, witness the signature, and verify in-circuit. More + expensive (Schnorr-on-secp256k1 inside Plonky2 is non-trivial + — see `MIGRATION_RESEARCH.md` §5.4) and not required for the + trust model decided in M1 + M2. + +→ **v1: option 1.** The mint-authority pubkey is a regular + public-input on the genesis/mint branches; the signature check is + off-circuit. The architectural call is open at §12.6 — flip to + in-circuit if a future deployment requires the stronger trust + model. + +### 5.4 Prover cost delta + +The per-tx cost delta is **minor**: + +- +4 public inputs (one new `HashDigest` worth) per proof. +- +4 × (`MAX_IN_COINS` + `MAX_OUT_COINS`) = +64 masked-equality + field-element constraints per proof. Each `connect_hashes_masked` + on a `HashOut` (4 elements per Plonky2 + `NUM_HASH_OUT_ELTS`) lands four masked-equality gates; with + `MAX_IN_COINS = MAX_OUT_COINS = 8` per + `program-plonky2/src/circuit/main.rs`, that is 16 slots × 4 = + 64 gates total — negligible against the ~50 k-gate outer + circuit (`INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15`). +- One extra `HashOut` (4 field elements) added to the + coin-identifier pre-image (was `(asth_4, coin_index_1)` = 5 + elements; now `(asth_4, asset_id_4, coin_index_1)` = 9 + elements). Plonky2 Goldilocks Poseidon has `SPONGE_RATE = 8` + (`plonky2::hash::poseidon::SPONGE_RATE`), so 5 elements + absorbed in one permutation; 9 elements now absorb in two. The + per-coin Poseidon cost roughly doubles for the identifier + derivation, but this is one extra permutation per slot — + negligible against the per-slot work elsewhere in the circuit. + +The R2 performance budget from `CONTRIBUTING.md` invariant 3 (warm +≤ 5 s, ≤ 64 GB peak) is not threatened by multi-asset alone. + +### 5.5 Cite-points + +For implementers, the relevant code sites in the current circuit: + +- Public-input count: `program-plonky2/src/circuit/main.rs::N_PROOF_DATA_PUBLIC_INPUTS`. +- Per-slot in-coin processing (where the new `asset_id` equality + gate lands): the in-coin loop in `build_circuit`. +- Per-slot out-coin processing: the out-coin loop in + `build_circuit`, alongside the existing identifier-check. +- Coin-identifier derivation: `program-plonky2/src/types.rs::calculate_coin_identifier`. +- Padding constants: `INNER_PAD_BITS_STAGE_5D_NEXT_5`, + re-verified via `recursion_shape_probe::dump_phase_2a_pad_bits_sweep`. + +--- + +## 6. State layer + +### 6.1 SMT changes + +Coin commitments include `asset_id` in the pre-image via the new +`calculate_coin_identifier` formula (§5.2). The SMT structure stays +single-tree per **M4**; `asset_id` is just one more field in the +leaf pre-image, so the existing `program-plonky2/src/merkle/sparse_merkle_tree.rs` +needs no structural change. The global commitment-history SMT and +MMR (see `SPEC.md` §5) keep their current shape — they are keyed by +the commitment pubkey, not by `asset_id`, so cross-asset proofs +share the same history root and the same anonymity-set at the +commitment layer. + +### 6.2 Postgres schema deltas + +New table `assets` — one row per registered asset, immutable +post-insert: + +```sql +CREATE TABLE assets ( + asset_id BYTEA PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + decimals SMALLINT NOT NULL, + mint_authority_pubkey BYTEA NOT NULL, + creator_address BYTEA NOT NULL, + initial_supply BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX assets_name_idx ON assets (name); +``` + +The `name UNIQUE` constraint is the first-come-first-served +enforcement point (decision M3 / §10). + +The `accounts` row needs to hold a per-asset balance. Two options +match the trade-off space of `SPEC.md` §12.8 and `MIGRATION_RESEARCH.md` +§5: simpler vs. more queryable. + +**Option (a) — JSONB column on `accounts`:** + +```sql +ALTER TABLE accounts ADD COLUMN balances JSONB NOT NULL DEFAULT '{}'; +-- Shape: { "": , ... } +``` + +**Option (b) — separate `account_balances` table:** + +```sql +CREATE TABLE account_balances ( + address BYTEA NOT NULL REFERENCES accounts(address) ON DELETE CASCADE, + asset_id BYTEA NOT NULL REFERENCES assets(asset_id), + amount BIGINT NOT NULL, + PRIMARY KEY (address, asset_id) +); +``` + +→ **v1: option (a).** The bincode-`Account`-in-`BYTEA` pattern +already used for the `accounts` table (see +`CONTRIBUTING.md` § "Persistent State") composes naturally with a +`BTreeMap` field on `Account`; the JSONB column is a +side index for ad-hoc queries (`SELECT … WHERE balances ? +''` works in Postgres). If the operational team later +needs richer balance queries (top-holders, distribution histograms), +add option (b) as a derived table populated by a trigger; not +needed for the MVP. + +The `minting_meta.num_pubkeys` counter that the faucet uses +(`CONTRIBUTING.md` § "Persistent State") becomes per-asset. +Simplest shape: fold it into `assets` as a `num_pubkeys BIGINT NOT +NULL DEFAULT 0` column, advanced atomically per mint. + +```sql +ALTER TABLE assets ADD COLUMN num_pubkeys BIGINT NOT NULL DEFAULT 0; +``` + +The standalone `minting_meta` row is dropped at cutover (no +migration window, see §6.3). + +### 6.3 Migration notes + +Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 2 ("Closed +test environment — DEV *and* PRD"), the cutover wipes server state +and starts fresh. No live-migration logic. + +The recovery procedure from `CONTRIBUTING.md` § "DEV state +recovery" applies as written: stop the server, truncate every +state-layer table (now including `assets`), drop the proofs +directory, restart. The pre-multi-asset coins are abandoned on-chain +(they're random test data); the new server starts at genesis with +an empty `assets` table. + +PR-A1/A2/A3 already left DEV and PRD with empty Postgres state +after the Plonky2 cutover (`SPEC.md` invariant 2; PR +[#73](https://github.com/zk-coins/node/pull/73) finalised the +state-wipe pattern). Multi-asset reuses the same operational +procedure; no new wipe tooling required. + +--- + +## 7. API changes + +For each endpoint, the new shape and back-compat note. + +### 7.1 `POST /api/asset/create` (new) + +Genesis a new asset. + +``` +Body: +{ + "name": "FOO", + "decimals": 8, + "initial_supply": 1000000, + "mint_authority_pubkey": "<33-byte hex>", + "signature": "<64-byte BIP-340 Schnorr hex>", + "timestamp": 1716393600 +} + +Response (201 Created): +{ + "asset_id": "<32-byte hex>", + "name": "foo" +} + +Response (409 Conflict): +{ "error": "asset name already taken" } +``` + +The handler: + +1. Normalises `name` (`to_lowercase()`, UTF-8-validate, byte-length + check ≤ 32). +2. Validates `decimals ∈ [0, 18]`. +3. Verifies the BIP-340 Schnorr signature against + `mint_authority_pubkey` over + `H("zkcoins:asset-genesis" || name_normalised || decimals || + initial_supply_le || timestamp_le)`. +4. Computes `asset_id` per §4.2. +5. Begins a transaction: `INSERT INTO assets … ON CONFLICT (name) + DO NOTHING`. If the insert affected zero rows, the name was + already taken — return 409. Otherwise, run the prover to + produce the `AssetGenesisProof`, persist the proof file, and + advance the SMT. This matches the existing + `UsernameStore::claim` pattern in `node/src/username.rs` + (`ON CONFLICT (username) DO NOTHING` + post-check on the + returned row count). +6. Returns `{ asset_id, name }`. + +Suggested handler name: `asset_create_handler`. Suggested request +type: `AssetCreateRequest`. + +### 7.2 `GET /api/asset/list` (new) + +List every known asset. + +``` +Response: +{ + "assets": [ + { + "asset_id": "", + "name": "foo", + "decimals": 8, + "mint_authority_pubkey": "<33-byte hex>", + "creator_address": "<32-byte hex>", + "initial_supply": 1000000, + "num_pubkeys": 42, + "created_at": "2026-05-22T12:00:00Z" + }, + … + ] +} +``` + +Suggested handler name: `asset_list_handler`. Read-only; serves +straight from the `assets` table; cache headers per the existing +`/api/info` pattern. + +### 7.3 `GET /api/asset/info/:id_or_name` (new) + +Single-asset lookup. Path parameter is either the lowercased name +or the hex-encoded `asset_id`. Returns one of the records from +`/api/asset/list`'s `assets` array, or `404 Not Found`. + +Suggested handler name: `asset_info_handler`. + +### 7.4 `POST /api/mint` (modified) + +The current faucet semantics +(`feature = "faucet"`, no signature required because the server is +the minter) are removed. The new shape: + +``` +Body: +{ + "asset_id": "", + "recipient": "
", + "amount": 100, + "signature": "", + "timestamp": 1716393600 +} +``` + +Handler verifies the signature against the asset's stored +`mint_authority_pubkey` (§4.3). The faucet shortcut survives only +as the "creator never signed away the key, so they can call this" +case — it is no longer privileged. + +`feature = "faucet"` is collapsed into the always-on path; the +`Capabilities.faucet` flag stays for back-compat but is wired to +`multi_asset` truthiness (see §7.8). + +### 7.5 `POST /api/send` (modified) + +Adds `asset_id` to the request body: + +``` +Body: +{ + "account_address": "", + "recipient": "", + "amount": 100, + "asset_id": "", // NEW + "public_key": "<33-byte hex>", + "signature": "", + "timestamp": 1716393600 +} +``` + +The Schnorr-signed message extends to cover `asset_id` (see §4.4). +Existing single-asset wallets break here unless they update to the +new signature shape — gated by `Capabilities.multi_asset`. + +### 7.6 `GET /api/balance` (modified — breaking) + +Was: + +```json +{ "balance": 1234, "username": "alice" } +``` + +Becomes: + +```json +{ + "balances": [ + { "asset_id": "", "amount": 1234 } + ], + "username": "alice" +} +``` + +This is a breaking change for single-asset wallets. They MUST gate +on `Capabilities.multi_asset` and switch parser. There is no +back-compat shim — the migration is at cutover, the closed +environment makes it safe (invariant 2). + +### 7.7 `POST /api/commit` (unchanged) + +Shape unchanged. The underlying proof carries `asset_id` because +it is now part of `ProofData`, but the commit endpoint's wire +shape (proof_id + Schnorr commitment) does not. + +### 7.8 `GET /api/info` (modified) + +`Capabilities` gains `multi_asset`: + +```rust +pub struct Capabilities { + pub address_list: bool, + pub faucet: bool, + pub usernames: bool, + pub lnurl: bool, + pub multi_asset: bool, // NEW +} +``` + +The `faucet` flag stays for wallet-side back-compat (it has been +`false` since PR [#73](https://github.com/zk-coins/node/pull/73) +on both DEV and PRD anyway) but is functionally subsumed by +`multi_asset = true` once the upgrade lands. + +--- + +## 8. Wallet (client) impact + +This document is server-centric. The wallet (`zk-coins/app`) +adapts in four places; full design is out of scope here. + +- **Per-asset balance display.** The wallet's home screen renders a + list of `(asset_meta, amount)` rather than a single balance. + Drives a `/api/asset/list` fetch on first open and on background + refresh; `asset_id → AssetMeta` lookup is cached. +- **Asset selection in the send flow.** The send screen gains an + asset picker. The wallet's existing single-asset send becomes + "send the default asset"; the new send-flow is "pick asset, + enter amount, recipient". +- **Create-asset UX.** New screen: name, decimals, initial supply. + Signs the genesis request with the wallet's existing key + derivation tree — `mint_authority_pubkey` is the wallet's + account pubkey, no new key material required. +- **Schnorr signature scope.** The same BIP-340 key signs over the + extended message (now including `asset_id`); no key-management + changes. + +The current Schnorr-derivation pattern (BIP-32 child key per +commitment, derivation index = `num_pubkeys - 1`) carries over +without modification. `asset_id` is an extra field hashed into the +signed message, not a separate keyspace. + +--- + +## 9. Privacy properties + +The trade-off picked by M4 is explicit: per-transaction privacy +narrows from "anyone on the protocol" to "anyone on this asset". + +| Observer learns | From | When | +| --------------- | ---- | ---- | +| Transaction exists | On-chain `4242`-prefix inscription | Real-time | +| `asset_id` of the transaction | Public input of the proof, included in `ProofData` and the inscription's commitment message | Real-time | +| Transaction count per asset | Aggregate scanner data | Real-time | +| Total on-chain throughput per asset | Aggregate scanner data | Real-time | + +| Observer does **not** learn | Why | +| --------------------------- | --- | +| Sender address | Shielded by the SMT/MMR structure (`SPEC.md` §5) | +| Recipient address | Same | +| Amount | Same | +| Cross-asset linkage | Each transition concerns exactly one asset (M5); the wallet does not bundle transactions across assets | + +**Anonymity set:** per asset. All transfers of asset X mix +together; transfers of asset Y are a separate pool because +`asset_id` is public on the commitment. A new asset with low +volume has a small anonymity set on day one and grows with +adoption; this is the privacy/simplicity trade-off the design +accepts under M4. + +**Mitigation paths (out of scope for v1):** + +- Per-asset privacy pools with a per-asset SMT and a per-asset + MMR. Multiplies state cost by `n_assets`; deferred (§12.10). +- Hide `asset_id` behind a commitment (Pedersen `Commitment::commit(asset_id, rand)`) + in the on-chain inscription. Closes the "asset_id is public" + leak at the cost of a `Commitment::commit` opening in every + recipient's proof — same shape as the D2/D10 hiding-recipient + fix in `SPEC.md` §15. Tracked in §12.11. + +The two mitigations compose; they are tracked together in §12.10 +and §12.11. + +--- + +## 10. First-come-first-served namespace enforcement + +The mechanics behind decision M3. + +- **SQL enforcement.** `assets.name UNIQUE` + `INSERT … ON CONFLICT + (name) DO NOTHING` — the same pattern as the username store + (see `CONTRIBUTING.md` § "Persistent State" `usernames` row). + Whichever genesis transaction commits first wins. Concurrent + attempts on the same name receive `409 Conflict`. +- **No retroactive renaming.** Once `assets.name` is set, it is + immutable. The `assets` row is never `UPDATE`d after insert; + there is no admin endpoint to rename. +- **Case-insensitive normalisation.** `name.to_lowercase()` (Rust + default, locale-independent Unicode lowercasing) is applied at + validation time and at lookup time. This removes the cheapest + homograph class (`USDT` vs `usdt` vs `Usdt`) at the cost of + ruling out distinct names that differ only in case. +- **Trade-off acknowledged.** Full homograph defence + (`u` vs Cyrillic `u`, zero-width-joiner attacks) is out of scope + for v1. The same trade-off applies as in `feedback_dns_migration` + — every name shown in the wallet UI MUST be displayed with both + `name` and `asset_id` (the asset_id is the trust anchor; the + name is UX). Wallets that show only `name` carry the homograph + risk. + +Race-handling at the database layer is the canonical solution; do +not rely on application-side locking. Postgres' MVCC guarantees +that exactly one writer wins the unique-key race; the others' +`INSERT ... ON CONFLICT (name) DO NOTHING` returns zero affected +rows, which the handler translates to HTTP 409. This avoids the +need to catch and re-classify a `23505 unique_violation` — +matches `db::claim_username` in `node/src/db.rs`. + +--- + +## 11. Mint authority + +The mechanics behind decision M2. + +- **Genesis pins `mint_authority_pubkey`.** Compressed secp256k1, + written into the `assets` row at creation, immutable thereafter. +- **Subsequent mint signature.** Every `/api/mint` request carries + a BIP-340 Schnorr signature over + `SHA256("zkcoins:mint" || asset_id || recipient || amount_le || + timestamp_le)`, verified against the asset's + `mint_authority_pubkey`. Same secp256k1 primitive as the send + signature (`verify_send_signature` in `node/src/server.rs`); no + new crypto primitive. +- **Replay protection.** 5-minute timestamp window + (`now.abs_diff(timestamp) > 300 → reject`), matching the + existing pattern. +- **Per-asset request counter.** The `assets.num_pubkeys` column + advances per mint (§6.2). The minting account's + `prev_commitment_pubkey` is derived from this counter exactly as + the existing faucet's `minting_meta.num_pubkeys` does today. +- **No fixed supply.** The protocol does not enforce a hard cap. + Total supply is `initial_supply + Σ(mint amounts)`. Off-chain + registries may publish supply caps as a social convention; the + protocol does not. +- **Key rotation is out of scope.** A creator who loses their + mint-authority key loses the ability to mint more units. There + is no admin override, no rotation endpoint, no escape hatch. + Future work — see §12.7. + +--- + +## 12. Open questions / future work + +Three groups: open architectural questions the maintainer needs +to rule on before P2 starts (§12.1 – §12.6), deferred features +the design explicitly punts on (§12.7 – §12.12), and one +semantic clarification (§12.13). Bullets follow the shape of +`BRIDGE_MVP.md` §13. + +### 12.1 AssetId pre-image: keep `timestamp` or drop it? + +§4.2 includes `timestamp` in the Poseidon pre-image alongside +`creator_pubkey`, `name`, and `decimals`. The `assets.name UNIQUE` +constraint (M3 / §10) already enforces first-come-first-served +name uniqueness at the SQL layer, so `timestamp` is not load- +bearing for collision resistance on a single instance. + +- **Choice in doc:** include `timestamp`. Acts as a provenance + marker (off-chain registries learn when the asset was created + by inspecting the AssetId) and lets the same `(pubkey, name, + decimals)` tuple produce distinct AssetIds across state-wiped + test environments. +- **Alternative:** drop `timestamp`. AssetId becomes a pure + function of `(creator_pubkey, name, decimals)`; reproducible + across environments; smaller pre-image. +- **Trade-off:** keeping it costs nothing on-chain (one extra + field element in a Poseidon pre-image, already covered by §5.4) + and gives a free provenance hint. Dropping it makes AssetIds + reproducible across DEV/PRD, which simplifies cross-environment + testing but means a wiped DEV that re-creates `("FOO", 8)` from + the same creator collides with the old AssetId — fine in + practice (state is wiped together) but worth a maintainer call. + +### 12.2 Postgres balance shape: JSONB column vs separate table? + +§6.2 picks **option (a) — JSONB column on `accounts`**. The +trade-off is real and the maintainer may prefer (b). + +- **Choice in doc:** JSONB column. Composes naturally with the + existing `bincode-Account-in-BYTEA` pattern; the JSONB is a + side index for `WHERE balances ? ''` queries. +- **Alternative:** separate `account_balances` table keyed by + `(address, asset_id)` with a `BIGINT amount` column. Cleaner + for Postgres-side queries (top-holders, distribution + histograms, `SUM(amount) WHERE asset_id = X` for total + supply audits). +- **Trade-off:** JSONB minimises moving parts but pushes + query complexity into application code. The separate table + multiplies writes per state transition (one row per affected + asset per account) but makes operational queries trivial. If + the maintainer expects significant on-Postgres analytics + tooling, switch to (b) before P3 lands. + +### 12.3 Wallet rollout coordination for the breaking `/api/balance` shape + +§7.6 changes `/api/balance` from `{ balance: u64 }` to `{ +balances: [{ asset_id, amount }] }`. This is the single +client-visible breaking change in the upgrade. + +- **Choice in doc:** gate purely on `Capabilities.multi_asset = + true` from `/api/info`. Wallets check the capability flag on + every boot and switch their parser accordingly. +- **Alternative:** add a `version: u32` field to + `/api/balance`'s response (and to `/api/info`'s `Capabilities`) + so wallets can detect the schema bump even if they fail to + re-fetch `/api/info` first. Or: ship both shapes for a + cutover window (`balances` and `balance` both populated for + N days). +- **Trade-off:** invariant 2 (closed test environment, DEV and + PRD) makes the capability-flag approach safe — there are no + external wallets to worry about, and the wallet + (zk-coins/app) and server roll out together in lockstep. + Adding a version field is belt-and-braces that costs nothing + but pollutes the JSON. Recommend keeping capability-flag only + unless the maintainer wants the safety net. + +### 12.4 Unicode homograph defence beyond `to_lowercase()`? + +§10 picks case-insensitive normalisation via `name.to_lowercase()`. +This defends `USDT` / `Usdt` / `usdt` but not Cyrillic-А (U+0410) +vs Latin-A (U+0041), zero-width-joiner attacks, or other Unicode +confusables. + +- **Choice in doc:** Rust's locale-independent `to_lowercase()` + only. Wallet UI is expected to display both `name` and + `asset_id` so the AssetId is the trust anchor. +- **Alternative:** NFKC normalisation + a Unicode confusables + filter (e.g. `unicode-security` crate's `mixed_script_confusable` + detection) at the validation stage. Rejects names whose + script mix is suspicious; closes the most common phishing + vectors at registry-write time. +- **Trade-off:** `to_lowercase()` alone is cheap and reversible + but trusts the wallet UX to enforce the rest. NFKC + + confusables is the right long-term answer but adds a + dependency and rejects some legitimate names (mixed-script + brand names). The current design takes the cheap path and + treats the AssetId as the trust anchor; if mainnet hardening + ever lands, revisit at the namespace-governance step. + +### 12.5 `"zkcoins:send"` domain-tag: keep, drop, or version? + +§4.4 introduces a `"zkcoins:send"` domain-separation prefix on +the send-signature hash. Current `verify_send_signature` signs +without a prefix. + +- **Choice in doc:** add the prefix as defense-in-depth, mirroring + the `"zkcoins:mint"` and `"zkcoins:asset-genesis"` prefixes + on the other two message types. +- **Alternative:** keep the unprefixed shape and only add + `asset_id` to the existing fields. Simpler diff against the + current `verify_send_signature`; one fewer thing for the + wallet to update. +- **Trade-off:** the prefix prevents future cross-message + signature reuse (e.g. a malicious peer convincing a wallet to + sign what looks like a send but is actually a mint over the + same key material). Under invariant 2 (closed environment), + the attack surface is low — but the prefix is free at + signing time and the wallet update is a single hashing tweak + bundled with the `asset_id` widening. Recommend keeping + unless the maintainer objects to the broader signature + shape change. + +### 12.6 Off-circuit vs in-circuit Schnorr for the mint branch + +§5.3 picks off-circuit Schnorr verify for the mint and genesis +branches. The asset registry is server state, not on-chain state. + +- **Choice in doc:** off-circuit verify via existing + `secp.verify_schnorr`. The in-circuit branch only enforces + that the proof's `mint_authority_pubkey` matches the + registry value. +- **Alternative:** in-circuit BIP-340 Schnorr-on-secp256k1 + gadget. Verifies the mint signature inside the proof itself; + removes the server-state trust assumption. +- **Trade-off:** in-circuit Schnorr-on-secp256k1 is non-trivial + in Plonky2 (`MIGRATION_RESEARCH.md` §5.4 has the analysis). + For the closed test environment (invariant 2), off-circuit + is sufficient. If a future deployment treats minting as a + bridge primitive or moves to a trust-minimised setting, this + decision flips and the gadget cost lands in the prover + budget. + +### 12.7 Key rotation for mint authority (deferred feature) + +If a creator loses their signing key (or wants to migrate to a +new one), the asset is effectively frozen at its current supply. +A rotation mechanism — signed by the old key, written as an +`assets.rotation_pubkey` column — is the obvious extension. Out +of scope for v1 to keep the genesis path immutable; revisit +once a real key-loss event lands. + +### 12.8 Richer on-chain metadata (deferred feature) + +Logos, URIs, descriptions, social links. M6 explicitly excludes +these — they live in an off-chain registry the wallet consults +by `asset_id`. The on-chain genesis stays small. + +### 12.9 Cross-asset atomic swap inside zkCoins (deferred feature) + +M5 defers this. Trading happens on a separate DEX layer; the +BitVM2 bridge (`BRIDGE_MVP.md`) and the Lightning atomic swap +layer (`LIGHTNING_ATOMIC_SWAP.md`) are the canonical +out-of-protocol paths. + +### 12.10 Per-asset privacy pools (deferred feature) + +M4 picks the shared-pool design for simplicity. A per-asset +SMT + per-asset MMR raises anonymity-set per asset to "the +asset's own traffic, hidden from other assets' traffic" — same +as Tornado-style pool separation. Cost: multiplies state and +Bitcoin-side commitment traffic by `n_assets`. Deferred. + +### 12.11 Hiding `asset_id` on-chain (deferred feature) + +Combines with the D2/D10 hiding-recipient fix in `SPEC.md` §15. +Out of scope for v1; tracked alongside the mainnet-blocker +privacy fixes. Closes the "asset_id is public on every +commitment" leak at the cost of a `Commitment::commit` opening +in every recipient's proof. + +### 12.12 Burn (asset deflation) (deferred feature) + +Not in MVP. If a future creator wants explicit burn, the +cleanest design is a sentinel recipient address (`BURN_ADDRESS += HashDigest::ZERO` or a domain-separated constant) that the +circuit treats as a coin sink with no corresponding +`apply_coin`. Adds one branch in +`account_node::receive_coin`. Defer until a real use case +arrives. + +### 12.13 Decimals semantics (clarification) + +Purely UX-display. The on-chain `amount` is a `u64`; the +wallet formats with `decimals` for display only. No on-chain +math change. The protocol does not enforce that `amount % +10**decimals` makes sense. + +--- + +## 13. Implementation order + +Phased rollout, mapped to PR boundaries. Effort estimates are +qualitative (S = small, M = medium, L = large, XL = extra large) +per the convention in `BRIDGE_MVP.md` §12.1. + +| Phase | Scope | Effort | Risk | +| ----- | ----- | ------ | ---- | +| **P1 — Shared types + AssetId plumbing** | `shared/src/lib.rs` gains `AssetId`, `AssetMeta`; `Invoice` gains `asset_id`; `program-plonky2/src/types.rs::Coin`/`CoinTemplate` gain `asset_id`. No behaviour change yet — the field is propagated but the server defaults it to a placeholder `DEFAULT_ASSET_ID` so existing tests pass unchanged. Drop in a `MULTI_ASSET_FIXME` comment at every site that will need real handling in P5. | **S** | Low — mechanical | +| **P2 — Circuit extension** | `program-plonky2/src/circuit/main.rs`: bump `N_PROOF_DATA_PUBLIC_INPUTS` to 20, add `asset_id` public input, add per-slot masked-equality gates, extend `calculate_coin_identifier`. Re-run `recursion_shape_probe::dump_phase_2a_pad_bits_sweep` to confirm padding still fits. Coverage gate stays at 100%. The single heaviest lift. | **L** | Medium — cyclic-recursion padding may shift | +| **P3 — Asset registry endpoints** | `POST /api/asset/create`, `GET /api/asset/list`, `GET /api/asset/info/:id_or_name`. New `assets` table migration. SQL `name UNIQUE` enforcement. Handler tests for the 409-on-conflict race. | **M** | Low — standard HTTP API extension | +| **P4 — Mint signature verification** | `POST /api/mint` switches from faucet to signed creator-mint. Per-asset `num_pubkeys` counter. The faucet shortcut is removed; the always-on `Capabilities.faucet` is rewired to `multi_asset`. | **M** | Medium — replaces a known-good code path; tests must cover the per-asset replay protection | +| **P5 — Send + balance + commit shape** | `POST /api/send` extends signed message, `GET /api/balance` becomes per-asset map, single-asset off-circuit pre-check enforces M5, `Capabilities.multi_asset = true`. Backfill the `MULTI_ASSET_FIXME` sites from P1. | **L** | Medium — multiple coupled changes, all wallet-visible | +| **P6 — Wallet adaptation** | `zk-coins/app`: balance display, send-flow asset picker, create-asset UX. Separate PR(s) in the app repo, gated on `Capabilities.multi_asset` from the server's `/api/info`. | **L** | Medium — UX-heavy, parallel to server work | + +**Aggregate effort: M + L + M + M + L + L ≈ 4 person-months at +full focus.** Phase 1 can begin immediately; Phase 2 is the heavy +lift and gates Phases 3 onward. + +Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 4, every +phase ships with 100% test coverage on the activated surface +(`cargo llvm-cov --fail-under-lines 100 -- --test-threads=1` from +inside the affected crate). Negative tests — proof rejection when +in-coin `asset_id` differs from out-coin `asset_id`, signature +verification failure on a forged mint, 409 on duplicate name — are +mandatory. + +--- + +## 14. Non-Goals (Restated) + +So nobody scope-creeps: + +- Migrating existing single-asset state — **not in v1** (closed + test environment, state-wipe at cutover per invariant 2). +- Per-asset privacy pools — **deferred** (§12.10, decision M4). +- Cross-asset atomic swaps inside zkCoins — **out of protocol** + (decision M5, §12.9; lives in the BitVM bridge / Lightning + swap docs). +- Rich on-chain metadata (logo, URI, description) — **excluded** + (decision M6, §12.8). +- Mint-authority key rotation — **deferred** (§11, §12.7). +- Burn / deflationary mechanics — **not in MVP** (§12.12). +- In-circuit BIP-340 Schnorr verify for the mint branch — + **open architectural call** (§5.3, §12.6). +- Homograph-attack defence beyond `to_lowercase()` normalisation — + **open architectural call** (§10, §12.4). + +--- + +## 15. References + +- [`SPEC.md`](./SPEC.md) — single-asset protocol specification. + Multi-asset is additive to §3 (Account Model), §4 (Merkle + Structures), §7 (Program Inputs), §8 (Circuit Logic), §9 + (Public Output). +- [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) — Plonky2 + rationale, §5 (locked decisions), §7 (lessons learned). + Multi-asset extends the §5-style decisions list; the §7.22 + cyclic-recursion padding methodology applies to verifying the + new public-input count against `INNER_PAD_BITS_STAGE_5D_NEXT_5`. +- [`ROADMAP.md`](./ROADMAP.md) — status tracker. Add a row per + phase from §13 once implementation starts. +- [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) — structural reference for + this document. +- [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) — the + out-of-protocol cross-asset trading layer. +- [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) — the BTC-side + cross-asset trading layer. +- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — project invariants, + decision recipe, pre-push checklist. +- `program-plonky2/src/circuit/main.rs` — circuit entry point; + see `N_PROOF_DATA_PUBLIC_INPUTS`, `MAX_IN_COINS`, `MAX_OUT_COINS`, + `INNER_PAD_BITS_STAGE_5D_NEXT_5`. +- `program-plonky2/src/types.rs` — `Coin`, `CoinTemplate`, + `AccountState`, `ProofData`, `calculate_coin_identifier`. +- `shared/src/lib.rs` — `Invoice`, `ClientAccount::create_commitment`. +- `node/src/account_node.rs` — `Account`, `send_coins`, the + off-circuit pre-check pattern that the new single-asset + invariant follows. +- `node/src/server.rs` — `verify_send_signature` (mint signature + follows the same 5-minute replay window and message-hash + pattern), `Capabilities`. + +--- + +## 16. Change Log + +| Date | Change | +| ---- | ------ | +| 2026-05-22 | Initial draft. | diff --git a/README.md b/README.md index 1b1d13bb..136b8b3c 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,57 @@ -# zkCoins Server +# zkCoins Node + +[![Docker Image Version](https://img.shields.io/docker/v/zkcoins/node/latest?logo=docker&label=zkcoins%2Fnode&color=2496ED)](https://hub.docker.com/r/zkcoins/node) +[![Docker Pulls](https://img.shields.io/docker/pulls/zkcoins/node?logo=docker&color=2496ED)](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. +Container images: **[hub.docker.com/r/zkcoins/node](https://hub.docker.com/r/zkcoins/node)** + ## Live -| Environment | URL | Image | -| ----------- | -------------------------------------------------- | ---------------------- | -| **PRD** | [api.zkcoins.app](https://api.zkcoins.app) | `zkcoin/server:latest` | -| **DEV** | [dev-api.zkcoins.app](https://dev-api.zkcoins.app) | `zkcoin/server:beta` | +| Environment | URL | Image | +| ----------- | -------------------------------------------------- | ------------------------------------------------------------------------------------ | +| **PRD** | [api.zkcoins.app](https://api.zkcoins.app) | [`zkcoins/node:latest`](https://hub.docker.com/r/zkcoins/node/tags?name=latest) | +| **DEV** | [dev-api.zkcoins.app](https://dev-api.zkcoins.app) | [`zkcoins/node:beta`](https://hub.docker.com/r/zkcoins/node/tags?name=beta) | ## Stack | Layer | Technology | Why | | --------------- | -------------------- | ---------------------------------------------------- | -| Language | Rust 1.81 | Same as ZK circuits, memory safety, performance | +| Language | Rust nightly | Required for Plonky2 (`feature(specialization)`) | | Web framework | Axum | Built on Tokio, idiomatic async Rust | -| ZK Proofs | SP1 zkVM | Write proofs in standard Rust, no DSL | -| Data structures | SMT + MMR | Non-inclusion proofs + append-only history | +| ZK Proofs | Plonky2 + Poseidon-Goldilocks (cyclic recursion) | Server-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` | Full rationale: [docs.zkcoins.app/tech-decisions](https://docs.zkcoins.app/tech-decisions) +## Trust Model + +Proof generation runs **inside this server 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 server sees, in cleartext: + +- 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`) + +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 **server operator**, not the chain. + +| | Hosted (`api.zkcoins.app`) | Self-hosted | +| --- | --- | --- | +| On-chain privacy (vs. block explorers) | ✅ | ✅ | +| Operator sees plaintext transaction data | ❌ Yes — DFX runs the hosted node | ✅ No | +| Setup effort | ✅ None | ⚠️ Postgres + electrs + Bitcoin node | + +**If you need full transaction privacy, run your own server.** 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`, `faucet`, `usernames`, `lnurl`) is excluded from the MVP measurement — feature-gated routes do not need to be tested as long as the feature stays off in the PRD build. Concretely: +**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 server` (no `--all-features`) must report 100% lines, statements, branches, and functions on the MVP build. CI enforces this with `--fail-under-lines 100`. The current baseline is below 100% — the regression-block threshold is set to the current measured value and the goal is to lift it to 100% via follow-up PRs. +- `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. @@ -40,47 +65,45 @@ API endpoints, background services, their activation status, and the tests that **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 (latest run, `SP1_PROVER=mock` with `--all-features`). `—` means no test exists. +**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 | 75% (server) | -| Network info | `GET /api/info` | env¹ | mvp | 75% (server) | -| Get balance | `GET /api/balance?address=` | always | mvp | 75% (server) | -| List all addresses | `GET /api/address` | feature (`address-list`) | gate | 75% (server) | -| Mint coins (faucet, single-phase) | `POST /api/mint` | feature (`faucet`)² | gate | 91% (account) | -| Send — phase 1 (generate proof) | `POST /api/send` | env² | mvp | 75% (server) | -| Send — phase 2 (commit + broadcast) | `POST /api/commit` | env³ | mvp | 75% (server) · 0% (publisher) | -| Receive coin | `POST /api/receive` | always | mvp | 91% (account) | -| Download coin proof | `GET /api/proof/:id` | always | mvp | 75% (server) | -| Claim username | `POST /api/username/claim` | feature (`usernames`) | gate | 98% (username) | -| Resolve username | `GET /api/username/resolve/:username` | feature (`usernames`) | gate | 98% (username) | -| LNURL-Pay metadata | `GET /.well-known/lnurlp/:username` | feature (`lnurl`) | gate | 75% (server) | -| LNURL-Pay callback | `GET /lnurl/pay/:username` | feature (`lnurl`) | gate | 75% (server) | -| Bitcoin block scanner (background) | Loop in `main.rs`, 30 s poll | env⁴ | mvp | 51% (scanner) · 4% (main) | -| State persistence (SMT/MMR write) | Scanner callback on commitment match | always | mvp | 97% (state) | +| 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 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) | +| Send — phase 2 (commit + broadcast) | `POST /api/commit` | env³ | mvp | 100% (router) · 0% (publisher) | +| 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 `/api/commit` | env³ | mvp | 0% (publisher) | | Publisher UTXO lookup | Internal, before broadcast | env³ | mvp | 0% (publisher) | | 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 SP1. `SP1_PROVER=mock` skips real proving; `cpu`/`cuda`/`network` perform actual proving (latency and resource cost vary by stage — see [Proving Strategy](#proving-strategy)). -³ Requires `PUBLISHER_KEY` set to a real funded key and `ESPLORA_URL` reachable. With the default test key the server panics on `IS_MAINNET=true` startup; on testnet it accepts the call but broadcast will fail without funded UTXOs. -⁴ Scanner depends on `ESPLORA_URL` being reachable; on connection failure it backs off and retries. +² 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 server 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 default to mutinynet endpoints; 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): the PRD image build passes no features, the DEV image build passes all four. +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` | -| `faucet` | `POST /api/mint`, `MintRequest`, `AppState::minting_account` | -| `usernames` | `POST /api/username/claim`, `GET /api/username/resolve/:u`, `ClaimUsernameRequest`, `UsernameStore::{claim,save_to_file}`, `AppState::usernames_path` | -| `lnurl` | `GET /.well-known/lnurlp/:u`, `GET /lnurl/pay/:u` (depends on `usernames`) | +| `lnurl` | `GET /.well-known/lnurlp/:u`, `GET /lnurl/pay/:u` | -Build the MVP-only binary (PRD): `cargo build --release -p server`. Build with everything enabled (DEV / tests): `cargo build --release -p server --all-features`. The Docker `FEATURES` build arg accepts a comma-separated list and is forwarded to `cargo build --features`. +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 @@ -96,87 +119,87 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc #### Health check -- **Module:** `server.rs::main_app` route handler +- **Module:** `router.rs::main_app` route handler - **Behaviour:** returns the literal string `"ok"` with HTTP 200 -- **Tests:** `server.rs::tests::health_returns_ok` +- **Tests:** `router.rs::tests::health_returns_ok` #### Network info -- **Module:** `server.rs::info_handler` -- **Behaviour:** returns `{ "network": NETWORK_NAME }`. `NETWORK_NAME` defaults to `Mutinynet` when `IS_MAINNET=false`, `Mainnet` when `true` -- **Tests:** `server.rs::tests::info_returns_network_name` +- **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 server-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 server serves; **required env var** (server 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:** `server.rs::get_balance_handler` → `account_server.rs::AccountServer::get_account_balance` -- **Behaviour:** address parsed as hex pubkey, looks up the account. Returns `{ balance, username? }`. The minting address returns `u64::MAX` -- **Tests:** `server.rs::tests::balance_*` (5 tests covering happy path, unknown address, invalid hex, missing param, wrong length) +- **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:** `server.rs::get_address_handler` → `account_server.rs::AccountServer::get_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:** `server.rs::tests::address_returns_list` +- **Tests:** `router.rs::tests::address_returns_list` -#### Mint coins (faucet, single-phase) +#### Mint coins (single-phase) -- **Module:** `server.rs::mint_handler` → `account_server.rs::send_coins` with the server-held minting account +- **Module:** `router.rs::mint_handler` → `account_node.rs::send_coins` with the server-held minting account - **Behaviour:** server signs commitment itself (no client roundtrip) using the minting key -- **Proof generation:** `zkcoins_prover::Prover::create_account` (or `update_account` for the receiver) under SP1 -- **Tests:** `account_server.rs::tests::test_create_minting_account`, `test_mint_single_invoice`, `test_mint_repro_live_setup` +- **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:** `server.rs::send_coin_handler` → `verify_send_signature` (Schnorr over `SHA256(account_address || recipient || amount || timestamp)`, ±5 min skew) → `account_server.rs::send_coins` +- **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 `server.rs::tests::send_*` and `send_signature_*` (12 tests covering parser, signature verification, replay). Proof generation itself is not exercised — tests run with `SP1_PROVER=mock` +- **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:** `server.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_server.rs::receive_coin` to deliver the coin to the recipient -- **Tests:** `server.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 +- **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:** `server.rs::receive_coin_handler` → `account_server.rs::receive_coin` +- **Module:** `router.rs::receive_coin_handler` → `account_node.rs::receive_coin` - **Behaviour:** replay-protected via per-account `coin_history` SMT -- **Tests:** `account_server.rs::tests::test_receive_duplicate_coin_rejected`, `test_receive_updates_balance` +- **Tests:** `account_node.rs::tests::test_receive_duplicate_coin_rejected`, `test_receive_updates_balance` #### Download coin proof -- **Module:** `server.rs::get_proof_handler` → `ProofStore::get_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:** `server.rs::tests::proof_not_found_returns_404` +- **Tests:** `router.rs::tests::proof_not_found_returns_404` #### Claim username -- **Module:** `server.rs::claim_username_handler` → `username.rs::UsernameStore::claim` -- **Behaviour:** verifies Schnorr signature over `SHA256(username || pubkey || timestamp)` (5 min skew); writes to `usernames.bin` (atomic) -- **Tests:** `server.rs::tests::claim_username_*` (3 tests) + `username.rs::tests::*` (8 tests covering valid charset, duplicates, persistence) +- **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) #### Resolve username -- **Module:** `server.rs::resolve_username_handler` → `username.rs::UsernameStore::resolve` +- **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:** `server.rs::tests::resolve_unknown_username_returns_404`, `resolve_minting_address_by_hex_prefix`, `username.rs::tests::resolve_is_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:** `server.rs::lnurlp_handler`, `server.rs::lnurl_callback_handler` +- **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:** `server.rs::tests::lnurlp_known_address_returns_pay_request`, `lnurlp_unknown_user_returns_404`, `lnurl_pay_callback_returns_phase2_error` +- **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:** polls Esplora; 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 +- **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` (atomic writes via `atomic_write` helper) -- **Behaviour:** on each verified commitment: append SMT root to MMR, persist `smt.bin`, `mmr.bin`, `latest_block.bin` +- **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 @@ -187,17 +210,18 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc #### Planned -- **Explorer endpoints (`/api/stats`, `/api/nullifiers`)** — to power an `explorer.zkcoins.app` companion app +- **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 | | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SP1_PROVER` | `cpu` | `mock` (no real proofs, instant), `cpu`, `cuda`, `network`. Tests run with `mock`. | -| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora API endpoint (electrs or public) | +| `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora REST API endpoint (electrs or public) | +| `ESPLORA_WS_URL` | `wss://mutinynet.com/api/v1/ws` | Esplora WebSocket endpoint consumed by `scanner_ws` (issue #84). Override only when the upstream WS path changes | | `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet | | `NETWORK_NAME` | `Mutinynet` / `Mainnet` | Human-readable name returned by `/api/info`. Default depends on `IS_MAINNET` | +| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`. The client renders `@` from this. **Server 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` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** — server panics on startup if default test key is detected with `IS_MAINNET=true` | | `RUST_LOG` | `info` | Log level | @@ -207,37 +231,39 @@ Runtime config above shapes _behaviour_ of compiled-in routes. _Which_ routes ar Spawned from `main.rs::main`: -1. **REST server** (`tokio::spawn` of `start_rest_server`) — Axum app bound to `0.0.0.0:4242` -2. **Block scanner** (driven directly in main, not spawned) — `scan_for_inscriptions` runs an infinite loop polling Esplora every 30 s and writing state on each verified commitment +1. **REST server** (`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) ### Tests -| Stack | Command | What it covers | -| ---------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `cargo test` | `SP1_PROVER=mock cargo test -p server` | 45 tests covering only MVP code paths — what the PRD binary actually contains | -| `cargo test` | `SP1_PROVER=mock cargo test -p server --all-features` | 58 tests including the gated `address-list`, `faucet`, `usernames`, and `lnurl` routes | -| `cargo-llvm-cov` | `SP1_PROVER=mock cargo llvm-cov -p server --all-features` | Line coverage (latest run: **69.0% lines · 55.0% regions · 76.4% functions**) — measured with all gates on | +| 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 | -Per-module line coverage (latest run, all features): +Per-module coverage (CI-gated): -| Module | Tests | Line % | -| ------------------- | ----- | ------ | -| `server.rs` | 37 | 74.55% | -| `account_server.rs` | 6 | 91.12% | -| `state.rs` | 9 | 97.01% | -| `username.rs` | 8 | 98.29% | -| `scanner.rs` | 4 | 50.99% | -| `publisher.rs` | 0 | 0.00% | -| `main.rs` | 0 | 4.33% | +| 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; pure helpers (`parse_ws_frame`, `frame_signals_tx_seen`) are unit-tested, the I/O loop is covered indirectly via the publisher's `track-tx` round-trip | -`publisher.rs` and `main.rs` are untested by design — they require a live Bitcoin node and a funded publisher key. CI runs both the MVP build (`cargo build/clippy`) and the all-features build, plus `cargo test --all-features`. Coverage is collected ad-hoc, not in CI. +`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. ## Running Requires access to a Bitcoin node. See [Backend docs](https://docs.zkcoins.app/infrastructure/backend). ```bash -SP1_PROVER=mock cargo run -p server +cargo run -p node # Server starts on http://0.0.0.0:4242 ``` @@ -254,59 +280,64 @@ Mint uses a single-phase flow (server holds the minting account key). ## Project Structure ``` -server/ # Axum REST API +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/ -│ ├── main.rs # Entry point, chain scanner, bind 0.0.0.0:4242 -│ ├── server.rs # REST endpoints + /health -│ ├── account_server.rs # Account logic, coin proofs, prover calls -│ ├── state.rs # Sparse Merkle Tree + Merkle Mountain Range -│ ├── scanner.rs # Bitcoin block scanner (30s polling, prefix 4242) -│ └── publisher.rs # Taproot Inscription broadcaster (commit/reveal) -shared/ # Shared types (Commitment, Invoice, ClientAccount) -program/ # SP1 zkVM circuit types (AccountState, Coin, ProofData) -├── src/merkle/ # SMT + MMR implementations -script/ # Prover (real SP1 zkVM — create_account, update_account) +│ ├── 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 ```bash -docker build -t zkcoin/server . +docker build -t zkcoins/node . docker run -p 4242:4242 \ --network bitcoin \ - -e SP1_PROVER=mock \ -e ESPLORA_URL=http://electrs-mainnet:3000 \ - zkcoin/server + zkcoins/node ``` -The pre-built ELF (`elf/zkcoins-program`) is committed to the repo, so Docker builds do not require the Succinct toolchain — only standard Rust. +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`. ## CI/CD | Workflow | Trigger | Action | | ---------------------- | ------------ | ---------------------------------------------------- | -| `deploy-dev.yaml` | Push develop | Docker (ARM64) → `zkcoin/server:beta` → DEV server | -| `deploy-prd.yaml` | Push main | Docker (ARM64) → `zkcoin/server:latest` → PRD server | +| `deploy-dev.yaml` | Push develop | Docker (ARM64) → `zkcoins/node:beta` → DEV server | +| `deploy-prd.yaml` | Push main | Docker (ARM64) → `zkcoins/node:latest` → PRD server | | `auto-release-pr.yaml` | Push develop | Creates Release PR (develop → main) | Build time: ~5 minutes (Rust compilation on ARM64). ## Proving Strategy -Staged scaling for the SP1 prover: +zkCoins is **server-heavy**: a single trusted server 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 [`SPEC.md`](./SPEC.md) §13 + the memory `feedback_zkcoins_server_side_compute` for the full rationale. -| Stage | When to move | Configuration | -| ------------------------------ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **0. Mock (DEV)** | Development & testing | `SP1_PROVER=mock` — no real proofs, instant responses. Required on DEV because CPU prover causes OOM (SP1 `update_account` exceeds available memory). | -| **1. CPU (PRD)** | Production baseline | `SP1_PROVER=cpu` running on Mac Studio M3 Ultra, 96 GB unified memory. `create_account` works, `update_account` needs memory tuning. | -| **2. Succinct Prover Network** | CPU latency becomes a bottleneck | `SP1_PROVER=network` — no hardware commitment, requires PROVE token deposit and accepts token-price exposure. See [docs.succinct.xyz](https://docs.succinct.xyz/docs/sp1/prover-network/quickstart). | -| **3. Self-hosted CUDA** | Network volume too costly or PROVE exposure undesirable | `SP1_PROVER=cuda` on x86 Linux with NVIDIA GPU (Compute Capability ≥ 8.6, ≥ 24 GB VRAM — RTX 4090 / 5090 / RTX 6000 Ada). Apple Silicon is not supported. | +**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. -Skip stages only with concrete latency or cost data, not assumptions. +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. See [`program-plonky2/SESSION_STATE.md`](./program-plonky2/SESSION_STATE.md) for the detailed test-time table. ## Open Tasks -- [ ] GPU acceleration (`SP1_PROVER=cuda`) or Succinct Prover Network +- [ ] 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 `SPEC.md` §15 - [ ] Explorer endpoints (`/api/stats`, `/api/nullifiers`) - [ ] Light client support @@ -318,6 +349,19 @@ Skip stages only with concrete latency or cost data, not assumptions. | [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 | +## Design Documents + +| Document | Scope | Status | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------ | +| [`LIGHTNING_ATOMIC_SWAP.md`](./LIGHTNING_ATOMIC_SWAP.md) | Trustless LN ↔ zkCoins atomic swap design (HTLC on inscription funding tx) | Draft | +| [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md) | BTC ↔ zkCoins trustless mint/burn bridge — landscape, BitVM2 / Glock / Mosaic comparison, N=100 federation target | Draft | +| [`BRIDGE_MVP.md`](./BRIDGE_MVP.md) | Engineering spec for the bridge MVP — 8 phases, file-by-file, 5–7 months effort estimate | Draft | + +These documents describe the bridge and swap roadmap. They build on +the Plonky2 migration that landed via PR [#17](https://github.com/zk-coins/node/pull/17) +on 2026-05-18 and cross-reference `SPEC.md`, `MIGRATION_RESEARCH.md`, +and `ROADMAP.md`. + ## Protocol Based on [Shielded CSV](https://eprint.iacr.org/2025/068) by Jonas Nick (Blockstream), Liam Eagen (Alpen Labs), Robin Linus (ZeroSync). Server code derived from [ZeroSync/ZKCoins](https://github.com/ZeroSync/ZKCoins). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..657e1779 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,512 @@ +# Plonky2 Migration Roadmap + +Living tracker for the SP1 → Plonky2 + Poseidon migration. **Updated on +every commit to `develop`** — if this file is stale relative to recent +commits, that is a bug. The migration PR ([#17](https://github.com/zk-coins/node/pull/17)) +merged 2026-05-18; Steps 1–8 are done and Step 9 is partially done +(DEV live, signet e2e roundtrip + R2 performance measurement remain). + +Source documents: + +- [`CONTRIBUTING.md`](./CONTRIBUTING.md) § "Working on the Plonky2 Migration" — **start here for fresh sessions.** Onboarding, project invariants, decision recipe, pre-push checklist, foot-gun summary, navigation aid for everything below. +- [`SPEC.md`](./SPEC.md) — protocol specification (the *what*). +- [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) — analysis of the upstream references + design decisions + **§7 Lessons Learned during implementation** (the *why* + *what bit us*). +- [`program-plonky2/CONTRIBUTING.md`](./program-plonky2/CONTRIBUTING.md) — operational handoff: toolchain, build/test/lint commands, runtime characteristics, pitfalls (the *how to actually hack on this*). +- This file — execution plan, status, estimates (the *when and how-overview*). + +--- + +## Status at a Glance + +Legend: ✅ done · 🟡 in progress · ⏳ todo. Effort estimates are +person-days at full focus; multiply for part-time work. + +| # | Step | Status | Effort | Risk | +| - | ---- | ------ | ------ | ---- | +| 1 | Reconcile `SPEC.md` with paper divergences | ✅ done | — | — | +| 2 | Scaffold `program-plonky2/` standalone crate | ✅ done | — | — | +| 3a | Port off-circuit Poseidon hash + byte conversion | ✅ done | — | — | +| 3b | Port off-circuit sparse Merkle tree to Poseidon | ✅ done | — | low (regression covered) | +| 3c | Port off-circuit MMR to Poseidon | ✅ done | — | — | +| 3d | Port off-circuit `AccountState`/`Coin`/`ProofData` | ✅ done | — | — | +| 4a | In-circuit MMR inclusion gadget | ✅ done | — | — | +| 4b | In-circuit SMT inclusion gadget | ✅ done | — | — | +| 4c | In-circuit SMT non-inclusion gadget (verify only) | ✅ done | — | — | +| 4c+ | In-circuit SMT insert gadget (new-root computation) | ✅ done | — | — | +| 4d | Port `ProgramInputs` + `CommitmentMerkleProofs` types | ✅ done | — | — | +| 5 | Monolithic state-transition circuit (recursion, padding, vk-pin) | ✅ done (5a/5b/5c/5c+/5d/5d-next-3/5d-next-5). Stage 5d-next-5 source-side cyclic verify landed via PR [#23](https://github.com/zk-coins/node/pull/23) — aggregator pattern + Phase 2b per-slot SMT inclusion + SPEC §8 (c)(d)(e) chain + 3 §13 negatives. See [`MIGRATION_RESEARCH.md` §7.22](./MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721) for the empirical insights (`ConstantGate::new(2)` injection + `helper_degree = pad_bits + 1` sweep). | — | — | +| 6 | `script-plonky2/` host-side prover wrapper | ✅ done (`d96bb62`) | — | — | +| 7 | Server: **replace** SP1 path with Plonky2 (no feature flag, no dual backend) | ✅ done — `send_coins` performs **in-circuit source-side validation via Stage 5d-next-5 Phase 2 aggregator** (PR [#23](https://github.com/zk-coins/node/pull/23)); off-circuit pre-checks retained as defense-in-depth (microsecond-level fast-fail before the minute-scale prove). Initial server cut (`c71c9fc`) ran off-circuit-only because Phase 2 was deferred; the in-circuit wiring landed via the Step-7 follow-up. Dockerfile re-introduced (`dac0179`). 106 server tests pass on the MVP build, 119 with `--all-features` (32 baseline + 10 inline error-path in `d6a3cb9` + 64 ported SP1-era fixtures re-enabled in `account_node_tests.rs` / `router_tests.rs` + 13 feature-gated). Smoke-test verified end-to-end (`cargo run` + `/health` + `/api/info`, block scanner connects). | — | — | +| 8 | App / wallet: Schnorr-signing boundary, server-API integration | ✅ done — `zk-coins/app` ships `wasm.createCommitment(xpriv, num_pubkeys, asth_hex, ocr_hex)` (in `app/rust/client/src/lib.rs`) signing `SHA256(asth ‖ ocr)` via BIP-340 Schnorr (D11). Two-phase send: `/api/send` (Phase 1, proof) → `/api/commit` (Phase 2, signature). API client in `app/src/lib/api/client.ts` covers `info` / `balance` / `send` / `commit` / `mint` / `username/*` endpoints exactly matching server routes registered at `node/src/server.rs:1261–1289`. WASM mock + Vitest coverage gate already enforced in app repo. | — | — | +| 9 | DEV deployment + end-to-end roundtrip on signet | 🟡 DEV live — PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC; auto-deploy via `.github/workflows/deploy-dev.yaml` landed `zkcoins/node:beta` on `dev-api.zkcoins.app`. `/health` → 200 `ok`; `/api/info` → 200 with `{network:"Mutinynet", capabilities:{address_list, faucet, usernames, lnurl: true}, username_domain:"dev.zkcoins.app"}` (post-[#73](https://github.com/zk-coins/node/pull/73) `address_list` and `lnurl` are `false` because DEV ships the MVP-only binary identical to PRD; `faucet` and `usernames` are hardcoded `true` — mint and usernames are permanent MVP, not feature-gated; the `usernames` Cargo feature was later removed outright — see PR [#76](https://github.com/zk-coins/node/pull/76)). Bootstrap-unblock fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry; see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). Deploy concurrency guards + PRD smoke test in PR [#51](https://github.com/zk-coins/node/pull/51). DEV/PRD parity (drop DEV-only Cargo features + remove `DEV_SKIP_BROADCAST_FAILURE` env-gate) in PR [#73](https://github.com/zk-coins/node/pull/73). **Remaining:** ① e2e roundtrip (create account → mint → send → receive) on signet from `dev.zkcoins.app`; ② R2 measurement on M3 Ultra (warm ≤ 5 s, ideal ≤ 1 s; cold ≤ 30 s; peak mem < 64 GB); ③ reactive: redesign per R2 if the budget is missed. | 2–4 d | medium | +| — | Pre-mainnet blockers: D2/D10 (recipient hiding), D7 (reorg safety), D8 (per-coin nullifier-accum) | ⏳ todo | **+2–3 weeks** | high (real protocol redesign) | + +**MVP status:** Steps 1–8 ✅ done. Step 9 partially done — DEV is live and serving traffic; signet e2e roundtrip and the R2 performance measurement on M3 Ultra remain. **Remaining engineering effort: 0 d** for the migration itself; **remaining ops effort: ~2–4 d** for the e2e probe campaign + R2 budget check. If the R2 budget holds on first measurement, the migration is complete and the project moves to the pre-mainnet hardening track. + +### Definition of "MVP" + +For this project, an "MVP" is **minimum viable** in two simultaneous senses, both non-negotiable: + +1. **Minimal feature surface.** Only what's needed for one complete user loop (create account → mint → send → receive → balance updates). No feature-bloat. If a capability is not on the critical path for that loop, it does not enter the MVP — see SPEC.md §15's deferred items. +2. **100% test coverage on the activated surface.** Same standard as the SP1/SHA256 codebase (see README.md "Contributing"). Code that is gated OFF in the MVP build (Cargo features `address-list`, `lnurl` — disabled in both DEV and PRD images since PR [#73](https://github.com/zk-coins/node/pull/73)) is excluded; everything else MUST be tested. Mint and usernames are part of the MVP and are permanently compiled in (no `faucet` or `usernames` Cargo feature), so they count toward the activated surface. `cargo llvm-cov --fail-under-lines 100 -- --test-threads=1` is the gate (run from inside the affected crate; `--test-threads=1` keeps circuit-test memory peaks predictable on the M3 Ultra). + +These two requirements are not in tension — the first reduces the surface, the second keeps what remains clean. "MVP" is never an excuse to skip tests; it's an excuse to skip *features*. Negative tests (asserting that invalid witnesses are rejected) are mandatory for every gadget and every state-transition path. + +### Architecture summary + +The architecture is **server-side compute**: the server generates all ZK 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. + +**Hardware target: Mac Studio M3 Ultra, 96 GB unified RAM, single host.** All on-box compute is available: Performance and Efficiency cores, the integrated Apple Silicon GPU (via Metal), Neural Engine, AMX. What is **not** available: external hardware accelerators (no NVIDIA, CUDA, GPU farms) and external cloud proving services (no Succinct Prover Network, no AWS GPU, no Lambda Labs). Performance budget is what the M3 Ultra delivers; if a design overshoots, the design changes — we do not add external hardware. Note: Plonky2 currently has no Metal / Apple-Silicon-GPU backend, so the integrated GPU is effectively idle for proving. That is a library property (Plonky2 ships CPU + CUDA only), not a constraint we imposed; if a Metal backend becomes available it's fair game. + +zkCoins is in a **closed test environment** (DEV *and* PRD). No external users, no real money, no existing user-base to migrate. Step 7 therefore **replaces** the SP1 path outright rather than running a dual backend: SP1 modules are deleted, server starts with a clean Poseidon SMT/MMR state, no Cargo feature flag, no migration helpers. This is reflected in the lower effort estimates for step 7 (2–3 d instead of 3–5 d) and the dropped risk for R5. + +Pre-mainnet hardening adds another 2–3 weeks on top. + +--- + +## Done + +Commit refs (newest first). Doc-only commits to ROADMAP / SPEC / +MIGRATION_RESEARCH / CONTRIBUTING are not individually listed once +they merely correct or extend this file — see `git log` for the +exhaustive history. + +- [`d6a3cb9`](./../../commit/d6a3cb9) — test(account_node): 10 inline error-path tests (Account::new, get_minting_account_address Ok+Err, get_account_balance Ok+Err, load_from_file Err+missing-path, save+load roundtrip, send_coins Unknown account + Insufficient funds). Total test count 32 → 42. account_node.rs body still excluded from CI coverage gate (full SP1-era test-fixture port is a separate follow-up). state_tests.rs clippy auto-fixed in the same commit. +- [`dac0179`](./../../commit/dac0179) — feat(docker): Dockerfile for the Plonky2 node (Step 9 prep). `rust:bookworm` base + rustup auto-installs nightly via `rust-toolchain`. Multi-stage build, FEATURES build-arg, debian-bookworm-slim runtime, EXPOSE 4242. Local release build verified clean (1m 26s on M3 Ultra). Smoke run end-to-end: `cargo run --release -p node` + `curl /health` → `ok`, `curl /api/info` → `{"network":"Mutinynet"}`, block scanner connects + processes Mutinynet tip. +- [`c71c9fc`](./../../commit/c71c9fc) — feat(step-7): `send_coins` wired to the Plonky2 `Prover` wrapper. Off-circuit source-side validation (in-coin in source's output_coins_root + source commitment in history MMR) replaces Stage 5d-next-5 Phase 2 (deferred post-MVP, blocked on Plonky2 1.1.0 ConstantGate shape mismatch — see `MIGRATION_RESEARCH.md` §7.22 for the eventual resolution). MMR proof paths in `get_merkle_proofs` now extended to `MMR_PROOF_PATH_LEN`; history_root passed to prover is `state.mmr.root_extended(MMR_PROOF_PATH_LEN)`. Init vs AccountUpdate branch on `account.proof` + `DEV_SKIP_BROADCAST_FAILURE` env-var bypass preserved. The env-var bypass was later removed in PR [#73](https://github.com/zk-coins/node/pull/73) once DEV and PRD were unified on the MVP-only binary. Test re-enable (account_node_tests + router_tests modules disabled at include-point) is a separate follow-up. +- [`19dcecf`](./../../commit/19dcecf) — fix(ci): relax coverage scope to skip account_node.rs + server.rs during Step-7 migration (their test modules are gated off pending Stage 5d-next-5 merge); new `test_get_mmr_inclusion_proof_known_root_returns_ok` to keep state.rs at 100% line / function coverage. +- [`ee0ef4b`](./../../commit/ee0ef4b) — fix(ci+server): CI workflow rewritten for nightly toolchain + Plonky2 crate names; server clippy `-D warnings` cleanup (feature-gated structs `#[cfg(...)]`, deprecated `to_inner` → `to_keypair`, `unimplemented!` block replaced with explicit `Err` to avoid `diverging_sub_expression`); coverage timeout 30m → 60m. +- [`00adbb4`](./../../commit/00adbb4) — feat(step-7): workspace toolchain unification (stable → nightly, root absorbs `program-plonky2/` + `script-plonky2/`) + server-side import migration. `program/` + `script/` SP1 crates deleted. shared/server use the Plonky2-era modules (`hash`, `types`, `inputs`); `[u8;32]` → `HashOut` boundary conversions via `digest_from_bytes` / `digest_to_bytes`; MMR leaf hash switched from SHA256 to Poseidon `hash_concat`. `account_node::send_coins` body wrapped in `unimplemented!` pending Prover-API integration after Stage 5d-next-5 merge. 31 server tests passing (scanner, state, username, etc.); `account_node_tests` + `router_tests` modules disabled at include point. +- [`b76bd39`](./../../commit/b76bd39) — feat(program-plonky2): step 7 prep — serde derives + persistence helpers (SMT/MMR/types/inputs all get `Serialize`/`Deserialize`; `save_merkle_tree` / `load_merkle_tree` / `save_mmr` / `load_mmr` ported from SP1-era helpers; 4 new tests for round-trip + missing-path I/O errors; `[u8; 33]` pubkey worked around with inline `BigArray33` helper to dodge serde's N≤32 derive limit) +- [`d96bb62`](./../../commit/d96bb62) — feat(script-plonky2): step 6 — host-side prover wrapper around `StateTransitionCircuit` (new crate `script-plonky2/` with `Prover` struct + `prove_initial` / `prove_account_update` / `verify` thin wrappers; mirrors the SP1-era `script/` crate shape; nightly toolchain via rust-toolchain.toml symlink to program-plonky2) +- [`c1df545`](./../../commit/c1df545) — docs: defer Stage 5d-next-4 source-side cyclic verify to 5d-next-5 (post-MVP) — Plonky2 1.1.0's `dummy_circuit` can't reproduce `ConstantGate`-containing common_data shapes (Approach A) AND the in-circuit data-only fallback hit `goal_data != common` mismatch at build (Approach B); the trusted server folding only validly-proved commitments into history MMR makes Stage 5d-next-3 + prev_account CMP sufficient for server-heavy MVP. See MIGRATION_RESEARCH §7.21. +- [`6ea965a`](./../../commit/6ea965a) — docs: finalise session pickup — §7.20 + test-confirmation + verification checklist +- [`7db536d`](./../../commit/7db536d) — docs: session-state pickup notes for next agent +- [`50a1bd9`](./../../commit/50a1bd9) — test: speed up account_update panic-tests via cyclic_base_proof (~25 min wall saved per full sweep) +- [`8fab78a`](./../../commit/8fab78a) — test: combined in-and-out integration test on AccountUpdate (mirror of `d292855` on the cyclic-recursion + CommitmentMerkleProofs path) +- [`05c17f8`](./../../commit/05c17f8) — docs(SPEC): note MAX_OUT_COINS in the constants table +- [`a502b8f`](./../../commit/a502b8f) — test: cover assert_eq panics on the *_in_and_out_coins wrappers (3 new should_panic tests for `prove_*_with_in_and_out_coins`) +- [`508ec9c`](./../../commit/508ec9c) — docs(ROADMAP): refresh commit list + test count after MAX_OUT_COINS=8 bump +- [`d292855`](./../../commit/d292855) — test: combined in-and-out integration test (one Initial proof exercising both in-coins and out-coins loops in a single transition; validates running-balance mutations and interim/final account_state_hash distinction compose correctly) +- [`56f3a05`](./../../commit/56f3a05) — feat: stage 5d-next-3-bump — MAX_OUT_COINS to 8 (mirrors MAX_IN_COINS at SPEC §13's production target; INNER_PAD_BITS bumped 13 → 14) +- [`1943316`](./../../commit/1943316) — docs: stage 5d-next-4 design doc for source verification +- [`6b5a885`](./../../commit/6b5a885) — feat: stage 5d-next-3 — out-coins processing +- [`b2b82e7`](./../../commit/b2b82e7) — feat: stage 5d-next-2 — bump MAX_IN_COINS to 8 +- [`0195f71`](./../../commit/0195f71) — feat: stage 5d-next — apply_coin (recipient + balance + overflow). Per-slot witnesses extended with `coin_recipient`, `coin_amount_lo`, `coin_amount_hi`. Active slots assert `coin_recipient == account.owner` and `balance += coin_amount` with overflow check via `split_le(sum, 33)`. Running balance threaded through `MAX_IN_COINS` slots; final balance fed to a second Poseidon hash for the public `ProofData.account_state_hash`. New tests: positive (1 active in-coin, balance increases by 42, final hash matches off-circuit `apply_coin`); negatives (wrong recipient rejected, overflow rejected). +- [`7db3c29`](./../../commit/7db3c29) — feat: stage 5d (minimal) + 5e (partial) — in-coin slot processing for coin_history + four SPEC §13 negative tests. 5d adds `MAX_IN_COINS = 1` const, `InCoinSlotTargets` per slot (`active`, `coin_identifier`, 256-sibling `nip_path`), per-slot SMT non-inclusion + insert into `coin_history_root` masked by `active`, new `prove_initial_with_in_coins` / `prove_account_update_with_in_coins` wrappers, and 5 tests (1 positive + 1 negative + 3 panic guards). 5e adds 4 negative tests against the existing 5c+ predicates. +- [`2ce36ce`](./../../commit/2ce36ce) — test: cover assert_eq panic messages in set_cmp_witness (3 should_panic tests restoring 100% line coverage after 5c+) +- [`4bc5f2f`](./../../commit/4bc5f2f) — feat: stage 5c+ — `CommitmentMerkleProofs` in-circuit (SPEC §8 (c)(d)(e); fixed-shape SMT inclusion at `TREE_DEPTH = 256` + 2× MMR inclusion at `MMR_PROOF_PATH_LEN = 31`; new `MMR_MAX_DEPTH = 32` const + `MMRProof::extend_to(depth)` + `MerkleMountainRange::root_extended(depth)` off-circuit helpers; new `select_hash` masking pattern so every constraint fires only when `condition = true`; `dummy_cmp()` placeholder used by `prove_initial` to populate the unused fields; tests: positive bootstrap chain (Init→Update with full CommitmentMerkleProofs verify) plus negatives for (b), (c), (d).) +- [`4f317fe`](./../../commit/4f317fe) — refactor: SMT redesign to uncompressed fixed-256 paths (off-circuit `InclusionProof` / `NonInclusionProof` always carry exactly `TREE_DEPTH = 256` siblings; path compression removed from `insert` and proof generation; `NonInclusionProof.leaf` field dropped — non-inclusion now witnesses the empty-leaf default at the depth-256 slot; in-circuit `verify_smt_inclusion` / `verify_smt_non_inclusion` / `verify_smt_insert` reduced to a single `hash_up_full_path` engine; case A/B branch and `extension` parameter gone.) +- [`bba6470`](./../../commit/bba6470) — feat: stage 5c — AccountUpdate branch (condition now a free witness; cyclic verify binds SPEC §8 (a); state continuity (b) via `condition * (account_state_hash - prev.account_state_hash) == 0`; coin_history carry-over via `select(condition, prev.coin_history_root, DEFAULT_HASHES[0])`; mint exception masked with `!condition`; 5 tests incl. Initial→AccountUpdate chain and state-discontinuity rejection; SPEC §8 (c)(d)(e) MMR/SMT history checks DEFERRED to stage 5c+) +- [`d167237`](./../../commit/d167237) — feat: stage 5b — Initial-branch state-transition predicate (`circuit/main.rs` rewritten: counter payload replaced by 16-element `ProofData`, mint exception + empty-SMT roots + in-circuit Poseidon `AccountState::hash`, condition pinned `false`; 3 tests: mint accepted, non-mint zero-balance accepted, non-mint nonzero-balance rejected) +- [`83fa0c1`](./../../commit/83fa0c1) — feat: stage 5a — cyclic recursion plumbing PoC (`circuit/main.rs`, 2 tests: base + 1 recursive cycle; superseded by stage 5b) +- [`6cf949c`](./../../commit/6cf949c) — feat: SMT insert verify gadget (8 tests: 3 positive incl. deep-divergence Case B, 3 negative incl. case-A invariant, 2 build-time assertion panics) +- [`79bd39e`](./../../commit/79bd39e) — docs: hardware target — M3 Ultra single host, no external hardware, no cloud prover (later corrected to note the integrated Apple GPU IS available, just unused by Plonky2 today) +- [`e14d9df`](./../../commit/e14d9df) — feat: 100% test coverage on program-plonky2 (16 new tests + MMR refactor + coverage(off) annotations) +- [`2b6f2cb`](./../../commit/2b6f2cb) — docs: consistency review pass — fix stale counts, add glossary, reconcile §6 +- [`401f813`](./../../commit/401f813) — docs(ROADMAP): closed test env — replace SP1, don't migrate +- [`cd94f85`](./../../commit/cd94f85) — docs: CONTRIBUTING + §7 Lessons Learned (8 entries) +- [`4cf98ac`](./../../commit/4cf98ac) — docs(ROADMAP): Plonky3 as post-MVP path; document rejected alternative +- [`1967087`](./../../commit/1967087) — docs(ROADMAP): server-side compute, drop wasm Poseidon +- [`2fed8f0`](./../../commit/2fed8f0) — feat: port `ProgramInputs` + `CommitmentMerkleProofs` (4 tests) +- [`9ba03bc`](./../../commit/9ba03bc) — feat: SMT non-inclusion verify gadget (3 tests + 1 negative) +- [`8002ce3`](./../../commit/8002ce3) — feat: SMT inclusion gadget + `circuit/util` (4 tests) +- [`5c92a62`](./../../commit/5c92a62) — docs: initial ROADMAP +- [`15d45c9`](./../../commit/15d45c9) — feat: MMR inclusion gadget (4 tests) +- [`e1af850`](./../../commit/e1af850) — feat: AccountState/Coin/ProofData (8 tests) +- [`c28e279`](./../../commit/c28e279) — feat: MMR to Poseidon (8 tests) +- [`6215009`](./../../commit/6215009) — feat: SMT to Poseidon + zero-state collision fix (12 tests) +- [`984580f`](./../../commit/984580f) — feat: Poseidon hash module (5 tests) +- [`8fa6a92`](./../../commit/8fa6a92) — chore: toolchain pin + lock §5 decisions +- [`72c3b78`](./../../commit/72c3b78) — feat: scaffold `program-plonky2/` standalone crate +- [`049ec3e`](./../../commit/049ec3e) — docs: SPEC reconciled with paper, §15 divergences +- [`57cdce4`](./../../commit/57cdce4) — docs: migration research +- [`496c652`](./../../commit/496c652) — docs: circuit specification + +**Test count on this branch:** 103 (all green on nightly-2025-04-15). +Breakdown: `prelude` 1 · `hash` 5 · `merkle::smt` 19 · `merkle::mmr` 14 · +`types` 10 · `inputs` 5 · `circuit::mmr` 5 · `circuit::smt` 12 · +`circuit::main` 32. + +**Coverage:** **100% lines, 100% functions, 100% regions** on `program-plonky2/` +as measured by `cargo llvm-cov --fail-under-lines 100`. Test modules +are annotated with `#[cfg_attr(coverage_nightly, coverage(off))]` so +assertion-message-string regions inside tests don't pollute the +production-surface measurement. Defensive `else ZERO_HASH` branches +in the MMR were collapsed into `.get().copied().unwrap_or(...)` so the +unreachable bounds-check shares one region with the success path +rather than carrying its own perpetually-uncovered branch. + +--- + +## In Progress + +**Step 5 — Monolithic state-transition circuit** (✅ done, broken into +stages, each landed as its own reviewable commit; preserved below as +the historical record): + +- **5a — recursion plumbing PoC** ✅ done in [`83fa0c1`](./../../commit/83fa0c1), + superseded by 5b. `circuit/main.rs` skeleton with + `conditionally_verify_cyclic_proof_or_dummy`, + `add_verifier_data_public_inputs`, three-pass + `common_data_for_recursion`, and a counter payload (`counter = if + condition { inner.counter + 1 } else { 0 }`). The R1 evidence that + cyclic recursion + `circuit_digest` pinning work in our Plonky2 + 1.1.0 setup. Tests and payload replaced in 5b. +- **5b — Initial branch with real predicate** ✅ done in + [`d167237`](./../../commit/d167237). Counter payload replaced by + 16-element `ProofData` public output. In-circuit Poseidon + `AccountState::hash` (with 32-bit balance limbs and 56-bit pubkey + limbs, both range-checked), `is_minting` predicate via element-wise + `is_equal` AND, mint exception enforced as `(1 - is_minting) * + balance_limb == 0`, `output_coins_root` and `coin_history_root` + constants from `DEFAULT_HASHES[0]`. `condition` constrained to + `false`. Three tests in `circuit::main`. +- **5c — AccountUpdate branch** ✅ done in this revision. `condition` + is now a free witness. `conditionally_verify_cyclic_proof_or_dummy` + binds SPEC §8 (a) (same circuit via `circuit_digest`). State + continuity (b) enforced as `condition * (account_state_hash[i] - + prev.account_state_hash[i]) == 0` for each of the 4 hash elements. + `coin_history_root` carry-over via `select(condition, + prev.coin_history_root, DEFAULT_HASHES[0])`. Mint exception masked + with `(1 - condition) * (1 - is_minting)` so it only applies to + Initial. 5 tests in `circuit::main`: 3 Initial-side from 5b plus a + full Initial→AccountUpdate chain (recursive verify works + end-to-end) and an AccountUpdate state-discontinuity rejection. + **SPEC §8 (c)(d)(e) — `CommitmentMerkleProofs` predicate proving + prev was published in the global history MMR — is NOT YET WIRED. + Stage 5c+ closes that gap.** +- **5c+ — CommitmentMerkleProofs in-circuit** ✅ done in commit + [`4bc5f2f`](./../../commit/4bc5f2f). SPEC §8 (c)(d)(e) all wired via + in-circuit SMT inclusion (`TREE_DEPTH = 256`) + 2× MMR inclusion + (`MMR_PROOF_PATH_LEN = 31`). Coverage-fix in + [`2ce36ce`](./../../commit/2ce36ce). +- **5d — in-coin slots (minimal)** ✅ done in this revision. + `MAX_IN_COINS = 1` (production target is 8 per SPEC §13; bumping + the constant is mechanical). Per slot the circuit reserves an + `active` bit, a `coin_identifier`, and a 256-sibling + `nip_path`. Active slots prove SMT non-inclusion of + `coin_identifier` at the running `coin_history_root` and compute + the new root after inserting `coin_identifier` (used both as key + and as leaf value, making `coin_history` a set-membership SMT). + Inactive slots are masked no-ops. The `coin_history_root` running + value is chained through all slots and emitted as + `ProofData.coin_history_root`. **NOT YET WIRED (defer to 5d+):** + recursive verification of each in-coin's source proof, SMT + inclusion of `coin.identifier` in `source.output_coins_root`, the + source's own CommitmentMerkleProofs, and the apply_coin balance / + recipient update on `AccountState`. Without these, in-coins are + unsound (a prover can claim any `coin_identifier` was sent to + them); 5d+ closes the gap. New tests in `circuit::main`: positive + Init-with-1-active-in-coin into empty coin_history; tampered nip + path rejected; 3 panic guards (`nip_path` length, slot count for + `prove_initial_with_in_coins`, slot count for + `prove_account_update_with_in_coins`). +- **5d-next — apply_coin semantics** ✅ done in this revision. + Per-slot witnesses extended: `coin_recipient: HashOutTarget`, + `coin_amount_lo: Target`, `coin_amount_hi: Target` (both + range-checked to 32 bits). Per slot, masked by `active`: + - Recipient check `active * (coin_recipient[i] - owner[i]) == 0` + for each of 4 hash elements. + - Balance add with overflow check via `split_le(sum, 33)`: bits + auto-witnessed by Plonky2's `BaseSumGate` generator; bit 32 is + the carry / overflow. `new_lo = sum_lo - 2^32 * carry`, + `sum_hi = balance_hi + active * coin_amount_hi + carry`, + `new_hi = sum_hi - 2^32 * overflow`, `assert overflow == 0`. + - Running balance threaded through slots; final balance feeds a + second `Poseidon(owner || final_balance_lo || final_balance_hi || + pubkey_limbs)` for the FINAL `account_state_hash` in `ProofData`. + The earlier `account_state_hash` (from initial balance) keeps + serving SPEC §8 (b) state-continuity and (c) commitment-witness + checks. Tests: positive 1-active-in-coin with `coin.amount = 42` + increments balance and matches off-circuit `apply_coin` hash; + `recipient != owner` rejected; `amount` causing balance overflow + rejected. + +- **5d-next-2 — bump `MAX_IN_COINS` to 8** ✅ done in this revision. + `MAX_IN_COINS` const is now 8. `common_data_for_recursion_c` + padding bumped to `INNER_PAD_BITS = 13` (`1 << 13 = 8192` gates) + to accommodate the larger outer circuit. Test helper + `slots_first_active(&coin, &nip, &dummy_coin, &dummy_nip)` builds + a `MAX_IN_COINS`-length slot array with the first slot active. + All 4 `prove_*_with_in_coins` tests refactored to use it; build + and prove confirmed for `stage_5d_initial_with_one_active_in_coin` + (188s wall). + +- **5d-next-3 — out-coins processing** ✅ done in this revision. + `MAX_OUT_COINS = 1` slot reserved (mechanical bump to 8 later). + Per slot witnesses: `active`, `out_coin_identifier`, + `out_coin_amount_lo/hi`, `nip_path`. Per slot constraints (masked + by `active`): + - SMT non-inclusion + insert into `running_output_coins_root` + (mirroring the in-coins coin_history pattern, but for the new + `output_coins_root`). + - Balance subtraction with **underflow check** via + `split_le(diff, 64)` (vs. overflow check `split_le(sum, 33)` for + in-coins addition). + - `out_coin_identifier == Poseidon(interim_account_state_hash || + u32(slot_index))` — mirrors off-circuit + [`crate::types::calculate_coin_identifier`]. + + Pubkey rotation: new `next_public_key_limbs` witness. The FINAL + `account_state_hash` (committed as `ProofData.account_state_hash`) + uses the NEW pubkey; the interim hash (used for identifier + derivation) uses the INITIAL pubkey, per SPEC §8 step 3 ordering. + + API: new `prove_initial_with_in_and_out_coins` / + `prove_account_update_with_in_and_out_coins` for full caller + control. The existing `prove_initial` / `prove_account_update` + wrappers default `next_public_key = account_state.public_key` + (no rotation) and all-inactive out-coin slots. + + Tests: positive `stage_5d_next_3_initial_with_one_active_out_coin` + (one out-coin emits, balance decreases by amount, pubkey rotates, + output_coins_root matches off-circuit insert); two negatives + (wrong identifier, underflow); two panic guards (nip-path length, + out-slot count). + +- **5d-next-5 — source-side verification via aggregator pattern** ✅ + done via PR [#23](https://github.com/zk-coins/node/pull/23). + Architecture: non-cyclic [`SourceAggregatorCircuit`](program-plonky2/src/circuit/source_aggregator.rs) + bundles up to `MAX_IN_COINS` source proofs via per-slot + `conditionally_verify_proof`; the outer state-transition circuit + verifies the aggregator proof once via `verify_proof` and binds its + claimed state-transition `verifier_data` to its own via + `connect_hashes`. Per-slot SPEC §8 step 2 gates fire inside the + in-coin loop: SMT inclusion of `coin.identifier` in + `source.output_coins_root`, OCR coupling, SPEC §8 (c)(d)(e) chain + for source's commitment in `history_root`, strict + `connect(slot.active, aggregator.slot[i].active_pi)` so no in-coin + can be consumed without a verified source. Two Plonky2 1.1.0 + shape-mismatch blockers were resolved empirically: explicit + `ConstantGate::new(2)` injection in the helper's pass-3, and + `INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` (`helper_degree = pad_bits + + 1`). Probes characterising both insights live in + [`src/circuit/recursion_shape_probe.rs`](program-plonky2/src/circuit/recursion_shape_probe.rs). + Full end-state in + [`MIGRATION_RESEARCH.md` §7.22](./MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721). +- **5e — negative tests from SPEC §13** ✅ done — all 11 negatives + covered (the previously-deferred 3 source-side negatives landed + with Stage 5d-next-5 Phase 3). Covered: + - Initial non-mint balance ≠ 0 → rejected (`stage_5c_plus_initial_non_mint_nonzero_balance_rejected`). + - Initial mint accepted (`stage_5c_plus_initial_mint_with_balance_accepted`, returns coin_history_root = DEFAULT_HASHES[0]). + - Account update mismatched state hash → rejected (`stage_5c_plus_account_update_state_discontinuity_rejected`). + - Prev's commitment_history_root not in current MMR → 4 tests: + `stage_5e_account_update_tampered_mmr_a_path_rejected`, + `stage_5e_account_update_tampered_mmr_b_path_rejected`, + `stage_5e_account_update_wrong_mmr_sibling_rejected`, + `stage_5e_account_update_wrong_history_root_rejected`. + - Double-spend (same in-coin twice in coin_history) → rejected + (`stage_5e_double_spend_same_coin_twice_rejected`). + - Out-coin identifier mismatch → rejected + (`stage_5d_next_3_initial_out_coin_wrong_identifier_rejected`). + - Sum of outputs > balance (underflow) → rejected + (`stage_5d_next_3_initial_out_coin_underflow_rejected`). + - Sum of input amounts overflow → rejected + (`stage_5d_initial_in_coin_overflow_rejected`). + - Wrong recipient on in-coin → rejected + (`stage_5d_initial_in_coin_wrong_recipient_rejected`). + + Newly covered by Stage 5d-next-5 Phase 3 (PR #23): + - Input coin whose source-proof is not in commitment history → + `stage_5d_next_5_phase_3_source_not_in_history_rejected`. + - Input coin whose identifier is not in source's `output_coins_root` + → `stage_5d_next_5_phase_3_coin_not_in_source_ocr_rejected`. + - Wrong `vk` on recursive source proof → + `stage_5d_next_5_phase_3_wrong_st_vk_on_aggregator_rejected`. + + Original (pre-stage-5b) wording: Overflow, underflow, + wrong vk, double-spend, wrong identifier, mismatched + account_state_hash, etc. + +Each stage carries the 100 % line coverage gate before commit. + +--- + +## Next (in order) + +### Step 5 — Monolithic state-transition circuit — ✅ done (see *In Progress* above for the historical breakdown) +**Effort:** 3–5 days (actual). +**Files:** `program-plonky2/src/circuit/main.rs` (new) — the equivalent of `program/src/main.rs`. +**Scope:** assemble all gadgets into the full circuit; implement Initial vs. AccountUpdate branch via `conditionally_verify_cyclic_proof_or_dummy`; fix `MAX_IN_COINS = 8`; pin `vk` via `add_verifier_data_public_inputs`; commit `ProofData` as 16-element public output. +**Test plan (100% coverage gate applies):** + - Single send (1 in-coin → 1 out-coin) — initial proof path. + - Two sequential sends — update-proof recursion. + - All 11 negative cases from SPEC §13 (overflow, underflow, wrong vk, double-spend, wrong identifier, mismatched account_state_hash, etc.). Each is a separate `assert!(data.prove(pw).is_err())` test. + - `cargo llvm-cov` on the new circuit module must be 100% lines + branches. +**Risk:** **High.** First real test of Plonky2 cyclic recursion with our public-input shape. The BitVM reference's toy IVC pattern is the only existing example; correctness depends on identical `circuit_digest` between build passes (two-pass `common_data_for_recursion` trick). + +### Step 6 — `script-plonky2/` prover host +**Effort:** 1–2 days. +**Files:** new crate `script-plonky2/`. +**Mirror of:** `script/src/lib.rs::Prover`. +**Test plan (100% coverage gate applies):** + - End-to-end through `create_account` and `update_account` paths. + - Error path: malformed inputs rejected. + - `cargo llvm-cov` on the prover wrapper must be 100%. +**Risk:** Low. Plonky2 prover API is simpler than SP1's. + +### Step 7 — Server: replace SP1 with Plonky2 (no dual backend) +**Effort:** 2–3 days. +**Files:** `node/src/account_node.rs`, `node/src/state.rs`, `node/src/scanner.rs`, `node/src/server.rs`. Plus delete the SP1-specific imports and replace the old `program/` and `script/` references with `program-plonky2/` + `script-plonky2/`. +**Strategy:** closed test environment means no migration. Stop the running DEV/PRD server, delete the existing SMT/MMR data files (`smt.bin`, `mmr.bin`, `accounts.bin`, `latest_block.bin`), start the new Plonky2-based server with a fresh state. No Cargo feature flag, no compatibility shim, no parallel-deploy. +**Key challenge:** the Schnorr commitment message stays `SHA256(serialize(asth) ‖ serialize(ocr))` per §5.4 of `MIGRATION_RESEARCH.md`, so the scanner converts Poseidon outputs to bytes before SHA256 → BIP-340 verify. +**Test plan (100% coverage gate applies):** the same `cargo llvm-cov -p node --fail-under-lines 100` gate that already enforces this on the SP1 build carries over. Every handler, every error path, every scanner state transition that lives in the PRD-feature-set must be covered. The current SP1 coverage baseline (see README.md table) is the floor to maintain. +**Risk:** Low. Mechanical port, no compatibility surface area. + +### Step 8 — App / wallet — ✅ done +**Status:** Pre-existing app-repo wiring already matches the new Plonky2 server contract — no code change required for the MVP. +**Files in `zk-coins/app`:** + - `rust/client/src/lib.rs` — `create_commitment(xpriv, num_pubkeys, asth_hex, ocr_hex)` (BIP-340 Schnorr over `SHA256(asth ‖ ocr)`, returns `{public_key, signature, message}` JSON). + - `src/app/send/page.tsx` — Phase 1 (`/api/send`) + Phase 2 (`/api/commit`) two-step send flow with in-flight commit persistence + retry. + - `src/lib/api/client.ts` — typed client for every server route registered in `node/src/server.rs` (`info`, `balance`, `send`, `commit`, `mint`, `username/claim`, `username/resolve`, `address`). + - `src/__tests__/app/send-pipeline.test.tsx` — round-trip + retry + idempotency unit tests (mocked WASM). + - `src/__tests__/lib/api/contract.live.test.ts` — schema-conformance probes against a live server. +**Why nothing changed in the wallet for the Plonky2 cutover:** the wallet operates strictly above the server-side ZK boundary. It signs `SHA256(asth ‖ ocr)` — both 32-byte hex blobs supplied by the server — with secp256k1. Whether the server computed `asth`/`ocr` via SP1+SHA256 or Plonky2+Poseidon is opaque to the wallet, and `digest_to_bytes` on the server side already serialises Poseidon `HashOut` into the same 32-byte shape (see `program-plonky2/src/hash.rs:48`). +**Test gate:** existing Vitest coverage gate in `zk-coins/app` (per that repo's CONTRIBUTING.md). No new gate. +**Remaining open question for Step 9 verification:** that `signature_verifies_after_app_send` lands as an e2e probe against the live DEV server. This is part of Step 9, not Step 8. + +### Step 9 — DEV deployment + e2e — 🟡 DEV live, e2e + R2 pending +**Done:** + - PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC. Auto-deploy via `.github/workflows/deploy-dev.yaml` pushed `zkcoins/node:beta` to Docker Hub and deployed to the DEV host. Bootstrap fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry — see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). + - `https://dev-api.zkcoins.app/health` → 200 `ok`; `https://dev-api.zkcoins.app/api/info` → 200 with `{network:"Mutinynet", capabilities:{address_list, faucet, usernames, lnurl: true}, username_domain:"dev.zkcoins.app"}` (post-[#73](https://github.com/zk-coins/node/pull/73) `address_list` and `lnurl` are `false` because DEV ships the MVP-only binary identical to PRD; `faucet` and `usernames` are hardcoded `true` — mint and usernames are permanent MVP, not feature-gated; the `usernames` Cargo feature was later removed outright — see PR [#76](https://github.com/zk-coins/node/pull/76)). + - Deploy hardening: PR [#51](https://github.com/zk-coins/node/pull/51) added deploy-dev + deploy-prd concurrency guards and a PRD smoke test. + - DEV/PRD parity: PR [#73](https://github.com/zk-coins/node/pull/73) dropped the DEV-only Cargo features (`address-list`, `faucet`, `usernames`, `lnurl`) and removed the `DEV_SKIP_BROADCAST_FAILURE` env-gate so the two environments run the identical MVP-only binary. A follow-up refactor removed the `faucet` Cargo feature outright — mint is permanent MVP and ships unconditionally in every build — and a further refactor removed the `usernames` Cargo feature so usernames are permanent MVP and ship unconditionally too (PR [#76](https://github.com/zk-coins/node/pull/76)). +**Remaining:** + 1. e2e roundtrip on signet from `dev.zkcoins.app`: create account → mint → send → recipient receives. Success criterion: one happy-path + one failure-path per route. Tracked via a follow-up GitHub issue. + 2. Real performance measurement on the M3 Ultra. R2 budget: warm proof ≤ 5 s, ideally ≤ 1 s; cold-start ≤ 30 s including circuit-data load; peak mem < 64 GB during proving. Plonky2 currently runs CPU-only on Apple Silicon (no Metal backend); that's the operative baseline. + 3. If budget is missed: redesign per R2 (reduce `MAX_IN_COINS`, drop in-coin recursion, or switch to folding). **NOT** add external hardware or move to a cloud prover — the closed-environment + single-host constraint is non-negotiable. +**Test plan:** the authoritative coverage gate runs in CI on the self-hosted M3 Ultra runner pool (`.github/workflows/ci.yaml`, jobs `Node + Shared Tests` and `Coverage Gate`, gated behind the `ci:full` label per PR [#48](https://github.com/zk-coins/node/pull/48)); the pre-push hook only enforces fmt + clippy + `cargo check`. Step 9 verifies integration, not unit coverage. e2e success criterion: every endpoint round-trips under realistic conditions (one happy-path traversal per route plus at least one failure path per route). +**Risk:** Medium. First real exposure of the cyclic-recursive prover to production hardware under realistic load. If the budget holds, MVP is done. + +--- + +## Pre-Mainnet Hardening + +These are not MVP scope but block mainnet, per `SPEC.md` §15. + +| # | Item | Effort | +| - | ---- | ------ | +| D2/D10 | Hiding recipient commitments (`Commitment::commit(acct_id, rand)`) — fixes coin-linkability | 1 week | +| D7 | Conditional-noop on reorg (gracefully degrade when claimed nullifier-accum no longer a prefix) | 4–5 days | +| D8 | Per-coin nullifier-accum snapshot — recipients verify coin age locally | 2–3 days | +| Tests | Paper-derived test suite from `MIGRATION_RESEARCH.md` §3 (A-SEC, ToSAcc prefix, half-aggregate Schnorr, etc.) | 1 week | + +**Total pre-mainnet add-on: ~2–3 weeks.** + +--- + +## Long-term positioning + +Plonky2 is bridge technology. Post-MVP (after step 9): Plonky3 evaluation. Field/hash choice then via planned migration, not via ad-hoc drift. + +--- + +## Risk Register + +### R1 — Plonky2 cyclic recursion correctness (high) +**What can go wrong:** Step 5 fails because `circuit_digest` isn't stable between the two `common_data_for_recursion` passes, or the public-input layout in `add_verifier_data_public_inputs` is misaligned. +**Mitigation:** Start step 5 with the simplest possible "I verify myself with a trivial payload" circuit before adding the real predicate. Validates the recursion plumbing in isolation. +**Trigger to escalate:** if 1 day of debugging step 5 doesn't produce a verifying proof, escalate to the maintainers / the Plonky2 community. + +### R2 — 1-second proof target unreachable on M3 Ultra (medium) +**What can go wrong:** Real circuit with 1+8 recursive verifies is too large for sub-second proving on the target hardware. +**Hardware constraint:** Mac Studio M3 Ultra, 96 GB RAM, single host. The integrated Apple GPU is on the box and would be usable IF Plonky2 had a Metal backend — it doesn't, so de facto we're on CPU. External hardware (NVIDIA, CUDA, GPU farms) and external cloud provers (Succinct Network, AWS, etc.) are off the table. If proof time overshoots, the design changes; we do not add external hardware. +**Mitigation knobs (all design-level):** + (a) reduce `MAX_IN_COINS`; + (b) drop recursion of in-coin proofs (replace with off-circuit nullifier-set check; this is a protocol change); + (c) switch to a folding scheme (Nova / HyperNova / similar) that's CPU-native; + (d) opportunistic: if a Plonky2 Metal backend becomes available, evaluate. +**Explicitly OFF the table:** discrete NVIDIA / CUDA hardware (we have an Apple Silicon box, not an x86 + NVIDIA host), Succinct Prover Network (violates closed-test-env + no-external-services rule), Apple Neural Engine / AMX as custom-kernel targets (we won't author the kernels ourselves). +**Trigger to escalate:** measured proof time > 5 s on M3 Ultra. Wallet-side performance is N/A — proving is server-side; the wallet's send-flow latency = proof time + network roundtrip. + +### R3 — (removed) +Was: "Wasm Poseidon too slow." No longer applicable — the wallet performs no Poseidon hashing (server-side compute architecture). The wallet's only crypto is BIP-340 Schnorr signing of a SHA256 digest, which WebCrypto handles natively. + +### R4 — Pre-mainnet hardening pushes timeline (high) +**What can go wrong:** D2/D10 hiding recipient is a real protocol change, not a patch. May require re-doing step 5 if it doesn't fit the existing circuit shape. +**Mitigation:** Decide before mainnet whether to ship the MVP variant first (linkable recipients, documented) and harden later, or harden now. Currently planning the former (per §5.5 in MIGRATION_RESEARCH). +**Trigger to escalate:** if regulatory or PR feedback flags linkability before MVP launch. + +### R5 — SP1 stays in the workspace forever (mitigated by closed-env strategy) +**What was the worry:** dual-backend Cargo feature flag would let SP1 linger because there's no forcing event to remove it. +**Mitigation in place:** zkCoins is in a closed test environment (DEV + PRD), so step 7 doesn't introduce a feature flag — it deletes the SP1 path outright as part of the rewire. There is no parallel-backend phase, therefore no "follow-up cleanup PR" needed. Risk reduced from medium to low. + +### R6 — Plonky2 itself becomes the new dead-end (medium, long horizon) +**What can go wrong:** Plonky2 is in maintenance mode at 0xPolygonZero. Plonky3 is where active development goes (new gate sets, BabyBear field, Poseidon2 hash, GPU paths). If we ignore Plonky3 indefinitely we end up where SP1 left us — on a stack with no upstream momentum. +**Mitigation:** Treat Plonky2 as **bridge technology**, not the final destination. See *Post-MVP path: Plonky3* below. +**Trigger to escalate:** Plonky2 upstream goes 12 months without a release, OR Plonky3 reaches feature parity for our use-case (recursion + BIP-340-Schnorr boundary). + +--- + +## Post-MVP Path: Plonky3 + +Plonky2 is the **MVP bridge**, not the long-term substrate. After step 9 +succeeds we schedule a Plonky3 evaluation. Concretely: + +- **Field:** Plonky3 default is **BabyBear** (`p = 2^31 - 2^27 + 1`). + Smaller field, GPU-friendlier in general — but the GPU paths in + practice mean *CUDA*, which our M3 Ultra host can't run. Apple + Silicon GPU support would have to come via Metal in the prover + library; that's not the typical Plonky3-BabyBear GPU pitch. The + motivation for BabyBear here therefore reduces to "matches SP1's + choice / Plonky3-native"; Plonky2 we use Goldilocks because that's + Plonky2's mature default. +- **Hash:** Plonky3 default is **Poseidon2** (~2× faster than the + original Poseidon used in Plonky2). +- **Gadget reuse:** algorithmic structure (SMT, MMR, ProofData layout, + recursion contract) stays. The Plonky3 port is primarily plumbing — + re-typing field elements, swapping the hash function, adjusting limb + packing for BabyBear's smaller modulus. +- **Estimated effort for Plonky3 cutover:** 2–4 weeks. Field and hash + change cost ~20% of that; the rest is Plonky3's different API + (recursion patterns, gate sets, witness generation). +- **Trigger to start:** Plonky3 reaches feature parity for recursion + + our public-input layout. Currently (2026-05) it is close but the + recursion ergonomics are still under active iteration. + +### Considered alternative — adopt BabyBear + Poseidon2 inside Plonky2 *now* + +A reviewer suggested switching to BabyBear field and Poseidon2 hash +already during this Plonky2 migration so that the Plonky3 cutover later +becomes "pure glue code". Rejected for v1: + +1. **Plonky2 + BabyBear is fork-land.** `plonky2` 1.1.0 on crates.io is + Goldilocks-only. BabyBear support exists in community forks + (`plonky2-goldibear`-style) but those carry less upstream momentum + than the canonical Goldilocks build. We'd trade one upstream-mature + stack for one less-mature stack, with no MVP benefit. +2. **Poseidon2 in Plonky2 needs custom implementation.** The crate's + `PoseidonHash` is Poseidon1. Poseidon2 means either hand-rolling the + permutation or pulling another community crate. Custom crypto code + in the MVP path is exactly what we want to avoid. +3. **Migration cost now is non-trivial.** Switching to BabyBear means + re-doing `hash.rs`, `types.rs`, both Merkle modules (Goldilocks's + 2-limb u64 → BabyBear's 3-limb u64, 4-element digest → 8-element + digest, ProofData re-shape, etc.). Roughly 3–4 days of work that + produces no end-user-visible change. +4. **Plonky3 cutover later is not "glue code" anyway.** Plonky3's API + (recursion ergonomics, gate sets, witness generation) is meaningfully + different from Plonky2's. The field/hash choice contributes maybe 20% + of that work; the rest happens either way. Switching field early + shrinks the eventual diff by maybe one day, at the cost of slower MVP + delivery. + +The decision is reversible: if the Plonky3 evaluation post-step-9 shows +a clean enough path, we can do the field+hash switch *as part of* that +migration with no extra structural cost. + +--- + +## Update Protocol + +Whenever a commit lands on this branch: + +1. If the commit completes a step → flip its row in *Status at a Glance* to ✅ and move its entry under *Done*. +2. If the commit partially completes a step → flip to 🟡 and note progress under *In Progress*. +3. If new tasks emerge → add a row in *Next* or *Pre-Mainnet Hardening* with effort estimate. +4. If the commit invalidates an estimate → revise the *Effort* column. +5. If the commit hits or escalates a risk → update the relevant *Risk Register* entry. + +Stale roadmap = broken roadmap. If a commit changes scope and this file +isn't updated, the next reviewer should reject the PR until it is. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..800b2f75 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,488 @@ +# zkCoins Circuit Specification + +This document specifies the zkCoins state-transition circuit (currently implemented in Plonky2 + Poseidon in `program-plonky2/src/circuit/main.rs`) and the surrounding off-circuit responsibilities. It is **implementation-agnostic**: it does not mandate Plonky2, Poseidon, or any particular proof system. It is intended as a starting point for porting the circuit to other proof systems (e.g. Plonky3 with Poseidon2 / BabyBear) while preserving protocol semantics. Historical context: the original implementation used SP1 + SHA256 (recoverable at tag `v0.last-sp1`); PR [#17](https://github.com/zk-coins/node/pull/17) (merged 2026-05-18) migrated to Plonky2 + Poseidon-Goldilocks. + +> **Scope note.** This spec describes the **zkCoins MVP variant** of the Shielded CSV protocol, not the paper as published. It deliberately departs from [eprint 2025/068](https://eprint.iacr.org/2025/068) in 11 concrete ways — see §15 "Divergences from Shielded CSV (paper)" below, and [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) for full analysis against the upstream reference implementation at [`ShieldedCSV/ShieldedCSV`](https://github.com/ShieldedCSV/ShieldedCSV). +> +> **New here?** Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md) § "Working on the Plonky2 Migration" for the project invariants, decision recipe, and reading order. This spec is the *what*; CONTRIBUTING is the *how to navigate*. + +The reference implementation lives in: + +- `program-plonky2/src/types.rs` — `AccountState`, `Coin`, `ProofData` and pure helpers +- `program-plonky2/src/circuit/main.rs` — circuit entry point (build + prove) +- `program-plonky2/src/circuit/source_aggregator.rs` — non-cyclic per-slot source aggregator (Stage 5d-next-5) +- `program-plonky2/src/merkle/sparse_merkle_tree.rs` — Poseidon SMT +- `program-plonky2/src/merkle/merkle_mountain_range.rs` — Poseidon MMR +- `script-plonky2/src/lib.rs` — host-side Plonky2 prover wrapper +- `node/src/account_node.rs` — input preparation (host) +- `node/src/state.rs` — global state (SMT + MMR) +- `shared/src/commitment.rs` — Schnorr commitment used to bind a proof to an on-chain inscription + +--- + +## 1. Goal + +A zkCoins coin transfer produces a recursive SNARK that proves: + +1. The sender's **account state** transition is consistent with the input coins (sum of inputs ≥ sum of outputs, no overflow). +2. Each input coin was produced by a previous valid send proof (recursive verification). +3. Each input coin has not been spent before in this account (non-inclusion in the account's coin history, then inserted). +4. Each input coin's parent commitment is included in the **global commitment history** (so the chain ordering is authoritative). +5. The output coins have deterministic, content-addressed identifiers derived from the next account state. +6. A public `ProofData` summary is committed: the new account state hash, the new output-coins root, the global commitment-history root, and the new coin-history root. + +The proof is then "registered" on-chain by publishing a Schnorr commitment over `H(account_state_hash || output_coins_root)` as a Taproot inscription with txid prefix `4242`. The scanner picks up this commitment and inserts it into the global SMT, after which the global MMR root advances. + +--- + +## Glossary + +Abbreviations and shorthand used throughout this spec and the surrounding documents (`MIGRATION_RESEARCH.md`, `ROADMAP.md`, `program-plonky2/CONTRIBUTING.md`, source comments). + +| Term | Expansion | Meaning | +| ---- | --------- | ------- | +| **asth** | account state hash | `H(AccountState)` — the digest committed by a send proof as its post-state. | +| **ocr** | output coins root | The Merkle root of the SMT containing the send's output coin identifiers. | +| **vk** | verifying key | The proof system's verifier key. In Plonky2 it's the `circuit_digest`; pinned via `add_verifier_data_public_inputs`. | +| **pk** | public key | secp256k1 compressed pubkey, 33 bytes. For account commitments, rotates per send. | +| **SMT** | Sparse Merkle Tree | Binary tree of depth 256 (one level per key bit), used for the per-account coin history, the per-send output coins tree, and the global commitment SMT. | +| **MMR** | Merkle Mountain Range | (Misnomer in this codebase: actually a capacity-doubling padded Merkle tree.) Append-only structure holding the global commitment history. | +| **PCD** | Proof-Carrying Data | Recursive-proof composition abstraction used by the Shielded CSV paper; in Plonky2 we instantiate this with cyclic SNARK recursion. | +| **NIP** | NonInclusionProof | Witness that a key is *not* in an SMT. Two cases off-circuit: case A (empty subtree) and case B (path-compressed sibling leaf). | +| **IP** | InclusionProof | Witness that a key *is* in an SMT, with its associated value. | +| **D1–D11** | Divergences | Numbered list of differences between this implementation and Shielded CSV eprint 2025/068 (`MIGRATION_RESEARCH.md` §3, summarised in SPEC §15). | +| **R1–R6** | Risks | Numbered entries in the ROADMAP risk register. | +| **MAX_IN_COINS** | — | `= 8`. Fixed bound on input coins per send (Plonky2 circuit is fixed-shape; see decision §5.2 in MIGRATION_RESEARCH). | +| **MAX_OUT_COINS** | — | `= 8`. Fixed bound on output coins per send; same fixed-shape rationale as `MAX_IN_COINS`. | +| **TREE_DEPTH** | — | `= 256`. SMT depth (one level per key bit). | +| **Step N** | — | Refers to the corresponding row in ROADMAP's *Status at a Glance* table. | +| **BIP-340** | — | Bitcoin Schnorr signature scheme over secp256k1. The wallet uses BIP-340 to sign `SHA256(serialize(asth) ‖ serialize(ocr))`. | +| **Goldilocks** | — | The 64-bit prime field used by Plonky2 (`p = 2^64 - 2^32 + 1`). | +| **Poseidon** | — | Algebraic hash function we use for all Merkle node hashing and the field-element commitment of `AccountState`. | + +--- + +## 2. Conventions and Types + +### 2.1 Hash function + +Let `H : bytes → F^n` denote the protocol-wide hash function. In the reference implementation `H` is SHA256 (`HashDigest = [u8; 32]`). In a Plonky2 port, `H` should be an algebraic hash (e.g. Poseidon over the Goldilocks field, output 4 field elements ≡ 256 bits of security with appropriate parameters). Once chosen, `H` MUST be used consistently in: + +- All Merkle tree node hashes (`hash_concat`) +- The leaf-encoding rule (see §4.1) +- `AccountState::hash` (account commitment digest) +- `calculate_coin_identifier` +- The "commitment message" hashed before Schnorr signing (`H(account_state_hash || output_coins_root)`) +- The State's MMR-leaf rule (`H(smt_root || prev_mmr_root)`) +- The SMT key-derivation for a Bitcoin pubkey: `key = H(serialize_compressed(pubkey))` + +There is **no domain separation between "leaf hashing" and "internal node hashing"** in the SMT today, except that the very bottom leaf is `hash_concat(value, key)` and a domain-separated `hash_leaf(0x00 || data)` is used only for the DEFAULT_HASHES seed. A clean Plonky2 port SHOULD introduce explicit domain separation tags as field-element prefixes to avoid second-preimage ambiguity. See §10 for migration guidance. + +### 2.2 Primitive types + +| Type | Meaning | +| --------------- | ---------------------------------------------------------------------------------- | +| `HashDigest` | Output of `H`. Fixed-size byte string (32 bytes for SHA256, 4 field elts for Poseidon). | +| `Address` | `HashDigest` derived as `H(initial_public_key_bytes)`. | +| `Amount` | `u64`. Coin amounts are non-negative integers; circuit MUST check `checked_add`/`checked_sub`. | +| `PublicKey` | Compressed secp256k1 pubkey, 33 bytes. Schnorr signatures (BIP-340) use x-only. | +| `VerifyingKey` | Identifier of the proof system's verifying key. SP1 uses `[u32; 8]`. Plonky2 would use the circuit's `VerifierOnlyCircuitData` digest. | + +### 2.3 Coin identifier rule + +``` +identifier := H(account_state_hash || u32_be(coin_index)) +``` + +where `account_state_hash` is the **sender's next** account state hash (after balance is decremented but **before** the public key is rotated to `next_public_key`), and `coin_index` is the 0-based index of the coin in the `out_coins` vector. This makes coin identifiers deterministic and content-addressed, which is what allows the circuit to enforce uniqueness and non-malleability without needing a per-coin signature. + +--- + +## 3. Account Model + +### 3.1 `AccountState` + +``` +AccountState { + owner: Address // = H(initial_public_key_bytes), never changes + balance: u64 + public_key: PublicKey // current commitment pubkey (rotates each send) +} +``` + +`AccountState::hash` MUST be a deterministic, canonical encoding hashed with `H`. The reference uses `bincode::serialize` followed by SHA256; a Plonky2 port SHOULD use a fixed field-element layout: `[owner_limbs..., balance_low, balance_high, pubkey_x_limbs..., pubkey_y_parity]` and a single Poseidon call. + +### 3.2 Coin + +``` +Coin { + identifier: HashDigest // = H(sender_next_account_state_hash || u32_be(index)) + recipient: Address // recipient's account owner + amount: Amount +} +``` + +### 3.3 Account transitions inside the circuit + +- **`apply_coin(coin)`** (used for input coins): assert `coin.recipient == self.owner`, `self.balance = self.balance.checked_add(coin.amount)`. Overflow MUST cause the proof to fail. +- **`send_coins(out_coins, out_proofs, next_public_key)`** (used after applying all input coins): + - Build the `out_coins_root` by inserting each `out_coin.identifier` into an initially empty SMT, witnessed by a non-inclusion proof per coin. The circuit MUST assert `out_coins_root == current_root` before each insert (i.e. each proof witnesses the running root). + - Decrement `self.balance` by each coin's amount with `checked_sub`; underflow MUST cause the proof to fail. + - After all inserts: compute `account_hash := H(self)` and assert `coin.identifier == H(account_hash || u32_be(i))` for every output coin `i`. + - Finally rotate the account's `public_key` to `next_public_key`. + - Return `out_coins_root`. + +--- + +## 4. Merkle Structures + +### 4.1 Sparse Merkle Tree (SMT) + +- **Depth:** `TREE_DEPTH = 256`. The Poseidon-Goldilocks port keeps this — a `HashDigest` is 4 Goldilocks elements × 64 bits = 256 bits when serialised, so 256 levels exactly cover the key's bit space. Implementations on smaller fields (e.g. BabyBear, 31 bits) would pack the key into more limbs but typically keep the depth at 256 (full-key-bit-tree); see `program-plonky2/src/merkle/sparse_merkle_tree.rs::TREE_DEPTH`. +- **Key:** a `HashDigest`. Bit `i` is the MSB-first selector at level `i` (level 0 = root, level `TREE_DEPTH` = leaf). +- **Leaf encoding:** `leaf_hash = H(value || key)`. The `value` is itself a `HashDigest`. +- **Default leaf** at level `TREE_DEPTH`: `H(0x00 || ε)` (domain-separated empty leaf in the reference; Plonky2 SHOULD pick a fixed sentinel field-element constant). +- **Default internal hashes:** `DEFAULT_HASHES[level] = H(DEFAULT_HASHES[level+1] || DEFAULT_HASHES[level+1])`. +- **Inclusion proof** = `(key, siblings[0..TREE_DEPTH])`. Verifier reconstructs the root from `H(value, key)` upwards, using bit `i` of `key` (MSB-first) to decide ordering: bit=0 → `(current, sibling)`, bit=1 → `(sibling, current)`. +- **Non-inclusion proof** = `(key, root, siblings, leaf=(other_key, other_value))`. Two cases: + 1. **Empty subtree case:** `other_key == key` AND `other_value == DEFAULT_HASHES[siblings.len()]`. Verifier hashes that default leaf upwards. + 2. **Occupied sibling case:** `other_key != key` (assert). Verifier hashes `H(other_value, other_key)` upwards along `other_key`'s path. By the SMT invariant this proves no leaf with `key` is present along the same prefix. +- **Insert via non-inclusion proof:** the verifier-and-inserter recomputes the new root by extending the proof with default-hash padding down to the first differing bit between `key` and `other_key`, then hashes both leaves upward. This MUST yield the new root deterministically. + +### 4.2 Merkle Mountain Range (MMR) + +In the reference this is actually a **fixed-shape padded Merkle tree** with capacity doubling, not a classical MMR. The name is historical; the structure used is simpler. + +- Capacity is the next power of two ≥ leaf-count, starting at 2. +- Missing leaves are padded with `ZERO_HASH` (= 32 zero bytes, or the zero field element). +- Internal nodes: `node = H(left || right)`. Missing right siblings are `ZERO_HASH`. +- The root advances when a leaf is appended; capacity doubles when the tree fills (no re-hashing, just resize). +- **Proof** = `(index, path)` where `path[level]` is the sibling at each level from leaf to (level just below) root. Verifier: if `index` is even at this level, `H(current || sibling)`; else `H(sibling || current)`; `index /= 2`. + +--- + +## 5. Global Commitment Format and History + +### 5.1 Off-chain "commitment" (`shared::commitment::Commitment`) + +A `Commitment` produced by the client is: + +``` +Commitment { + public_key: PublicKey // commitment pubkey (= account's current pk) + signature: Schnorr(BIP-340) // over msg_hash (see below) + message: bytes // the raw 32-byte H(asth || ocr) digest (no double-hashing) +} +``` + +The signed message is `H(account_state_hash || output_coins_root)` where both inputs are `HashDigest`s. If a Plonky2 port keeps SHA256 _here_ for compatibility with secp256k1 Schnorr, that is fine — but the `account_state_hash` and `output_coins_root` operands themselves are produced by `H` and so MUST match the chosen circuit hash. Mismatching the two will break the scanner ↔ circuit link. + +### 5.2 Global state (`node::state::State`) + +- `smt: SparseMerkleTree` — keyed by `H(serialize_compressed(commitment_pubkey))`, value = `H(account_state_hash || output_coins_root)` (`Commitment::get_account_state_hash()` — misleading name, it's actually the message digest). +- `mmr: MerkleMountainRange` — leaves are `H(smt_root || prev_mmr_root)`. +- `prev_mmr_root: HashDigest` — the MMR root just before the most recent SMT update was folded in. +- `root_indices: Map` — host-side lookup, not part of the protocol. + +#### `State::update(commitments)` + +For each `Commitment c`: + +1. `key := H(serialize_compressed(c.public_key))` +2. `value := c.message` (= `H(asth || ocr)`) +3. `smt.insert(key, value)` — fails if key already present with a different value (replay/inconsistency). + +After all inserts: + +4. `smt_root := smt.root()` +5. `prev_mmr_root := mmr.root()` (capture, then update `self.prev_mmr_root`) +6. `leaf := H(smt_root || prev_mmr_root)` +7. `mmr.append(leaf)` +8. Return `mmr.root()` (the new global commitment-history root). + +This is the contract that the scanner enforces, and the circuit's `verify_commitment` / `verify_previous_root` assume. + +--- + +## 6. `CommitmentMerkleProofs` + +A bundle of Merkle witnesses linking one **proof** (account or coin) to the current global history root. Provided as a hint to the circuit; the circuit verifies them. + +``` +CommitmentMerkleProofs { + commitment_root: HashDigest // SMT root containing this commitment + commitment_proof: InclusionProof // proves commitment in that SMT + commitment_root_history_proof: MMRProof // proves SMT root is in the MMR (paired w/ prev_mmr_root) + commitment_root_mmr_sibling: HashDigest // = prev_mmr_root at the time this commitment was folded + previous_root_history_proof: (HashDigest, MMRProof) // proves the previous MMR root is also in the MMR + commitment_account_state_hash: HashDigest // claimed asth, opened + commitment_out_coins_root: HashDigest // claimed ocr, opened +} +``` + +### Verifier rules + +- `commitment_proof.verify(H(commitment_account_state_hash || commitment_out_coins_root), commitment_root)` MUST hold. +- `commitment_root_history_proof.verify(H(commitment_root || commitment_root_mmr_sibling), current_history_root)` MUST hold. +- `previous_root_history_proof.1.verify(H(previous_root_history_proof.0 || prev_proof_history_root), current_history_root)` MUST hold, where `prev_proof_history_root` is the `commitment_history_root` committed by the prior proof we are verifying. + +This chain is what enforces **monotonicity of history**: a new proof must extend the same history its inputs came from. + +--- + +## 7. Program Inputs (`ProgramInputs`) + +These are passed to the circuit on stdin (SP1) or as private witness (Plonky2). All fields are private witnesses except those re-derived from the public output (`ProofData`). + +``` +ProgramInputs { + proof_type: InitialProof | AccountUpdateProof + verification_key: VerifyingKey // self-hash for recursion (see §9) + account_state: AccountState // sender's state BEFORE this send + current_history_root: HashDigest // claimed global MMR root + + // Only present for AccountUpdateProof + prev_proof_public_values: Option // prior account proof's public output + prev_proof_history_proofs: Option // witness that prior proof was committed on-chain + + // Per input coin (in_coins[i]) + in_coins: [Coin] + in_coin_proofs_public_values: [ProofData_bytes] // each coin's source proof public output + in_coin_proofs_history_proofs: [CommitmentMerkleProofs] // witnesses each source proof was committed + in_coin_proofs_non_inclusion_proofs: [NonInclusionProof] // witnesses each coin is unseen in own coin_history + in_coins_inclusion_proofs: [InclusionProof] // witnesses each coin is in source's out_coins_root + + // Outputs + out_coins: [Coin] + out_coin_proofs: [NonInclusionProof] // running non-inclusion proofs into the new (initially empty) out_coins_tree + next_public_key: PublicKey // sender's rotated key +} +``` + +For the recursive proofs (`prev_proof_public_values` and each `in_coin_proofs_public_values`), the host MUST also supply the actual recursive proof artifact (in SP1: `SP1Stdin::write_proof`). In Plonky2 these become `ProofWithPublicInputsTarget`s and are verified by `verify_proof::(...)` against a fixed `verifier_data` digest. + +--- + +## 8. Circuit Logic + +The circuit reads `ProgramInputs`, performs all asserts and field updates, and commits a single `ProofData` as public output. + +``` +fn main(inputs: ProgramInputs): + vk := inputs.verification_key + account_state := inputs.account_state // mutable local + history_root := inputs.current_history_root + + // 1. Coin-history root: either default (initial proof) or carried from prev account proof. + coin_history_root := match inputs.proof_type: + InitialProof: + // Mint exception: the special MINTING_ADDRESS may have any starting balance. + if account_state.owner != MINTING_ADDRESS: + assert account_state.balance == 0 + DEFAULT_HASHES[0] + + AccountUpdateProof: + // Recursively verify the previous account proof. + prev := verify_proof(inputs.prev_proof_public_values, vk) + assert vk == prev.vk // (a) same circuit + assert account_state.hash() == prev.account_state_hash // (b) state continuity + mp := inputs.prev_proof_history_proofs + assert account_state.hash() == mp.commitment_account_state_hash // (c) opening matches witness + assert mp.verify_commitment(history_root) // (d) commitment in history + assert mp.verify_previous_root(prev.commitment_history_root, history_root) // (e) extends prior history + prev.coin_history_root + + // 2. Apply each input coin (in order). + for (i, coin) in inputs.in_coins.iter().enumerate(): + cp := verify_proof(inputs.in_coin_proofs_public_values[i], vk) // recursive + assert vk == cp.vk + // Source's out_coins_root must contain this coin. + assert inputs.in_coins_inclusion_proofs[i].verify(coin.identifier, cp.output_coins_root) + // Source's commitment must be in the global history. + mp := inputs.in_coin_proofs_history_proofs[i] + assert cp.output_coins_root == mp.commitment_out_coins_root + assert mp.verify_commitment(history_root) + assert mp.verify_previous_root(cp.commitment_history_root, history_root) + // Coin must be unseen in own coin_history and inserted there. + nip := inputs.in_coin_proofs_non_inclusion_proofs[i] + assert coin_history_root == nip.root + coin_history_root := nip.verify_and_insert(coin.identifier) + account_state := account_state.apply_coin(coin) // assert recipient == owner, checked_add + + // 3. Build new out_coins_root and rotate pubkey. + out_coins_root := account_state.send_coins( + inputs.out_coins, inputs.out_coin_proofs, inputs.next_public_key + ) + // send_coins internally: + // - For each (out_coin, ncl_proof): + // assert out_coins_root_running == ncl_proof.root + // out_coins_root_running := ncl_proof.insert(out_coin.identifier) + // balance := balance.checked_sub(out_coin.amount) // assert no underflow + // - Compute account_hash := H(account_state) + // - For each (i, out_coin): + // assert out_coin.identifier == H(account_hash || u32_be(i)) + // - account_state.public_key := next_public_key + + // 4. Commit public output. + commit(ProofData { + vk: vk, + account_state_hash: account_state.hash(), + output_coins_root: out_coins_root, + commitment_history_root: history_root, + coin_history_root: coin_history_root, + }) +``` + +### Note on the minting account + +`MINTING_ADDRESS` is a `HashDigest` constant. In the Plonky2/Poseidon build it is a domain-separated placeholder baked into `program-plonky2/src/types.rs::MINTING_ADDRESS` and **overridden at runtime** in `runtime.rs::start_rest_node`: after constructing the minting `ClientAccount` from `minting_secret.bin`, the code sets `minting_client.address = *MINTING_ADDRESS` so the prover circuit and the server state share the same value. This runtime override was added in PR [#36](https://github.com/zk-coins/node/pull/36) to fix a panic-in-tokio-spawn regression (see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). The closed test environment means we are not bound to the historical SP1 minting key. + +--- + +## 9. Public Output (`ProofData`) + +``` +ProofData { + vk: VerifyingKey + account_state_hash: HashDigest + output_coins_root: HashDigest + commitment_history_root: HashDigest + coin_history_root: HashDigest +} +``` + +`vk` is the **circuit's own verifying-key digest**. It's used to enforce that a recursively verified proof was generated by the exact same circuit (preventing a different circuit from forging public values). + +In SP1 this is `vk.hash_u32()` (the verifying key reduced to `[u32; 8]`). In Plonky2 the standard pattern is to pass a public input that pins `verifier_data.circuit_digest`. The host MUST hard-code this digest in the on-chain protocol params and the scanner. + +--- + +## 10. Recursion Contract + +The circuit verifies recursive proofs of itself. Two requirements: + +1. **Same circuit:** every recursively verified proof's `vk` field MUST equal the verifier's own `vk`. +2. **Public-value binding:** when verifying a recursive proof, the verifier MUST bind the entire `ProofData` it just consumed (`account_state_hash`, `output_coins_root`, `commitment_history_root`, `coin_history_root`) into the rest of the circuit logic. In SP1 this is automatic via `sp1_zkvm::lib::verify::verify_sp1_proof(&vkey, &public_values_digest)`. In Plonky2 this requires connecting each public input of the recursive `ProofTarget` to the corresponding local target. + +For the **initial proof** there is no prior account proof to verify. The circuit takes the `InitialProof` branch, asserts `balance == 0` (except for `MINTING_ADDRESS`), and seeds `coin_history_root` with `DEFAULT_HASHES[0]`. + +--- + +## 11. Off-Circuit Responsibilities + +### 11.1 Node (`node::account_node::send_coins`) + +1. Look up the sender's `Account` (its coin queue, prior account proof, and own coin_history SMT). +2. For each queued `CoinProof`: + - Build a `CommitmentMerkleProofs` for the **coin's source proof** (witness it's on-chain). + - Build a `NonInclusionProof` against the account's own coin_history (proves replay safety) and insert into it. + - Carry over the per-coin `InclusionProof` (the proof that the coin was in its source's `out_coins_root`). +3. Build the `out_coins` from invoices, with deterministic identifiers derived from the **next** account state hash. +4. Build per-out-coin running `NonInclusionProof`s against an empty SMT. +5. If a prior account proof exists, build a `CommitmentMerkleProofs` for it and choose `AccountUpdateProof`; else choose `InitialProof`. +6. Call the prover. On success: persist the proof, clear `coin_queue`, set `balance := balance + queued_balance - invoiced_amount`, store the proof as the new `account.proof`. +7. Return the `CoinProof`s (one per output coin), each containing the new proof + inclusion proof into the new `out_coins_root`. The recipient client later POSTs these to `/api/receive`. + +### 11.2 Client (`shared::ClientAccount::create_commitment`) + +Given a fresh server response `(proof_id, account_state_hash, output_coins_root)`: + +1. Sign `H(account_state_hash || output_coins_root)` with the **current** commitment private key (BIP-32 derivation index = `num_pubkeys - 1` in the reference). +2. POST `(proof_id, commitment)` to `/api/commit`. The server attaches this commitment to the proof, builds a Taproot commit+reveal tx pair whose commit-tx txid begins with `4242`, and broadcasts. + +### 11.3 Scanner (`node::scanner`) + +1. Poll Esplora (or any Bitcoin tx source). +2. Filter txs whose txid hex starts with `4242`. +3. Extract Taproot inscription payload (`extract_inscription_content`). +4. Deserialize as `Commitment`. +5. Verify the Schnorr signature (`Commitment::verify`). +6. Forward to `State::update([commitment])` and persist `latest_block`. + +The block height/order is implicitly authoritative: whoever lands first in the SMT wins. Replay is prevented by the SMT's reject-on-duplicate-key rule. + +--- + +## 12. Migration Notes: Porting to Plonky2 + Poseidon + +This list captures the non-trivial decisions a port must make. None of them are optional. + +1. **Pick `H`.** Recommended: Poseidon over Goldilocks (`F = GF(2^64 - 2^32 + 1)`), width 12, full+partial rounds per the standard parameter set. `HashDigest` becomes 4 field elements (≡ 256-bit security with appropriate rate). + +2. **Re-derive `MINTING_ADDRESS`.** Plonky2 port has it as a domain-separated placeholder (`program-plonky2/src/types.rs::MINTING_ADDRESS`). At server runtime, `runtime.rs::start_rest_node` overrides it by setting `minting_client.address = *MINTING_ADDRESS` on the freshly-constructed `ClientAccount` so the prover circuit and runtime state agree on the value (see `MIGRATION_RESEARCH.md` §7.23). Closed test environment means no requirement to match the historical SP1 minting key. + +3. **`AccountState` hashing.** Drop `bincode + SHA256`. Define a canonical field-element layout (e.g. `[owner_limbs(4), balance_lo, balance_hi, pubkey_x_limbs(4), pubkey_parity]`) and hash with Poseidon. Both circuit and host MUST agree. + +4. **SMT depth.** Set `TREE_DEPTH` to the bit-length of `HashDigest` in the new field. For Poseidon-256 over Goldilocks treated as 4×64-bit limbs, you can either keep depth 256 (key = bits of all 4 limbs) or move to a smaller depth and accept a tiny non-injectivity probability (not recommended). Recommended: keep 256 with explicit big-endian limb ordering. + +5. **Add domain separation.** Replace the current leaf rule `H(value, key)` and internal-node rule `H(left, right)` with tagged variants: `H(LEAF_TAG, value, key)` and `H(NODE_TAG, left, right)`. This is essentially free in algebraic-hash circuits and removes a class of second-preimage edge cases the SHA256 version papers over. + +6. **Schnorr message hashing.** secp256k1 BIP-340 Schnorr signs SHA256(msg). You have two choices: + - **Keep secp256k1 + SHA256 for the signature only.** The signed *message* becomes `SHA256(account_state_hash || output_coins_root)` where `account_state_hash` and `output_coins_root` are 32-byte serializations of Poseidon outputs. This keeps wallet UX and Bitcoin-native signing unchanged. + - **Switch to an in-circuit-friendly signature** (e.g. EdDSA over a Plonky2-friendly curve). Cheaper to verify in-circuit, but breaks Bitcoin-native key reuse. + For an MVP, keep option (1). + +7. **Verifying-key binding.** Replace `vk: [u32; 8]` with the Plonky2 `circuit_digest` (a `HashOut`). Bind this as a public input on every recursive verification step. + +8. **Public-value serialization.** SP1's `bincode::serialize(&ProofData)` doesn't apply. Define `ProofData` as a flat array of field elements committed in order. The hash committed by `verify_proof` is the Poseidon hash of those public inputs. + +9. **MMR `ZERO_HASH`.** Replace with the zero field element (or the additive identity in the chosen group). Adjust `DEFAULT_HASHES` derivation accordingly. + +10. **`u32_be(coin_index)` in identifier.** Replace with one field element (range-checked to `< 2^32`) for in-circuit efficiency. + +11. **Number-of-input-coins bound.** SP1 lets `in_coins.len()` be dynamic at proving time. Plonky2 circuits are fixed-shape — pick a max (e.g. 8 input coins per send, padded with dummy "amount = 0" coins). The circuit MUST treat amount-zero coins as no-ops (skip non-inclusion insertion, skip apply, but still consume one slot of fixed-size arrays). + +12. **No `panic!`, no `expect!`.** In Plonky2 every "fail the proof" path becomes a constraint. Replace `Result<_, &'static str>` host code with explicit asserts inside the circuit. Note in particular: `checked_add`/`checked_sub`/`balance == 0`/`recipient == owner`/`coin.identifier == expected_identifier`. + +13. **Don't trust the `verify_previous_root` shortcut in the host.** `account_node.rs::get_merkle_proofs` has a `let _ = proofs.verify_previous_root(...)` comment claiming it's redundant. That redundancy holds because the in-circuit predicate re-checks it — for `prev_account` via Stage 5c+'s `CommitmentMerkleProofs` gates, and for in-coin sources via Stage 5d-next-5 Phase 2b's per-slot SPEC §8 (c)(d)(e) chain. + +--- + +## 13. Invariants the Tests Should Encode + +A test-suite for the ported circuit MUST cover at minimum: + +- **Initial proof, non-mint, balance != 0** → proof rejected. +- **Initial proof, mint** → proof accepted; coin_history_root is `DEFAULT_HASHES[0]`. +- **Account update, mismatched `account_state.hash()` vs prev `account_state_hash`** → rejected. +- **Account update, prev's `commitment_history_root` not in current MMR** → rejected. +- **Input coin whose source-proof is not in commitment history** → rejected. +- **Input coin whose identifier is not in source's `output_coins_root`** → rejected. +- **Double-spend: same input coin twice in coin_history** → rejected. +- **Output coin with `identifier != H(account_hash || index)`** → rejected. +- **Sum of outputs > balance + sum of inputs** → rejected (underflow). +- **Overflow on sum of input amounts** → rejected. +- **Wrong `vk` on recursive proof** → rejected. + +--- + +## 14. References + +- Shielded CSV paper — Jonas Nick, Liam Eagen, Robin Linus. https://eprint.iacr.org/2025/068 +- Shielded CSV reference implementation (normative) — https://github.com/ShieldedCSV/ShieldedCSV +- `BitVM/zkCoins` Plonky2 prototype (IVC scaffold only) — https://github.com/BitVM/zkCoins +- Plonky2 implementation — this repository, `program-plonky2/src/circuit/main.rs` +- Historical SP1 implementation — preserved at tag `v0.last-sp1` +- Migration research and divergence analysis — [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) + +--- + +## 15. Divergences from Shielded CSV (paper) + +This implementation differs from the published Shielded CSV protocol in 11 concrete ways. Each is either a deliberate MVP simplification, a deferred feature, or a privacy/soundness gap that must be closed before mainnet. The detailed analysis lives in [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) §3. Summary table: + +| # | This SPEC | Paper | Class | Status | +| --- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------- | -------------------- | +| D1 | `identifier = H(asth ‖ u32_be(idx))` (32 B) | `CoinID = tx_hash ‖ idx` (34 B), `CoinIDOnChain = blockchain_loc ‖ idx` (8 B) | Architectural | Accepted for MVP | +| D2 | `Coin.recipient = Address` (plaintext) | `coin.essence.address = Commitment::commit(acct_id, rand)` (hiding) | **Privacy** | **Must fix pre-mainnet** | +| D3 | Single Schnorr commitment in Taproot inscription, txid prefix `4242` | Half-aggregate BIP-340 Schnorr `AggregateNullifier` via third-party publishers | Architectural | Accepted for MVP | +| D4 | Global state = SMT(`H(pk)` → `H(asth ‖ ocr)`) + MMR over `H(smt_root ‖ prev_mmr_root)` | `ToSAcc` tuple-of-sets over `(pk, sig_comm, blockchain_loc, fee_acct_comm)` with prefix proofs | Architectural | Open | +| D5 | SMT depth 256, hash-keyed (uniform) | `AccM` lex-ordered by `CoinIDOnChain` for subtree pruning | Scalability | Re-evaluate at scale | +| D6 | No fee field, no fee output | `fee: u64` + `FEE_IDX = 0xffff` reserved coin index for publisher payout | Missing feature | Deferred | +| D7 | No conditional-noop on reorg | `conditional_nav` degrades tx to no-op if claimed nullifier-accum no longer prefix | **Reorg safety** | **Must fix pre-mainnet** | +| D8 | `Coin` carries no `nullifier_accum` snapshot | `Coin` carries snapshot; receiver verifies it's in their local history | **Soundness** | **Must fix pre-mainnet** | +| D9 | No range/uniqueness checks on `coin_index` | `idx` strictly increasing within tx; `idx == FEE_IDX` reserved | Soundness | Cheap fix | +| D10 | `apply_coin` checks `coin.recipient == self.owner` plaintext | Opens `Commitment::commit(acct_id, rand)` with witnessed `acct_comm_rand` | **Privacy** | **Tied to D2** | +| D11 | `MINTING_ADDRESS` hard-coded | `payment_init_newacct` for fresh accounts; `issuance(IssuanceProof)` branch | Architectural | Deferred | + +**Bottom line:** D2/D10, D7, D8 are blockers for mainnet (privacy + soundness + reorg safety). D6 is a UX/economics blocker (no fee → no publisher incentive). The rest are documented departures from paper fidelity that the MVP accepts. diff --git a/elf/zkcoins-program b/elf/zkcoins-program deleted file mode 100755 index e6991fa4..00000000 Binary files a/elf/zkcoins-program and /dev/null differ diff --git a/node/Cargo.toml b/node/Cargo.toml new file mode 100644 index 00000000..3b581a05 --- /dev/null +++ b/node/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "node" +version.workspace = true +edition.workspace = true + +[dependencies] +bitcoin = { workspace = true } +bitcoin_hashes = { version = "0.16.0", features = ["std"] } +sha2 = { workspace = true } +serde = { workspace = true } +bincode = { workspace = true } +hex = "0.4.3" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "net", "time", "sync"] } +# Event-driven chain ingestion (issue #84): WebSocket subscription to +# the Esplora-compatible block-event stream replaces the previous +# 30-s tip polling loop. `rustls-tls-webpki-roots` keeps the TLS +# stack self-contained on CI hosts (no system openssl), matching the +# `reqwest` features used by `api_remote`. +tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] } +# `StreamExt` / `SinkExt` are used by `scanner_ws` to drive the +# tungstenite stream inside `tokio::time::timeout(...)` on each +# `.next()` call and to send the subscribe frame. +futures-util = "0.3" +# Promoted from `[dev-dependencies]` to `[dependencies]` so the +# scanner can parse the `block` / `blocks` JSON frames returned by +# the Esplora WS endpoint. +serde_json = "1.0" +# Optional ZMQ subscriber path (issue #84, dormant in this PR). When +# `feature = "zmq"` is enabled the operator can plug a Bitcoin Core +# ZMQ stream into the same channel the WebSocket scanner publishes +# on — useful for self-hosters running a node directly. The MVP +# binary keeps this off; no module references the crate yet, so a +# missing system libzmq does not break the default build. Pinned +# with `=` (vs. caret) because the feature is dormant — there is no +# active integration to validate against a SemVer-compatible bump, +# so any version drift should be a deliberate code change with +# review, not a silent `cargo update` side-effect. +bitcoincore-zmq = { version = "=1.5.4", optional = true } +esplora-client = { git = "https://github.com/BitVM/rust-esplora-client", branch = "master" } +axum = { version = "0.7.9", features = ["json", "multipart"] } +anyhow = "1.0" +zkcoins-prover = { path = "../script-plonky2/", package = "zkcoins-prover-plonky2" } +zkcoins-program = { path = "../program-plonky2/", package = "zkcoins-program-plonky2" } +shared = { path = "../shared/" } +lazy_static = { workspace = true } +tower-http = { version = "0.5", features = ["cors", "fs"] } +# Postgres state-layer. PR-A1 wires the module + migrations + tests +# only; bootstrap integration happens in PR-A2 + PR-A3 (see the +# `#[allow(dead_code)]` on the module). +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "macros", + "migrate", +] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +# Used by `publisher_tests` for Esplora mocking and by `router_tests` +# to mock the Esplora HTTP endpoint behind the `/health/ready` +# readiness probe so the tests never hit the real +# `https://mutinynet.com/api` from CI. +wiremock = "0.6" +# Used by `db_tests` to spin up a real Postgres 17 per test run. +# The legacy `clients::Cli` of v0.14/0.15 was replaced by a global +# `runner()` — see `db_tests::setup_pool` for the shape we use. +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["postgres"] } +# HTTP client for the `api_remote` integration test, which exercises +# the deployed DEV server end-to-end. rustls (not native-tls) to keep +# the test runner self-contained on CI hosts without openssl headers. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Random key + suffix generation for the `api_remote` suite so each +# run picks a fresh wallet and avoids collisions with concurrent +# DEV-server consumers. +rand = "0.8" +# Auto-cleaning scratch directories for the ProofStore tests in +# `router_tests`. Replaces the ad-hoc `std::env::temp_dir() + nanos +# + remove_dir_all().ok()` shape — the `TempDir` Drop impl removes +# the directory even when the test panics, so no test leaves a +# leaked /tmp/zkcoins-* tree behind. +tempfile = "3" + +[features] +# All non-MVP features are off by default. When a feature is not enabled, the +# corresponding routes, handlers, and supporting modules are excluded from the +# binary at compile time via `#[cfg(feature = "…")]`, so the disabled code +# cannot run, crash, or be exploited at runtime. +default = [] +address-list = [] +lnurl = [] +# Dormant self-host operator opt-in (issue #84). When enabled, a ZMQ +# subscriber publishes block-hash events into the same channel the +# WebSocket scanner uses. No module activates the subscriber in this +# PR; the flag exists so the crate dependency is feature-gated and +# the MVP binary does not require `libzmq`. +zmq = ["dep:bitcoincore-zmq"] diff --git a/node/migrations/0001_initial.sql b/node/migrations/0001_initial.sql new file mode 100644 index 00000000..51be7007 --- /dev/null +++ b/node/migrations/0001_initial.sql @@ -0,0 +1,50 @@ +-- Initial Postgres schema for the zkCoins server state-layer. +-- +-- This migration is part of PR-A1 in the 3-PR Postgres migration +-- series (file-based bincode -> Postgres). The schema is installed +-- by `db::connect_and_migrate`; nothing here is wired into the +-- server bootstrap yet — that happens in PR-A2 (state + latest block) +-- and PR-A3 (accounts + usernames). +-- +-- Design notes: +-- * `smt_state`, `mmr_state`, `latest_block` are singletons keyed +-- on a fixed `id = 1` row. The CHECK constraint prevents +-- accidental multi-row inserts that would silently break +-- `load_*` callers. +-- * BYTEA is used for binary blobs (bincode-serialized SMT/MMR, +-- 32-byte block hashes, 32-byte account addresses, raw account +-- blobs). Postgres TEXT would force base64/hex round-trips for +-- no benefit. +-- * `updated_at` / `created_at` audit columns default to NOW(). +-- They are not part of any application invariant — purely for +-- ops triage. + +CREATE TABLE smt_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + data BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE mmr_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + data BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE accounts ( + address BYTEA PRIMARY KEY, + data BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE usernames ( + name TEXT PRIMARY KEY, + address BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE latest_block ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + block_hash BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/node/migrations/0002_minting_meta.sql b/node/migrations/0002_minting_meta.sql new file mode 100644 index 00000000..7456166e --- /dev/null +++ b/node/migrations/0002_minting_meta.sql @@ -0,0 +1,27 @@ +-- Faucet minting counter persistence (PR-A3). +-- +-- The legacy `minting_num_pubkeys.bin` sibling file tracked the +-- monotonically increasing BIP-32 child index the faucet uses to +-- generate each mint's commitment public key. The counter MUST survive +-- process restarts; otherwise the next mint sends the wrong +-- `prev_commitment_pubkey` and `send_coins` rejects the transition. +-- +-- A standalone singleton table is the simplest fit: +-- * the row is tiny (one `BIGINT`) and updated at most once per mint +-- (a feature-gated, low-frequency endpoint), +-- * it is logically independent of the per-address `accounts` rows, +-- * `ON CONFLICT (id) DO UPDATE` makes the upsert race-free at the +-- SQL layer (matches the rest of the state-layer's idempotent +-- write pattern). +-- +-- `num_pubkeys` is stored as `BIGINT` (signed) even though the in- +-- memory `ClientAccount.num_pubkeys` is `u32`: Postgres has no +-- unsigned integer type, and `BIGINT` covers the full `u32` range +-- without any cast contortion. The application layer rejects values +-- outside `0..=u32::MAX` when loading. + +CREATE TABLE minting_meta ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + num_pubkeys BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/node/migrations/0003_pending_inscriptions.sql b/node/migrations/0003_pending_inscriptions.sql new file mode 100644 index 00000000..216e8c5c --- /dev/null +++ b/node/migrations/0003_pending_inscriptions.sql @@ -0,0 +1,60 @@ +-- Pending inscriptions state-machine table (Phase B of the publisher +-- crash-recovery hardening, building on PR #105's WS-timeout-race fix +-- and PR #106's CLI recovery tool). +-- +-- The publisher constructs a `(commit_tx, reveal_tx)` pair from the +-- current commitment payload, broadcasts the commit, then broadcasts +-- the reveal. Anything that fails between the two broadcasts — +-- container crash, host OOM, lost in-memory `reveal_tx` bytes, +-- transient Esplora outage — leaves the commit UTXO spent at the +-- script-path anchor with no on-chain reveal to claim it. The funds +-- are unrecoverable without re-deriving the exact same `reveal_tx` +-- (PR #106's CLI exists for this case, manually). +-- +-- This table closes the gap by persisting the full pair BEFORE the +-- first broadcast attempt, and walking each row through the +-- `constructed → commit_broadcast → reveal_broadcast → complete` +-- state machine as each broadcast lands. A startup-time resumer +-- (`publisher::resume_pending_inscriptions`) loads any row whose +-- status is anything but `complete` and re-drives it: the commit (if +-- not yet sent) or the reveal (if the commit landed but the reveal +-- did not). Esplora's `txn-already-known` / `bad-txns-inputs- +-- missingorspent` responses make every step idempotent. +-- +-- Schema notes: +-- * `commit_txid` is `UNIQUE` so a retry of the same (commit, reveal) +-- pair after a transient broadcast failure cannot insert a second +-- row. The publisher computes the txid deterministically from the +-- constructed commit tx, so this is stable across restarts. +-- * `commitment`, `commit_tx`, `reveal_tx` are bincode/consensus- +-- serialized blobs. The resume path deserializes them via the same +-- `bitcoin::consensus::deserialize` shape used by the live +-- broadcast. +-- * `commit_output_value` carries the script-path anchor output's +-- value in sats; needed by `build_reveal_only` if a future +-- rebuilder were to re-derive the reveal from the commitment +-- payload. Today we persist the full `reveal_tx` so the rebuild +-- path is not exercised, but the column is cheap to carry and +-- matches the existing CLI's parameter shape. +-- * The CHECK constraint enumerates every valid state so a typo in +-- the application code surfaces as a Postgres constraint violation +-- instead of a silent state-machine drift. +-- * The partial index on `status <> 'complete'` keeps the resumer's +-- boot-time scan O(pending) instead of O(total). After enough +-- mints this list will be perpetually empty on a healthy server. + +CREATE TABLE pending_inscriptions ( + id BIGSERIAL PRIMARY KEY, + commit_txid BYTEA NOT NULL UNIQUE, + status TEXT NOT NULL, + commitment BYTEA NOT NULL, + commit_tx BYTEA NOT NULL, + reveal_tx BYTEA NOT NULL, + commit_output_value BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (status IN ('constructed','commit_broadcast','reveal_broadcast','complete','failed')) +); + +CREATE INDEX pending_inscriptions_status_idx + ON pending_inscriptions (status) WHERE status <> 'complete'; diff --git a/node/migrations/0004_mmr_root_index.sql b/node/migrations/0004_mmr_root_index.sql new file mode 100644 index 00000000..c75f7fd4 --- /dev/null +++ b/node/migrations/0004_mmr_root_index.sql @@ -0,0 +1,38 @@ +-- MMR root index persistence (Phase C of the post-PR-A* state-layer +-- hardening, follow-on to PR #107's pending_inscriptions table). +-- +-- `State::root_indices` is the in-memory `HashMap` consulted by `State::get_mmr_inclusion_proof` +-- whenever an account's prior proof references a historical +-- `commitment_history_root`. Before this migration the map was rebuilt +-- empty on every bootstrap (`State::new` / `load_from_pg`), which meant +-- any account whose latest proof pointed at a `commitment_history_root` +-- produced before the container restart could never produce a new send +-- or mint: the lookup returned `Err` and the handler surfaced 422 +-- `Unable to get mmr inclusion proof for the previous root`. +-- +-- The table mirrors the in-memory shape one row per `(prev_mmr_root)` +-- key. `INSERT … ON CONFLICT DO NOTHING` handles legitimate replays +-- (the same `prev_mmr_root` cannot legitimately map to two distinct +-- `(smt_root, leaf_index)` tuples — the MMR append is monotonic, so +-- the first writer's value is also the correct value). +-- +-- Schema notes: +-- * `prev_mmr_root` is the `HashDigest` byte-encoding produced by +-- `zkcoins_program::hash::digest_to_bytes` — 32 raw bytes, +-- reinterpreting a Poseidon `HashOut`. The column is BYTEA +-- PRIMARY KEY; Postgres TEXT would force hex round-trips for no +-- benefit (same rationale as the address columns in 0001). +-- * `leaf_index` is the MMR leaf position assigned at append time. +-- In-memory it is a `usize` (matches `mmr.leaf_count()`); we +-- persist it as BIGINT and check at read time that the value fits +-- `u64`/`usize` (defensive cast — see `db::load_root_indices`). +-- * `created_at` is informational only; no application invariant +-- depends on it. Useful for ops triage after a recovery event. + +CREATE TABLE mmr_root_index ( + prev_mmr_root BYTEA PRIMARY KEY, + smt_root BYTEA NOT NULL, + leaf_index BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/node/migrations/0005_drop_minting_meta_num_pubkeys.sql b/node/migrations/0005_drop_minting_meta_num_pubkeys.sql new file mode 100644 index 00000000..5d3bd48c --- /dev/null +++ b/node/migrations/0005_drop_minting_meta_num_pubkeys.sql @@ -0,0 +1,28 @@ +-- Drop `minting_meta` entirely (Phase D). +-- +-- Pre-Phase-D the singleton `minting_meta` row carried a single +-- `num_pubkeys BIGINT` counter — how many BIP-32 child indices the +-- faucet had ever spent in a successful mint. The counter survived +-- process restarts so the next `current_private_key()` derivation +-- aligned with the last on-chain commitment. +-- +-- Phase D removes that counter as a separately-stored value. The +-- count is now derived from the Sparse Merkle Tree on demand: +-- `derive_num_pubkeys_from_smt(minting_xpriv, smt)` walks `pk_0, +-- pk_1, …` and stops at the first `sha256(pk_n.serialize())` whose +-- leaf is absent from the SMT. The SMT is already the canonical +-- truth (loaded from `smt_state` at boot, mutated by the scanner +-- on every inscription) and is the source the previous startup +-- invariant check measured the counter *against* — collapsing the +-- two into one removes the desync class that issue zk-coins/node#89 +-- documented. +-- +-- `minting_meta` had no other columns (id + num_pubkeys + +-- updated_at), so dropping the whole table is the cleanest shape. +-- The matching `load_minting_num_pubkeys` / `upsert_minting_num_pubkeys` +-- helpers and the `commit_mint_tx` counter step are removed in +-- the same commit. After this migration runs, no code reads or +-- writes the table; the migration is destructive but the value was +-- the bug we are fixing — the SMT carries the truth. + +DROP TABLE IF EXISTS minting_meta; diff --git a/server/minting_secret.bin b/node/minting_secret.bin similarity index 100% rename from server/minting_secret.bin rename to node/minting_secret.bin diff --git a/node/src/account_node.rs b/node/src/account_node.rs new file mode 100644 index 00000000..888f5e2d --- /dev/null +++ b/node/src/account_node.rs @@ -0,0 +1,1133 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::db; +use crate::state::State; +use bitcoin::secp256k1::PublicKey; +use serde::{Deserialize, Serialize}; +use shared::commitment::Commitment; +use shared::{Address, Invoice}; +use sqlx::PgPool; +use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes, HashDigest, ZERO_HASH}; +use zkcoins_program::inputs::CommitmentMerkleProofs; +use zkcoins_program::merkle::merkle_mountain_range::MMR_MAX_DEPTH; +use zkcoins_program::merkle::sparse_merkle_tree::{ + InclusionProof, NonInclusionProof, SparseMerkleTree, DEFAULT_HASHES, TREE_DEPTH, +}; +use zkcoins_program::types::{ + calculate_coin_identifier, AccountState, Amount, Coin, CoinTemplate, ProofData, +}; +use zkcoins_prover::{InCoinSourceWitness, Proof, Prover}; + +/// Fixed in-circuit MMR proof depth. Must match +/// [`zkcoins_program::circuit::main::MMR_PROOF_PATH_LEN`]. +const MMR_PROOF_PATH_LEN: usize = MMR_MAX_DEPTH - 1; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CoinProof { + pub proof: Proof, + pub coin: Coin, + pub inclusion_proof: InclusionProof, + pub commitment: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Account { + pub proof: Option, + pub coin_queue: Vec, + pub coin_history: SparseMerkleTree, + pub balance: u64, +} + +impl Account { + /// Deep-clone an `Account` via bincode round-trip. + /// + /// `SparseMerkleTree` is not `Clone` (the upstream type in + /// `program-plonky2` deliberately keeps the API minimal), so we go + /// through the serialisation boundary the rest of this module + /// already exercises for persistence. The serialiser is the same + /// one [`AccountNode::serialize_account`] uses, so any future + /// change to the on-disk shape continues to be a single point of + /// truth. + /// + /// Returns the deserialised twin or a `bincode::Error` from the + /// round-trip. Both fallible arms are propagated up to the caller + /// (`AccountNode::prepare_mint`) which surfaces them as the + /// caller-facing "Failed to snapshot minting account" error. + pub(crate) fn try_deep_clone(&self) -> Result { + let bytes = bincode::serialize(self)?; + bincode::deserialize(&bytes) + } +} + +/// Result of [`AccountNode::prepare_mint`]: the tentative mutated +/// minting account (clone — not yet swapped into `self.accounts`) +/// together with the freshly-generated coin proofs the mint flow needs +/// to inscribe and deliver. The caller commits the mutation atomically +/// via [`AccountNode::commit_mint`] once the on-chain broadcast and +/// the optimistic `minting_meta.num_pubkeys` UPDATE have both +/// succeeded. +#[derive(Debug)] +pub struct MintingPrepared { + pub mutated_minting: Account, + pub coin_proofs: Vec, +} + +impl Account { + pub fn new() -> Self { + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 0, + } + } + /// Uses the coin_template and next_public_key to create the next account_state and generates a + /// Coin with filled in identifier (as it commits to the next account state hash). + /// + /// Total: caller (`send_coins`) is responsible for upstream balance + slot-count validation; + /// once that is done this function cannot fail. Returns `Vec` directly so the call site + /// has no dead `?` propagation path. + pub fn create_coins( + &self, + address: HashDigest, + next_public_key: PublicKey, + public_key: zkcoins_program::types::PublicKey, + coin_templates: Vec, + ) -> Vec { + let mut next_account_state = AccountState { + owner: address, + balance: self.get_balance(), + public_key, + }; + for coin_template in &coin_templates { + // Caller (send_coins) already validated balance >= total + // invoiced amount before reaching this function. The expect + // here is documentation of that invariant. + next_account_state.balance = next_account_state + .balance + .checked_sub(coin_template.amount) + .expect("balance was validated by send_coins"); + } + + let next_account_state_hash = next_account_state.hash(); + let coins = coin_templates.into_iter().enumerate().map(|(i, template)| { + Coin::new( + template, + calculate_coin_identifier(next_account_state_hash, i as u32), + ) + }); + // Set the next public key. + let _ = next_public_key.serialize(); + // next_account_state.public_key is intentionally not updated + // here because the caller (send_coins) sources `next_public_key` + // separately for the Prover witness — once Stage 5d-next-5 + // Prover-API integration lands, this update + return will be + // wired through. + let _ = next_account_state; + coins.collect() + } + + pub fn get_balance(&self) -> Amount { + self.coin_queue + .iter() + .fold(self.balance, |acc, x| acc + x.coin.amount) + } +} + +pub struct AccountNode { + accounts: HashMap, + prover: Prover, + state: Arc>, +} + +impl AccountNode { + /// Get the keypair to the pubkey this account commited to (which is derived key num_pubkeys - + /// 1) + // TODO: Move to client. + /// + /// Test-only after PR-A3 — the production bootstrap rehydrates the + /// server from Postgres via `load_from_pg`, never `new`. Kept + /// because every test in `account_node_tests.rs`, + /// `router_tests.rs`, and `runtime_tests.rs` uses it to + /// build a known-empty server before importing fixture accounts. + #[cfg_attr(not(test), allow(dead_code))] + pub fn new(state: Arc>) -> Self { + let accounts = HashMap::new(); + let prover = Prover::new(); + + AccountNode { + accounts, + prover, + state, + } + } + + pub fn import_account(&mut self, address: HashDigest, account: Account) { + self.accounts.insert(address, account); + } + + // TODO: User needs to provide a signature and the salt and the secret information for the + // address to authenticate. + pub fn get_account_balance(&self, account_address: &Address) -> Result { + match self.accounts.get(account_address) { + Some(account) => Ok(account + .coin_queue + .iter() + .fold(account.balance, |acc, x| acc + x.coin.amount)), + _ => Err("No account with this address"), + } + } + + pub fn get_addresses(&self) -> Vec
{ + self.accounts.keys().cloned().collect::>() + } + + pub fn receive_coin(&mut self, coin_proof: CoinProof) -> Result<(), &'static str> { + let recipient = coin_proof.coin.recipient; + let mut account = self + .accounts + .remove(&recipient) + .unwrap_or_else(Account::new); + Self::receive_coin_into(&mut account, coin_proof)?; + self.accounts.insert(recipient, account); + Ok(()) + } + + /// Pure-by-account variant of [`Self::receive_coin`]. Validates + /// the supplied proof + inclusion proof against the recipient + /// account and, on success, pushes the coin into the recipient's + /// `coin_queue`. The caller owns the `&mut Account` lifecycle — + /// used by the mint flow's prepare-then-commit path to apply + /// receives on cloned recipients before the on-chain broadcast + /// commit window. + pub fn receive_coin_into( + account: &mut Account, + coin_proof: CoinProof, + ) -> Result<(), &'static str> { + // PLONKY2 MIGRATION (Step 7): The SP1-era `proof.public_values` + // (a writable byte stream) is replaced by Plonky2's + // `proof.public_inputs: Vec` (field elements). The + // `ProofData::from_field_elements` helper is the canonical + // bridge. + let pis: [zkcoins_program::F; zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = + coin_proof.proof.public_inputs + [..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .map_err(|_| "Proof public_inputs too short")?; + let proof_data = ProofData::from_field_elements(&pis); + + // Verify the inclusion of the coin in the proof. + if !coin_proof + .inclusion_proof + .verify(coin_proof.coin.identifier, proof_data.output_coins_root) + { + return Err("Coin inclusion proof verification failed"); + } + + // Log coin receipt without exposing full address (privacy). + let addr_bytes = zkcoins_program::hash::digest_to_bytes(&coin_proof.coin.recipient); + eprintln!( + "Receiving coin for address: {:02x}{:02x}…", + addr_bytes[0], addr_bytes[1] + ); + + // Reject duplicate coins (replay protection) + let coin_id = coin_proof.coin.identifier; + if account + .coin_queue + .iter() + .any(|cp| cp.coin.identifier == coin_id) + { + return Err("Coin already in queue (duplicate)"); + } + if account + .coin_history + .generate_inclusion_proof(&zkcoins_program::hash::digest_to_bytes(&coin_id)) + .is_ok() + { + return Err("Coin already spent (replay)"); + } + + account.coin_queue.push(coin_proof); + Ok(()) + } + + /// Get all required merkle proofs from the state for the public key and the previous proof. + /// Static method: does not access self.accounts, only the state guard. + /// + /// The returned bundle is shaped for in-circuit consumption: MMR + /// proofs are pre-extended to [`MMR_PROOF_PATH_LEN`] siblings and + /// the SMT inclusion proof carries the full [`TREE_DEPTH`] + /// siblings (the off-circuit SMT produces this length by + /// construction). + fn get_merkle_proofs( + previous_proof: Proof, + public_key: PublicKey, + state: &MutexGuard<'_, State>, + ) -> Result { + let account_merkle_proofs = state + .get_commitment_proof(&public_key) + .or(Err("Unable to get merkle proofs for provided public key"))?; + + // PLONKY2 MIGRATION (Step 7): see `receive_coin` for the + // bridge from SP1's `public_values` to Plonky2's `public_inputs`. + let pis: [zkcoins_program::F; zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = + previous_proof.public_inputs + [..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .map_err(|_| "Proof public_inputs too short")?; + let proof_data = ProofData::from_field_elements(&pis); + let _ = previous_proof; // silence unused-mut warning + let previous_root = proof_data.commitment_history_root; + let previous_root_proof = state.get_mmr_inclusion_proof(previous_root).or(Err( + "Unable to get mmr inclusion proof for the previous root", + ))?; + + let proofs = CommitmentMerkleProofs { + commitment_root: account_merkle_proofs.2, + commitment_proof: account_merkle_proofs.1, + // Pad MMR proofs to the fixed depth the in-circuit gadget + // expects (`MMR_PROOF_PATH_LEN`). Off-circuit MMR proofs + // have variable depth equal to log2(capacity). + commitment_root_history_proof: account_merkle_proofs.3.extend_to(MMR_PROOF_PATH_LEN), + commitment_root_mmr_sibling: state.prev_mmr_root, + previous_root_history_proof: ( + previous_root_proof.0, + previous_root_proof.1.extend_to(MMR_PROOF_PATH_LEN), + ), + commitment_account_state_hash: proof_data.account_state_hash, + commitment_out_coins_root: proof_data.output_coins_root, + }; + + Ok(proofs) + } + + /// Build a syntactically-valid but semantically-empty + /// `NonInclusionProof` for inactive in-coin / out-coin slots. + /// The slot's `active = false` bit masks the in-circuit check. + fn dummy_nip() -> NonInclusionProof { + NonInclusionProof { + key: [0u8; 32], + root: ZERO_HASH, + siblings: vec![ZERO_HASH; TREE_DEPTH], + } + } + + fn dummy_coin() -> Coin { + Coin { + identifier: ZERO_HASH, + recipient: ZERO_HASH, + amount: 0, + } + } + + pub fn send_coins( + &mut self, + invoices: Vec, + account_address: Address, + public_key: PublicKey, + next_public_key: PublicKey, + prev_commitment_pubkey: Option, + ) -> Result, &'static str> { + // Thin wrapper: borrow the account out of the map, run the + // shared `send_coins_inner` body against it, and write it back + // on success. The Err arm leaves the map untouched. + let mut account = self + .accounts + .remove(&account_address) + .ok_or("Unknown account address")?; + match Self::send_coins_inner( + &self.prover, + &self.state, + &mut account, + invoices, + account_address, + public_key, + next_public_key, + prev_commitment_pubkey, + ) { + Ok(coin_proofs) => { + self.accounts.insert(account_address, account); + Ok(coin_proofs) + } + Err(e) => { + // Restore the account untouched. `send_coins_inner` does + // not commit mutations until the prove step succeeds, so + // the value we put back equals what we removed. + self.accounts.insert(account_address, account); + Err(e) + } + } + } + + /// Pure-by-account variant of [`Self::send_coins`]. Runs the full + /// state-transition (witness assembly, prove, post-prove account + /// mutation) against an externally-owned `&mut Account` and returns + /// the produced coin proofs. The caller is responsible for deciding + /// whether to commit the mutated account back into the node + /// (e.g. after on-chain broadcast succeeded — see + /// [`Self::prepare_mint`] + [`Self::commit_mint`]). + /// + /// Identical body to the pre-refactor `send_coins`; the only change + /// is that the `account_address` lookup is the caller's + /// responsibility (the account is passed in). The "Unknown account + /// address" check therefore lives at the wrapper site. + #[allow(clippy::too_many_arguments)] + fn send_coins_inner( + prover: &Prover, + state: &Mutex, + account: &mut Account, + invoices: Vec, + account_address: Address, + public_key: PublicKey, + next_public_key: PublicKey, + prev_commitment_pubkey: Option, + ) -> Result, &'static str> { + let state = &state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + // Slot-count guards. Done up-front before the expensive + // get_merkle_proofs / coin-history-SMT loop so a caller + // violating the per-transition slot budget fails fast (and + // doesn't pay state-mutation cost first). `out_coins.len() == + // invoices.len()` by construction in `create_coins`, so the + // out-coin guard collapses to `invoices.len() > MAX_OUT_COINS`. + const MAX_IN_COINS: usize = zkcoins_program::circuit::main::MAX_IN_COINS; + const MAX_OUT_COINS: usize = zkcoins_program::circuit::main::MAX_OUT_COINS; + if account.coin_queue.len() > MAX_IN_COINS { + return Err("Too many in-coins for one transition"); + } + if invoices.len() > MAX_OUT_COINS { + return Err("Too many out-coins for one transition"); + } + + // Check if the account balance is enough + let balance = account + .coin_queue + .iter() + .fold(account.balance, |acc, x| acc + x.coin.amount); + let invoiced_amount = invoices.iter().fold(0, |acc, x| acc + x.amount); + if balance < invoiced_amount { + return Err("Insufficient funds"); + } + + // TODO: Copy this over to the client because they too have to check that the + // out_coins_tree is correct and only contains the coins from the invoices. + // Create the coin templates. + let mut coin_templates = vec![]; + for invoice in invoices { + coin_templates.push(CoinTemplate::new(invoice.recipient, invoice.amount)); + } + + let mut coin_history_proofs = vec![]; + let mut coin_non_inclusion_proofs = vec![]; + let mut coin_inclusion_proofs = vec![]; + let mut in_coins = vec![]; + for coin_proof in &account.coin_queue { + coin_history_proofs.push({ + match &coin_proof.commitment { + Some(commitment) => Self::get_merkle_proofs( + coin_proof.proof.clone(), + commitment.public_key, + state, + )?, + None => return Err("Coin is missing commitment"), + } + }); + let coin_id_bytes = zkcoins_program::hash::digest_to_bytes(&coin_proof.coin.identifier); + coin_non_inclusion_proofs.push({ + account + .coin_history + .generate_non_inclusion_proof(coin_id_bytes) + .or(Err("Should provide an inclusion proof"))? + }); + coin_inclusion_proofs.push(coin_proof.inclusion_proof.clone()); + in_coins.push(coin_proof.coin.clone()); + account + .coin_history + .insert(coin_id_bytes, coin_proof.coin.identifier) + .or(Err("Coin should not exist in coin history tree"))?; + } + // PLONKY2 MIGRATION (Step 7): SP1's `ProgramInputsBuilder` has + // no Plonky2 analogue — the cyclic-recursion circuit's API + // takes per-slot witnesses (`InCoinSlotWitness`) directly. The + // construction below builds the same witness data, threaded + // through to the `Prover::prove_*` calls instead of a single + // builder struct. + let account_state_for_prove = AccountState { + owner: account_address, + balance: account.balance, + public_key: public_key.serialize(), + }; + + let out_coins = account.create_coins( + account_address, + next_public_key, + public_key.serialize(), + coin_templates, + ); + // SparseMerkleTree::new() always returns DEFAULT_HASHES[0] as + // its root, and a non-inclusion-proof-driven update produces the + // same root as a direct insert — both invariants are part of the + // SMT impl's own test suite. We do not double-check here. + let mut out_coins_tree = SparseMerkleTree::new(); + let _initial_root = DEFAULT_HASHES[0]; + + let mut out_coin_proofs = vec![]; + for coin in &out_coins { + let coin_id_bytes = zkcoins_program::hash::digest_to_bytes(&coin.identifier); + let non_inclusion_proof = out_coins_tree + .generate_non_inclusion_proof(coin_id_bytes) + .or(Err("Coin should not exist in tree yet"))?; + out_coin_proofs.push(non_inclusion_proof.clone()); + out_coins_tree.insert(coin_id_bytes, coin.identifier)?; + let _expected = non_inclusion_proof.insert(coin.identifier); + } + + // Defense-in-depth: validate the source-side properties + // off-circuit before paying the prove cost. The in-circuit + // gate-set (Stage 5d-next-5 Phase 2b — merged in PR #23) is + // the authoritative enforcement; this off-circuit pass exists + // to (a) reject malformed requests with a specific HTTP error + // string within microseconds instead of an opaque + // `prove failed` after minute-scale prove cost, and (b) catch + // any future drift between off-circuit witness construction + // and the in-circuit predicate. Memory + // `feedback_threat_model_over_checklist`: the cost is + // microseconds vs minute-scale prove, so the defense-in-depth + // wins. See `MIGRATION_RESEARCH.md` §7.22 for the in-circuit + // architecture (aggregator pattern + Phase 2b per-slot SMT + // inclusion + SPEC §8 (c)(d)(e) chain). + for ((coin, source_cmp), source_inclusion) in in_coins + .iter() + .zip(coin_history_proofs.iter()) + .zip(coin_inclusion_proofs.iter()) + { + if !source_inclusion.verify(coin.identifier, source_cmp.commitment_out_coins_root) { + return Err("In-coin not present in source's output_coins_root"); + } + if !source_cmp.verify_commitment(state.mmr.root_extended(MMR_PROOF_PATH_LEN)) { + return Err("Source commitment not present in history MMR"); + } + } + + // Build the fixed-shape MAX_IN_COINS slot tuples. Active + // slots come from account.coin_queue; inactive slots use the + // ZERO_HASH dummies. Slot-count guards live at the top of + // `send_coins`; by the time we reach this point both + // `in_coins.len() <= MAX_IN_COINS` and `out_coins.len() <= + // MAX_OUT_COINS` are invariants of the function. + let dummy_nip = Self::dummy_nip(); + let dummy_coin = Self::dummy_coin(); + let mut in_coin_slots: Vec<(bool, &Coin, &NonInclusionProof)> = + Vec::with_capacity(MAX_IN_COINS); + for (coin, nip) in in_coins.iter().zip(coin_non_inclusion_proofs.iter()) { + in_coin_slots.push((true, coin, nip)); + } + for _ in in_coins.len()..MAX_IN_COINS { + in_coin_slots.push((false, &dummy_coin, &dummy_nip)); + } + + // Stage 5d-next-5 Phase 2b: per-slot source witnesses. Each + // active in-coin's source proof, SMT-inclusion path, and + // CommitmentMerkleProofs bundle (already built into + // `coin_history_proofs` / `coin_inclusion_proofs`) are + // threaded into the prover. Inactive slots get `None`. + let mut sources: Vec> = Vec::with_capacity(MAX_IN_COINS); + for ((coin_proof, source_cmp), source_inclusion) in account + .coin_queue + .iter() + .zip(coin_history_proofs.iter()) + .zip(coin_inclusion_proofs.iter()) + { + sources.push(Some(InCoinSourceWitness { + source_proof: &coin_proof.proof, + source_inclusion, + source_cmp, + })); + } + for _ in account.coin_queue.len()..MAX_IN_COINS { + sources.push(None); + } + + let mut out_coin_slots: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = + Vec::with_capacity(MAX_OUT_COINS); + for (coin, nip) in out_coins.iter().zip(out_coin_proofs.iter()) { + out_coin_slots.push((true, coin.identifier, coin.amount, nip)); + } + for _ in out_coins.len()..MAX_OUT_COINS { + out_coin_slots.push((false, ZERO_HASH, 0u64, &dummy_nip)); + } + + // The Plonky2 cyclic recursion verifies against `history_root` + // extended to the fixed in-circuit MMR depth. + let history_root_extended = state.mmr.root_extended(MMR_PROOF_PATH_LEN); + let next_public_key_bytes = next_public_key.serialize(); + + let proof: Proof = match &account.proof { + Some(account_proof) => { + let account_commitment_public_key = prev_commitment_pubkey + .ok_or("prev_commitment_pubkey required for account update")?; + let prev_cmp = Self::get_merkle_proofs( + account_proof.clone(), + account_commitment_public_key, + state, + )?; + prover + .prove_account_update_with_in_and_out_coins_and_sources( + &account_state_for_prove, + history_root_extended, + account_proof, + &prev_cmp, + &in_coin_slots, + &out_coin_slots, + &next_public_key_bytes, + &sources, + ) + .map_err(|_| "prove_account_update_with_in_and_out_coins_and_sources failed")? + } + None => prover + .prove_initial_with_in_and_out_coins_and_sources( + &account_state_for_prove, + history_root_extended, + &in_coin_slots, + &out_coin_slots, + &next_public_key_bytes, + &sources, + ) + .map_err(|_| "prove_initial_with_in_and_out_coins_and_sources failed")?, + }; + + // Proof generation succeeded — commit the state changes. + account.coin_queue.clear(); + account.balance = balance - invoiced_amount; + account.proof = Some(proof.clone()); + + // Build CoinProof entries for distribution to recipients. + // + // Multi-out-coin correctness: `generate_inclusion_proof` runs + // against the FINAL `out_coins_tree` (after every slot has + // been inserted), so each recipient's `InclusionProof` + // siblings are valid against the SAME `output_coins_root` + // that the source proof committed to — regardless of which + // slot the recipient's coin landed in. This is the production + // invariant that the in-circuit Phase 2b SMT-inclusion check + // relies on. (The test fixture + // `build_test_source_witness` in + // `program-plonky2/src/circuit/main.rs` is single-out-coin / + // slot-0 only by construction — see its docstring.) + let mut coin_proofs = vec![]; + for coin in out_coins { + let coin_id_bytes = zkcoins_program::hash::digest_to_bytes(&coin.identifier); + coin_proofs.push(CoinProof { + proof: proof.clone(), + inclusion_proof: out_coins_tree.generate_inclusion_proof(&coin_id_bytes)?.0, + coin, + // User fills in the commitment and sends back via /commit. + commitment: None, + }); + } + Ok(coin_proofs) + } + + pub fn get_minting_account_address(&mut self) -> Result { + match self.accounts.get(&*zkcoins_program::types::MINTING_ADDRESS) { + Some(_) => Ok(*zkcoins_program::types::MINTING_ADDRESS), + None => Err("Minting account not created"), + } + } + + /// Prepare a mint transition WITHOUT mutating `self.accounts`. + /// + /// Used by the mint flow's prepare-then-commit refactor (see + /// [`crate::router::mint_handler`] + zk-coins/node#89): the + /// caller produces the prover output and the recipient coin proofs + /// here, then attempts the on-chain inscription broadcast, then — + /// only on broadcast success — commits the mutated minting account + /// via [`Self::commit_mint`] inside the same Postgres transaction + /// that bumps `minting_meta.num_pubkeys`. + /// + /// The clone of the minting `Account` is the unit of "tentative + /// state": any partial mutation `send_coins_inner` would perform on + /// the real account (coin_queue clear, proof set, coin_history SMT + /// insert) lives on the clone instead. If the broadcast fails the + /// clone is dropped and `self.accounts` is byte-identical to what + /// it was before the call. + /// + /// Returns `Err("Minting account not created")` if the minting + /// account has not been bootstrapped yet — the wrapper site already + /// guards this via `get_minting_account_address`, but the check is + /// kept inline so this method is sound to call standalone. + pub fn prepare_mint( + &self, + invoices: Vec, + public_key: PublicKey, + next_public_key: PublicKey, + prev_commitment_pubkey: Option, + ) -> Result { + let minting_address = *zkcoins_program::types::MINTING_ADDRESS; + let live = self + .accounts + .get(&minting_address) + .ok_or("Minting account not created")?; + let mut snapshot = live + .try_deep_clone() + .map_err(|_| "Failed to snapshot minting account")?; + let coin_proofs = Self::send_coins_inner( + &self.prover, + &self.state, + &mut snapshot, + invoices, + minting_address, + public_key, + next_public_key, + prev_commitment_pubkey, + )?; + Ok(MintingPrepared { + mutated_minting: snapshot, + coin_proofs, + }) + } + + /// Atomically swap a prepared minting-account snapshot into the + /// in-memory map. Pair of [`Self::prepare_mint`]; the caller MUST + /// have observed a successful on-chain broadcast + a successful + /// optimistic `UPDATE minting_meta` before invoking this — see + /// `mint_handler` for the canonical call site. + pub fn commit_mint(&mut self, mutated_minting: Account) { + self.accounts + .insert(*zkcoins_program::types::MINTING_ADDRESS, mutated_minting); + } + + /// Read-only handle on the shared [`State`] (SMT + MMR). Exposed so + /// the startup invariant check in `runtime` can verify + /// every persisted minting-account pubkey has a corresponding SMT + /// commitment without round-tripping through a dedicated + /// `AppState` field. + pub fn state(&self) -> &Arc> { + &self.state + } + + /// Borrow a single account by address. Returned for read-only + /// inspection (e.g. snapshotting a freshly mutated `Account` for + /// persistence outside the lock). + pub fn get_account(&self, address: &Address) -> Option<&Account> { + self.accounts.get(address) + } + + /// Serialize a single `Account` to bincode for `db::upsert_account`. + /// + /// Pulled out as an associated function (no `&self` borrow) so + /// handlers can take an account snapshot, drop the + /// `Arc>` lock, and persist the bytes outside + /// the lock — required because the upsert is `async` and a + /// `std::sync::MutexGuard` may not be held across an `.await`. + /// + /// `bincode::serialize` on a well-formed `Account` cannot fail in + /// practice (no fallible `Serialize` impls in the field graph), so + /// the return type is the raw byte vector rather than a `Result`. + /// Returning `Result` previously introduced an uncovered `?` + /// branch at every call site without buying any real recovery + /// path; if a future field gains a fallible serializer, switch + /// this back to `Result` and propagate through the existing + /// `PersistAccountError::Serialize` variant. + pub fn serialize_account(account: &Account) -> Vec { + bincode::serialize(account) + .expect("bincode::serialize cannot fail for the current Account shape") + } + + /// Reload an `AccountNode` from Postgres. + /// + /// The bootstrap-seeded minting account is NOT created here — + /// `start_rest_node` does that explicitly once it has observed an + /// absent minting row. Returning the rebuilt map here keeps this + /// constructor a pure "rehydrate everything that was persisted" + /// call with no side effects. + pub async fn load_from_pg( + state: Arc>, + pool: &PgPool, + ) -> Result { + let rows = db::load_all_accounts(pool).await?; + let mut accounts: HashMap = HashMap::with_capacity(rows.len()); + for (addr_bytes, data_bytes) in rows { + let addr_arr: [u8; 32] = addr_bytes + .as_slice() + .try_into() + .map_err(|_| LoadAccountNodeError::BadAddressLength(addr_bytes.len()))?; + let address = digest_from_bytes(&addr_arr); + let account: Account = bincode::deserialize(&data_bytes)?; + accounts.insert(address, account); + } + let prover = Prover::new(); + Ok(AccountNode { + accounts, + prover, + state, + }) + } +} + +/// Error type for `AccountNode::load_from_pg`. Mirrors the +/// `state::LoadStateError` split so the bootstrap caller can react +/// differently to "database is unreachable" (retry, fail loud) vs. +/// "the persisted blob is corrupt" (no useful retry — escalate). +#[derive(Debug)] +pub enum LoadAccountNodeError { + /// The Postgres call itself failed (connect, query, decode). + Db(sqlx::Error), + /// A row's `address` column was not the expected 32 bytes. + BadAddressLength(usize), + /// A row's `data` column failed bincode-deserialize as `Account`. + Deserialize(bincode::Error), +} + +impl std::fmt::Display for LoadAccountNodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadAccountNodeError::Db(e) => write!(f, "database error: {}", e), + LoadAccountNodeError::BadAddressLength(n) => write!( + f, + "accounts.address has unexpected length {} (expected 32)", + n + ), + LoadAccountNodeError::Deserialize(e) => { + write!(f, "account blob deserialize: {}", e) + } + } + } +} + +impl std::error::Error for LoadAccountNodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + LoadAccountNodeError::Db(e) => Some(e), + LoadAccountNodeError::BadAddressLength(_) => None, + LoadAccountNodeError::Deserialize(e) => Some(e), + } + } +} + +impl From for LoadAccountNodeError { + fn from(e: sqlx::Error) -> Self { + LoadAccountNodeError::Db(e) + } +} + +impl From for LoadAccountNodeError { + fn from(e: bincode::Error) -> Self { + LoadAccountNodeError::Deserialize(e) + } +} + +/// Helper used by both the bootstrap and the handlers: serialize the +/// account at `address` and persist it via `db::upsert_account`. +/// +/// Holds an `&AccountNode` to snapshot the bincode bytes +/// *synchronously*, then runs the `async` upsert with no live mutex +/// guard. Callers MUST acquire the snapshot before the `.await` (i.e. +/// inside a `{ ... }` scope that releases the +/// `MutexGuard<'_, AccountNode>`) — see the handler sites in +/// `router.rs` for the pattern. +/// +/// Returns the bincode-encoded bytes on success so the caller can log +/// the byte length without re-serializing. +pub async fn persist_account( + pool: &PgPool, + address: &Address, + account: &Account, +) -> Result { + let bytes = AccountNode::serialize_account(account); + let addr_bytes = digest_to_bytes(address); + db::upsert_account(pool, &addr_bytes, &bytes).await?; + Ok(bytes.len()) +} + +/// Error type for `persist_account`. Wraps the single failure mode +/// (database write — connect, transaction, decode). Bincode encoding +/// of the in-memory `Account` is infallible for the current shape and +/// is therefore unwrapped inside `serialize_account` rather than +/// propagated here. +#[derive(Debug)] +pub enum PersistAccountError { + /// The Postgres upsert failed (connect, transaction, decode). + Db(sqlx::Error), +} + +impl std::fmt::Display for PersistAccountError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PersistAccountError::Db(e) => write!(f, "database error: {}", e), + } + } +} + +impl std::error::Error for PersistAccountError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + PersistAccountError::Db(e) => Some(e), + } + } +} + +impl From for PersistAccountError { + fn from(e: sqlx::Error) -> Self { + PersistAccountError::Db(e) + } +} + +#[cfg(test)] +mod inline_tests { + //! Inline error-path tests that don't require a full Plonky2 prove. + //! They cover the early-return error paths in `send_coins` and the + //! single-line lookup paths in `get_minting_account_address`, + //! `get_account`, and `get_account_balance`. The Postgres-based + //! `load_from_pg` and `persist_account` paths are tested against a + //! real Postgres 17 container in `account_node_tests.rs`. The + //! richer prover-driven fixtures also live there. + + use super::*; + + fn fresh_node() -> AccountNode { + AccountNode::new(Arc::new(Mutex::new(State::new()))) + } + + #[test] + fn get_minting_account_address_errors_when_not_imported() { + let mut node = fresh_node(); + assert_eq!( + node.get_minting_account_address().unwrap_err(), + "Minting account not created" + ); + } + + #[test] + fn get_minting_account_address_returns_minting_address_when_present() { + let mut node = fresh_node(); + node.import_account(*zkcoins_program::types::MINTING_ADDRESS, Account::new()); + assert_eq!( + node.get_minting_account_address().unwrap(), + *zkcoins_program::types::MINTING_ADDRESS + ); + } + + #[test] + fn get_account_balance_errors_for_unknown_address() { + let node = fresh_node(); + let unknown = zkcoins_program::hash::digest_from_bytes(&[7u8; 32]); + assert_eq!( + node.get_account_balance(&unknown).unwrap_err(), + "No account with this address" + ); + } + + #[test] + fn get_account_balance_returns_zero_for_empty_account() { + let mut node = fresh_node(); + let address = zkcoins_program::hash::digest_from_bytes(&[1u8; 32]); + node.import_account(address, Account::new()); + assert_eq!(node.get_account_balance(&address).unwrap(), 0); + } + + #[test] + fn get_account_returns_some_for_known_address() { + let mut node = fresh_node(); + let address = zkcoins_program::hash::digest_from_bytes(&[1u8; 32]); + let mut account = Account::new(); + account.balance = 42; + node.import_account(address, account); + let got = node.get_account(&address).expect("present"); + assert_eq!(got.balance, 42); + } + + #[test] + fn get_account_returns_none_for_unknown_address() { + let node = fresh_node(); + let unknown = zkcoins_program::hash::digest_from_bytes(&[9u8; 32]); + assert!(node.get_account(&unknown).is_none()); + } + + #[test] + fn serialize_account_roundtrips_via_bincode() { + let mut a = Account::new(); + a.balance = 7; + let bytes = AccountNode::serialize_account(&a); + let back: Account = bincode::deserialize(&bytes).expect("deserialize ok"); + assert_eq!(back.balance, 7); + } + + /// Helper: build a stable PublicKey for use in send_coins error + /// tests. Doesn't need to map to anything real — `send_coins` + /// returns "Unknown account address" before touching it. + fn dummy_secp_public_key() -> bitcoin::secp256k1::PublicKey { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); + bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk) + } + + #[test] + fn send_coins_errors_for_unknown_account() { + let mut node = fresh_node(); + let recipient = zkcoins_program::hash::digest_from_bytes(&[2u8; 32]); + let account_address = zkcoins_program::hash::digest_from_bytes(&[3u8; 32]); + let pk = dummy_secp_public_key(); + let result = node.send_coins( + vec![Invoice::new(1, recipient)], + account_address, + pk, + pk, + None, + ); + assert_eq!(result.unwrap_err(), "Unknown account address"); + } + + #[test] + fn send_coins_errors_on_insufficient_funds() { + let mut node = fresh_node(); + let account_address = zkcoins_program::hash::digest_from_bytes(&[4u8; 32]); + node.import_account(account_address, Account::new()); + let recipient = zkcoins_program::hash::digest_from_bytes(&[5u8; 32]); + let pk = dummy_secp_public_key(); + let result = node.send_coins( + vec![Invoice::new(100, recipient)], + account_address, + pk, + pk, + None, + ); + assert_eq!(result.unwrap_err(), "Insufficient funds"); + } + + #[test] + fn prepare_mint_errors_when_minting_account_absent() { + let node = fresh_node(); + let pk = dummy_secp_public_key(); + let result = node.prepare_mint(vec![], pk, pk, None); + assert_eq!(result.unwrap_err(), "Minting account not created"); + } + + #[test] + fn account_new_has_zero_balance_and_empty_queue() { + let a = Account::new(); + assert_eq!(a.balance, 0); + assert!(a.coin_queue.is_empty()); + assert_eq!(a.get_balance(), 0); + } + + #[test] + fn load_account_node_error_display_and_source() { + // Display and `source()` coverage for all three error variants. + // The Db variant wraps the simplest sqlx::Error we can construct: + // ColumnNotFound is a unit-ish variant taking only the column name. + let db_err = LoadAccountNodeError::from(sqlx::Error::ColumnNotFound("address".to_string())); + assert!(format!("{}", db_err).contains("database error")); + assert!(std::error::Error::source(&db_err).is_some()); + + let bad = LoadAccountNodeError::BadAddressLength(7); + assert!(format!("{}", bad).contains("expected 32")); + assert!(std::error::Error::source(&bad).is_none()); + + let de_err = LoadAccountNodeError::from(bincode::Error::new(bincode::ErrorKind::Custom( + "boom".into(), + ))); + assert!(format!("{}", de_err).contains("account blob deserialize")); + assert!(std::error::Error::source(&de_err).is_some()); + } + + #[test] + fn persist_account_error_display_and_source() { + let db_err = PersistAccountError::from(sqlx::Error::ColumnNotFound("data".to_string())); + assert!(format!("{}", db_err).contains("database error")); + assert!(std::error::Error::source(&db_err).is_some()); + } + + #[tokio::test] + async fn persist_account_propagates_db_error() { + // Lazy pool that never connects → upsert returns Db error. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"); + let address = zkcoins_program::hash::digest_from_bytes(&[1u8; 32]); + let account = Account::new(); + let err = persist_account(&pool, &address, &account) + .await + .expect_err("expected db error"); + assert!( + matches!(err, PersistAccountError::Db(_)), + "unexpected: {:?}", + err + ); + } + + #[tokio::test] + async fn load_from_pg_propagates_db_error() { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"); + let state = Arc::new(Mutex::new(State::new())); + // `AccountNode` is intentionally not `Debug` (it owns a + // `Prover` which is itself non-Debug), so `expect_err` is not + // available. Use `.err()` + `.expect()` instead of a `match` + // with an `Ok(_) => panic!` arm — that arm is structurally + // unreachable in a passing test, which leaves the Coverage + // Gate (`account_node.rs` is in scope, only `_tests.rs$` + // files are ignored) at 99.83% on the dead match arm. + let err = AccountNode::load_from_pg(state, &pool) + .await + .err() + .expect("load_from_pg should fail when DB is unreachable"); + assert!( + matches!(err, LoadAccountNodeError::Db(_)), + "unexpected: {:?}", + err + ); + } + + /// Mirror of `router_tests::lock_or_recover_recovers_from_poisoned_mutex` + /// for the `send_coins` site: poisoning the shared `state` mutex + /// must NOT crash the handler — the `unwrap_or_else(PoisonError:: + /// into_inner)` recovery branch returns the inner guard so the + /// next check (the "Unknown account address" guard in this test) + /// is the one that surfaces in the response. Without this, the + /// recovery closure has no covering test and any future change to + /// the lock-acquire pattern would silently lose the poison-safe + /// behaviour. + #[test] + fn send_coins_recovers_from_poisoned_state_mutex() { + let state = Arc::new(Mutex::new(State::new())); + let state_for_poison = Arc::clone(&state); + + // Poison the state mutex by panicking while holding the guard. + let _ = std::thread::spawn(move || { + let _guard = state_for_poison.lock().unwrap(); + panic!("intentional panic to poison the state mutex"); + }) + .join(); + assert!(state.is_poisoned(), "state mutex must be poisoned"); + + let mut node = AccountNode::new(Arc::clone(&state)); + let recipient = zkcoins_program::hash::digest_from_bytes(&[2u8; 32]); + let account_address = zkcoins_program::hash::digest_from_bytes(&[3u8; 32]); + let pk = dummy_secp_public_key(); + // The send_coins call must traverse the poisoned-lock recovery + // path before hitting the "Unknown account address" guard. + let result = node.send_coins( + vec![Invoice::new(1, recipient)], + account_address, + pk, + pk, + None, + ); + assert_eq!(result.unwrap_err(), "Unknown account address"); + } +} + +#[cfg(test)] +#[path = "account_node_tests.rs"] +mod tests; diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs new file mode 100644 index 00000000..1666d27a --- /dev/null +++ b/node/src/account_node_tests.rs @@ -0,0 +1,1203 @@ +use std::time::Instant; + +use super::*; +use crate::state::State; +use bitcoin::{ + bip32::{ChildNumber, Xpriv, Xpub}, + key::Secp256k1, + secp256k1::{All, PublicKey as BitcoinPublicKey, SecretKey}, + Network, +}; +use lazy_static::lazy_static; +use shared::{commitment::Commitment, ProofData}; +use zkcoins_program::hash::{ + digest_from_bytes, digest_to_bytes, hash_bytes, hash_concat, ZERO_HASH, +}; +use zkcoins_program::types::MINTING_ADDRESS; + +lazy_static! { + static ref SECP256K1_TEST_CTX: Secp256k1 = Secp256k1::new(); +} + +fn generate_test_public_key(private_key: &Xpriv, index: u32) -> BitcoinPublicKey { + Xpub::from_priv(&SECP256K1_TEST_CTX, private_key) + .derive_pub(&SECP256K1_TEST_CTX, &[ChildNumber::Normal { index }]) + .expect("Failed to derive public key for test") + .public_key +} + +fn derive_test_secret_key(private_key: &Xpriv, index: u32) -> SecretKey { + private_key + .derive_priv(&SECP256K1_TEST_CTX, &[ChildNumber::Normal { index }]) + .expect("Unable to derive private key for test") + .private_key +} + +struct TestAccountData { + xpriv: Xpriv, + address: Address, + num_pubkeys: u32, +} + +impl TestAccountData { + fn new_minting_account() -> Self { + let secret = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(Network::Bitcoin, secret) + .expect("Failed to create private key for minting account."); + + TestAccountData { + xpriv, + address: *MINTING_ADDRESS, + num_pubkeys: 0, + } + } + + fn new_generic(seed: &[u8; 32], network: Network) -> Self { + let xpriv = Xpriv::new_master(network, seed) + .expect("Failed to create private key for generic account."); + + let initial_pk_bytes = generate_test_public_key(&xpriv, 0).serialize().to_vec(); + let address = hash_bytes(&initial_pk_bytes); + + TestAccountData { + xpriv, + address, + num_pubkeys: 0, + } + } + + fn execute_send_coins( + &mut self, + node: &mut AccountNode, + invoices: Vec, + ) -> Result, String> { + let current_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys); + let next_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys + 1); + let prev_pk = if self.num_pubkeys > 0 { + Some(generate_test_public_key(&self.xpriv, self.num_pubkeys - 1)) + } else { + None + }; + + let mut coin_proofs = + node.send_coins(invoices, self.address, current_pk, next_pk, prev_pk)?; + + // The key used for the commitment corresponds to current_pk + let signing_secret_key = derive_test_secret_key(&self.xpriv, self.num_pubkeys); + + self.num_pubkeys += 1; // Increment after deriving signing key for current op, before it's used for next op + + for cp in &mut coin_proofs { + // Plonky2 bridge: SP1's `proof.public_values: Vec` (bincode + // blob) is replaced by `proof.public_inputs: Vec` (Goldilocks + // field elements). The first + // `N_PROOF_DATA_PUBLIC_INPUTS = 16` slots reconstruct `ProofData`. + let pis: [zkcoins_program::F; + zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = cp + .proof + .public_inputs[..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .expect("Proof public_inputs too short"); + let proof_data = ProofData::from_field_elements(&pis); + let commitment_hash_input = hash_concat( + &proof_data.account_state_hash, + &proof_data.output_coins_root, + ); + cp.commitment = Some( + Commitment::new( + &signing_secret_key, + digest_to_bytes(&commitment_hash_input).to_vec(), + ) + .expect("Failed to create commitment for coin proof in test"), + ); + } + Ok(coin_proofs) + } +} + +#[test] +fn test_wallet_operations() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + assert_eq!( + *MINTING_ADDRESS, + node.get_minting_account_address().unwrap(), + "Minting address in node and program are different" + ); + + let mut account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let mut account_2_data = TestAccountData::new_generic(&[2u8; 32], Network::Signet); + + assert_eq!(node.get_account_balance(&MINTING_ADDRESS).unwrap(), 10_000); + assert!(node.get_account_balance(&account_1_data.address).is_err()); + assert!(node.get_account_balance(&account_2_data.address).is_err()); + + // Note: Invoices use addresses. + let account_2_invoice = Invoice::new(100, account_2_data.address); + let account_1_invoice = Invoice::new(100, account_1_data.address); + + let mut coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![account_2_invoice, account_1_invoice]) + .unwrap(); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + node.receive_coin(coin_proofs.pop().unwrap()) // Order might matter if tied to invoice order + .expect("Unable to receive coin for account_1_invoice"); // Assuming account_1_invoice was last in vec or order doesn't strictly map here + node.receive_coin(coin_proofs.pop().unwrap()) + .expect("Unable to receive coin for account_2_invoice"); + + assert_eq!( + node.get_account_balance(&account_1_data.address).unwrap(), + 100 + ); + assert_eq!( + node.get_account_balance(&account_2_data.address).unwrap(), + 100 + ); + println!("Minting successful"); + + let mut coin_proofs_from_acc2 = account_2_data + .execute_send_coins(&mut node, vec![account_1_invoice]) // account_2 sends to account_1 + .expect("Unable to send coin from account_2"); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs_from_acc2 + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + // Balances before receiving the new coin by account_1 + assert_eq!( + node.get_account_balance(&account_1_data.address).unwrap(), + 100 + ); + assert_eq!( + node.get_account_balance(&account_2_data.address).unwrap(), + 0 + ); // account_2's balance reduced after send + + node.receive_coin(coin_proofs_from_acc2.pop().unwrap()) + .expect("Unable to receive coin by account_1 from account_2"); + assert_eq!( + node.get_account_balance(&account_1_data.address).unwrap(), + 200 + ); + assert_eq!( + node.get_account_balance(&account_2_data.address).unwrap(), + 0 + ); + + // Send with timer + let start_time = Instant::now(); + let mut coin_proofs_from_acc1 = account_1_data + .execute_send_coins(&mut node, vec![account_2_invoice]) // account_1 sends to account_2 + .expect("Unable to send coin from account_1"); + let duration = start_time.elapsed(); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs_from_acc1 + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + println!("TIME ELAPSED FOR ONE RECURSIVE SEND: {:?}", duration); + node.receive_coin(coin_proofs_from_acc1.pop().unwrap()) + .expect("Unable to receive coin by account_2 from account_1"); + assert_eq!( + node.get_account_balance(&account_1_data.address).unwrap(), + 100 + ); // 200 - 100 + assert_eq!( + node.get_account_balance(&account_2_data.address).unwrap(), + 100 + ); // 0 + 100 +} + +#[test] +fn test_create_minting_account() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(state_arc); + + let minting_account_data = TestAccountData::new_minting_account(); + + node.import_account( + minting_account_data.address, // This is MINTING_ADDRESS + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + assert_eq!( + node.get_minting_account_address().unwrap(), + *MINTING_ADDRESS, + "Minting address is not stored in node correctly." + ); + assert_eq!(node.get_account_balance(&MINTING_ADDRESS).unwrap(), 10_000); +} + +#[test] +fn test_mint_single_invoice() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let invoice = Invoice::new(100, account_1_data.address); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![invoice]) + .expect("Mint with single invoice failed"); + + assert_eq!(coin_proofs.len(), 1); +} + +#[test] +fn test_receive_duplicate_coin_rejected() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let invoice = Invoice::new(100, account_1_data.address); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![invoice]) + .expect("Mint failed"); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + let coin_proof = coin_proofs.into_iter().next().unwrap(); + let duplicate = coin_proof.clone(); + + // First receive should succeed + node.receive_coin(coin_proof) + .expect("First receive should succeed"); + + // Second receive of the same coin should be rejected + let result = node.receive_coin(duplicate); + assert!(result.is_err(), "Duplicate coin receive must be rejected"); +} + +#[test] +fn test_receive_updates_balance() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); + let invoice = Invoice::new(250, account_1_data.address); + + // Balance should not exist before any receive + assert!( + node.get_account_balance(&account_1_data.address).is_err(), + "Account should not exist before receiving coins" + ); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![invoice]) + .expect("Mint failed"); + + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + for cp in coin_proofs { + node.receive_coin(cp).expect("Receive should succeed"); + } + + // Balance should reflect the received coin amount + let balance = node + .get_account_balance(&account_1_data.address) + .expect("Account should exist after receive"); + assert_eq!( + balance, 250, + "Balance should equal the received coin amount" + ); +} + +/// Reproduces the exact configuration of /api/mint on the live DEV server: +/// recipient = raw [1u8; 32] bytes, amount = 1. +#[test] +fn test_mint_repro_live_setup() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 1_000_000, + }, + ); + + let recipient: Address = digest_from_bytes(&[1u8; 32]); + let invoice = Invoice::new(1, recipient); + + let coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![invoice]) + .expect("Mint repro failed"); + + assert_eq!(coin_proofs.len(), 1); +} + +/// PR-A3 replacement for the previous file-based `save_and_load_roundtrip`: +/// persist an imported account via `persist_account` (the same helper +/// the handler sites call), then rebuild a fresh `AccountNode` via +/// `load_from_pg` and assert the imported account survived round-trip. +#[tokio::test] +async fn test_persist_and_load_from_pg_roundtrip() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let address: HashDigest = digest_from_bytes(&[42u8; 32]); + let mut acct = Account::new(); + acct.balance = 11; + node.import_account(address, acct); + + // Snapshot + upsert mirrors the handler-site pattern. + let account_snapshot = node.get_account(&address).cloned_via_bincode(); + crate::account_node::persist_account(&pool, &address, &account_snapshot) + .await + .expect("persist_account ok"); + + // Rebuild from PG and verify the row came back. + let loaded = AccountNode::load_from_pg(state_arc, &pool) + .await + .expect("load_from_pg ok"); + assert_eq!(loaded.get_account_balance(&address).unwrap(), 11); +} + +/// `Account` does not implement `Clone` (its inner Plonky2 proof types +/// are sealed). The test above only needs an owned copy for the +/// persistence call, so bounce it through bincode locally. Kept as a +/// trait extension to keep the test body readable without polluting +/// the production `Account` API. +trait CloneViaBincode { + fn cloned_via_bincode(self) -> Account; +} + +impl CloneViaBincode for Option<&Account> { + fn cloned_via_bincode(self) -> Account { + let a = self.expect("account present"); + let bytes = bincode::serialize(a).expect("serialize"); + bincode::deserialize(&bytes).expect("deserialize") + } +} + +#[test] +fn test_get_minting_account_address_returns_err_when_not_imported() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(state_arc); + assert!(node.get_minting_account_address().is_err()); +} + +#[test] +fn test_get_account_balance_returns_err_for_unknown_address() { + let state_arc = Arc::new(Mutex::new(State::new())); + let node = AccountNode::new(state_arc); + let unknown: Address = digest_from_bytes(&[7u8; 32]); + assert!(node.get_account_balance(&unknown).is_err()); +} + +/// PR-A3 replacement for the previous `test_load_from_file_rejects_corrupted_bytes`: +/// plant a row whose `data` blob is not valid bincode and assert +/// `load_from_pg` surfaces the corruption as `LoadAccountNodeError +/// ::Deserialize` rather than panicking or silently dropping the row. +#[tokio::test] +async fn test_load_from_pg_rejects_corrupted_blob() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + + let bad_addr = vec![0xAAu8; 32]; + sqlx::query("INSERT INTO accounts (address, data) VALUES ($1, $2)") + .bind(&bad_addr) + .bind(b"not bincode".to_vec()) + .execute(&pool) + .await + .unwrap(); + + let state_arc = Arc::new(Mutex::new(State::new())); + // `AccountNode` is intentionally not `Debug`, so `expect_err` + // isn't available; match the Result instead. + match AccountNode::load_from_pg(state_arc, &pool).await { + Ok(_) => panic!("expected deserialize error"), + Err(err) => assert!( + matches!( + err, + crate::account_node::LoadAccountNodeError::Deserialize(_) + ), + "unexpected: {:?}", + err + ), + } +} + +/// PR-A3 negative test: plant a row whose `address` column is not the +/// expected 32 bytes and assert the loader surfaces the mismatch as +/// `LoadAccountNodeError::BadAddressLength`. +#[tokio::test] +async fn test_load_from_pg_rejects_wrong_address_length() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + + sqlx::query("INSERT INTO accounts (address, data) VALUES ($1, $2)") + .bind(vec![0u8; 7]) // wrong length + .bind(b"anything".to_vec()) + .execute(&pool) + .await + .unwrap(); + + let state_arc = Arc::new(Mutex::new(State::new())); + match AccountNode::load_from_pg(state_arc, &pool).await { + Ok(_) => panic!("expected bad-address length"), + Err(err) => assert!( + matches!( + err, + crate::account_node::LoadAccountNodeError::BadAddressLength(7) + ), + "unexpected: {:?}", + err + ), + } +} + +#[test] +fn test_send_coins_returns_err_for_unknown_account() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(state_arc); + let account_data = TestAccountData::new_generic(&[1u8; 32], Network::Bitcoin); + + let recipient: Address = digest_from_bytes(&[2u8; 32]); + let invoice = Invoice::new(1, recipient); + + let current_pk = generate_test_public_key(&account_data.xpriv, 0); + let next_pk = generate_test_public_key(&account_data.xpriv, 1); + + let result = node.send_coins( + vec![invoice], + account_data.address, + current_pk, + next_pk, + None, + ); + assert_eq!(result.unwrap_err(), "Unknown account address"); +} + +#[test] +fn test_send_coins_returns_err_insufficient_funds() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(state_arc); + let account_data = TestAccountData::new_generic(&[1u8; 32], Network::Bitcoin); + node.import_account(account_data.address, Account::new()); + + let recipient: Address = digest_from_bytes(&[2u8; 32]); + let invoice = Invoice::new(100, recipient); + + let current_pk = generate_test_public_key(&account_data.xpriv, 0); + let next_pk = generate_test_public_key(&account_data.xpriv, 1); + + let result = node.send_coins( + vec![invoice], + account_data.address, + current_pk, + next_pk, + None, + ); + assert_eq!(result.unwrap_err(), "Insufficient funds"); +} + +#[test] +fn test_receive_coin_rejects_invalid_inclusion_proof() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting_account_data = TestAccountData::new_minting_account(); + node.import_account( + minting_account_data.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let recipient: Address = digest_from_bytes(&[1u8; 32]); + let invoice = Invoice::new(100, recipient); + + let mut coin_proofs = minting_account_data + .execute_send_coins(&mut node, vec![invoice]) + .expect("send_coins should succeed"); + + // Tamper with the coin identifier so the existing inclusion proof + // no longer verifies against it. receive_coin must reject. + let mut coin_proof = coin_proofs.pop().unwrap(); + coin_proof.coin.identifier = digest_from_bytes(&[99u8; 32]); + + let result = node.receive_coin(coin_proof); + assert_eq!( + result.unwrap_err(), + "Coin inclusion proof verification failed" + ); +} + +#[test] +fn test_send_coins_twice_from_same_account_uses_update_account() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let recipient: Address = digest_from_bytes(&[42u8; 32]); + + // First send: account.proof is None -> create_account branch. + let coin_proofs_1 = minting + .execute_send_coins(&mut node, vec![Invoice::new(100, recipient)]) + .expect("first send should succeed"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs_1 + .iter() + .map(|cp| cp.commitment.clone().unwrap()) + .collect::>(), + ) + .unwrap(); + + // After the first send, account.proof = Some. A second send from the + // same account must therefore take the AccountUpdateProof branch + // (update_account, not create_account). + let coin_proofs_2 = minting + .execute_send_coins(&mut node, vec![Invoice::new(50, recipient)]) + .expect("second send should succeed (update_account path)"); + assert_eq!(coin_proofs_2.len(), 1); +} + +#[test] +fn test_receive_coin_rejects_replay_via_coin_history() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + let recipient: Address = digest_from_bytes(&[9u8; 32]); + let coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(50, recipient)]) + .unwrap(); + let coin_proof = coin_proofs[0].clone(); + let coin_id = coin_proof.coin.identifier; + + // First receive — succeeds, coin lands in the recipient's coin_queue. + node.receive_coin(coin_proof.clone()).unwrap(); + + // Simulate the recipient having spent the coin: identifier goes + // from coin_queue into coin_history. + { + let recipient_account = node.accounts.get_mut(&recipient).unwrap(); + recipient_account + .coin_history + .insert(digest_to_bytes(&coin_id), coin_id) + .unwrap(); + recipient_account + .coin_queue + .retain(|cp| cp.coin.identifier != coin_id); + } + + // Replay: receiving the same coin again must be rejected via the + // coin_history check rather than the coin_queue check. + let result = node.receive_coin(coin_proof); + assert_eq!(result.unwrap_err(), "Coin already spent (replay)"); +} + +/// Stage 5d-next-5 Phase 2b negative regression: an in-coin whose +/// off-circuit `source_inclusion` siblings have been tampered with +/// must NOT make it to the prover. The defense-in-depth shim in +/// `send_coins` fast-fails with the documented error string; +/// without the shim the in-circuit SMT-inclusion check would still +/// reject, but only after a minute-scale prove. +/// +/// Construction: do a real mint → recipient receive flow so that +/// the recipient's `account.coin_queue[0]` carries an HONEST +/// `inclusion_proof` produced by `out_coins_tree.generate_inclusion_proof`. +/// Then reach into the server's internal `accounts` map and flip +/// one sibling on the queued entry's `inclusion_proof`. The next +/// `send_coins` call from that recipient must surface the +/// "In-coin not present in source's output_coins_root" error. +#[test] +fn test_send_coins_rejects_tampered_source_proof_inclusion() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + // Real recipient with a deterministic seed; pin the address so + // we can reach back into `node.accounts` after `receive_coin`. + let recipient_data = TestAccountData::new_generic(&[42u8; 32], Network::Signet); + let recipient_addr = recipient_data.address; + + // Mint emits one coin to the recipient — honest end-to-end flow, + // so the `inclusion_proof` returned in `CoinProof` is well-formed + // by construction. + let mut coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(100, recipient_addr)]) + .expect("mint send_coins"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .expect("state.update"); + + node.receive_coin(coin_proofs.pop().expect("at least one coin")) + .expect("recipient receive_coin"); + + // Tamper the queued `inclusion_proof.siblings[0]` directly on the + // server's internal `accounts` map. The honest off-circuit + // `source_inclusion.verify` walks the path siblings; flipping + // the topmost sibling produces a recomputed root that doesn't + // match the source's committed `output_coins_root`. + { + let account = node + .accounts + .get_mut(&recipient_addr) + .expect("recipient account present after receive_coin"); + assert_eq!( + account.coin_queue.len(), + 1, + "recipient has exactly one queued in-coin after a single mint" + ); + account.coin_queue[0].inclusion_proof.siblings[0] = hash_bytes(b"tampered-sibling"); + } + + // The defense-in-depth off-circuit pre-check fires before the + // expensive prove and surfaces the specific rejection string. + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[99u8; 32]))], + recipient_addr, + current_pk, + next_pk, + None, + ); + assert_eq!( + result.unwrap_err(), + "In-coin not present in source's output_coins_root", + "tampered source-inclusion siblings must surface the off-circuit defense-in-depth rejection" + ); +} + +/// Slot-count guard: `invoices.len() > MAX_OUT_COINS` fires at the +/// top of `send_coins` before the heavy in-coin loop and prove cost. +/// Empty account + (`MAX_OUT_COINS + 1`) invoices triggers it +/// without paying a prove. +#[test] +fn test_send_coins_rejects_too_many_invoices() { + use zkcoins_program::circuit::main::MAX_OUT_COINS; + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + let minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 1_000_000, + }, + ); + + let invoices: Vec = (0..(MAX_OUT_COINS + 1) as u8) + .map(|i| Invoice::new(1, digest_from_bytes(&[i; 32]))) + .collect(); + + let current_pk = generate_test_public_key(&minting.xpriv, minting.num_pubkeys); + let next_pk = generate_test_public_key(&minting.xpriv, minting.num_pubkeys + 1); + let result = node.send_coins(invoices, minting.address, current_pk, next_pk, None); + assert_eq!(result.unwrap_err(), "Too many out-coins for one transition"); +} + +/// Slot-count guard: `account.coin_queue.len() > MAX_IN_COINS` fires +/// at the top of `send_coins` before the heavy in-coin loop and +/// prove cost. We mint one coin honestly (one Init prove), then +/// clone it `MAX_IN_COINS + 1` times into the recipient's +/// `coin_queue` and confirm send_coins fails fast. +#[test] +fn test_send_coins_rejects_too_many_coins_in_queue() { + use zkcoins_program::circuit::main::MAX_IN_COINS; + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + let recipient_data = TestAccountData::new_generic(&[20u8; 32], Network::Signet); + let recipient_addr = recipient_data.address; + + // One honest mint produces one valid CoinProof we can clone. + let mut coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(100, recipient_addr)]) + .expect("mint send_coins"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .expect("state.update"); + + let cp = coin_proofs.pop().expect("at least one coin"); + node.receive_coin(cp.clone()) + .expect("recipient receive_coin"); + + // Force `coin_queue.len()` past the budget by cloning the single + // honest entry. The slot-count guard fires before any siblings + // are walked or any prove is attempted, so the clones being + // identical doesn't matter. + { + let account = node + .accounts + .get_mut(&recipient_addr) + .expect("recipient account present after receive_coin"); + for _ in 0..MAX_IN_COINS { + account.coin_queue.push(cp.clone()); + } + assert!( + account.coin_queue.len() > MAX_IN_COINS, + "test fixture must overflow the in-coin slot budget" + ); + } + + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[99u8; 32]))], + recipient_addr, + current_pk, + next_pk, + None, + ); + assert_eq!(result.unwrap_err(), "Too many in-coins for one transition"); +} + +/// In-coin loop: a queued `CoinProof` whose `commitment.public_key` +/// is not registered in `state.commitment_proofs` makes +/// `get_merkle_proofs` return its "Unable to get merkle proofs..." +/// error string. Set up by minting → receiving WITHOUT calling +/// `state.update` first, so the recipient's queue entry references a +/// commitment public_key the state never indexed. +#[test] +fn test_send_coins_errors_when_state_lacks_commitment_for_in_coin() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + let recipient_data = TestAccountData::new_generic(&[21u8; 32], Network::Signet); + let recipient_addr = recipient_data.address; + + let mut coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(75, recipient_addr)]) + .expect("mint send_coins"); + // Intentionally SKIP `state_arc.update(...)` — state never sees + // the minting account's commitment, so get_merkle_proofs cannot + // look up the commitment proof on the recipient's send_coins call. + node.receive_coin(coin_proofs.pop().expect("at least one coin")) + .expect("recipient receive_coin"); + + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[99u8; 32]))], + recipient_addr, + current_pk, + next_pk, + None, + ); + assert_eq!( + result.unwrap_err(), + "Unable to get merkle proofs for provided public key" + ); +} + +/// AccountUpdate branch: when `account.proof = Some(...)` and the +/// caller passes a `prev_commitment_pubkey` that the state's +/// commitment-proof index does not contain, the second call to +/// `get_merkle_proofs` (inside the AccountUpdate-prove preparation) +/// surfaces "Unable to get merkle proofs..." just like the in-coin +/// loop's call. Set up via one honest mint + receive + state.update; +/// then pass a fresh, never-indexed `prev_commitment_pubkey`. +#[test] +fn test_send_coins_errors_when_state_lacks_commitment_for_prev_account_proof() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + let recipient_data = TestAccountData::new_generic(&[22u8; 32], Network::Signet); + let recipient_addr = recipient_data.address; + + let mut coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(50, recipient_addr)]) + .expect("mint send_coins"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .expect("state.update"); + node.receive_coin(coin_proofs.pop().expect("at least one coin")) + .expect("recipient receive_coin"); + + // Forge an `account.proof = Some(...)` on the recipient by reusing + // the minting account's proof we just produced (signature + // verification doesn't happen on this path — `get_merkle_proofs` + // only consults state for the prev_commitment_pubkey lookup). + { + let mint_account = node + .accounts + .get_mut(&minting.address) + .expect("minting account present"); + let proof = mint_account.proof.clone(); + let recipient_account = node + .accounts + .get_mut(&recipient_addr) + .expect("recipient account present after receive_coin"); + recipient_account.proof = proof; + } + + // Pass a `prev_commitment_pubkey` that the state's commitment + // index has never seen — the lookup fails inside + // get_merkle_proofs and propagates "Unable to get merkle proofs...". + let stranger_seed = Xpriv::new_master(Network::Signet, &[99u8; 32]).expect("stranger xpriv"); + let unknown_prev_pk = generate_test_public_key(&stranger_seed, 0); + + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[99u8; 32]))], + recipient_addr, + current_pk, + next_pk, + Some(unknown_prev_pk), + ); + // The AccountUpdate-branch get_merkle_proofs call uses + // `prev_commitment_pubkey`, which is not in state, so the lookup + // fails. The error string is identical to the in-coin loop's, + // which is fine — both signal the same caller-fixable malformed + // witness, and Item 1's HTTP mapping translates both to 422. + assert_eq!( + result.unwrap_err(), + "Unable to get merkle proofs for provided public key" + ); +} + +#[test] +fn test_send_coins_rejects_coin_queue_entry_without_commitment() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + let recipient: Address = digest_from_bytes(&[10u8; 32]); + let coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(50, recipient)]) + .unwrap(); + let mut coin_proof = coin_proofs[0].clone(); + // Strip the commitment so the next send attempt from the recipient + // hits the "Coin is missing commitment" branch. + coin_proof.commitment = None; + + node.receive_coin(coin_proof).unwrap(); + + let mut recipient_data = TestAccountData::new_generic(&[10u8; 32], bitcoin::Network::Signet); + // Force the test data to use the same address as the recipient. + recipient_data.address = recipient; + + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[11u8; 32]))], + recipient_data.address, + current_pk, + next_pk, + None, + ); + assert_eq!(result.unwrap_err(), "Coin is missing commitment"); +} + +/// In-coin loop: when the off-circuit pre-check at +/// `account_node.rs:419` rebuilds a source `CommitmentMerkleProofs` +/// whose `commitment_root_mmr_sibling` does not match the actual +/// MMR leaf for that source, `verify_commitment` returns false and +/// `send_coins` surfaces "Source commitment not present in history +/// MMR". This is the companion of +/// `test_send_coins_rejects_tampered_source_proof_inclusion`: it +/// closes the line-419 error branch the way the inclusion-proof +/// test closes the line-416 branch, and it is the off-circuit +/// defense-in-depth analogue of the in-circuit history-MMR check. +/// +/// Construction: honest mint → `state.update` → recipient +/// `receive_coin`, so the recipient's `coin_queue[0]` carries a +/// well-formed `inclusion_proof` (line 416 passes) and the source +/// commitment is genuinely indexed in `state.smt` / `state.mmr` +/// (line-241 `get_mmr_inclusion_proof` lookup succeeds). Then +/// overwrite `state.prev_mmr_root` with `ZERO_HASH` directly. The +/// `get_merkle_proofs` builder reads that field verbatim into +/// `commitment_root_mmr_sibling`, so the source CMP recomputes a +/// leaf `hash_concat(commitment_root, ZERO_HASH)` that does not +/// appear in `state.mmr`. The genuine MMR proof is still threaded +/// through, so the recomputed root mismatches the actual history +/// root and only the MMR half of `verify_commitment` rejects — +/// leaving the line-416 SMT-out_coins-inclusion path untouched, +/// which is exactly the branch line 419 is meant to gate. +#[test] +fn test_send_coins_rejects_source_commitment_missing_from_history_mmr() { + let state_arc = Arc::new(Mutex::new(State::new())); + let mut node = AccountNode::new(Arc::clone(&state_arc)); + + let mut minting = TestAccountData::new_minting_account(); + node.import_account( + minting.address, + Account { + proof: None, + coin_queue: vec![], + coin_history: SparseMerkleTree::new(), + balance: 10_000, + }, + ); + + let recipient_data = TestAccountData::new_generic(&[43u8; 32], Network::Signet); + let recipient_addr = recipient_data.address; + + let mut coin_proofs = minting + .execute_send_coins(&mut node, vec![Invoice::new(100, recipient_addr)]) + .expect("mint send_coins"); + state_arc + .lock() + .unwrap() + .update( + &coin_proofs + .iter() + .map(|x| x.commitment.clone().unwrap()) + .collect::>(), + ) + .expect("state.update"); + + node.receive_coin(coin_proofs.pop().expect("at least one coin")) + .expect("recipient receive_coin"); + + // Desync `state.prev_mmr_root` from the actual history-MMR + // leaf. `get_merkle_proofs` writes this verbatim into source + // CMP's `commitment_root_mmr_sibling`, so the off-circuit + // `verify_commitment_root` recomputes a leaf that doesn't + // appear in `state.mmr` — without touching the out-coins SMT + // inclusion path that line 416 gates. + { + let mut state = state_arc.lock().unwrap(); + state.prev_mmr_root = ZERO_HASH; + } + + let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); + let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); + let result = node.send_coins( + vec![Invoice::new(1, digest_from_bytes(&[99u8; 32]))], + recipient_addr, + current_pk, + next_pk, + None, + ); + assert_eq!( + result.unwrap_err(), + "Source commitment not present in history MMR", + "desynced `state.prev_mmr_root` must surface the off-circuit history-MMR rejection at account_node.rs:419", + ); +} diff --git a/node/src/bin/recover_inscription.rs b/node/src/bin/recover_inscription.rs new file mode 100644 index 00000000..357a0f9f --- /dev/null +++ b/node/src/bin/recover_inscription.rs @@ -0,0 +1,375 @@ +//! Recover a stuck inscription anchor by rebuilding + broadcasting +//! the missing reveal transaction. +//! +//! Use case: the publisher broadcast a script-path Taproot commit +//! transaction but the reveal never made it to the network (process +//! crash between `client.broadcast(commit_tx)` and +//! `client.broadcast(reveal_tx)`, lost reveal bytes, etc.). The +//! commitment is recoverable as long as the operator has saved the +//! 145-byte bincode commitment payload and the commit txid from the +//! node logs. +//! +//! PR #105's REST fallback covers the WS-slow / WS-flaky failure mode +//! during normal operation; this CLI is the escape hatch for any +//! other failure between commit-broadcast and reveal-broadcast. +//! +//! The reveal is reconstructed deterministically from +//! `(commit_txid, commit_value, commitment_data, publisher_key)` via +//! the `publisher::build_reveal_only` helper — the same code path the +//! in-process publisher uses to mine the reveal. The CLI then sanity- +//! checks that the recovered reveal spends the operator-supplied +//! `--anchor-address` (so a wrong commitment payload or wrong network +//! can't produce a transaction that spends to nowhere) and broadcasts +//! via Esplora REST. +//! +//! Required env vars: +//! - `PUBLISHER_KEY` — 32-byte hex secp256k1 secret, must match the +//! key that signed the commit. +//! - `IS_MAINNET` — `"true"` for `Network::Bitcoin`, anything else +//! resolves to `Network::Signet` (Mutinynet). +//! +//! Optional env vars: +//! - `NETWORK_NAME` — log-only label. +//! +//! Required flags: +//! - `--commit-txid ` — the broadcast commit txid (64 hex chars). +//! - `--commitment-hex ` — the inscription payload (bincode of +//! `Commitment`) as hex, exactly as logged by the publisher. +//! - `--commit-value ` — value of the commit's anchor output[0]. +//! - `--anchor-address ` — bech32m P2TR address holding the +//! funds. Recovery aborts if the recovered reveal does not spend +//! this address. +//! +//! Optional flags: +//! - `--esplora-url ` — Esplora REST endpoint. Defaults to +//! `https://mutinynet.com/api`. +//! - `--dry-run` — log the reveal hex and exit without broadcasting. + +use std::process::ExitCode; +use std::str::FromStr; + +use bitcoin::consensus::Encodable; +use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; +use bitcoin::{Address, Network, Txid}; +use esplora_client::{ + r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, +}; + +use node::publisher; + +const DEFAULT_ESPLORA_URL: &str = "https://mutinynet.com/api"; + +#[derive(Debug)] +struct CliArgs { + commit_txid: String, + commitment_hex: String, + commit_value: u64, + anchor_address: String, + esplora_url: String, + dry_run: bool, +} + +fn print_usage(program: &str) { + eprintln!( + "usage: {program} \\ + --commit-txid \\ + --commitment-hex \\ + --commit-value \\ + --anchor-address \\ + [--esplora-url ] \\ + [--dry-run] + +env: PUBLISHER_KEY (required, 32-byte hex), IS_MAINNET (required, true|false) + NETWORK_NAME (optional, log-only) +" + ); +} + +/// Parse argv into a `CliArgs`. Errors carry the user-facing message +/// already formatted; the caller prints them to stderr. +fn parse_args(argv: Vec) -> Result { + let mut iter = argv.into_iter(); + let program = iter.next().unwrap_or_else(|| "recover_inscription".into()); + + let mut commit_txid: Option = None; + let mut commitment_hex: Option = None; + let mut commit_value: Option = None; + let mut anchor_address: Option = None; + let mut esplora_url: Option = None; + let mut dry_run = false; + + fn take_value>(iter: &mut I, flag: &str) -> Result { + iter.next() + .ok_or_else(|| format!("flag `{flag}` requires a value")) + } + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--commit-txid" => commit_txid = Some(take_value(&mut iter, "--commit-txid")?), + "--commitment-hex" => commitment_hex = Some(take_value(&mut iter, "--commitment-hex")?), + "--commit-value" => { + let raw = take_value(&mut iter, "--commit-value")?; + commit_value = Some( + raw.parse::() + .map_err(|e| format!("--commit-value must be a u64 sats value: {e}"))?, + ); + } + "--anchor-address" => anchor_address = Some(take_value(&mut iter, "--anchor-address")?), + "--esplora-url" => esplora_url = Some(take_value(&mut iter, "--esplora-url")?), + "--dry-run" => dry_run = true, + "-h" | "--help" => { + print_usage(&program); + return Err(String::new()); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + let commit_txid = commit_txid.ok_or_else(|| "--commit-txid is required".to_string())?; + let commitment_hex = + commitment_hex.ok_or_else(|| "--commitment-hex is required".to_string())?; + let commit_value = commit_value.ok_or_else(|| "--commit-value is required".to_string())?; + let anchor_address = + anchor_address.ok_or_else(|| "--anchor-address is required".to_string())?; + let esplora_url = esplora_url.unwrap_or_else(|| DEFAULT_ESPLORA_URL.to_string()); + + Ok(CliArgs { + commit_txid, + commitment_hex, + commit_value, + anchor_address, + esplora_url, + dry_run, + }) +} + +/// Validate parsed args (txid format, hex, address parses for network). +/// Returns the typed inputs ready for `build_reveal_only`. +struct ValidatedArgs { + commit_txid: Txid, + commitment_bytes: Vec, + commit_value: u64, + anchor_address: Address, + network: Network, + esplora_url: String, + dry_run: bool, +} + +fn validate_args(args: CliArgs, network: Network) -> Result { + if args.commit_txid.len() != 64 || !args.commit_txid.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "--commit-txid must be 64 hex chars, got {} chars", + args.commit_txid.len() + )); + } + let commit_txid = Txid::from_str(&args.commit_txid) + .map_err(|e| format!("--commit-txid is not a valid txid: {e}"))?; + + let commitment_bytes = hex::decode(&args.commitment_hex) + .map_err(|e| format!("--commitment-hex is not valid hex: {e}"))?; + if commitment_bytes.is_empty() { + return Err("--commitment-hex decoded to 0 bytes".into()); + } + + if args.commit_value == 0 { + return Err("--commit-value must be > 0".into()); + } + + let anchor_address = Address::from_str(&args.anchor_address) + .map_err(|e| format!("--anchor-address is not a valid address: {e}"))? + .require_network(network) + .map_err(|e| { + format!( + "--anchor-address {} is not valid for network {:?}: {}", + args.anchor_address, network, e + ) + })?; + + Ok(ValidatedArgs { + commit_txid, + commitment_bytes, + commit_value: args.commit_value, + anchor_address, + network, + esplora_url: args.esplora_url, + dry_run: args.dry_run, + }) +} + +/// Resolve network from env (`IS_MAINNET=true` → Bitcoin, else +/// Signet) and log the operator label if `NETWORK_NAME` is set. +fn resolve_network_from_env() -> Network { + let is_mainnet = std::env::var("IS_MAINNET") + .map(|v| v == "true") + .unwrap_or(false); + let label = std::env::var("NETWORK_NAME").unwrap_or_else(|_| { + if is_mainnet { + "Mainnet".to_string() + } else { + "Mutinynet".to_string() + } + }); + println!("recover_inscription: network={label} is_mainnet={is_mainnet}"); + if is_mainnet { + Network::Bitcoin + } else { + Network::Signet + } +} + +/// Derive the publisher's P2TR (key-spend) address used as the reveal's +/// output. Matches the derivation in `lib::PUBLISHER_ADDRESS`. +fn derive_publisher_address(publisher_key: &str, network: Network) -> Result { + let secp = Secp256k1::new(); + let sk = SecretKey::from_str(publisher_key) + .map_err(|e| format!("PUBLISHER_KEY is not a valid 32-byte hex secret: {e}"))?; + let key_pair = Keypair::from_secret_key(&secp, &sk); + let (xonly, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + Ok(Address::p2tr(&secp, xonly, None, network)) +} + +/// Encode a `Transaction` to its hex serialization (consensus bytes → +/// lowercase hex). +fn serialize_tx_hex(tx: &bitcoin::Transaction) -> String { + let mut buf = Vec::new(); + tx.consensus_encode(&mut buf) + .expect("Vec never fails consensus_encode"); + hex::encode(buf) +} + +async fn run(validated: ValidatedArgs, publisher_key: String) -> Result<(), String> { + // Build the reveal deterministically from the operator-supplied + // commit txid + value + commitment payload. The publisher's + // matching `inscription_txs` happy-path goes through the same + // helper, so this is the identical code path the original mint + // would have used had the reveal broadcast not failed. + let publisher_address = derive_publisher_address(&publisher_key, validated.network)?; + println!( + "recover_inscription: publisher_address={} commit_txid={} commit_value={}", + publisher_address, validated.commit_txid, validated.commit_value + ); + + let (reveal_tx, derived_commit_address) = publisher::build_reveal_only( + validated.commit_txid, + validated.commit_value, + &validated.commitment_bytes, + &publisher_key, + &publisher_address, + validated.network, + ); + + // Sanity-check: the script-path commit address we re-derived from + // the commitment payload + publisher key MUST match the + // operator-supplied `--anchor-address`. If not, the wrong + // commitment payload or wrong key was supplied and broadcasting + // would burn the funds to an address nobody can spend from. + if derived_commit_address != validated.anchor_address { + return Err(format!( + "anchor-address mismatch: derived={derived_commit_address} supplied={} \ + (wrong commitment-hex or publisher key?)", + validated.anchor_address + )); + } + println!( + "recover_inscription: derived commit address matches --anchor-address {}", + validated.anchor_address + ); + + let reveal_txid = reveal_tx.compute_txid(); + let reveal_hex = serialize_tx_hex(&reveal_tx); + + if validated.dry_run { + println!("recover_inscription: dry-run — reveal_tx_hex={reveal_hex}"); + println!("recover_inscription: dry-run — reveal_txid={reveal_txid}"); + return Ok(()); + } + + // Broadcast via Esplora REST `POST /tx`. The publisher uses the + // same `esplora-client` crate to do exactly this on the happy + // path (`publisher::broadcast_inscription_txs`). + let builder = EsploraBuilder::new(&validated.esplora_url); + let client = EsploraAsyncClient::::from_builder(builder).map_err(|e| { + format!( + "failed to build esplora client for {}: {e}", + validated.esplora_url + ) + })?; + + println!( + "recover_inscription: broadcasting reveal {} via {}...", + reveal_txid, validated.esplora_url + ); + client + .broadcast(&reveal_tx) + .await + .map_err(|e| format!("esplora broadcast failed: {e}"))?; + + // Single GET to confirm the reveal landed in the mempool / a + // block. Mirrors the REST fallback shape from PR #105 — one GET, + // not a poll loop (preserves the "No polling — events only" + // invariant from CONTRIBUTING.md). + let esplora_status = match client.get_tx(&reveal_txid).await { + Ok(Some(_)) => "200", + Ok(None) => "404", + Err(e) => { + println!( + "recover_inscription: reveal broadcast — txid={reveal_txid} esplora-status=error \ + (GET /tx/{reveal_txid} failed: {e})" + ); + return Ok(()); + } + }; + println!( + "recover_inscription: reveal broadcast — txid={reveal_txid} esplora-status={esplora_status}" + ); + Ok(()) +} + +fn run_blocking(validated: ValidatedArgs, publisher_key: String) -> Result<(), String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to build tokio runtime: {e}"))?; + runtime.block_on(run(validated, publisher_key)) +} + +fn main() -> ExitCode { + let argv: Vec = std::env::args().collect(); + let args = match parse_args(argv) { + Ok(a) => a, + Err(msg) => { + if !msg.is_empty() { + eprintln!("recover_inscription: {msg}"); + } + return ExitCode::from(1); + } + }; + + let publisher_key = match std::env::var("PUBLISHER_KEY") { + Ok(k) => k, + Err(_) => { + eprintln!( + "recover_inscription: PUBLISHER_KEY env var must be set (32-byte hex secret)" + ); + return ExitCode::from(1); + } + }; + + let network = resolve_network_from_env(); + + let validated = match validate_args(args, network) { + Ok(v) => v, + Err(msg) => { + eprintln!("recover_inscription: {msg}"); + return ExitCode::from(1); + } + }; + + match run_blocking(validated, publisher_key) { + Ok(()) => ExitCode::SUCCESS, + Err(msg) => { + eprintln!("recover_inscription: {msg}"); + ExitCode::from(1) + } + } +} diff --git a/node/src/db.rs b/node/src/db.rs new file mode 100644 index 00000000..e71de99f --- /dev/null +++ b/node/src/db.rs @@ -0,0 +1,657 @@ +// Postgres state-layer for the zkCoins server. +// +// Introduced in PR-A1 of the 3-PR Postgres migration series; the +// schema (see `node/migrations/*.sql`) and the typed API around +// `sqlx::PgPool` were defined there. PR-A2 wired the state-layer +// (`load_smt`, `load_mmr`, `load_latest_block`, `persist_state_tx`) +// into the bootstrap and scanner callback, fixing the cross-file +// inconsistency window flagged as issue #11. PR-A3 wired the +// `load_all_accounts` / `upsert_account` / `load_all_usernames` / +// `claim_username` / `resolve_username` calls into `AccountNode` and +// `UsernameStore`. The Phase-D rework dropped the +// `load_minting_num_pubkeys` / `upsert_minting_num_pubkeys` pair and +// the optimistic counter-bump step inside `commit_mint_tx`: the +// minting account's `num_pubkeys` is now derived from SMT membership +// at runtime (see `state::derive_num_pubkeys_from_smt`). Migration +// 0005 drops the `minting_meta` table outright. +// +// Choice of `sqlx::query` (runtime checked) over `sqlx::query!` +// (compile-time checked): all SQL in this module is short, hand- +// written, and exercised end-to-end by the test suite. Going with +// runtime-checked queries avoids forcing every contributor — and the +// CI Coverage-Gate job — to either run a Postgres container at build +// time or sync an `.sqlx/` offline cache. The trade-off is a slightly +// later failure mode for schema drift, which the tests catch on the +// first run. + +use sqlx::{postgres::PgPoolOptions, PgPool}; +use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes, HashDigest}; + +/// Connect to `url` and run every migration in `./migrations` against +/// the pool. Returns the live pool on success. +/// +/// Used in PR-A2 from `main.rs::main` before any state load. +pub async fn connect_and_migrate(url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(url) + .await?; + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| sqlx::Error::Migrate(Box::new(e)))?; + Ok(pool) +} + +// ---- State persistence (PR-A2) -------------------------------------------- + +/// Load the bincode-serialized Sparse Merkle Tree blob. +pub async fn load_smt(pool: &PgPool) -> Result>, sqlx::Error> { + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM smt_state WHERE id = 1") + .fetch_optional(pool) + .await?; + Ok(row.map(|(data,)| data)) +} + +/// Load the bincode-serialized Merkle Mountain Range blob. +pub async fn load_mmr(pool: &PgPool) -> Result>, sqlx::Error> { + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM mmr_state WHERE id = 1") + .fetch_optional(pool) + .await?; + Ok(row.map(|(data,)| data)) +} + +/// Load the 32-byte block hash of the last fully-processed block. +pub async fn load_latest_block(pool: &PgPool) -> Result, sqlx::Error> { + let row: Option<(Vec,)> = + sqlx::query_as("SELECT block_hash FROM latest_block WHERE id = 1") + .fetch_optional(pool) + .await?; + match row { + None => Ok(None), + Some((bytes,)) => { + // The schema does not enforce a 32-byte length (BYTEA is + // arbitrary), so we defensively reject anything else here + // rather than panicking deep in the scanner. In practice + // only `persist_state_tx` writes this column, and it takes + // a `&[u8; 32]`, so this branch should be unreachable. + let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + sqlx::Error::Decode( + format!( + "latest_block.block_hash has unexpected length {} (expected 32)", + bytes.len() + ) + .into(), + ) + })?; + Ok(Some(arr)) + } + } +} + +/// Atomically write SMT, MMR, `latest_block`, and (optionally) the +/// freshly-inserted `mmr_root_index` row in one transaction. +/// +/// The whole point of moving these blobs into Postgres is the +/// transactional guarantee — issue #11 documents the file-based +/// failure mode where a crash between `smt.bin`, `mmr.bin`, and +/// `latest_block.bin` leaves the three out of sync, and the next +/// start-up either replays already-processed commitments (dup +/// inserts into the SMT) or loses commitments outright. A single +/// `BEGIN; UPSERT; UPSERT; UPSERT; INSERT; COMMIT` removes that window. +/// +/// The Phase-C `mmr_root_index` write is part of the SAME transaction +/// because a crash between the state snapshot and the root_index INSERT +/// is catastrophic for replay healing: on restart the scanner resumes +/// from the saved `latest_block` and re-scans the same commit tx → +/// `state.update` runs again → SMT insert is idempotent but `mmr.append` +/// is NOT → MMR diverges → `prev_mmr_root` becomes a NEW key → fresh +/// `root_indices` entry written under the new key → the original +/// missing entry is never healed. Folding the INSERT into the same tx +/// means either both land or neither does; on a crash before COMMIT, +/// the next start-up re-runs `state.update` against the SAME unchanged +/// MMR and writes the SAME `(prev_mmr_root, smt_root, leaf_index)` — +/// `ON CONFLICT (prev_mmr_root) DO NOTHING` makes that a no-op on the +/// row that did land, or a fresh insert on the row that did not. +/// +/// `root_index_entry` is `Option<…>` because the first call from a +/// fresh database (no `State::update` has fired yet) has nothing to +/// write — only the bootstrap path which seeds an empty SMT/MMR would +/// hit that case in practice. Today every scanner-callback caller +/// passes `Some(...)`. +pub async fn persist_state_tx( + pool: &PgPool, + smt: &[u8], + mmr: &[u8], + latest_block: &[u8; 32], + root_index_entry: Option<(&HashDigest, &HashDigest, u64)>, +) -> Result<(), sqlx::Error> { + // `leaf_index` is a `u64` coming from `mmr.leaf_count()`, which is + // bounded by the total inscription count (≪ 2^63 in practice). The + // cast is infallible on 64-bit targets, which is our only deployment + // target (Linux x86_64 / aarch64). + let root_index_bytes = root_index_entry.map(|(prev_root, smt_root, leaf_index)| { + ( + digest_to_bytes(prev_root), + digest_to_bytes(smt_root), + leaf_index as i64, + ) + }); + + let mut tx = pool.begin().await?; + sqlx::query( + "INSERT INTO smt_state (id, data, updated_at) \ + VALUES (1, $1, NOW()) \ + ON CONFLICT (id) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(smt) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO mmr_state (id, data, updated_at) \ + VALUES (1, $1, NOW()) \ + ON CONFLICT (id) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(mmr) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO latest_block (id, block_hash, updated_at) \ + VALUES (1, $1, NOW()) \ + ON CONFLICT (id) DO UPDATE \ + SET block_hash = EXCLUDED.block_hash, updated_at = EXCLUDED.updated_at", + ) + .bind(&latest_block[..]) + .execute(&mut *tx) + .await?; + if let Some((prev_bytes, smt_bytes, leaf_i64)) = root_index_bytes { + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index, created_at) \ + VALUES ($1, $2, $3, NOW()) \ + ON CONFLICT (prev_mmr_root) DO NOTHING", + ) + .bind(&prev_bytes[..]) + .bind(&smt_bytes[..]) + .bind(leaf_i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/// Phase-E atomic helper used by `mint_handler` after a successful +/// broadcast: writes the SMT, MMR, `mmr_root_index` row AND advances +/// the `pending_inscriptions` row to `complete` — all in one +/// transaction. Leaves `latest_block` untouched (the scanner is the +/// sole writer; the freshly broadcast inscription has not been mined +/// yet, so the mint handler has no business overwriting the resume +/// marker). +/// +/// ## Crash-recovery contract (the BLOCKER fix) +/// +/// The previous two-step shape (`persist_state_without_block_tx` then +/// a standalone `update_pending_status(... COMPLETE)`) opened a crash +/// window between the SMT/MMR/root_index COMMIT and the mark-complete +/// UPDATE: on restart, `State::load_from_pg` rebuilt in-memory state +/// WITH the new leaf, but the row was still `reveal_broadcast`. When +/// the scanner later re-scanned the block, `should_skip_scanner_state_update` +/// returned `false` and the callback fell through to `state.update` → +/// `mmr.append` appended the same leaf a second time, diverging the +/// MMR root. +/// +/// Folding the row advance into the same transaction closes the +/// window: either the SMT/MMR/root_index AND the `complete` row land +/// together, or none of them do. Scanner re-scan after a successful +/// commit observes `complete` and short-circuits cleanly; scanner re-scan +/// after a rolled-back commit observes `reveal_broadcast` and integrates +/// the inscription itself (the in-memory mutation was performed against +/// the live `Arc>` but the COMMIT was atomic, so the +/// caller's outer reaction to the Err propagation must be to NOT trust +/// the in-memory snapshot — see `mint_handler`'s 503 path). +/// +/// The UPDATE has a guard `status <> 'complete'` so a re-run on an +/// already-complete row is a no-op and does not bump `updated_at`, +/// keeping the audit trail tight. +/// +/// ## Arguments +/// +/// * `smt` / `mmr` — bincode blobs going into the singleton rows. +/// * `root_index_entry` — `Some((prev_mmr_root, smt_root, leaf_index))` +/// for the freshly-appended leaf. `None` is accepted for symmetry +/// with `persist_state_tx` but `mint_handler` always passes `Some` +/// because every successful `state.update` produces a new root entry. +/// * `commit_txid` — raw 32-byte little-endian commit txid of the +/// inscription, matching the `pending_inscriptions.commit_txid` +/// column. +pub async fn persist_state_and_mark_complete_tx( + pool: &PgPool, + smt: &[u8], + mmr: &[u8], + root_index_entry: Option<(&HashDigest, &HashDigest, u64)>, + commit_txid: &[u8], +) -> Result<(), sqlx::Error> { + // See `persist_state_tx` for why the `u64 -> i64` cast is infallible + // on every target we ship. + let root_index_bytes = root_index_entry.map(|(prev_root, smt_root, leaf_index)| { + ( + digest_to_bytes(prev_root), + digest_to_bytes(smt_root), + leaf_index as i64, + ) + }); + + let mut tx = pool.begin().await?; + sqlx::query( + "INSERT INTO smt_state (id, data, updated_at) \ + VALUES (1, $1, NOW()) \ + ON CONFLICT (id) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(smt) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO mmr_state (id, data, updated_at) \ + VALUES (1, $1, NOW()) \ + ON CONFLICT (id) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(mmr) + .execute(&mut *tx) + .await?; + if let Some((prev_bytes, smt_bytes, leaf_i64)) = root_index_bytes { + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index, created_at) \ + VALUES ($1, $2, $3, NOW()) \ + ON CONFLICT (prev_mmr_root) DO NOTHING", + ) + .bind(&prev_bytes[..]) + .bind(&smt_bytes[..]) + .bind(leaf_i64) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "UPDATE pending_inscriptions \ + SET status = $1, updated_at = NOW() \ + WHERE commit_txid = $2 AND status <> $1", + ) + .bind(PENDING_STATUS_COMPLETE) + .bind(commit_txid) + .execute(&mut *tx) + .await?; + tx.commit().await +} + +// ---- Account persistence (PR-A3) ------------------------------------------ + +/// Load every `(address, data)` pair from the `accounts` table. +/// +/// Used at boot in PR-A3 to rebuild the in-memory `AccountNode` +/// map. Returns an empty vector if the table is empty. +pub async fn load_all_accounts(pool: &PgPool) -> Result, Vec)>, sqlx::Error> { + let rows: Vec<(Vec, Vec)> = + sqlx::query_as("SELECT address, data FROM accounts ORDER BY address") + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Upsert a single account row. The bincode blob in `data` is +/// considered authoritative — concurrent writers must serialize at +/// the application layer (`Arc>` in main.rs). +pub async fn upsert_account(pool: &PgPool, address: &[u8], data: &[u8]) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO accounts (address, data, updated_at) \ + VALUES ($1, $2, NOW()) \ + ON CONFLICT (address) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(address) + .bind(data) + .execute(pool) + .await?; + Ok(()) +} + +// ---- Username persistence (PR-A3) ----------------------------------------- + +/// Load every `(name, address)` pair from the `usernames` table. +pub async fn load_all_usernames(pool: &PgPool) -> Result)>, sqlx::Error> { + let rows: Vec<(String, Vec)> = + sqlx::query_as("SELECT name, address FROM usernames ORDER BY name") + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Attempt to claim `name` for `address`. Returns `Ok(true)` on a +/// fresh claim, `Ok(false)` if the name is already taken (no row +/// inserted, existing row left untouched). The `ON CONFLICT DO +/// NOTHING` makes this race-free at the SQL level. +pub async fn claim_username( + pool: &PgPool, + name: &str, + address: &[u8], +) -> Result { + let result = sqlx::query( + "INSERT INTO usernames (name, address, created_at) \ + VALUES ($1, $2, NOW()) \ + ON CONFLICT (name) DO NOTHING", + ) + .bind(name) + .bind(address) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Resolve a username to its bound address. Returns `Ok(None)` if +/// the name is not registered. +/// +/// Currently unused on the read path — `UsernameStore` keeps the full +/// `name → address` map in memory after the bootstrap `load_all_usernames` +/// call, and `resolve` / `get_username` answer locally. Kept exposed +/// so a future `lnurl`-style read-through cache can call it directly +/// without re-introducing a `HashMap` mirror. +#[allow(dead_code)] // re-added when a read-through caller lands +pub async fn resolve_username(pool: &PgPool, name: &str) -> Result>, sqlx::Error> { + let row: Option<(Vec,)> = sqlx::query_as("SELECT address FROM usernames WHERE name = $1") + .bind(name) + .fetch_optional(pool) + .await?; + Ok(row.map(|(addr,)| addr)) +} + +// ---- Minting commit transaction (Phase D) --------------------------------- + +/// Atomically upsert every account row mutated by a successful mint. +/// +/// Phase D removed the optimistic `minting_meta.num_pubkeys` counter +/// bump that used to sit at the head of this transaction: the +/// minting-account `num_pubkeys` is now derived from SMT membership at +/// runtime (`state::derive_num_pubkeys_from_smt`), so the only DB-side +/// work left is the per-account UPSERT bundle. The signature still +/// returns `Result<(), sqlx::Error>` to keep the call-site shape +/// symmetric with the other helpers; the `bool` "race lost" +/// discriminator on the old API is gone because the in-process +/// concurrency gate has moved out of Postgres (see `mint_handler` for +/// the new gate). +/// +/// All UPSERTs share one transaction so the bundle is atomic even on +/// a partial DB failure — either every recipient + the mutated minting +/// account land, or none do. +pub async fn commit_mint_tx(pool: &PgPool, accounts: &[(&[u8], &[u8])]) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + for (address, data) in accounts { + sqlx::query( + "INSERT INTO accounts (address, data, updated_at) \ + VALUES ($1, $2, NOW()) \ + ON CONFLICT (address) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(*address) + .bind(*data) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +// ---- Pending inscription persistence (Phase B) ---------------------------- + +/// State-machine label persisted in `pending_inscriptions.status`. +/// +/// The four in-progress states (`constructed`, `commit_broadcast`, +/// `reveal_broadcast`) track the publisher's progress through the +/// commit + reveal broadcast pair. `complete` is terminal-success; +/// `failed` is reserved for future use (today the resumer treats +/// every non-complete row as retryable). +pub const PENDING_STATUS_CONSTRUCTED: &str = "constructed"; +pub const PENDING_STATUS_COMMIT_BROADCAST: &str = "commit_broadcast"; +pub const PENDING_STATUS_REVEAL_BROADCAST: &str = "reveal_broadcast"; +pub const PENDING_STATUS_COMPLETE: &str = "complete"; + +/// In-memory representation of a `pending_inscriptions` row loaded by +/// [`load_pending_in_progress`]. The blob columns are returned raw — +/// callers deserialize via the same `bitcoin::consensus::deserialize` +/// shape used at write time. +#[derive(Debug, Clone)] +pub struct PendingInscriptionRow { + pub id: i64, + pub commit_txid: Vec, + pub status: String, + pub commitment: Vec, + pub commit_tx: Vec, + pub reveal_tx: Vec, + pub commit_output_value: i64, +} + +/// Insert a fresh `constructed` row before the publisher attempts the +/// first commit broadcast. `commit_txid` is the deterministic txid of +/// the supplied `commit_tx` bytes; callers compute it once and pass it +/// in so retries can match the UNIQUE constraint. +/// +/// On UNIQUE-violation (a previous attempt persisted the same pair and +/// crashed before completing), the function returns `Ok(false)` so the +/// caller can carry on with the existing row instead of double- +/// inserting. Every other DB error propagates. +pub async fn insert_pending_inscription( + pool: &PgPool, + commit_txid: &[u8], + commitment: &[u8], + commit_tx: &[u8], + reveal_tx: &[u8], + commit_output_value: i64, +) -> Result { + let result = sqlx::query( + "INSERT INTO pending_inscriptions \ + (commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (commit_txid) DO NOTHING", + ) + .bind(commit_txid) + .bind(PENDING_STATUS_CONSTRUCTED) + .bind(commitment) + .bind(commit_tx) + .bind(reveal_tx) + .bind(commit_output_value) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Advance a row to the supplied status. The caller is responsible for +/// passing a status that the CHECK constraint accepts — using the +/// `PENDING_STATUS_*` constants guarantees that. +pub async fn update_pending_status( + pool: &PgPool, + commit_txid: &[u8], + status: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE pending_inscriptions \ + SET status = $1, updated_at = NOW() \ + WHERE commit_txid = $2", + ) + .bind(status) + .bind(commit_txid) + .execute(pool) + .await?; + Ok(()) +} + +/// Look up the current `status` value for a `pending_inscriptions` row +/// keyed by its `commit_txid`. Returns `Ok(None)` when no row exists +/// (an external inscription that never went through this server's mint +/// flow, e.g. an out-of-band manual recovery via the `recover_inscription` +/// CLI in PR #106, or a fresh database). +/// +/// Phase E (this commit) wires `mint_handler` to advance `state.update` +/// synchronously after the on-chain broadcast succeeds and then mark +/// the row `complete`. The scanner uses this lookup to decide whether +/// it can skip its own `state.update` call when it later observes the +/// same commit on chain: a `complete` row means the SMT/MMR already +/// hold the inscription's entry and a second `smt.insert` / `mmr.append` +/// would either no-op (idempotent SMT path on identical key+value) or +/// — worse — diverge the MMR if any byte differs. Any other status, +/// including a missing row, means the scanner remains responsible for +/// integrating the inscription. +/// +/// The `commit_txid` argument is the raw 32-byte little-endian txid of +/// the inscription's commit transaction, identical to the `commit_txid` +/// column written by `insert_pending_inscription`. +pub async fn pending_inscription_status_by_commit_txid( + pool: &PgPool, + commit_txid: &[u8], +) -> Result, sqlx::Error> { + let row: Option<(String,)> = + sqlx::query_as("SELECT status FROM pending_inscriptions WHERE commit_txid = $1") + .bind(commit_txid) + .fetch_optional(pool) + .await?; + Ok(row.map(|(status,)| status)) +} + +/// Load every row whose status is not `complete`, ordered by `id` so +/// the resumer walks them in insertion order. The partial index +/// `pending_inscriptions_status_idx` keeps this scan O(pending), not +/// O(total). +pub async fn load_pending_in_progress( + pool: &PgPool, +) -> Result, sqlx::Error> { + // Tuple layout: (id, commit_txid, status, commitment, commit_tx, + // reveal_tx, commit_output_value). Aliased to keep the + // `sqlx::query_as` annotation under clippy's `type_complexity` + // threshold. + type RawRow = (i64, Vec, String, Vec, Vec, Vec, i64); + let rows: Vec = sqlx::query_as( + "SELECT id, commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value \ + FROM pending_inscriptions \ + WHERE status <> 'complete' \ + ORDER BY id", + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map( + |(id, commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value)| { + PendingInscriptionRow { + id, + commit_txid, + status, + commitment, + commit_tx, + reveal_tx, + commit_output_value, + } + }, + ) + .collect()) +} + +// ---- MMR root index persistence (Phase C) --------------------------------- + +/// Insert a single `(prev_mmr_root) -> (smt_root, leaf_index)` row. +/// +/// Called from the scanner callback right after `State::update` +/// successfully appended a new MMR leaf. `ON CONFLICT DO NOTHING` makes +/// replays idempotent: an MMR append is monotonic, so the same +/// `prev_mmr_root` key cannot legitimately resolve to two distinct +/// `(smt_root, leaf_index)` tuples — the first writer's value is +/// authoritative and a re-entrant retry (e.g. a scanner re-scan after a +/// crash that already persisted this entry) is a no-op. +/// +/// `leaf_index` is the in-memory `usize` from `mmr.leaf_count()`. We +/// cast through `i64` because Postgres has no unsigned BIGINT — the +/// load path rejects negative values, so this round-trip is safe up to +/// `i64::MAX`, well above any plausible MMR depth. +pub async fn insert_root_index( + pool: &PgPool, + prev_root: &HashDigest, + smt_root: &HashDigest, + leaf_index: u64, +) -> Result<(), sqlx::Error> { + let prev_bytes = digest_to_bytes(prev_root); + let smt_bytes = digest_to_bytes(smt_root); + // MMR leaf_index is bounded by total inscription count (≪ 2^63 in + // practice); the cast is infallible on 64-bit targets which is our + // only deployment target. + let leaf_i64 = leaf_index as i64; + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index, created_at) \ + VALUES ($1, $2, $3, NOW()) \ + ON CONFLICT (prev_mmr_root) DO NOTHING", + ) + .bind(&prev_bytes[..]) + .bind(&smt_bytes[..]) + .bind(leaf_i64) + .execute(pool) + .await?; + Ok(()) +} + +/// Load every `(prev_mmr_root, smt_root, leaf_index)` row from the +/// `mmr_root_index` table, ordered by `leaf_index` so the caller can +/// rebuild the in-memory map deterministically (and so the highest +/// `leaf_index` entry — used to restore `State::prev_mmr_root` — is +/// always the last element). +/// +/// Returns an empty vector when the table has never been written +/// (fresh database). Length / digest decoding mirrors the defensive +/// branch in [`load_latest_block`]: 32 bytes for each digest, with a +/// `sqlx::Error::Decode` surface on length mismatch rather than a +/// panic deep in the bootstrap. +pub async fn load_root_indices( + pool: &PgPool, +) -> Result, sqlx::Error> { + let rows: Vec<(Vec, Vec, i64)> = sqlx::query_as( + "SELECT prev_mmr_root, smt_root, leaf_index FROM mmr_root_index ORDER BY leaf_index", + ) + .fetch_all(pool) + .await?; + let mut out = Vec::with_capacity(rows.len()); + for (prev_bytes, smt_bytes, leaf_i64) in rows { + let prev_arr: [u8; 32] = prev_bytes.as_slice().try_into().map_err(|_| { + sqlx::Error::Decode( + format!( + "mmr_root_index.prev_mmr_root has unexpected length {} (expected 32)", + prev_bytes.len() + ) + .into(), + ) + })?; + let smt_arr: [u8; 32] = smt_bytes.as_slice().try_into().map_err(|_| { + sqlx::Error::Decode( + format!( + "mmr_root_index.smt_root has unexpected length {} (expected 32)", + smt_bytes.len() + ) + .into(), + ) + })?; + if leaf_i64 < 0 { + return Err(sqlx::Error::Decode( + format!( + "mmr_root_index.leaf_index out of u64 range: {} (must be >= 0)", + leaf_i64 + ) + .into(), + )); + } + out.push(( + digest_from_bytes(&prev_arr), + digest_from_bytes(&smt_arr), + leaf_i64 as u64, + )); + } + Ok(out) +} + +#[cfg(test)] +#[path = "db_tests.rs"] +mod tests; diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs new file mode 100644 index 00000000..7d4f5bb7 --- /dev/null +++ b/node/src/db_tests.rs @@ -0,0 +1,733 @@ +// Postgres state-layer tests for `db.rs`. +// +// Strategy: every test gets its own Postgres 17 container via +// `testcontainers_modules::postgres::Postgres`. Per-test isolation is +// the simplest model — no shared state, no `truncate_all` ordering, +// no risk of cross-test contamination. The container boot is ~3-5 s +// each and the suite runs single-threaded under +// `--test-threads=1` (mirrors the rest of the server test gate), so +// the total wall time stays comfortably below a minute even with the +// per-test container. +// +// Migrations are applied via `db::connect_and_migrate`, the same code +// path the production bootstrap will exercise in PR-A2. + +use super::*; +use sqlx::Row; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; + +/// Start a fresh `postgres:17` container and connect a migrated pool +/// to it. The container handle is returned alongside the pool so the +/// caller can keep it alive for the duration of the test — dropping +/// it tears the container down. +async fn setup_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +#[tokio::test] +async fn connect_and_migrate_creates_all_tables() { + let (pool, _container) = setup_pool().await; + // Introspect via `information_schema.tables` — works on any + // Postgres 9+ and avoids hard-coding pg_catalog quirks. + let rows = sqlx::query( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' \ + ORDER BY table_name", + ) + .fetch_all(&pool) + .await + .expect("introspection query failed"); + let names: Vec = rows.into_iter().map(|r| r.get::(0)).collect(); + // _sqlx_migrations is created implicitly by sqlx::migrate!. + // `pending_inscriptions` lands via 0003_pending_inscriptions.sql + // (Phase B). `mmr_root_index` lands via 0004_mmr_root_index.sql + // (Phase C). `minting_meta` is created by 0002 then dropped by + // 0005 (Phase D), so it is absent from the final schema. + assert_eq!( + names, + vec![ + "_sqlx_migrations".to_string(), + "accounts".to_string(), + "latest_block".to_string(), + "mmr_root_index".to_string(), + "mmr_state".to_string(), + "pending_inscriptions".to_string(), + "smt_state".to_string(), + "usernames".to_string(), + ] + ); +} + +#[tokio::test] +async fn load_smt_returns_none_initially() { + let (pool, _container) = setup_pool().await; + assert!(load_smt(&pool).await.expect("load_smt failed").is_none()); +} + +#[tokio::test] +async fn load_mmr_returns_none_initially() { + let (pool, _container) = setup_pool().await; + assert!(load_mmr(&pool).await.expect("load_mmr failed").is_none()); +} + +#[tokio::test] +async fn load_latest_block_returns_none_initially() { + let (pool, _container) = setup_pool().await; + assert!(load_latest_block(&pool) + .await + .expect("load_latest_block failed") + .is_none()); +} + +#[tokio::test] +async fn persist_state_tx_writes_smt_mmr_block_atomically() { + let (pool, _container) = setup_pool().await; + let smt = vec![0xAAu8; 64]; + let mmr = vec![0xBBu8; 128]; + let block = [0xCCu8; 32]; + persist_state_tx(&pool, &smt, &mmr, &block, None) + .await + .expect("persist_state_tx failed"); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(smt)); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(mmr)); + assert_eq!(load_latest_block(&pool).await.unwrap(), Some(block)); +} + +#[tokio::test] +async fn persist_state_tx_is_idempotent_on_conflict() { + let (pool, _container) = setup_pool().await; + let smt1 = vec![1u8; 16]; + let mmr1 = vec![2u8; 16]; + let block1 = [3u8; 32]; + persist_state_tx(&pool, &smt1, &mmr1, &block1, None) + .await + .unwrap(); + + let smt2 = vec![4u8; 32]; + let mmr2 = vec![5u8; 32]; + let block2 = [6u8; 32]; + persist_state_tx(&pool, &smt2, &mmr2, &block2, None) + .await + .unwrap(); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(smt2)); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(mmr2)); + assert_eq!(load_latest_block(&pool).await.unwrap(), Some(block2)); +} + +#[tokio::test] +async fn persist_state_tx_writes_root_index_in_same_transaction() { + // Phase-C atomicity guarantee: the `mmr_root_index` row rides + // along inside the same Postgres transaction as SMT/MMR/ + // latest_block. Closing the crash window between the snapshot + // and the standalone INSERT is the whole point — see the + // doc-comment on `persist_state_tx` for the heal-on-restart + // story. This test asserts all four landed from one call. + let (pool, _container) = setup_pool().await; + let smt = vec![0xAAu8; 64]; + let mmr = vec![0xBBu8; 128]; + let block = [0xCCu8; 32]; + let prev_root = zkcoins_program::hash::digest_from_bytes(&[0x10u8; 32]); + let smt_root = zkcoins_program::hash::digest_from_bytes(&[0x20u8; 32]); + persist_state_tx(&pool, &smt, &mmr, &block, Some((&prev_root, &smt_root, 7))) + .await + .expect("persist_state_tx failed"); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(smt)); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(mmr)); + assert_eq!(load_latest_block(&pool).await.unwrap(), Some(block)); + let entries = load_root_indices(&pool).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (prev_root, smt_root, 7)); +} + +#[tokio::test] +async fn persist_state_tx_root_index_on_conflict_does_nothing() { + // Re-scanning the same commit tx after a crash MUST be a no-op on + // the root_index row — `update()` is replayed against the same + // unchanged MMR and the (prev_mmr_root, smt_root, leaf_index) + // tuple is identical, so `ON CONFLICT (prev_mmr_root) DO NOTHING` + // keeps the original row authoritative. Belt-and-braces: the + // second call's `smt_root` differs to prove that the conflict + // branch genuinely takes the DO NOTHING path (otherwise the row + // would be silently mutated). + let (pool, _container) = setup_pool().await; + let smt = vec![1u8; 16]; + let mmr = vec![2u8; 16]; + let block = [3u8; 32]; + let prev_root = zkcoins_program::hash::digest_from_bytes(&[0x10u8; 32]); + let original_smt_root = zkcoins_program::hash::digest_from_bytes(&[0x20u8; 32]); + let different_smt_root = zkcoins_program::hash::digest_from_bytes(&[0x99u8; 32]); + + persist_state_tx( + &pool, + &smt, + &mmr, + &block, + Some((&prev_root, &original_smt_root, 0)), + ) + .await + .unwrap(); + persist_state_tx( + &pool, + &smt, + &mmr, + &block, + Some((&prev_root, &different_smt_root, 0)), + ) + .await + .unwrap(); + + let entries = load_root_indices(&pool).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].1, original_smt_root, + "second call must DO NOTHING, original row stays authoritative" + ); +} + +#[tokio::test] +async fn load_latest_block_rejects_wrong_length() { + // Defensive branch in `load_latest_block`: the application only + // writes 32-byte values via `persist_state_tx`, but BYTEA accepts + // any length. Insert a deliberately wrong-length row directly + // and assert the loader returns an `sqlx::Error::Decode` rather + // than panicking or silently truncating. + let (pool, _container) = setup_pool().await; + sqlx::query("INSERT INTO latest_block (id, block_hash) VALUES (1, $1)") + .bind(vec![0u8; 7]) + .execute(&pool) + .await + .unwrap(); + let err = load_latest_block(&pool) + .await + .expect_err("expected decode error"); + assert!( + matches!(err, sqlx::Error::Decode(_)), + "unexpected: {:?}", + err + ); +} + +#[tokio::test] +async fn load_all_accounts_returns_empty_initially() { + let (pool, _container) = setup_pool().await; + let rows = load_all_accounts(&pool).await.unwrap(); + assert!(rows.is_empty()); +} + +#[tokio::test] +async fn upsert_account_inserts_then_updates() { + let (pool, _container) = setup_pool().await; + let addr = vec![0xAAu8; 32]; + upsert_account(&pool, &addr, b"first").await.unwrap(); + let rows = load_all_accounts(&pool).await.unwrap(); + assert_eq!(rows, vec![(addr.clone(), b"first".to_vec())]); + + upsert_account(&pool, &addr, b"second").await.unwrap(); + let rows = load_all_accounts(&pool).await.unwrap(); + assert_eq!(rows, vec![(addr, b"second".to_vec())]); +} + +#[tokio::test] +async fn load_all_accounts_returns_all_inserted() { + let (pool, _container) = setup_pool().await; + let a1 = vec![0x01u8; 32]; + let a2 = vec![0x02u8; 32]; + let a3 = vec![0x03u8; 32]; + upsert_account(&pool, &a1, b"d1").await.unwrap(); + upsert_account(&pool, &a2, b"d2").await.unwrap(); + upsert_account(&pool, &a3, b"d3").await.unwrap(); + let mut rows = load_all_accounts(&pool).await.unwrap(); + rows.sort(); + assert_eq!( + rows, + vec![ + (a1, b"d1".to_vec()), + (a2, b"d2".to_vec()), + (a3, b"d3".to_vec()), + ] + ); +} + +#[tokio::test] +async fn load_all_usernames_returns_empty_initially() { + let (pool, _container) = setup_pool().await; + let rows = load_all_usernames(&pool).await.unwrap(); + assert!(rows.is_empty()); +} + +#[tokio::test] +async fn claim_username_returns_true_on_new() { + let (pool, _container) = setup_pool().await; + let addr = vec![0xAAu8; 32]; + let ok = claim_username(&pool, "alice", &addr).await.unwrap(); + assert!(ok); + let rows = load_all_usernames(&pool).await.unwrap(); + assert_eq!(rows, vec![("alice".to_string(), addr)]); +} + +#[tokio::test] +async fn claim_username_returns_false_on_conflict() { + let (pool, _container) = setup_pool().await; + let addr1 = vec![0xAAu8; 32]; + let addr2 = vec![0xBBu8; 32]; + assert!(claim_username(&pool, "alice", &addr1).await.unwrap()); + // Second claim with a different address must NOT overwrite. + assert!(!claim_username(&pool, "alice", &addr2).await.unwrap()); + // The original binding must survive. + let rows = load_all_usernames(&pool).await.unwrap(); + assert_eq!(rows, vec![("alice".to_string(), addr1)]); +} + +#[tokio::test] +async fn resolve_username_returns_address_for_claimed_name() { + let (pool, _container) = setup_pool().await; + let addr = vec![0xABu8; 32]; + claim_username(&pool, "bob", &addr).await.unwrap(); + let resolved = resolve_username(&pool, "bob").await.unwrap(); + assert_eq!(resolved, Some(addr)); +} + +#[tokio::test] +async fn resolve_username_returns_none_for_unknown() { + let (pool, _container) = setup_pool().await; + let resolved = resolve_username(&pool, "nobody").await.unwrap(); + assert!(resolved.is_none()); +} + +#[tokio::test] +async fn connect_and_migrate_propagates_connect_failure() { + // Bogus port → connect() fails fast (no Postgres listening) and + // the error propagates via `?`. Exercises the otherwise-unreached + // error branch in `connect_and_migrate`. + let err = connect_and_migrate("postgres://postgres:postgres@127.0.0.1:1/postgres") + .await + .expect_err("expected connect failure"); + assert!( + matches!(err, sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut), + "unexpected: {:?}", + err + ); +} + +/// Happy-path: `commit_mint_tx` upserts every account in the bundle in +/// a single transaction. Phase D collapsed the optimistic counter bump +/// out of this helper (the minting account's `num_pubkeys` is now +/// derived from SMT membership at runtime), so the only assertion left +/// is "every row in the input slice round-trips through `accounts`". +/// Multi-row exercises the loop body that the old single-account +/// fixture never visited. +#[tokio::test] +async fn commit_mint_tx_upserts_every_account_atomically() { + let (pool, _container) = setup_pool().await; + let addr_a = [0xAAu8; 32]; + let data_a = vec![0xA1u8; 8]; + let addr_b = [0xBBu8; 32]; + let data_b = vec![0xB1u8; 12]; + let accounts: Vec<(&[u8], &[u8])> = vec![(&addr_a[..], &data_a), (&addr_b[..], &data_b)]; + commit_mint_tx(&pool, &accounts) + .await + .expect("commit_mint_tx must succeed"); + + let rows = load_all_accounts(&pool).await.unwrap(); + let mut got: Vec<(Vec, Vec)> = rows.into_iter().collect(); + got.sort(); + let mut want = vec![ + (addr_a.to_vec(), data_a.clone()), + (addr_b.to_vec(), data_b.clone()), + ]; + want.sort(); + assert_eq!(got, want, "all accounts in the bundle must round-trip"); +} + +/// Second call with the same address overwrites the prior payload via +/// the `ON CONFLICT (address) DO UPDATE` branch. Exercises the +/// idempotent-replay shape the post-Phase-D mint flow relies on (a +/// concurrent receive between the snapshot and the commit will retry +/// with the latest serialized Account on the next mint). +#[tokio::test] +async fn commit_mint_tx_is_idempotent_on_conflict() { + let (pool, _container) = setup_pool().await; + let addr = [0xCCu8; 32]; + let first = vec![0x01u8; 16]; + let second = vec![0x02u8; 24]; + + commit_mint_tx(&pool, &[(&addr[..], &first)]) + .await + .expect("first commit"); + commit_mint_tx(&pool, &[(&addr[..], &second)]) + .await + .expect("second commit"); + + let rows = load_all_accounts(&pool).await.unwrap(); + assert_eq!(rows, vec![(addr.to_vec(), second.clone())]); +} + +/// Empty input slice → empty transaction, no UPSERTs, no error. Pins +/// the no-op shape so a future refactor that turns the empty case into +/// a panic or error surfaces here rather than at a live caller. +#[tokio::test] +async fn commit_mint_tx_with_empty_accounts_is_noop() { + let (pool, _container) = setup_pool().await; + commit_mint_tx(&pool, &[]) + .await + .expect("empty commit must succeed"); + let rows = load_all_accounts(&pool).await.unwrap(); + assert!(rows.is_empty()); +} + +#[tokio::test] +async fn connect_and_migrate_propagates_migration_failure() { + // Apply our migrations, then poison the `_sqlx_migrations` table + // so the next `connect_and_migrate` re-run sees a checksum + // mismatch and bails out via the `sqlx::Error::Migrate` branch. + // This is the only sqlx-native way to force a deterministic + // migration error without writing a second `.sql` file solely + // for the test (which would itself drift from the real schema). + let (pool, container) = setup_pool().await; + sqlx::query("UPDATE _sqlx_migrations SET checksum = $1") + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let err = connect_and_migrate(&url) + .await + .expect_err("expected migration failure"); + assert!( + matches!(err, sqlx::Error::Migrate(_)), + "unexpected: {:?}", + err + ); +} + +// ---- Phase E: pending_inscription_status_by_commit_txid ------------------ + +#[tokio::test] +async fn pending_inscription_status_by_commit_txid_returns_none_for_unknown_txid() { + // Scanner's pre-state.update lookup: an external / out-of-band + // inscription (not produced by this server's mint flow) has no + // `pending_inscriptions` row. The helper must return `None` so the + // scanner falls through to its normal state.update path instead of + // short-circuiting. + let (pool, _container) = setup_pool().await; + let status = pending_inscription_status_by_commit_txid(&pool, &[0xABu8; 32]) + .await + .expect("lookup must not error on missing row"); + assert!(status.is_none()); +} + +#[tokio::test] +async fn pending_inscription_status_by_commit_txid_returns_current_status() { + let (pool, _container) = setup_pool().await; + let commit_txid = [0xCDu8; 32]; + let commitment = b"test-commitment"; + let commit_tx = b"test-commit-tx"; + let reveal_tx = b"test-reveal-tx"; + insert_pending_inscription( + &pool, + &commit_txid, + commitment, + commit_tx, + reveal_tx, + 12_345, + ) + .await + .expect("insert must succeed"); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_CONSTRUCTED.to_string()) + ); + + update_pending_status(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST) + .await + .unwrap(); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_REVEAL_BROADCAST.to_string()) + ); + + update_pending_status(&pool, &commit_txid, PENDING_STATUS_COMPLETE) + .await + .unwrap(); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_COMPLETE.to_string()) + ); +} + +// ---- Phase E: persist_state_and_mark_complete_tx ------------------------- + +/// Helper: insert a `pending_inscriptions` row in the given starting +/// status so the atomic-tx tests can exercise the mark-complete step. +async fn seed_pending_row(pool: &PgPool, commit_txid: &[u8], status: &str) { + insert_pending_inscription( + pool, + commit_txid, + b"test-commitment", + b"test-commit-tx", + b"test-reveal-tx", + 12_345, + ) + .await + .expect("insert pending row"); + update_pending_status(pool, commit_txid, status) + .await + .expect("seed status"); +} + +#[tokio::test] +async fn persist_state_and_mark_complete_tx_writes_state_and_advances_row() { + // The atomic Phase-E helper writes SMT/MMR/root_index AND marks the + // pending row `complete` in one transaction. `latest_block` is left + // untouched (the scanner is the only legitimate writer). + let (pool, _container) = setup_pool().await; + let commit_txid = [0x55u8; 32]; + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + let smt = vec![0x11u8; 64]; + let mmr = vec![0x22u8; 128]; + let prev_root = zkcoins_program::hash::digest_from_bytes(&[0x40u8; 32]); + let smt_root = zkcoins_program::hash::digest_from_bytes(&[0x50u8; 32]); + + persist_state_and_mark_complete_tx( + &pool, + &smt, + &mmr, + Some((&prev_root, &smt_root, 3)), + &commit_txid, + ) + .await + .expect("persist_state_and_mark_complete_tx must succeed"); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(smt)); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(mmr)); + assert_eq!(load_latest_block(&pool).await.unwrap(), None); + let entries = load_root_indices(&pool).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (prev_root, smt_root, 3)); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_COMPLETE.to_string()) + ); +} + +#[tokio::test] +async fn persist_state_and_mark_complete_tx_preserves_existing_latest_block() { + // A scanner sweep landed a `latest_block` before the mint flow ever + // ran. The mint flow's atomic persist call must NOT rewind that + // pointer back to the genesis fallback — the helper is responsible + // for SMT/MMR/root_index/pending_inscriptions only. + let (pool, _container) = setup_pool().await; + let scanner_block = [0x77u8; 32]; + persist_state_tx(&pool, b"old-smt", b"old-mmr", &scanner_block, None) + .await + .unwrap(); + + let commit_txid = [0x66u8; 32]; + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + persist_state_and_mark_complete_tx(&pool, b"new-smt", b"new-mmr", None, &commit_txid) + .await + .unwrap(); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(b"new-smt".to_vec())); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(b"new-mmr".to_vec())); + assert_eq!( + load_latest_block(&pool).await.unwrap(), + Some(scanner_block), + "latest_block must remain untouched" + ); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_COMPLETE.to_string()) + ); +} + +#[tokio::test] +async fn persist_state_and_mark_complete_tx_accepts_no_root_index() { + // Mirror the `persist_state_tx` no-root-index branch: a call with + // `None` writes SMT + MMR + the row advance only. The + // mmr_root_index table stays empty, no error, latest_block untouched. + let (pool, _container) = setup_pool().await; + let commit_txid = [0x88u8; 32]; + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + persist_state_and_mark_complete_tx(&pool, b"smt-only", b"mmr-only", None, &commit_txid) + .await + .expect("no-root-index path must succeed"); + + assert_eq!(load_smt(&pool).await.unwrap(), Some(b"smt-only".to_vec())); + assert_eq!(load_mmr(&pool).await.unwrap(), Some(b"mmr-only".to_vec())); + assert!(load_root_indices(&pool).await.unwrap().is_empty()); + assert_eq!(load_latest_block(&pool).await.unwrap(), None); + assert_eq!( + pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .unwrap(), + Some(PENDING_STATUS_COMPLETE.to_string()) + ); +} + +#[tokio::test] +async fn persist_state_and_mark_complete_tx_rollback_on_failure_leaves_state_untouched() { + // The BLOCKER fix's load-bearing invariant: when the atomic tx + // fails, NOTHING lands on disk — not the SMT, not the MMR, not the + // root_index row, and crucially the pending row stays at its prior + // status (so scanner-replay will integrate the inscription and + // mark complete itself, never doubling up). + // + // We synthesize a tx failure by passing a `commit_txid` that + // violates the BYTEA length expectation: the `pending_inscriptions.commit_txid` + // column is `BYTEA NOT NULL` with no length check at the SQL + // level, so we instead force a constraint violation by writing the + // mmr_root_index row twice with conflicting payloads — wait, the + // helper uses ON CONFLICT DO NOTHING. The cleanest way to force a + // mid-tx failure is a leaf_index value that does not fit i64; the + // helper's `i64::try_from(u64)` returns `sqlx::Error::Encode` + // BEFORE the BEGIN, so that wouldn't actually exercise the + // rollback path. Instead, drop the pending_inscriptions table + // between the seed and the call so the UPDATE inside the tx + // surfaces a sqlx::Error and the BEGIN/COMMIT envelope rolls + // SMT/MMR back. + let (pool, _container) = setup_pool().await; + let commit_txid = [0x99u8; 32]; + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + // Pre-call snapshot: nothing in the state tables yet. + assert_eq!(load_smt(&pool).await.unwrap(), None); + assert_eq!(load_mmr(&pool).await.unwrap(), None); + + // Force a mid-tx failure by dropping `pending_inscriptions`. The + // UPDATE inside the helper will fail with "relation does not + // exist", the transaction rolls back, and the smt/mmr UPSERTs + // performed earlier in the same tx are undone. + sqlx::query("DROP TABLE pending_inscriptions") + .execute(&pool) + .await + .unwrap(); + + let prev_root = zkcoins_program::hash::digest_from_bytes(&[0xA0u8; 32]); + let smt_root = zkcoins_program::hash::digest_from_bytes(&[0xB0u8; 32]); + let res = persist_state_and_mark_complete_tx( + &pool, + b"would-be-smt", + b"would-be-mmr", + Some((&prev_root, &smt_root, 7)), + &commit_txid, + ) + .await; + assert!( + res.is_err(), + "atomic helper must surface the UPDATE failure" + ); + + // Post-call invariant: SMT/MMR did NOT advance. The + // BEGIN/COMMIT envelope rolled the earlier UPSERTs back. + assert_eq!( + load_smt(&pool).await.unwrap(), + None, + "atomic-tx rollback must leave smt_state untouched" + ); + assert_eq!( + load_mmr(&pool).await.unwrap(), + None, + "atomic-tx rollback must leave mmr_state untouched" + ); + assert!( + load_root_indices(&pool).await.unwrap().is_empty(), + "atomic-tx rollback must leave mmr_root_index untouched" + ); +} + +#[tokio::test] +async fn persist_state_and_mark_complete_tx_idempotent_on_already_complete_row() { + // The UPDATE guard `status <> 'complete'` keeps the helper + // idempotent: a retry against a row that is already `complete` + // re-runs the SMT/MMR/root_index UPSERTs (identical bytes, no-op + // semantically) but does NOT bump `updated_at` on the pending + // row. This matters for the audit log on scanner-replay edge + // cases where the mint flow's tx committed but a transient client + // error caused the caller to retry. + let (pool, _container) = setup_pool().await; + let commit_txid = [0xAAu8; 32]; + seed_pending_row(&pool, &commit_txid, PENDING_STATUS_REVEAL_BROADCAST).await; + + let prev_root = zkcoins_program::hash::digest_from_bytes(&[0x10u8; 32]); + let smt_root = zkcoins_program::hash::digest_from_bytes(&[0x20u8; 32]); + persist_state_and_mark_complete_tx( + &pool, + b"smt-1", + b"mmr-1", + Some((&prev_root, &smt_root, 1)), + &commit_txid, + ) + .await + .expect("first call must succeed"); + + // Record the row's updated_at after the first complete advance. + // We compare as text to avoid pulling chrono into the test build — + // TIMESTAMPTZ::text round-trips losslessly. + let (first_updated_at,): (String,) = + sqlx::query_as("SELECT updated_at::text FROM pending_inscriptions WHERE commit_txid = $1") + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .unwrap(); + + // A second invocation against the same (already-complete) row + // must succeed and leave the row's updated_at untouched. + persist_state_and_mark_complete_tx( + &pool, + b"smt-1", + b"mmr-1", + Some((&prev_root, &smt_root, 1)), + &commit_txid, + ) + .await + .expect("retry against already-complete row must succeed"); + + let (second_updated_at,): (String,) = + sqlx::query_as("SELECT updated_at::text FROM pending_inscriptions WHERE commit_txid = $1") + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!( + first_updated_at, second_updated_at, + "guarded UPDATE must NOT bump updated_at on already-complete row" + ); +} diff --git a/node/src/lib.rs b/node/src/lib.rs new file mode 100644 index 00000000..a3f43208 --- /dev/null +++ b/node/src/lib.rs @@ -0,0 +1,151 @@ +//! Library crate root for `node`. +//! +//! The server is primarily a binary (`main.rs`), but a few pieces of +//! it must be reachable from out-of-tree integration tests +//! (`node/tests/api_remote.rs` in particular). Exposing those +//! modules through a `lib` target keeps the binary side of the crate +//! untouched while letting the integration suite import the +//! `Capabilities` struct (for feature-gate detection on `/api/info`) +//! and the `CoinProof` struct used to decode the binary blobs +//! returned by `GET /api/proof/:id`. Other response types remain +//! reachable through their owning modules but are not currently +//! consumed by the suite. +//! +//! Everything declared here is also `use`d from `main.rs` so the +//! production binary keeps working with no change in behaviour. + +// `Account::new()` and `State::new()` are visible from the lib root +// after the binary → bin+lib split. Clippy's `new_without_default` +// lint did not fire while these types lived in a `bin` target — the +// lint is library-target sensitive. Adding `Default` impls would +// change the public API of the crate (downstream callers could pick +// `Default::default()` over `::new()`), which is out of scope for +// this refactor. Suppress at the crate root so the lint stays off +// for the new lib target while the existing call sites stay +// untouched. +#![allow(clippy::new_without_default)] + +pub mod account_node; +pub mod db; +pub mod publisher; +pub mod router; +pub mod runtime; +pub mod scanner; +pub mod scanner_runtime; +pub mod scanner_ws; +pub mod scanner_ws_parse; +pub mod state; +pub mod username; + +use crate::publisher::EsploraConfig; +use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; +use lazy_static::lazy_static; +use sqlx::PgPool; +use std::str::FromStr; +use zkcoins_program::hash::HashDigest; + +lazy_static! { + pub static ref NETWORK_CONFIG: EsploraConfig = { + let url = std::env::var("ESPLORA_URL") + .unwrap_or_else(|_| "https://mutinynet.com/api".to_string()); + let is_mainnet = std::env::var("IS_MAINNET") + .map(|v| v == "true") + .unwrap_or(false); + let network_name = std::env::var("NETWORK_NAME") + .unwrap_or_else(|_| if is_mainnet { "Mainnet".to_string() } else { "Mutinynet".to_string() }); + let ws_url = std::env::var("ESPLORA_WS_URL").ok(); + println!( + "Network config: {} ({}) ws={}", + network_name, + url, + ws_url.as_deref().unwrap_or(crate::scanner_ws::DEFAULT_ESPLORA_WS_URL) + ); + EsploraConfig { url, is_mainnet, network_name, ws_url, track_tx_timeout: None } + }; + + /// Domain used by the client to render `@`. + /// Distinct from `network_name` because the same Bitcoin network + /// (e.g. Mutinynet) is served from two isolated test worlds + /// (`dev.zkcoins.app`, `zkcoins.app`) — the client needs the + /// stage's external hostname, not the chain identifier. + pub static ref USERNAME_DOMAIN: String = { + let domain = std::env::var("USERNAME_DOMAIN").expect( + "USERNAME_DOMAIN env var must be set (e.g. `zkcoins.app` on PRD, \ + `dev.zkcoins.app` on DEV) — see #95 for the cross-network rationale", + ); + println!("Username domain: {}", domain); + domain + }; + + /// Publisher Bitcoin private key (32-byte hex). REQUIRED env var. + /// No fallback default exists: the previous `1234567890abcdef…` + /// placeholder was a publicly-known test key that drainer bots + /// swept within minutes of any on-chain top-up. The matching + /// public address is exposed by `GET /health/publisher`. + pub static ref PUBLISHER_KEY: String = std::env::var("PUBLISHER_KEY") + .expect("PUBLISHER_KEY env var must be set — no default exists. \ + Generate a 32-byte hex secret via `openssl rand -hex 32`."); + + /// Taproot publisher address derived once at startup from + /// `PUBLISHER_KEY` against the configured `NETWORK_CONFIG`. Folding + /// the secp256k1 work into `lazy_static` keeps the request path of + /// `publisher_health_handler` pure I/O (no per-request `SecretKey + /// ::from_str` / `Address::p2tr`) and removes a structurally + /// unreachable `Err` arm — `PUBLISHER_KEY` is validated here, so + /// an invalid key panics at startup, not on the first health + /// probe. Log-only, NOT a secret (the matching key lives in + /// `PUBLISHER_KEY`). + pub static ref PUBLISHER_ADDRESS: bitcoin::Address = { + let secp = Secp256k1::new(); + let sk = SecretKey::from_str(&PUBLISHER_KEY) + .expect("PUBLISHER_KEY must be a valid 32-byte hex secp256k1 secret"); + let key_pair = Keypair::from_secret_key(&secp, &sk); + let (xonly, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + bitcoin::Address::p2tr(&secp, xonly, None, NETWORK_CONFIG.network()) + }; + + /// Postgres connection string for the state-layer. Required; the + /// bootstrap refuses to start without it because there is no + /// sensible default for a database URL. + pub static ref DATABASE_URL: String = { + std::env::var("DATABASE_URL").expect( + "DATABASE_URL env var must be set (e.g. \ + postgresql://zkcoins:@postgres:5432/zkcoins)", + ) + }; +} + +/// Run `db::persist_state_tx` from a *synchronous* context that already +/// lives on a tokio worker thread. +/// +/// The scanner's `InscriptionCallback` is a sync `Fn`, but +/// `persist_state_tx` is async. The naive bridge — +/// `Handle::current().block_on(future)` — panics on the multi_thread +/// flavor. `block_in_place` is the documented sync-in-async escape +/// hatch for multi_thread runtimes. +/// +/// `root_index_entry` carries the freshly-inserted `mmr_root_index` +/// row so the Phase-C write lands in the SAME Postgres transaction as +/// the SMT/MMR/latest_block snapshot — see the doc-comment on +/// `db::persist_state_tx` for the heal-on-restart rationale. +pub fn persist_state_from_sync_context( + pool: &PgPool, + smt: &[u8], + mmr: &[u8], + latest_block: &[u8; 32], + root_index_entry: Option<(&HashDigest, &HashDigest, u64)>, +) -> Result<(), sqlx::Error> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(db::persist_state_tx( + pool, + smt, + mmr, + latest_block, + root_index_entry, + )) + }) +} + +#[cfg(test)] +#[path = "main_tests.rs"] +mod tests; diff --git a/node/src/main.rs b/node/src/main.rs new file mode 100644 index 00000000..2a5b4839 --- /dev/null +++ b/node/src/main.rs @@ -0,0 +1,371 @@ +//! Binary entrypoint for `node`. +//! +//! Modules live in `lib.rs`; this file only wires the bootstrap +//! (panic hook, Postgres pool, scanner task, REST listener) together. +//! Splitting the modules out of the binary lets out-of-tree +//! integration tests (`node/tests/api_remote.rs`) import the +//! handler response types and the `CoinProof` struct without +//! duplicating definitions or making the binary itself reachable +//! from a `cargo test --test ...` target. + +use node::account_node; +use node::db; +use node::publisher::EsploraConfig; +use node::runtime::start_rest_node; +use node::scanner_runtime::scan_for_inscriptions; +use node::scanner_ws::{run_scanner_ws, ScannerWsConfig}; +use node::state::State; +use node::username; +use node::{persist_state_from_sync_context, DATABASE_URL, NETWORK_CONFIG}; +use shared::commitment::Commitment; +use std::error::Error as StdError; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; + +// Postgres state-layer carries every persistent slice of server state +// after PR-A3: SMT / MMR / latest_block (PR-A2), accounts + usernames +// (PR-A3), and the minting account's `minting_meta.num_pubkeys` counter +// (PR-A3). The `accounts.bin`, `usernames.bin`, and +// `minting_num_pubkeys.bin` sibling files no longer exist, and the +// `atomic_write` helper that supported them is removed — the only +// remaining on-disk writes are the per-proof files under +// `${PROOFS_DIR:-./proofs}/{id}.bin`, owned by `ProofStore` in +// `router.rs`. +const ACCOUNT_NODE_ADDR: &str = "0.0.0.0:4242"; + +use bitcoin::hashes::Hash; +use bitcoin::BlockHash; +use esplora_client::{ + r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // A panic in any tokio worker — for example the bootstrap task that + // owns the HTTP listener — by default only kills that task. The rest + // of the process (notably the chain scanner) keeps running, the + // container stays `Up`, but the REST port is never bound. Cloudflare + // sees the upstream as alive-but-unresponsive and serves 502s for + // hours. Override the panic hook so any panic anywhere aborts the + // whole process; `restart: unless-stopped` in compose then crash- + // loops the container until the underlying cause is fixed, which is + // far easier to spot than a silent zombie. + let default_panic_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default_panic_hook(info); + std::process::exit(1); + })); + + // Open the Postgres pool and run pending migrations BEFORE any + // state load — `connect_and_migrate` is idempotent (sqlx tracks + // applied migrations in `_sqlx_migrations`) and so safe to call on + // every boot. A connect failure here aborts the whole bootstrap; + // there is no useful "degraded" mode without persistent state. + let pool = Arc::new( + db::connect_and_migrate(&DATABASE_URL) + .await + .expect("connect and migrate database"), + ); + println!("Connected to Postgres state-layer"); + + // Load existing state from Postgres (PR-A2). When SMT/MMR rows are + // absent (fresh DB), `load_from_pg` returns an empty State — + // equivalent to the previous file-based `State::new()` fallback. + let state = Arc::new(Mutex::new( + State::load_from_pg(&pool) + .await + .expect("load state from Postgres"), + )); + println!("Loaded State from Postgres"); + + // Reload AccountNode + UsernameStore from Postgres. The matching + // file-based loaders from PR-A1/A2 are gone — these two calls are + // the single source of truth after PR-A3. A DB error here aborts + // the bootstrap (same reasoning as the State load above). + let account_node = account_node::AccountNode::load_from_pg(Arc::clone(&state), &pool) + .await + .expect("load account server from Postgres"); + println!("Loaded AccountNode from Postgres"); + let username_store = username::UsernameStore::load_from_pg(&pool) + .await + .expect("load username store from Postgres"); + println!("Loaded UsernameStore from Postgres"); + + // Spawn the account_node as a separate task. A bootstrap error + // here (Postgres unreachable, listener bind failure) used to be + // `eprintln!`'d and dropped on the floor by this `tokio::spawn` + // block — the scanner kept running, the container stayed `Up`, + // and Cloudflare served 502s for hours because nothing was bound + // to the listener port. Aborting the whole process on bootstrap + // failure means the orchestrator crash-loops the container and + // alerting fires on the loop, matching the panic-hook behaviour + // above (zk-coins/node#89 round-2 MAJOR 2). + let pool_for_rest = Arc::clone(&pool); + tokio::spawn(async move { + if let Err(e) = start_rest_node( + account_node, + username_store, + ACCOUNT_NODE_ADDR, + pool_for_rest, + ) + .await + { + eprintln!("Account server error: {}", e); + std::process::exit(1); + } + }); + + // Try to load the latest block hash from Postgres or fall back to + // Esplora's current tip. The Postgres row is written atomically + // alongside the SMT/MMR snapshot in the scanner callback, which is + // the structural fix for issue #11. + let network_config: &EsploraConfig = &NETWORK_CONFIG; + let start_block_hash = match db::load_latest_block(&pool).await? { + Some(hash_bytes) => { + let hash = BlockHash::from_byte_array(hash_bytes); + println!("Resuming from previously saved block: {}", hash); + hash + } + None => { + println!("No saved block hash found, fetching latest from Esplora..."); + let client = EsploraAsyncClient::::from_builder(EsploraBuilder::new( + &network_config.url, + ))?; + let tip_hash = client.get_tip_hash().await?; + println!("Fetched latest tip hash from Esplora: {}", tip_hash); + tip_hash + } + }; + + // Clones for the scanner callback closure. + let pool_for_callback = Arc::clone(&pool); + let state_for_callback = Arc::clone(&state); + + // Event-driven chain ingestion (issue #84). The previous + // implementation polled `get_tip_hash` every 30 s, gating + // visibility on `/api/mint` and `/api/send` by up to a full + // block-time + poll-interval. `scanner_ws::run_scanner_ws` + // subscribes to the Esplora WebSocket stream and publishes + // each new tip into the bounded channel below; the scanner + // runtime drains the channel and walks forward through the + // block-status `next_best` chain between events. + // + // Channel depth = 64: plenty of headroom for the burst the + // initial `blocks` seed produces on subscribe (3-15 entries + // observed), bounded so a stuck consumer cannot grow the + // queue without bound. + let ws_config = ScannerWsConfig::from_env(); + println!( + "Event-driven scanner: WS={} (override via ESPLORA_WS_URL)", + ws_config.url + ); + let (tip_tx, tip_rx) = mpsc::channel::(64); + tokio::spawn(run_scanner_ws(ws_config, tip_tx)); + + scan_for_inscriptions(network_config, start_block_hash, &move |content_bytes: Vec, commit_txid, current_block_hash| { + println!("Received content size: {} bytes", content_bytes.len()); + + // Try to deserialize the content as a Commitment + match bincode::deserialize::(&content_bytes) { + Ok(commitment) => { + println!("Successfully deserialized as commitment"); + println!("Public key: {}", commitment.public_key); + + // Verify the commitment + if !commitment.verify() { + println!("Commitment verification failed, not adding to state"); + return; + } + println!("Commitment signature verified successfully"); + + // Phase E: if the in-process mint flow has already + // advanced this inscription through `state.update` (the + // `pending_inscriptions` row is `complete`), the + // scanner has nothing to do — its `state.update` call + // would be a no-op for the SMT (same key + same value + // → idempotent insert) but would diverge the MMR + // because `mmr.append` is monotonic. Skipping early + // also avoids a redundant `persist_state_tx`. Any + // other status (including a missing row, which covers + // out-of-band recovery inscriptions and inscriptions + // from a previous boot whose mint flow crashed before + // marking the row complete) falls through to the + // regular state.update path. + let commit_txid_bytes = commit_txid.as_byte_array(); + let pending_status = persist_pending_status_lookup( + &pool_for_callback, + commit_txid_bytes, + ); + if node::scanner::should_skip_scanner_state_update(pending_status.as_deref()) { + println!( + "scanner: commit {} already integrated by mint_handler — skipping state.update", + commit_txid + ); + return; + } + + // Capture the public_key before moving `commitment` into + // `state.update` so we can reference it in the Err arm. + let pubkey_for_log = commitment.public_key; + + // Lock-scope: do the state mutation, capture the bytes + // needed for persistence, then DROP THE LOCK before the + // async DB call. Holding `std::sync::Mutex` across an + // .await is unsound; also we want subsequent commitments + // to make progress while the previous tx commits. + let snapshot = { + let mut state_guard = state_for_callback.lock().unwrap(); + match state_guard.update_and_snapshot_for_persist(&[commitment]) { + Ok((new_root, smt_bytes, mmr_bytes, root_index_entry)) => { + Some((new_root, smt_bytes, mmr_bytes, root_index_entry)) + } + Err(e) => { + // Errors are logged but do NOT panic — the scanner is + // best-effort and we never want a single bad commitment + // (replay, client bug, or a re-scan after crash where + // the SMT already has this public_key with a different + // leaf value) to take the whole REST server down. The + // scanner advances to the next block regardless. + eprintln!( + "Skipping commitment for public_key {}: state.update failed: {}", + pubkey_for_log, e + ); + None + } + } + }; // mutex dropped here, BEFORE the async tx below + + if let Some((new_root, smt_bytes, mmr_bytes, root_index_entry)) = snapshot { + let block_hash_bytes = current_block_hash.to_byte_array(); + + // The callback runs INSIDE the async + // `scan_for_inscriptions` task on a multi_thread + // tokio runtime, so we cannot just + // `Handle::current().block_on(...)` — the docs say + // "may panic when called from a thread that is part + // of the current Tokio runtime" and on + // `#[tokio::main]` (multi_thread by default) it + // does panic the first time a real inscription is + // scanned. The fix is the documented + // `block_in_place(|| Handle::current().block_on(…))` + // pattern, encapsulated in + // `persist_state_from_sync_context`. + // + // The freshly-inserted `mmr_root_index` row rides + // along in the SAME transaction (Phase C). Folding + // it in here closes the crash window the previous + // two-call shape opened: a crash between the state + // snapshot and the standalone root_index INSERT + // resumed the scanner from a `latest_block` whose + // MMR already contained the new leaf, so the + // re-scanned commit advanced the MMR a second + // time, the new `prev_mmr_root` diverged, and the + // originally-missing row was never healed. With + // both writes atomic, a crash before COMMIT leaves + // the saved `latest_block` BEFORE this block; the + // re-scan replays `state.update` against the same + // unchanged MMR and writes the same row again + // (ON CONFLICT DO NOTHING is a no-op when it + // already landed). + let root_index_ref = root_index_entry + .as_ref() + .map(|(p, s, i)| (p, s, *i as u64)); + let persist_result = persist_state_from_sync_context( + &pool_for_callback, + &smt_bytes, + &mmr_bytes, + &block_hash_bytes, + root_index_ref, + ); + match persist_result { + Ok(()) => { + println!( + "Persisted state. New MMR root: {}", + hex::encode(zkcoins_program::hash::digest_to_bytes(&new_root)) + ); + // Phase E: if this commit came from our own + // mint flow but crashed between broadcast + // Ok and `state.update` (so the row is + // still at `reveal_broadcast`), the scanner + // has just completed the integration; mark + // the row `complete` so a future re-scan + // skips its state.update path. For rows + // that never existed (external / recovery + // inscriptions) the UPDATE simply affects + // zero rows, which is correct. + if pending_status.is_some() { + if let Err(e) = mark_pending_complete_from_sync_context( + &pool_for_callback, + commit_txid_bytes, + ) { + eprintln!( + "Failed to mark pending_inscriptions {} complete after scanner state.update: {}", + commit_txid, e + ); + } + } + } + Err(e) => eprintln!("persist_state_tx failed: {}", e), + } + } + } + Err(e) => { + // Print more detailed debug information + println!("Found inscription with our message but failed to deserialize as commitment\nError: {}", e); + } + } + }, tip_rx) + .await?; + + Ok(()) +} + +/// Synchronous wrapper around +/// [`db::pending_inscription_status_by_commit_txid`] for the scanner +/// callback's pre-`state.update` lookup (Phase E). +/// +/// Mirrors [`persist_state_from_sync_context`]: the scanner callback is +/// a sync `Fn` invoked from a multi_thread tokio worker, and the +/// `Handle::current().block_on(...)` bare form panics there. We use +/// `block_in_place` + `Handle::current().block_on(...)`, exactly as the +/// state-persist helper does. DB errors are swallowed by the call site +/// (the scanner falls through to its normal `state.update` path on +/// `None`), so this helper returns the inner `Option` directly +/// after logging any failure. +fn persist_pending_status_lookup(pool: &sqlx::PgPool, commit_txid_bytes: &[u8]) -> Option { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(db::pending_inscription_status_by_commit_txid( + pool, + commit_txid_bytes, + )) + .unwrap_or_else(|e| { + eprintln!( + "scanner: pending_inscriptions lookup for commit {} failed: {} (falling through to state.update)", + hex::encode(commit_txid_bytes), + e + ); + None + }) + }) +} + +/// Synchronous wrapper around +/// [`db::update_pending_status`] for the scanner callback's +/// post-`state.update` advance to `complete` (Phase E). +/// +/// Same multi_thread tokio bridging story as +/// [`persist_pending_status_lookup`]. Errors propagate to the caller so +/// the callback can log them with the right context line. +fn mark_pending_complete_from_sync_context( + pool: &sqlx::PgPool, + commit_txid_bytes: &[u8], +) -> Result<(), sqlx::Error> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(db::update_pending_status( + pool, + commit_txid_bytes, + db::PENDING_STATUS_COMPLETE, + )) + }) +} diff --git a/node/src/main_tests.rs b/node/src/main_tests.rs new file mode 100644 index 00000000..8f2e003f --- /dev/null +++ b/node/src/main_tests.rs @@ -0,0 +1,95 @@ +// Bootstrap-level tests for `main.rs`. +// +// Today the only thing here is regression coverage for the +// `block_in_place(block_on(...))` bridge used inside the scanner's +// synchronous `InscriptionCallback`. Without `block_in_place`, the +// naive `Handle::current().block_on(persist_state_tx(…))` form panics +// at runtime on the multi_thread tokio runtime (the default for +// `#[tokio::main]`) — and "runtime" here means "the first time the +// scanner sees a real inscription on Mutinynet". CI did not catch the +// original form because no integration test ever drove the sync +// callback through a real multi_thread worker; this test does. + +use super::*; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; + +/// Spin up a fresh `postgres:17` container, run all migrations, and +/// return the live pool. Mirrors `db_tests::setup_pool` but lives in +/// this file so the `main.rs` test module stays self-contained. +async fn setup_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +/// Regression test for the scanner-callback panic. +/// +/// The production scanner calls `persist_state_from_sync_context` +/// from a *synchronous* closure that runs *inline* on a multi_thread +/// tokio worker — the callback is invoked from inside an `async fn`, +/// so it executes on whichever worker thread is currently driving +/// the scanner task. The earlier form — `Handle::current().block_on(...)` +/// without `block_in_place` — panicked the first time a real +/// inscription was processed (see Tokio docs on `Handle::block_on`: +/// "may panic when called from a thread that is part of the current +/// Tokio runtime"). This test reproduces that exact shape: +/// +/// 1. Stand up a Postgres testcontainer + migrated pool. +/// 2. From an `async fn` body running on a multi_thread worker, +/// invoke a synchronous closure that calls +/// `persist_state_from_sync_context` — the same call shape as +/// `scanner_runtime` → `InscriptionCallback`. +/// 3. Re-read on the async side and assert the row landed. +/// +/// If somebody ever "simplifies" the helper back to a bare +/// `Handle::current().block_on(...)`, this test panics with +/// "Cannot start a runtime from within a runtime" / "may panic" and +/// CI catches it before it ships. +/// +/// `flavor = "multi_thread"` is *load-bearing*: `block_in_place` +/// itself panics on the current-thread flavor (`"can call blocking +/// only when running on the multi-threaded runtime"`). The +/// production bootstrap is multi_thread, so this test mirrors it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn persist_state_from_sync_context_works_from_sync_closure_on_multi_thread() { + let (pool, _container) = setup_pool().await; + + let smt = vec![0x11u8; 64]; + let mmr = vec![0x22u8; 128]; + let block = [0x33u8; 32]; + + // The scanner's `InscriptionCallback` is a sync `Fn(...)` that + // gets called from inside an `async fn`. We mimic that here: the + // outer `async fn` (this test body) is on a multi_thread worker; + // the closure below is a plain `FnOnce()` invoked inline, so it + // runs on that same worker thread — exactly the topology where + // bare `Handle::current().block_on(...)` panics. + let persist_from_sync_closure = || -> Result<(), sqlx::Error> { + persist_state_from_sync_context(&pool, &smt, &mmr, &block, None) + }; + persist_from_sync_closure() + .expect("persist_state_from_sync_context returned Err (regression: did block_in_place get removed?)"); + + // Round-trip verification: the helper actually wrote what we + // gave it. Without this assertion, a no-op stub would still pass + // the "no panic" half of the test. + assert_eq!(db::load_smt(&pool).await.unwrap(), Some(smt)); + assert_eq!(db::load_mmr(&pool).await.unwrap(), Some(mmr)); + assert_eq!(db::load_latest_block(&pool).await.unwrap(), Some(block)); +} diff --git a/node/src/publisher.rs b/node/src/publisher.rs new file mode 100644 index 00000000..7fdd7182 --- /dev/null +++ b/node/src/publisher.rs @@ -0,0 +1,1012 @@ +use bitcoin::{ + absolute::LockTime, + blockdata::{opcodes, script}, + hashes::Hash, + key::TapTweak, + locktime::absolute::Height, + script::PushBytesBuf, + secp256k1::{self, Secp256k1, SecretKey, XOnlyPublicKey}, + sighash::{Prevouts, SighashCache}, + taproot::{LeafVersion, TaprootBuilder}, + transaction::Version, + Address, Amount, Network, OutPoint, ScriptBuf, Sequence, TapLeafHash, TapSighashType, + Transaction, TxIn, TxOut, Txid, Weight, Witness, +}; + +use std::str::FromStr; +// Import specific Esplora client types +use esplora_client::{ + r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, +}; +use sqlx::PgPool; + +use crate::db; + +// Define a configuration struct for Esplora +#[derive(Clone, Debug)] +pub struct EsploraConfig { + pub url: String, + pub is_mainnet: bool, + pub network_name: String, + /// Esplora WebSocket endpoint used by the publisher's per-broadcast + /// `track-tx` wait (issue #84). `None` falls back to + /// `ESPLORA_WS_URL` (defaulting to `wss://mutinynet.com/api/v1/ws`); + /// tests inject an in-process URL to avoid hitting the real + /// upstream. + pub ws_url: Option, + /// Override for the per-broadcast `track-tx` safety-net (issue + /// #84). `None` uses the production default + /// `TRACK_TX_TIMEOUT_SECS = 30`; tests pass a short Duration so + /// the "broadcast genuinely failed" path (short WS timeout + + /// wiremock default 404 on `GET /tx/{txid}` ⇒ REST fallback returns + /// `None` ⇒ hard `WsError::Timeout`) does not stall the suite for + /// the full 30 s. + /// + /// Test-injection backdoor: production callers always leave this + /// `None` and inherit the 30 s safety-net. Hidden from the + /// rustdoc index (issue #84 review round 4 MINOR 5). + #[doc(hidden)] + pub track_tx_timeout: Option, +} + +impl EsploraConfig { + pub fn network(&self) -> Network { + if self.is_mainnet { + Network::Bitcoin + } else { + Network::Signet + } + } +} + +// Define constants for transaction identification +pub const INSCRIPTION_MARKER_PREFIX: &str = "4242"; + +const MAX_CHUNK_SIZE: usize = 520; +const MAX_MINING_ATTEMPTS: u32 = 400000; +const MIN_INSCRIPTION_AMOUNT: u64 = 800; + +/// Safety-net deadline for the per-broadcast `track-tx` WS wait +/// (issue #84). The publisher subscribes to the Esplora WS for the +/// commit txid before broadcasting the reveal, and proceeds the +/// moment the peer reports the commit as seen. If 30 s pass without +/// any track-tx event, the publisher issues a SINGLE REST +/// `GET /tx/{commit_txid}` fallback against the Esplora endpoint: +/// a 200 means the tx is in mempool / a block (the WS just missed +/// the frame, a regularly-observed Mutinynet failure mode) and the +/// publisher proceeds with the reveal; a 404 or any other error +/// propagates `WsError::Timeout`. The underlying rationale is +/// unchanged: a missing event without REST corroboration is still +/// a real upstream / network problem worth surfacing — never a +/// silent fallback to "broadcast the reveal anyway". +const TRACK_TX_TIMEOUT_SECS: u64 = 30; + +use crate::scanner_ws::DEFAULT_ESPLORA_WS_URL; + +const COMMIT_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(68); +const REVEAL_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(295); + +fn min_fee(tx: &Transaction, witness_weight: Option) -> u64 { + let mut weight = tx.weight().to_wu(); + if tx.input.iter().any(|utxo| utxo.witness.is_empty()) { + weight += witness_weight.unwrap().to_wu() + * tx.input + .iter() + .map(|utxo| utxo.witness.is_empty() as u64) + .sum::() + } + weight.div_ceil(4) +} + +pub fn inscription_txs( + commitment_data: &[u8], + publisher_address: &Address, + outpoints_with_sats: Vec<(OutPoint, u64)>, + publisher_key: &str, + config: &EsploraConfig, +) -> (Transaction, Transaction) { + // Create secp context and keys + let secp256k1 = Secp256k1::new(); + let sk = SecretKey::from_str(publisher_key).unwrap(); + let key_pair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); + let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + + let network = config.network(); + + println!("Publisher address: {}", publisher_address); + + let amount: u64 = outpoints_with_sats.iter().map(|(_, sats)| sats).sum(); + + // Build the script-path Taproot anchor that commits to the data. + // The same builder is used by `build_reveal_only`, ensuring the + // commit address (and therefore the reveal-spend script) matches + // exactly between the in-process happy path and out-of-band + // recovery callers. + let TaprootAnchor { + commit_address, + reveal_script, + taproot_spend_info, + } = build_taproot_anchor(commitment_data, public_key, network); + + // Create commit transaction + let mut commit_tx = Transaction { + version: Version(1), + lock_time: LockTime::Blocks(Height::ZERO), + input: outpoints_with_sats + .iter() + .map(|(outpoint, _)| TxIn { + previous_output: *outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { + value: Amount::ZERO, + script_pubkey: commit_address.script_pubkey(), + }], + }; + + let commit_fee = min_fee(&commit_tx, Some(COMMIT_TX_WITNESS_WEIGHT)); + commit_tx.output.first_mut().unwrap().value = Amount::from_sat(amount - commit_fee); + + // Create input TxOuts for signing + let input_txout = outpoints_with_sats + .iter() + .map(|(_, sats)| TxOut { + value: Amount::from_sat(*sats), + script_pubkey: publisher_address.script_pubkey(), + }) + .collect::>(); + + // Sign each input of the commit transaction + for idx in 0..outpoints_with_sats.len() { + let mut sighash_cache = SighashCache::new(&mut commit_tx); + let signature_hash = sighash_cache + .taproot_key_spend_signature_hash( + idx, + &Prevouts::All(&input_txout), + TapSighashType::Default, + ) + .unwrap(); + + // Sign with the tweaked keypair + let message = secp256k1::Message::from_digest_slice(&signature_hash[..]).unwrap(); + let keypair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); + let tweaked_keypair = keypair.tap_tweak(&secp256k1, None).to_keypair(); + let signature = secp256k1.sign_schnorr(&message, &tweaked_keypair); + + // Add the signature to the witness + let witness = sighash_cache.witness_mut(idx).unwrap(); + witness.clear(); + witness.push(signature.as_ref()); + } + + let commit_txid = commit_tx.compute_txid(); + let commit_output_value = commit_tx.output[0].value.to_sat(); + + let reveal_tx = build_reveal_only_inner( + commit_txid, + commit_output_value, + publisher_address, + &key_pair, + &reveal_script, + &taproot_spend_info, + &secp256k1, + ); + + (commit_tx, reveal_tx) +} + +/// Internal helper carrying the script-path anchor artefacts that both +/// `inscription_txs` and the recovery CLI need to reconstruct. +struct TaprootAnchor { + commit_address: Address, + reveal_script: ScriptBuf, + taproot_spend_info: bitcoin::taproot::TaprootSpendInfo, +} + +/// Builds the script-path Taproot anchor (commit address + reveal +/// script + spend info) from a commitment payload, the publisher's +/// x-only pubkey, and the target network. Pure / deterministic — the +/// same `(commitment_data, public_key, network)` triple always produces +/// the same anchor. +fn build_taproot_anchor( + commitment_data: &[u8], + public_key: XOnlyPublicKey, + network: Network, +) -> TaprootAnchor { + let secp256k1 = Secp256k1::new(); + + // Build a taproot script committing to the data + let mut script_builder = script::Builder::new() + .push_slice(public_key.serialize()) + .push_opcode(opcodes::all::OP_CHECKSIG) + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF); + + // Add the commitment data in chunks + for chunk in commitment_data.chunks(MAX_CHUNK_SIZE) { + let buffer = PushBytesBuf::try_from(chunk.to_vec()).unwrap(); + script_builder = script_builder.push_slice(buffer); + } + + let reveal_script = script_builder + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + + let taproot_spend_info = TaprootBuilder::new() + .add_leaf(0, reveal_script.clone()) + .unwrap() + .finalize(&secp256k1, public_key) + .unwrap(); + + let commit_address = Address::p2tr_tweaked(taproot_spend_info.output_key(), network); + + TaprootAnchor { + commit_address, + reveal_script, + taproot_spend_info, + } +} + +/// Reveal-only constructor used by both the in-process publisher path +/// (`inscription_txs`) and the out-of-band recovery CLI +/// (`bin/recover_inscription.rs`). +/// +/// Re-derives the script-path Taproot anchor from `commitment_data` +/// and the publisher key, then assembles + nonce-mines the reveal +/// transaction that spends the commit anchor's output[0] back to the +/// publisher address. The caller supplies the already-broadcast +/// `commit_txid` and the anchor output's value in sats — there is no +/// commit broadcast or commit signing on this path. +/// +/// Returns the mined reveal transaction together with the derived +/// commit address so the caller can sanity-check it against the +/// observed on-chain anchor. +pub fn build_reveal_only( + commit_txid: Txid, + commit_output_value: u64, + commitment_data: &[u8], + publisher_key: &str, + publisher_address: &Address, + network: Network, +) -> (Transaction, Address) { + let secp256k1 = Secp256k1::new(); + let sk = SecretKey::from_str(publisher_key).unwrap(); + let key_pair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); + let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + + let TaprootAnchor { + commit_address, + reveal_script, + taproot_spend_info, + } = build_taproot_anchor(commitment_data, public_key, network); + + let reveal_tx = build_reveal_only_inner( + commit_txid, + commit_output_value, + publisher_address, + &key_pair, + &reveal_script, + &taproot_spend_info, + &secp256k1, + ); + + (reveal_tx, commit_address) +} + +/// Inner reveal-construction loop shared by `inscription_txs` and +/// `build_reveal_only`. Takes the pre-derived anchor artefacts so we +/// only re-derive once per call site, matching the legacy code path. +#[allow(clippy::too_many_arguments)] +fn build_reveal_only_inner( + commit_txid: Txid, + commit_output_value: u64, + publisher_address: &Address, + key_pair: &secp256k1::Keypair, + reveal_script: &ScriptBuf, + taproot_spend_info: &bitcoin::taproot::TaprootSpendInfo, + secp256k1: &Secp256k1, +) -> Transaction { + // The reveal spends the commit anchor; mirror the prevout `TxOut` + // used for signing so the legacy and recovery paths produce a + // byte-identical witness for the same inputs. The scriptPubKey is + // derived directly from the tweaked output key (network-agnostic — + // P2TR scriptPubKey is `OP_1 <32-byte-output-key>` on every chain). + let commit_prevout = TxOut { + value: Amount::from_sat(commit_output_value), + script_pubkey: ScriptBuf::new_p2tr_tweaked(taproot_spend_info.output_key()), + }; + + // Create reveal transaction + let mut reveal_tx = Transaction { + version: Version(1), + lock_time: LockTime::from_consensus(0), + input: vec![TxIn { + previous_output: OutPoint::new(commit_txid, 0), + script_sig: script::Builder::new().into_script(), + witness: Witness::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }], + output: vec![TxOut { + value: Amount::ZERO, + script_pubkey: publisher_address.script_pubkey(), + }], + }; + + let reveal_fee = min_fee(&reveal_tx, Some(REVEAL_TX_WITNESS_WEIGHT)); + reveal_tx.output.first_mut().unwrap().value = + Amount::from_sat(commit_output_value - reveal_fee); + + // Mine the reveal transaction to have a txid starting with our marker + println!( + "Mining reveal transaction to start with {}...", + INSCRIPTION_MARKER_PREFIX + ); + let target_prefix = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap(); + + let control_block = taproot_spend_info + .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) + .unwrap(); + + for nonce in 0..MAX_MINING_ATTEMPTS { + // Update the nSequence for mining + reveal_tx.input[0].sequence = Sequence(nonce); + + // Sign the transaction with the new sequence + let mut sighash_cache = SighashCache::new(&mut reveal_tx); + let signature_hash = sighash_cache + .taproot_script_spend_signature_hash( + 0, + &Prevouts::All(&[&commit_prevout]), + TapLeafHash::from_script(reveal_script, LeafVersion::TapScript), + TapSighashType::Default, + ) + .unwrap(); + + let message = secp256k1::Message::from_digest_slice(&signature_hash[..]).unwrap(); + let signature = secp256k1.sign_schnorr(&message, key_pair); + + let witness = sighash_cache.witness_mut(0).unwrap(); + witness.clear(); + witness.push(signature.as_ref()); + witness.push(reveal_script.clone()); + witness.push(control_block.serialize()); + + // Check if the txid starts with our target prefix + let txid = reveal_tx.compute_txid(); + let txid_bytes = txid.as_byte_array(); + + if txid_bytes.starts_with(&target_prefix) { + println!("Found matching txid: {} with nSequence: {}", txid, nonce); + break; + } + + if nonce % 10000 == 0 { + println!("Tried {} nonces...", nonce); + } + + if nonce == MAX_MINING_ATTEMPTS - 1 { + println!("WARNING: Reached maximum attempts without finding a match"); + } + } + + reveal_tx +} + +/// Broadcasts the commit and reveal transactions to the Bitcoin +/// network via the Esplora REST API and waits for the commit +/// transaction to appear in the mempool before sending the reveal. +/// +/// The propagation gap used to be papered over by a fixed 5 s +/// `PROPAGATION_WAIT_SECS` async sleep; issue #84 replaces +/// that polling wait with a short-lived WebSocket subscription to +/// `{"action":"track-tx","data":""}` against the +/// Esplora WS endpoint, returning the moment the peer reports the +/// commit txid as seen. A 30 s safety-net (`TRACK_TX_TIMEOUT_SECS`) +/// caps the WS wait; if it elapses we issue ONE REST +/// `GET /tx/{commit_txid}` against the Esplora endpoint and treat a +/// 200 as success (the tx is in mempool / a block and the WS just +/// missed the frame, a regularly-observed Mutinynet failure mode). +/// A 404 propagates the original WS timeout — the broadcast genuinely +/// did not land. This is a single REST GET, NOT a poll loop; the +/// no-polling invariant from the `CONTRIBUTING.md` "No polling — +/// events only" section is preserved. +/// +/// The fallback fires on the OUTER `TRACK_TX_TIMEOUT_SECS` budget +/// (exposed via `TrackTxStream::wait` in `scanner_ws.rs`) — the +/// inner per-frame `TRACK_TX_FRAME_WATCHDOG` reconnect loop in +/// `scanner_ws.rs` is untouched. +/// +/// Order of operations is load-bearing: the `track-tx` subscription +/// MUST be established BEFORE the commit broadcast. Otherwise the +/// upstream may finish propagating the tx between +/// `client.broadcast(commit_tx)` and `subscribe_track_tx(...)`, and +/// the "tx in mempool" event would fire before any subscriber is +/// listening — wedging the wait for the full 30 s safety-net even +/// on the happy path. +pub async fn broadcast_inscription_txs( + config: &EsploraConfig, + commit_tx: &Transaction, + reveal_tx: &Transaction, +) -> Result<(Txid, Txid), Box> { + // Create an Esplora client + let builder = EsploraBuilder::new(&config.url); + let client = EsploraAsyncClient::::from_builder(builder)?; + + let commit_txid = commit_tx.compute_txid(); + let ws_url = config.ws_url.clone().unwrap_or_else(|| { + std::env::var("ESPLORA_WS_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_WS_URL.to_string()) + }); + let track_tx_timeout = config + .track_tx_timeout + .unwrap_or_else(|| std::time::Duration::from_secs(TRACK_TX_TIMEOUT_SECS)); + + // Subscribe to the `track-tx` WS BEFORE broadcasting the commit + // (issue #84). The previous ordering opened a race window between + // the REST broadcast and the WS subscribe: if the peer finished + // propagating the tx in that window, the event fired before any + // listener was attached. + println!( + "Subscribing to commit tx {} via WS ({}) before broadcast...", + commit_txid, ws_url + ); + let stream = crate::scanner_ws::subscribe_track_tx(&ws_url, commit_txid).await?; + + println!("Broadcasting commit transaction..."); + client.broadcast(commit_tx).await?; + println!("Commit transaction broadcast successfully: {}", commit_txid); + + // Wait for the commit txid to surface in the upstream mempool + // before broadcasting the reveal. Event-driven (issue #84), + // not a fixed sleep — see the function docstring for the design. + println!( + "Waiting for commit tx {} to appear in mempool via WS (deadline {:?})...", + commit_txid, track_tx_timeout + ); + match stream.wait(track_tx_timeout).await { + Ok(()) => {} + Err(crate::scanner_ws::WsError::Timeout) => { + // Mutinynet's public WS endpoint regularly goes 30-90 s + // between frames; a 30 s WS timeout therefore does NOT + // prove the tx is not on-chain. Issue ONE REST GET to + // distinguish "WS missed the frame" (tx is in + // mempool / a block → success) from "broadcast genuinely + // failed" (404 → propagate the original timeout). + // + // Single GET, NOT a poll loop — see the + // "No polling — events only" section in CONTRIBUTING.md. + println!( + "WS timeout for {}; falling back to esplora-REST GET /tx/{}", + commit_txid, commit_txid + ); + match client.get_tx(&commit_txid).await { + Ok(Some(_)) => { + println!( + "esplora-REST fallback confirmed commit tx {} is on-chain / in mempool", + commit_txid + ); + } + Ok(None) => { + println!( + "esplora-REST fallback: commit tx {} not found (404); broadcast genuinely failed", + commit_txid + ); + return Err(Box::new(crate::scanner_ws::WsError::Timeout)); + } + Err(e) => { + println!( + "esplora-REST fallback failed for {}: {}; propagating original WS timeout", + commit_txid, e + ); + return Err(Box::new(crate::scanner_ws::WsError::Timeout)); + } + } + } + Err(other) => return Err(other.into()), + } + + println!("Broadcasting reveal transaction..."); + client.broadcast(reveal_tx).await?; + let reveal_txid = reveal_tx.compute_txid(); + println!("Reveal transaction broadcast successfully: {}", reveal_txid); + + Ok((commit_txid, reveal_txid)) +} + +/// Fetches available UTXOs for the publisher address +pub async fn get_publisher_utxo( + publisher_address: &Address, + config: &EsploraConfig, + min_amount: Option, +) -> Result, Box> { + let builder = EsploraBuilder::new(&config.url); + let client = EsploraAsyncClient::::from_builder(builder)?; + + // Get all UTXOs for the address + let utxos = client.get_address_utxo(publisher_address.clone()).await?; + + // Find UTXOs with sufficient value + let required_amount = min_amount.unwrap_or(0); + let mut outpoints_with_sats = Vec::<(OutPoint, u64)>::new(); + let mut sats_amount_sum = 0; + + for utxo in utxos { + let sats = utxo.value.to_sat(); + outpoints_with_sats.push((OutPoint::new(utxo.txid, utxo.vout), sats)); + sats_amount_sum += sats; + } + + // Discard UTXOs if total amount is insufficient + if sats_amount_sum < required_amount { + outpoints_with_sats.clear(); + } + + Ok(outpoints_with_sats) +} + +/// Creates and broadcasts inscription transactions with the given commitment data. +/// +/// **Persistence contract (Phase B).** When `pool` is `Some`, the +/// constructed `(commit_tx, reveal_tx)` pair is persisted to the +/// `pending_inscriptions` table BEFORE the first broadcast attempt +/// and the row is walked through the `constructed → commit_broadcast +/// → reveal_broadcast → complete` state machine as each broadcast +/// lands. A crash anywhere in this sequence leaves a recoverable row +/// for [`resume_pending_inscriptions`] to re-drive on the next boot. +/// +/// When `pool` is `None` (out-of-band callers / unit tests that don't +/// need persistence), the function behaves exactly like the +/// pre-Phase-B version — no DB writes, no resume hooks. +pub async fn create_and_broadcast_inscription( + commitment_data: &[u8], + config: &EsploraConfig, + pool: Option<&PgPool>, +) -> Result<(Txid, Txid), Box> { + // Generate publisher address + let publisher_key = &*crate::PUBLISHER_KEY; + let secp256k1 = Secp256k1::new(); + let sk = SecretKey::from_str(publisher_key)?; + let key_pair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); + let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + let network = config.network(); + let publisher_address = Address::p2tr(&secp256k1, public_key, None, network); + println!("Publisher address: {}", publisher_address); + + // Fetch UTXOs + println!("Fetching UTXOs..."); + let outpoints_with_sats = + get_publisher_utxo(&publisher_address, config, Some(MIN_INSCRIPTION_AMOUNT)).await?; + + if outpoints_with_sats.is_empty() { + eprintln!( + "ERROR: No UTXOs found for publisher address {}. Fund it to continue.", + publisher_address + ); + return Err( + "No UTXOs available for inscription broadcast — publisher wallet is empty".into(), + ); + } + + // Log found UTXOs + for (outpoint, sats) in &outpoints_with_sats { + println!( + "Found UTXO: {}:{} with value {} sats", + outpoint.txid, outpoint.vout, sats + ); + } + + // Create the inscription transactions + let (commit_tx, reveal_tx) = inscription_txs( + commitment_data, + &publisher_address, + outpoints_with_sats, + publisher_key, + config, + ); + + // Print transaction IDs + let commit_txid = commit_tx.compute_txid(); + let reveal_txid = reveal_tx.compute_txid(); + println!("\nCommit TX ID: {}", commit_txid); + println!("Reveal TX ID: {}", reveal_txid); + + // Persist the (commit, reveal) pair BEFORE attempting any + // broadcast. Crash-recovery (Phase B) hinges on the row being on + // disk at every state-machine boundary — if we crash between + // construct and commit-broadcast we want the resumer to find the + // row and re-broadcast both; if we crash between commit and + // reveal we want the resumer to find the row and re-broadcast + // just the reveal. Both behaviours require the row already + // exists by the time the first network call returns. + if let Some(pool) = pool { + let commit_tx_bytes = bitcoin::consensus::serialize(&commit_tx); + let reveal_tx_bytes = bitcoin::consensus::serialize(&reveal_tx); + let commit_output_value = commit_tx.output[0].value.to_sat() as i64; + match db::insert_pending_inscription( + pool, + commit_txid.as_byte_array(), + commitment_data, + &commit_tx_bytes, + &reveal_tx_bytes, + commit_output_value, + ) + .await + { + Ok(true) => { + println!( + "Persisted pending_inscriptions row (constructed) for commit={}", + commit_txid + ); + } + Ok(false) => { + // UNIQUE-conflict: the same commit_txid is already on + // disk (a previous attempt persisted, then crashed + // before completing). The resumer will pick it up on + // the next boot; in the meantime we still want to try + // broadcasting now in case the operator hasn't + // restarted yet. + println!( + "pending_inscriptions row for commit={} already exists; proceeding with broadcast", + commit_txid + ); + } + Err(e) => { + eprintln!( + "Failed to persist pending_inscriptions row for {}: {}", + commit_txid, e + ); + return Err(format!("persist pending inscription: {}", e).into()); + } + } + } + + // Broadcast the transactions + match broadcast_inscription_txs_with_persistence(config, &commit_tx, &reveal_tx, pool).await { + Ok((commit_txid, reveal_txid)) => { + println!("Successfully broadcast transactions:"); + println!("Commit TXID: {}", commit_txid); + println!("Reveal TXID: {}", reveal_txid); + Ok((commit_txid, reveal_txid)) + } + Err(e) => { + println!("Failed to broadcast transactions: {}", e); + Err(e) + } + } +} + +/// Esplora returns this substring inside an `HttpResponse { status: +/// 400, message }` payload when the commit's input UTXO was already +/// spent — typically because a previous attempt's commit broadcast +/// landed even though our process crashed before recording the +/// success. The resume path treats this as "commit already on chain; +/// advance and proceed to reveal" instead of a hard failure. +fn is_inputs_missingorspent_error(err: &dyn std::error::Error) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("bad-txns-inputs-missingorspent") + || msg.contains("missing-inputs") + || msg.contains("txn-already-known") +} + +/// Same as [`broadcast_inscription_txs`] but, when `pool` is +/// `Some`, advances the matching `pending_inscriptions` row through +/// `commit_broadcast → reveal_broadcast → complete` as each broadcast +/// step succeeds. +/// +/// Status updates are best-effort: a DB-write failure after a +/// successful chain broadcast is logged but does NOT bubble back to +/// the caller — the chain is the source of truth, the row is +/// bookkeeping. If a status update fails, the next boot's resumer +/// will simply re-broadcast the next step (Esplora replies +/// `txn-already-known`) and advance the row then. +/// +/// The body is a transcription of [`broadcast_inscription_txs`] with +/// status-update hooks woven in at the three points where the chain +/// confirms a step. Keeping the two functions separate (rather than +/// having one take `Option<&PgPool>`) avoids changing the existing +/// public surface and keeps the pure-broadcast code path readable. +pub async fn broadcast_inscription_txs_with_persistence( + config: &EsploraConfig, + commit_tx: &Transaction, + reveal_tx: &Transaction, + pool: Option<&PgPool>, +) -> Result<(Txid, Txid), Box> { + let builder = EsploraBuilder::new(&config.url); + let client = EsploraAsyncClient::::from_builder(builder)?; + + let commit_txid = commit_tx.compute_txid(); + let commit_txid_bytes = *commit_txid.as_byte_array(); + let ws_url = config.ws_url.clone().unwrap_or_else(|| { + std::env::var("ESPLORA_WS_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_WS_URL.to_string()) + }); + let track_tx_timeout = config + .track_tx_timeout + .unwrap_or_else(|| std::time::Duration::from_secs(TRACK_TX_TIMEOUT_SECS)); + + println!( + "Subscribing to commit tx {} via WS ({}) before broadcast...", + commit_txid, ws_url + ); + let stream = crate::scanner_ws::subscribe_track_tx(&ws_url, commit_txid).await?; + + println!("Broadcasting commit transaction..."); + client.broadcast(commit_tx).await?; + println!("Commit transaction broadcast successfully: {}", commit_txid); + advance_pending_status( + pool, + &commit_txid_bytes, + db::PENDING_STATUS_COMMIT_BROADCAST, + ) + .await; + + println!( + "Waiting for commit tx {} to appear in mempool via WS (deadline {:?})...", + commit_txid, track_tx_timeout + ); + match stream.wait(track_tx_timeout).await { + Ok(()) => {} + Err(crate::scanner_ws::WsError::Timeout) => { + // Mutinynet's public WS endpoint regularly goes 30-90 s + // between frames; the REST fallback distinguishes "WS + // missed the frame" from a genuine broadcast failure. + // Same shape as `broadcast_inscription_txs` — see that + // function's docstring for the full rationale. + println!( + "WS timeout for {}; falling back to esplora-REST GET /tx/{}", + commit_txid, commit_txid + ); + match client.get_tx(&commit_txid).await { + Ok(Some(_)) => { + println!( + "esplora-REST fallback confirmed commit tx {} is on-chain / in mempool", + commit_txid + ); + } + Ok(None) => { + println!( + "esplora-REST fallback: commit tx {} not found (404); broadcast genuinely failed", + commit_txid + ); + return Err(Box::new(crate::scanner_ws::WsError::Timeout)); + } + Err(e) => { + println!( + "esplora-REST fallback failed for {}: {}; propagating original WS timeout", + commit_txid, e + ); + return Err(Box::new(crate::scanner_ws::WsError::Timeout)); + } + } + } + Err(other) => return Err(other.into()), + } + + println!("Broadcasting reveal transaction..."); + client.broadcast(reveal_tx).await?; + let reveal_txid = reveal_tx.compute_txid(); + println!("Reveal transaction broadcast successfully: {}", reveal_txid); + advance_pending_status( + pool, + &commit_txid_bytes, + db::PENDING_STATUS_REVEAL_BROADCAST, + ) + .await; + // Phase E: the row stays at `reveal_broadcast` here. The caller + // (`mint_handler`) advances to `complete` only AFTER it has applied + // `state.update` to the in-memory SMT/MMR and persisted the snapshot. + // The scanner's pre-`state.update` lookup uses the + // `complete` marker to decide whether the inscription has already + // been integrated by the mint flow — advancing here would set the + // marker before the integration actually happened and let a + // mid-flight crash leave a `complete` row whose SMT/MMR were never + // updated, which the scanner would then skip on replay. + + Ok((commit_txid, reveal_txid)) +} + +/// Helper: when `pool` is `Some`, set the row's status and log any +/// error rather than propagating it. The chain has already accepted +/// the step by the time this is called, so a DB-side failure is +/// recoverable on the next boot via the resumer. +async fn advance_pending_status(pool: Option<&PgPool>, commit_txid_bytes: &[u8], status: &str) { + let Some(pool) = pool else { + return; + }; + if let Err(e) = db::update_pending_status(pool, commit_txid_bytes, status).await { + eprintln!( + "Failed to advance pending_inscriptions row {} to {}: {}", + hex::encode(commit_txid_bytes), + status, + e + ); + } +} + +/// Re-broadcast every pending inscription left in the +/// `pending_inscriptions` table by a previous boot. +/// +/// Strategy: load every row whose status is not `complete`, then +/// dispatch by status: +/// +/// * `constructed` — re-broadcast both commit and reveal. If the +/// commit broadcast returns `bad-txns-inputs-missingorspent` the +/// commit's input was already spent by a previous attempt that +/// landed before we crashed; advance to `commit_broadcast` and +/// continue to the reveal. +/// * `commit_broadcast` — re-broadcast just the reveal. The commit +/// is already on chain. +/// * `reveal_broadcast` — re-broadcast the reveal anyway (idempotent; +/// Esplora returns `txn-already-known`) and advance to `complete`. +/// +/// **Non-fatal on errors.** A failure here MUST NOT crash the +/// bootstrap — the publisher's CLI recovery tool (PR #106) remains +/// the operator's escape hatch. Errors are logged loudly so they +/// surface in the container's stdout / log aggregator. +pub async fn resume_pending_inscriptions( + pool: &PgPool, + config: &EsploraConfig, +) -> Result<(), Box> { + let rows = db::load_pending_in_progress(pool).await?; + if rows.is_empty() { + println!("resume_pending_inscriptions: no pending rows"); + return Ok(()); + } + println!( + "resume_pending_inscriptions: resuming {} pending row(s)", + rows.len() + ); + + for row in rows { + if let Err(e) = resume_single_row(pool, config, &row).await { + eprintln!( + "resume_pending_inscriptions: row id={} commit_txid={} status={} failed: {}", + row.id, + hex::encode(&row.commit_txid), + row.status, + e + ); + } + } + Ok(()) +} + +/// Drives one [`db::PendingInscriptionRow`] to `complete`. Split out +/// of [`resume_pending_inscriptions`] so a per-row failure short- +/// circuits with `?` cleanly without abandoning the rest of the +/// queue. +async fn resume_single_row( + pool: &PgPool, + config: &EsploraConfig, + row: &db::PendingInscriptionRow, +) -> Result<(), Box> { + let commit_tx: Transaction = bitcoin::consensus::deserialize(&row.commit_tx) + .map_err(|e| format!("deserialize commit_tx: {}", e))?; + let reveal_tx: Transaction = bitcoin::consensus::deserialize(&row.reveal_tx) + .map_err(|e| format!("deserialize reveal_tx: {}", e))?; + + let builder = EsploraBuilder::new(&config.url); + let client = EsploraAsyncClient::::from_builder(builder)?; + + let commit_txid = commit_tx.compute_txid(); + + match row.status.as_str() { + db::PENDING_STATUS_CONSTRUCTED => { + println!( + "resume: row id={} status=constructed → re-broadcasting commit {}", + row.id, commit_txid + ); + match client.broadcast(&commit_tx).await { + Ok(()) => { + db::update_pending_status( + pool, + &row.commit_txid, + db::PENDING_STATUS_COMMIT_BROADCAST, + ) + .await?; + } + Err(e) if is_inputs_missingorspent_error(&e) => { + // The commit already landed on a previous attempt. + // Advance and fall through to the reveal step. + println!( + "resume: commit {} already on chain (bad-txns-inputs-missingorspent), advancing", + commit_txid + ); + db::update_pending_status( + pool, + &row.commit_txid, + db::PENDING_STATUS_COMMIT_BROADCAST, + ) + .await?; + } + Err(e) => return Err(e.into()), + } + broadcast_reveal_and_complete(pool, &client, &row.commit_txid, &reveal_tx).await?; + } + db::PENDING_STATUS_COMMIT_BROADCAST => { + println!( + "resume: row id={} status=commit_broadcast → broadcasting reveal for {}", + row.id, commit_txid + ); + broadcast_reveal_and_complete(pool, &client, &row.commit_txid, &reveal_tx).await?; + } + db::PENDING_STATUS_REVEAL_BROADCAST => { + println!( + "resume: row id={} status=reveal_broadcast → re-broadcasting reveal for {} (idempotent)", + row.id, commit_txid + ); + // Re-broadcast is idempotent: Esplora returns + // `txn-already-known` if the reveal landed on a previous + // attempt. Treat that as success. + match client.broadcast(&reveal_tx).await { + Ok(()) => {} + Err(e) if is_inputs_missingorspent_error(&e) => { + println!( + "resume: reveal for {} already on chain (txn-already-known)", + commit_txid + ); + } + Err(e) => return Err(e.into()), + } + // Phase E: leave the row at `reveal_broadcast`. The scanner + // will observe the commit on chain, see the non-`complete` + // status, run `state.update` itself, and only then mark the + // row `complete` — the `complete` marker now means "SMT/MMR + // contain this inscription's entry", which the resumer + // cannot truthfully assert from outside the state lock. + } + other => { + // Forward-compatible: an unknown status (e.g. a future + // `failed` value) is skipped instead of crashing the + // bootstrap. + println!( + "resume: row id={} commit_txid={} has unknown status {:?}; skipping", + row.id, + hex::encode(&row.commit_txid), + other + ); + } + } + Ok(()) +} + +/// Broadcast `reveal_tx` and advance the matching row to +/// `reveal_broadcast`. Used by both the `constructed` and +/// `commit_broadcast` resume branches. +/// +/// Phase E: this no longer flips the row to `complete`. The `complete` +/// marker now means "SMT/MMR contain this inscription's entry", which +/// only the in-process mint flow (or the scanner-replay path after +/// re-running `state.update`) can truthfully assert. The resumer is +/// outside both code paths, so it stops at `reveal_broadcast` and +/// lets the scanner finish the integration. +async fn broadcast_reveal_and_complete( + pool: &PgPool, + client: &EsploraAsyncClient, + commit_txid_bytes: &[u8], + reveal_tx: &Transaction, +) -> Result<(), Box> { + match client.broadcast(reveal_tx).await { + Ok(()) => {} + Err(e) if is_inputs_missingorspent_error(&e) => { + // Reveal already on chain — proceed to advance the row. + println!( + "resume: reveal {} already on chain (txn-already-known)", + reveal_tx.compute_txid() + ); + } + Err(e) => return Err(e.into()), + } + db::update_pending_status(pool, commit_txid_bytes, db::PENDING_STATUS_REVEAL_BROADCAST).await?; + // Phase E: do not advance to `complete` here either. See the + // `PENDING_STATUS_REVEAL_BROADCAST` branch in `resume_single_row` + // for the rationale — `complete` is now reserved for "SMT/MMR + // hold this entry", which the scanner sets after running + // `state.update`. + Ok(()) +} + +#[cfg(test)] +#[path = "publisher_tests.rs"] +mod tests; diff --git a/node/src/publisher_tests.rs b/node/src/publisher_tests.rs new file mode 100644 index 00000000..06073675 --- /dev/null +++ b/node/src/publisher_tests.rs @@ -0,0 +1,1327 @@ +//! Tests for `publisher.rs`. +//! +//! The pure inscription building / Schnorr signing / witness mining logic +//! in `inscription_txs` is exercised end-to-end with deterministic inputs. +//! The Esplora-touching helpers (`get_publisher_utxo`, +//! `broadcast_inscription_txs`, `create_and_broadcast_inscription`) are +//! exercised against a `wiremock` mock server so no real network is hit. + +use super::*; +use crate::db; +use bitcoin::blockdata::opcodes; +use bitcoin::hashes::Hash; +use bitcoin::script::Instruction; +use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; +use bitcoin::{Address, Network, OutPoint, Txid, XOnlyPublicKey}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use std::str::FromStr; +use std::time::Duration; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Test publisher key used to produce deterministic Taproot addresses +/// and signatures. The production `PUBLISHER_KEY` is now a required env +/// var with no default (see `lib.rs`); this constant is a local +/// test-only placeholder passed directly into `inscription_txs` and +/// never reaches the global `crate::PUBLISHER_KEY` resolution. Matches +/// the CI test value in `.github/workflows/ci.yaml`. +const TEST_PUBLISHER_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + +fn test_publisher_address(network: Network) -> Address { + let secp = Secp256k1::new(); + let sk = SecretKey::from_str(TEST_PUBLISHER_KEY).unwrap(); + let key_pair = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = XOnlyPublicKey::from_keypair(&key_pair); + Address::p2tr(&secp, xonly, None, network) +} + +/// Build an arbitrary deterministic outpoint with all-zero txid and the +/// given vout. Good enough for tests — nothing on chain is verified. +fn fake_outpoint(vout: u32) -> OutPoint { + OutPoint::new(Txid::all_zeros(), vout) +} + +/// Spin up a wiremock server and produce an `EsploraConfig` that points +/// the publisher code at it. The WS endpoint is left unset because most +/// HTTP-only tests never reach the broadcast path. +async fn setup_mock_esplora() -> (MockServer, EsploraConfig) { + let mock_server = MockServer::start().await; + let config = EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + (mock_server, config) +} + +/// Spin up an in-process WS server that emulates the Esplora +/// `track-tx` flow used by `broadcast_inscription_txs` (issue #84): +/// accept the subscribe frame and, depending on `mode`, either echo +/// back a `mempool: true` event for the txid the client subscribed +/// to (mode = "echo") or stay silent (mode = "silent") so the +/// publisher's 30-s safety-net fires. Returns the `ws://` URL. +async fn spawn_track_tx_ws(mode: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{}", addr); + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(s) => s, + Err(_) => return, + }; + let mut ws = match tokio_tungstenite::accept_async(stream).await { + Ok(w) => w, + Err(_) => continue, + }; + // Read the subscribe frame. + let first = match ws.next().await { + Some(Ok(WsMessage::Text(t))) => t, + _ => continue, + }; + let value: serde_json::Value = match serde_json::from_str(&first) { + Ok(v) => v, + Err(_) => continue, + }; + if value.get("action") == Some(&serde_json::json!("track-tx")) { + if let Some(txid_str) = value.get("data").and_then(|v| v.as_str()) { + if mode == "echo" { + // Documented mempool.space `txPosition` shape; + // see `scanner_ws::frame_signals_tx_seen`. + let frame = format!( + r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, + txid_str + ); + let _ = ws.send(WsMessage::Text(frame)).await; + } + } + } + // Hold the connection open until the test aborts the + // task. `std::future::pending` keeps the socket alive + // indefinitely so a slow CI runner can never let the + // helper observe a clean close before the event arrives; + // a bounded `sleep(60s)` could expire and mask a race. + std::future::pending::<()>().await; + } + }); + url +} + +// ----------------------------------------------------------------------------- +// Pure logic: inscription_txs +// ----------------------------------------------------------------------------- + +/// The reveal transaction's txid must start with the `INSCRIPTION_MARKER_PREFIX` +/// (the scanner relies on this prefix to find inscriptions in the chain). +#[test] +fn inscription_txs_produces_taproot_commit_and_reveal_with_marker_prefix() { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + let (commit_tx, reveal_tx) = inscription_txs( + b"Hello, zkCoins!", + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + // commit_tx must spend the supplied outpoint. + assert_eq!(commit_tx.input.len(), 1); + assert_eq!(commit_tx.input[0].previous_output, fake_outpoint(0)); + + // reveal_tx txid starts with the marker prefix (so the scanner picks + // it up). `hex::decode` is the canonical inverse of the publisher's + // own check. + let target = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap(); + let txid_bytes = reveal_tx.compute_txid().as_byte_array().to_vec(); + assert!( + txid_bytes.starts_with(&target), + "reveal txid {} does not start with {}", + reveal_tx.compute_txid(), + INSCRIPTION_MARKER_PREFIX + ); +} + +/// Reveal-script witness must embed the commitment payload bytes verbatim. +/// In a Taproot script-spend the witness layout is `[sig, script, control]`, +/// so the script is the second-to-last witness item. +#[test] +fn inscription_txs_embeds_commitment_data_in_reveal_script() { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + let publisher_address = test_publisher_address(config.network()); + let payload = b"Hello, zkCoins!".to_vec(); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + let (_commit_tx, reveal_tx) = inscription_txs( + &payload, + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + let witness_items: Vec> = reveal_tx.input[0] + .witness + .iter() + .map(|w| w.to_vec()) + .collect(); + assert_eq!( + witness_items.len(), + 3, + "reveal witness must be [sig, script, control_block]" + ); + + // The script lives at index `len - 2`. Walk its push-data chunks and + // collect them to reconstruct the embedded payload. + let script_bytes = &witness_items[witness_items.len() - 2]; + let script = bitcoin::ScriptBuf::from_bytes(script_bytes.clone()); + + let mut collected = Vec::new(); + let mut prev_was_op_false = false; + let mut inside = false; + for ins in script.instructions().flatten() { + if inside { + match ins { + Instruction::PushBytes(b) => collected.extend_from_slice(b.as_bytes()), + Instruction::Op(op) if op == opcodes::all::OP_ENDIF => break, + _ => {} + } + } else { + match ins { + Instruction::PushBytes(b) if b.is_empty() => prev_was_op_false = true, + Instruction::Op(op) if op == opcodes::all::OP_IF && prev_was_op_false => { + inside = true; + } + _ => prev_was_op_false = false, + } + } + } + + assert_eq!( + collected, payload, + "reveal script must embed the exact commitment data" + ); +} + +/// Commitment payloads larger than `MAX_CHUNK_SIZE` (520 bytes) must be +/// split into multiple push-data chunks inside the reveal script. +#[test] +fn inscription_txs_chunks_large_commitment_data() { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + let publisher_address = test_publisher_address(config.network()); + // 600 bytes of repeating non-zero pattern (zero bytes would collide + // with the OP_FALSE delimiter inside the loop below). + let payload: Vec = (0..600).map(|i| (i % 255 + 1) as u8).collect(); + let outpoints = vec![(fake_outpoint(0), 200_000u64)]; + + let (_commit_tx, reveal_tx) = inscription_txs( + &payload, + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + let witness_items: Vec> = reveal_tx.input[0] + .witness + .iter() + .map(|w| w.to_vec()) + .collect(); + let script_bytes = &witness_items[witness_items.len() - 2]; + let script = bitcoin::ScriptBuf::from_bytes(script_bytes.clone()); + + // Count push-data chunks inside the OP_FALSE / OP_IF envelope. + let mut prev_was_op_false = false; + let mut inside = false; + let mut chunk_count = 0usize; + for ins in script.instructions().flatten() { + if inside { + match ins { + Instruction::PushBytes(_) => chunk_count += 1, + Instruction::Op(op) if op == opcodes::all::OP_ENDIF => break, + _ => {} + } + } else { + match ins { + Instruction::PushBytes(b) if b.is_empty() => prev_was_op_false = true, + Instruction::Op(op) if op == opcodes::all::OP_IF && prev_was_op_false => { + inside = true; + } + _ => prev_was_op_false = false, + } + } + } + + // 600 bytes / 520 per chunk = 2 chunks (520 + 80). + assert_eq!( + chunk_count, 2, + "600-byte payload must be split into exactly 2 push_slice chunks" + ); +} + +/// The commit transaction's input witness must carry a 64-byte BIP-340 +/// Schnorr signature (key-spend, default sighash → no sighash flag byte). +#[test] +fn inscription_txs_signs_commit_input_with_taproot_keyspend() { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + let (commit_tx, _reveal_tx) = inscription_txs( + b"Hello, zkCoins!", + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + let witness_items: Vec> = commit_tx.input[0] + .witness + .iter() + .map(|w| w.to_vec()) + .collect(); + assert_eq!( + witness_items.len(), + 1, + "key-spend witness must be exactly [signature]" + ); + assert_eq!( + witness_items[0].len(), + 64, + "BIP-340 Schnorr signature with default sighash is 64 bytes (no sighash flag)" + ); +} + +/// `EsploraConfig::network()` must map `is_mainnet=false` to `Signet`. +/// The publisher derives the commit/publisher address from this network, +/// so an off-by-one here would silently broadcast to the wrong chain. +#[test] +fn inscription_txs_uses_signet_when_is_mainnet_false() { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + assert_eq!(config.network(), Network::Signet); + + // And the mainnet branch — guards the bool-flip too. + let mainnet_config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: true, + network_name: "Mainnet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + assert_eq!(mainnet_config.network(), Network::Bitcoin); +} + +// ----------------------------------------------------------------------------- +// Esplora HTTP, mocked via wiremock +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn get_publisher_utxo_returns_empty_when_address_has_no_utxos() { + let (server, config) = setup_mock_esplora().await; + let publisher_address = test_publisher_address(config.network()); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let result = get_publisher_utxo(&publisher_address, &config, None) + .await + .expect("call should succeed"); + assert!(result.is_empty(), "empty Esplora response → empty Vec"); +} + +#[tokio::test] +async fn get_publisher_utxo_returns_utxos_with_value() { + let (server, config) = setup_mock_esplora().await; + let publisher_address = test_publisher_address(config.network()); + + let txid_hex = "1111111111111111111111111111111111111111111111111111111111111111"; + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": txid_hex, + "vout": 3, + "value": 1000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + + let result = get_publisher_utxo(&publisher_address, &config, None) + .await + .expect("call should succeed"); + + assert_eq!(result.len(), 1, "exactly one UTXO is mapped through"); + let (outpoint, sats) = result[0]; + assert_eq!(sats, 1000); + assert_eq!(outpoint.vout, 3); + assert_eq!(outpoint.txid, Txid::from_str(txid_hex).unwrap()); +} + +#[tokio::test] +async fn get_publisher_utxo_returns_empty_when_total_below_minimum() { + let (server, config) = setup_mock_esplora().await; + let publisher_address = test_publisher_address(config.network()); + + let txid_hex = "2222222222222222222222222222222222222222222222222222222222222222"; + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": txid_hex, + "vout": 0, + "value": 500, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + + // 500 sats present, but caller demands at least 1000 → wallet is + // declared empty (publisher will refuse to broadcast). + let result = get_publisher_utxo(&publisher_address, &config, Some(1000)) + .await + .expect("call should succeed"); + assert!( + result.is_empty(), + "total below minimum must collapse to an empty vec" + ); +} + +#[tokio::test] +async fn broadcast_inscription_txs_returns_both_txids_on_success() { + let (server, mut config) = setup_mock_esplora().await; + // Plug a mock WS server in so the publisher's track-tx wait + // resolves immediately instead of hitting its 30-s safety-net. + config.ws_url = Some(spawn_track_tx_ws("echo").await); + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + // Build a real (commit, reveal) pair — broadcast just serialises and + // POSTs them, so the txids the function returns are the ones we + // computed locally. + let (commit_tx, reveal_tx) = inscription_txs( + b"Hello, zkCoins!", + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + let expected_commit_txid = commit_tx.compute_txid(); + let expected_reveal_txid = reveal_tx.compute_txid(); + + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string(expected_commit_txid.to_string())) + .mount(&server) + .await; + + let (got_commit, got_reveal) = broadcast_inscription_txs(&config, &commit_tx, &reveal_tx) + .await + .expect("broadcast should succeed when Esplora accepts both txs"); + + assert_eq!(got_commit, expected_commit_txid); + assert_eq!(got_reveal, expected_reveal_txid); +} + +#[tokio::test] +async fn broadcast_inscription_txs_errors_when_track_tx_event_never_arrives() { + // Silent WS mock — exercises the "broadcast genuinely failed" + // path: the short WS timeout elapses, the publisher's REST + // fallback hits the wiremock default (no `GET /tx/{txid}` route + // mounted ⇒ 404 ⇒ esplora-client returns `Ok(None)`), and the + // publisher surfaces a hard `WsError::Timeout` instead of + // silently broadcasting the reveal (issue #84 design). + let (server, mut config) = setup_mock_esplora().await; + config.ws_url = Some(spawn_track_tx_ws("silent").await); + // Override the production 30-s deadline so the test fails fast + // rather than blocking the suite for half a minute. + config.track_tx_timeout = Some(Duration::from_millis(300)); + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + let (commit_tx, reveal_tx) = inscription_txs( + b"Hello, zkCoins!", + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with( + ResponseTemplate::new(200).set_body_string(commit_tx.compute_txid().to_string()), + ) + .mount(&server) + .await; + + let err = broadcast_inscription_txs(&config, &commit_tx, &reveal_tx) + .await + .expect_err("silent WS must surface a hard error, not silent fallback"); + assert!( + err.to_string().to_lowercase().contains("timeout") + || err.to_string().to_lowercase().contains("ws"), + "error should mention the WS timeout, got: {}", + err + ); +} + +#[tokio::test] +async fn broadcast_inscription_txs_propagates_esplora_error() { + let (server, config) = setup_mock_esplora().await; + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + + let (commit_tx, reveal_tx) = inscription_txs( + b"Hello, zkCoins!", + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ); + + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(400).set_body_string("sendrawtransaction RPC error")) + .mount(&server) + .await; + + let err = broadcast_inscription_txs(&config, &commit_tx, &reveal_tx) + .await + .expect_err("400 from Esplora must bubble up as Err"); + + // We don't pin the exact message, but it must be non-empty. + assert!( + !err.to_string().is_empty(), + "error should carry a non-empty message" + ); +} + +// ----------------------------------------------------------------------------- +// create_and_broadcast_inscription — integration over the mocked HTTP layer +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn create_and_broadcast_inscription_fails_when_no_utxos() { + let (server, config) = setup_mock_esplora().await; + let publisher_address = test_publisher_address(config.network()); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let err = create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None) + .await + .expect_err("empty wallet must produce an Err"); + + assert!( + err.to_string().contains("No UTXOs available"), + "error should describe the empty-wallet condition, got: {}", + err + ); +} + +#[tokio::test] +async fn create_and_broadcast_inscription_succeeds_end_to_end_with_mocked_esplora() { + let (server, mut config) = setup_mock_esplora().await; + config.ws_url = Some(spawn_track_tx_ws("echo").await); + let publisher_address = test_publisher_address(config.network()); + + // 1) Address-UTXO lookup — return one UTXO with enough sats to cover + // both commit + reveal fees. + let funding_txid = "3333333333333333333333333333333333333333333333333333333333333333"; + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": funding_txid, + "vout": 0, + "value": 100_000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + + // 2) Broadcast — accept both commit and reveal POSTs. + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + let (commit_txid, reveal_txid) = + create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None) + .await + .expect("end-to-end inscription should succeed against mocked Esplora"); + assert_ne!( + commit_txid, reveal_txid, + "commit and reveal must be distinct transactions" + ); + + // Reveal txid must carry the inscription marker prefix. + let target = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap(); + assert!( + reveal_txid.as_byte_array().starts_with(&target), + "reveal txid {} must start with marker {}", + reveal_txid, + INSCRIPTION_MARKER_PREFIX + ); +} + +// ----------------------------------------------------------------------------- +// Phase B: pending_inscriptions persistence + resume +// ----------------------------------------------------------------------------- +// +// These tests pair a real Postgres 17 container (via testcontainers) with +// wiremock-mocked Esplora. They exercise: +// +// 1-3) Forward path: `create_and_broadcast_inscription` persists a +// `constructed` row BEFORE the commit broadcast, advances it to +// `commit_broadcast`, `reveal_broadcast`, and finally `complete` +// as each step lands. +// 4-7) Resume path: `resume_pending_inscriptions` walks each non- +// complete row to `complete` regardless of starting status, skips +// completed rows, and is idempotent when called a second time. +// 8) Resume path tolerance: a `bad-txns-inputs-missingorspent` +// rejection from Esplora's commit-broadcast on resume means the +// commit already landed on a previous attempt; the resumer +// advances and continues with the reveal instead of bailing. + +/// Spin up a fresh `postgres:17` container and connect a migrated pool. +async fn setup_phaseb_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +/// Read the current status of a pending row by `commit_txid`. Panics if +/// no row exists — the caller is asserting that one is present. +async fn fetch_pending_status(pool: &PgPool, commit_txid: &[u8]) -> String { + let row: (String,) = + sqlx::query_as("SELECT status FROM pending_inscriptions WHERE commit_txid = $1") + .bind(commit_txid) + .fetch_one(pool) + .await + .expect("pending row should exist"); + row.0 +} + +/// Count rows in `pending_inscriptions` (any status). +async fn count_pending_rows(pool: &PgPool) -> i64 { + let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_inscriptions") + .fetch_one(pool) + .await + .expect("count query"); + n +} + +/// Build a (commit, reveal) pair using the test publisher key against the +/// supplied UTXO. The mining loop inside `inscription_txs` is +/// deterministic for a given input set so the test can recompute either +/// txid from the returned txs. +fn build_test_pair(commitment_data: &[u8]) -> (Transaction, Transaction) { + let config = EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + let publisher_address = test_publisher_address(config.network()); + let outpoints = vec![(fake_outpoint(0), 100_000u64)]; + inscription_txs( + commitment_data, + &publisher_address, + outpoints, + TEST_PUBLISHER_KEY, + &config, + ) +} + +/// Insert a row in the supplied state directly via the db helper. Used +/// to seed the resume tests without going through the forward path. +async fn seed_pending_row( + pool: &PgPool, + commit_tx: &Transaction, + reveal_tx: &Transaction, + commitment_data: &[u8], + status: &str, +) { + let commit_txid = commit_tx.compute_txid(); + let commit_tx_bytes = bitcoin::consensus::serialize(commit_tx); + let reveal_tx_bytes = bitcoin::consensus::serialize(reveal_tx); + let commit_output_value = commit_tx.output[0].value.to_sat() as i64; + let inserted = db::insert_pending_inscription( + pool, + commit_txid.as_byte_array(), + commitment_data, + &commit_tx_bytes, + &reveal_tx_bytes, + commit_output_value, + ) + .await + .expect("seed insert"); + assert!(inserted, "fresh insert should succeed"); + if status != db::PENDING_STATUS_CONSTRUCTED { + db::update_pending_status(pool, commit_txid.as_byte_array(), status) + .await + .expect("seed status update"); + } +} + +#[tokio::test] +async fn broadcast_persists_constructed_row_before_commit_broadcast() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, mut config) = setup_mock_esplora().await; + config.ws_url = Some(spawn_track_tx_ws("echo").await); + let publisher_address = test_publisher_address(config.network()); + + let funding_txid = "3333333333333333333333333333333333333333333333333333333333333333"; + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": funding_txid, + "vout": 0, + "value": 100_000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + // Reject every POST /tx so the broadcast fails AFTER the + // constructed row was persisted. The assertion is that the row + // landed on disk BEFORE the broadcast attempt — i.e. it is present + // even though the broadcast errored out. + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(400).set_body_string("simulated broadcast failure")) + .mount(&server) + .await; + + let _err = create_and_broadcast_inscription(b"phaseb-1", &config, Some(&pool)) + .await + .expect_err("broadcast must fail (400)"); + + // Exactly one row, status = constructed (commit broadcast failed + // so the advance to `commit_broadcast` never fired). + assert_eq!(count_pending_rows(&pool).await, 1); + let row = sqlx::query_as::<_, (String, Vec, Vec, Vec)>( + "SELECT status, commit_tx, reveal_tx, commitment FROM pending_inscriptions", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, db::PENDING_STATUS_CONSTRUCTED); + assert!( + !row.1.is_empty() && !row.2.is_empty(), + "commit_tx and reveal_tx must be persisted as non-empty blobs" + ); + assert_eq!(row.3, b"phaseb-1"); +} + +#[tokio::test] +async fn broadcast_advances_to_commit_broadcast_after_commit_success() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, mut config) = setup_mock_esplora().await; + // Silent WS so the post-commit track-tx wait times out and the + // REST fallback (no GET mounted ⇒ 404) propagates the WS timeout. + // This stops the broadcast BEFORE the reveal POST fires, leaving + // the row in `commit_broadcast`. + config.ws_url = Some(spawn_track_tx_ws("silent").await); + config.track_tx_timeout = Some(Duration::from_millis(200)); + let publisher_address = test_publisher_address(config.network()); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": "3333333333333333333333333333333333333333333333333333333333333333", + "vout": 0, + "value": 100_000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + // Accept the commit POST (200) — every POST /tx hits this single + // mock. The publisher then waits for the WS event that never + // arrives, and the broadcast errors out before the reveal POST is + // attempted, so we can observe the intermediate `commit_broadcast` + // status. + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + let _err = create_and_broadcast_inscription(b"phaseb-2", &config, Some(&pool)) + .await + .expect_err("WS timeout (silent mock + no REST fallback) must surface"); + + // One row, advanced from `constructed` to `commit_broadcast` by + // the commit-OK hook but stuck there because the reveal step + // never ran. + assert_eq!(count_pending_rows(&pool).await, 1); + let (commit_txid_bytes,): (Vec,) = + sqlx::query_as("SELECT commit_txid FROM pending_inscriptions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_COMMIT_BROADCAST + ); +} + +#[tokio::test] +async fn broadcast_advances_to_reveal_broadcast_after_reveal_success() { + // Phase E: `complete` now means "SMT/MMR contain this inscription's + // entry", not "reveal landed on chain". The broadcast leg stops at + // `reveal_broadcast`; the caller (`mint_handler`) advances the row + // to `complete` only after running `state.update` in-process. This + // test exercises the publisher in isolation (no mint flow), so the + // expected terminal status here is `reveal_broadcast`. + let (pool, _container) = setup_phaseb_pool().await; + let (server, mut config) = setup_mock_esplora().await; + config.ws_url = Some(spawn_track_tx_ws("echo").await); + let publisher_address = test_publisher_address(config.network()); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": "3333333333333333333333333333333333333333333333333333333333333333", + "vout": 0, + "value": 100_000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + let _result = create_and_broadcast_inscription(b"phaseb-3", &config, Some(&pool)) + .await + .expect("happy path must succeed"); + + // Final state is `reveal_broadcast` — see Phase E note above. + assert_eq!(count_pending_rows(&pool).await, 1); + let (status,): (String,) = sqlx::query_as("SELECT status FROM pending_inscriptions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, db::PENDING_STATUS_REVEAL_BROADCAST); +} + +#[tokio::test] +async fn resume_from_commit_broadcast_rebroadcasts_reveal_only() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, config) = setup_mock_esplora().await; + + let (commit_tx, reveal_tx) = build_test_pair(b"resume-cb"); + seed_pending_row( + &pool, + &commit_tx, + &reveal_tx, + b"resume-cb", + db::PENDING_STATUS_COMMIT_BROADCAST, + ) + .await; + + // Accept POST /tx (the resumer only broadcasts the reveal here). + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + resume_pending_inscriptions(&pool, &config) + .await + .expect("resume must succeed"); + + let commit_txid_bytes = commit_tx.compute_txid().as_byte_array().to_vec(); + // Phase E: resume stops at `reveal_broadcast` — the scanner will + // run state.update against the on-chain inscription and mark the + // row `complete` after the SMT/MMR are updated. + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST + ); + + // Exactly one POST /tx (the reveal). The commit was already on + // chain by the time we crashed, so the resumer must not broadcast + // it again — that would consume a fresh publisher-wallet UTXO. + let received = server.received_requests().await.unwrap(); + let post_tx_count = received + .iter() + .filter(|r| r.method == wiremock::http::Method::POST && r.url.path() == "/tx") + .count(); + assert_eq!( + post_tx_count, 1, + "resume(commit_broadcast) must POST /tx exactly once (the reveal)" + ); +} + +#[tokio::test] +async fn resume_from_constructed_rebroadcasts_both() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, config) = setup_mock_esplora().await; + + let (commit_tx, reveal_tx) = build_test_pair(b"resume-co"); + seed_pending_row( + &pool, + &commit_tx, + &reveal_tx, + b"resume-co", + db::PENDING_STATUS_CONSTRUCTED, + ) + .await; + + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + resume_pending_inscriptions(&pool, &config) + .await + .expect("resume must succeed"); + + let commit_txid_bytes = commit_tx.compute_txid().as_byte_array().to_vec(); + // Phase E: see the `commit_broadcast` resume test above — terminal + // status from a resume-driven re-broadcast is `reveal_broadcast`; + // the scanner's state.update is what flips it to `complete`. + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST + ); + + // Two POSTs (commit + reveal). + let received = server.received_requests().await.unwrap(); + let post_tx_count = received + .iter() + .filter(|r| r.method == wiremock::http::Method::POST && r.url.path() == "/tx") + .count(); + assert_eq!( + post_tx_count, 2, + "resume(constructed) must POST /tx twice (commit + reveal)" + ); +} + +#[tokio::test] +async fn resume_skips_complete_rows() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, config) = setup_mock_esplora().await; + + let (commit_tx, reveal_tx) = build_test_pair(b"resume-skip"); + seed_pending_row( + &pool, + &commit_tx, + &reveal_tx, + b"resume-skip", + db::PENDING_STATUS_COMPLETE, + ) + .await; + + // No mocks mounted on POST /tx — if the resumer touches Esplora at + // all the call will surface as a wiremock-unmatched 404 and the + // status flip below would fail because the reveal broadcast would + // error out and roll the row back. We assert the resumer is a + // no-op by checking the post-state matches the seeded state + // exactly. + resume_pending_inscriptions(&pool, &config) + .await + .expect("resume must succeed (no-op)"); + + let commit_txid_bytes = commit_tx.compute_txid().as_byte_array().to_vec(); + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_COMPLETE + ); + let received = server.received_requests().await.unwrap(); + assert!( + received.is_empty(), + "resume(complete) must not hit Esplora; got {} requests", + received.len() + ); +} + +#[tokio::test] +async fn resume_is_idempotent_when_called_twice() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, config) = setup_mock_esplora().await; + + let (commit_tx, reveal_tx) = build_test_pair(b"resume-idem"); + seed_pending_row( + &pool, + &commit_tx, + &reveal_tx, + b"resume-idem", + db::PENDING_STATUS_REVEAL_BROADCAST, + ) + .await; + + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + // First call: walks the row from `reveal_broadcast` and re- + // broadcasts the reveal. Phase E: the resumer leaves the row at + // `reveal_broadcast` (the scanner is what marks it `complete` + // after running state.update), so the assertion below pins the + // pre-scanner status, not `complete`. Idempotency is exercised + // by the second call below. + resume_pending_inscriptions(&pool, &config) + .await + .expect("first resume must succeed"); + let commit_txid_bytes = commit_tx.compute_txid().as_byte_array().to_vec(); + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST + ); + + let after_first = server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method == wiremock::http::Method::POST && r.url.path() == "/tx") + .count(); + + // Second call: row is still `reveal_broadcast`. The resumer re- + // dispatches into the same `reveal_broadcast` branch and re- + // broadcasts the reveal a second time — Esplora returns `txn- + // already-known` (200 in the wiremock fallback) and the row stays + // at `reveal_broadcast`. The idempotency invariant the test pins + // is now "no error path, end status unchanged". + resume_pending_inscriptions(&pool, &config) + .await + .expect("second resume must succeed"); + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST + ); + let after_second = server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method == wiremock::http::Method::POST && r.url.path() == "/tx") + .count(); + // Phase E: the resumer re-broadcasts the reveal on every call to + // the `reveal_broadcast` branch, since it no longer flips the row + // to `complete`. This matches the documented idempotency contract + // (`txn-already-known` from Esplora) and is harmless at the chain + // layer. + assert_eq!( + after_second, + after_first + 1, + "second resume must POST /tx exactly once more (the idempotent reveal re-broadcast)" + ); +} + +#[tokio::test] +async fn resume_tolerates_bad_inputs_error_on_double_spend() { + // The `constructed` retry case: a previous attempt's commit + // landed on chain (so the input UTXO is already spent) but we + // crashed before recording the success. The resumer re-tries + // the commit, Esplora replies 400 with + // `bad-txns-inputs-missingorspent`, the resumer must advance + // the row and proceed to broadcast the reveal. + let (pool, _container) = setup_phaseb_pool().await; + let (server, config) = setup_mock_esplora().await; + + let (commit_tx, reveal_tx) = build_test_pair(b"resume-doublespend"); + seed_pending_row( + &pool, + &commit_tx, + &reveal_tx, + b"resume-doublespend", + db::PENDING_STATUS_CONSTRUCTED, + ) + .await; + + // Two stacked mocks on the same path: the FIRST request is matched + // by the `up_to_n_times(1)` mock (returns 400 + + // bad-txns-inputs-missingorspent — the commit-re-broadcast hits + // this), every subsequent request falls through to the fallback + // mock (200 — the reveal broadcast hits this). + // + // wiremock matches mocks in LIFO insertion order, so we mount the + // fallback FIRST and the up-to-1 rejection second. + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with( + ResponseTemplate::new(400) + .set_body_string("sendrawtransaction RPC error: bad-txns-inputs-missingorspent"), + ) + .up_to_n_times(1) + .mount(&server) + .await; + + resume_pending_inscriptions(&pool, &config) + .await + .expect("resume must tolerate the bad-inputs rejection on commit"); + + let commit_txid_bytes = commit_tx.compute_txid().as_byte_array().to_vec(); + // Phase E: the resumer stops at `reveal_broadcast`; the scanner is + // what flips the row to `complete` after running state.update on + // the on-chain inscription. This test exercises the publisher in + // isolation, so the expected terminal status is `reveal_broadcast`. + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST, + "row must end in reveal_broadcast after the resumer absorbs the double-spend signal and broadcasts the reveal" + ); + let post_tx_count = server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method == wiremock::http::Method::POST && r.url.path() == "/tx") + .count(); + assert_eq!( + post_tx_count, 2, + "resume must POST /tx twice: rejected commit + accepted reveal" + ); +} + +// ----------------------------------------------------------------------------- +// Phase E: mint_handler advances state synchronously after broadcast +// ----------------------------------------------------------------------------- +// +// The three tests below pin the Phase-E contract: +// +// 1. The publisher leg stops at `reveal_broadcast` — `mint_handler` +// drives the advance to `complete` only after `state.update` +// has been applied in-process. This complements +// `broadcast_advances_to_reveal_broadcast_after_reveal_success` +// above by making the contract explicit in test name + assertion. +// +// 2. Scanner-side: a row at `complete` short-circuits the scanner's +// `state.update` step — the lookup helper returns the marker the +// scanner checks, and `should_skip_scanner_state_update` returns +// true for that marker only. +// +// 3. Scanner-side fallback: an in-progress row (or no row at all) +// lets the scanner run `state.update` itself — the recovery / +// external-mint path stays intact. + +/// `mint_handler_advances_state_synchronously_with_broadcast`: +/// happy-path broadcast against a real Postgres + mocked Esplora +/// leaves the row at `reveal_broadcast`, NOT `complete`. The +/// `complete` advance is the caller's responsibility (Phase E moved +/// it out of the publisher). +#[tokio::test] +async fn mint_handler_advances_state_synchronously_with_broadcast() { + let (pool, _container) = setup_phaseb_pool().await; + let (server, mut config) = setup_mock_esplora().await; + config.ws_url = Some(spawn_track_tx_ws("echo").await); + let publisher_address = test_publisher_address(config.network()); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "txid": "3333333333333333333333333333333333333333333333333333333333333333", + "vout": 0, + "value": 100_000, + "status": { "confirmed": true, "block_height": 100, "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", "block_time": 1700000000 } + } + ]))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + + let (commit_txid, _reveal_txid) = + create_and_broadcast_inscription(b"phase-e-1", &config, Some(&pool)) + .await + .expect("happy path must succeed"); + + // Publisher leg stopped at `reveal_broadcast` — the `mint_handler` + // caller is what flips it to `complete` after running + // `state.update`. This is the Phase E load-bearing contract. + let commit_txid_bytes = commit_txid.as_byte_array().to_vec(); + assert_eq!( + fetch_pending_status(&pool, &commit_txid_bytes).await, + db::PENDING_STATUS_REVEAL_BROADCAST, + "Phase E: publisher must stop at reveal_broadcast and let mint_handler advance to complete" + ); + + // Drive the caller-side advance to `complete` (the mint flow's + // post-state.update step) and re-check. + db::update_pending_status(&pool, &commit_txid_bytes, db::PENDING_STATUS_COMPLETE) + .await + .expect("post-state.update advance must succeed"); + assert_eq!( + db::pending_inscription_status_by_commit_txid(&pool, &commit_txid_bytes) + .await + .expect("lookup must succeed"), + Some(db::PENDING_STATUS_COMPLETE.to_string()) + ); +} + +/// `scanner_skips_already_integrated_commit_on_replay`: the scanner- +/// callback decision used by `main.rs` short-circuits when the +/// pending row is `complete`. Pairs the DB-level lookup with the +/// pure-logic predicate so the integration is visible end-to-end +/// (insert pending → mark complete → lookup → predicate). +#[tokio::test] +async fn scanner_skips_already_integrated_commit_on_replay() { + let (pool, _container) = setup_phaseb_pool().await; + let commit_txid = [0x42u8; 32]; + + db::insert_pending_inscription( + &pool, + &commit_txid, + b"phase-e-2", + b"commit-tx-bytes", + b"reveal-tx-bytes", + 12_345, + ) + .await + .expect("insert pending"); + db::update_pending_status(&pool, &commit_txid, db::PENDING_STATUS_COMPLETE) + .await + .expect("advance to complete"); + + let observed = db::pending_inscription_status_by_commit_txid(&pool, &commit_txid) + .await + .expect("lookup must succeed"); + assert_eq!( + observed.as_deref(), + Some(db::PENDING_STATUS_COMPLETE), + "fetched status must reflect the mint handler's complete advance" + ); + assert!( + crate::scanner::should_skip_scanner_state_update(observed.as_deref()), + "scanner must short-circuit state.update for an already-integrated commit" + ); +} + +/// `scanner_falls_back_to_state_update_for_commits_not_in_pending`: +/// the recovery / external-mint path. A commit observed on chain that +/// has no `pending_inscriptions` row (or one still in flight) must +/// drive the scanner through its normal state.update path. +#[tokio::test] +async fn scanner_falls_back_to_state_update_for_commits_not_in_pending() { + let (pool, _container) = setup_phaseb_pool().await; + let external_txid = [0x99u8; 32]; + + // Case 1: no row at all (external / out-of-band inscription). + let no_row = db::pending_inscription_status_by_commit_txid(&pool, &external_txid) + .await + .expect("lookup must not error on missing row"); + assert!(no_row.is_none()); + assert!( + !crate::scanner::should_skip_scanner_state_update(no_row.as_deref()), + "scanner must NOT skip state.update when no pending row exists" + ); + + // Case 2: row present but the mint flow crashed before marking + // complete — status is still `reveal_broadcast`. The scanner is + // the recovery path here. + let crashed_txid = [0x55u8; 32]; + db::insert_pending_inscription( + &pool, + &crashed_txid, + b"phase-e-3-crashed", + b"commit-tx-crashed", + b"reveal-tx-crashed", + 99, + ) + .await + .expect("insert pending"); + db::update_pending_status(&pool, &crashed_txid, db::PENDING_STATUS_REVEAL_BROADCAST) + .await + .expect("advance to reveal_broadcast"); + let crashed_status = db::pending_inscription_status_by_commit_txid(&pool, &crashed_txid) + .await + .expect("lookup must succeed"); + assert_eq!( + crashed_status.as_deref(), + Some(db::PENDING_STATUS_REVEAL_BROADCAST) + ); + assert!( + !crate::scanner::should_skip_scanner_state_update(crashed_status.as_deref()), + "scanner must run state.update when the mint flow stopped before state-advance" + ); +} diff --git a/node/src/router.rs b/node/src/router.rs new file mode 100644 index 00000000..8247c37b --- /dev/null +++ b/node/src/router.rs @@ -0,0 +1,1890 @@ +use axum::{ + body::Bytes, + extract::{Json, Path, State}, + http::{header, Method, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use bitcoin::secp256k1::{self as secp, schnorr::Signature as SchnorrSignature, Message}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use shared::commitment::Commitment; +use shared::ClientAccount; +use shared::{Invoice, ProofData}; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use tower_http::cors::CorsLayer; +use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes}; +use zkcoins_prover::Proof; + +use crate::account_node::{AccountNode, CoinProof}; +use crate::db; +use crate::publisher::create_and_broadcast_inscription; +use crate::publisher::EsploraConfig; +use crate::username::UsernameStore; +use crate::{NETWORK_CONFIG, USERNAME_DOMAIN}; + +/// Verify a Schnorr signature over send request fields. +/// Message = SHA256(account_address || recipient || amount || timestamp) +fn verify_send_signature(request: &SendCoinRequest) -> Result<(), &'static str> { + let signature_hex = request.signature.as_deref().ok_or("Missing signature")?; + let timestamp = request.timestamp.ok_or("Missing timestamp")?; + + // Reject requests older than 5 minutes + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now.abs_diff(timestamp) > 300 { + return Err("Request timestamp too old or in the future"); + } + + // Build the message: SHA256(account_address || recipient || amount || timestamp) + let mut hasher = Sha256::new(); + hasher.update(request.account_address.as_bytes()); + hasher.update(request.recipient.as_bytes()); + hasher.update(request.amount.to_le_bytes()); + hasher.update(timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let sig_bytes = hex::decode(signature_hex).or(Err("Invalid signature hex"))?; + let sig = + SchnorrSignature::from_slice(&sig_bytes).or(Err("Invalid Schnorr signature format"))?; + + let (xonly, _parity) = request.public_key.x_only_public_key(); + let secp = secp::Secp256k1::verification_only(); + + secp.verify_schnorr(&sig, &msg, &xonly) + .or(Err("Signature verification failed")) +} + +/// Lock a mutex, recovering from poison if a previous holder panicked. +/// This prevents cascade failures where one panic takes down all handlers. +pub(crate) fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|poisoned| { + eprintln!("WARNING: Recovering from poisoned mutex"); + poisoned.into_inner() + }) +} + +// Define a struct for our application state +#[derive(Clone)] +pub(crate) struct AppState { + pub(crate) account_node: Arc>, + pub(crate) proof_store: Arc, + pub(crate) minting_account: Arc>, + pub(crate) username_store: Arc>, + /// Postgres pool for per-account upserts (accounts table); the + /// minting account's `num_pubkeys` is derived from SMT membership + /// at runtime (Phase D), no separately-stored counter. Cloned + /// cheaply via `Arc`; the underlying connections are pooled. + pub(crate) pool: Arc, + /// Esplora endpoint configuration consumed by the `/health/ready` + /// readiness probe and by the mint-flow inscription broadcast in + /// `mint_handler`. Injecting the config through `AppState` lets + /// tests redirect Esplora calls at a `wiremock::MockServer` + /// without having to mutate the process-wide `NETWORK_CONFIG` + /// lazy_static (which is frozen on first access and shared across + /// every test in the binary). In production `start_rest_node` + /// clones `NETWORK_CONFIG` into this slot so the runtime + /// behaviour is unchanged. + pub(crate) esplora_config: Arc, + /// Test-only synchronisation primitive used by + /// `mint_handler_concurrent_mint_during_proof_returns_503`. The + /// production code path notifies via `notify_one()` after entering + /// phase 2 of `mint_handler` (after the `account_node` guard is + /// acquired) so the test can `.notified().await` deterministically + /// instead of `tokio::time::sleep(200ms)`. Hidden behind + /// `cfg(test)` so the field does not exist in release builds. + #[cfg(test)] + pub(crate) phase2_reached: Arc, + /// Test-only deterministic hold between `prepare_mint` (phase 2) + /// and the phase-3 re-derive. The handler acquires + immediately + /// drops this mutex AFTER `prepare_mint` returns and BEFORE the + /// re-derive reads SMT membership. Constructed unlocked so all + /// production-shaped tests proceed immediately (acquire is a + /// non-blocking no-op). The concurrent-mint race test grabs the + /// guard BEFORE spawning the request, holds it across the pk_N + /// injection, then drops it — a hard happens-before edge that + /// works for any number of sequential mints (unlike a `Notify` + /// where one consumed permit would block subsequent waiters). + /// Hidden behind `cfg(test)` so the field does not exist in + /// release builds. + #[cfg(test)] + pub(crate) phase3_release_lock: Arc>, + /// Test-only deterministic hold between the broadcast result and + /// the phase-3b state advance (`update_and_snapshot_for_persist`). + /// Mirrors `phase3_release_lock`: the handler acquires + immediately + /// drops this mutex AFTER `create_and_broadcast_inscription` returns + /// and BEFORE acquiring the state lock to apply the new commitment. + /// Constructed unlocked so production-shaped tests proceed + /// immediately. The in-process state.update Err test grabs the + /// guard before spawning the request, lets the handler run through + /// broadcast, injects the colliding SMT entry, then drops the + /// guard — at which point the handler's `state.update` observes + /// the collision and returns 503. Hidden behind `cfg(test)` so the + /// field does not exist in release builds. + #[cfg(test)] + pub(crate) state_advance_release_lock: Arc>, +} + +// Response types for our API +#[derive(Serialize, Deserialize)] +pub struct BalanceResponse { + balance: u64, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, +} + +#[cfg(any(feature = "address-list", feature = "lnurl"))] +#[derive(Serialize, Deserialize)] +pub struct AddressesResponse { + addresses: Vec, +} + +#[derive(Deserialize)] +pub struct SendCoinRequest { + account_address: String, + recipient: String, + amount: u64, + public_key: bitcoin::secp256k1::PublicKey, + next_public_key: bitcoin::secp256k1::PublicKey, + prev_commitment_pubkey: Option, + signature: Option, + timestamp: Option, +} + +#[derive(Deserialize)] +pub struct MintRequest { + account_address: String, + amount: u64, +} + +// `ReceiveCoinRequest` was the SP1-era POST body shape for a coin +// drop. It is currently unused — the receive flow is exercised via +// scanner + state.update — but kept as a placeholder for the future +// authenticated push endpoint. Mark `dead_code` to silence the lint. +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct ReceiveCoinRequest { + coin_proof: Proof, +} + +/// Persistent proof store — survives server restarts. +/// Each proof is stored as an individual file: /data/proofs/{id}.bin +pub(crate) struct ProofStore { + dir: String, + next_id: AtomicU64, +} + +impl ProofStore { + pub(crate) fn new(dir: &str) -> Self { + std::fs::create_dir_all(dir).ok(); + // Scan existing files to find the highest ID + let max_id = std::fs::read_dir(dir) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + e.file_name() + .to_str()? + .strip_suffix(".bin")? + .parse::() + .ok() + }) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + + ProofStore { + dir: dir.to_string(), + next_id: AtomicU64::new(max_id + 1), + } + } + + /// Build a safe file path for a proof ID within the store directory. + /// The ID is always a server-generated u64 and the suffix is the + /// literal ".bin", so `base.join(...)` cannot escape `base` — no + /// extra starts_with check is needed. + fn proof_path(&self, id: u64) -> Option { + let base = std::path::Path::new(&self.dir).canonicalize().ok()?; + Some(base.join(format!("{}.bin", id))) + } + + fn add_proof(&self, proof_with_commitment: CoinProof) -> u64 { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let path = self + .proof_path(id) + .expect("proof store directory exists (created in ProofStore::new)"); + let bytes = + bincode::serialize(&proof_with_commitment).expect("CoinProof is always serializable"); + Self::persist_proof_bytes(&path, &bytes, id); + id + } + + /// Best-effort persist: write `bytes` to `path` atomically, log the + /// I/O error if the write fails. Extracted so the error arm can be + /// exercised directly without having to construct a real `CoinProof` + /// (which requires the Plonky2 prover to run). + /// + /// "Atomic" here means write-to-temp + rename. `File::create` + + /// `sync_all` flushes the data file before the rename, and the + /// final rename is a single inode swap from the OS's perspective, + /// so a crash between the two never leaves a half-written + /// `{id}.bin` for `get_proof` to find. Inlined (rather than calling + /// a shared `atomic_write` helper) because the only remaining + /// user after PR-A3 is this proof store — `accounts.bin`, + /// `usernames.bin`, and `minting_num_pubkeys.bin` all moved to + /// Postgres. + fn persist_proof_bytes(path: &std::path::Path, bytes: &[u8], id: u64) { + let path_str = path.to_str().unwrap_or(""); + let tmp_path = format!("{}.tmp", path_str); + let result: std::io::Result<()> = (|| { + use std::io::Write; + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&tmp_path, path_str)?; + Ok(()) + })(); + if let Err(e) = result { + eprintln!("Failed to persist proof {}: {}", id, e); + } + } + + fn get_proof(&self, id: u64) -> Option { + let path = self.proof_path(id)?; + let bytes = std::fs::read(&path).ok()?; + bincode::deserialize(&bytes).ok() + } +} + +#[derive(Serialize, Deserialize, Default)] +pub struct SendCoinResponse { + pub(crate) success: bool, + /// Structured error message on failure. `None` on success. Mirrors + /// the body string returned alongside a 4xx/5xx status code, so + /// clients deserialising a non-2xx response can branch on it without + /// re-reading the body. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) proof_id: Option, + /// Hex-encoded hash fields the client needs to create a commitment (only set for user sends). + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) account_state_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output_coins_root: Option, +} + +/// Map a `send_coins` error string to an HTTP status code plus a +/// client-safe body message. +/// +/// Threat model (memory `feedback_threat_model_over_checklist`): +/// +/// - **422 UNPROCESSABLE_ENTITY** — the request is well-formed but the +/// witness is invalid (insufficient balance, in-coin not in source's +/// output_coins_root, source commitment not in history MMR, etc.). +/// The defense-in-depth shim added in PR #26 (Stage 5d-next-5 +/// Phase 2b) produces two of these strings in microseconds before +/// the minute-scale prove cost is paid; surfacing the specific +/// string lets clients distinguish "fix your inclusion proof" from +/// "fix your account selection". +/// - **404 NOT_FOUND** — sender address is not known to the server. +/// - **400 BAD_REQUEST** — request structure violates the API contract +/// (e.g. AccountUpdate transition without `prev_commitment_pubkey`). +/// - **500 INTERNAL_SERVER_ERROR** — the prover failed. Body collapses +/// to a generic `"prove failed"` to avoid leaking prover-internal +/// state to the caller. The full error string is logged via +/// `eprintln!` in the handler. +pub(crate) fn map_send_coins_error(err: &str) -> (StatusCode, &'static str) { + match err { + "Unknown account address" => (StatusCode::NOT_FOUND, "Unknown account address"), + "prev_commitment_pubkey required for account update" => ( + StatusCode::BAD_REQUEST, + "prev_commitment_pubkey required for account update", + ), + "Insufficient funds" => (StatusCode::UNPROCESSABLE_ENTITY, "Insufficient funds"), + // `get_merkle_proofs` failures — reachable from `send_coins` + // via the `prev_commitment_pubkey` path. The client supplied + // the wrong public key, or the previous proof references a + // history root the server hasn't seen yet (stale snapshot). + // Both are caller-fixable, hence 422 rather than 500. + "Unable to get merkle proofs for provided public key" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Unable to get merkle proofs for provided public key", + ), + "Unable to get mmr inclusion proof for the previous root" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Unable to get mmr inclusion proof for the previous root", + ), + // Truncated proof public-inputs vector — the proof stored on + // the account is corrupt or was produced by an incompatible + // build of the prover. Not caller-fixable; surfaces as 500. + "Proof public_inputs too short" => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Proof public_inputs too short", + ), + "In-coin not present in source's output_coins_root" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "In-coin not present in source's output_coins_root", + ), + "Source commitment not present in history MMR" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Source commitment not present in history MMR", + ), + "Coin is missing commitment" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Coin is missing commitment", + ), + "Should provide an inclusion proof" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Should provide an inclusion proof", + ), + "Coin should not exist in coin history tree" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Coin should not exist in coin history tree", + ), + "Coin should not exist in tree yet" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Coin should not exist in tree yet", + ), + "Too many in-coins for one transition" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Too many in-coins for one transition", + ), + "Too many out-coins for one transition" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "Too many out-coins for one transition", + ), + s if s.ends_with("failed") => (StatusCode::INTERNAL_SERVER_ERROR, "prove failed"), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"), + } +} + +/// Build a `SendCoinResponse` for a failed `send_coins` call, paired +/// with the appropriate HTTP status code. +pub(crate) fn send_coins_error_response(err: &str) -> (StatusCode, Json) { + let (status, body) = map_send_coins_error(err); + ( + status, + Json(SendCoinResponse { + success: false, + error: Some(body.to_string()), + ..SendCoinResponse::default() + }), + ) +} + +/// Build a `SendCoinResponse` for a request-level failure (signature +/// verification, hex decode, address length mismatch, broadcast +/// failure, etc.). Lets every handler failure carry a body.error +/// string instead of an opaque empty body. +pub(crate) fn handler_error_response( + status: StatusCode, + msg: &'static str, +) -> (StatusCode, Json) { + ( + status, + Json(SendCoinResponse { + success: false, + error: Some(msg.to_string()), + ..SendCoinResponse::default() + }), + ) +} + +/// Build the 503 response returned by `mint_handler` when the +/// post-proof re-derivation of `num_pubkeys` (from SMT membership) +/// reveals that another mint already landed on-chain since the SNAPSHOT +/// phase. Extracted from `mint_handler` so the (otherwise hard-to-race) +/// branch can be covered by a deterministic unit test in +/// `router_tests.rs` without having to orchestrate a real concurrent- +/// mint race against the live prover. +pub(crate) fn concurrent_mint_during_proof_response( + expected_num_pubkeys: u32, + observed_num_pubkeys: u32, +) -> (StatusCode, Json) { + eprintln!( + "Concurrent mint detected during proof phase: expected num_pubkeys={}, observed={}", + expected_num_pubkeys, observed_num_pubkeys + ); + handler_error_response(StatusCode::SERVICE_UNAVAILABLE, "Concurrent mint detected") +} + +#[derive(Deserialize)] +pub struct CommitRequest { + proof_id: u64, + /// Hex-encoded compressed public key (33 bytes) that signed the commitment. + public_key: bitcoin::secp256k1::PublicKey, + /// Hex-encoded Schnorr signature (64 bytes). + signature: String, + /// Hex-encoded message that was signed (the concatenation of account_state_hash + output_coins_root). + message: String, +} + +#[derive(Serialize, Deserialize)] +pub struct InfoResponse { + network: String, + capabilities: Capabilities, + /// External hostname this server serves, used by the client to render + /// `@`. DEV and PRD share the chain identifier + /// but live behind different external hostnames, so the client cannot + /// derive this from `network` alone — the server reports it directly. + username_domain: String, +} + +/// Server-side feature gates exposed to clients so the app can render +/// capability-driven UI without a parallel build-time env-flag set. +/// Each bool reflects a compile-time Cargo feature on the server binary, +/// except `faucet`: mint is part of the MVP and is always available, so +/// the field is hardcoded `true`. It is kept on the struct for API +/// back-compat with wallet clients that introspect `/api/info`. +#[derive(Serialize, Deserialize)] +pub struct Capabilities { + pub address_list: bool, + /// Always `true`. Mint is permanently part of the MVP binary; the + /// field is retained only so existing wallet clients deserialising + /// `/api/info` don't break. + pub faucet: bool, + pub usernames: bool, + pub lnurl: bool, +} + +// --- Username & LNURL types --- + +#[derive(Deserialize)] +pub struct ClaimUsernameRequest { + username: String, + address: String, + public_key: bitcoin::secp256k1::PublicKey, + signature: String, + timestamp: u64, +} + +#[derive(Serialize, Deserialize)] +pub struct UsernameResponse { + username: String, + address: String, +} + +#[cfg(feature = "lnurl")] +#[derive(Serialize, Deserialize)] +pub struct LnurlpResponse { + tag: String, + callback: String, + #[serde(rename = "minSendable")] + min_sendable: u64, + #[serde(rename = "maxSendable")] + max_sendable: u64, + metadata: String, +} + +#[derive(Serialize, Deserialize)] +pub struct LnurlErrorResponse { + status: String, + reason: String, +} + +// Handler functions for our REST API +async fn get_balance_handler( + State(state): State, + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + let account_node = lock_or_recover(&state.account_node); + + // Check if an address parameter was provided + if let Some(address_hex) = params.get("address") { + // Convert hex string to Address type + let address_vec = match hex::decode(address_hex.trim_start_matches("0x")) { + Ok(addr) => addr, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(BalanceResponse { + balance: 0, + username: None, + }), + ) + } + }; + + // Convert Vec to [u8; 32], then to Poseidon HashDigest. + let mut address_bytes = [0u8; 32]; + if address_vec.len() == 32 { + address_bytes.copy_from_slice(&address_vec); + } else { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(BalanceResponse { + balance: 0, + username: None, + }), + ); + } + let address = digest_from_bytes(&address_bytes); + + // Get balance for the specific account + let username = { + let username_store = lock_or_recover(&state.username_store); + username_store.get_username(&address).map(String::from) + }; + match account_node.get_account_balance(&address) { + Ok(balance) => (StatusCode::OK, Json(BalanceResponse { balance, username })), + // Unobserved address: canonical zero-balance state, not a not-found condition. + Err(_) => ( + StatusCode::OK, + Json(BalanceResponse { + balance: 0, + username, + }), + ), + } + } else { + // Missing required `address` query parameter — malformed request, + // not a routing miss. Matches the 422 returned by the invalid-hex + // and wrong-length branches above. + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(BalanceResponse { + balance: 0, + username: None, + }), + ) + } +} + +#[cfg(feature = "address-list")] +async fn get_address_handler(State(state): State) -> impl IntoResponse { + let account_node = lock_or_recover(&state.account_node); + + // Convert addresses to hex strings + let hex_addresses: Vec = account_node + .get_addresses() + .iter() + .map(|addr| format!("0x{}", hex::encode(digest_to_bytes(addr)))) + .collect(); + + Json(AddressesResponse { + addresses: hex_addresses, + }) +} + +async fn receive_coin_handler( + State(state): State, + body: Bytes, // Accept raw binary data instead of multipart +) -> impl IntoResponse { + // Try to deserialize the binary data as a CoinProof + let coin_proof = match bincode::deserialize::(&body) { + Ok(cp) => cp, + Err(e) => { + eprintln!("Failed to deserialize proof with commitment: {}", e); + return Json(SendCoinResponse::default()); + } + }; + let recipient = coin_proof.coin.recipient; + // Snapshot the recipient's mutated account inside the (sync) lock + // scope so the post-receive Postgres upsert runs without holding + // the guard across an `.await` point. + let snapshot: Option> = { + let mut account_node = lock_or_recover(&state.account_node); + match account_node.receive_coin(coin_proof) { + Ok(_) => account_node + .get_account(&recipient) + .map(AccountNode::serialize_account), + Err(_) => None, + } + }; + match snapshot { + Some(bytes) => { + let addr_bytes = digest_to_bytes(&recipient); + if let Err(e) = db::upsert_account(&state.pool, &addr_bytes, &bytes).await { + eprintln!("Failed to upsert recipient account after receive: {}", e); + } + Json(SendCoinResponse { + success: true, + ..Default::default() + }) + } + None => Json(SendCoinResponse::default()), + } +} + +async fn send_coin_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + println!("Received send post request..."); + + // Verify sender signature if provided (graceful: skip if not present for backwards compat) + if request.signature.is_some() { + if let Err(e) = verify_send_signature(&request) { + eprintln!("Signature verification failed: {}", e); + return handler_error_response( + StatusCode::UNAUTHORIZED, + "Signature verification failed", + ); + } + } + + // Create converted addresses (from_address and to_address) + let from_address_vec = match hex::decode(request.account_address.trim_start_matches("0x")) { + Ok(addr) => addr, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "account_address is not valid hex", + ) + } + }; + let to_address_vec = match hex::decode(request.recipient.trim_start_matches("0x")) { + Ok(addr) => addr, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "recipient is not valid hex", + ) + } + }; + + // Convert Vec to [u8; 32], then to Poseidon HashDigest. + let mut from_address_bytes = [0u8; 32]; + let mut to_address_bytes = [0u8; 32]; + if from_address_vec.len() == 32 && to_address_vec.len() == 32 { + from_address_bytes.copy_from_slice(&from_address_vec); + to_address_bytes.copy_from_slice(&to_address_vec); + } else { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "address must be 32 bytes (64 hex chars)", + ); + } + let from_address = digest_from_bytes(&from_address_bytes); + let to_address = digest_from_bytes(&to_address_bytes); + + // TODO: Provide the correct public keys from the client + // Acquire the account_node lock only for the duration of sending + // coins, and snapshot the resulting account bincode bytes *inside* + // the lock scope so the post-send Postgres upsert runs without + // holding the (sync) `std::sync::Mutex` guard across the `.await`. + // The guard cannot be held across an await point: `std::sync:: + // MutexGuard` is not `Send`, and even if it were, parking the + // future would block other handlers behind the same lock for the + // duration of the DB round-trip. + // `updated_account_bytes` is only meaningful on the Ok branch + // below — `send_coins` Ok implies the sender account exists in + // memory (it was just mutated). On the Err branch the snapshot is + // unused; we initialize it to an empty `Vec` to avoid an + // `Option`-shaped sentinel whose `None`-arm at the upsert site + // would never be reached at runtime (and thus could not be + // covered by tests). + let send_result: Result, &str>; + let updated_account_bytes: Vec; + { + let mut account_node_lock = lock_or_recover(&state.account_node); + let res = account_node_lock.send_coins( + vec![Invoice::new(request.amount, to_address)], + from_address, + request.public_key, + request.next_public_key, + request.prev_commitment_pubkey, + ); + updated_account_bytes = match &res { + Ok(_) => AccountNode::serialize_account( + account_node_lock + .get_account(&from_address) + .expect("send_coins Ok implies the sender account is in memory"), + ), + Err(_) => Vec::new(), + }; + send_result = res; + } + + eprintln!( + "Send result: {}", + if send_result.is_ok() { "ok" } else { "err" } + ); + + match send_result { + Ok(mut coin_proofs) => { + // PLONKY2 MIGRATION (Step 7): bridge from SP1's + // `public_values` byte stream to Plonky2's `public_inputs` + // field-element vector via `ProofData::from_field_elements`. + let pis: [zkcoins_program::F; + zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = coin_proofs[0] + .proof + .public_inputs[..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .expect("Plonky2 Proof emits N_PROOF_DATA_PUBLIC_INPUTS field elements"); + let pd = ProofData::from_field_elements(&pis); + let ash_hex = Some(hex::encode(digest_to_bytes(&pd.account_state_hash))); + let ocr_hex = Some(hex::encode(digest_to_bytes(&pd.output_coins_root))); + + // Note: User-initiated sends never pre-set + // `coin_proofs[0].commitment` (see + // `account_node::send_coins`, which always emits + // `commitment: None`). The mint flow constructs and + // broadcasts its own commitment inside `mint_handler`. The + // pre-MVP `if let Some(commitment) = coin_proofs[0] + // .commitment.as_ref() { … broadcast … }` block that used + // to live here was dead under both flows and has been + // removed; clients commit explicitly via `/api/commit`. + + // Persist proof FIRST (crash-safe: proof exists even if + // account save fails). send_coins always returns a non-empty + // Vec on Ok, so pop().unwrap() is total here. + let proof_id = state.proof_store.add_proof( + coin_proofs + .pop() + .expect("send_coins returns at least one coin_proof on Ok"), + ); + // Now persist the mutated sender account (proof is already + // safe on disk). Best-effort: a database hiccup here leaves + // the proof + in-memory state correct but the persistent + // account row stale; the next mutation will overwrite it. + // We log and continue rather than failing the request, + // which mirrors the pre-Postgres `save_to_file` semantics. + let addr_bytes = digest_to_bytes(&from_address); + if let Err(e) = + db::upsert_account(&state.pool, &addr_bytes, &updated_account_bytes).await + { + eprintln!("Failed to upsert sender account after send: {}", e); + } + + ( + StatusCode::OK, + Json(SendCoinResponse { + success: true, + error: None, + proof_id: Some(proof_id), + account_state_hash: ash_hex, + output_coins_root: ocr_hex, + }), + ) + } + Err(e) => { + eprintln!("send_coins error: {}", e); + send_coins_error_response(e) + } + } +} + +/// Mint a fresh coin into `account_address`, advancing the minting +/// account's BIP-32 child index by 1 — but only if the on-chain +/// inscription broadcast succeeds AND no concurrent mint beat us to +/// the Postgres commit. +/// +/// **Four phases, load-bearing ordering** (zk-coins/node#89): +/// +/// 1. **SNAPSHOT.** Take the account_node guard briefly to clone the +/// `Arc>`, then derive `N = derive_num_pubkeys_from_smt +/// (xpriv, &smt)` under the state lock — N is the first BIP-32 +/// child index whose `sha256(pk_n.serialize())` is absent from the +/// SMT. Generate the three pubkeys the prover witness needs +/// (`pk_N`, `pk_{N+1}`, optional `pk_{N-1}`). No mutation. +/// 2. **PROOF.** Briefly take the `account_node` guard, call +/// [`AccountNode::prepare_mint`] (clone-based, pure). Release +/// the guard. Build the signed `Commitment` over the prover's +/// output_coins_root + account_state_hash using a transient +/// ClientAccount clone with `num_pubkeys = N + 1` (so +/// `current_private_key` derives at index N) — the shared +/// ClientAccount is NOT mutated yet. Re-derive N from the SMT +/// immediately before signing and abort with 503 if it has +/// advanced — the scanner may have ingested a concurrent mint's +/// inscription while we were proving, which would invalidate the +/// pubkeys baked into the prover witness. +/// 3. **BROADCAST.** Inscribe the serialized `Commitment` onto Bitcoin. +/// On any error → 503 SERVICE_UNAVAILABLE. No DB write, no in- +/// memory mutation, no recipient update. The next mint retries +/// from `N` cleanly. +/// 4. **COMMIT.** Apply receives to the LIVE recipients under the +/// account_node lock (additive `receive_coin`, never overwriting), +/// then UPSERT the mutated minting account and every touched +/// recipient via [`db::commit_mint_tx`]. No counter step — N is +/// re-derived from SMT membership at the next mint. +/// +/// **Concurrency gate (Phase D).** The pre-Phase-D shape carried an +/// optimistic `UPDATE minting_meta SET num_pubkeys = N+1 WHERE +/// num_pubkeys = N` inside `commit_mint_tx` that serialised concurrent +/// mints at the DB layer: the loser observed `rows_affected == 0` and +/// the handler mapped that to a 503. Phase D dropped the counter +/// outright (it lived only in `minting_meta`, which migration 0005 +/// drops), so the in-process gate is the phase-2 re-derivation +/// described above. The on-chain gate is the scanner's `state.update`: +/// `SparseMerkleTree::insert` errors on a duplicate key with a +/// different value, so a true double-mint at pubkey index N (two +/// handlers that both broadcast before either inscription was +/// scanned) surfaces as a "Key already exists in the tree with +/// different value" error inside the scanner callback — the second +/// inscription is logged and dropped, the first remains +/// authoritative. The on-chain blobs are operationally cheap (the +/// publisher pays the fee, not the user). Clients that see a 503 +/// retry; the next mint observes the new N and proceeds. +/// +/// **Retry semantics.** Because the inscription is deterministically +/// derived from `(commitment, publisher_key)`, a 503 from broadcast +/// failure followed by a retry produces the *same* inscription txid. +/// Bitcoin's mempool will respond with `txn-already-known` if the +/// first broadcast actually landed but the response was lost — the +/// caller observes a second 503 here even though the chain has the +/// commitment. The scanner-on-next-boot reconciliation path closes +/// this window: the inscription is ingested into the SMT on the next +/// scanner sweep, the next mint's `derive_num_pubkeys_from_smt` walks +/// past it cleanly, and the wallet's retry semantics drive progress. +/// Document-only — no in-handler retry. +async fn mint_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + println!("Minting coins..."); + let account_address_vec = match hex::decode(request.account_address.trim_start_matches("0x")) { + Ok(addr) => addr, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "account_address is not valid hex", + ) + } + }; + + let mut account_address_bytes = [0u8; 32]; + if account_address_vec.len() == 32 { + account_address_bytes.copy_from_slice(&account_address_vec); + } else { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "account_address must be 32 bytes (64 hex chars)", + ); + } + let account_address = digest_from_bytes(&account_address_bytes); + + // ---- 1. SNAPSHOT phase (no mutation) --------------------------------- + // Derive `N = num_pubkeys` from SMT membership: the SMT is loaded + // from Postgres at boot and mutated by the scanner on every + // inscription, so it is authoritative. We avoid holding the + // `account_node` guard across the SMT walk by cloning the inner + // `Arc>` first. + let state_arc = { + let account_node_guard = lock_or_recover(&state.account_node); + account_node_guard.state().clone() + }; + let (expected_num_pubkeys, minting_pubkey, next_minting_pubkey, prev_commitment_pubkey) = { + let minting_account_guard = lock_or_recover(&state.minting_account); + let n = { + let state_guard = lock_or_recover(&state_arc); + crate::state::derive_num_pubkeys_from_smt( + &minting_account_guard.private_key, + &state_guard.smt, + ) + }; + let prev_pk = if n > 0 { + Some(minting_account_guard.generate_public_key(n - 1)) + } else { + None + }; + ( + n, + minting_account_guard.generate_public_key(n), + minting_account_guard.generate_public_key(n + 1), + prev_pk, + ) + }; + + // ---- 2. PROOF phase (no mutation, clone-based) ----------------------- + let prepared = { + let account_node_guard = lock_or_recover(&state.account_node); + // Test-only barrier: notify any test waiting on + // `state.phase2_reached` that the handler has acquired the + // account_node guard and is about to invoke `prepare_mint`. + // Production builds compile this out entirely (the field does + // not exist in release). + #[cfg(test)] + state.phase2_reached.notify_one(); + // get_minting_account_address borrows immutably below, fine. + if account_node_guard + .get_account(&zkcoins_program::types::MINTING_ADDRESS) + .is_none() + { + return handler_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Minting account not configured", + ); + } + account_node_guard.prepare_mint( + vec![Invoice::new(request.amount, account_address)], + minting_pubkey, + next_minting_pubkey, + prev_commitment_pubkey, + ) + }; + let mut prepared = match prepared { + Ok(p) => { + eprintln!("Mint prepare: ok"); + p + } + Err(e) => { + eprintln!("Mint prepare: err — {}", e); + return send_coins_error_response(e); + } + }; + + // Test-only deterministic hold between `prepare_mint` and the + // phase-3 re-derive. Pre-unlocked in all `test_state` + // constructors so production-shaped tests acquire + drop in one + // step. The concurrent-mint race test holds the guard from the + // outside across the pk_N injection, forcing the handler to + // block here until the injection is visible. Production builds + // compile this out entirely (the field does not exist). + #[cfg(test)] + drop(state.phase3_release_lock.lock().await); + + // Build the BIP-340 commitment over the prover's outputs. Sign with + // the index-N private key — this is the same key the wallet would + // sign with once `num_pubkeys` advances past N. We do NOT mutate + // the shared ClientAccount's `num_pubkeys`; build a transient clone + // where `num_pubkeys = N + 1` so its `current_private_key()` + // derives at index N. + // + // Re-derive N from SMT membership immediately before signing — if + // the scanner ingested a concurrent mint's inscription while we + // were proving, the pubkeys baked into the witness are stale and + // every downstream consumer will reject the resulting commitment. + // Abort with 503; the wallet retries and the next attempt observes + // the new N. This is the in-process leg of the Phase-D concurrency + // gate documented on `mint_handler`'s doc-comment. + let commitment = { + let minting_account_guard = lock_or_recover(&state.minting_account); + let current_num_pubkeys = { + let state_guard = lock_or_recover(&state_arc); + crate::state::derive_num_pubkeys_from_smt( + &minting_account_guard.private_key, + &state_guard.smt, + ) + }; + if current_num_pubkeys != expected_num_pubkeys { + return concurrent_mint_during_proof_response( + expected_num_pubkeys, + current_num_pubkeys, + ); + } + let pis: [zkcoins_program::F; zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = + prepared.coin_proofs[0].proof.public_inputs + [..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .expect("prover always emits N_PROOF_DATA_PUBLIC_INPUTS field elements"); + let proof_data = ProofData::from_field_elements(&pis); + let signing_clone = shared::ClientAccount { + address: minting_account_guard.address, + num_pubkeys: expected_num_pubkeys + 1, + private_key: minting_account_guard.private_key, + }; + signing_clone.create_commitment( + &proof_data.account_state_hash, + &proof_data.output_coins_root, + ) + }; + prepared.coin_proofs[0].commitment = Some(commitment.clone()); + + // ---- 3. BROADCAST phase --------------------------------------------- + let commitment_data = bincode::serialize(&commitment).expect("Failed to serialize commitment"); + println!( + "Sending commitment data with size: {} bytes", + commitment_data.len() + ); + println!("Commitment data hex: {}", hex::encode(&commitment_data)); + // NOTE (idempotent retry, zk-coins/node#89): on a retry after a + // transient broadcast failure the publisher wallet's UTXO set has + // changed (`get_publisher_utxo` selects fresh inputs every call), + // so the new `commit_tx` has different inputs → different + // commit_txid. Bitcoin does NOT short-circuit with + // `txn-already-known` — both attempts land on chain as distinct + // transactions. Idempotency is enforced one layer up: the + // inscription payload encodes the same `(public_key, commitment)` + // for both broadcasts, the scanner's `SparseMerkleTree::insert` is + // idempotent on same key + same value (the second insert is a + // no-op), and `State::update` deduplicates accordingly. The MMR + // rebuild from scanner replay therefore produces a stable state + // regardless of how many transient broadcast attempts landed on + // chain. The handler still observes an Err here on a genuine + // broadcast failure and returns 503; reconciliation happens on the + // next scanner sweep. No in-handler retry. + let broadcast_outcome = create_and_broadcast_inscription( + &commitment_data, + &state.esplora_config, + Some(&state.pool), + ) + .await; + let commit_txid_bytes: [u8; 32] = match broadcast_outcome { + Ok((commit_txid, _reveal_txid)) => { + use bitcoin::hashes::Hash as _; + commit_txid.to_byte_array() + } + Err(err) => { + eprintln!("Error broadcasting mint inscription: {}", err); + return handler_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Failed to broadcast mint inscription on-chain", + ); + } + }; + + // ---- 3b. STATE_ADVANCE phase (Phase E, broadcast OK) ---------------- + // Apply the freshly-broadcast commitment to the in-memory SMT + MMR + // and persist the resulting snapshot — together with the + // `pending_inscriptions.status = 'complete'` row advance — in ONE + // atomic Postgres transaction (`persist_state_and_mark_complete_tx`). + // The scanner's pre-state.update lookup uses that `complete` marker + // to skip its own redundant integration when it later observes the + // same commit on chain. + // + // Rationale (this is the regression Phase E fixes): the scanner + // observed a mint's commit ~20-30 s after `/api/mint` returned 200. + // A wallet that issued a second mint inside that window walked + // `derive_num_pubkeys_from_smt` against the un-updated SMT, signed + // with the same pubkey index as the first mint, and surfaced + // `Unable to get mmr inclusion proof for the previous root` at the + // prover. Advancing `state.update` synchronously here closes the + // window: the second mint's SMT walk sees the first mint's entry + // immediately. The scanner becomes a redundant observer for our + // own inscriptions and remains the authoritative path for external + // recovery inscriptions and out-of-band commits. + // + // Lock topology: the state lock is acquired AFTER the broadcast + // completes (broadcasting is slow and would otherwise serialize + // all `/api/mint` requests behind a single in-flight inscription). + // + // Crash-recovery contract (the BLOCKER this commit fixed): the + // previous two-step shape (persist SMT/MMR/root_index, then a + // standalone UPDATE to `complete`) opened a window where the + // SMT/MMR/root_index could land on disk while the row stayed at + // `reveal_broadcast`. On restart, `State::load_from_pg` rebuilt the + // in-memory state WITH the new leaf, the scanner re-scanned the + // block, observed `reveal_broadcast` → `should_skip_scanner_state_update` + // returned `false`, and `state.update` ran a second time — the SMT + // insert was an idempotent no-op (same key+value) but + // `mmr.append(leaf)` appended a DUPLICATE leaf, diverging the MMR + // root. The atomic single-tx persist + mark-complete below + // guarantees that on success, the scanner-skip predicate will + // correctly fire on replay. On tx failure, the row stays at + // `reveal_broadcast` and the in-memory state advance was NOT + // persisted to disk (transaction atomicity); the scanner will + // replay cleanly. + // Test-only deterministic hold between the broadcast result and + // the phase-3b state advance. Pre-unlocked in all `test_state` + // constructors so production-shaped tests acquire + drop in one + // step. The in-process state.update Err test holds the guard + // across a colliding SMT injection so the handler observes the + // collision when its `state.update` finally runs. Production + // builds compile this out entirely (the field does not exist). + #[cfg(test)] + drop(state.state_advance_release_lock.lock().await); + + let state_advance_outcome = { + let state_arc_for_advance = { + let account_node_guard = lock_or_recover(&state.account_node); + account_node_guard.state().clone() + }; + let mut state_guard = lock_or_recover(&state_arc_for_advance); + state_guard.update_and_snapshot_for_persist(std::slice::from_ref(&commitment)) + }; + let (new_root, smt_bytes, mmr_bytes, root_index_entry) = match state_advance_outcome { + Ok(snapshot) => snapshot, + Err(e) => { + // The in-process SMT/MMR could not be advanced — typically + // an SMT key-collision-with-different-value (a concurrent + // mint race that slipped the phase-2 re-derive gate, or a + // genuine bug). The broadcast already landed on chain, but + // the caller's mint was NOT integrated synchronously. The + // publisher already advanced the row to `reveal_broadcast` + // BEFORE the broadcast call; we keep it there so the + // scanner-replay path will pick the inscription up from + // chain and run state.update against the un-mutated SMT. + // Return 503 so the wallet knows the mint did NOT land + // synchronously and can poll for completion. + eprintln!( + "mint_handler: in-process state.update failed: {} (broadcast already landed; scanner-replay will reconcile)", + e + ); + return handler_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "mint broadcast landed on chain but in-process state advance failed; scanner will reconcile", + ); + } + }; + let root_index_ref = root_index_entry.as_ref().map(|(p, s, i)| (p, s, *i as u64)); + match db::persist_state_and_mark_complete_tx( + &state.pool, + &smt_bytes, + &mmr_bytes, + root_index_ref, + &commit_txid_bytes, + ) + .await + { + Ok(()) => { + println!( + "mint_handler: state.update persisted + row marked complete. New MMR root: {}", + hex::encode(zkcoins_program::hash::digest_to_bytes(&new_root)) + ); + } + Err(e) => { + // The atomic tx rolled back: SMT/MMR/root_index AND + // the row advance all stayed at their pre-call values + // on disk. The in-memory SMT/MMR HAVE already been + // mutated (that happened above before the await), so + // they are now ahead of disk by exactly one leaf. + // On restart, `State::load_from_pg` returns the + // pre-update on-disk state and the scanner-replay path + // walks the block, observes the row at + // `reveal_broadcast`, and integrates the inscription + // itself — a clean heal. Return 503 so the caller + // knows the durable state did not advance. + eprintln!( + "mint_handler: atomic persist + mark-complete failed: {} (scanner-replay will heal)", + e + ); + return handler_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "mint broadcast landed on chain but durable state advance failed; scanner will reconcile", + ); + } + } + + // ---- 4. COMMIT phase (broadcast OK) --------------------------------- + // Apply receives to the LIVE in-memory recipient under the + // account_node lock (additive `receive_coin`, never overwriting), + // then UPSERT every touched account (minting + recipients) in a + // single sqlx transaction via [`db::commit_mint_tx`]. + // + // Rationale (zk-coins/node#89 round-2 MAJOR 1): a previous shape + // snapshot-cloned each recipient under the lock, mutated the + // clone, then `import_account`'d the clone back after the tx + // commit. Between the snapshot read and the post-tx overwrite the + // lock was released across the `await` on `commit_mint_tx`. A + // concurrent `/api/send` flow that landed in + // `broadcast_commit_and_deliver` could mutate the live recipient + // in that window — and the post-tx `import_account` would clobber + // it with our stale clone, losing the concurrent update both in + // memory and (eventually) in the DB. The fix is to take the + // account_node guard, do the `receive_coin` mutations, snapshot + // the LIVE account state inside the same critical section, then + // hand the bundle (already-fresh bytes) to the async DB upsert. + let minting_addr_bytes = + zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS); + let minting_snapshot_bytes = AccountNode::serialize_account(&prepared.mutated_minting); + + let recipient_snapshots: Vec<(zkcoins_program::hash::HashDigest, Vec)> = { + let mut account_node_guard = lock_or_recover(&state.account_node); + account_node_guard.commit_mint(prepared.mutated_minting); + let mut snaps = Vec::with_capacity(prepared.coin_proofs.len()); + for coin_proof in &prepared.coin_proofs { + let recipient = coin_proof.coin.recipient; + if let Err(e) = account_node_guard.receive_coin(coin_proof.clone()) { + // Best-effort: a duplicate / replay error here means + // the recipient already has this coin (e.g. scanner- + // replay after restart). Log and still snapshot + // whatever the live recipient looks like so the DB + // row stays current. + eprintln!("Failed to receive minted coin into live recipient: {}", e); + } + if let Some(acct) = account_node_guard.get_account(&recipient) { + snaps.push((recipient, AccountNode::serialize_account(acct))); + } + } + snaps + }; + + // Build the per-account UPSERT bundle. `commit_mint_tx` writes + // every entry in one transaction so a partial-failure leaves the + // accounts table consistent. + let mut commit_rows: Vec<(&[u8], &[u8])> = Vec::with_capacity(1 + recipient_snapshots.len()); + commit_rows.push((&minting_addr_bytes[..], &minting_snapshot_bytes[..])); + let recipient_addr_bytes: Vec<[u8; 32]> = recipient_snapshots + .iter() + .map(|(addr, _)| zkcoins_program::hash::digest_to_bytes(addr)) + .collect(); + for ((_, bytes), addr_bytes) in recipient_snapshots.iter().zip(recipient_addr_bytes.iter()) { + commit_rows.push((&addr_bytes[..], &bytes[..])); + } + if let Err(e) = db::commit_mint_tx(&state.pool, &commit_rows).await { + eprintln!("Failed to commit mint transaction to Postgres: {}", e); + // The on-chain commitment landed and the in-memory state is + // already updated, but the DB persistence failed. Return 503 + // so the client knows nothing is durable on our side; the + // scanner-replay path on next boot will rehydrate the SMT + // from chain and the next mint observes the correct N via + // `derive_num_pubkeys_from_smt`. + return handler_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Failed to persist mint commit transaction", + ); + } + + let mut coin_proofs = prepared.coin_proofs; + // `mint_handler` passes a single-element `vec![Invoice::new(...)]` + // to `prepare_mint`; `send_coins_inner` builds `coin_proofs` with + // `out_coins.len() == coin_templates.len() == invoices.len() == 1`, + // so the Ok-arm Vec has length exactly 1 — `pop()` is total. + let proof_id = state.proof_store.add_proof( + coin_proofs + .pop() + .expect("send_coins returns exactly one coin_proof for single-invoice mint"), + ); + ( + StatusCode::OK, + Json(SendCoinResponse { + success: true, + error: None, + proof_id: Some(proof_id), + account_state_hash: None, + output_coins_root: None, + }), + ) +} + +// New handler to get a binary proof by ID +async fn get_proof_handler( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.proof_store.get_proof(id) { + Some(proof_with_commitment) => { + // Serialize the proof and commitment together to binary + let binary_data = bincode::serialize(&proof_with_commitment).unwrap_or_default(); + + // Set appropriate headers for binary download + let mut headers = header::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + header::HeaderValue::from_static("attachment; filename=\"coin_proof.bin\""), + ); + + (StatusCode::OK, headers, Bytes::from(binary_data)) + } + None => ( + StatusCode::NOT_FOUND, + header::HeaderMap::new(), + Bytes::new(), + ), + } +} + +/// Accepts a client-signed commitment for a previously generated proof. +/// Broadcasts the commitment as a Taproot inscription and delivers the coin to the recipient. +/// +/// **Broadcast-then-deliver invariant (zk-coins/node#89).** Unlike +/// the mint flow, the `/api/commit` endpoint receives a *proof_id* the +/// server already generated (in an earlier `/api/send` call), looks up +/// the persisted `CoinProof`, broadcasts its commitment, and only then +/// hands the proof to `receive_coin` for the recipient mutation. The +/// in-memory mutation lives in [`broadcast_commit_and_deliver`] in +/// `runtime.rs`; the broadcast call sits at the very top of +/// that function and returns 503 on failure with NO subsequent state +/// mutation, so there is no analogue of the mint state-desync class +/// here. DO NOT reorder the broadcast and the `receive_coin` call — +/// the audit in zk-coins/node#89 verified this ordering is correct +/// and any future refactor must preserve it. +async fn commit_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + // Retrieve the stored coin proof + let coin_proof = match state.proof_store.get_proof(request.proof_id) { + Some(p) => p, + None => { + return handler_error_response(StatusCode::NOT_FOUND, "Unknown proof_id"); + } + }; + + // Reconstruct the Commitment from the client-provided fields + let message_bytes = match hex::decode(&request.message) { + Ok(b) => b, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "message is not valid hex", + ); + } + }; + let sig_bytes = match hex::decode(&request.signature) { + Ok(b) => b, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "signature is not valid hex", + ); + } + }; + let signature = match bitcoin::secp256k1::schnorr::Signature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "signature is not a valid Schnorr signature", + ); + } + }; + + let commitment = Commitment { + public_key: request.public_key, + signature, + message: message_bytes, + }; + + // Verify the commitment + if !commitment.verify() { + return handler_error_response(StatusCode::UNAUTHORIZED, "Commitment signature invalid"); + } + + crate::runtime::broadcast_commit_and_deliver(&state, commitment, coin_proof, request.proof_id) + .await +} + +/// JSON body returned by `GET /health/ready`. `failures` is empty on a +/// fully ready server; each failing dependency contributes one stable +/// short tag (`"db"`, `"esplora"`) so a Kuma monitor parses the cause +/// without having to scrape the status code in isolation. +#[derive(Serialize)] +struct ReadyResponse { + ready: bool, + failures: Vec<&'static str>, +} + +/// Readiness probe (`GET /health/ready`). +/// +/// **Liveness vs readiness.** The pre-existing `/health` endpoint is +/// the Kubernetes-style liveness probe: it returns `"ok"` with 200 as +/// long as the HTTP listener is bound and the tokio runtime is alive. +/// It deliberately does NOT touch the database or Esplora, so an +/// upstream blip never restarts the process — losing the in-memory +/// `account_node` and `state` to a restart would lose every mint / +/// send the scanner has not yet checkpointed. +/// +/// `/health/ready` is the complementary readiness probe: it actively +/// pings Postgres (`SELECT 1`) and Esplora (`GET /blocks/tip/height`, +/// re-using the configured `ESPLORA_URL`) and returns 503 if either +/// fails. A load balancer / uptime monitor uses this to decide +/// "should traffic flow?" without using it to decide "should this +/// process die?". The Kuma monitor at +/// watches `api.zkcoins.app/health/ready` +/// on a 60 s interval — separate alert from the liveness check. +/// +/// No caching: each call issues a fresh DB round-trip plus an Esplora +/// HEAD-equivalent. Both are sub-100 ms in steady state, and a cached +/// stale "ready" is worse than a slightly slow honest answer. +async fn ready_handler(State(state): State) -> impl IntoResponse { + let mut failures: Vec<&'static str> = Vec::new(); + + if sqlx::query("SELECT 1").execute(&*state.pool).await.is_err() { + failures.push("db"); + } + + if check_esplora(&state.esplora_config).await.is_err() { + failures.push("esplora"); + } + + let ready = failures.is_empty(); + let status = if ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + (status, Json(ReadyResponse { ready, failures })) +} + +/// Ping the configured Esplora endpoint. A successful tip-height fetch +/// proves the upstream is reachable AND serving the public REST API +/// (a TCP-only liveness check would miss a broken nginx upstream). +async fn check_esplora( + config: &EsploraConfig, +) -> Result<(), Box> { + use esplora_client::{r#async::DefaultSleeper, AsyncClient, Builder}; + let client = AsyncClient::::from_builder(Builder::new(&config.url))?; + client.get_height().await?; + Ok(()) +} + +/// JSON body returned by `GET /health/publisher`. Surface enough state +/// for the deploy-dev preflight (and a curious operator) to make the +/// "should I top up the publisher wallet?" decision without scraping +/// Esplora directly. `address` is the publisher's Taproot bech32 — log- +/// only, NOT a secret (the matching key lives in `PUBLISHER_KEY`). +#[derive(Serialize)] +struct PublisherHealthResponse { + address: String, + utxo_count: u64, + total_sats: u64, +} + +/// Operational preflight (`GET /health/publisher`). +/// +/// Reads the publisher Taproot wallet's UTXO set via the configured +/// Esplora endpoint and reports `(address, utxo_count, total_sats)`. +/// The deploy-dev workflow probes this BEFORE running the API E2E +/// suite — an empty wallet would otherwise cause every mint to 503 +/// and historically masked as a "green" run because the E2E suite +/// itself silently treated 5xx as a skip. Returning 503 on an +/// Esplora-side error is intentional: the operator should see the +/// failure mode, not a fabricated empty response. +async fn publisher_health_handler(State(state): State) -> impl IntoResponse { + let publisher_address = crate::PUBLISHER_ADDRESS.clone(); + + match crate::publisher::get_publisher_utxo(&publisher_address, &state.esplora_config, None) + .await + { + Ok(utxos) => { + let utxo_count = utxos.len() as u64; + let total_sats: u64 = utxos.iter().map(|(_, sats)| sats).sum(); + ( + StatusCode::OK, + Json( + serde_json::to_value(PublisherHealthResponse { + address: publisher_address.to_string(), + utxo_count, + total_sats, + }) + .expect("publisher health response serializes"), + ), + ) + .into_response() + } + Err(e) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "Esplora-side error fetching publisher UTXOs", + "detail": e.to_string(), + "address": publisher_address.to_string(), + })), + ) + .into_response(), + } +} + +async fn info_handler() -> impl IntoResponse { + Json(InfoResponse { + network: NETWORK_CONFIG.network_name.clone(), + capabilities: Capabilities { + address_list: cfg!(feature = "address-list"), + // Hardcoded — mint is permanent MVP; field is back-compat only. + faucet: true, + // Hardcoded — usernames are permanent MVP; field is back-compat only. + usernames: true, + lnurl: cfg!(feature = "lnurl"), + }, + username_domain: USERNAME_DOMAIN.clone(), + }) +} + +#[derive(Serialize)] +struct RootResponse { + service: &'static str, + version: &'static str, + network: String, + endpoints: RootEndpoints, + docs: &'static str, +} + +#[derive(Serialize)] +struct RootEndpoints { + info: &'static str, + balance: &'static str, + send: &'static str, + receive: &'static str, + commit: &'static str, + proof: &'static str, + health: &'static str, +} + +/// Root handler — anything hitting `https://api.zkcoins.app/` (browser visit, +/// uptime probe, curious operator) gets a small JSON identifying the service, +/// the package version, the connected network, and pointers to the real +/// endpoints. Cheaper than serving a static landing page and still answers the +/// "is this the right host?" question without surfacing a bare 404. +async fn root_handler() -> impl IntoResponse { + Json(RootResponse { + service: "zkcoins-node", + version: env!("CARGO_PKG_VERSION"), + network: NETWORK_CONFIG.network_name.clone(), + endpoints: RootEndpoints { + info: "GET /api/info", + balance: "GET /api/balance?address={hex}", + send: "POST /api/send", + receive: "POST /api/receive", + commit: "POST /api/commit", + proof: "GET /api/proof/{id}", + health: "GET /health", + }, + docs: "https://docs.zkcoins.app", + }) +} + +// --- Username & LNURL handlers --- + +async fn claim_username_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + // Normalise the username up-front so the Schnorr signature, the + // in-memory mirror, and the Postgres row all agree on the exact + // byte string. Hashing the raw `request.username` while persisting + // `to_lowercase()` lets a wallet that signs over `"Alice"` end up + // squatting `"alice"` — see PR #76's prod-readiness review. + let normalized_username = match crate::username::UsernameStore::validate(&request.username) { + Ok(n) => n, + Err(err) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: err.into(), + }), + ) + .into_response(); + } + }; + + // Decode address + let address_vec = match hex::decode(request.address.trim_start_matches("0x")) { + Ok(a) => a, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid address hex".into(), + }), + ) + .into_response() + } + }; + let mut address_bytes = [0u8; 32]; + if address_vec.len() != 32 { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Address must be 32 bytes".into(), + }), + ) + .into_response(); + } + address_bytes.copy_from_slice(&address_vec); + let address = digest_from_bytes(&address_bytes); + + // Verify public key matches address: sha256(compressed_pubkey) == address + let pk_hash: [u8; 32] = Sha256::digest(request.public_key.serialize()).into(); + if pk_hash != address_bytes { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Public key does not match address".into(), + }), + ) + .into_response(); + } + + // Verify timestamp freshness (5 min window) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now.abs_diff(request.timestamp) > 300 { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Timestamp too old or in the future".into(), + }), + ) + .into_response(); + } + + // Verify Schnorr signature over sha256("zkcoins:claim_username" || address_hex || normalised_username || timestamp_le). + // The wallet MUST sign over the lowercase form (same normalisation + // as `UsernameStore::validate`) — otherwise the same input that the + // server persists is not what the signature commits to, opening + // the case-mismatch squat described above. + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(request.address.as_bytes()); + hasher.update(normalized_username.as_bytes()); + hasher.update(request.timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let sig_bytes = match hex::decode(&request.signature) { + Ok(b) => b, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid signature hex".into(), + }), + ) + .into_response() + } + }; + let sig = match SchnorrSignature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Invalid signature format".into(), + }), + ) + .into_response() + } + }; + let (xonly, _) = request.public_key.x_only_public_key(); + let secp = secp::Secp256k1::verification_only(); + if secp.verify_schnorr(&sig, &msg, &xonly).is_err() { + return ( + StatusCode::UNAUTHORIZED, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Signature verification failed".into(), + }), + ) + .into_response(); + } + + // Claim path, three steps. The previous `mem::take` approach left + // the in-memory `UsernameStore` observable as empty for the full + // duration of the DB round-trip — every `resolve` / `get_username` + // request in that window saw a blank mirror, including + // `get_balance_handler`'s `username` lookup. + // + // Split design: + // 1. short sync lock → `precheck` (read-only) + // 2. drop lock → async `db::claim_username` (`ON CONFLICT DO NOTHING`) + // 3. short sync lock → `commit_after_db` (in-memory insert) + // + // Reads concurrent with a claim now always see the full mirror. + // Concurrent writers race at the SQL `ON CONFLICT` boundary as + // before; the second writer hits `rows_affected == 0` and the + // handler maps that to a 409. The post-commit insert is idempotent + // — re-inserting the same `(normalized, address)` is a no-op. + if let Err(reason) = + lock_or_recover(&state.username_store).precheck(&normalized_username, &address) + { + // `precheck` returns the static collision strings the wallet + // surfaces verbatim. The status is `409 CONFLICT` for either + // collision variant — same shape as the SQL-layer race below. + return ( + StatusCode::CONFLICT, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: reason.into(), + }), + ) + .into_response(); + } + + let addr_bytes = digest_to_bytes(&address); + let inserted = + match crate::db::claim_username(&state.pool, &normalized_username, &addr_bytes).await { + Ok(b) => b, + Err(db_err) => { + eprintln!("Failed to persist username claim: {}", db_err); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Failed to persist username claim".into(), + }), + ) + .into_response(); + } + }; + if !inserted { + // Concurrent claimer won the `ON CONFLICT` race for the same + // name. Surface as the same 409 a precheck collision would. + return ( + StatusCode::CONFLICT, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Username already taken".into(), + }), + ) + .into_response(); + } + + lock_or_recover(&state.username_store).commit_after_db(normalized_username.clone(), address); + + ( + StatusCode::OK, + Json(UsernameResponse { + username: normalized_username, + address: format!("0x{}", hex::encode(digest_to_bytes(&address))), + }), + ) + .into_response() +} + +/// Resolve an identifier to an address. Checks the username store first, +/// then falls back to hex-prefix matching against known account addresses. +/// Used by the always-on username handlers and the gated LNURL handlers. +fn resolve_identifier( + state: &AppState, + identifier: &str, +) -> Option<(zkcoins_program::hash::HashDigest, String)> { + let normalized = identifier.to_lowercase(); + + // 1. Check custom username + let username_store = lock_or_recover(&state.username_store); + if let Some(address) = username_store.resolve(&normalized) { + return Some((address, normalized)); + } + drop(username_store); + + // 2. Check hex prefix against known addresses + let account_node = lock_or_recover(&state.account_node); + account_node + .get_addresses() + .into_iter() + .find(|addr| hex::encode(digest_to_bytes(addr)).starts_with(&normalized)) + .map(|addr| (addr, normalized)) +} + +async fn resolve_username_handler( + State(state): State, + Path(username): Path, +) -> impl IntoResponse { + match resolve_identifier(&state, &username) { + Some((address, resolved_name)) => ( + StatusCode::OK, + Json(UsernameResponse { + username: resolved_name, + address: format!("0x{}", hex::encode(digest_to_bytes(&address))), + }), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Username not found".into(), + }), + ) + .into_response(), + } +} + +#[cfg(feature = "lnurl")] +async fn lnurlp_handler( + State(state): State, + Path(username): Path, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + if resolve_identifier(&state, &username).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "User not found".into(), + }), + ) + .into_response(); + } + + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("api.zkcoins.app"); + let scheme = if host.contains("localhost") { + "http" + } else { + "https" + }; + let normalized = username.to_lowercase(); + let callback = format!("{}://{}/lnurl/pay/{}", scheme, host, normalized); + let metadata = format!( + "[[\"text/plain\",\"Pay {} on zkCoins\"],[\"text/identifier\",\"{}@zkcoins.app\"]]", + normalized, normalized + ); + + ( + StatusCode::OK, + Json(LnurlpResponse { + tag: "payRequest".into(), + callback, + min_sendable: 1_000, + max_sendable: 1_000_000_000_000, + metadata, + }), + ) + .into_response() +} + +#[cfg(feature = "lnurl")] +async fn lnurl_callback_handler( + State(_state): State, + Path(_username): Path, +) -> impl IntoResponse { + Json(LnurlErrorResponse { + status: "ERROR".into(), + reason: "Lightning payments coming soon (Phase 2)".into(), + }) +} + +/// Build the full application router with all API routes, CORS, health check, and fallback. +/// Extracted so it can be reused in integration tests via `oneshot()`. +pub(crate) fn create_router(state: AppState) -> Router { + let cors = CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([header::CONTENT_TYPE]); + + // MVP routes — always compiled in. + let app = Router::new() + .route("/", get(root_handler)) + .route("/health", get(|| async { "ok" })) + .route("/health/ready", get(ready_handler)) + .route("/health/publisher", get(publisher_health_handler)) + .route("/api/info", get(info_handler)) + .route("/api/balance", get(get_balance_handler)) + .route("/api/send", post(send_coin_handler)) + .route("/api/receive", post(receive_coin_handler)) + .route("/api/proof/:id", get(get_proof_handler)) + .route("/api/commit", post(commit_handler)) + .route("/api/mint", post(mint_handler)) + .route("/api/username/claim", post(claim_username_handler)) + .route( + "/api/username/resolve/:username", + get(resolve_username_handler), + ); + + // Gated routes — only compiled in when their Cargo feature is enabled. + // With a feature off, the handler does not exist in the binary and the + // route is not registered, so the endpoint returns 404 via the fallback + // and there is no code path to execute. + #[cfg(feature = "address-list")] + let app = app.route("/api/address", get(get_address_handler)); + + #[cfg(feature = "lnurl")] + let app = app + .route("/.well-known/lnurlp/:username", get(lnurlp_handler)) + .route("/lnurl/pay/:username", get(lnurl_callback_handler)); + + app.with_state(state) + .fallback(|| async { StatusCode::NOT_FOUND }) + .layer(cors) +} + +#[cfg(test)] +#[path = "router_tests.rs"] +mod tests; diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs new file mode 100644 index 00000000..fbcd6763 --- /dev/null +++ b/node/src/router_tests.rs @@ -0,0 +1,5274 @@ +use super::*; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use crate::account_node::{Account, AccountNode}; +use crate::state::State; + +/// Build a `PgPool` that points at nowhere — every query against it +/// fails fast with a connect error. Used by the server-handler test +/// suite below so the handlers' persistence-side `.await` lines run +/// the error branch (which mirrors the legacy file-IO best-effort +/// semantics: log + continue, never fail the response). The matching +/// happy-path tests for the upsert lines run against a real +/// Postgres 17 testcontainer in `db_tests.rs`, `account_node_tests.rs`, +/// `username_tests.rs`, and `runtime_tests.rs`. +fn dead_pool() -> Arc { + Arc::new( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(50)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"), + ) +} + +/// Create a minimal AppState for testing. +/// The AccountNode is constructed with a real (mock) prover so that the +/// type system is satisfied, but we seed it with a minting account so that +/// balance / address queries work without needing the minting_secret.bin +/// flow. +fn test_state() -> AppState { + let state = Arc::new(Mutex::new(State::new())); + let mut account_node = AccountNode::new(Arc::clone(&state)); + + // Seed a minting account with max balance (mirrors production setup) + let mut minting_account = Account::new(); + minting_account.balance = 1_000_000; + account_node.import_account(*zkcoins_program::types::MINTING_ADDRESS, minting_account); + + // Create a dummy minting ClientAccount from a deterministic key + let minting_client = { + let secret = include_bytes!("../minting_secret.bin"); + let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) + .expect("Failed to create test private key"); + shared::ClientAccount::new(private_key) + }; + + AppState { + account_node: Arc::new(Mutex::new(account_node)), + proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-test-proofs")), + minting_account: Arc::new(Mutex::new(minting_client)), + username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), + pool: dead_pool(), + // Most tests don't exercise the readiness probe and so don't + // care about Esplora — point at a guaranteed-unreachable URL + // so an accidental call fails fast instead of hitting the real + // mutinynet.com from CI. The three `/health/ready` tests below + // override this slot with a `wiremock::MockServer` URL. + esplora_config: Arc::new(crate::publisher::EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }), + phase2_reached: Arc::new(tokio::sync::Notify::new()), + phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), + state_advance_release_lock: Arc::new(tokio::sync::Mutex::new(())), + } +} + +/// Variant of [`test_state`] that swaps the lazy `dead_pool` for a real +/// migrated Postgres pool. Used by the handful of happy-path tests +/// whose handler actually has to persist (e.g. `claim_username` — +/// hard-fails with 503 on DB error, unlike `send`/`mint`/`receive` +/// whose `db::upsert_account` calls are best-effort log-and-continue). +fn live_test_state(pool: Arc) -> AppState { + let mut state = test_state(); + state.pool = pool; + state +} + +/// Helper: send a request through the router and return (status, body string). +async fn send_request(request: Request) -> (StatusCode, String) { + let app = create_router(test_state()); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + (status, body) +} + +// --- GET /health --- + +#[tokio::test] +async fn health_returns_ok() { + let req = Request::get("/health").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "ok"); +} + +// --- GET / (root) --- + +#[tokio::test] +async fn root_returns_service_metadata() { + let req = Request::get("/").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + // Verify the response is JSON and contains the service identifier plus + // a pointer to /api/info — those two are enough to prove the handler + // ran and serialized correctly. + let json: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(json["service"], "zkcoins-node"); + assert_eq!(json["endpoints"]["info"], "GET /api/info"); + assert!(json["version"].as_str().is_some_and(|v| !v.is_empty())); + assert!(json["network"].as_str().is_some_and(|v| !v.is_empty())); +} + +// --- GET /api/info --- + +#[tokio::test] +async fn info_returns_network_name_capabilities_and_username_domain() { + let req = Request::get("/api/info").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let info: InfoResponse = serde_json::from_str(&body).expect("valid JSON"); + // The lazy_static defaults to "Mutinynet" when IS_MAINNET is unset + assert!(!info.network.is_empty(), "network name must not be empty"); + + // Capabilities reflect the cargo feature set this binary was built with. + // Same `cfg!(...)` evaluation as the handler, so the test passes both in + // MVP builds (all false) and `--all-features` builds (all true). + assert_eq!( + info.capabilities.address_list, + cfg!(feature = "address-list") + ); + // Mint is permanent MVP — `faucet` is hardcoded `true`, not cfg-derived. + assert!(info.capabilities.faucet); + // Usernames are permanent MVP — `usernames` is hardcoded `true`. + assert!(info.capabilities.usernames); + assert_eq!(info.capabilities.lnurl, cfg!(feature = "lnurl")); + + // The lazy_static defaults to "zkcoins.app" (PRD) when USERNAME_DOMAIN is unset + assert!( + !info.username_domain.is_empty(), + "username_domain must not be empty" + ); +} + +#[tokio::test] +async fn info_serialization_format_is_stable() { + let req = Request::get("/api/info").body(Body::empty()).unwrap(); + let (_, body) = send_request(req).await; + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + + // Top-level fields the app contract relies on. + assert!(v["network"].is_string()); + assert!(v["capabilities"].is_object()); + assert!(v["username_domain"].is_string()); + + let caps = &v["capabilities"]; + for key in ["address_list", "faucet", "usernames", "lnurl"] { + assert!(caps[key].is_boolean(), "capability `{key}` must be bool"); + } +} + +// --- GET /api/balance --- + +#[tokio::test] +async fn balance_unknown_address_returns_ok_with_zero() { + // 32 zero bytes in hex = 64 hex chars + let address_hex = "00".repeat(32); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 0); + assert!(resp.username.is_none()); +} + +#[tokio::test] +async fn balance_unknown_address_with_claimed_username_returns_username() { + let state = test_state(); + let address_bytes = [0xABu8; 32]; + let address = zkcoins_program::hash::digest_from_bytes(&address_bytes); + + // Pre-populate the in-memory map (no Postgres round-trip — see + // the comment on `insert_for_test`). + { + let mut store = state.username_store.lock().unwrap(); + store.insert_for_test("alice", address); + } + + let uri = format!("/api/balance?address={}", hex::encode(address_bytes)); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK); + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 0); + assert_eq!(resp.username, Some("alice".to_string())); +} + +#[tokio::test] +async fn balance_minting_address_returns_max() { + let address_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 1_000_000u64); +} + +#[tokio::test] +async fn balance_missing_address_param_returns_unprocessable() { + let req = Request::get("/api/balance").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 0); + assert!(resp.username.is_none()); +} + +#[tokio::test] +async fn balance_invalid_hex_returns_unprocessable() { + let req = Request::get("/api/balance?address=not_valid_hex") + .body(Body::empty()) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn balance_wrong_length_returns_unprocessable() { + // 16 bytes = 32 hex chars, but the handler expects exactly 32 bytes + let short_hex = "ab".repeat(16); + let uri = format!("/api/balance?address={}", short_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +// --- GET /api/address --- + +#[cfg(feature = "address-list")] +#[tokio::test] +async fn address_returns_list() { + let req = Request::get("/api/address").body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: AddressesResponse = serde_json::from_str(&body).expect("valid JSON"); + // The test state has the minting address seeded + assert!( + !resp.addresses.is_empty(), + "should contain at least the minting address" + ); + assert!( + resp.addresses[0].starts_with("0x"), + "addresses should be 0x-prefixed" + ); +} + +// --- POST /api/send with missing fields --- + +#[tokio::test] +async fn send_missing_body_returns_error() { + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 422 when JSON deserialization fails (missing required fields) + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_invalid_json_returns_bad_request() { + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from("not json")) + .unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 400 Bad Request for syntactically invalid JSON + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn send_no_content_type_returns_error() { + let req = Request::post("/api/send").body(Body::from("{}")).unwrap(); + let (status, _body) = send_request(req).await; + + // Axum returns 415 Unsupported Media Type when content-type is missing for Json extractor + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); +} + +// --- POST /api/mint with missing fields --- + +#[tokio::test] +async fn mint_missing_body_returns_error() { + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +// --- GET /api/proof/{id} for non-existent proof --- + +#[tokio::test] +async fn proof_not_found_returns_404() { + let req = Request::get("/api/proof/9999").body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// --- POST /api/commit with missing fields --- + +#[tokio::test] +async fn commit_missing_body_returns_error() { + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +// --- Fallback for unknown routes --- + +#[tokio::test] +async fn unknown_route_returns_404() { + let req = Request::get("/does-not-exist").body(Body::empty()).unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// ======================================================================= +// Helper: send a request through a *shared* router (same AppState across +// calls) instead of creating a fresh test_state() for every request. +// ======================================================================= +async fn send_request_with_state(state: AppState, request: Request) -> (StatusCode, String) { + let app = create_router(state); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + (status, body) +} + +// --- GET /api/username/resolve/{username} --- + +#[tokio::test] +async fn resolve_unknown_username_returns_404() { + let req = Request::get("/api/username/resolve/nonexistent") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!(resp.reason.contains("not found")); +} + +#[tokio::test] +async fn resolve_minting_address_by_hex_prefix() { + // The minting address starts with "af53a1" — a short prefix is enough + // for resolve_identifier to match via hex-prefix fallback. + let full_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let prefix = &full_hex[..8]; // first 8 hex chars + + let uri = format!("/api/username/resolve/{}", prefix); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.address, format!("0x{}", full_hex)); + assert_eq!(resp.username, prefix); +} + +// --- POST /api/username/claim --- + +#[tokio::test] +async fn claim_username_empty_body_returns_422() { + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn claim_username_no_content_type_returns_415() { + let req = Request::post("/api/username/claim") + .body(Body::from("{}")) + .unwrap(); + let (status, _body) = send_request(req).await; + + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); +} + +// --- GET /.well-known/lnurlp/{username} --- + +#[cfg(feature = "lnurl")] +#[tokio::test] +async fn lnurlp_unknown_user_returns_404() { + let req = Request::get("/.well-known/lnurlp/nobody") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!(resp.reason.contains("not found")); +} + +#[cfg(feature = "lnurl")] +#[tokio::test] +async fn lnurlp_known_address_returns_pay_request() { + // The minting address is resolvable by hex prefix through resolve_identifier. + let full_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let prefix = &full_hex[..8]; + + let uri = format!("/.well-known/lnurlp/{}", prefix); + let req = Request::get(&uri) + .header("host", "api.zkcoins.app") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: LnurlpResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.tag, "payRequest"); + assert!( + resp.callback.contains(prefix), + "callback should include the identifier" + ); + assert_eq!(resp.min_sendable, 1_000); + assert_eq!(resp.max_sendable, 1_000_000_000_000); + assert!(resp.metadata.contains("zkCoins")); +} + +// --- GET /lnurl/pay/{username} --- + +#[cfg(feature = "lnurl")] +#[tokio::test] +async fn lnurl_pay_callback_returns_phase2_error() { + let req = Request::get("/lnurl/pay/someone") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!( + resp.reason.contains("Phase 2"), + "should mention Phase 2: {}", + resp.reason + ); +} + +// --- Balance includes username field --- + +#[tokio::test] +async fn balance_minting_address_has_no_username() { + let address_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request(req).await; + + assert_eq!(status, StatusCode::OK); + + // username should be absent (skip_serializing_if = None) + let raw: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert!( + raw.get("username").is_none() || raw["username"].is_null(), + "minting address without a claimed username should have no username field" + ); +} + +#[tokio::test] +async fn balance_includes_username_when_claimed() { + let state = test_state(); + + // Pre-populate the in-memory username map via the test-only + // helper (bypasses the async Postgres path; production code + // claims via the /api/username/claim handler). + { + let mut username_store = state.username_store.lock().unwrap(); + username_store.insert_for_test("satoshi", *zkcoins_program::types::MINTING_ADDRESS); + } + + let address_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let uri = format!("/api/balance?address={}", address_hex); + let req = Request::get(&uri).body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK); + + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 1_000_000u64); + assert_eq!(resp.username, Some("satoshi".to_string())); +} + +// --- Concurrent balance reads --- + +#[tokio::test] +async fn concurrent_balance_reads_are_consistent() { + let state = test_state(); + let address_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let uri = format!("/api/balance?address={}", address_hex); + + // Spawn many concurrent balance requests against the same shared state. + let mut handles = vec![]; + for _ in 0..20 { + let s = state.clone(); + let u = uri.clone(); + handles.push(tokio::spawn(async move { + let req = Request::get(&u).body(Body::empty()).unwrap(); + send_request_with_state(s, req).await + })); + } + + for handle in handles { + let (status, body) = handle.await.expect("task should not panic"); + assert_eq!(status, StatusCode::OK); + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!( + resp.balance, 1_000_000u64, + "every concurrent read must see the same minting balance" + ); + } +} + +// --- Concurrent mixed reads and username operations --- + +#[tokio::test] +async fn concurrent_reads_with_username_claim() { + let state = test_state(); + let address_hex = hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + + // Claim a username through the store directly (bypasses both + // signature validation and the async Postgres path; production + // claims go through the /api/username/claim handler). + { + let mut store = state.username_store.lock().unwrap(); + store.insert_for_test("testuser", *zkcoins_program::types::MINTING_ADDRESS); + } + + // Spawn concurrent balance + resolve requests + let mut handles = vec![]; + + for i in 0..10 { + let s = state.clone(); + let hex = address_hex.clone(); + handles.push(tokio::spawn(async move { + if i % 2 == 0 { + // Balance request + let req = Request::get(format!("/api/balance?address={}", hex)) + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(s, req).await; + assert_eq!(status, StatusCode::OK); + let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.balance, 1_000_000u64); + assert_eq!(resp.username, Some("testuser".to_string())); + } else { + // Resolve request + let req = Request::get("/api/username/resolve/testuser") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(s, req).await; + assert_eq!(status, StatusCode::OK); + let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(resp.username, "testuser"); + assert_eq!(resp.address, format!("0x{}", hex)); + } + })); + } + + for handle in handles { + handle.await.expect("task should not panic"); + } +} + +// --- POST /api/commit with non-existent proof_id --- + +#[tokio::test] +async fn commit_nonexistent_proof_id_returns_404() { + let state = test_state(); + let body = serde_json::json!({ + "proof_id": 999999, + "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "signature": "00".repeat(64), + "message": "00".repeat(32), + }); + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// --- POST /api/commit with valid proof_id but invalid signature --- + +#[tokio::test] +async fn commit_invalid_signature_returns_error() { + // Submit a commit with a fabricated proof_id that does not exist but with + // a structurally valid body — the handler should return 404 (proof not found). + let commit_body = serde_json::json!({ + "proof_id": 99999, + "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "signature": "ab".repeat(64), + "message": "cd".repeat(32), + }); + let req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&commit_body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "commit with non-existent proof_id must return 404" + ); +} + +// --- verify_send_signature tests --- + +#[test] +fn send_signature_rejects_missing_signature() { + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: None, + timestamp: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing signature")); +} + +#[test] +fn send_signature_rejects_missing_timestamp() { + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("ab".repeat(64)), + timestamp: None, + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing timestamp")); +} + +#[test] +fn send_signature_rejects_expired_timestamp() { + let old_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 600; // 10 minutes ago + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("ab".repeat(64)), + timestamp: Some(old_timestamp), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("timestamp")); +} + +#[test] +fn send_signature_rejects_invalid_hex() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .parse() + .unwrap(), + prev_commitment_pubkey: None, + signature: Some("not_valid_hex".to_string()), + timestamp: Some(now), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid signature hex")); +} + +#[test] +fn send_signature_rejects_wrong_signature() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign a DIFFERENT message than what verify_send_signature expects + let wrong_msg = Message::from_digest([0u8; 32]); + let (_xonly, _) = public_key.x_only_public_key(); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&wrong_msg, &keypair); + + let request = SendCoinRequest { + account_address: "0x".to_string() + &hex::encode([1u8; 32]), + recipient: "0x".to_string() + &hex::encode([2u8; 32]), + amount: 100, + public_key, + next_public_key: public_key, + prev_commitment_pubkey: None, + signature: Some(hex::encode(sig.serialize())), + timestamp: Some(now), + }; + let result = verify_send_signature(&request); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Signature verification failed")); +} + +// --- POST /api/username/claim with valid Schnorr signature --- + +#[tokio::test] +async fn claim_username_with_valid_signature() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + // The `claim_username_handler` hard-fails with 503 if persistence + // fails — unlike the other handlers whose DB upserts are + // log-and-continue. So this happy-path test cannot use the lazy + // `dead_pool`; it boots a real Postgres 17 container, mirroring + // the per-test isolation pattern from `db_tests::setup_pool` / + // `username_tests::setup_pool` / `runtime_tests::setup_pool`. + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container + .get_host() + .await + .expect("failed to get container host"); + let port = pg_container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + // address = sha256(compressed_pubkey) + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "testclaim"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Build claim message: sha256("zkcoins:claim_username" || address_hex || username || timestamp_le) + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + // Import the address into the account_node so resolve_identifier can find it + let state = live_test_state(pool); + { + let mut account_node = state.account_node.lock().unwrap(); + account_node.import_account( + zkcoins_program::hash::digest_from_bytes(&address), + Account::new(), + ); + } + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::OK, + "Claim should succeed: {}", + resp_body + ); + + let resp: UsernameResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.username, username); + assert_eq!(resp.address, format!("0x{}", address_hex)); +} + +/// Mixed-case input is normalised to lowercase **before** the +/// signature is hashed, so a wallet that signs over the normalised +/// form (`"alice"`) and sends the user-typed form (`"Alice"`) is +/// accepted and persisted under `"alice"`. Guards the case-mismatch +/// squat fix from PR #76's prod-readiness review. +#[tokio::test] +async fn claim_username_mixed_case_input_normalised_before_hashing() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container + .get_host() + .await + .expect("failed to get container host"); + let port = pg_container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[9u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let user_input = "Alice"; + let normalised = "alice"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign over the NORMALISED form — that is the contract the node + // enforces by canonicalising before hashing. + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(normalised.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let state = live_test_state(pool); + { + let mut account_node = state.account_node.lock().unwrap(); + account_node.import_account( + zkcoins_program::hash::digest_from_bytes(&address), + Account::new(), + ); + } + + // Send the mixed-case form. The server normalises, hashes over + // the lowercase form, and the signature verifies. + let body = serde_json::json!({ + "username": user_input, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::OK, + "claim should succeed: {}", + resp_body + ); + let resp: UsernameResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + // Response echoes the canonical lowercase name, NOT the raw input. + assert_eq!(resp.username, normalised); +} + +/// Counterpart to the test above: a wallet that signs over the RAW +/// mixed-case input (legacy/buggy behaviour) must be rejected by the +/// server, because the server hashes the normalised form. Without +/// this, the case-mismatch squat is reachable: attacker signs `"Bob"`, +/// server persists `"bob"`, the legitimate `bob` owner is locked out. +#[tokio::test] +async fn claim_username_raw_case_signature_rejected() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[10u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let user_input = "Bob"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign over the RAW form — the bug we are fixing. + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(user_input.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let state = test_state(); + { + let mut account_node = state.account_node.lock().unwrap(); + account_node.import_account( + zkcoins_program::hash::digest_from_bytes(&address), + Account::new(), + ); + } + + let body = serde_json::json!({ + "username": user_input, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "raw-case signature must fail; server hashes normalised form" + ); +} + +/// In-memory `precheck` collision must surface as `409 CONFLICT` with +/// the verbatim collision string the wallet shows the user. Drives the +/// claim handler's precheck `Err` branch without any DB round-trip: +/// the in-memory mirror is pre-seeded via `insert_for_test`, the +/// signature is valid, and the handler short-circuits before the +/// `db::claim_username` call. +#[tokio::test] +async fn claim_username_precheck_conflict_returns_409() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[11u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "claimed"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let state = test_state(); + { + let mut account_node = state.account_node.lock().unwrap(); + account_node.import_account( + zkcoins_program::hash::digest_from_bytes(&address), + Account::new(), + ); + } + // Pre-seed the name → arbitrary OTHER address so the precheck's + // `usernames.contains_key(normalized)` branch fires (rather than + // the address-already-has-a-username branch). + { + let mut store = state.username_store.lock().unwrap(); + store.insert_for_test( + username, + zkcoins_program::hash::digest_from_bytes(&[99u8; 32]), + ); + } + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::CONFLICT, "body: {}", resp_body); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert!( + resp.reason.contains("Username already taken"), + "unexpected reason: {}", + resp.reason + ); +} + +#[tokio::test] +async fn claim_username_wrong_pubkey() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[8u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + // Use a DIFFERENT address that does NOT match sha256(pubkey) + let wrong_address: [u8; 32] = [0xAA; 32]; + let address_hex = hex::encode(wrong_address); + + let username = "wrongpk"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Sign with the correct message format but the address doesn't match the pubkey + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Claim with mismatched pubkey/address must be rejected" + ); +} + +#[tokio::test] +async fn claim_username_expired_timestamp() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[9u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "expiredts"; + // Timestamp 10 minutes in the past (exceeds 5-min window) + let expired_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 600; + + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(expired_timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": expired_timestamp, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, _) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Claim with expired timestamp must be rejected" + ); +} + +/// `UsernameStore::validate` rejects names outside `[a-z0-9._-]{1,64}`. +/// Drives the handler's first early-return arm (the `validate` `Err` +/// branch), so no DB round-trip and no signature work is needed. +#[tokio::test] +async fn claim_username_invalid_format_returns_422() { + let body = serde_json::json!({ + "username": "alice@evil", + "address": hex::encode([0u8; 32]), + "public_key": bitcoin::secp256k1::PublicKey::from_secret_key( + &secp::Secp256k1::new(), + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ) + .to_string(), + "signature": hex::encode([0u8; 64]), + "timestamp": 0u64, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "body: {resp_body}" + ); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Username may only contain a-z, 0-9, -, _, ."); +} + +/// Non-hex address payload triggers the `hex::decode` early-return arm. +#[tokio::test] +async fn claim_username_invalid_address_hex_returns_422() { + let body = serde_json::json!({ + "username": "alice", + "address": "z".repeat(64), + "public_key": bitcoin::secp256k1::PublicKey::from_secret_key( + &secp::Secp256k1::new(), + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ) + .to_string(), + "signature": hex::encode([0u8; 64]), + "timestamp": 0u64, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "body: {resp_body}" + ); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Invalid address hex"); +} + +/// Valid hex address but not 32 bytes triggers the length-check arm. +#[tokio::test] +async fn claim_username_wrong_address_length_returns_422() { + let body = serde_json::json!({ + "username": "alice", + "address": hex::encode([0u8; 30]), + "public_key": bitcoin::secp256k1::PublicKey::from_secret_key( + &secp::Secp256k1::new(), + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ) + .to_string(), + "signature": hex::encode([0u8; 64]), + "timestamp": 0u64, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "body: {resp_body}" + ); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Address must be 32 bytes"); +} + +/// Address matches `sha256(pubkey)` and the timestamp is fresh, so the +/// handler reaches the signature-hex decode step before bailing on the +/// non-hex `signature` field. +#[tokio::test] +async fn claim_username_invalid_signature_hex_returns_422() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[12u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let body = serde_json::json!({ + "username": "sighex", + "address": address_hex, + "public_key": public_key.to_string(), + "signature": "zz", + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "body: {resp_body}" + ); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Invalid signature hex"); +} + +/// Signature is valid hex but the wrong length for a BIP-340 Schnorr +/// signature (64 bytes), so `SchnorrSignature::from_slice` rejects it. +#[tokio::test] +async fn claim_username_invalid_signature_format_returns_422() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[13u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // 63 bytes of zeros — valid hex, wrong Schnorr length. + let body = serde_json::json!({ + "username": "sigfmt", + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode([0u8; 63]), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "body: {resp_body}" + ); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Invalid signature format"); +} + +/// Pool with no reachable server: `db::claim_username` returns an error +/// after the in-memory `precheck` passes. The handler must map that +/// onto a 503. Mirrors `claim_propagates_db_error_when_pool_is_dead` +/// from `username_tests.rs`, but exercises the handler's error arm. +#[tokio::test] +async fn claim_username_db_error_returns_503() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[14u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "dberr"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + // `test_state()` already plugs in `dead_pool` — a lazy PgPool + // pointing at 127.0.0.1:1 that fails fast with a connect error. + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request(req).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "body: {resp_body}"); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Failed to persist username claim"); +} + +/// Concurrent-claim SQL race: plant the row directly via SQL so the +/// in-memory `precheck` mirror stays empty (passes) but the +/// `INSERT ... ON CONFLICT DO NOTHING` reports `rows_affected == 0`. +/// The handler must map that onto a 409 with the SQL-race reason +/// string. Mirrors `claim_falls_back_to_validation_when_sql_layer_catches_race` +/// from `username_tests.rs`, but exercises the handler's `!inserted` +/// arm rather than the `UsernameStore::claim` wrapper. +#[tokio::test] +async fn claim_username_sql_race_returns_409() { + use bitcoin::secp256k1::{Keypair, SecretKey}; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container + .get_host() + .await + .expect("failed to get container host"); + let port = pg_container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // Plant the username row bound to a different address, without + // touching the in-memory mirror — so `precheck` passes and + // `db::claim_username` returns `Ok(false)`. + sqlx::query("INSERT INTO usernames (name, address) VALUES ($1, $2)") + .bind("racename") + .bind(vec![0xAAu8; 32]) + .execute(pool.as_ref()) + .await + .expect("failed to plant username row"); + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[15u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + + let username = "racename"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.as_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let state = live_test_state(pool); + + let body = serde_json::json!({ + "username": username, + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::CONFLICT, "body: {resp_body}"); + let resp: LnurlErrorResponse = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(resp.status, "ERROR"); + assert_eq!(resp.reason, "Username already taken"); +} + +#[test] +fn send_signature_accepts_valid_signature() { + use bitcoin::secp256k1::SecretKey; + + let secp = secp::Secp256k1::new(); + let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + + let account_address = "0x".to_string() + &hex::encode([1u8; 32]); + let recipient = "0x".to_string() + &hex::encode([2u8; 32]); + let amount: u64 = 100; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Build the exact same message as verify_send_signature + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &keypair); + + let request = SendCoinRequest { + account_address, + recipient, + amount, + public_key, + next_public_key: public_key, + prev_commitment_pubkey: None, + signature: Some(hex::encode(sig.serialize())), + timestamp: Some(now), + }; + // `.expect` surfaces the actual error string on failure; the + // previous `is_ok()` shape silently swallowed it. + verify_send_signature(&request).expect("valid Schnorr signature must verify"); +} + +// --- POST /api/send (happy path, exercises the full handler) --- + +#[tokio::test] +async fn send_with_valid_signature_returns_proof_id_and_hashes() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + + // Build the AppState the same way test_state() does so the handler can + // run through the entire send pipeline (signature -> SP1 mock prover -> + // proof persistence -> response). + let state = test_state(); + + // Derive the minting account's BIP-32 keys from the same secret the + // production code uses, so the SP1 prover's expectations line up with + // the account already seeded in test_state. + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = + Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).expect("test minting xpriv"); + let secp = secp::Secp256k1::new(); + + let derive_pk = |index: u32| -> PublicKey { + Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index }]) + .expect("derive_pub") + .public_key + }; + let derive_sk = |index: u32| -> SecretKey { + xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index }]) + .expect("derive_priv") + .private_key + }; + + let sk_0 = derive_sk(0); + let pk_0 = derive_pk(0); + let pk_1 = derive_pk(1); + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([1u8; 32]); + let amount: u64 = 100; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Build the exact same message the handler will hash for the signature. + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let app = create_router(state); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + assert_eq!(status, StatusCode::OK, "body: {body}"); + let response_json: serde_json::Value = + serde_json::from_str(&body).expect("response is valid JSON"); + assert_eq!(response_json["success"], true); + let proof_id = response_json["proof_id"] + .as_u64() + .expect("proof_id missing from response"); + assert!(proof_id > 0, "proof_id must be a positive u64"); + + // Value-bearing assertions on the send response payload. The + // previous `.as_str().is_some()` shape passed for any non-null + // string — including the all-zero placeholder a buggy handler + // could emit, or a truncated hex string. Decoding to bytes and + // asserting 32-byte length + non-zero pins both regressions. + let account_state_hash_hex = response_json["account_state_hash"] + .as_str() + .expect("account_state_hash present"); + let ash_bytes = hex::decode(account_state_hash_hex).expect("ash is hex"); + assert_eq!(ash_bytes.len(), 32, "account_state_hash must be 32 bytes"); + assert!( + ash_bytes.iter().any(|&b| b != 0), + "account_state_hash must be non-zero" + ); + + let output_coins_root_hex = response_json["output_coins_root"] + .as_str() + .expect("output_coins_root present"); + let ocr_bytes = hex::decode(output_coins_root_hex).expect("ocr is hex"); + assert_eq!(ocr_bytes.len(), 32, "output_coins_root must be 32 bytes"); + assert!( + ocr_bytes.iter().any(|&b| b != 0), + "output_coins_root must be non-zero" + ); +} + +/// Companion to `send_with_valid_signature_returns_proof_id_and_hashes` +/// that drives the post-send `db::upsert_account` path against a real +/// Postgres 17 testcontainer instead of `dead_pool`. The default +/// `test_state` exercises the upsert *error* arm (log-and-continue); +/// this test exercises the upsert *success* arm so the if-let-Some +/// block falls through without entering the `if let Err` branch — +/// the only path that touches the line after the inner Err handler. +/// +/// The persist itself is best-effort, so the assertions are scoped +/// to (a) the handler still returning 200 with a usable proof_id and +/// (b) the `accounts` row being readable from Postgres after the +/// call. Together they pin both observable side-effects of the +/// happy-path upsert. +#[tokio::test] +async fn send_with_valid_signature_persists_sender_account_to_postgres() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container + .get_host() + .await + .expect("failed to get container host"); + let port = pg_container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let state = live_test_state(Arc::clone(&pool)); + + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = + Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).expect("test minting xpriv"); + let secp = secp::Secp256k1::new(); + + let derive_pk = |index: u32| -> PublicKey { + Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index }]) + .expect("derive_pub") + .public_key + }; + let derive_sk = |index: u32| -> SecretKey { + xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index }]) + .expect("derive_priv") + .private_key + }; + + let sk_0 = derive_sk(0); + let pk_0 = derive_pk(0); + let pk_1 = derive_pk(1); + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([1u8; 32]); + let amount: u64 = 100; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let msg = Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &keypair); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK, "body: {resp_body}"); + let response_json: serde_json::Value = + serde_json::from_str(&resp_body).expect("response is valid JSON"); + assert_eq!(response_json["success"], true); + assert!(response_json["proof_id"].as_u64().is_some()); + + // The post-send upsert must have written the sender (minting) + // account row. Confirm it via a direct SELECT so the assertion + // doesn't depend on the handler's own read path. + let from_address_bytes = + zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS); + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM accounts WHERE address = $1") + .bind(&from_address_bytes[..]) + .fetch_optional(&*pool) + .await + .expect("select accounts row"); + let (data,) = row.expect("upsert wrote the sender account row"); + assert!(!data.is_empty(), "account blob must be non-empty"); +} + +#[tokio::test] +async fn commit_with_bad_message_hex_returns_422() { + // Build a sendable state + perform a valid send first so a proof_id + // exists in the store, then send a commit that decodes-fails on the + // message hex. + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let derive_pk = |idx: u32| -> PublicKey { + Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .public_key + }; + let derive_sk = |idx: u32| -> SecretKey { + xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .private_key + }; + + let pk_0 = derive_pk(0); + let pk_1 = derive_pk(1); + let sk_0 = derive_sk(0); + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([2u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + let proof_id = send_resp["proof_id"].as_u64().unwrap(); + + // Now post a commit with garbage in the message hex. + let commit_body = serde_json::json!({ + "proof_id": proof_id, + "public_key": hex::encode(pk_0.serialize()), + "signature": hex::encode([0u8; 64]), + "message": "not-hex-at-all-zzzz", + }); + let commit_req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(commit_body.to_string())) + .unwrap(); + let (status, _body) = send_request_with_state(state, commit_req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn commit_with_bad_signature_hex_returns_422() { + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let derive_pk = |idx: u32| -> PublicKey { + Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .public_key + }; + let derive_sk = |idx: u32| -> SecretKey { + xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .private_key + }; + let pk_0 = derive_pk(0); + let pk_1 = derive_pk(1); + let sk_0 = derive_sk(0); + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([3u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + let proof_id = send_resp["proof_id"].as_u64().unwrap(); + + // Bad signature hex (odd length). + let commit_body = serde_json::json!({ + "proof_id": proof_id, + "public_key": hex::encode(pk_0.serialize()), + "signature": "zzz", + "message": hex::encode([0u8; 32]), + }); + let commit_req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(commit_body.to_string())) + .unwrap(); + let (status, _body) = send_request_with_state(state, commit_req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn commit_with_unverifiable_commitment_returns_401() { + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let derive_pk = |idx: u32| -> PublicKey { + Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .public_key + }; + let derive_sk = |idx: u32| -> SecretKey { + xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) + .unwrap() + .private_key + }; + let pk_0 = derive_pk(0); + let pk_1 = derive_pk(1); + let sk_0 = derive_sk(0); + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([4u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + + // Valid hex shapes but the commitment signature won't verify against + // the message+public_key combination. + let commit_body = serde_json::json!({ + "proof_id": serde_json::from_str::(&body).unwrap()["proof_id"], + "public_key": hex::encode(pk_0.serialize()), + "signature": hex::encode([0u8; 64]), + "message": hex::encode([0u8; 64]), + }); + let commit_req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(commit_body.to_string())) + .unwrap(); + let (status, _body) = send_request_with_state(state, commit_req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn send_with_invalid_signature_returns_401() { + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode(zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS)), + "recipient": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 50, + "public_key": hex::encode([2u8; 33]), // garbage compressed pubkey of valid length + "next_public_key": hex::encode([3u8; 33]), + "signature": hex::encode([0u8; 64]), // valid hex shape but wrong sig + "timestamp": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + // serde will reject "02" + [2u8;32] as not-a-valid-pubkey at body parsing, + // so we accept either UNPROCESSABLE_ENTITY (parse-failed) or UNAUTHORIZED + // (parse-succeeded but signature verification failed). + assert!( + status == StatusCode::UNAUTHORIZED || status == StatusCode::UNPROCESSABLE_ENTITY, + "expected 401 or 422, got {status}" + ); +} + +#[tokio::test] +async fn send_with_non_hex_account_address_returns_422() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "not-hex-at-all".to_string(); + let recipient = "0x".to_string() + &hex::encode([1u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_with_wrong_length_address_returns_422() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + // Account address is parseable hex but only 16 bytes, not 32. + let account_address = "0x".to_string() + &hex::encode([1u8; 16]); + let recipient = "0x".to_string() + &hex::encode([2u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_with_insufficient_funds_returns_422_with_error_string() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + + // Build a state where the minting account has been emptied. + let state_arc = Arc::new(Mutex::new(State::new())); + let mut account_node = AccountNode::new(Arc::clone(&state_arc)); + let mut empty_minting = Account::new(); + empty_minting.balance = 0; + account_node.import_account(*zkcoins_program::types::MINTING_ADDRESS, empty_minting); + let minting_client = { + let secret = include_bytes!("../minting_secret.bin"); + let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) + .expect("test minting xpriv"); + shared::ClientAccount::new(private_key) + }; + let state = AppState { + account_node: Arc::new(Mutex::new(account_node)), + proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-test-proofs-empty")), + minting_account: Arc::new(Mutex::new(minting_client)), + username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), + pool: dead_pool(), + esplora_config: Arc::new(crate::publisher::EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }), + phase2_reached: Arc::new(tokio::sync::Notify::new()), + phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), + state_advance_release_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([1u8; 32]); + let amount: u64 = 100; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + // After the Item 1 HTTP error-mapping landed (see PR following #28), + // send_coins failures surface as 4xx with body.error rather than + // 200 + success:false. Insufficient funds maps to 422. + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(resp["success"], false); + assert_eq!(resp["error"], "Insufficient funds"); +} + +#[tokio::test] +async fn receive_coin_with_invalid_bincode_returns_default_response() { + let req = Request::post("/api/receive") + .header("content-type", "application/octet-stream") + .body(Body::from(vec![0xff, 0xfe, 0xfd, 0xfc])) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::OK); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(resp["success"], false); +} + +#[tokio::test] +async fn send_with_non_hex_recipient_returns_422() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "absolutely-not-hex".to_string(); + let amount: u64 = 1; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +// ----------------------------------------------------------------- +// `lock_or_recover_*` tests — nextest per-test process isolation note +// ----------------------------------------------------------------- +// +// The three `lock_or_recover_*_poisoned` tests below intentionally +// panic inside a spawned thread to poison the mutex they hold, then +// call `lock_or_recover` on the same `Arc>` to assert that +// the helper recovers the inner value via `into_inner`. Each test +// MUST run in its own process — under the default `cargo test` +// runner (single binary, threadpool) the second-test poison setup +// can race against the first test's recovery path because both +// share the libtest thread that observes panics. We rely on +// `cargo-nextest`'s per-test process isolation (see `CONTRIBUTING.md` +// > "Tests" and `.config/nextest.toml`) to give each test a fresh +// process. Running these tests outside nextest is supported (the +// project's CI uses `cargo nextest run`); a bare `cargo test` will +// occasionally surface a spurious "double panic" diagnostic in the +// shared libtest panic handler. Switch to nextest if you reproduce +// this locally. + +#[test] +fn lock_or_recover_recovers_from_poisoned_mutex() { + let mutex = Arc::new(Mutex::new(42i32)); + let mutex_clone = Arc::clone(&mutex); + + // Poison the mutex by panicking inside lock(). + let _ = std::thread::spawn(move || { + let _guard = mutex_clone.lock().unwrap(); + panic!("intentional panic to poison the mutex"); + }) + .join(); + + assert!( + mutex.is_poisoned(), + "mutex must be poisoned after the panic" + ); + + // Recovering must succeed and yield the inner value. + let guard = lock_or_recover(&mutex); + assert_eq!(*guard, 42); +} + +#[tokio::test] +async fn commit_with_valid_signature_fails_broadcast_returns_503() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // Spin up a wiremock Esplora that returns the publisher's UTXOs + // (so `get_publisher_utxo` finds inputs) but FAILS the broadcast + // with a 400. This pins the test to "valid signature, broadcast + // genuinely fails → 503" instead of "valid signature, broadcast + // might or might not succeed against a public Mutinynet". The + // previous accept-either assertion masked a hypothetical + // regression where the handler returned 200 without actually + // broadcasting. + let mock_server = MockServer::start().await; + let secp = secp::Secp256k1::new(); + let publisher_sk = SecretKey::from_slice( + &hex::decode("0000000000000000000000000000000000000000000000000000000000000001").unwrap(), + ) + .expect("CI test publisher key parses"); + let publisher_kp = Keypair::from_secret_key(&secp, &publisher_sk); + let (publisher_xonly, _) = bitcoin::secp256k1::XOnlyPublicKey::from_keypair(&publisher_kp); + let publisher_address = + bitcoin::Address::p2tr(&secp, publisher_xonly, None, bitcoin::Network::Signet); + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "txid": "4444444444444444444444444444444444444444444444444444444444444444", + "vout": 0, + "value": 100_000, + "status": { + "confirmed": true, + "block_height": 100, + "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "block_time": 1_700_000_000 + } + } + ]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with( + ResponseTemplate::new(400).set_body_string("sendrawtransaction RPC error -25"), + ) + .mount(&mock_server) + .await; + + let mut state = test_state(); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }); + + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + // Send first to get proof_id + the hashes the client signs over. + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([5u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + let proof_id = send_resp["proof_id"].as_u64().unwrap(); + let ash_hex = send_resp["account_state_hash"] + .as_str() + .unwrap() + .to_string(); + let ocr_hex = send_resp["output_coins_root"].as_str().unwrap().to_string(); + + // Build a valid commitment that the handler will accept. + let ash_bytes = hex::decode(&ash_hex).unwrap(); + let ocr_bytes = hex::decode(&ocr_hex).unwrap(); + let mut commit_message = Vec::with_capacity(ash_bytes.len() + ocr_bytes.len()); + commit_message.extend_from_slice(&ash_bytes); + commit_message.extend_from_slice(&ocr_bytes); + // Commitment::new SHA256s the message internally, so just pass the + // pre-image bytes the handler will receive. + let commitment = shared::commitment::Commitment::new(&sk_0, commit_message.clone()) + .expect("commitment creation"); + assert!(commitment.verify(), "test commitment must verify locally"); + + let commit_body = serde_json::json!({ + "proof_id": proof_id, + "public_key": hex::encode(commitment.public_key.serialize()), + "signature": hex::encode(commitment.signature.serialize()), + "message": hex::encode(&commitment.message), + }); + let commit_req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(commit_body.to_string())) + .unwrap(); + let (status, _) = send_request_with_state(state, commit_req).await; + // The commitment verifies, the handler proceeds to broadcast. The + // wiremock Esplora rejects the broadcast (400) so the handler MUST + // return SERVICE_UNAVAILABLE. Anything else means the handler + // either bypassed the broadcast (a regression — it should always + // attempt it on a valid commitment) or fabricated a 200 response + // despite the upstream failure (a worse regression). + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "expected 503 from valid-commit + broken-broadcast, got {status}" + ); +} + +#[test] +fn proof_store_proof_path_returns_none_for_nonexistent_directory() { + // proof_path canonicalizes the configured directory. If the directory + // does not exist, canonicalize fails and proof_path returns None. + let store = ProofStore::new("/nonexistent/zkcoins/proof/dir"); + // The directory was created by ProofStore::new, but to test the + // None branch we point at one that does not exist. + let truly_missing = ProofStore { + dir: "/this/path/genuinely/does/not/exist/zkcoins".to_string(), + next_id: std::sync::atomic::AtomicU64::new(0), + }; + assert!(truly_missing.proof_path(7).is_none()); + // The real store was created and resolves fine for arbitrary ids. + drop(store); +} + +#[test] +fn proof_store_new_picks_up_max_id_from_existing_files() { + // `tempfile::tempdir` removes the directory on Drop even when the + // test panics, so no /tmp/zkcoins-* tree leaks on failure. + let tmp = tempfile::tempdir().expect("create tempdir"); + let dir = tmp.path(); + // Drop a few well-formed and one malformed filename. + std::fs::write(dir.join("3.bin"), b"placeholder").unwrap(); + std::fs::write(dir.join("17.bin"), b"placeholder").unwrap(); + std::fs::write(dir.join("garbage.bin"), b"placeholder").unwrap(); + std::fs::write(dir.join("notbin.txt"), b"placeholder").unwrap(); + + let store = ProofStore::new(dir.to_str().unwrap()); + // next_id starts at max(3, 17) + 1 = 18; the malformed names are skipped. + let id = store.next_id.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(id, 18); +} + +#[test] +fn persist_proof_bytes_logs_error_when_write_fails() { + // Pointing at a file inside a directory that does not exist guarantees + // `File::create` inside `atomic_write` returns an `Err` on both Linux + // and macOS. The function is best-effort: it logs and returns (). + // Exercising it covers the `if let Err(e) = ...` arm in router.rs + // that was reported uncovered on the Linux runner only. + let bad = std::path::Path::new("/this/path/does/not/exist/zkcoins/0.bin"); + ProofStore::persist_proof_bytes(bad, b"payload", 42); +} + +#[test] +fn persist_proof_bytes_succeeds_when_write_succeeds() { + // Mirror test for the Ok arm so the helper is fully exercised. + // `tempfile::tempdir` cleans up on Drop, even on test panic. + let tmp = tempfile::tempdir().expect("create tempdir"); + let path = tmp.path().join("99.bin"); + ProofStore::persist_proof_bytes(&path, b"payload", 99); + assert_eq!(std::fs::read(&path).unwrap(), b"payload"); +} + +#[tokio::test] +async fn commit_with_wrong_length_signature_returns_422() { + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([6u8; 32]); + let amount: u64 = 1; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + let proof_id = send_resp["proof_id"].as_u64().unwrap(); + + // Signature hex is parseable, but length is wrong (1 byte instead of 64). + let commit_body = serde_json::json!({ + "proof_id": proof_id, + "public_key": hex::encode(pk_0.serialize()), + "signature": "00", + "message": hex::encode([0u8; 32]), + }); + let commit_req = Request::post("/api/commit") + .header("content-type", "application/json") + .body(Body::from(commit_body.to_string())) + .unwrap(); + let (status, _) = send_request_with_state(state, commit_req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn receive_coin_with_valid_proof_succeeds() { + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([7u8; 32]); + let amount: u64 = 1; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let proof_id = serde_json::from_str::(&body).unwrap()["proof_id"] + .as_u64() + .unwrap(); + + // Read the stored proof bytes via /api/proof/:id and POST them back + // to /api/receive — this should exercise the success path of + // receive_coin_handler. + let proof_req = Request::get(format!("/api/proof/{}", proof_id)) + .body(Body::empty()) + .unwrap(); + let app = create_router(state.clone()); + let proof_resp = app.oneshot(proof_req).await.unwrap(); + assert_eq!(proof_resp.status(), StatusCode::OK); + let proof_bytes = proof_resp.into_body().collect().await.unwrap().to_bytes(); + assert!(!proof_bytes.is_empty()); + + let receive_req = Request::post("/api/receive") + .header("content-type", "application/octet-stream") + .body(Body::from(proof_bytes.to_vec())) + .unwrap(); + let (status, body) = send_request_with_state(state, receive_req).await; + assert_eq!(status, StatusCode::OK); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + resp["success"], true, + "receive should report success: {body}" + ); +} + +#[tokio::test] +async fn send_with_wrong_signature_returns_401() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::PublicKey; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([8u8; 32]); + let amount: u64 = 1; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // 64 zero bytes — valid hex shape, valid signature length, but + // will never verify against the request's pk_0 over the SHA256 + // of (account_address || recipient || amount || timestamp). + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode([0u8; 64]), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn receive_coin_duplicate_returns_success_false() { + // After a valid receive, posting the same proof bytes again should + // exercise the Err arm of account_node.receive_coin (duplicate + // detection via coin_queue). + let state = test_state(); + + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + let account_address = "0x".to_string() + + &hex::encode(zkcoins_program::hash::digest_to_bytes( + &zkcoins_program::types::MINTING_ADDRESS, + )); + let recipient = "0x".to_string() + &hex::encode([9u8; 32]); + let amount: u64 = 1; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let send_body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let send_req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(send_body.to_string())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), send_req).await; + assert_eq!(status, StatusCode::OK, "send failed: {body}"); + let proof_id = serde_json::from_str::(&body).unwrap()["proof_id"] + .as_u64() + .unwrap(); + + let app = create_router(state.clone()); + let proof_resp = app + .oneshot( + Request::get(format!("/api/proof/{}", proof_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let proof_bytes = proof_resp.into_body().collect().await.unwrap().to_bytes(); + + // First receive: succeeds. + let receive_req = Request::post("/api/receive") + .header("content-type", "application/octet-stream") + .body(Body::from(proof_bytes.to_vec())) + .unwrap(); + let (status, body) = send_request_with_state(state.clone(), receive_req).await; + assert_eq!(status, StatusCode::OK); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(resp["success"], true); + + // Second receive of the same bytes: receive_coin returns Err, the + // handler responds with success=false (the L351 Err arm). + let receive_req = Request::post("/api/receive") + .header("content-type", "application/octet-stream") + .body(Body::from(proof_bytes.to_vec())) + .unwrap(); + let (status, body) = send_request_with_state(state, receive_req).await; + assert_eq!(status, StatusCode::OK); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(resp["success"], false); +} + +#[tokio::test] +async fn send_without_signature_skips_verification_and_proceeds() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::PublicKey; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + + // signature field omitted entirely -> request.signature is None -> + // the verify_send_signature block is skipped (legacy/back-compat path). + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode(zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS)), + "recipient": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 1, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send_request(req).await; + // Without signature, the handler proceeds to send_coins on the + // minting account (seeded with 1_000_000 in test_state) and returns OK. + assert_eq!(status, StatusCode::OK); +} + +#[test] +fn lock_or_recover_account_node_poisoned() { + // Generic instantiation: cover the AccountNode-specific monomorphic + // copy of lock_or_recover's poison-recovery closure. + let state_arc = Arc::new(Mutex::new(State::new())); + let node = Arc::new(Mutex::new(AccountNode::new(Arc::clone(&state_arc)))); + let server_clone = Arc::clone(&node); + + let _ = std::thread::spawn(move || { + let _guard = server_clone.lock().unwrap(); + panic!("intentional poison"); + }) + .join(); + + assert!(node.is_poisoned()); + let _guard = lock_or_recover(&node); +} + +#[test] +fn lock_or_recover_username_store_poisoned() { + // Generic instantiation: cover the UsernameStore-specific monomorphic + // copy of lock_or_recover's poison-recovery closure. + let store = Arc::new(Mutex::new(crate::username::UsernameStore::new())); + let store_clone = Arc::clone(&store); + + let _ = std::thread::spawn(move || { + let _guard = store_clone.lock().unwrap(); + panic!("intentional poison"); + }) + .join(); + + assert!(store.is_poisoned()); + let _guard = lock_or_recover(&store); +} + +// --- Item 1 (Issue #28) — HTTP error mapping for /api/send + /api/mint --- +// +// `map_send_coins_error` is the single source of truth for translating +// `account_node::send_coins` failure strings into a `(StatusCode, +// body)` pair. These unit tests pin every documented error string to +// its mapped pair so adding a new error string anywhere in `send_coins` +// will silently fall through the `_ => INTERNAL_SERVER_ERROR` arm of +// the helper but loudly break one of these tests if the new string was +// supposed to be mapped to a 4xx. + +#[test] +fn map_send_coins_error_unknown_account_address_is_404() { + let (status, body) = crate::router::map_send_coins_error("Unknown account address"); + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body, "Unknown account address"); +} + +#[test] +fn map_send_coins_error_prev_commitment_pubkey_required_is_400() { + let (status, body) = + crate::router::map_send_coins_error("prev_commitment_pubkey required for account update"); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body, "prev_commitment_pubkey required for account update"); +} + +#[test] +fn map_send_coins_error_insufficient_funds_is_422() { + let (status, body) = crate::router::map_send_coins_error("Insufficient funds"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Insufficient funds"); +} + +#[test] +fn map_send_coins_error_unable_to_get_merkle_proofs_is_422() { + // Reachable from send_coins via the prev_commitment_pubkey path + // (account_node::get_merkle_proofs:224). Caller supplied a + // public_key that has no associated commitment proof in state. + let (status, body) = + crate::router::map_send_coins_error("Unable to get merkle proofs for provided public key"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Unable to get merkle proofs for provided public key"); +} + +#[test] +fn map_send_coins_error_unable_to_get_mmr_inclusion_proof_is_422() { + // Reachable from send_coins via get_merkle_proofs (account_node::236). + // Caller's previous_proof references a history root the server's MMR + // hasn't observed yet — stale snapshot, caller-fixable. + let (status, body) = crate::router::map_send_coins_error( + "Unable to get mmr inclusion proof for the previous root", + ); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + body, + "Unable to get mmr inclusion proof for the previous root" + ); +} + +#[test] +fn map_send_coins_error_proof_public_inputs_too_short_is_500() { + // Reachable from send_coins via get_merkle_proofs (account_node::232). + // The proof bytes stored against the account are too short to + // decode N_PROOF_DATA_PUBLIC_INPUTS field elements — server-side + // corruption or version mismatch, not caller-fixable. + let (status, body) = crate::router::map_send_coins_error("Proof public_inputs too short"); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "Proof public_inputs too short"); +} + +#[test] +fn map_send_coins_error_phase_2b_shim_in_coin_not_in_source_ocr_is_422() { + let (status, body) = + crate::router::map_send_coins_error("In-coin not present in source's output_coins_root"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "In-coin not present in source's output_coins_root"); +} + +#[test] +fn map_send_coins_error_phase_2b_shim_source_not_in_history_is_422() { + let (status, body) = + crate::router::map_send_coins_error("Source commitment not present in history MMR"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Source commitment not present in history MMR"); +} + +#[test] +fn map_send_coins_error_coin_missing_commitment_is_422() { + let (status, body) = crate::router::map_send_coins_error("Coin is missing commitment"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Coin is missing commitment"); +} + +#[test] +fn map_send_coins_error_missing_inclusion_proof_is_422() { + let (status, body) = crate::router::map_send_coins_error("Should provide an inclusion proof"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Should provide an inclusion proof"); +} + +#[test] +fn map_send_coins_error_coin_already_in_coin_history_is_422() { + let (status, body) = + crate::router::map_send_coins_error("Coin should not exist in coin history tree"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Coin should not exist in coin history tree"); +} + +#[test] +fn map_send_coins_error_coin_already_in_output_smt_is_422() { + let (status, body) = crate::router::map_send_coins_error("Coin should not exist in tree yet"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Coin should not exist in tree yet"); +} + +#[test] +fn map_send_coins_error_too_many_in_coins_is_422() { + let (status, body) = + crate::router::map_send_coins_error("Too many in-coins for one transition"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Too many in-coins for one transition"); +} + +#[test] +fn map_send_coins_error_too_many_out_coins_is_422() { + let (status, body) = + crate::router::map_send_coins_error("Too many out-coins for one transition"); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body, "Too many out-coins for one transition"); +} + +#[test] +fn map_send_coins_error_prove_failed_initial_collapses_to_500_prove_failed() { + // Per the threat-model note in map_send_coins_error, the prover-internal + // error string is intentionally collapsed to a generic "prove failed" + // body so 5xx responses don't leak prover state to callers. + let (status, body) = crate::router::map_send_coins_error( + "prove_initial_with_in_and_out_coins_and_sources failed", + ); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "prove failed"); +} + +#[test] +fn map_send_coins_error_prove_failed_account_update_collapses_to_500_prove_failed() { + let (status, body) = crate::router::map_send_coins_error( + "prove_account_update_with_in_and_out_coins_and_sources failed", + ); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "prove failed"); +} + +#[test] +fn map_send_coins_error_unknown_string_is_500_internal_error() { + // A new `send_coins` error string we haven't mapped yet must NOT + // accidentally surface as 200 OK / 4xx. The default arm is 500 with + // a generic "internal error" body so the wallet treats it as a + // server problem and the operator finds the unmapped string in the + // `eprintln!` log. + let (status, body) = crate::router::map_send_coins_error("a string we never added"); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "internal error"); +} + +#[tokio::test] +async fn send_with_unknown_account_returns_404_with_error_string() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + + // test_state() only seeds the minting account. Any other 32-byte + // address is unknown to the account_node, so send_coins returns + // "Unknown account address" which the handler maps to 404. + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + let sk_0: SecretKey = xpriv + .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .private_key; + + // An address that is well-formed (hex, 32 bytes) but never claimed + // an account on the server. + let account_address = "0x".to_string() + &hex::encode([0xAAu8; 32]); + let recipient = "0x".to_string() + &hex::encode([1u8; 32]); + let amount: u64 = 50; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = Keypair::from_secret_key(&secp, &sk_0); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "account_address": account_address, + "recipient": recipient, + "amount": amount, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(resp["success"], false); + assert_eq!(resp["error"], "Unknown account address"); +} + +// ======================================================================= +// GET /health/ready — readiness probe +// ======================================================================= +// +// The readiness probe combines a Postgres `SELECT 1` with an Esplora +// `/blocks/tip/height` ping. Each test below exercises one of the three +// reachable code paths (db ok + esplora ok / db fail + esplora ok / db +// ok + esplora fail) so the new `ready_handler` and `check_esplora` +// functions reach 100% line + region coverage. The DB side uses the +// existing `dead_pool` / live-testcontainer helpers; the Esplora side +// uses a per-test `wiremock::MockServer` so no real network is hit. + +/// Spin up a Postgres 17 testcontainer and return a migrated pool — +/// the live half of the readiness happy path (and the db-ok side of +/// the esplora-fails test). +async fn ready_live_pool() -> ( + Arc, + testcontainers::ContainerAsync, +) { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container + .get_host() + .await + .expect("failed to get container host"); + let port = pg_container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + // The container handle MUST outlive the pool: `testcontainers` + // tears the container down on `Drop`, which would close the + // backing Postgres before the test finishes querying. + (pool, pg_container) +} + +/// Build an `AppState` whose `esplora_config` points at the supplied +/// `wiremock` URL. The DB pool is supplied separately so tests can +/// mix-and-match dead vs. live Postgres. +fn ready_state(pool: Arc, esplora_url: String) -> AppState { + let mut state = test_state(); + state.pool = pool; + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: esplora_url, + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }); + state +} + +#[tokio::test] +async fn ready_returns_200_when_db_and_esplora_reachable() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let (pool, _pg) = ready_live_pool().await; + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/blocks/tip/height")) + .respond_with(ResponseTemplate::new(200).set_body_string("123456")) + .mount(&mock_server) + .await; + + let state = ready_state(pool, mock_server.uri()); + let req = Request::get("/health/ready").body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(v["ready"], true); + assert_eq!(v["failures"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn ready_returns_503_when_db_unreachable() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // Esplora is healthy … + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/blocks/tip/height")) + .respond_with(ResponseTemplate::new(200).set_body_string("123456")) + .mount(&mock_server) + .await; + + // … but Postgres is the lazy-connect dead pool, which fails on first + // query with a connect error. `ready_handler` must surface that as + // 503 + `failures: ["db"]`. + let state = ready_state(dead_pool(), mock_server.uri()); + let req = Request::get("/health/ready").body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(v["ready"], false); + let failures: Vec = v["failures"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert_eq!(failures, vec!["db".to_string()]); +} + +#[tokio::test] +async fn ready_returns_503_when_esplora_unreachable() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let (pool, _pg) = ready_live_pool().await; + + // Live Postgres + Esplora returning 500 → only `esplora` fails. + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/blocks/tip/height")) + .respond_with(ResponseTemplate::new(500).set_body_string("upstream down")) + .mount(&mock_server) + .await; + + let state = ready_state(pool, mock_server.uri()); + let req = Request::get("/health/ready").body(Body::empty()).unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(v["ready"], false); + let failures: Vec = v["failures"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert_eq!(failures, vec!["esplora".to_string()]); +} + +// ======================================================================= +// GET /health/publisher — operational preflight +// ======================================================================= +// +// The publisher health probe surfaces (address, utxo_count, total_sats) +// for the deploy-dev preflight. Two reachable arms after the lazy_static +// `PUBLISHER_ADDRESS` refactor: Ok (Esplora responded) and Err (Esplora- +// side error). The `SecretKey::from_str` panic-arm is no longer in the +// request path — `PUBLISHER_KEY` is validated once at startup. + +#[tokio::test] +async fn health_publisher_returns_200_with_utxo_count_and_total_sats_when_esplora_responds() { + // Mock Esplora returning a known UTXO set so the handler's Ok arm + // is exercised: GET /address/{publisher_addr}/utxo returns a JSON + // array of UTXOs that get_publisher_utxo parses and sums. + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let esplora_mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex(r"^/address/.+/utxo$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "txid": "a".repeat(64), + "vout": 0, + "value": 50_000, + "status": { "confirmed": true, "block_height": 1, "block_hash": "b".repeat(64), "block_time": 0 } + }, + { + "txid": "c".repeat(64), + "vout": 1, + "value": 12_345, + "status": { "confirmed": true, "block_height": 2, "block_hash": "d".repeat(64), "block_time": 0 } + } + ]))) + .mount(&esplora_mock) + .await; + + let mut state = mint_test_state(); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: esplora_mock.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }); + + let req = Request::get("/health/publisher") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK, "body={}", body); + let v: serde_json::Value = serde_json::from_str(&body).expect("publisher health body is JSON"); + assert!( + v["address"] + .as_str() + .expect("address present") + .starts_with("tb1p"), + "publisher address must be Mutinynet bech32 Taproot, got: {:?}", + v["address"] + ); + assert_eq!(v["utxo_count"].as_u64().expect("utxo_count u64"), 2); + assert_eq!(v["total_sats"].as_u64().expect("total_sats u64"), 62_345); +} + +#[tokio::test] +async fn health_publisher_returns_503_when_esplora_unreachable() { + // Drive the Err arm: mint_test_state() already points esplora at + // 127.0.0.1:1 (unreachable), so get_publisher_utxo returns Err + // and the handler must map to 503. + let state = mint_test_state(); + let req = Request::get("/health/publisher") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "body={}", body); + let v: serde_json::Value = + serde_json::from_str(&body).expect("publisher health err body is JSON"); + assert_eq!( + v["error"].as_str().expect("error field present"), + "Esplora-side error fetching publisher UTXOs" + ); + assert!( + v["address"] + .as_str() + .expect("address present") + .starts_with("tb1p"), + "publisher address must be returned even on Esplora failure, got: {:?}", + v["address"] + ); + assert!( + v["detail"].as_str().is_some(), + "detail field must be present for diagnostics" + ); +} + +// ======================================================================= +// POST /api/mint — handler coverage +// ======================================================================= +// +// Before #480300b the mint endpoint was gated behind the `faucet` Cargo +// feature, so `mint_handler` was excluded from the MVP-scope coverage +// gate. After the gate removal (mint is now permanent MVP) every line +// of the handler counts toward `--fail-under-lines 100 --fail-under- +// functions 100`. The tests below cover each reachable arm: +// +// - request validation (422 invalid hex / 422 wrong length) +// - bootstrap failure (500 missing minting account) +// - `send_coins` failure mapping (422 via the slot-count guard, which +// fires before the prover so the test is cheap) +// - the post-`send_coins` Ok arm: num_pubkeys increment, ProofData +// reconstruction, commitment build, `db::upsert_minting_num_pubkeys`, +// and the inscription broadcast. +// +// The happy-path tests run the real prover; one mint takes ~seconds on +// the M3-Ultra runner but compiles cheaply, so they stay in the unit- +// test suite rather than moving to `tests/`. + +/// Build an `AppState` configured for mint tests: minting account +/// seeded with `1u64 << 48` (Goldilocks-safe — see `runtime +/// ::start_rest_node`'s bootstrap comment), real prover wired +/// through the default `AccountNode`, dead Postgres pool by default +/// (callers swap it for a live pool via the second return value). +fn mint_test_state() -> AppState { + let state_inner = Arc::new(Mutex::new(State::new())); + let mut account_node = AccountNode::new(Arc::clone(&state_inner)); + + // The Plonky2 state-transition circuit packs the running balance + // as `balance_hi * 2^32 + balance_lo`; keeping the seed below 2^48 + // matches the production bootstrap in `start_rest_node`. + let mut minting_account = Account::new(); + minting_account.balance = 1u64 << 48; + account_node.import_account(*zkcoins_program::types::MINTING_ADDRESS, minting_account); + + // Mirror the production bootstrap: the wallet's address is forced + // to the canonical `MINTING_ADDRESS` constant, regardless of what + // `ClientAccount::new` would otherwise derive from the secret. + let minting_client = { + let secret = include_bytes!("../minting_secret.bin"); + let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) + .expect("Failed to create test private key"); + let mut c = shared::ClientAccount::new(private_key); + c.address = *zkcoins_program::types::MINTING_ADDRESS; + c + }; + + AppState { + account_node: Arc::new(Mutex::new(account_node)), + proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-mint-test-proofs")), + minting_account: Arc::new(Mutex::new(minting_client)), + username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), + pool: dead_pool(), + esplora_config: Arc::new(crate::publisher::EsploraConfig { + url: "http://127.0.0.1:1/api".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }), + phase2_reached: Arc::new(tokio::sync::Notify::new()), + phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), + state_advance_release_lock: Arc::new(tokio::sync::Mutex::new(())), + } +} + +/// Variant of [`mint_test_state`] that DROPS the minting account so +/// `get_minting_account_address` returns Err — drives the 500 +/// "Minting account not configured" arm in `mint_handler`. +fn mint_test_state_without_minting_account() -> AppState { + let state = mint_test_state(); + { + let mut node = state.account_node.lock().unwrap(); + // Reset to a brand-new server with no accounts at all. The + // `Arc>` inside `server` is replaced too, but the + // shared `state_inner` is dropped on overwrite which is fine + // — nothing else holds it after `mint_test_state` returns. + *node = AccountNode::new(Arc::new(Mutex::new(State::new()))); + } + state +} + +#[tokio::test] +async fn mint_invalid_hex_address_returns_422() { + let body = serde_json::json!({ + "account_address": "not_hex", + "amount": 100u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(mint_test_state(), req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "account_address is not valid hex"); +} + +#[tokio::test] +async fn mint_wrong_address_length_returns_422() { + // 16 bytes of hex (32 chars) — well-formed hex but not 32 bytes, + // so the length check fires. + let body = serde_json::json!({ + "account_address": "0x".to_string() + &"ab".repeat(16), + "amount": 100u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(mint_test_state(), req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!( + v["error"], + "account_address must be 32 bytes (64 hex chars)" + ); +} + +#[tokio::test] +async fn mint_without_minting_account_returns_500() { + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 100u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = + send_request_with_state(mint_test_state_without_minting_account(), req).await; + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Minting account not configured"); +} + +#[tokio::test] +async fn mint_insufficient_funds_returns_422() { + // Replace the minting account's balance with zero so `send_coins` + // bails out on the balance check (Err arm of `mint_handler`'s + // outer match) before paying the prover cost. Maps to 422 via + // `send_coins_error_response`. + let state = mint_test_state(); + { + let mut node = state.account_node.lock().unwrap(); + // Re-import the minting account with balance=0. The previous + // import is overwritten by HashMap semantics inside + // `import_account`. + let mut empty = Account::new(); + empty.balance = 0; + node.import_account(*zkcoins_program::types::MINTING_ADDRESS, empty); + } + + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 100u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Insufficient funds"); +} + +/// Drives `mint_handler` through the prepare-then-broadcast phases: +/// `prepare_mint` runs the full prover, builds the commitment, then +/// the inscription broadcast fails against the default unreachable +/// `esplora_config` (127.0.0.1:1) and the handler returns 503. +/// +/// **zk-coins/node#89 regression guard.** The asserts below pin the +/// no-state-advance contract that the prepare-then-commit refactor +/// introduced: after a broadcast failure the in-memory +/// `minting_account.num_pubkeys` MUST still be 0, the minting +/// `Account` in the server's map MUST still have an empty +/// `coin_queue`, `proof = None`, and the unchanged seed balance, and +/// the recipient account MUST NOT exist yet. Before this PR the +/// handler had already bumped the counter + mutated the minting +/// `Account` + (in the soft-fail DEV flavour) returned 200 — see the +/// issue text for the production manifestation. +#[tokio::test] +async fn mint_broadcast_failure_returns_503() { + let state = mint_test_state(); + let recipient_bytes = [7u8; 32]; + let recipient_addr = zkcoins_program::hash::digest_from_bytes(&recipient_bytes); + + // Snapshot the pre-mint minting Account so we can prove the + // failed-broadcast path leaves it byte-identical. + let minting_balance_before: u64; + let minting_coin_queue_len_before: usize; + let minting_proof_some_before: bool; + { + let server_guard = state.account_node.lock().unwrap(); + let acct = server_guard + .get_account(&zkcoins_program::types::MINTING_ADDRESS) + .expect("minting account seeded by mint_test_state"); + minting_balance_before = acct.balance; + minting_coin_queue_len_before = acct.coin_queue.len(); + minting_proof_some_before = acct.proof.is_some(); + } + let num_pubkeys_before = state.minting_account.lock().unwrap().num_pubkeys; + assert_eq!( + num_pubkeys_before, 0, + "fresh mint_test_state starts with num_pubkeys=0" + ); + + let recipient = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state.clone(), req).await; + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Failed to broadcast mint inscription on-chain"); + + // No-state-advance asserts: every persistent + in-memory side of + // the mint flow must look exactly as it did before the request. + let num_pubkeys_after = state.minting_account.lock().unwrap().num_pubkeys; + assert_eq!( + num_pubkeys_after, 0, + "in-memory minting_account.num_pubkeys must NOT advance on broadcast failure (zk-coins/node#89)" + ); + { + let server_guard = state.account_node.lock().unwrap(); + let acct_after = server_guard + .get_account(&zkcoins_program::types::MINTING_ADDRESS) + .expect("minting account still present after failed mint"); + assert_eq!( + acct_after.balance, minting_balance_before, + "minting Account balance must NOT change on broadcast failure" + ); + assert_eq!( + acct_after.coin_queue.len(), + minting_coin_queue_len_before, + "minting Account coin_queue must NOT change on broadcast failure" + ); + assert_eq!( + acct_after.proof.is_some(), + minting_proof_some_before, + "minting Account proof must NOT be set by a failed-broadcast mint" + ); + assert!( + server_guard.get_account(&recipient_addr).is_none(), + "recipient account must NOT be created when broadcast fails" + ); + } +} + +/// Companion to `mint_broadcast_failure_returns_503` that drives the +/// inscription broadcast through a wiremock Esplora that ACCEPTS the +/// commit + reveal POSTs, so `mint_handler` falls through into the +/// post-broadcast section: `receive_coin` loop, account-snapshot +/// builder, per-account `db::upsert_account` log-and-continue loop, +/// and the `coin_proofs.pop().expect(...)` value-extraction returning +/// 200 with a usable `proof_id`. +/// +/// Uses a live Postgres testcontainer so the `upsert_minting_num_pubkeys` +/// + `upsert_account` calls hit the Ok arm of the persistence helpers +/// (rather than the dead-pool Err arm, which the broadcast-failure test +/// above covers). Together the two tests pin every line of the +/// mint_handler Ok branch. +#[tokio::test] +async fn mint_happy_path_broadcasts_and_returns_proof_id() { + use bitcoin::Network; + use bitcoin::{ + key::Secp256k1, + secp256k1::{Keypair, SecretKey}, + XOnlyPublicKey, + }; + use std::str::FromStr; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // 1. Spin up a real Postgres so the upsert helpers run their Ok + // arms (the dead-pool test above already covers the Err arms). + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // 2. Spin up wiremock and answer the publisher's UTXO + broadcast + // requests. The publisher key under test is the CI test value + // set via the `PUBLISHER_KEY` env var in `.github/workflows/ci.yaml` + // (`0000…0001`, a syntactically valid 32-byte hex placeholder + // distinct from the publicly burned `1234…` key removed in the + // "require PUBLISHER_KEY on every network" hardening) — derive + // the matching Taproot address so the `/address//utxo` + // mock matches. + let mock_server = MockServer::start().await; + let secp = Secp256k1::new(); + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .expect("CI test publisher key parses"); + let key_pair = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = XOnlyPublicKey::from_keypair(&key_pair); + let publisher_address = bitcoin::Address::p2tr(&secp, xonly, None, Network::Signet); + + // 100_000 sats covers the commit + reveal fees (mirrors the + // publisher_tests::create_and_broadcast_inscription_succeeds_end_to_end + // setup). + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "txid": "3333333333333333333333333333333333333333333333333333333333333333", + "vout": 0, + "value": 100_000, + "status": { + "confirmed": true, + "block_height": 100, + "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "block_time": 1_700_000_000 + } + } + ]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&mock_server) + .await; + + // 3. Wire the AppState to the live pool + wiremock URL. + let ws_url = mint_broadcast_mock_ws().await; + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + let recipient_bytes = [9u8; 32]; + let recipient_hex = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient_hex, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK, "body: {}", resp_body); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], true); + let proof_id = v["proof_id"] + .as_u64() + .expect("proof_id missing from response"); + // NOTE (B5 deferred): can't pin proof_id == 1 because proof store ID + // grows across DB lifetime — same constraint as the minting balance + // bound. We assert > 0 (= the proof was actually persisted) and rely + // on the integration test (api_remote `mint_roundtrip_lands_balance_and_proof`) + // to verify the proof file is fetchable + bincode-decodable. + assert!( + proof_id > 0, + "fresh-state mint must emit a non-zero proof_id" + ); + // Per the mint_handler contract, the mint response intentionally + // omits `account_state_hash` and `output_coins_root` (those are + // returned by /api/send instead). + assert!(v["account_state_hash"].is_null()); + assert!(v["output_coins_root"].is_null()); + + // 4. Verify the persistence side-effects of the Ok arm: the + // accounts row for the MINTING address was upserted by + // `commit_mint_tx`. Phase D removed the separately-stored + // `minting_meta.num_pubkeys` counter; the value is derived from + // SMT membership at runtime, and the SMT is updated + // asynchronously by the scanner when it observes the + // inscription on chain. Within the test boundary the scanner + // has not run, so the only persisted evidence of the successful + // mint is the upserted accounts row. + let minting_addr_bytes = + zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS); + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM accounts WHERE address = $1") + .bind(&minting_addr_bytes[..]) + .fetch_optional(&*pool) + .await + .expect("select minting accounts row"); + let (data,) = row.expect("upsert wrote the minting account row"); + assert!(!data.is_empty(), "minting account blob must be non-empty"); +} + +/// Covers the `current_num_pubkeys > 0` arm of the +/// `prev_commitment_pubkey` derivation at the top of `mint_handler`. +/// The default mint state has empty SMT → derive returns 0 → handler +/// takes the `None` arm of that `if`. Pre-seeding the SMT with `pk_0` +/// (the minting account's first BIP-32 child pubkey) bumps +/// `derive_num_pubkeys_from_smt` to 1, so the handler takes the +/// `Some(prev_pk)` arm. The downstream `send_coins` stays on the +/// initial-prove path because the in-memory minting `Account` still +/// has `proof = None` (no prior mint has actually run on this +/// AppState), so the handler reaches the broadcast call. The broadcast +/// then fails against the default unreachable Esplora URL and the +/// handler returns 503, but the key-generation arm we wanted is +/// already covered by that point. +#[tokio::test] +async fn mint_with_nonzero_num_pubkeys_covers_prev_pubkey_arm() { + use bitcoin::hashes::Hash; + let state = mint_test_state(); + // Seed the SMT with pk_0 so `derive_num_pubkeys_from_smt` returns + // 1. The leaf value is arbitrary (we only check membership). + { + let mc = state.minting_account.lock().unwrap(); + let pk0 = mc.generate_public_key(0); + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&pk0.serialize()).to_byte_array(); + let node_guard = state.account_node.lock().unwrap(); + let state_arc = node_guard.state().clone(); + drop(node_guard); + let mut state_guard = state_arc.lock().unwrap(); + state_guard + .smt + .insert(key, zkcoins_program::hash::digest_from_bytes(&[1u8; 32])) + .expect("seed pk_0 into SMT"); + } + + let recipient = "0x".to_string() + &hex::encode([5u8; 32]); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + resp_body + ); +} + +/// Spin up the wiremock Esplora + matching publisher Taproot UTXO mock +/// used by the mint happy-path test. Returned `MockServer` is kept +/// alive by the caller; dropping it tears down the HTTP listener. +/// Spin up an in-process WS server that emulates the mempool.space +/// `track-tx` flow used by `publisher::broadcast_inscription_txs` +/// (issue #84): accept the subscribe frame and echo a documented +/// `txPosition` event for the txid the client subscribed to, so +/// the publisher's `wait_for_tx_in_mempool` resolves immediately. +/// Returns the `ws://` URL. +async fn mint_broadcast_mock_ws() -> String { + use futures_util::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as WsMessage; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{}", addr); + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(s) => s, + Err(_) => return, + }; + // Spawn per-connection so the accept loop continues + // immediately and tests issuing multiple sequential mints + // (each with its own WS connect) are not serialised behind + // the previous connection's 60s keepalive sleep. + tokio::spawn(async move { + let mut ws = match tokio_tungstenite::accept_async(stream).await { + Ok(w) => w, + Err(_) => return, + }; + let first = match ws.next().await { + Some(Ok(WsMessage::Text(t))) => t, + _ => return, + }; + let value: serde_json::Value = match serde_json::from_str(&first) { + Ok(v) => v, + Err(_) => return, + }; + if value.get("action") == Some(&serde_json::json!("track-tx")) { + if let Some(txid_str) = value.get("data").and_then(|v| v.as_str()) { + // Documented mempool.space `txPosition` shape; + // see `scanner_ws::frame_signals_tx_seen`. + let frame = format!( + r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, + txid_str + ); + let _ = ws.send(WsMessage::Text(frame)).await; + } + } + let _ = tokio::time::sleep(std::time::Duration::from_secs(60)).await; + }); + } + }); + url +} + +async fn mint_broadcast_mock_server() -> wiremock::MockServer { + use bitcoin::Network; + use bitcoin::{ + key::Secp256k1, + secp256k1::{Keypair, SecretKey}, + XOnlyPublicKey, + }; + use std::str::FromStr; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + let secp = Secp256k1::new(); + let sk = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .expect("CI test publisher key parses"); + let key_pair = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = XOnlyPublicKey::from_keypair(&key_pair); + let publisher_address = bitcoin::Address::p2tr(&secp, xonly, None, Network::Signet); + + Mock::given(method("GET")) + .and(path(format!("/address/{}/utxo", publisher_address))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "txid": "3333333333333333333333333333333333333333333333333333333333333333", + "vout": 0, + "value": 100_000, + "status": { + "confirmed": true, + "block_height": 100, + "block_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "block_time": 1_700_000_000 + } + } + ]))) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&mock_server) + .await; + + mock_server +} + +/// Drives the Err arm of the pre-broadcast `pending_inscriptions` +/// persist that PR #107 introduced. With the lazy `dead_pool` that +/// connect-errors on first use, the publisher's +/// `broadcast_inscription_txs_with_persistence` fails at the very +/// first DB write (the `constructed`-row INSERT) BEFORE any tx is +/// broadcast on chain. The publisher wraps the persistence error as +/// `"persist pending inscription: …"` and the handler maps that to +/// `503 SERVICE_UNAVAILABLE` "Failed to broadcast mint inscription +/// on-chain". +/// +/// Contract: with a broken persistence layer, no on-chain commitment +/// is published and `mint_handler` returns 503 cleanly. Coverage of +/// the deeper post-broadcast `commit_mint_tx` Err branch is in +/// `mint_commit_mint_tx_failure_returns_503` below (live pool + +/// `accounts`-table trigger). +#[tokio::test] +async fn mint_pending_inscriptions_persist_failure_returns_503() { + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + // dead_pool stays in place from mint_test_state; only swap the + // Esplora URL so the broadcast succeeds. + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + let recipient = "0x".to_string() + &hex::encode([4u8; 32]); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Failed to broadcast mint inscription on-chain"); +} + +/// Drives the Err arm of the post-broadcast `db::commit_mint_tx` call +/// at the tail of `mint_handler` (router.rs ~ "Failed to persist mint +/// commit transaction"). Uses a live Postgres so the publisher's +/// pre-broadcast `pending_inscriptions` INSERT, the broadcast itself, +/// the in-memory `state.update`, and the atomic +/// `persist_state_and_mark_complete_tx` all succeed; an `accounts` +/// trigger then raises on the final `INSERT` so `commit_mint_tx` +/// rolls back. Handler converts to 503. +/// +/// Coverage: this is the only test exercising the `commit_mint_tx` +/// Err branch in `mint_handler` post-Phase-E (the dead-pool path +/// short-circuits earlier — see +/// `mint_pending_inscriptions_persist_failure_returns_503`). +#[tokio::test] +async fn mint_commit_mint_tx_failure_returns_503() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // Trigger raises on every accounts INSERT, surfacing as + // `sqlx::Error::Database` from inside `commit_mint_tx`'s tx. + sqlx::query( + "CREATE OR REPLACE FUNCTION fail_accounts_insert() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'simulated commit_mint_tx failure'; + END; + $$ LANGUAGE plpgsql", + ) + .execute(&*pool) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER block_accounts_insert BEFORE INSERT ON accounts \ + FOR EACH ROW EXECUTE FUNCTION fail_accounts_insert()", + ) + .execute(&*pool) + .await + .unwrap(); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + let recipient_bytes = [12u8; 32]; + let recipient = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "commit_mint_tx failure must surface 503, body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Failed to persist mint commit transaction"); +} + +/// Drives the Err arm of `AccountNode::receive_coin_into` inside +/// the commit phase of `mint_handler`. Pre-populates the recipient +/// account's `coin_history` SMT with the identifier that +/// `prepare_mint` is about to produce, so `receive_coin_into` returns +/// `Err("Coin already spent (replay)")` on the cloned recipient. +/// Identifier prediction mirrors `Account::create_coins` off-circuit +/// (canonical AccountState layout + Poseidon hash + index 0). +/// +/// Per the prepare-then-commit refactor (zk-coins/node#89) the +/// receive error is logged and the unchanged recipient clone still +/// participates in `commit_mint_tx`. With a live Postgres the +/// transaction commits, the handler returns 200 OK, and +/// `minting_meta.num_pubkeys` advances to 1. +#[tokio::test] +async fn mint_receive_coin_failure_logs_and_returns_ok() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let recipient_bytes = [6u8; 32]; + let recipient = zkcoins_program::hash::digest_from_bytes(&recipient_bytes); + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + // Predict the coin identifier that `prepare_mint` will assign to + // the freshly-minted output coin. `Account::create_coins` builds + // `next_account_state` with `owner = MINTING_ADDRESS`, + // `balance = minting_balance - amount`, and + // `public_key = current minting pubkey`, then hashes it and feeds + // the digest into `calculate_coin_identifier(_, 0)`. + let amount: u64 = 1; + let minting_balance: u64 = 1u64 << 48; + let minting_pubkey_bytes = { + let mc = state.minting_account.lock().unwrap(); + mc.generate_public_key(0).serialize() + }; + let next_account_state = zkcoins_program::types::AccountState { + owner: *zkcoins_program::types::MINTING_ADDRESS, + balance: minting_balance - amount, + public_key: minting_pubkey_bytes, + }; + let predicted_coin_id = + zkcoins_program::types::calculate_coin_identifier(next_account_state.hash(), 0); + let predicted_coin_id_bytes = zkcoins_program::hash::digest_to_bytes(&predicted_coin_id); + + // Pre-insert the predicted identifier into the recipient's + // coin_history SMT so `receive_coin_into` sees the coin as + // already spent. + let mut recipient_account = Account::new(); + recipient_account + .coin_history + .insert(predicted_coin_id_bytes, predicted_coin_id) + .expect("insert into fresh SMT must succeed"); + { + let mut node = state.account_node.lock().unwrap(); + node.import_account(recipient, recipient_account); + } + + let recipient_hex = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient_hex, + "amount": amount, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state, req).await; + + assert_eq!(status, StatusCode::OK, "body: {}", resp_body); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], true); + let proof_id = v["proof_id"] + .as_u64() + .expect("proof_id missing from response"); + // NOTE (B5 deferred): can't pin proof_id == 1 because proof store ID + // grows across DB lifetime — same constraint as the minting balance + // bound. We assert > 0 (= the proof was actually persisted) and rely + // on the integration test (api_remote `mint_roundtrip_lands_balance_and_proof`) + // to verify the proof file is fetchable + bincode-decodable. + assert!( + proof_id > 0, + "fresh-state mint must emit a non-zero proof_id" + ); +} + +/// Retry-after-broadcast-failure (zk-coins/node#89). +/// +/// First mint runs against an unreachable Esplora (the default +/// `mint_test_state` config points at 127.0.0.1:1) — the handler +/// fails the broadcast and returns 503. The persisted state must be +/// untouched: no `accounts` row for the minting address, the minting +/// Account still has `proof = None` and `coin_queue` empty. Second +/// mint reuses the same `AppState` but swaps in a working wiremock +/// Esplora; the broadcast succeeds, `commit_mint_tx` writes the +/// bundle in one transaction, and the handler returns 200. After the +/// second call the recipient `accounts` row exists with the minted +/// coin in its queue, and the proofs Vec was popped once. +/// +/// Phase D removed the `minting_meta.num_pubkeys` counter; the +/// per-mint `derive_num_pubkeys_from_smt` walks the SMT directly so +/// there is no persisted counter to assert here. The scanner has not +/// run within the test boundary, so `derive_num_pubkeys_from_smt` +/// would still return 0 after the second mint — that race window is +/// the documented in-process gate (see `mint_handler` doc-comment), +/// not a regression. +/// +/// **Idempotent-retry caveat (documented in `mint_handler`).** On a +/// real broadcast failure where the first commit + reveal pair +/// actually landed on chain but the response was lost, a retry +/// produces an identical inscription txid and Bitcoin returns +/// `txn-already-known`. The handler returns 503 again; reconciliation +/// happens on the next scanner sweep. This test does NOT cover that +/// branch — it only proves the "broadcast genuinely failed, no chain +/// effect, retry succeeds" flow. +#[tokio::test] +async fn mint_retry_after_broadcast_failure_succeeds() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // ---- First mint: dead Esplora → 503 --------------------------------- + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + // Keep the default unreachable URL so the broadcast fails. + let cloned_state_first = state.clone(); + + let recipient_bytes = [9u8; 32]; + let recipient = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status1, _body1) = send_request_with_state(cloned_state_first, req).await; + assert_eq!(status1, StatusCode::SERVICE_UNAVAILABLE); + + // Confirm no `accounts` row was written for the minting address — + // Phase D's `commit_mint_tx` only runs after the broadcast + // succeeds, so a 503 from the broadcast leg leaves the table + // empty. Phase D removed the separately-stored + // `minting_meta.num_pubkeys` counter, so there is no DB-side + // counter to inspect. + let minting_addr_bytes = + zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS); + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM accounts WHERE address = $1") + .bind(&minting_addr_bytes[..]) + .fetch_optional(&*pool) + .await + .expect("select accounts row after failed mint"); + assert!( + row.is_none(), + "no accounts row for minting address must be written when broadcast fails" + ); + + // ---- Second mint: working Esplora → 200 ----------------------------- + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + let cloned_state_second = state.clone(); + let body2 = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req2 = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body2.to_string())) + .unwrap(); + let (status2, resp_body2) = send_request_with_state(cloned_state_second, req2).await; + assert_eq!(status2, StatusCode::OK, "body: {}", resp_body2); + + // Final state: minting accounts row was upserted (the retry + // landed in `commit_mint_tx`), recipient account exists with the + // minted coin in its queue. No `minting_meta.num_pubkeys` + // assertion — Phase D removed the counter. + let minting_row: Option<(Vec,)> = + sqlx::query_as("SELECT data FROM accounts WHERE address = $1") + .bind(&minting_addr_bytes[..]) + .fetch_optional(&*pool) + .await + .expect("select minting accounts row after retry"); + assert!( + minting_row.is_some(), + "minting accounts row must be written by the successful retry" + ); + let recipient_digest = zkcoins_program::hash::digest_from_bytes(&recipient_bytes); + { + let node_guard = state.account_node.lock().unwrap(); + let recipient_account = node_guard + .get_account(&recipient_digest) + .expect("recipient account must be created on successful mint"); + // The second mint above credits `1u64`; the recipient's + // coin_queue must reflect exactly that single inflow. A + // shape-only `is_some()` previously masked a bug where the + // account row was inserted with an empty queue. + assert_eq!( + recipient_account.coin_queue.len(), + 1, + "recipient coin_queue must hold exactly the minted coin, got {:?}", + recipient_account.coin_queue.len() + ); + } +} + +// Phase D removed the optimistic `commit_mint_tx` UPDATE branch that +// the pre-Phase-D `concurrent_mints_only_one_commits` test pinned. +// The new concurrency gate is the phase-2 re-derive of +// `derive_num_pubkeys_from_smt` against the live SMT — covered by +// `mint_handler_concurrent_mint_during_proof_returns_503` below, which +// drives the same 503 exit through `mint_handler` end-to-end. + +/// Drives the post-proof "concurrent mint detected during proof phase" +/// branch of `mint_handler` (router.rs:854-858 / zk-coins/node#90) +/// against the pure helper. +/// +/// Pairs with `mint_handler_concurrent_mint_during_proof_returns_503` +/// below, which drives the SAME branch end-to-end through +/// `mint_handler` so the call site itself (the +/// `return concurrent_mint_during_proof_response(...)` invocation) +/// is covered, not just the helper. +#[tokio::test] +async fn concurrent_mint_during_proof_response_returns_503() { + let (status, Json(body)) = crate::router::concurrent_mint_during_proof_response(0, 1); + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "concurrent-mint-during-proof must surface 503" + ); + assert!(!body.success); + assert_eq!(body.error.as_deref(), Some("Concurrent mint detected")); +} + +/// End-to-end race that drives the post-proof "concurrent mint +/// detected during proof phase" branch of `mint_handler` through the +/// HTTP layer so the `return concurrent_mint_during_proof_response(...)` +/// call site (router.rs) is covered, not just the helper. +/// +/// Phase D shape: the in-process gate is a re-derive of +/// `derive_num_pubkeys_from_smt` between the phase-1 SNAPSHOT and the +/// phase-3 commit-signing leg. Triggering the gate deterministically +/// means inserting `pk_0`'s key into the SMT between the two derives +/// (simulating a scanner ingestion of a concurrent mint's inscription +/// while we were proving). +/// +/// Synchronisation strategy (deterministic, NOT time-based): the +/// handler signals it has acquired the `state.account_node` guard +/// at the top of phase 2 via the test-only +/// `state.phase2_reached: Arc` field; the test +/// `.notified().await`s on it, then inserts the minting account's +/// `pk_0` into the SMT. The handler proceeds through phase 2 (prover +/// work), reaches phase 3, re-derives `num_pubkeys` from the SMT, +/// observes the bumped count (1 vs the captured expected 0), and +/// returns 503 before ever touching the broadcast / Esplora / +/// Postgres paths — so the bare `mint_test_state()` (dead pool, +/// unreachable Esplora) is sufficient. +/// +/// Requires the multi-thread runtime: phase 2's `prepare_mint` is +/// blocking CPU work that would otherwise stall the single-threaded +/// executor and prevent the test thread from running the SMT +/// insertion step. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mint_handler_concurrent_mint_during_proof_returns_503() { + use bitcoin::hashes::Hash; + let state = mint_test_state(); + + // Acquire `phase3_release_lock` BEFORE spawning the request. The + // handler's `lock().await` between `prepare_mint` and the phase-3 + // re-derive will BLOCK until this test drops the guard after + // injecting `pk_0`. Using a Mutex (vs a Notify with one-permit + // semantics) makes this primitive reusable for any number of + // sequential mints — production-shaped tests acquire + drop in + // one step against the unlocked Mutex. + let phase3_guard = state.phase3_release_lock.clone().lock_owned().await; + + // Pre-subscribe to the phase-2 notify BEFORE spawning the request + // so a fast handler that acquires `account_node` and fires + // `notify_one()` immediately cannot lose the signal. `Notified` is + // a future created up-front; the `notify_one` call buffers the + // wake-up even when no one is currently awaiting, so dropping the + // `Notified` before the await would be unsound here. + let notified = state.phase2_reached.notified(); + tokio::pin!(notified); + + let recipient = "0x".to_string() + &hex::encode([7u8; 32]); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + + // Drive the request on a worker so we can manipulate state from + // this task while the handler runs. + let state_for_request = state.clone(); + let request_task = + tokio::spawn(async move { send_request_with_state(state_for_request, req).await }); + + // Wait until the handler signals it has acquired the + // `account_node` guard at the top of phase 2. Phase 1 (the SMT + // walk + minting_account pubkey derivation) has finished by this + // point because it runs BEFORE phase 2 in `mint_handler`. This is + // a hard happens-before edge: the SMT insert below cannot run + // until the handler is observably past the phase-1 snapshot. + // Defensive timeouts: if a regression skips notify_one(), the test + // would otherwise hang for the full 120-min CI job budget. 30 s is + // >>> prepare_mint typical runtime (~200ms in the test build). + tokio::time::timeout(std::time::Duration::from_secs(30), notified.as_mut()) + .await + .expect( + "phase2_reached notify must fire within 30s — regression in mint_handler phase 2 entry", + ); + + // Insert pk_0's key into the SMT so the phase-3 re-derive returns + // 1 instead of the captured `expected_num_pubkeys = 0`. The + // handler is currently blocked on `state.phase3_release` (drained + // above) so phase 3 cannot run before this insert lands, even on + // a sub-microsecond prover. + { + let pk0 = { + let mc = state.minting_account.lock().unwrap(); + mc.generate_public_key(0) + }; + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&pk0.serialize()).to_byte_array(); + let node_guard = state.account_node.lock().unwrap(); + let state_arc = node_guard.state().clone(); + drop(node_guard); + let mut state_guard = state_arc.lock().unwrap(); + state_guard + .smt + .insert(key, zkcoins_program::hash::digest_from_bytes(&[2u8; 32])) + .expect("inject pk_0 into SMT"); + } + + // Release the handler from the phase3_release hold. It now runs + // the phase-3 re-derive against the just-mutated SMT, observes + // the bumped count, and returns 503 "Concurrent mint detected". + drop(phase3_guard); + + let (status, resp_body) = + tokio::time::timeout(std::time::Duration::from_secs(60), request_task) + .await + .expect("mint request must complete within 60s") + .expect("request task panicked"); + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "concurrent-mint-during-proof must surface 503, body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Concurrent mint detected"); +} + +// Phase D folded the recipient upsert into the same `commit_mint_tx` +// transaction as the minting account upsert (one bundle, one Postgres +// transaction). The pre-Phase-D `upsert_mint_recipient_or_log` helper +// was a standalone best-effort step after the commit and is gone, so +// the dead-pool branch test that pinned it is gone too — failure of +// `commit_mint_tx` itself is covered by `mint_commit_tx_failure_returns_503`. + +/// Phase E: `mint_handler` advances `state.update` synchronously after +/// a successful broadcast — the SMT contains the freshly-minted +/// pubkey BEFORE the response returns, and the corresponding +/// `pending_inscriptions` row is `complete`, both observable from +/// outside the handler immediately after the request finishes. +/// +/// Closes the regression that motivated Phase E: a second `/api/mint` +/// issued in the ~20-30 s scanner-observation window for the first +/// mint walked an un-updated SMT, derived `num_pubkeys = 0` again, +/// and surfaced `Unable to get mmr inclusion proof for the previous +/// root` at the prover. Synchronous state.update closes that window. +#[tokio::test] +async fn mint_handler_advances_state_synchronously_with_broadcast() { + use bitcoin::hashes::Hash as _; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + // Sanity: the SMT starts empty so derive_num_pubkeys_from_smt + // returns 0. + let pk0_key = { + let mc = state.minting_account.lock().unwrap(); + let pk0 = mc.generate_public_key(0); + bitcoin::hashes::sha256::Hash::hash(&pk0.serialize()).to_byte_array() + }; + { + let node_guard = state.account_node.lock().unwrap(); + let state_arc = node_guard.state().clone(); + let state_guard = state_arc.lock().unwrap(); + assert!( + state_guard.smt.get(&pk0_key).is_none(), + "fresh test state must not contain pk_0 in its SMT" + ); + assert_eq!( + crate::state::derive_num_pubkeys_from_smt( + &state.minting_account.lock().unwrap().private_key, + &state_guard.smt + ), + 0, + "fresh test state must derive num_pubkeys == 0" + ); + } + + let recipient_bytes = [10u8; 32]; + let recipient = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state.clone(), req).await; + assert_eq!(status, StatusCode::OK, "body: {}", resp_body); + + // After the response returns, the SMT must already hold pk_0 — + // this is the load-bearing Phase E behaviour. A second mint in the + // same scanner window would now derive num_pubkeys = 1 and + // proceed against the correct root. + let state_arc = { + let node_guard = state.account_node.lock().unwrap(); + node_guard.state().clone() + }; + { + let state_guard = state_arc.lock().unwrap(); + assert!( + state_guard.smt.get(&pk0_key).is_some(), + "Phase E regression: mint_handler must advance SMT before returning 200" + ); + assert_eq!( + crate::state::derive_num_pubkeys_from_smt( + &state.minting_account.lock().unwrap().private_key, + &state_guard.smt + ), + 1, + "Phase E: SMT must reflect the new mint so the next mint sees num_pubkeys = 1" + ); + // MMR advanced by exactly one leaf. + assert_eq!(state_guard.mmr.leaf_count(), 1); + // The new MMR leaf's prev_mmr_root must be a key in root_indices — + // this is the lookup the second mint's prover needs. + assert!( + state_guard + .root_indices + .contains_key(&state_guard.prev_mmr_root), + "root_indices must hold the entry for the freshly written prev_mmr_root" + ); + } + + // And the pending_inscriptions row reached `complete` in the same + // request, so a scanner observation of the same commit will + // short-circuit via `should_skip_scanner_state_update`. + let (pending_status,): (String,) = + sqlx::query_as("SELECT status FROM pending_inscriptions ORDER BY id DESC LIMIT 1") + .fetch_one(&*pool) + .await + .expect("a pending row must exist for the minted commitment"); + assert_eq!( + pending_status, + crate::db::PENDING_STATUS_COMPLETE, + "Phase E: mint_handler must mark pending_inscriptions complete after state.update" + ); + let (commit_txid_bytes,): (Vec,) = + sqlx::query_as("SELECT commit_txid FROM pending_inscriptions ORDER BY id DESC LIMIT 1") + .fetch_one(&*pool) + .await + .expect("commit_txid column must populate"); + assert!(crate::scanner::should_skip_scanner_state_update( + crate::db::pending_inscription_status_by_commit_txid(&pool, &commit_txid_bytes) + .await + .unwrap() + .as_deref() + )); +} + +/// Phase E BLOCKER fix: if the atomic +/// `persist_state_and_mark_complete_tx` rolls back mid-transaction, the +/// `pending_inscriptions` row MUST stay at its prior status (here: +/// `reveal_broadcast`) and the on-disk SMT/MMR/root_index must NOT +/// advance. The scanner-replay path is then free to integrate the +/// inscription from chain on the next sweep without doubling up the MMR +/// leaf (which is exactly the BLOCKER class the atomic tx eliminated). +/// +/// Mechanism: install a `BEFORE UPDATE` trigger on +/// `pending_inscriptions` that raises an exception when the new +/// `status` value is `complete`. The trigger fires INSIDE the atomic +/// tx — the BEGIN/UPSERT(smt)/UPSERT(mmr)/INSERT(mmr_root_index) steps +/// all run successfully, then the final UPDATE...SET status='complete' +/// raises and the COMMIT envelope rolls everything back. The handler +/// surfaces 503 and the on-disk state is byte-for-byte identical to +/// the pre-call snapshot. +/// +/// (The in-memory SMT/MMR mutation already happened before the await +/// — that is a known property of the new shape; the contract is that +/// on tx Err, durable state is unchanged and the handler signals 503 +/// so the caller knows not to trust the in-memory state across a +/// restart.) +#[tokio::test] +async fn mint_handler_atomic_tx_rollback_leaves_state_and_row_consistent() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // Install the trigger that fails the in-tx mark-complete UPDATE. + // PL/pgSQL: any UPDATE that sets `status = 'complete'` raises + // before the row mutates, surfacing a `sqlx::Error::Database` from + // inside the atomic envelope. + sqlx::query( + "CREATE OR REPLACE FUNCTION fail_complete() RETURNS trigger AS $$ + BEGIN + IF NEW.status = 'complete' THEN + RAISE EXCEPTION 'simulated mark-complete failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql", + ) + .execute(&*pool) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER block_complete BEFORE UPDATE ON pending_inscriptions \ + FOR EACH ROW EXECUTE FUNCTION fail_complete()", + ) + .execute(&*pool) + .await + .unwrap(); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + let recipient_bytes = [11u8; 32]; + let recipient = "0x".to_string() + &hex::encode(recipient_bytes); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, resp_body) = send_request_with_state(state.clone(), req).await; + + // The trigger fires inside the atomic tx, the tx rolls back, the + // handler converts to 503. + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "atomic tx rollback must surface 503, body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert!( + v["error"] + .as_str() + .unwrap_or("") + .contains("durable state advance failed"), + "response error must explain the failure mode, got: {}", + v["error"] + ); + + // On-disk SMT/MMR/root_index did NOT advance — the atomic + // envelope rolled them back together with the failed UPDATE. + assert_eq!( + crate::db::load_smt(&pool).await.unwrap(), + None, + "atomic-tx rollback must leave smt_state untouched" + ); + assert_eq!( + crate::db::load_mmr(&pool).await.unwrap(), + None, + "atomic-tx rollback must leave mmr_state untouched" + ); + assert!( + crate::db::load_root_indices(&pool) + .await + .unwrap() + .is_empty(), + "atomic-tx rollback must leave mmr_root_index untouched" + ); + + // The pending row stays at `reveal_broadcast` (the publisher set + // it there before the broadcast, and the mark-complete UPDATE was + // exactly the call that the trigger blocked). Scanner-replay on + // next boot observes the row, falls through + // `should_skip_scanner_state_update`, integrates the inscription + // itself, and runs state.update against the (still-clean) on-disk + // SMT — yielding leaf_count == 1, not 2. + let (pending_status,): (String,) = + sqlx::query_as("SELECT status FROM pending_inscriptions ORDER BY id DESC LIMIT 1") + .fetch_one(&*pool) + .await + .expect("a pending row must exist for the broadcasted commitment"); + assert_eq!( + pending_status, + crate::db::PENDING_STATUS_REVEAL_BROADCAST, + "atomic-tx rollback: pending row must stay at reveal_broadcast for scanner-replay to pick up" + ); + let (commit_txid_bytes,): (Vec,) = + sqlx::query_as("SELECT commit_txid FROM pending_inscriptions ORDER BY id DESC LIMIT 1") + .fetch_one(&*pool) + .await + .unwrap(); + assert!( + !crate::scanner::should_skip_scanner_state_update( + crate::db::pending_inscription_status_by_commit_txid(&pool, &commit_txid_bytes) + .await + .unwrap() + .as_deref() + ), + "scanner must NOT skip its state.update for an inscription whose mark-complete failed" + ); + + // Drop the trigger so any follow-up scanner-replay (out of scope + // for this test) would succeed; we assert the contract above and + // leave the verification of the heal-on-replay path to the e2e + // tests covered by `mint_handler_advances_state_synchronously_with_broadcast`. + sqlx::query("DROP TRIGGER block_complete ON pending_inscriptions") + .execute(&*pool) + .await + .unwrap(); +} + +/// Phase E in-process state.update Err coverage: if the SMT already +/// contains the mint's signing pubkey under a DIFFERENT value when +/// `update_and_snapshot_for_persist` runs (a concurrent-mint race that +/// slipped both phase-2 gates, or a genuine bug), the handler must +/// return 503 with the documented "in-process state advance failed" +/// reason. The broadcast already landed on chain at this point, so the +/// publisher has advanced the row to `reveal_broadcast`; the scanner- +/// replay path picks the inscription up from chain on its next sweep. +/// +/// Mechanism: hold `state_advance_release_lock` BEFORE spawning the +/// request so the handler blocks AFTER the broadcast and BEFORE +/// acquiring the state lock for `update_and_snapshot_for_persist`. Mid- +/// hold, inject `pk_0`'s key into the SMT with a bogus value. Drop the +/// guard — the handler resumes, the SMT `insert` returns +/// `"Key already exists in the tree with different value"`, and the +/// match arm at the top of phase 3b surfaces 503. +/// +/// Asserts: +/// - response is 503 with the expected error message +/// - the pending_inscriptions row stays at `reveal_broadcast` +/// - the on-disk SMT/MMR/root_index DID NOT advance (no atomic +/// persist tx ran for this mint) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mint_handler_in_process_state_advance_collision_returns_503() { + use bitcoin::hashes::Hash; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + // Hold the state-advance release lock so the handler will block + // after broadcast and before `update_and_snapshot_for_persist`. + let advance_guard = state.state_advance_release_lock.clone().lock_owned().await; + + let recipient = "0x".to_string() + &hex::encode([12u8; 32]); + let body = serde_json::json!({ + "account_address": recipient, + "amount": 1u64, + }); + let req = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + + let state_for_request = state.clone(); + let request_task = + tokio::spawn(async move { send_request_with_state(state_for_request, req).await }); + + // Wait until the publisher has advanced the row to + // `reveal_broadcast` — that is the observable signal that the + // broadcast has landed and the handler is now blocked on the + // state_advance_release_lock. Polling avoids races with the + // publisher's WS handshake; a hard timeout guards against a + // regression that would otherwise hang for the full CI budget. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let commit_txid_bytes: Vec = loop { + if std::time::Instant::now() > deadline { + panic!( + "publisher did not advance any pending row to `reveal_broadcast` within 60s; \ + regression in mint_handler broadcast phase" + ); + } + let row: Option<(Vec, String)> = sqlx::query_as( + "SELECT commit_txid, status FROM pending_inscriptions ORDER BY id DESC LIMIT 1", + ) + .fetch_optional(&*pool) + .await + .unwrap(); + if let Some((ctxid, status)) = row { + if status == crate::db::PENDING_STATUS_REVEAL_BROADCAST { + break ctxid; + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }; + + // Inject pk_0's key into the SMT with a value that will NOT match + // what `update_and_snapshot_for_persist` is about to write. The + // handler is currently blocked on the state_advance_release_lock + // (drained above) so its SMT mutation cannot run before this + // injection lands. + { + let pk0 = { + let mc = state.minting_account.lock().unwrap(); + mc.generate_public_key(0) + }; + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&pk0.serialize()).to_byte_array(); + let node_guard = state.account_node.lock().unwrap(); + let state_arc = node_guard.state().clone(); + drop(node_guard); + let mut state_guard = state_arc.lock().unwrap(); + // A digest that does NOT match the legitimate + // `commitment.get_account_state_hash()` the handler will derive. + state_guard + .smt + .insert(key, zkcoins_program::hash::digest_from_bytes(&[0xAAu8; 32])) + .expect("inject pk_0 -> bogus value into SMT"); + } + + // Release the handler. It now runs `update_and_snapshot_for_persist`, + // the SMT insert at pk_0 errors with "Key already exists in the + // tree with different value", and the handler returns 503. + drop(advance_guard); + + let (status, resp_body) = + tokio::time::timeout(std::time::Duration::from_secs(60), request_task) + .await + .expect("mint request must complete within 60s") + .expect("request task panicked"); + + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "in-process state.update collision must surface 503, body: {}", + resp_body + ); + let v: serde_json::Value = serde_json::from_str(&resp_body).expect("valid JSON"); + assert_eq!(v["success"], false); + assert!( + v["error"] + .as_str() + .unwrap_or("") + .contains("in-process state advance failed"), + "response error must explain the in-process collision failure mode, got: {}", + v["error"] + ); + + // The pending row stays at `reveal_broadcast`: the publisher set it + // there before the broadcast and the handler bailed before the + // atomic persist + mark-complete tx could run. + let (pending_status,): (String,) = + sqlx::query_as("SELECT status FROM pending_inscriptions WHERE commit_txid = $1") + .bind(&commit_txid_bytes) + .fetch_one(&*pool) + .await + .expect("the broadcasted commitment's row must exist"); + assert_eq!( + pending_status, + crate::db::PENDING_STATUS_REVEAL_BROADCAST, + "in-process collision: pending row must stay at reveal_broadcast for scanner-replay to pick up" + ); + + // On-disk SMT/MMR/root_index did NOT advance — the handler bailed + // before invoking the atomic persist + mark-complete transaction. + assert_eq!( + crate::db::load_smt(&pool).await.unwrap(), + None, + "in-process collision must leave smt_state untouched" + ); + assert_eq!( + crate::db::load_mmr(&pool).await.unwrap(), + None, + "in-process collision must leave mmr_state untouched" + ); + assert!( + crate::db::load_root_indices(&pool) + .await + .unwrap() + .is_empty(), + "in-process collision must leave mmr_root_index untouched" + ); + + // Scanner-replay path stays armed (row not at `complete`). + assert!( + !crate::scanner::should_skip_scanner_state_update( + crate::db::pending_inscription_status_by_commit_txid(&pool, &commit_txid_bytes) + .await + .unwrap() + .as_deref() + ), + "scanner must NOT skip its state.update for an inscription whose in-process advance failed" + ); +} + +/// Phase E concurrent-mint coverage: two `/api/mint` requests with +/// DIFFERENT recipients (different commitments → different SMT keys) +/// must both succeed end-to-end. Both walk past the phase-2 re-derive +/// gate (they observe DIFFERENT `expected_num_pubkeys` because the +/// first mint's state advance lands before the second's gate runs — +/// or, if interleaved, the gate's re-derive observes the freshly +/// inserted pubkey and the second's `num_pubkeys` is already bumped). +/// Both serialize on the state lock for the in-process state.update, +/// and the atomic persist + mark-complete commits both rows. +/// +/// Asserts: +/// - both responses are 200 +/// - both pending_inscriptions rows reach `complete` +/// - MMR `leaf_count == 2` +/// - mmr_root_index has exactly 2 entries +/// - no SMT key-collision error path was hit (no `Key already exists` +/// log; tested indirectly by both 200 responses — the new 503 path +/// for in-process state.update Err would surface here if a +/// collision occurred). +/// +/// Note: this test serializes the two requests deliberately (await +/// the first 200 before sending the second) so we can deterministically +/// assert end-state. The earlier `mint_handler_concurrent_mint_during_proof_returns_503` +/// covers the truly-concurrent case (same `expected_num_pubkeys`); the +/// genuine concurrent-different-recipients race relies on the in-process +/// re-derive gate to either let both through (sequentially) or 503 one +/// of them. The end-state invariant — MMR leaf_count == 2 for two +/// successful mints — is the load-bearing piece this test pins. +#[tokio::test] +async fn mint_handler_two_sequential_mints_with_different_recipients_advance_cleanly() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.unwrap(); + let port = pg_container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let mock_server = mint_broadcast_mock_server().await; + let ws_url = mint_broadcast_mock_ws().await; + + let mut state = mint_test_state(); + state.pool = Arc::clone(&pool); + state.esplora_config = Arc::new(crate::publisher::EsploraConfig { + url: mock_server.uri(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: Some(ws_url), + track_tx_timeout: None, + }); + + // First mint: recipient A. + let recipient_a = "0x".to_string() + &hex::encode([0xAAu8; 32]); + let req_a = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "account_address": recipient_a, "amount": 1u64 }).to_string(), + )) + .unwrap(); + let (status_a, body_a) = send_request_with_state(state.clone(), req_a).await; + assert_eq!(status_a, StatusCode::OK, "first mint body: {}", body_a); + + // After the first mint returns, the SMT must hold pk_0 and + // derive_num_pubkeys_from_smt must observe 1. This is the + // invariant the synchronous state.update advance gives the next + // mint. + { + let state_arc = { + let node_guard = state.account_node.lock().unwrap(); + node_guard.state().clone() + }; + let state_guard = state_arc.lock().unwrap(); + assert_eq!(state_guard.mmr.leaf_count(), 1, "after mint A"); + assert_eq!( + crate::state::derive_num_pubkeys_from_smt( + &state.minting_account.lock().unwrap().private_key, + &state_guard.smt + ), + 1, + "after mint A, num_pubkeys must derive to 1 so mint B uses pk_1" + ); + } + + // Second mint: recipient B (different commitment → different SMT + // key). Must walk through cleanly; no `Key already exists` path. + let recipient_b = "0x".to_string() + &hex::encode([0xBBu8; 32]); + let req_b = Request::post("/api/mint") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "account_address": recipient_b, "amount": 2u64 }).to_string(), + )) + .unwrap(); + let (status_b, body_b) = send_request_with_state(state.clone(), req_b).await; + assert_eq!(status_b, StatusCode::OK, "second mint body: {}", body_b); + + // Final invariants: + // - in-memory MMR holds exactly 2 leaves + // - in-memory derive_num_pubkeys_from_smt == 2 + // - 2 root_indices entries + { + let state_arc = { + let node_guard = state.account_node.lock().unwrap(); + node_guard.state().clone() + }; + let state_guard = state_arc.lock().unwrap(); + assert_eq!( + state_guard.mmr.leaf_count(), + 2, + "two successful mints → leaf_count == 2 (regression: a duplicate append would give 3 or 4)" + ); + assert_eq!( + crate::state::derive_num_pubkeys_from_smt( + &state.minting_account.lock().unwrap().private_key, + &state_guard.smt + ), + 2, + "two successful mints → derive_num_pubkeys_from_smt == 2" + ); + assert_eq!( + state_guard.root_indices.len(), + 2, + "two successful mints → 2 root_indices entries" + ); + } + + // Both pending_inscriptions rows reached `complete`. + let complete_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM pending_inscriptions WHERE status = 'complete'") + .fetch_one(&*pool) + .await + .unwrap(); + assert_eq!( + complete_count, 2, + "both pending rows must reach `complete` after their respective atomic txs" + ); + + // On-disk MMR root_index table mirrors the in-memory state: 2 + // rows, one per mint. The atomic tx wrote both + // SMT/MMR/root_index/status-complete bundles together. + let on_disk_root_indices = crate::db::load_root_indices(&pool).await.unwrap(); + assert_eq!( + on_disk_root_indices.len(), + 2, + "on-disk mmr_root_index must have 2 entries after two successful atomic txs" + ); +} diff --git a/node/src/runtime.rs b/node/src/runtime.rs new file mode 100644 index 00000000..4b375b18 --- /dev/null +++ b/node/src/runtime.rs @@ -0,0 +1,263 @@ +//! Runtime bootstrap: binds a TCP listener and runs the Axum app. +//! +//! This file is intentionally excluded from the coverage scope. The +//! function below cannot be exercised by unit tests — it owns the +//! process lifecycle (port binding, signal-driven shutdown via axum) +//! and exists purely to wire the dependency graph defined in +//! `router.rs` to a real network socket. +//! +//! Anything that is testable in isolation (handlers, helpers, the +//! router construction in `create_router`) stays in `router.rs` and +//! is measured normally. + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::http::StatusCode; +use axum::Json; +use shared::commitment::Commitment; +use sqlx::PgPool; +use tokio::net::TcpListener; + +use crate::account_node::{persist_account, CoinProof}; +use crate::db; +use crate::publisher::{create_and_broadcast_inscription, resume_pending_inscriptions}; +use crate::router::{lock_or_recover, SendCoinResponse}; +use crate::NETWORK_CONFIG; + +use bitcoin::bip32::Xpriv; +use shared::ClientAccount; + +use crate::account_node::AccountNode; +use crate::router::{create_router, AppState, ProofStore}; +use crate::username::UsernameStore; + +pub async fn start_rest_node( + account_node: AccountNode, + username_store: UsernameStore, + addr: &str, + pool: Arc, +) -> anyhow::Result<()> { + let socket_addr = addr + .parse::() + .map_err(|e| anyhow::anyhow!("Failed to parse address: {}", e))?; + + let shared_account_node = Arc::new(Mutex::new(account_node)); + + // Proof files keep using a local directory — the proof store is + // append-only and the proofs themselves are large (bincode- + // serialized Plonky2 proofs) so a `BYTEA` column would balloon the + // Postgres image. `PROOFS_DIR` defaults to `./proofs` for parity + // with the pre-PR-A3 layout; the deployment overrides it to the + // mounted data volume. + let proofs_dir = std::env::var("PROOFS_DIR").unwrap_or_else(|_| "./proofs".to_string()); + let proof_store = Arc::new(ProofStore::new(&proofs_dir)); + + let minting_account = { + let secret = include_bytes!("../minting_secret.bin"); + let private_key = Xpriv::new_master(NETWORK_CONFIG.network(), secret) + .expect("Failed to create private key."); + println!( + "Set MINTING_ADDRESS to {:?}", + *zkcoins_program::types::MINTING_ADDRESS + ); + let mut minting_client = ClientAccount::new(private_key); + // Phase D: `num_pubkeys` is no longer carried in the shared + // ClientAccount as boot state. Each `/api/mint` derives the + // count fresh from the SMT via + // `state::derive_num_pubkeys_from_smt`, which is the canonical + // source of truth (the SMT is loaded from Postgres at boot and + // mutated by the scanner on every inscription). The in-memory + // field stays at 0 here; mint_handler reads N off the SMT + // before deriving pubkeys and signs with a transient clone at + // `num_pubkeys = N + 1` exactly as before. + // + // Plonky2 migration (D11 in MIGRATION_RESEARCH.md): MINTING_ADDRESS + // is a well-known constant derived from `hash_bytes(b"zkcoins: + // minting-address:placeholder:v1")`, NOT from minting_secret.bin. + // ClientAccount::new derives `address` from the privkey's first + // child pubkey for ordinary wallets; for the minting wallet that + // derivation is meaningless — only the wallet's commitment-signing + // side is used. Force the address to the canonical constant so + // the rest of the server (which reads minting_account.address as + // the on-chain identity of the minting wallet) is internally + // consistent. The test harness already constructs the minting + // account this way (see + // router_tests.rs::TestAccountData::new_minting_account). + minting_client.address = *zkcoins_program::types::MINTING_ADDRESS; + Arc::new(Mutex::new(minting_client)) + }; + + let shared_username_store = Arc::new(Mutex::new(username_store)); + + let state = AppState { + account_node: shared_account_node, + proof_store, + minting_account, + username_store: shared_username_store, + pool: Arc::clone(&pool), + // The readiness probe uses this to ping Esplora; in production + // it points at the same `ESPLORA_URL` as the scanner / publisher. + esplora_config: Arc::new(NETWORK_CONFIG.clone()), + #[cfg(test)] + phase2_reached: Arc::new(tokio::sync::Notify::new()), + #[cfg(test)] + phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(test)] + state_advance_release_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + + // Bootstrap the minting account if it isn't already in the DB. + // The snapshot pattern mirrors the handler sites: take the + // mutation under the sync guard, then drop the guard before the + // async upsert. + let bootstrap_snapshot: Option<(zkcoins_program::hash::HashDigest, Vec)> = { + let mut account_node_guard = state.account_node.lock().unwrap(); + if account_node_guard.get_minting_account_address().is_err() { + let mut minting_server_account = crate::account_node::Account::new(); + // The Plonky2 state-transition circuit packs the running + // balance as a Goldilocks field element via + // `balance_hi * 2^32 + balance_lo`. Values >= p (the + // Goldilocks prime ≈ 2^64 - 2^32 + 1) reduce mod p inside + // the circuit but stay full-width in the witness setter, + // which trips a "wire set twice" partition error. Stay + // safely below 2^48 so the circuit-vs-witness sides agree + // even after many mint operations. + minting_server_account.balance = 1u64 << 48; + account_node_guard.import_account( + *zkcoins_program::types::MINTING_ADDRESS, + minting_server_account, + ); + account_node_guard + .get_account(&zkcoins_program::types::MINTING_ADDRESS) + .map(AccountNode::serialize_account) + .map(|bytes| (*zkcoins_program::types::MINTING_ADDRESS, bytes)) + } else { + None + } + }; + if let Some((address, _bytes)) = bootstrap_snapshot.as_ref() { + // Look the account up once more through `persist_account` so + // the helper's error variants are wired in the same way as the + // handler sites. The address + (re-fetched) account go through + // the lock again only briefly; the second snapshot reads the + // same row we just inserted so it is guaranteed to be present. + let acct_clone = { + let guard = state.account_node.lock().unwrap(); + guard.get_account(address).and_then(|a| { + let b = AccountNode::serialize_account(a); + bincode::deserialize::(&b).ok() + }) + }; + if let Some(account) = acct_clone { + if let Err(e) = persist_account(&pool, address, &account).await { + eprintln!("Failed to upsert bootstrap minting account: {}", e); + } + } + } + + // Phase D removed the startup `check_minting_state_invariant`: + // `num_pubkeys` is now derived from SMT membership at runtime + // (`state::derive_num_pubkeys_from_smt`), so the predicate the + // check measured ("every pubkey_idx ∈ 0..num_pubkeys has a + // commitment in the SMT") is a tautology by construction. The + // pre-Phase-D check existed only because the counter and the SMT + // could disagree — collapsing them into one removes the disagree + // mode and the check that measured it. + + // Phase B: re-broadcast any pending inscriptions left over from + // a previous boot. A crash between commit-broadcast and + // reveal-broadcast (or between construction and either broadcast) + // leaves a row in `pending_inscriptions` with status != complete; + // walk each one to completion before opening the listener so + // operators do not see a stuck UTXO until the next mint triggers + // the resumer. + // + // Failures here are LOGGED and SWALLOWED — the operator's escape + // hatch is the PR #106 CLI recovery tool, and a transient + // Esplora outage on boot must not crash-loop the container. + if let Err(e) = resume_pending_inscriptions(&pool, &NETWORK_CONFIG).await { + eprintln!( + "Failed to resume pending inscriptions on bootstrap (continuing anyway): {}", + e + ); + } + + let app = create_router(state); + + println!("REST server started at {}", socket_addr); + let listener = TcpListener::bind(socket_addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +/// Broadcast the commit inscription and, on success, deliver the coin +/// to the recipient and persist the account state. This contains the +/// network call (Bitcoin broadcast) and the post-broadcast bookkeeping, +/// plus the success/failure response dispatch — all of which cannot be +/// exercised by unit tests, so the whole function lives in the runtime +/// module that is excluded from the coverage scope. +/// +/// **Invariant (zk-coins/node#89).** The broadcast `if let Err(...) +/// { return 503 }` MUST stay above every `receive_coin`/`upsert_account` +/// line. The mint flow had to be refactored to prepare-then-commit +/// because its old shape advanced state ahead of broadcast; this +/// function does not have that bug because its broadcast is already +/// the first effect. Any future refactor that moves a state mutation +/// above the broadcast re-introduces the state-desync class — do not. +pub(crate) async fn broadcast_commit_and_deliver( + state: &AppState, + commitment: Commitment, + coin_proof: CoinProof, + proof_id: u64, +) -> (StatusCode, Json) { + let commitment_data = bincode::serialize(&commitment).expect("Failed to serialize commitment"); + println!( + "Broadcasting user commitment ({} bytes)", + commitment_data.len() + ); + if let Err(err) = + create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG, Some(&state.pool)).await + { + eprintln!("Error broadcasting commit inscription: {}", err); + return crate::router::handler_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Failed to broadcast commitment inscription on-chain", + ); + } + + let mut updated_proof = coin_proof; + updated_proof.commitment = Some(commitment); + let recipient = updated_proof.coin.recipient; + let snapshot: Option> = { + let mut account_node_guard = lock_or_recover(&state.account_node); + if let Err(e) = account_node_guard.receive_coin(updated_proof) { + eprintln!("Failed to receive coin after commit: {}", e); + } + account_node_guard + .get_account(&recipient) + .map(AccountNode::serialize_account) + }; + if let Some(bytes) = snapshot { + let addr_bytes = zkcoins_program::hash::digest_to_bytes(&recipient); + if let Err(e) = db::upsert_account(&state.pool, &addr_bytes, &bytes).await { + eprintln!("Failed to upsert account after commit: {}", e); + } + } + + ( + StatusCode::OK, + Json(SendCoinResponse { + success: true, + error: None, + proof_id: Some(proof_id), + account_state_hash: None, + output_coins_root: None, + }), + ) +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/node/src/runtime_tests.rs b/node/src/runtime_tests.rs new file mode 100644 index 00000000..a299624d --- /dev/null +++ b/node/src/runtime_tests.rs @@ -0,0 +1,297 @@ +//! Smoke tests that exercise the runtime bootstrap end-to-end. +//! +//! `runtime.rs` itself is excluded from the coverage scope (it +//! binds a real socket and owns the process lifecycle), but its +//! bootstrap path carries regressions that the 100% MVP-scope gate +//! cannot catch. Each test here covers a specific failure mode that +//! production has hit (or would hit on the next migration in the same +//! class): +//! +//! - `start_rest_node_binds_and_serves_health` — the Plonky2-migration +//! outage. An `assert_eq!` against `MINTING_ADDRESS` panicked the +//! tokio worker that owned the HTTP listener while the scanner worker +//! kept running. Container stayed `Up`, Cloudflare served 502s for +//! hours. The test probes `/health`; a bootstrap panic manifests as +//! a TCP connect timeout and fails the test with a clear diagnostic. +//! +//! - `bootstrap_initial_minting_account_balance_is_goldilocks_safe` — +//! guards the `1u64 << 48` constant for the seeded minting balance. +//! `u64::MAX` (the pre-Plonky2 value) reduces mod the Goldilocks +//! prime inside the state-transition circuit and trips a +//! "wire set twice" panic on every mint. The test probes +//! `/api/balance?address=` and asserts the +//! returned balance stays in the Goldilocks-safe range. +//! +//! Both tests share the same probe-port / spawn / wait / cleanup +//! shape; once a third bootstrap test lands the duplicated setup is +//! worth extracting into a helper. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use sqlx::PgPool; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; + +use crate::account_node::AccountNode; +use crate::db::connect_and_migrate; +use crate::runtime::start_rest_node; +use crate::state::State; +use crate::username::UsernameStore; +use zkcoins_program::hash::digest_to_bytes; +use zkcoins_program::types::MINTING_ADDRESS; + +/// Boot a fresh `postgres:17` container, run the server migrations +/// against it, and return the live pool plus the container handle. +/// Dropping the container handle tears the container down, so the +/// caller keeps it alive for the duration of the test. +/// +/// Each test gets its own container — the same isolation model as +/// `db_tests::setup_pool`. The shape is duplicated here rather than +/// re-exported across modules to keep `db_tests` and +/// `runtime_tests` independently runnable (a shared helper +/// would have to live in a `pub(crate)` module guarded with `#[cfg +/// (test)]` and pulled in by both test files via `#[path = ...]`, +/// which is heavier than the few lines below). The PR-A3 cleanup may +/// dedupe both into a `test_db` helper module. +async fn setup_pool() -> (Arc, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (Arc::new(pool), container) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn start_rest_node_binds_and_serves_health() { + // Pick a free ephemeral port by binding/dropping a probe listener. + // The race window between drop and rebind is irrelevant in CI and + // pre-push (no other process listens on this port); a collision + // would surface as a deterministic bind error below, not silent + // corruption. + let probe = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind probe"); + let port = probe.local_addr().expect("probe addr").port(); + drop(probe); + let addr = format!("127.0.0.1:{}", port); + + // The lazy_static reads of `NETWORK_CONFIG` and `USERNAME_DOMAIN` + // happen on first access in this test binary. The pre-push hook + // exports both of these already; setting them here defensively + // makes the test runnable in any environment. + std::env::set_var("USERNAME_DOMAIN", "test.zkcoins.local"); + std::env::set_var("ESPLORA_URL", "http://127.0.0.1:1/api"); + + // PR-A3 moved all sibling-file state (accounts.bin, usernames.bin, + // minting_num_pubkeys.bin) into Postgres; the bootstrap only needs + // a proofs directory now, which is configured via the `PROOFS_DIR` + // env var read inside `start_rest_node`. PID + port keeps the + // tempdir unique across parallel runs even though pre-push uses + // --test-threads=1. + let tmp = std::env::temp_dir().join(format!( + "zkcoins-startup-test-{}-{}", + std::process::id(), + port + )); + std::fs::create_dir_all(&tmp).expect("create tempdir"); + std::env::set_var("PROOFS_DIR", tmp.to_string_lossy().into_owned()); + + // Mimic main.rs wiring: fresh State and empty AccountNode / + // UsernameStore, so the bootstrap exercises the "no saved state" + // branch that was the production failure mode. + let state = Arc::new(Mutex::new(State::new())); + let account_node = AccountNode::new(Arc::clone(&state)); + let username_store = UsernameStore::new(); + + let (pool, _pg_container) = setup_pool().await; + + let handle = + tokio::spawn( + async move { start_rest_node(account_node, username_store, &addr, pool).await }, + ); + + // Wait for the listener to come up. axum binds within ~hundreds of + // ms on a warm cargo cache; cap the wait at 5 s so a regression + // fails fast instead of hanging the whole suite. + let mut last_err: Option = None; + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(100)).await; + match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { + Ok(mut stream) => { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .await + .expect("write probe"); + let mut buf = vec![0u8; 1024]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let resp = String::from_utf8_lossy(&buf[..n]).into_owned(); + handle.abort(); + std::fs::remove_dir_all(&tmp).ok(); + assert!( + resp.starts_with("HTTP/1.1 200"), + "expected 200 on /health, got: {}", + &resp[..resp.len().min(300)] + ); + // `/health` is the documented liveness probe whose + // body is the literal string "ok" (see the route + // registration in `router::create_router`). A 200 + // status with a different body would still satisfy + // the old assertion but signal a regression in the + // contract Kuma watches. + let body = resp + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .trim_end_matches('\0') + .trim(); + assert!( + body.starts_with("ok"), + "expected /health body to start with `ok`, got: {:?}", + body + ); + return; + } + Err(e) => last_err = Some(e), + } + } + handle.abort(); + std::fs::remove_dir_all(&tmp).ok(); + panic!( + "start_rest_node never bound on 127.0.0.1:{} within 5 s; last connect error: {:?}", + port, last_err + ); +} + +/// Regression guard: the bootstrap-seeded minting account balance must +/// stay Goldilocks-safe (strictly less than `2^48`). +/// +/// The Plonky2 state-transition circuit packs `u64` balances as +/// `balance_hi * 2^32 + balance_lo`. Values at or above the Goldilocks +/// modulus `p ≈ 2^64 - 2^32 + 1` reduce mod `p` inside the circuit but +/// stay full-width in the witness setter — that mismatch trips a +/// "wire set twice" partition error and panics every mint operation. +/// Before the Plonky2 migration the initial balance was `u64::MAX`, +/// which is exactly the value that triggers the panic. +/// +/// This test exercises the bootstrap end-to-end, queries the public +/// `/api/balance?address=` endpoint, and asserts +/// the returned balance is non-zero *and* well below `2^49` (one bit of +/// head-room above the documented `< 2^48` cap so a deliberate bump +/// within the safe range does not require updating the test, while a +/// regression to `u64::MAX` or any other unsafe value fails loudly). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bootstrap_initial_minting_account_balance_is_goldilocks_safe() { + let probe = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind probe"); + let port = probe.local_addr().expect("probe addr").port(); + drop(probe); + let addr = format!("127.0.0.1:{}", port); + + std::env::set_var("USERNAME_DOMAIN", "test.zkcoins.local"); + std::env::set_var("ESPLORA_URL", "http://127.0.0.1:1/api"); + + let tmp = std::env::temp_dir().join(format!( + "zkcoins-balance-test-{}-{}", + std::process::id(), + port + )); + std::fs::create_dir_all(&tmp).expect("create tempdir"); + std::env::set_var("PROOFS_DIR", tmp.to_string_lossy().into_owned()); + + let state = Arc::new(Mutex::new(State::new())); + let account_node = AccountNode::new(Arc::clone(&state)); + let username_store = UsernameStore::new(); + + let (pool, _pg_container) = setup_pool().await; + + let handle = + tokio::spawn( + async move { start_rest_node(account_node, username_store, &addr, pool).await }, + ); + + let minting_hex = hex::encode(digest_to_bytes(&MINTING_ADDRESS)); + let request = format!( + "GET /api/balance?address={} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + minting_hex + ); + + let mut last_err: Option = None; + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(100)).await; + match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { + Ok(mut stream) => { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + stream + .write_all(request.as_bytes()) + .await + .expect("write probe"); + let mut buf = Vec::with_capacity(2048); + stream.read_to_end(&mut buf).await.expect("read response"); + handle.abort(); + std::fs::remove_dir_all(&tmp).ok(); + let resp = String::from_utf8_lossy(&buf).into_owned(); + assert!( + resp.starts_with("HTTP/1.1 200"), + "expected 200 on /api/balance, got: {}", + &resp[..resp.len().min(300)] + ); + // Body is the JSON payload after the blank line separating + // headers and body. Find it and parse the `balance` field. + let body = resp.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(&resp); + let parsed: serde_json::Value = + serde_json::from_str(body.trim()).unwrap_or_else(|e| { + panic!("failed to parse balance JSON body {:?}: {}", body, e) + }); + let balance = parsed + .get("balance") + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| panic!("balance field missing or not u64: {}", body)); + assert!( + balance > 0, + "bootstrap must seed a non-zero minting balance, got 0 \ + (regression: bootstrap path skipped or import_account broken)" + ); + assert!( + balance < (1u64 << 49), + "bootstrap minting balance {} is NOT Goldilocks-safe \ + (must stay below 2^48; 2^49 ceiling here gives 1 bit of \ + head-room). u64::MAX or any value >= p would panic the \ + Plonky2 circuit with `wire set twice` on the next mint.", + balance + ); + return; + } + Err(e) => last_err = Some(e), + } + } + handle.abort(); + std::fs::remove_dir_all(&tmp).ok(); + panic!( + "start_rest_node never bound on 127.0.0.1:{} within 5 s; last connect error: {:?}", + port, last_err + ); +} + +// Phase D removed the startup `check_minting_state_invariant` check. +// `num_pubkeys` is now derived from SMT membership at runtime +// (`state::derive_num_pubkeys_from_smt`), which is the same source the +// pre-Phase-D check measured the counter *against*. With the counter +// and the SMT collapsed into one value the desync mode the check +// guarded against can no longer arise, so the test that exercised the +// `CRITICAL: minting state desync` Err arm is gone too. diff --git a/server/src/scanner.rs b/node/src/scanner.rs similarity index 55% rename from server/src/scanner.rs rename to node/src/scanner.rs index bb941fdc..f0a524fa 100644 --- a/server/src/scanner.rs +++ b/node/src/scanner.rs @@ -9,8 +9,43 @@ use bitcoin::script::Instruction; use bitcoin::script::ScriptBuf; use bitcoin::{BlockHash, Transaction, Txid}; -/// Type alias for the inscription callback function -pub(crate) type InscriptionCallback = dyn Fn(Vec, BlockHash) + Send + Sync + 'static; +/// Pure-logic decision: given the current +/// `pending_inscriptions.status` value for a commit txid (or `None` +/// when the row does not exist), should the scanner skip its +/// `state.update` call for this inscription? +/// +/// Returns `true` only when the row exists AND its status is +/// `db::PENDING_STATUS_COMPLETE` — Phase E's contract that the mint +/// flow integrated the inscription in-process. Every other state (no +/// row, an in-progress row, an unknown future status) falls through +/// to the scanner's normal `state.update` path: +/// +/// * `None` — external / out-of-band inscription, never went through +/// the mint flow on this node. +/// * `constructed` / `commit_broadcast` / `reveal_broadcast` — the +/// mint flow broadcast but never reached the post-state.update +/// `complete` advance, so the SMT/MMR are still missing this entry +/// and the scanner is the recovery path. +/// * any other string — forward-compatible no-op (mirrors +/// `resume_single_row`'s "unknown status" branch). +pub fn should_skip_scanner_state_update(pending_status: Option<&str>) -> bool { + matches!(pending_status, Some(s) if s == crate::db::PENDING_STATUS_COMPLETE) +} + +/// Type alias for the inscription callback function. +/// +/// Arguments are `(content_bytes, commit_txid, block_hash)`: +/// * `content_bytes` — the raw inscription payload extracted from the +/// reveal-side script. +/// * `commit_txid` — the txid of the inscription's commit transaction, +/// equivalently `reveal_tx.input[0].previous_output.txid`. The mint +/// flow keys the `pending_inscriptions` table by this value (see +/// `db::pending_inscription_status_by_commit_txid`), so a callback +/// that wants to skip its own `state.update` when the mint flow has +/// already applied the inscription needs the commit_txid here. +/// * `block_hash` — the Bitcoin block in which the reveal landed; the +/// scanner uses it as the new `latest_block` after persisting state. +pub(crate) type InscriptionCallback = dyn Fn(Vec, Txid, BlockHash) + Send + Sync + 'static; /// Pure logic: filter a list of txids down to those starting with the /// marker prefix. Extracted from the scan loop so it can be unit-tested @@ -28,6 +63,15 @@ pub(crate) fn filter_marker_txids(txids: Vec, marker_bytes: &[u8]) -> Vec< /// extract the content bytes, and invoke the callback with them. /// In a Taproot script-spend the witness is `[signature, script, control_block]` /// so the script is always the second-to-last witness item. +/// +/// Each match invokes `callback` with `(content_bytes, commit_txid, +/// current_block_hash)`. `commit_txid` is the previous-output txid of +/// the input whose witness carried the matching envelope — by +/// construction the txid of the inscription's commit transaction. Mint +/// inscriptions broadcast by `publisher::create_and_broadcast_inscription` +/// pin their reveal's `input[0]` to the commit's vout 0, so the +/// commit_txid surfaced here matches the `commit_txid` column in +/// `pending_inscriptions` for every inscription this server originated. pub(crate) fn process_transaction_inscriptions( tx: &Transaction, current_block_hash: BlockHash, @@ -38,7 +82,11 @@ pub(crate) fn process_transaction_inscriptions( if witness_items.len() >= 3 { let script_bytes = witness_items[witness_items.len() - 2]; if let Some(content_bytes) = extract_inscription_content(script_bytes) { - callback(content_bytes, current_block_hash); + callback( + content_bytes, + input.previous_output.txid, + current_block_hash, + ); } } } diff --git a/node/src/scanner_runtime.rs b/node/src/scanner_runtime.rs new file mode 100644 index 00000000..272f3a97 --- /dev/null +++ b/node/src/scanner_runtime.rs @@ -0,0 +1,230 @@ +//! Runtime bootstrap for the inscription scanner. +//! +//! This file is intentionally excluded from the coverage scope. The +//! functions below own the network I/O (HTTP REST calls to Esplora +//! for the per-block `get_block_txids` / `get_tx` lookups) and the +//! infinite scan loop — neither can be exercised by unit tests +//! without spinning up a fake Esplora server. +//! +//! The pure logic that can be tested without a Bitcoin node lives in +//! `scanner.rs` (filter_marker_txids, process_transaction_inscriptions, +//! extract_inscription_content) and is measured normally. +//! +//! Event-driven (issue #84): new chain tips arrive on an +//! `mpsc::Receiver` fed by `scanner_ws::run_scanner_ws`. +//! Per-tip we walk forward through `get_block_status.next_best` until +//! we catch up with the published hash, then `rx.recv().await` blocks +//! until the next WS event. The chain-tip wait path no longer sleeps; +//! the only remaining sleep is a bounded retry on transient HTTP +//! failures, marked with the `scanner-polling-ok:` token (NOT an +//! `#[allow(...)]` attribute — see issue #84 round-4 MINOR 4) so the +//! CI lint added in the same PR grandfathers it as a last-resort +//! error-backoff, not a poll on the chain tip. + +use bitcoin::{BlockHash, Transaction, Txid}; +use esplora_client::r#async::DefaultSleeper; +use esplora_client::{AsyncClient, Builder, Error as EsploraError, Sleeper}; +use std::collections::HashSet; +use std::error::Error as StdError; +use std::fmt; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Hard error returned when the WS-fed `tip_rx` channel closes +/// unexpectedly mid-scan (issue #84, round-2 MAJOR 2). A closed +/// channel means the `scanner_ws::run_scanner_ws` task that owns the +/// `tip_tx` half has died (panic, unrecoverable error). Returning +/// `Ok(())` here used to make the scanner appear healthy while the +/// chain-tip ingestion was effectively dead — exactly the +/// "appears healthy" failure mode issue #84 set out to eliminate. +/// Surfacing a non-zero exit lets the container orchestrator restart +/// the process and alerting fire on the crash-loop, instead of the +/// REST API silently serving stale state for hours. +#[derive(Debug)] +pub struct TipChannelClosed; + +impl fmt::Display for TipChannelClosed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "chain-tip stream closed unexpectedly — WS scanner task died \ + (issue #84: 'appears healthy' failure mode — the scanner exits \ + non-zero so the orchestrator restarts the process)" + ) + } +} + +impl std::error::Error for TipChannelClosed {} + +use crate::publisher::{EsploraConfig, INSCRIPTION_MARKER_PREFIX}; +use crate::scanner::{filter_marker_txids, process_transaction_inscriptions, InscriptionCallback}; + +/// Bounded retry-sleep for transient HTTP errors against the Esplora +/// REST endpoint (per-block `get_block_txids` / `get_tx`). NOT a poll +/// on the chain tip — that is the WS receiver's job. Kept short so +/// the next WS event can preempt a stuck HTTP call. +const HTTP_RETRY_BACKOFF: Duration = Duration::from_secs(5); + +struct InscriptionScanner { + client: AsyncClient, + processed_blocks: HashSet, + current_block_hash: Option, +} + +impl InscriptionScanner { + fn new(client: AsyncClient) -> Self { + Self { + client, + processed_blocks: HashSet::new(), + current_block_hash: None, + } + } + + /// Drive the scanner forever: walk forward from `start_block_hash`, + /// then wait on the WS-fed `tip_rx` for each subsequent tip. + /// + /// `tip_rx.recv().await` is the documented backpressure point: if + /// the WS reader is faster than this loop, the bounded channel + /// stalls the WS task instead of dropping notifications. + async fn scan_from_block( + &mut self, + start_block_hash: BlockHash, + callback: &InscriptionCallback, + tip_rx: &mut mpsc::Receiver, + ) -> Result<(), Box> { + let mut current_hash = start_block_hash; + + loop { + self.current_block_hash = Some(current_hash); + + if self.processed_blocks.contains(¤t_hash) { + println!("Reached chain tip. Waiting for next WS block event..."); + let next_tip = match tip_rx.recv().await { + Some(h) => h, + None => { + // Hard error, not Ok(()): see TipChannelClosed + // docstring for the issue #84 "appears healthy" + // failure mode rationale. The top-level + // `main()` Err print is the only log; no + // intermediate `eprintln!` here (would + // double-print the same line — issue #84 + // round-4 NIT 2). + return Err(Box::new(TipChannelClosed)); + } + }; + if self.processed_blocks.contains(&next_tip) { + continue; + } + current_hash = next_tip; + continue; + } + + println!("Processing block: {}", current_hash); + + let txids = match self.client.get_block_txids(current_hash).await { + Ok(txids) => txids, + Err(e) => { + // Transient HTTP failure against Esplora — back + // off briefly and retry. NOT a poll on the chain + // tip; the WS receiver feeds new tips + // independently. See module-level docstring for + // the CI-lint opt-out rationale. + println!("Error fetching block txids {}: {}", current_hash, e); + // Bounded retry on HTTP failure, not a tip poll. + // See CONTRIBUTING.md § "No polling — events + // only" for the CI-lint opt-out rationale; the + // `scanner-polling-ok:` marker on the same line + // as the sleep is the literal token the grep + // step in `.github/workflows/ci.yaml` uses to + // grandfather this single allowed sleep. + tokio::time::sleep(HTTP_RETRY_BACKOFF).await; // scanner-polling-ok: bounded HTTP-retry backoff, not a chain-tip poll + continue; + } + }; + + let marker_bytes = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap_or_default(); + let matching_txids: Vec = filter_marker_txids(txids, &marker_bytes); + + for txid in matching_txids { + println!("Found transaction with marker prefix: {}", txid); + match self.client.get_tx(&txid).await { + Ok(Some(tx)) => { + self.process_transaction(&tx, callback).await?; + } + Ok(None) => { + println!("Transaction {} not found", txid); + } + Err(e) => { + println!("Error fetching transaction {}: {}", txid, e); + } + } + } + + self.processed_blocks.insert(current_hash); + + let block_status = self.client.get_block_status(¤t_hash).await?; + match block_status.next_best { + Some(next_hash) => current_hash = next_hash, + None => { + // Caught up. Wait for the next WS tip event + // instead of polling. The `processed_blocks` + // guard at the top of the loop swallows + // duplicate publishes from the WS anchor-on- + // reconnect path. + println!("Reached chain tip. Waiting for next WS block event..."); + let next_tip = match tip_rx.recv().await { + Some(h) => h, + None => { + // Hard error, not Ok(()): see + // TipChannelClosed docstring for the + // issue #84 "appears healthy" failure + // mode rationale. The top-level `main()` + // Err print is the only log; no + // intermediate `eprintln!` here (would + // double-print the same line — issue #84 + // round-4 NIT 2). + return Err(Box::new(TipChannelClosed)); + } + }; + if self.processed_blocks.contains(&next_tip) { + continue; + } + current_hash = next_tip; + } + } + } + } + + async fn process_transaction( + &self, + tx: &Transaction, + callback: &InscriptionCallback, + ) -> Result<(), EsploraError> { + if let Some(current_hash) = self.current_block_hash { + process_transaction_inscriptions(tx, current_hash, callback); + } + Ok(()) + } +} + +/// Scans for inscription transactions in the blockchain. +/// +/// `tip_rx` is the WS-fed channel of new chain tips. The scanner +/// walks forward through `next_best` between events and blocks on +/// `tip_rx.recv()` at every chain-tip catch-up — no polling. +pub async fn scan_for_inscriptions( + config: &EsploraConfig, + start_block_hash: BlockHash, + callback: &InscriptionCallback, + mut tip_rx: mpsc::Receiver, +) -> Result<(), Box> { + let builder = Builder::new(&config.url); + let client = AsyncClient::::from_builder(builder)?; + let mut scanner = InscriptionScanner::new(client); + + scanner + .scan_from_block(start_block_hash, callback, &mut tip_rx) + .await?; + + Ok(()) +} diff --git a/server/src/scanner_tests.rs b/node/src/scanner_tests.rs similarity index 80% rename from server/src/scanner_tests.rs rename to node/src/scanner_tests.rs index 4a12431b..b2937f39 100644 --- a/server/src/scanner_tests.rs +++ b/node/src/scanner_tests.rs @@ -229,16 +229,21 @@ fn process_transaction_inscriptions_invokes_callback_with_payload() { let hash = make_block_hash(); let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let received_clone = received.clone(); - let callback: Box, BlockHash) + Send + Sync> = Box::new(move |bytes, h| { - received_clone.lock().unwrap().push((bytes, h)); - }); + let callback: Box, Txid, BlockHash) + Send + Sync> = + Box::new(move |bytes, ctxid, h| { + received_clone.lock().unwrap().push((bytes, ctxid, h)); + }); process_transaction_inscriptions(&tx, hash, callback.as_ref()); let calls = received.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, payload); - assert_eq!(calls[0].1, hash); + // commit_txid is the previous_output txid of the reveal input — + // `make_tx_with_witness` uses `OutPoint::null()` which carries an + // all-zeros txid. + assert_eq!(calls[0].1, Txid::all_zeros()); + assert_eq!(calls[0].2, hash); } #[test] @@ -260,9 +265,10 @@ fn process_transaction_inscriptions_ignores_inputs_without_witness() { let hash = make_block_hash(); let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let received_clone = received.clone(); - let callback: Box, BlockHash) + Send + Sync> = Box::new(move |bytes, h| { - received_clone.lock().unwrap().push((bytes, h)); - }); + let callback: Box, Txid, BlockHash) + Send + Sync> = + Box::new(move |bytes, ctxid, h| { + received_clone.lock().unwrap().push((bytes, ctxid, h)); + }); process_transaction_inscriptions(&tx, hash, callback.as_ref()); assert!(received.lock().unwrap().is_empty()); @@ -292,14 +298,61 @@ fn process_transaction_inscriptions_ignores_witness_without_envelope() { let hash = make_block_hash(); let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let received_clone = received.clone(); - let callback: Box, BlockHash) + Send + Sync> = Box::new(move |bytes, h| { - received_clone.lock().unwrap().push((bytes, h)); - }); + let callback: Box, Txid, BlockHash) + Send + Sync> = + Box::new(move |bytes, ctxid, h| { + received_clone.lock().unwrap().push((bytes, ctxid, h)); + }); process_transaction_inscriptions(&tx, hash, callback.as_ref()); assert!(received.lock().unwrap().is_empty()); } +// ---- Phase E: should_skip_scanner_state_update ----------------------------- + +#[test] +fn should_skip_scanner_state_update_returns_true_only_for_complete() { + // Mint flow integrated the inscription in-process and marked the + // pending row `complete`. Scanner must skip its own `state.update`. + assert!(should_skip_scanner_state_update(Some( + crate::db::PENDING_STATUS_COMPLETE + ))); +} + +#[test] +fn should_skip_scanner_state_update_false_for_missing_row() { + // Out-of-band / recovery inscription that never went through this + // server's mint flow: no `pending_inscriptions` row, scanner is the + // authoritative integration path. + assert!(!should_skip_scanner_state_update(None)); +} + +#[test] +fn should_skip_scanner_state_update_false_for_in_progress_states() { + // Every non-complete pending status means the mint flow did not + // finish the in-process state.update step. The scanner must fall + // through and integrate the inscription itself (recovery path). + assert!(!should_skip_scanner_state_update(Some( + crate::db::PENDING_STATUS_CONSTRUCTED + ))); + assert!(!should_skip_scanner_state_update(Some( + crate::db::PENDING_STATUS_COMMIT_BROADCAST + ))); + assert!(!should_skip_scanner_state_update(Some( + crate::db::PENDING_STATUS_REVEAL_BROADCAST + ))); +} + +#[test] +fn should_skip_scanner_state_update_false_for_unknown_status() { + // Forward-compatibility: a future status string (e.g. `failed`) + // must NOT cause the scanner to short-circuit. Mirrors the unknown- + // status branch in `resume_single_row`. + assert!(!should_skip_scanner_state_update(Some( + "some-future-status" + ))); + assert!(!should_skip_scanner_state_update(Some(""))); +} + #[test] fn extract_inscription_skips_non_push_opcodes_inside_envelope() { // Inside the OP_FALSE OP_IF envelope, anything that is not a push or diff --git a/node/src/scanner_ws.rs b/node/src/scanner_ws.rs new file mode 100644 index 00000000..b93a4878 --- /dev/null +++ b/node/src/scanner_ws.rs @@ -0,0 +1,551 @@ +//! Event-driven chain ingestion via the Esplora WebSocket stream. +//! +//! Subscribes to the mempool.space-compatible WebSocket endpoint +//! (`ESPLORA_WS_URL`, default `wss://mutinynet.com/api/v1/ws`) and +//! publishes each new tip `BlockHash` into an `mpsc::Sender` that the +//! existing `scanner_runtime` drains. Replaces the 30-s tip polling +//! loop that previously gated `/api/mint` and `/api/send` visibility +//! by up to a full block-time + poll-interval (issue #84). +//! +//! TODO(structured-logging): this module still uses `println!` / +//! `eprintln!` for runtime logs, consistent with the rest of the +//! `server` crate's current conventions. Once the crate-wide +//! migration to `tracing` lands (out of scope for issue #84), the +//! reconnect / liveness lines below are the first candidates for +//! structured fields (peer URL, attempt count, backoff value) since +//! they sit on a hot path that operators need to grep cleanly. +//! +//! ### Design points +//! +//! - Reconnect-with-backoff is encapsulated here. The outer +//! `scanner_runtime` never sees a disconnect — it only sees +//! `BlockHash`es arriving on the channel. +//! - Backpressure-aware `Sender::send().await` (no `try_send`): if +//! the downstream scanner is busy processing a block, the WS +//! reader pauses rather than dropping tip notifications. +//! - 90 s liveness watchdog (`liveness_timeout`) wraps every +//! `ws.next()` in `tokio::time::timeout`. A silent half-open WS +//! triggers a forced reconnect, which is the only behaviour worth +//! the `tokio::time::` reference in event-driven code (documented +//! in CONTRIBUTING.md, enforced by the CI lint added in the same +//! PR). +//! - On reconnect, fetch the current tip via the existing +//! `EsploraClient::get_tip_hash` and push that hash into the +//! channel too. This plugs the gap that opened while we were +//! disconnected — `scanner_runtime` already deduplicates against +//! `processed_blocks`, so re-publishing an already-processed hash +//! is a no-op. +//! - Every `connect_async` is wrapped in a 15 s `CONNECT_TIMEOUT` +//! (issue #84 round-4 MAJOR 1). A half-broken middlebox can stall +//! the TCP handshake for the kernel SYN-retransmit budget +//! (60-180 s on Linux/Darwin); bounding it explicitly lets the +//! reconnect-backoff loop drive recovery instead of stalling on a +//! single attempt. +//! +//! ### Wire format +//! +//! On subscribe (`{"action":"want","data":["blocks"]}`) the server +//! immediately seeds the new client with the last few blocks in a +//! `{"blocks": [, , ...]}` message. Each subsequent tip is +//! pushed as `{"block": }`. Both shapes are handled; unknown +//! frames are logged and ignored. + +use std::time::Duration; + +use bitcoin::BlockHash; +use esplora_client::{ + r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, +}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +pub use crate::scanner_ws_parse::{frame_signals_tx_seen, parse_ws_frame}; + +/// Default endpoint for Mutinynet's mempool.space-compatible WebSocket +/// API. Overridable via `ESPLORA_WS_URL` for self-host operators and +/// for DEV failover (the URL is not officially documented for +/// Mutinynet, but it follows the upstream mempool.space convention +/// and was smoke-tested against `wss://mutinynet.com/api/v1/ws` and +/// `wss://mempool.space/signet/api/v1/ws` before this PR landed). +pub const DEFAULT_ESPLORA_WS_URL: &str = "wss://mutinynet.com/api/v1/ws"; + +/// Default for the liveness watchdog. A real new block arrives at +/// least every ~10 min on any live signet/mainnet, so 90 s with no +/// frame at all (including `pong` / keep-alives) is a strong "the +/// socket is half-open" signal. +pub const DEFAULT_LIVENESS_TIMEOUT: Duration = Duration::from_secs(90); + +/// Default initial reconnect delay. Doubled on each consecutive +/// failure up to `DEFAULT_RECONNECT_MAX`. +pub const DEFAULT_RECONNECT_MIN: Duration = Duration::from_millis(500); + +/// Default cap on the exponential reconnect backoff. 30 s matches +/// the previous polling cadence — if the upstream is genuinely +/// down for that long, we are no worse off than before. +pub const DEFAULT_RECONNECT_MAX: Duration = Duration::from_secs(30); + +/// Default fallback when no `ESPLORA_URL` is in the environment. +/// Kept in sync with `lib.rs::NETWORK_CONFIG`. +pub const DEFAULT_ESPLORA_HTTP_URL: &str = "https://mutinynet.com/api"; + +/// Wall-clock budget for completing a single WS connect handshake. +/// A half-broken middlebox can stall the TCP handshake for the +/// kernel SYN-retransmit budget (60-180 s on Linux/Darwin); bound it +/// explicitly so the reconnect-backoff loop drives recovery instead. +/// Issue #84 review (round 4) MAJOR 1. +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// Initial backoff between failed `track-tx` reconnect attempts inside +/// `wait_for_tx_inner_resilient`. Doubles up to `TRACK_TX_RECONNECT_BACKOFF_MAX`. +/// Issue #84 review (round 4) MAJOR 2: prevents a tight handshake-spin +/// loop against an immediate-close peer; the outer 30 s `track-tx` +/// timeout still bounds total work. +const TRACK_TX_RECONNECT_BACKOFF_MIN: Duration = Duration::from_millis(50); + +/// Cap on the inner `track-tx` reconnect backoff. +const TRACK_TX_RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(1); + +/// Errors surfaced by the per-broadcast `subscribe_track_tx` + +/// `TrackTxStream::wait` two-phase helper used by +/// `publisher::broadcast_inscription_txs`. +#[derive(Debug)] +pub enum WsError { + /// `tokio_tungstenite::connect_async` returned an error. + Connect(String), + /// The subscribe frame failed to send. + Subscribe(String), + /// The peer closed the socket or surfaced an error mid-stream + /// before the expected event arrived. + Stream(String), + /// The safety-net deadline elapsed without the expected event. + Timeout, +} + +impl std::fmt::Display for WsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WsError::Connect(e) => write!(f, "WS connect failed: {}", e), + WsError::Subscribe(e) => write!(f, "WS subscribe failed: {}", e), + WsError::Stream(e) => write!(f, "WS stream error: {}", e), + WsError::Timeout => write!(f, "WS timeout (no expected event in window)"), + } + } +} + +impl std::error::Error for WsError {} + +/// Wrap `connect_async` in a hard wall-clock deadline so a stalled +/// TCP/TLS handshake cannot wedge the surrounding reconnect loop for +/// the kernel SYN-retransmit budget. On timeout the returned error +/// maps to the same `WsError::Connect` shape an actual connect +/// failure would yield, so the caller's reconnect logic is uniform. +async fn connect_with_timeout( + url: &str, +) -> Result< + tokio_tungstenite::WebSocketStream>, + WsError, +> { + match tokio::time::timeout(CONNECT_TIMEOUT, tokio_tungstenite::connect_async(url)).await { + Ok(Ok((ws, _))) => Ok(ws), + Ok(Err(e)) => Err(WsError::Connect(e.to_string())), + Err(_) => Err(WsError::Connect(format!( + "connect_async timed out after {:?}", + CONNECT_TIMEOUT + ))), + } +} + +/// Runtime knobs for the scanner WS task. Sensible defaults are +/// exposed via `from_env`; tests construct it directly with shorter +/// timeouts. +#[derive(Clone, Debug)] +pub struct ScannerWsConfig { + /// Esplora WebSocket URL. Default: `DEFAULT_ESPLORA_WS_URL`. + pub url: String, + /// HTTP Esplora URL used to fetch the current tip after each + /// reconnect (plugs gaps that opened while disconnected). + pub http_url: String, + /// Initial reconnect delay. Doubles up to `reconnect_max`. + pub reconnect_min: Duration, + /// Cap on the exponential reconnect backoff. + pub reconnect_max: Duration, + /// Force-reconnect deadline for `ws.next()`. A silent half-open + /// socket would otherwise wedge the scanner indefinitely. + pub liveness_timeout: Duration, +} + +impl ScannerWsConfig { + /// Read the config from the environment, falling back to the + /// defaults documented above. Logged once at startup by the + /// caller in `main.rs`. + pub fn from_env() -> Self { + let url = + std::env::var("ESPLORA_WS_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_WS_URL.to_string()); + let http_url = + std::env::var("ESPLORA_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_HTTP_URL.to_string()); + Self { + url, + http_url, + reconnect_min: DEFAULT_RECONNECT_MIN, + reconnect_max: DEFAULT_RECONNECT_MAX, + liveness_timeout: DEFAULT_LIVENESS_TIMEOUT, + } + } +} + +/// Run the WS scanner forever. Connects, subscribes, drains frames, +/// reconnects on any error. Never returns under normal operation — +/// the receiver side decides when to stop draining. +/// +/// `tip_tx.send(...).await` is the documented backpressure point: if +/// `scanner_runtime` is busy processing a block, the reader stalls +/// rather than dropping tips. +pub async fn run_scanner_ws(config: ScannerWsConfig, tip_tx: mpsc::Sender) -> ! { + // Build the HTTP Esplora client ONCE outside the reconnect loop + // so a tight reconnect storm does not rebuild it per attempt. + // Construction is cheap, but rebuilding it on every iteration is + // wasted work and obscures the fact that the same client is the + // shared dependency of every anchor-on-reconnect call. + // + // Issue #84 review (round 4) MAJOR 4: collapsed the previous + // duplicated fallback loop into a single state machine by making + // `http_client` an `Option`. If construction failed the inner + // anchor call logs a warning and skips the re-anchor; the next + // session's first WS-pushed block re-establishes the tip. + let http_client: Option> = + match EsploraAsyncClient::::from_builder(EsploraBuilder::new( + &config.http_url, + )) { + Ok(c) => Some(c), + Err(e) => { + // If the HTTP client cannot even be constructed (e.g. + // an unparseable URL) we have no useful fallback. + // Stay loud: every reconnect from here on logs that + // the re-anchor is skipped. + eprintln!( + "scanner_ws: failed to build Esplora HTTP client for {}: {}. \ + Re-anchor on reconnect will be skipped.", + config.http_url, e + ); + None + } + }; + + let mut backoff = config.reconnect_min; + loop { + match connect_and_drain(&config, &tip_tx).await { + Ok(()) => { + // `connect_and_drain` only returns Ok when the peer + // closed the socket cleanly — still a reconnect + // condition, but reset the backoff so we don't punish + // a graceful close. + backoff = config.reconnect_min; + eprintln!("scanner_ws: peer closed cleanly, reconnecting"); + } + Err(e) => { + eprintln!( + "scanner_ws: session ended ({}). Reconnecting in {:?}", + e, backoff + ); + } + } + + // After every reconnect — clean or not — re-anchor on the + // current tip via HTTP. This catches blocks that landed + // while we were disconnected. `scanner_runtime` deduplicates + // against `processed_blocks`, so a no-op re-publish is safe. + if let Some(client) = &http_client { + if let Err(e) = anchor_on_current_tip(client, &tip_tx).await { + eprintln!( + "scanner_ws: failed to fetch current tip after reconnect: {}", + e + ); + } + } else { + eprintln!("scanner_ws: no HTTP client, skipping anchor on reconnect"); + } + + tokio::time::sleep(backoff).await; // scanner-polling-ok: reconnect-with-backoff between failed WS sessions, not a chain-tip poll + backoff = (backoff * 2).min(config.reconnect_max); + } +} + +/// Single connect → subscribe → drain cycle. Returns Ok on a clean +/// close, Err on any failure. Caller schedules the reconnect. +async fn connect_and_drain( + config: &ScannerWsConfig, + tip_tx: &mpsc::Sender, +) -> Result<(), WsError> { + let mut ws = connect_with_timeout(&config.url).await?; + println!("scanner_ws: connected to {}", config.url); + + let subscribe = serde_json::json!({ "action": "want", "data": ["blocks"] }).to_string(); + ws.send(WsMessage::Text(subscribe)) + .await + .map_err(|e| WsError::Subscribe(e.to_string()))?; + + loop { + let next = tokio::time::timeout(config.liveness_timeout, ws.next()).await; + let frame = match next { + Ok(Some(Ok(m))) => m, + Ok(Some(Err(e))) => return Err(WsError::Stream(e.to_string())), + Ok(None) => return Ok(()), // clean close + Err(_) => { + return Err(WsError::Stream(format!( + "no frame in {:?} (liveness watchdog)", + config.liveness_timeout + ))); + } + }; + + match frame { + WsMessage::Text(text) => { + for hash in parse_ws_frame(&text) { + if tip_tx.send(hash).await.is_err() { + // Receiver dropped → scanner_runtime is + // shutting down; drop any remaining hashes in + // this frame (anchor_on_current_tip on the + // next session would replay the latest tip + // anyway). Issue #84 review (round 4) MAJOR 3. + return Err(WsError::Stream("receiver dropped".into())); + } + } + } + WsMessage::Binary(_) => { + // Esplora WS does not send binary frames for the + // `blocks` subscription, but tungstenite delivers + // protocol frames here too. Ignore quietly. + } + WsMessage::Ping(_) | WsMessage::Pong(_) => { + // tungstenite handles ping/pong internally; nothing + // to do. + } + WsMessage::Close(_) => return Ok(()), + // The `Frame` variant of `tungstenite::Message` only + // surfaces under the `frame` cargo feature, which we do + // not enable. Keep the arm here as a defensive catch-all + // so a future tungstenite upgrade that flips the feature + // default does not break the build via a non-exhaustive + // match warning. + #[allow(unreachable_patterns)] + WsMessage::Frame(_) => {} + } + } +} + +/// On reconnect, fetch the current tip via HTTP and push it into +/// the channel so `scanner_runtime` can re-anchor. Bounded by a +/// short timeout — the channel must not stall on a slow tip lookup. +/// The Esplora client is owned by `run_scanner_ws` and passed in by +/// reference so we do not rebuild it on every reconnect. +async fn anchor_on_current_tip( + client: &EsploraAsyncClient, + tip_tx: &mpsc::Sender, +) -> Result<(), String> { + let lookup = tokio::time::timeout(Duration::from_secs(10), client.get_tip_hash()); + let hash = match lookup.await { + Ok(Ok(h)) => h, + Ok(Err(e)) => return Err(e.to_string()), + Err(_) => return Err("get_tip_hash timed out".into()), + }; + + if tip_tx.send(hash).await.is_err() { + return Err("receiver dropped".into()); + } + Ok(()) +} + +/// Per-frame watchdog used by the inner `track-tx` wait loop. The +/// outer 30 s `TRACK_TX_TIMEOUT_SECS` budget is owned by the publisher; +/// this inner watchdog detects a half-open peer that swallows frames +/// without delivering any event, so we can reconnect-and-re-subscribe +/// within the outer envelope rather than sitting for the full 30 s on +/// a wedged socket. +const TRACK_TX_FRAME_WATCHDOG: Duration = Duration::from_secs(10); + +/// A live `track-tx` subscription against the Esplora WS. Returned by +/// [`subscribe_track_tx`]. Calling [`TrackTxStream::wait`] drains the +/// subscription until the peer reports the tracked txid as seen, or +/// until `timeout` elapses (whichever comes first). +/// +/// The split between `subscribe_track_tx` and `wait` is load-bearing +/// (issue #84): the publisher MUST establish the subscription BEFORE +/// broadcasting the commit transaction, otherwise the upstream may +/// propagate the tx between the broadcast and the subscribe and the +/// "tx in mempool" event would fire before we are listening. With the +/// split, the subscribe handshake is complete before the broadcast +/// races against it. +pub struct TrackTxStream { + ws: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + url: String, + txid: bitcoin::Txid, + txid_str: String, +} + +impl std::fmt::Debug for TrackTxStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrackTxStream") + .field("url", &self.url) + .field("txid", &self.txid) + .finish_non_exhaustive() + } +} + +impl TrackTxStream { + /// Drain the subscription until the peer reports the tracked + /// txid, or the outer `timeout` elapses. The implementation also + /// runs a per-frame watchdog ([`TRACK_TX_FRAME_WATCHDOG`]) so a + /// silent half-open peer triggers a forced reconnect within the + /// outer budget rather than wedging the full window. + /// + /// On reconnect we re-open the WS and re-send the `track-tx` + /// subscribe frame, then continue waiting against the remaining + /// outer budget. This keeps the publisher's contract simple: a + /// missing event surfaces as `WsError::Timeout` exactly when the + /// caller's deadline elapses, regardless of how many half-open + /// reconnects happened in between. + pub async fn wait(self, timeout: Duration) -> Result<(), WsError> { + tokio::time::timeout(timeout, wait_for_tx_inner_resilient(self)) + .await + .map_err(|_| WsError::Timeout)? + } +} + +/// Open a short-lived WS to `url`, subscribe to `track-tx` for +/// `txid`, and return the live stream WITHOUT yet waiting for an +/// event. The caller is expected to drive the actual wait via +/// [`TrackTxStream::wait`] after performing whatever side-effect the +/// subscription is gating (in our case: broadcasting the commit +/// transaction on the Esplora REST endpoint). +/// +/// Splitting the two-phase API away from the old all-in-one +/// `wait_for_tx_in_mempool` plugs the issue #84 race: with the +/// single-call helper, the publisher used to broadcast the commit +/// BEFORE the subscribe completed, so the "tx in mempool" event +/// could fire before any listener was attached. +pub async fn subscribe_track_tx(url: &str, txid: bitcoin::Txid) -> Result { + let mut ws = connect_with_timeout(url).await?; + + let txid_str = txid.to_string(); + let subscribe = serde_json::json!({ + "action": "track-tx", + "data": txid_str, + }) + .to_string(); + ws.send(WsMessage::Text(subscribe)) + .await + .map_err(|e| WsError::Subscribe(e.to_string()))?; + + Ok(TrackTxStream { + ws, + url: url.to_string(), + txid, + txid_str, + }) +} + +/// Inner loop with per-frame watchdog + transparent reconnect. On a +/// per-frame timeout (`TRACK_TX_FRAME_WATCHDOG`), tear the current WS +/// down and re-subscribe; continue draining until the outer caller's +/// deadline elapses (which it does via `tokio::time::timeout` wrapping +/// this future in `TrackTxStream::wait`). +/// +/// Issue #84 review (round 4) MAJOR 2: a peer that accepts and +/// immediately closes (or drops every frame) used to make this loop +/// tight-spin a fresh TCP+TLS handshake per iteration. We now apply +/// an exponential backoff between failed reconnects (50 ms → 1 s) +/// and reset it to 50 ms on the next successful connect+subscribe so +/// a single transient drop does not penalise subsequent good runs. +/// The outer 30 s `tokio::time::timeout` continues to bound total +/// work, so the backoff can never starve the publisher. +async fn wait_for_tx_inner_resilient(stream: TrackTxStream) -> Result<(), WsError> { + let TrackTxStream { + mut ws, + url, + txid, + txid_str, + } = stream; + + // Per-reconnect backoff. Doubles per consecutive failure, capped + // at `TRACK_TX_RECONNECT_BACKOFF_MAX`. Reset to MIN whenever the + // current session yields any frame from the peer ("good run"). + let mut reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; + + loop { + let next = tokio::time::timeout(TRACK_TX_FRAME_WATCHDOG, ws.next()).await; + match next { + Ok(Some(Ok(WsMessage::Text(text)))) => { + if frame_signals_tx_seen(&text, &txid_str) { + return Ok(()); + } + // Non-matching text frame (heartbeat, position update + // for some other tx, mempool stats). Keep draining. + // The peer is delivering frames → this is a "good + // run", so reset the reconnect backoff. + reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; + } + Ok(Some(Ok(WsMessage::Close(_)))) | Ok(None) => { + // Peer closed the socket before delivering the event. + // Reconnect and re-subscribe; the outer timeout caps + // how long we keep trying. + eprintln!( + "scanner_ws: track-tx peer closed before event for {}; reconnecting after {:?}", + txid, reconnect_backoff + ); + tokio::time::sleep(reconnect_backoff).await; // scanner-polling-ok: reconnect-backoff between failed track-tx sessions (issue #84 round-4 MAJOR 2) + ws = reconnect_track_tx(&url, &txid_str).await?; + reconnect_backoff = (reconnect_backoff * 2).min(TRACK_TX_RECONNECT_BACKOFF_MAX); + } + Ok(Some(Ok(_))) => { + // Binary / ping / pong / raw frame — tungstenite + // handles ping/pong internally and the others are not + // emitted by Esplora for this subscription. Ignore, + // but treat as evidence of a live peer. + reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; + } + Ok(Some(Err(e))) => { + return Err(WsError::Stream(e.to_string())); + } + Err(_) => { + // Per-frame watchdog elapsed. Treat as half-open and + // reconnect within the outer caller's budget. + eprintln!( + "scanner_ws: track-tx frame watchdog ({:?}) elapsed for {}; reconnecting after {:?}", + TRACK_TX_FRAME_WATCHDOG, txid, reconnect_backoff + ); + tokio::time::sleep(reconnect_backoff).await; // scanner-polling-ok: reconnect-backoff between failed track-tx sessions (issue #84 round-4 MAJOR 2) + ws = reconnect_track_tx(&url, &txid_str).await?; + reconnect_backoff = (reconnect_backoff * 2).min(TRACK_TX_RECONNECT_BACKOFF_MAX); + } + } + } +} + +/// Helper used by the inner wait loop: tear down the current ws (the +/// drop happens by reassignment in the caller) and open a fresh +/// connection with the same `track-tx` subscription frame. +async fn reconnect_track_tx( + url: &str, + txid_str: &str, +) -> Result< + tokio_tungstenite::WebSocketStream>, + WsError, +> { + let mut ws = connect_with_timeout(url).await?; + let subscribe = serde_json::json!({ + "action": "track-tx", + "data": txid_str, + }) + .to_string(); + ws.send(WsMessage::Text(subscribe)) + .await + .map_err(|e| WsError::Subscribe(e.to_string()))?; + Ok(ws) +} + +#[cfg(test)] +#[path = "scanner_ws_tests.rs"] +mod tests; diff --git a/node/src/scanner_ws_parse.rs b/node/src/scanner_ws_parse.rs new file mode 100644 index 00000000..56597a5b --- /dev/null +++ b/node/src/scanner_ws_parse.rs @@ -0,0 +1,109 @@ +//! Pure parsers for the Esplora WebSocket frame shapes. +//! +//! Split out from `scanner_ws.rs` so the pure logic stays inside the +//! 100% coverage gate while the runtime/network code (which cannot be +//! exercised without spinning up a fake WS server) remains excluded +//! from coverage via `--ignore-filename-regex`. Issue #84 review +//! (round 4) MINOR 6. + +use std::str::FromStr; + +use bitcoin::BlockHash; + +/// Parse a `BlockHash` out of the `block.id` (or first +/// `blocks[].id`) field of an Esplora WS frame. Returns +/// `Some(hash)` only for the two documented shapes: +/// +/// - `{"block": {"id": "", ...}}` +/// - `{"blocks": [{"id": "", ...}, ...]}` (initial seed) +/// +/// Anything else (heartbeats, mempool-block updates the scanner +/// does not subscribe to, malformed frames) is silently dropped. +/// The reason this returns `Vec` rather than a single +/// hash is the `blocks` shape — the initial subscribe response +/// carries several entries, and we publish each so +/// `scanner_runtime`'s dedupe handles the rest. +pub fn parse_ws_frame(text: &str) -> Vec { + let value: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + if let Some(block) = value.get("block") { + return block + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| BlockHash::from_str(s).ok()) + .map(|h| vec![h]) + .unwrap_or_default(); + } + + if let Some(blocks) = value.get("blocks").and_then(|v| v.as_array()) { + return blocks + .iter() + .filter_map(|b| b.get("id").and_then(|v| v.as_str())) + .filter_map(|s| BlockHash::from_str(s).ok()) + .collect(); + } + + Vec::new() +} + +/// Return true when the frame reports the tracked txid in one of the +/// documented mempool.space `track-tx` response shapes: +/// +/// - `{"tx": {"txid": "", ...}}` (initial tx-detected event; +/// value is the full transaction object, which carries `txid`) +/// - `{"txPosition": {"txid": "", ...}}` (mempool position +/// update; value is `{txid, position, accelerationPositions}`) +/// - `{"txConfirmed": ""}` (tx confirmed in a new block; +/// value is the txid string directly) +/// +/// Critically, the subscribe-echo shape +/// `{"action":"track-tx","data":""}` MUST NOT match — upstreams +/// that echo the subscribe frame back would otherwise resolve the +/// wait immediately, before the tx had actually propagated. The unit +/// test `frame_signals_tx_seen_does_not_match_subscribe_echo` +/// enforces this. +pub fn frame_signals_tx_seen(text: &str, txid: &str) -> bool { + let value: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return false, + }; + + // `{"txConfirmed": ""}` — direct string value. + if value + .get("txConfirmed") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == txid) + { + return true; + } + + // `{"txPosition": {"txid": "", ...}}` + if value + .get("txPosition") + .and_then(|v| v.get("txid")) + .and_then(|v| v.as_str()) + .is_some_and(|s| s == txid) + { + return true; + } + + // `{"tx": {"txid": "", ...}}` — the full transaction object + // carries `txid` as a nested field. + if value + .get("tx") + .and_then(|v| v.get("txid")) + .and_then(|v| v.as_str()) + .is_some_and(|s| s == txid) + { + return true; + } + + false +} + +#[cfg(test)] +#[path = "scanner_ws_parse_tests.rs"] +mod tests; diff --git a/node/src/scanner_ws_parse_tests.rs b/node/src/scanner_ws_parse_tests.rs new file mode 100644 index 00000000..a837e6b9 --- /dev/null +++ b/node/src/scanner_ws_parse_tests.rs @@ -0,0 +1,135 @@ +//! Unit tests for the pure WS-frame parsers. +//! +//! Split out from `scanner_ws_tests.rs` so the pure helper coverage +//! lives next to the pure helpers and stays inside the 100% line + +//! function coverage gate. Issue #84 review (round 4) MINOR 6. + +use super::*; +use bitcoin::BlockHash; +use std::str::FromStr; + +/// Sample block hash used in fixtures. Real Mutinynet block from the +/// smoke test before the patch landed; the exact value is irrelevant +/// — only the hex shape and the `BlockHash::from_str` round-trip +/// matter to the parser. +const SAMPLE_BLOCK_HASH_HEX: &str = + "0000001188cdecb3bfe1cd91cf2209071e272e1b87efe33773717b05270fdf0c"; + +const SAMPLE_BLOCK_HASH_HEX_2: &str = + "000002b1da7c7e2e2092ae5e4caf0828d1bc301490ddc714d8a3b80f84e333c0"; + +fn sample_hash() -> BlockHash { + BlockHash::from_str(SAMPLE_BLOCK_HASH_HEX).unwrap() +} + +fn sample_hash_2() -> BlockHash { + BlockHash::from_str(SAMPLE_BLOCK_HASH_HEX_2).unwrap() +} + +#[test] +fn parse_ws_frame_extracts_single_block_hash() { + let frame = format!( + r#"{{"block":{{"id":"{}","height":3123724}}}}"#, + SAMPLE_BLOCK_HASH_HEX + ); + let parsed = parse_ws_frame(&frame); + assert_eq!(parsed, vec![sample_hash()]); +} + +#[test] +fn parse_ws_frame_extracts_blocks_array_initial_seed() { + let frame = format!( + r#"{{"blocks":[{{"id":"{}","height":1}},{{"id":"{}","height":2}}]}}"#, + SAMPLE_BLOCK_HASH_HEX, SAMPLE_BLOCK_HASH_HEX_2 + ); + let parsed = parse_ws_frame(&frame); + assert_eq!(parsed, vec![sample_hash(), sample_hash_2()]); +} + +#[test] +fn parse_ws_frame_ignores_unknown_shapes() { + // mempool-blocks updates the scanner does not subscribe to. + assert!(parse_ws_frame(r#"{"mempool-blocks":[]}"#).is_empty()); + // Empty object. + assert!(parse_ws_frame("{}").is_empty()); + // Malformed JSON. + assert!(parse_ws_frame("not json").is_empty()); + // Block field present but the id is not a valid hash. + assert!(parse_ws_frame(r#"{"block":{"id":"zzzz"}}"#).is_empty()); +} + +#[test] +fn parse_ws_frame_returns_empty_when_block_id_is_invalid_hex() { + // `block.id` is a string but not a valid BlockHash hex — must + // not panic, must return empty Vec. Covers the + // `BlockHash::from_str(hash).is_err()` fallthrough branch in + // `parse_ws_frame`. + let frame = r#"{"block":{"id":"not-a-real-hash"}}"#; + assert!(parse_ws_frame(frame).is_empty()); +} + +#[test] +fn frame_signals_tx_seen_matches_documented_mempool_shapes() { + let txid_hex = "1111111111111111111111111111111111111111111111111111111111111111"; + + // `{"txConfirmed": ""}` — value is the txid string directly. + assert!(frame_signals_tx_seen( + &format!(r#"{{"txConfirmed":"{}"}}"#, txid_hex), + txid_hex + )); + // `{"txPosition": {"txid": "", "position": {...}}}` + assert!(frame_signals_tx_seen( + &format!( + r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, + txid_hex + ), + txid_hex + )); + // `{"tx": {"txid": "", ...}}` — full tx detection event. + assert!(frame_signals_tx_seen( + &format!( + r#"{{"tx":{{"txid":"{}","fee":100,"vsize":200}}}}"#, + txid_hex + ), + txid_hex + )); + + // Different txid — must not match. + let other = "2222222222222222222222222222222222222222222222222222222222222222"; + assert!(!frame_signals_tx_seen( + &format!(r#"{{"txConfirmed":"{}"}}"#, other), + txid_hex + )); + assert!(!frame_signals_tx_seen( + &format!(r#"{{"txPosition":{{"txid":"{}"}}}}"#, other), + txid_hex + )); + + // Malformed JSON + assert!(!frame_signals_tx_seen("garbage", txid_hex)); +} + +/// Regression for issue #84 review (round 2, MINOR 5): an upstream +/// that echoed the subscribe frame back to the client used to satisfy +/// the wildcard `json_contains_string` matcher, which would have +/// resolved the wait before the tx had actually propagated. The +/// matcher now restricts itself to the documented response shapes +/// (`txConfirmed`, `txPosition`, `tx`) and explicitly does NOT match +/// the subscribe-echo frame. +#[test] +fn frame_signals_tx_seen_does_not_match_subscribe_echo() { + let txid_hex = "1111111111111111111111111111111111111111111111111111111111111111"; + let echo = format!(r#"{{"action":"track-tx","data":"{}"}}"#, txid_hex); + assert!( + !frame_signals_tx_seen(&echo, txid_hex), + "subscribe-echo frame must NOT trigger the matcher" + ); + + // Also: an unrelated frame that just happens to mention the txid + // in a non-documented field must not match. + let unrelated = format!(r#"{{"someOtherKey":{{"txid":"{}"}}}}"#, txid_hex); + assert!( + !frame_signals_tx_seen(&unrelated, txid_hex), + "non-documented shape mentioning the txid must not match" + ); +} diff --git a/node/src/scanner_ws_tests.rs b/node/src/scanner_ws_tests.rs new file mode 100644 index 00000000..a363708e --- /dev/null +++ b/node/src/scanner_ws_tests.rs @@ -0,0 +1,377 @@ +//! Tests for `scanner_ws.rs`. +//! +//! The connect-subscribe-drain loop and the `wait_for_tx_in_mempool` +//! helper are exercised against an in-process WebSocket server +//! constructed with `tokio_tungstenite::accept_async` — no real +//! network hop, no upstream dependency, no flakiness from public +//! Mutinynet outages. +//! +//! Pure parsers (`parse_ws_frame`, `frame_signals_tx_seen`) live in +//! `scanner_ws_parse.rs` and are unit-tested in +//! `scanner_ws_parse_tests.rs` so they stay inside the 100% coverage +//! gate (issue #84 round-4 MINOR 6). + +use super::*; +use bitcoin::{BlockHash, Txid}; +use futures_util::{SinkExt, StreamExt}; +use std::str::FromStr; +use std::time::Duration; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// Sample block hash used in fixtures. Real Mutinynet block from the +/// smoke test before the patch landed; the exact value is irrelevant +/// — only the hex shape and the `BlockHash::from_str` round-trip +/// matter to the parser. +const SAMPLE_BLOCK_HASH_HEX: &str = + "0000001188cdecb3bfe1cd91cf2209071e272e1b87efe33773717b05270fdf0c"; + +const SAMPLE_BLOCK_HASH_HEX_2: &str = + "000002b1da7c7e2e2092ae5e4caf0828d1bc301490ddc714d8a3b80f84e333c0"; + +fn sample_hash() -> BlockHash { + BlockHash::from_str(SAMPLE_BLOCK_HASH_HEX).unwrap() +} + +fn sample_hash_2() -> BlockHash { + BlockHash::from_str(SAMPLE_BLOCK_HASH_HEX_2).unwrap() +} + +// ----------------------------------------------------------------------------- +// In-process WS server fixtures +// ----------------------------------------------------------------------------- + +/// Spawn a single-shot WS server on `127.0.0.1:0`. The handler +/// receives the accepted stream and is responsible for performing +/// the subscribe handshake and any test-specific scripting. Returns +/// the `ws://` URL bound by the OS. +async fn spawn_ws_server(handler: F) -> String +where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, +{ + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{}", addr); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + handler(ws).await; + }); + url +} + +/// Helper: read the `want`/`blocks` subscribe frame and assert its +/// shape. Returns the parsed JSON so handlers can layer additional +/// assertions on top. +async fn expect_subscribe_blocks( + ws: &mut tokio_tungstenite::WebSocketStream, +) { + let first = ws.next().await.unwrap().unwrap(); + let text = match first { + WsMessage::Text(t) => t, + other => panic!("expected text subscribe frame, got {:?}", other), + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value.get("action"), Some(&serde_json::json!("want"))); + assert_eq!(value.get("data"), Some(&serde_json::json!(["blocks"]))); +} + +// ----------------------------------------------------------------------------- +// run_scanner_ws — happy path + reconnect + liveness watchdog +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn run_scanner_ws_publishes_blocks_from_server() { + let url = spawn_ws_server(|mut ws| async move { + expect_subscribe_blocks(&mut ws).await; + // Send initial seed (`blocks` array) + one fresh tip. + let initial = format!( + r#"{{"blocks":[{{"id":"{}","height":1}}]}}"#, + SAMPLE_BLOCK_HASH_HEX + ); + let tip = format!( + r#"{{"block":{{"id":"{}","height":2}}}}"#, + SAMPLE_BLOCK_HASH_HEX_2 + ); + ws.send(WsMessage::Text(initial)).await.unwrap(); + ws.send(WsMessage::Text(tip)).await.unwrap(); + // Hold the socket open until the test aborts the task. A + // bounded `sleep(60s)` would silently expire on a slow CI + // runner and let the scanner observe a clean close, masking + // any race the test is trying to pin. `pending` has the + // identical "hold forever" semantic without the bound. + std::future::pending::<()>().await; + }) + .await; + + let (tx, mut rx) = mpsc::channel::(8); + let config = ScannerWsConfig { + url, + http_url: "http://127.0.0.1:1/api".to_string(), // unused on happy path + reconnect_min: Duration::from_millis(10), + reconnect_max: Duration::from_millis(50), + liveness_timeout: Duration::from_secs(5), + }; + let handle = tokio::spawn(run_scanner_ws(config, tx)); + + let h1 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("first hash should arrive within 5s") + .expect("channel open"); + let h2 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("second hash should arrive within 5s") + .expect("channel open"); + assert_eq!(h1, sample_hash()); + assert_eq!(h2, sample_hash_2()); + + handle.abort(); +} + +#[tokio::test] +async fn run_scanner_ws_reconnects_after_server_close() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{}", addr); + + tokio::spawn(async move { + // First connection: send one block then close. + let (s1, _) = listener.accept().await.unwrap(); + let mut ws1 = tokio_tungstenite::accept_async(s1).await.unwrap(); + expect_subscribe_blocks(&mut ws1).await; + let m1 = format!( + r#"{{"block":{{"id":"{}","height":1}}}}"#, + SAMPLE_BLOCK_HASH_HEX + ); + ws1.send(WsMessage::Text(m1)).await.unwrap(); + ws1.close(None).await.unwrap(); + drop(ws1); + + // Second connection: send the second block. + let (s2, _) = listener.accept().await.unwrap(); + let mut ws2 = tokio_tungstenite::accept_async(s2).await.unwrap(); + expect_subscribe_blocks(&mut ws2).await; + let m2 = format!( + r#"{{"block":{{"id":"{}","height":2}}}}"#, + SAMPLE_BLOCK_HASH_HEX_2 + ); + ws2.send(WsMessage::Text(m2)).await.unwrap(); + // Hold forever until the test aborts (see the matching note + // on the first sleep replacement above). + std::future::pending::<()>().await; + }); + + let (tx, mut rx) = mpsc::channel::(8); + let config = ScannerWsConfig { + url, + http_url: "http://127.0.0.1:1/api".to_string(), + reconnect_min: Duration::from_millis(10), + reconnect_max: Duration::from_millis(50), + liveness_timeout: Duration::from_secs(5), + }; + let handle = tokio::spawn(run_scanner_ws(config, tx)); + + let h1 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("first hash within 5s") + .expect("channel open"); + assert_eq!(h1, sample_hash()); + + // Drain anything the http-anchor path pushed in between (it + // points at a closed port, so it errors out and pushes nothing + // — but be tolerant of an empty/extra value). + let h2 = loop { + let next = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("second hash within 5s") + .expect("channel open"); + if next != sample_hash() { + break next; + } + }; + assert_eq!(h2, sample_hash_2()); + + handle.abort(); +} + +#[tokio::test] +async fn run_scanner_ws_force_reconnects_on_liveness_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{}", addr); + + tokio::spawn(async move { + // First connection: send one block, then park BRIEFLY without + // sending anything else — the scanner's liveness watchdog + // (300 ms below) must fire while the handler is parked, then + // the handler reaches the second `accept_async` in time for + // the scanner's reconnect attempt to complete inside the + // outer 10 s budget. Issue #84 review (round 4) BLOCKER: the + // previous version parked for 120 s, blocking the second + // accept and starving the scanner's reconnect handshake. + let (s1, _) = listener.accept().await.unwrap(); + let mut ws1 = tokio_tungstenite::accept_async(s1).await.unwrap(); + expect_subscribe_blocks(&mut ws1).await; + let m1 = format!( + r#"{{"block":{{"id":"{}","height":1}}}}"#, + SAMPLE_BLOCK_HASH_HEX + ); + ws1.send(WsMessage::Text(m1)).await.unwrap(); + // Short controlled park: ≫ liveness_timeout (300 ms) so the + // watchdog fires before we drop ws1, but ≪ outer test budget + // (10 s) so the reconnect handshake completes in-window. + tokio::time::sleep(Duration::from_millis(500)).await; + drop(ws1); + + let (s2, _) = listener.accept().await.unwrap(); + let mut ws2 = tokio_tungstenite::accept_async(s2).await.unwrap(); + expect_subscribe_blocks(&mut ws2).await; + let m2 = format!( + r#"{{"block":{{"id":"{}","height":2}}}}"#, + SAMPLE_BLOCK_HASH_HEX_2 + ); + ws2.send(WsMessage::Text(m2)).await.unwrap(); + // Hold forever until the test aborts (see the matching note + // on the first sleep replacement above). + std::future::pending::<()>().await; + }); + + let (tx, mut rx) = mpsc::channel::(8); + let config = ScannerWsConfig { + url, + http_url: "http://127.0.0.1:1/api".to_string(), + reconnect_min: Duration::from_millis(10), + reconnect_max: Duration::from_millis(50), + // Aggressive watchdog so the test stays fast. + liveness_timeout: Duration::from_millis(300), + }; + let handle = tokio::spawn(run_scanner_ws(config, tx)); + + let h1 = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("first hash within 5s") + .expect("channel open"); + assert_eq!(h1, sample_hash()); + + // After watchdog fires we expect the second connection to land + // the second block. Drain any anchor-on-reconnect leftovers. + let h2 = loop { + let next = tokio::time::timeout(Duration::from_secs(10), rx.recv()) + .await + .expect("second hash within 10s") + .expect("channel open"); + if next != sample_hash() { + break next; + } + }; + assert_eq!(h2, sample_hash_2()); + + handle.abort(); +} + +// ----------------------------------------------------------------------------- +// subscribe_track_tx / TrackTxStream::wait (two-phase API, issue #84 +// round-2 MAJOR 1: subscribe MUST precede the commit broadcast) +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn subscribe_track_tx_then_wait_returns_when_peer_emits_txid() { + let txid = + Txid::from_str("1111111111111111111111111111111111111111111111111111111111111111").unwrap(); + let txid_str = txid.to_string(); + + let url = { + let txid_for_handler = txid_str.clone(); + spawn_ws_server(move |mut ws| async move { + // Expect the `track-tx` subscribe frame. + let first = ws.next().await.unwrap().unwrap(); + let text = match first { + WsMessage::Text(t) => t, + other => panic!("expected text frame, got {:?}", other), + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value.get("action"), Some(&serde_json::json!("track-tx"))); + assert_eq!( + value.get("data"), + Some(&serde_json::json!(txid_for_handler)) + ); + + // Send the documented mempool.space `txPosition` shape. + let frame = format!( + r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, + txid_for_handler + ); + ws.send(WsMessage::Text(frame)).await.unwrap(); + // Hold forever until the test aborts. + std::future::pending::<()>().await; + }) + .await + }; + + let stream = subscribe_track_tx(&url, txid) + .await + .expect("subscribe should succeed"); + stream + .wait(Duration::from_secs(5)) + .await + .expect("track-tx event should resolve the wait"); +} + +#[tokio::test] +async fn track_tx_wait_returns_timeout_when_event_never_arrives() { + let txid = + Txid::from_str("2222222222222222222222222222222222222222222222222222222222222222").unwrap(); + let url = spawn_ws_server(|mut ws| async move { + // Consume the subscribe frame but never echo the event. + let _ = ws.next().await; + // Hold forever until the test aborts. + std::future::pending::<()>().await; + }) + .await; + + let stream = subscribe_track_tx(&url, txid) + .await + .expect("subscribe should succeed"); + let err = stream + .wait(Duration::from_millis(300)) + .await + .expect_err("must surface Timeout when no event arrives"); + assert!( + matches!(err, WsError::Timeout), + "unexpected error: {:?}", + err + ); +} + +#[tokio::test] +async fn subscribe_track_tx_returns_connect_error_on_bad_url() { + let txid = + Txid::from_str("3333333333333333333333333333333333333333333333333333333333333333").unwrap(); + // 127.0.0.1:1 is reserved (tcpmux) and refused on macOS / Linux + // CI runners — produces an immediate connect error. + let err = subscribe_track_tx("ws://127.0.0.1:1", txid) + .await + .expect_err("connect to closed port must fail"); + assert!( + matches!(err, WsError::Connect(_)), + "expected Connect, got: {:?}", + err + ); +} + +// ----------------------------------------------------------------------------- +// Smoke — `from_env` +// ----------------------------------------------------------------------------- + +#[test] +fn scanner_ws_config_from_env_uses_defaults_when_unset() { + // Don't touch the process-wide env; just verify the defaults + // are exposed via `DEFAULT_*` constants and that the struct + // assembles. The full `from_env` round-trip is exercised by the + // bootstrap in `main.rs`. + assert_eq!(DEFAULT_ESPLORA_WS_URL, "wss://mutinynet.com/api/v1/ws"); + assert_eq!(DEFAULT_LIVENESS_TIMEOUT, Duration::from_secs(90)); + assert!(DEFAULT_RECONNECT_MIN < DEFAULT_RECONNECT_MAX); +} diff --git a/node/src/state.rs b/node/src/state.rs new file mode 100644 index 00000000..83a6a587 --- /dev/null +++ b/node/src/state.rs @@ -0,0 +1,381 @@ +use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use serde::{Deserialize, Serialize}; +use shared::commitment::Commitment; +use shared::SECP256K1; +use sqlx::PgPool; +use std::collections::HashMap; +use zkcoins_program::circuit::main::MMR_PROOF_PATH_LEN; +use zkcoins_program::hash::{hash_concat, HashDigest, ZERO_HASH}; +use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange}; +use zkcoins_program::merkle::sparse_merkle_tree::{InclusionProof, SparseMerkleTree}; + +use crate::db; + +/// Defensive upper bound on the [`derive_num_pubkeys_from_smt`] loop. +/// +/// The MVP faucet bumps `num_pubkeys` once per `/api/mint`, a feature- +/// gated low-frequency endpoint. One million successful mints is several +/// orders of magnitude above the deployment envelope (closed test +/// environment, hand-driven mints), so a loop that exceeds the bound is +/// a structural bug — either the SMT was corrupted to contain millions +/// of synthetic minting pubkeys, or the caller passed an Xpriv that +/// shadows another wallet's branch. Panic rather than return a poisoned +/// `u32`: the safe response to a state we cannot reason about is to +/// stop, not to keep minting. +const DERIVE_NUM_PUBKEYS_LOOP_BOUND: u32 = 1_000_000; + +/// Derive the minting account's `num_pubkeys` from SMT membership. +/// +/// The faucet generates a fresh BIP-32 child pubkey for each mint +/// (`pk_n = generate_public_key(xpriv, n)`) and the scanner inserts +/// `key = sha256(pk_n.serialize())` into the SMT once the on-chain +/// inscription lands. The count of successful mints is therefore the +/// length of the prefix `pk_0, pk_1, …` whose keys are all present in +/// the SMT — equivalently, the smallest `n` whose key is absent. +/// +/// Walks `n = 0, 1, 2, …`, deriving each pubkey and checking SMT +/// membership via [`SparseMerkleTree::get`] (the cheapest membership +/// primitive — O(1) `HashMap::get` on the leaf table, no proof +/// reconstruction). Returns the first miss. +/// +/// Replaces the pre-Phase-D `minting_meta.num_pubkeys` counter as the +/// single source of truth: the SMT is already authoritative for "which +/// minting commitments landed on-chain" (the scanner is the only writer +/// and `state.update`'s `smt.insert` is idempotent on same key + same +/// value), so collapsing the counter into it removes the desync class +/// documented in zk-coins/node#89 by construction. The startup +/// invariant check that compared the two values is now a tautology and +/// has been removed. +/// +/// **Loop bound.** Capped at [`DERIVE_NUM_PUBKEYS_LOOP_BOUND`]; an +/// overrun panics. See the constant's docs for the rationale. +pub fn derive_num_pubkeys_from_smt(xpriv: &Xpriv, smt: &SparseMerkleTree) -> u32 { + derive_num_pubkeys_from_smt_with_bound(xpriv, smt, DERIVE_NUM_PUBKEYS_LOOP_BOUND) +} + +/// Bound-parametrised inner of [`derive_num_pubkeys_from_smt`]. +/// +/// Exposed at `pub(crate)` so the test suite can exercise the loop- +/// bound panic branch with a tiny bound (millions of real BIP-32 +/// derivations + Poseidon SMT inserts is several minutes of wall time; +/// the bound branch is the same regardless of the constant). Production +/// callers MUST use the wrapper above with [`DERIVE_NUM_PUBKEYS_LOOP_BOUND`]. +pub(crate) fn derive_num_pubkeys_from_smt_with_bound( + xpriv: &Xpriv, + smt: &SparseMerkleTree, + bound: u32, +) -> u32 { + let xpub = Xpub::from_priv(&SECP256K1, xpriv); + let mut n: u32 = 0; + loop { + let pk: PublicKey = xpub + .derive_pub(&SECP256K1, &[ChildNumber::Normal { index: n }]) + .expect("BIP-32 unhardened derivation cannot fail for u32 indices") + .public_key; + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&pk.serialize()).to_byte_array(); + if smt.get(&key).is_none() { + return n; + } + if n >= bound { + panic!( + "derive_num_pubkeys_from_smt: SMT contains more than {} consecutive minting pubkeys; \ + the loop bound is a safety net for a state we cannot reason about", + bound + ); + } + n += 1; + } +} + +/// State stores both a Sparse Merkle Tree (for individual commitments) +/// and a Merkle Mountain Range (for accumulating SMT roots). +#[derive(Debug, Serialize, Deserialize)] +pub struct State { + /// The Sparse Merkle Tree to store individual commitments + pub smt: SparseMerkleTree, + /// The Merkle Mountain Range to accumulate SMT roots + pub mmr: MerkleMountainRange, + /// Maps previous MMR roots to (SMT root, leaf index) pairs + pub root_indices: HashMap, + /// The previous MMR root + pub prev_mmr_root: HashDigest, +} + +/// Error type for `State::load_from_pg`. Distinguishes database errors +/// (connectivity, schema mismatch) from on-disk-blob corruption +/// (bincode rejected the SMT or MMR payload) so the bootstrap caller +/// can react accordingly. +#[derive(Debug)] +pub enum LoadStateError { + /// The Postgres call itself failed (connect, query, decode). + Db(sqlx::Error), + /// The SMT/MMR bincode blob in Postgres could not be deserialized. + Deserialize(bincode::Error), +} + +impl std::fmt::Display for LoadStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadStateError::Db(e) => write!(f, "database error: {}", e), + LoadStateError::Deserialize(e) => write!(f, "state blob deserialize: {}", e), + } + } +} + +impl std::error::Error for LoadStateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + LoadStateError::Db(e) => Some(e), + LoadStateError::Deserialize(e) => Some(e), + } + } +} + +impl From for LoadStateError { + fn from(e: sqlx::Error) -> Self { + LoadStateError::Db(e) + } +} + +impl From for LoadStateError { + fn from(e: bincode::Error) -> Self { + LoadStateError::Deserialize(e) + } +} + +impl State { + /// Creates a new state with an empty SMT of the default depth and an empty MMR. + pub fn new() -> Self { + State { + smt: SparseMerkleTree::new(), + mmr: MerkleMountainRange::new(), + root_indices: HashMap::new(), + prev_mmr_root: ZERO_HASH, + } + } + + /// Updates the state by inserting a set of commitments into the SMT, + /// then appending a new leaf to the MMR that combines the new SMT root + /// and the previous MMR root. + /// + /// Returns the new MMR root. + /// + /// After a successful call, the freshly-inserted `root_indices` + /// entry is uniquely identifiable as + /// `(self.prev_mmr_root, self.root_indices[&self.prev_mmr_root])` + /// — the function writes `self.prev_mmr_root` and inserts using the + /// same value as the map key, in that order, immediately before + /// `self.mmr.append`. Callers that need to persist this entry + /// (Phase C: `db::insert_root_index`) read it back from `self` + /// rather than threading a pool into this synchronous method, which + /// would force every test caller to grow a Postgres dependency. + pub fn update(&mut self, commitments: &[Commitment]) -> Result { + // 1. Insert all commitments into the SMT + for commitment in commitments { + // Use the public key as the key for the tree (hashed) + let key_bytes = commitment.public_key.serialize(); + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); + + // Store the BIP-340 message digest (32 raw bytes) reinterpreted + // as a Poseidon `HashOut` — `digest_from_bytes` is the + // canonical inverse of `digest_to_bytes` (round-trip safe). + let message_bytes = commitment.get_account_state_hash(); + let message_data = zkcoins_program::hash::digest_from_bytes(&message_bytes); + + // Update the SMT with just the message + self.smt.insert(key, message_data)?; + } + + // 2. Get the current SMT root + let smt_root = self.smt.root(); + + // 3. Create a new leaf that combines the SMT root and previous MMR + // root. Uses Poseidon `hash_concat` (architectural invariant: + // Poseidon everywhere in Merkle structures). Replaces the + // SP1-era SHA256. + // + // The previous MMR root is recorded in its *extended* form + // (`mmr.root_extended(MMR_PROOF_PATH_LEN)`) because every + // downstream consumer — the public output of a Plonky2 proof + // (`commitment_history_root` in `ProofData`), the in-circuit + // CMP sibling (`commitment_root_mmr_sibling`), and the + // `root_indices` lookup at the next AccountUpdate — works in + // the extended representation that the circuit's fixed-depth + // invariant demands. Using the natural root anywhere along + // that chain produces a hash that the circuit can't reconcile + // with the public input, surfacing as a witness-partition + // conflict at prove time. + let prev_mmr_root = self.mmr.root_extended(MMR_PROOF_PATH_LEN); + self.prev_mmr_root = prev_mmr_root; + + let leaf = hash_concat(&smt_root, &prev_mmr_root); + + let leaf_index = self.mmr.leaf_count(); + self.root_indices + .insert(prev_mmr_root, (smt_root, leaf_index)); + + // 4. Append the new leaf to the MMR + self.mmr.append(leaf); + + // 5. Return the new MMR root + Ok(self.mmr.root()) + } + + /// Gets an inclusion proof for a leaf in the MMR that was created with the given previous MMR root. + pub fn get_mmr_inclusion_proof( + &self, + prev_mmr_root: HashDigest, + ) -> Result<(HashDigest, MMRProof), &'static str> { + match self.root_indices.get(&prev_mmr_root) { + Some(&(smt_root, index)) => self.mmr.get_proof(index).map(|proof| (smt_root, proof)), + None => Err("Couldn't find MMR inclusion proof"), + } + } + + /// Gets an inclusion proof for a specific commitment in the SMT, + /// along with an inclusion proof of the current SMT root in the MMR. + pub fn get_commitment_proof( + &self, + public_key: &PublicKey, + ) -> Result<(HashDigest, InclusionProof, HashDigest, MMRProof), &'static str> { + let key_bytes = public_key.serialize(); + let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); + + let (smt_proof, commitment) = self.smt.generate_inclusion_proof(&key)?; + + let smt_root = self.smt.root(); + + let leaf_count = self.mmr.leaf_count(); + if leaf_count == 0 { + return Err("MMR leaf count = 0"); + } + let latest_leaf_index = leaf_count - 1; + + let mmr_proof = self.mmr.get_proof(latest_leaf_index)?; + + Ok((commitment, smt_proof, smt_root, mmr_proof)) + } + + /// Load the SMT and MMR blobs from Postgres and rebuild a `State`, + /// then rehydrate `root_indices` and `prev_mmr_root` from the + /// dedicated `mmr_root_index` table (migration `0004`). + /// + /// Phase C of the state-layer hardening series. Before this code + /// landed, `root_indices` was treated as a pure runtime memoization + /// and silently reset to empty on every restart — which broke any + /// account whose latest proof referenced a `commitment_history_root` + /// produced before the restart (`get_mmr_inclusion_proof` returned + /// `Err`, `/api/mint` surfaced 422 `Unable to get mmr inclusion + /// proof for the previous root`). The map is now persisted per + /// successful `update()` and rebuilt here. + /// + /// `prev_mmr_root` is restored from the highest-`leaf_index` entry + /// in the loaded map — that entry's KEY is precisely the value the + /// last successful `update()` wrote to `self.prev_mmr_root` + /// (`update` inserts using `prev_mmr_root` as the key and the + /// current `leaf_count` as the leaf_index, in that order, immediately + /// before `mmr.append`). On a fresh database the table is empty, + /// `root_indices` stays empty, and `prev_mmr_root` stays + /// `ZERO_HASH` exactly like `State::new`. + pub async fn load_from_pg(pool: &PgPool) -> Result { + let mut state = Self::new(); + if let Some(data) = db::load_smt(pool).await? { + state.smt = bincode::deserialize(&data)?; + } + if let Some(data) = db::load_mmr(pool).await? { + state.mmr = bincode::deserialize(&data)?; + } + let entries = db::load_root_indices(pool).await?; + // The DB ORDER BY leaf_index means `entries` is monotonic; the + // last element is the one whose KEY is the most recently written + // `prev_mmr_root`. Drain it in order, capturing the last KEY as + // we go so we don't have to re-scan the assembled HashMap. + let mut last_key: Option = None; + for (prev_root, smt_root, leaf_index) in entries { + // `leaf_index` is a `u64` from Postgres, non-negative by the + // load query's filter. The production target is 64-bit + // (Linux x86_64 / aarch64), so the cast to `usize` is + // infallible. + let leaf_usize = leaf_index as usize; + state.root_indices.insert(prev_root, (smt_root, leaf_usize)); + last_key = Some(prev_root); + } + if let Some(prev) = last_key { + state.prev_mmr_root = prev; + } + Ok(state) + } + + /// Apply `commitments` via [`State::update`] and capture the + /// snapshot tuple required to feed `db::persist_state_tx` on the + /// async side without holding the state lock across the await. + /// + /// Returns `(new_mmr_root, smt_bytes, mmr_bytes, root_index_entry)`: + /// * `new_mmr_root` is the value [`State::update`] returns (the + /// root of the MMR after the new leaf was appended). + /// * `smt_bytes` / `mmr_bytes` are the bincode blobs that go into + /// the `smt_state` / `mmr_state` singleton rows. + /// * `root_index_entry` is the freshly-inserted + /// `(prev_mmr_root, smt_root, leaf_index)` triple — recovered + /// from the live `root_indices` map under the same lock so the + /// caller does not need to repeat [`State::update`]'s internal + /// bookkeeping. `None` only on a serialize-side bincode error + /// propagated from [`Self::serialize_for_persist`]. + /// + /// This helper exists so the scanner-callback (`main.rs`) and the + /// new Phase-E synchronous in-process integration in + /// [`crate::router::mint_handler`] share a single source of truth + /// for "what bytes must I hand to `persist_state_tx` after a + /// successful update?". Both callers acquire the state lock, run + /// this method, drop the lock, then await `persist_state_tx` with + /// the returned tuple — keeping the `std::sync::Mutex` off the + /// `.await` while still letting `update` and `serialize_for_persist` + /// observe a consistent snapshot. + #[allow(clippy::type_complexity)] + pub fn update_and_snapshot_for_persist( + &mut self, + commitments: &[Commitment], + ) -> Result< + ( + HashDigest, + Vec, + Vec, + Option<(HashDigest, HashDigest, usize)>, + ), + &'static str, + > { + let new_root = self.update(commitments)?; + let root_index_entry = self + .root_indices + .get(&self.prev_mmr_root) + .copied() + .map(|(smt_root, leaf_index)| (self.prev_mmr_root, smt_root, leaf_index)); + let (smt_bytes, mmr_bytes) = self + .serialize_for_persist() + .map_err(|_| "state serialize_for_persist failed (bincode)")?; + Ok((new_root, smt_bytes, mmr_bytes, root_index_entry)) + } + + /// Serialize the SMT and MMR to bincode blobs for `persist_state_tx`. + /// + /// Returned tuple is `(smt_bytes, mmr_bytes)`. The caller is + /// expected to hand these straight to `db::persist_state_tx` + /// together with the corresponding block hash. + /// + /// `bincode::serialize` on these structures is infallible in + /// practice (no `Serialize` impl in the SMT/MMR trees returns Err), + /// but the error path is propagated as a `bincode::Error` rather + /// than panicked over so a future schema change that introduces a + /// fallible branch surfaces as a recoverable error. + pub fn serialize_for_persist(&self) -> Result<(Vec, Vec), bincode::Error> { + let smt_bytes = bincode::serialize(&self.smt)?; + let mmr_bytes = bincode::serialize(&self.mmr)?; + Ok((smt_bytes, mmr_bytes)) + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/node/src/state_tests.rs b/node/src/state_tests.rs new file mode 100644 index 00000000..eda89c4c --- /dev/null +++ b/node/src/state_tests.rs @@ -0,0 +1,908 @@ +use super::*; +use crate::db::{connect_and_migrate, insert_root_index, load_root_indices, persist_state_tx}; +use bitcoin::bip32::{ChildNumber, Xpub}; +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use bitcoin::Network; +use shared::SECP256K1; +use sqlx::PgPool; +use std::str::FromStr; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; +use zkcoins_program::circuit::main::MMR_PROOF_PATH_LEN; +use zkcoins_program::hash::{digest_from_bytes, hash_concat}; + +const HASH_SIZE: usize = 32; + +// Helper function to create a test commitment with a given message +fn create_test_commitment(message: &[u8], key_hex: &str) -> Commitment { + let _secp = Secp256k1::new(); + let secret_key = SecretKey::from_str(key_hex).expect("Invalid key"); + Commitment::new(&secret_key, message.to_vec()).expect("Failed to create commitment") +} + +/// Start a fresh `postgres:17` container and connect a migrated pool +/// to it. The container handle is returned alongside the pool so the +/// caller can keep it alive for the duration of the test — dropping +/// it tears the container down. +/// +/// This mirrors `db_tests::setup_pool` deliberately rather than +/// sharing a helper module; both files keep their setups inline so +/// each is independently runnable / readable. PR-A3 may dedupe into a +/// `test_db` helper once the PR-A2/A3 churn settles. +async fn setup_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +#[tokio::test] +async fn test_update_with_single_commitment() { + let mut state = State::new(); + + // Create a test commitment + let commitment = create_test_commitment( + b"test message", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + + // Update state with this commitment + let new_root = state.update(std::slice::from_ref(&commitment)).unwrap(); + + // The SMT should now contain this commitment + let key_bytes = commitment.public_key.serialize(); + let _key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); + + // The MMR should have one leaf now + assert_ne!(state.mmr.root(), ZERO_HASH); + assert_eq!(state.mmr.root(), new_root); +} + +#[tokio::test] +async fn test_update_with_multiple_commitments() { + let mut state = State::new(); + + // Create test commitments with different keys + let commitments = [ + create_test_commitment( + b"message 1", + "0000000000000000000000000000000000000000000000000000000000000001", + ), + create_test_commitment( + b"message 2", + "0000000000000000000000000000000000000000000000000000000000000002", + ), + create_test_commitment( + b"message 3", + "0000000000000000000000000000000000000000000000000000000000000003", + ), + ]; + + // First update with one commitment + let root1 = state.update(&[commitments[0].clone()]).unwrap(); + + // Then update with the other two + let root2 = state + .update(&[commitments[1].clone(), commitments[2].clone()]) + .unwrap(); + + // The roots should be different after each update + assert_ne!(root1, root2); + + // After the second update, the MMR should have two leaves + assert_eq!(state.mmr.root(), root2); +} + +#[tokio::test] +async fn test_persist_and_load_state_roundtrip() { + // Migration of the old `test_save_and_load_state`: persist via + // `db::persist_state_tx` and reload via `State::load_from_pg`. + // Roots must round-trip — that is the structural guarantee the + // file-based pair used to provide, now backed by an atomic + // BEGIN/COMMIT in Postgres (issue #11 fix). + let (pool, _container) = setup_pool().await; + + // Create and populate a state + let mut original_state = State::new(); + let commitments = vec![ + create_test_commitment( + b"message for save/load test", + "0000000000000000000000000000000000000000000000000000000000000004", + ), + create_test_commitment( + b"another message", + "0000000000000000000000000000000000000000000000000000000000000005", + ), + ]; + original_state.update(&commitments).unwrap(); + + // Serialize + persist atomically. + let (smt_bytes, mmr_bytes) = original_state.serialize_for_persist().unwrap(); + let block_hash = [0xABu8; 32]; + persist_state_tx(&pool, &smt_bytes, &mmr_bytes, &block_hash, None) + .await + .expect("persist_state_tx failed"); + + // Reload from Postgres. + let loaded_state = State::load_from_pg(&pool).await.expect("load_from_pg"); + + // Verify the loaded state has the same roots + assert_eq!(original_state.smt.root(), loaded_state.smt.root()); + assert_eq!(original_state.mmr.root(), loaded_state.mmr.root()); +} + +#[tokio::test] +async fn test_load_from_pg_empty_returns_fresh_state() { + // No rows in smt_state / mmr_state means a fresh server: both + // trees must come back empty — equivalent to State::new(). + let (pool, _container) = setup_pool().await; + let loaded = State::load_from_pg(&pool).await.expect("load_from_pg"); + let fresh = State::new(); + assert_eq!(loaded.smt.root(), fresh.smt.root()); + assert_eq!(loaded.mmr.root(), fresh.mmr.root()); + assert_eq!(loaded.prev_mmr_root, ZERO_HASH); + assert!(loaded.root_indices.is_empty()); +} + +#[tokio::test] +async fn test_load_from_pg_returns_err_on_corrupted_smt_blob() { + // The `Deserialize` branch of `LoadStateError`: insert a row whose + // bytes can never be decoded as a `SparseMerkleTree` and assert + // the loader surfaces that as `LoadStateError::Deserialize` rather + // than panicking or silently falling back to `State::new()`. + let (pool, _container) = setup_pool().await; + sqlx::query("INSERT INTO smt_state (id, data) VALUES (1, $1)") + .bind(vec![0xFFu8; 8]) + .execute(&pool) + .await + .unwrap(); + let err = State::load_from_pg(&pool) + .await + .expect_err("expected deserialize error"); + assert!( + matches!(err, crate::state::LoadStateError::Deserialize(_)), + "unexpected: {:?}", + err + ); + // Display + source: exercise the Error / Display impls so the + // 100% coverage gate stays green on the trait surface. + let msg = format!("{}", err); + assert!(msg.contains("state blob deserialize")); + assert!(std::error::Error::source(&err).is_some()); +} + +#[tokio::test] +async fn test_load_from_pg_returns_err_on_corrupted_mmr_blob() { + // Same as the SMT corruption test, but for the MMR row. + // Persist a valid SMT first so we exercise the second + // deserialize branch. + let (pool, _container) = setup_pool().await; + let empty_smt = bincode::serialize(&SparseMerkleTree::new()).unwrap(); + sqlx::query("INSERT INTO smt_state (id, data) VALUES (1, $1)") + .bind(empty_smt) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO mmr_state (id, data) VALUES (1, $1)") + .bind(vec![0xFFu8; 8]) + .execute(&pool) + .await + .unwrap(); + let err = State::load_from_pg(&pool) + .await + .expect_err("expected deserialize error"); + assert!( + matches!(err, crate::state::LoadStateError::Deserialize(_)), + "unexpected: {:?}", + err + ); +} + +#[tokio::test] +async fn test_load_from_pg_propagates_db_error() { + // Build a pool that connects to nothing, then call load_from_pg. + // The pool's first query attempt times out → `sqlx::Error` → our + // `LoadStateError::Db` variant. Covers the `From` + // and the `LoadStateError::Db` Display branch. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"); + let err = State::load_from_pg(&pool) + .await + .expect_err("expected db error"); + assert!( + matches!(err, crate::state::LoadStateError::Db(_)), + "unexpected: {:?}", + err + ); + let msg = format!("{}", err); + assert!(msg.contains("database error")); + assert!(std::error::Error::source(&err).is_some()); +} + +#[tokio::test] +async fn test_serialize_for_persist_roundtrip() { + // The serialize helper must produce blobs that load_from_pg + // accepts back. Belt-and-braces against any silent format drift + // between the two halves of the persistence layer. + let mut state = State::new(); + state + .update(&[create_test_commitment( + b"roundtrip", + "0000000000000000000000000000000000000000000000000000000000000006", + )]) + .unwrap(); + + let (pool, _container) = setup_pool().await; + let (smt_bytes, mmr_bytes) = state.serialize_for_persist().unwrap(); + persist_state_tx(&pool, &smt_bytes, &mmr_bytes, &[0u8; 32], None) + .await + .unwrap(); + let loaded = State::load_from_pg(&pool).await.unwrap(); + assert_eq!(loaded.smt.root(), state.smt.root()); + assert_eq!(loaded.mmr.root(), state.mmr.root()); +} + +#[tokio::test] +async fn test_sequential_updates_consistency() { + let mut state = State::new(); + + // Create several test commitments + let messages = [b"msg1", b"msg2", b"msg3", b"msg4", b"msg5"]; + let mut roots = Vec::new(); + + // Process commitments one by one and record roots + for (i, &msg) in messages.iter().enumerate() { + let key_hex = format!("{:064x}", i + 1); + let commitment = create_test_commitment(msg, &key_hex); + + let root = state.update(&[commitment]).unwrap(); + roots.push(root); + } + + // Verify that each update produced a different root + for i in 1..roots.len() { + assert_ne!( + roots[i - 1], + roots[i], + "Sequential updates should produce different roots" + ); + } + + // Verify that the final state has the expected root + assert_eq!(state.mmr.root(), *roots.last().unwrap()); +} + +#[tokio::test] +async fn test_get_commitment_proof_with_mmr() { + let mut state = State::new(); + + // Create test commitment + let commitment = create_test_commitment( + b"test message", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + + // Update state with this commitment + let mmr_root = state.update(std::slice::from_ref(&commitment)).unwrap(); + + // Get the complete proof (SMT + MMR). `.expect` itself asserts + // the Ok arm — a redundant `assert!(.is_ok())` before unwrap would + // double-emit on the same failure mode. + let (commitment_msg, smt_proof, smt_root, mmr_proof) = state + .get_commitment_proof(&commitment.public_key) + .expect("Should return a valid proof for existing commitment"); + + // Verify the message + assert_eq!( + commitment.message, + b"test message".to_vec(), + "Should return the correct message" + ); + + assert_ne!(smt_root, ZERO_HASH, "SMT root should not be zero"); + + // Verify MMR proof info + assert_eq!(mmr_proof.index, 0, "First update should be at leaf index 0"); + assert!( + !mmr_proof.path.is_empty(), + "MMR proof path should not be empty" + ); + + // Verify that the MMR root matches what was returned from update + assert_eq!( + state.mmr.root(), + mmr_root, + "MMR root should match what was returned from update" + ); + + assert!(smt_proof.verify(commitment_msg, smt_root)); + assert!(mmr_proof.verify(hash_concat(&smt_root, &state.prev_mmr_root), mmr_root)); +} + +#[tokio::test] +async fn test_reproduce_tree_verify() { + let mut state = State::new(); + + // Create test commitment + let _commitment = create_test_commitment( + &[1; HASH_SIZE], + "1000000000000000000000000000000000000000000000000000000000000000", + ); + + // Update state with this commitment + //let mmr_root = state.update(&[commitment.clone()]); + //let key_bytes = commitment.public_key.serialize(); + let key = [ + 127u8, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + //let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key).to_byte_array(); + //let mut smt = SparseMerkleTree::new(256); + let leaf = zkcoins_program::hash::digest_from_bytes(&[1; HASH_SIZE]); + state.smt.insert(key, leaf).unwrap(); + let root = state.smt.root(); + + //// Get the complete proof (SMT + MMR) + ////let proof_result = state.get_commitment_proof(&commitment.public_key); + + let proof_result = state.smt.generate_inclusion_proof(&key); + + let (smt_proof, _) = proof_result.unwrap(); + + assert!(smt_proof.verify(leaf, root)); +} + +#[tokio::test] +async fn test_get_commitment_proof_nonexistent() { + let mut state = State::new(); + + // Add a different commitment to the state + let existing_commitment = create_test_commitment( + b"existing message", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + state.update(&[existing_commitment]).unwrap(); + + // Try to get proof for a non-existent commitment + let non_existent = create_test_commitment( + b"non-existent message", + "0000000000000000000000000000000000000000000000000000000000000099", + ); + + let result = state.get_commitment_proof(&non_existent.public_key); + assert!( + result.is_err(), + "Should return Err for non-existent commitment" + ); +} + +#[tokio::test] +async fn test_get_commitment_proof_empty_mmr() { + let state = State::new(); + + // Create a commitment but don't add it to the state yet + let commitment = create_test_commitment( + b"test message", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + + // Try to get proof with empty MMR + let result = state.get_commitment_proof(&commitment.public_key); + assert!(result.is_err(), "Should return Err when MMR is empty"); +} + +#[tokio::test] +async fn test_get_commitment_proof_with_multiple_updates() { + let mut state = State::new(); + + // Create several test commitments + let messages = [b"msg1", b"msg2", b"msg3", b"msg4", b"msg5"]; + let mut roots = Vec::new(); + + // Process commitments one by one and record roots + for (i, &msg) in messages.iter().enumerate() { + let key_hex = format!("{:064x}", i + 1); + let commitment = create_test_commitment(msg, &key_hex); + + let root = state.update(&[commitment]).unwrap(); + roots.push(root); + } + + // Verify that each update produced a different root + for i in 1..roots.len() { + assert_ne!( + roots[i - 1], + roots[i], + "Sequential updates should produce different roots" + ); + } + + // Verify that the final state has the expected root + assert_eq!(state.mmr.root(), *roots.last().unwrap()); +} + +#[tokio::test] +async fn test_get_mmr_inclusion_proof_unknown_root_returns_err() { + // get_mmr_inclusion_proof must return Err when the previous MMR + // root passed in is not tracked in root_indices. + let state = State::new(); + let unknown_root = zkcoins_program::hash::digest_from_bytes(&[99u8; 32]); + let result = state.get_mmr_inclusion_proof(unknown_root); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_get_mmr_inclusion_proof_known_root_returns_ok() { + // After update(), root_indices maps the pre-update MMR root to a + // (smt_root, leaf_index) tuple — feeding that root back must + // return Ok and the leaf must verify against the post-update MMR + // root via the returned proof. The recorded root is the *extended* + // form (`root_extended(MMR_PROOF_PATH_LEN)`) so it matches what a + // Plonky2 proof commits as `commitment_history_root`. + let mut state = State::new(); + let pre_root = state.mmr.root_extended(MMR_PROOF_PATH_LEN); + + let commitment = create_test_commitment( + b"known-root test", + "0000000000000000000000000000000000000000000000000000000000000007", + ); + let _post_root = state.update(&[commitment]).expect("update"); + let post_root_extended = state.mmr.root_extended(MMR_PROOF_PATH_LEN); + + let (smt_root, proof) = state + .get_mmr_inclusion_proof(pre_root) + .expect("inclusion proof for known prev_mmr_root"); + let leaf = hash_concat(&smt_root, &pre_root); + let proof_extended = proof.extend_to(MMR_PROOF_PATH_LEN); + assert!(proof_extended.verify(leaf, post_root_extended)); +} + +#[tokio::test] +async fn test_get_commitment_proof_returns_err_when_smt_has_key_but_mmr_empty() { + // This inconsistent state cannot arise from normal operation + // (update() always grows both trees together) — it is reached + // only by loading mismatched on-disk state. The defensive guard + // in get_commitment_proof must return Err rather than panic on + // the leaf_count - 1 subtraction. + // + // In the Postgres world the equivalent inconsistent state is + // synthesized by persisting a non-empty SMT alongside an empty + // MMR directly, then reloading. + let (pool, _container) = setup_pool().await; + + let mut populated = State::new(); + let commitment = create_test_commitment( + b"mismatched scenario", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + populated.update(std::slice::from_ref(&commitment)).unwrap(); + + // Persist the populated SMT but an EMPTY MMR (overwrite the MMR + // row with the freshly-constructed empty tree). + let smt_bytes = bincode::serialize(&populated.smt).unwrap(); + let empty_mmr_bytes = bincode::serialize(&MerkleMountainRange::new()).unwrap(); + persist_state_tx(&pool, &smt_bytes, &empty_mmr_bytes, &[0u8; 32], None) + .await + .unwrap(); + + let mismatched = State::load_from_pg(&pool).await.unwrap(); + let result = mismatched.get_commitment_proof(&commitment.public_key); + assert!(result.is_err()); +} + +// ---- Phase C: mmr_root_index persistence ---------------------------------- + +/// Drive `State::update` N times and persist each step atomically via +/// the extended [`persist_state_tx`] (SMT/MMR/latest_block + +/// `mmr_root_index` in one transaction). Mirrors the production +/// scanner-callback shape after the Phase-C atomicity fix. +async fn populate_state_with_persistence(pool: &PgPool, count: usize) -> State { + let mut state = State::new(); + for i in 0..count { + let key_hex = format!("{:064x}", i + 1); + let commitment = create_test_commitment(format!("phase-c-{}", i).as_bytes(), &key_hex); + state.update(&[commitment]).expect("update"); + let (smt_root, leaf_index) = *state + .root_indices + .get(&state.prev_mmr_root) + .expect("update inserted root_indices entry keyed by prev_mmr_root"); + let (smt_bytes, mmr_bytes) = state.serialize_for_persist().unwrap(); + persist_state_tx( + pool, + &smt_bytes, + &mmr_bytes, + &[0u8; 32], + Some((&state.prev_mmr_root, &smt_root, leaf_index as u64)), + ) + .await + .expect("persist_state_tx"); + } + state +} + +#[tokio::test] +async fn test_root_indices_persist_and_load_roundtrip() { + // Drive a handful of updates with per-update persistence, drop the + // in-memory state, reload via `State::load_from_pg`, and assert + // that the HashMap content + `prev_mmr_root` round-trip. + let (pool, _container) = setup_pool().await; + let original = populate_state_with_persistence(&pool, 3).await; + + // Sanity: the in-memory map has exactly the number of updates we + // ran (each update inserts a fresh `prev_mmr_root` key because the + // MMR grows monotonically). + assert_eq!(original.root_indices.len(), 3); + let original_prev = original.prev_mmr_root; + let original_entries: Vec<(HashDigest, (HashDigest, usize))> = original + .root_indices + .iter() + .map(|(k, v)| (*k, *v)) + .collect(); + drop(original); + + let loaded = State::load_from_pg(&pool).await.expect("load_from_pg"); + assert_eq!(loaded.root_indices.len(), 3); + for (key, value) in &original_entries { + assert_eq!( + loaded.root_indices.get(key).copied(), + Some(*value), + "root_indices entry must round-trip" + ); + } + assert_eq!( + loaded.prev_mmr_root, original_prev, + "prev_mmr_root must be restored from the highest-leaf_index entry" + ); +} + +#[tokio::test] +async fn test_load_from_pg_with_empty_root_index_table_yields_empty_map() { + // Fresh DB: the table exists but has no rows. `load_from_pg` must + // succeed and leave `root_indices` empty + `prev_mmr_root` at + // `ZERO_HASH` (matches `State::new`). + let (pool, _container) = setup_pool().await; + let loaded = State::load_from_pg(&pool).await.expect("load_from_pg"); + assert!(loaded.root_indices.is_empty()); + assert_eq!(loaded.prev_mmr_root, ZERO_HASH); +} + +#[tokio::test] +async fn test_get_mmr_inclusion_proof_after_restart_succeeds() { + // The original bug: a container restart cleared `root_indices`, so + // any account whose latest proof referenced a `commitment_history_ + // root` from BEFORE the restart hit + // `get_mmr_inclusion_proof -> Err`, and `/api/mint` surfaced 422 + // `Unable to get mmr inclusion proof for the previous root`. + // + // After Phase C, every entry persisted by `insert_root_index` is + // rebuilt by `load_from_pg`, so each historical + // `prev_mmr_root` must resolve to a valid `(smt_root, MMRProof)` + // tuple on the reloaded state. Belt-and-braces: also verify the + // returned proof against the post-update MMR root in extended form + // (matches what a Plonky2 proof commits as `commitment_history_root`). + let (pool, _container) = setup_pool().await; + + // Capture each pre-update `prev_mmr_root` during the populate run. + let mut prev_roots: Vec = Vec::new(); + let mut state = State::new(); + let n = 4; + for i in 0..n { + let pre_root = state.mmr.root_extended(MMR_PROOF_PATH_LEN); + prev_roots.push(pre_root); + + let key_hex = format!("{:064x}", i + 10); + let commitment = create_test_commitment(format!("restart-test-{}", i).as_bytes(), &key_hex); + state.update(&[commitment]).expect("update"); + let (smt_root, leaf_index) = *state + .root_indices + .get(&state.prev_mmr_root) + .expect("update inserted root_indices entry"); + let (smt_bytes, mmr_bytes) = state.serialize_for_persist().unwrap(); + persist_state_tx( + &pool, + &smt_bytes, + &mmr_bytes, + &[0u8; 32], + Some((&state.prev_mmr_root, &smt_root, leaf_index as u64)), + ) + .await + .expect("persist_state_tx"); + } + let final_mmr_root_extended = state.mmr.root_extended(MMR_PROOF_PATH_LEN); + drop(state); + + // "Restart" — load a fresh State from the same pool. + let restarted = State::load_from_pg(&pool).await.expect("load_from_pg"); + assert_eq!(restarted.root_indices.len(), n); + + for (i, prev_root) in prev_roots.iter().enumerate() { + let (smt_root, proof) = restarted + .get_mmr_inclusion_proof(*prev_root) + .unwrap_or_else(|e| { + panic!( + "historical prev_mmr_root {} must resolve after restart, got Err({})", + i, e + ) + }); + let leaf = hash_concat(&smt_root, prev_root); + let proof_extended = proof.extend_to(MMR_PROOF_PATH_LEN); + assert!( + proof_extended.verify(leaf, final_mmr_root_extended), + "restored proof must verify against the loaded MMR root (entry {})", + i + ); + } +} + +#[tokio::test] +async fn test_load_root_indices_rejects_short_prev_root_blob() { + // Defensive decode branch in `load_root_indices`: a manually- + // inserted row whose `prev_mmr_root` BYTEA is not 32 bytes must + // surface as `sqlx::Error::Decode` rather than panicking on the + // `try_into::<[u8; 32]>()`. + let (pool, _container) = setup_pool().await; + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index) \ + VALUES ($1, $2, $3)", + ) + .bind(&vec![0xAAu8; 8][..]) + .bind(&vec![0xBBu8; 32][..]) + .bind(0_i64) + .execute(&pool) + .await + .unwrap(); + let err = load_root_indices(&pool) + .await + .expect_err("expected decode error on short prev_mmr_root"); + let msg = format!("{}", err); + assert!(msg.contains("prev_mmr_root"), "unexpected: {}", msg); +} + +#[tokio::test] +async fn test_load_root_indices_rejects_short_smt_root_blob() { + // Same defensive branch, for the `smt_root` column. + let (pool, _container) = setup_pool().await; + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index) \ + VALUES ($1, $2, $3)", + ) + .bind(&vec![0xAAu8; 32][..]) + .bind(&vec![0xBBu8; 8][..]) + .bind(0_i64) + .execute(&pool) + .await + .unwrap(); + let err = load_root_indices(&pool) + .await + .expect_err("expected decode error on short smt_root"); + let msg = format!("{}", err); + assert!(msg.contains("smt_root"), "unexpected: {}", msg); +} + +#[tokio::test] +async fn test_load_root_indices_rejects_negative_leaf_index() { + // Defensive branch in `load_root_indices`: BIGINT is signed and the + // column has no CHECK constraint, so a manual operator INSERT could + // plant a negative value. Surface as decode error. + // + // ALSO covers the matching `load_from_pg` -> `LoadStateError::Db` + // path: the error is wrapped in `LoadStateError::Db` because + // `load_root_indices` returns `sqlx::Error` and the `From` impl on + // `LoadStateError` re-wraps it. + let (pool, _container) = setup_pool().await; + sqlx::query( + "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index) \ + VALUES ($1, $2, $3)", + ) + .bind(&vec![0xAAu8; 32][..]) + .bind(&vec![0xBBu8; 32][..]) + .bind(-1_i64) + .execute(&pool) + .await + .unwrap(); + let err = load_root_indices(&pool) + .await + .expect_err("expected decode error on negative leaf_index"); + let msg = format!("{}", err); + assert!(msg.contains("leaf_index"), "unexpected: {}", msg); + + // And the matching `State::load_from_pg` surface — must arrive as + // `LoadStateError::Db` (the `From` branch). + let err = State::load_from_pg(&pool) + .await + .expect_err("expected db error from load_from_pg"); + assert!( + matches!(err, crate::state::LoadStateError::Db(_)), + "unexpected: {:?}", + err + ); +} + +#[tokio::test] +async fn test_insert_root_index_is_idempotent_on_conflict() { + // Single-row insert is `ON CONFLICT DO NOTHING` — re-issuing the + // same `prev_mmr_root` must not error and must not duplicate. + let (pool, _container) = setup_pool().await; + let prev = digest_from_bytes(&[1u8; 32]); + let smt = digest_from_bytes(&[2u8; 32]); + insert_root_index(&pool, &prev, &smt, 0) + .await + .expect("first insert"); + insert_root_index(&pool, &prev, &smt, 0) + .await + .expect("second insert (idempotent)"); + let loaded = load_root_indices(&pool).await.unwrap(); + assert_eq!(loaded.len(), 1); +} + +// ---- derive_num_pubkeys_from_smt (Phase D) -------------------------------- + +/// Derive the BIP-32 child pubkey at `index` from `xpriv` using the same +/// derivation path the production [`derive_num_pubkeys_from_smt`] walks. +/// Test-only helper so each membership setup builds the exact same key +/// bytes the production code will subsequently look up. +fn derive_pk(xpriv: &Xpriv, index: u32) -> bitcoin::secp256k1::PublicKey { + Xpub::from_priv(&SECP256K1, xpriv) + .derive_pub(&SECP256K1, &[ChildNumber::Normal { index }]) + .expect("derive_pub") + .public_key +} + +/// SMT key for a pubkey, matching [`State::update`]'s +/// `sha256(public_key.serialize())` convention. +fn smt_key_for_pk(pk: &bitcoin::secp256k1::PublicKey) -> [u8; 32] { + bitcoin::hashes::sha256::Hash::hash(&pk.serialize()).to_byte_array() +} + +/// Empty SMT → no minting pubkey has been issued yet. +#[test] +fn derive_num_pubkeys_from_smt_empty_returns_zero() { + let xpriv = Xpriv::new_master(Network::Signet, &[7u8; 32]).expect("xpriv"); + let smt = SparseMerkleTree::new(); + assert_eq!(derive_num_pubkeys_from_smt(&xpriv, &smt), 0); +} + +/// SMT contains `pk_0, pk_1, …, pk_{N-1}` → derive returns N. +/// +/// Covers the "found at index N" branch of the algorithm: every loop +/// iteration up to `n = N - 1` finds the key in the SMT and `continue`s, +/// the `n = N` iteration misses and returns. Drives a small N (3) so the +/// test stays fast — the branch under test is invariant in N. +#[test] +fn derive_num_pubkeys_from_smt_returns_first_missing_index() { + let xpriv = Xpriv::new_master(Network::Signet, &[11u8; 32]).expect("xpriv"); + let mut smt = SparseMerkleTree::new(); + // Stuff in pk_0, pk_1, pk_2. Value bytes are arbitrary — the + // derive function only checks key presence, not leaf value. + for n in 0..3u32 { + let pk = derive_pk(&xpriv, n); + let key = smt_key_for_pk(&pk); + let dummy_value = digest_from_bytes(&[(n + 1) as u8; 32]); + smt.insert(key, dummy_value).expect("smt insert"); + } + assert_eq!(derive_num_pubkeys_from_smt(&xpriv, &smt), 3); +} + +/// Two distinct minting wallets writing into the same SMT don't +/// contaminate each other's derived counts: each `xpriv` walks its own +/// branch and stops at its own first miss. +#[test] +fn derive_num_pubkeys_from_smt_is_xpriv_scoped() { + let xpriv_a = Xpriv::new_master(Network::Signet, &[1u8; 32]).expect("xpriv a"); + let xpriv_b = Xpriv::new_master(Network::Signet, &[2u8; 32]).expect("xpriv b"); + let mut smt = SparseMerkleTree::new(); + // Insert pk_0 from xpriv_a only. + let pk_a0 = derive_pk(&xpriv_a, 0); + smt.insert(smt_key_for_pk(&pk_a0), digest_from_bytes(&[9u8; 32])) + .expect("smt insert"); + assert_eq!(derive_num_pubkeys_from_smt(&xpriv_a, &smt), 1); + assert_eq!(derive_num_pubkeys_from_smt(&xpriv_b, &smt), 0); +} + +// ---- Phase E: update_and_snapshot_for_persist ------------------------------ + +/// Happy path: `update_and_snapshot_for_persist` applies the same +/// mutations as `update`, returns the same new MMR root, and produces +/// snapshot bytes that round-trip through `bincode::deserialize` to the +/// in-memory SMT/MMR. The freshly-inserted `root_index_entry` matches +/// `state.prev_mmr_root` → `(smt_root, leaf_index)`. +#[test] +fn update_and_snapshot_for_persist_emits_bytes_and_root_index_entry() { + let mut state = State::new(); + let commitment = create_test_commitment( + b"phase-e test", + "0000000000000000000000000000000000000000000000000000000000000007", + ); + + let (new_root, smt_bytes, mmr_bytes, root_index_entry) = state + .update_and_snapshot_for_persist(std::slice::from_ref(&commitment)) + .expect("update_and_snapshot_for_persist must succeed"); + + assert_eq!(state.mmr.root(), new_root); + // The root_index entry's key is the freshly written prev_mmr_root, + // and the (smt_root, leaf_index) tuple comes from the SMT/MMR + // post-update. + let (prev_root, smt_root, leaf_index) = + root_index_entry.expect("a fresh update must emit a root_index entry"); + assert_eq!(prev_root, state.prev_mmr_root); + assert_eq!(smt_root, state.smt.root()); + assert_eq!(leaf_index, state.mmr.leaf_count() - 1); + + // Snapshot bytes round-trip to the same in-memory shape. + let smt_back: SparseMerkleTree = bincode::deserialize(&smt_bytes).expect("smt deserialize"); + let mmr_back: MerkleMountainRange = bincode::deserialize(&mmr_bytes).expect("mmr deserialize"); + assert_eq!(smt_back.root(), state.smt.root()); + assert_eq!(mmr_back.root(), state.mmr.root()); +} + +/// Error propagation: `update_and_snapshot_for_persist` surfaces the +/// SMT's `"Key already exists in the tree with different value"` error +/// when the same public key is inserted twice with distinct messages. +/// This is the in-memory equivalent of the cross-handler concurrent +/// mint race that Phase E's STATE_ADVANCE step relies on the tolerant +/// log branch to handle. +#[test] +fn update_and_snapshot_for_persist_propagates_smt_collision() { + let mut state = State::new(); + let first = create_test_commitment( + b"first", + "0000000000000000000000000000000000000000000000000000000000000008", + ); + state + .update_and_snapshot_for_persist(std::slice::from_ref(&first)) + .expect("first update"); + + // Same key, different leaf value → SMT collision. + let second = create_test_commitment( + b"second", + "0000000000000000000000000000000000000000000000000000000000000008", + ); + let err = state + .update_and_snapshot_for_persist(std::slice::from_ref(&second)) + .expect_err("colliding commitment must surface an error"); + assert!( + err.contains("Key already exists"), + "unexpected error string: {}", + err + ); +} + +/// Loop-bound panic: every index up to and including `bound` is in the +/// SMT → the next iteration hits `n >= bound` and panics. Exercises the +/// safety-net branch of the algorithm; uses the +/// `derive_num_pubkeys_from_smt_with_bound` inner with a tiny bound so +/// the SMT setup is fast (a million real BIP-32 derivations would take +/// minutes). +#[test] +#[should_panic(expected = "loop bound is a safety net")] +fn derive_num_pubkeys_from_smt_panics_on_loop_bound_exceeded() { + let xpriv = Xpriv::new_master(Network::Signet, &[33u8; 32]).expect("xpriv"); + let mut smt = SparseMerkleTree::new(); + // Fill the SMT with pk_0..=pk_BOUND so the loop never finds a miss. + const BOUND: u32 = 3; + for n in 0..=BOUND + 1 { + let pk = derive_pk(&xpriv, n); + let key = smt_key_for_pk(&pk); + smt.insert(key, digest_from_bytes(&[(n as u8).wrapping_add(1); 32])) + .expect("smt insert"); + } + let _ = derive_num_pubkeys_from_smt_with_bound(&xpriv, &smt, BOUND); +} diff --git a/node/src/username.rs b/node/src/username.rs new file mode 100644 index 00000000..06e0752f --- /dev/null +++ b/node/src/username.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; +use shared::Address; +use sqlx::PgPool; +use std::collections::HashMap; + +use crate::db; +use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes}; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct UsernameStore { + usernames: HashMap, +} + +impl UsernameStore { + /// Test-only after PR-A3 — the production bootstrap calls + /// `load_from_pg`. Kept because every store-touching test + /// constructs a known-empty store via `new()`. + #[cfg_attr(not(test), allow(dead_code))] + pub fn new() -> Self { + Self::default() + } + + /// Test-only sync helper: insert a `(normalized_name, address)` + /// pair directly into the in-memory map, bypassing both the + /// validation rules and the Postgres round-trip. Production code + /// must go through `claim` so the SQL `ON CONFLICT DO NOTHING` + /// boundary catches races; tests that just need a pre-populated + /// store (for handler smoke tests, concurrent-read tests, etc.) + /// use this to avoid bringing up a testcontainer per test. + #[cfg(test)] + pub(crate) fn insert_for_test(&mut self, normalized_name: &str, address: Address) { + self.usernames.insert(normalized_name.to_string(), address); + } + + /// Validate `username` against the public charset rules. Pulled + /// out of `claim` so the same checks can run at the SQL boundary + /// without a duplicate copy of the rules, and so the + /// `claim_username` handler can normalise the value once at entry + /// — the Schnorr signature hash and the persisted name then agree + /// on the exact byte string, ruling out a case-mismatch squat. + /// + /// Returns the normalized (lowercased) name on success. + pub(crate) fn validate(username: &str) -> Result { + let normalized = username.to_lowercase(); + if normalized.is_empty() || normalized.len() > 64 { + return Err("Username must be 1-64 characters"); + } + if !normalized + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err("Username may only contain a-z, 0-9, -, _, ."); + } + Ok(normalized) + } + + /// Synchronous pre-flight check against the in-memory mirror. The + /// claim handler runs this under a short `std::sync::Mutex` guard, + /// then drops the guard before the DB round-trip — so concurrent + /// `resolve` / `get_username` reads never observe a blank store + /// while a claim is mid-flight (the bug the previous `mem::take` + /// approach surfaced). + /// + /// Returns a 4xx-shaped validation message on collision. The + /// dedicated `&'static str` return — rather than the broader + /// `ClaimUsernameError` — keeps the handler's error mapping a flat + /// `Result<(), &'static str>` with no unreachable `Db` arm; that + /// would otherwise read as dead code under the 100 % coverage gate. + pub(crate) fn precheck(&self, normalized: &str, address: &Address) -> Result<(), &'static str> { + if self.usernames.contains_key(normalized) { + return Err("Username already taken"); + } + if self.usernames.values().any(|a| a == address) { + return Err("Address already has a username"); + } + Ok(()) + } + + /// In-memory commit that runs after the DB `ON CONFLICT DO NOTHING` + /// has reported `rows_affected == 1`. Held under the same short + /// sync guard as `precheck` would be — no `.await` inside, no + /// `mem::take`, the store is never observable as empty. + pub(crate) fn commit_after_db(&mut self, normalized: String, address: Address) { + self.usernames.insert(normalized, address); + } + + /// Claim `username` for `address`, persisting to Postgres + /// atomically via `db::claim_username`'s `ON CONFLICT DO NOTHING` + /// path. On success the in-memory mirror is updated too so + /// subsequent `resolve` / `get_username` calls don't have to + /// round-trip to the database. + /// + /// The "address already has a username" check is enforced at the + /// in-memory level only — the database schema permits multiple + /// names per address by design (a future product change might + /// allow aliasing) and the application-level rule is the + /// authoritative one for the MVP. + /// + /// This convenience wrapper composes `validate` + `precheck` + + /// `db::claim_username` + `commit_after_db` so the unit tests can + /// drive the full pipeline in one call. The production + /// `claim_username_handler` calls the steps directly because it + /// must not hold a `std::sync::Mutex` guard across the async DB + /// round-trip. + pub async fn claim( + &mut self, + pool: &PgPool, + username: &str, + address: Address, + ) -> Result<(), ClaimUsernameError> { + let normalized = Self::validate(username).map_err(ClaimUsernameError::Validation)?; + self.precheck(&normalized, &address) + .map_err(ClaimUsernameError::Validation)?; + + let addr_bytes = digest_to_bytes(&address); + let inserted = db::claim_username(pool, &normalized, &addr_bytes).await?; + if !inserted { + // The SQL layer caught a race against another process / + // worker that claimed the name between the in-memory check + // above and this insert. Surface as the same string the + // in-memory check would have produced. + return Err(ClaimUsernameError::Validation("Username already taken")); + } + + self.commit_after_db(normalized, address); + Ok(()) + } + + pub fn resolve(&self, username: &str) -> Option
{ + self.usernames.get(&username.to_lowercase()).copied() + } + + pub fn get_username(&self, address: &Address) -> Option<&str> { + self.usernames + .iter() + .find(|(_, a)| *a == address) + .map(|(name, _)| name.as_str()) + } + + /// Rebuild a `UsernameStore` from the `usernames` table. + /// + /// The full table is read into memory at boot so subsequent + /// `resolve` / `get_username` calls — the hot read path — answer + /// locally. The table is small (one row per registered user) and + /// only grows through the `claim_username` endpoint, so the memory + /// footprint is bounded. + pub async fn load_from_pg(pool: &PgPool) -> Result { + let rows = db::load_all_usernames(pool).await?; + let mut usernames: HashMap = HashMap::with_capacity(rows.len()); + for (name, addr_bytes) in rows { + let addr_arr: [u8; 32] = addr_bytes + .as_slice() + .try_into() + .map_err(|_| LoadUsernameStoreError::BadAddressLength(addr_bytes.len()))?; + usernames.insert(name, digest_from_bytes(&addr_arr)); + } + Ok(UsernameStore { usernames }) + } +} + +/// Error type for `UsernameStore::claim`. Wraps the validation error +/// strings (returned to the API caller as a 4xx body) and any database +/// error from the underlying `db::claim_username` upsert. +#[derive(Debug)] +pub enum ClaimUsernameError { + /// Caller-fixable input rejection (charset, length, duplicate). + Validation(&'static str), + /// The Postgres `INSERT ... ON CONFLICT DO NOTHING` failed for a + /// reason other than a name conflict (connect, transaction). + Db(sqlx::Error), +} + +impl std::fmt::Display for ClaimUsernameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClaimUsernameError::Validation(s) => write!(f, "{}", s), + ClaimUsernameError::Db(e) => write!(f, "database error: {}", e), + } + } +} + +impl std::error::Error for ClaimUsernameError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClaimUsernameError::Validation(_) => None, + ClaimUsernameError::Db(e) => Some(e), + } + } +} + +impl From for ClaimUsernameError { + fn from(e: sqlx::Error) -> Self { + ClaimUsernameError::Db(e) + } +} + +/// Error type for `UsernameStore::load_from_pg`. Same split as +/// `state::LoadStateError` and `account_node::LoadAccountNodeError` +/// — bootstrap callers branch on these. +#[derive(Debug)] +pub enum LoadUsernameStoreError { + /// The Postgres call itself failed (connect, query, decode). + Db(sqlx::Error), + /// A row's `address` column was not the expected 32 bytes. + BadAddressLength(usize), +} + +impl std::fmt::Display for LoadUsernameStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadUsernameStoreError::Db(e) => write!(f, "database error: {}", e), + LoadUsernameStoreError::BadAddressLength(n) => write!( + f, + "usernames.address has unexpected length {} (expected 32)", + n + ), + } + } +} + +impl std::error::Error for LoadUsernameStoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + LoadUsernameStoreError::Db(e) => Some(e), + LoadUsernameStoreError::BadAddressLength(_) => None, + } + } +} + +impl From for LoadUsernameStoreError { + fn from(e: sqlx::Error) -> Self { + LoadUsernameStoreError::Db(e) + } +} + +#[cfg(test)] +#[path = "username_tests.rs"] +mod tests; diff --git a/node/src/username_tests.rs b/node/src/username_tests.rs new file mode 100644 index 00000000..ac5407af --- /dev/null +++ b/node/src/username_tests.rs @@ -0,0 +1,265 @@ +// UsernameStore tests for the Postgres-backed `claim` / `load_from_pg` +// implementation (PR-A3). Mirrors the testcontainer + per-test fresh +// schema pattern used in `db_tests.rs` and `state_tests.rs` — each +// test gets its own `postgres:17` container so there is no shared +// state to clean up between tests. + +use super::*; +use sqlx::PgPool; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; +use zkcoins_program::hash::digest_from_bytes; + +use crate::db::connect_and_migrate; + +/// Test helper: byte literal → Poseidon `HashDigest = HashOut`. +fn addr(seed: u8) -> Address { + digest_from_bytes(&[seed; 32]) +} + +/// Mirror of `db_tests::setup_pool`: per-test container, isolated +/// schema, dropped when the container handle drops. The duplication +/// is intentional — see the comment in `state_tests.rs::setup_pool` +/// for the rationale (each test module stays independently runnable +/// and readable). +async fn setup_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +#[tokio::test] +async fn claim_and_resolve_persists_via_pg() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + let address = addr(1); + + store + .claim(&pool, "Alice", address) + .await + .expect("claim ok"); + assert_eq!(store.resolve("alice"), Some(address)); + assert_eq!(store.resolve("Alice"), Some(address)); + assert_eq!(store.get_username(&address), Some("alice")); + + // The row must round-trip via load_from_pg. + let reloaded = UsernameStore::load_from_pg(&pool) + .await + .expect("load_from_pg"); + assert_eq!(reloaded.resolve("alice"), Some(address)); + assert_eq!(reloaded.get_username(&address), Some("alice")); +} + +#[tokio::test] +async fn duplicate_username_rejected_with_validation() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + store.claim(&pool, "alice", addr(1)).await.unwrap(); + let err = store + .claim(&pool, "alice", addr(2)) + .await + .expect_err("expected duplicate rejection"); + assert!(matches!(err, ClaimUsernameError::Validation(_))); + assert!(format!("{}", err).contains("Username already taken")); +} + +#[tokio::test] +async fn duplicate_address_rejected_with_validation() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + let address = addr(1); + store.claim(&pool, "alice", address).await.unwrap(); + let err = store + .claim(&pool, "bob", address) + .await + .expect_err("expected duplicate rejection"); + assert!(matches!(err, ClaimUsernameError::Validation(_))); + assert!(format!("{}", err).contains("Address already has a username")); +} + +#[tokio::test] +async fn invalid_username_rejected() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + assert!(store.claim(&pool, "", addr(1)).await.is_err()); + assert!(store.claim(&pool, "hello world", addr(2)).await.is_err()); + assert!(store.claim(&pool, "hello@world", addr(3)).await.is_err()); + assert!(store.claim(&pool, &"a".repeat(65), addr(4)).await.is_err()); +} + +#[tokio::test] +async fn valid_usernames_accepted() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + store.claim(&pool, "alice", addr(1)).await.unwrap(); + store.claim(&pool, "bob-99", addr(2)).await.unwrap(); + store.claim(&pool, "carol_x", addr(3)).await.unwrap(); + store.claim(&pool, "dave.btc", addr(4)).await.unwrap(); +} + +#[tokio::test] +async fn resolve_is_case_insensitive() { + let (pool, _container) = setup_pool().await; + let mut store = UsernameStore::new(); + let address = addr(5); + store.claim(&pool, "Alice", address).await.unwrap(); + + assert_eq!(store.resolve("alice"), Some(address)); + assert_eq!(store.resolve("ALICE"), Some(address)); + assert_eq!(store.resolve("Alice"), Some(address)); + assert_eq!(store.resolve("aLiCe"), Some(address)); +} + +#[tokio::test] +async fn get_username_returns_none_for_unknown() { + let store = UsernameStore::new(); + let unknown_address = addr(99); + assert_eq!(store.get_username(&unknown_address), None); +} + +#[tokio::test] +async fn load_from_pg_returns_empty_initially() { + let (pool, _container) = setup_pool().await; + let store = UsernameStore::load_from_pg(&pool).await.expect("load ok"); + assert_eq!(store.resolve("alice"), None); + assert_eq!(store.get_username(&addr(1)), None); +} + +#[tokio::test] +async fn claim_propagates_db_error_when_pool_is_dead() { + // Lazy pool that never connects → claim returns Db error. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"); + let mut store = UsernameStore::new(); + let err = store + .claim(&pool, "alice", addr(1)) + .await + .expect_err("expected db error"); + assert!( + matches!(err, ClaimUsernameError::Db(_)), + "unexpected: {:?}", + err + ); + let msg = format!("{}", err); + assert!(msg.contains("database error")); + assert!(std::error::Error::source(&err).is_some()); + // After a DB-side failure the in-memory mirror must NOT be updated; + // a later retry should be able to claim the same name once the DB + // is reachable again. + assert_eq!(store.resolve("alice"), None); +} + +#[tokio::test] +async fn load_from_pg_propagates_db_error() { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:postgres@127.0.0.1:1/postgres") + .expect("connect_lazy never fails"); + let err = UsernameStore::load_from_pg(&pool) + .await + .expect_err("expected db error"); + assert!( + matches!(err, LoadUsernameStoreError::Db(_)), + "unexpected: {:?}", + err + ); + let msg = format!("{}", err); + assert!(msg.contains("database error")); + assert!(std::error::Error::source(&err).is_some()); +} + +#[tokio::test] +async fn load_from_pg_rejects_wrong_address_length() { + // Plant a row with an out-of-spec 7-byte address directly via SQL. + // The schema (`BYTEA NOT NULL`) is intentionally permissive; the + // application layer is the authoritative check, so the loader must + // surface the mismatch as a typed error rather than panic on the + // try_into. + let (pool, _container) = setup_pool().await; + sqlx::query("INSERT INTO usernames (name, address) VALUES ($1, $2)") + .bind("alice") + .bind(vec![0u8; 7]) + .execute(&pool) + .await + .unwrap(); + let err = UsernameStore::load_from_pg(&pool) + .await + .expect_err("expected bad-address length"); + assert!( + matches!(err, LoadUsernameStoreError::BadAddressLength(7)), + "unexpected: {:?}", + err + ); + // Exercise the Display + Error::source paths on both variants. + let msg = format!("{}", err); + assert!(msg.contains("expected 32")); + assert!(std::error::Error::source(&err).is_none()); +} + +#[test] +fn validation_error_display_passes_through_message() { + let err = ClaimUsernameError::Validation("Username must be 1-64 characters"); + assert_eq!(format!("{}", err), "Username must be 1-64 characters"); + assert!(std::error::Error::source(&err).is_none()); +} + +/// Simulate a race between the in-memory pre-check and the SQL +/// `ON CONFLICT DO NOTHING` boundary: another writer (here, a direct +/// SQL insert that bypasses the in-memory mirror) claims the name +/// first, so when `claim` reaches the database the row already exists. +/// `db::claim_username` then returns `inserted = false`, and `claim` +/// must surface the same "Username already taken" Validation error +/// as the in-memory pre-check would have produced. This is the +/// branch that wraps the SQL-layer race fallback in `username.rs`. +#[tokio::test] +async fn claim_falls_back_to_validation_when_sql_layer_catches_race() { + let (pool, _container) = setup_pool().await; + + // Plant the row directly via SQL so `UsernameStore::new()`'s + // in-memory map stays empty — the in-memory `contains_key` check + // will pass, and execution will flow into `db::claim_username` + // where Postgres' `ON CONFLICT DO NOTHING` will return 0 rows + // affected. + sqlx::query("INSERT INTO usernames (name, address) VALUES ($1, $2)") + .bind("alice") + .bind(vec![1u8; 32]) + .execute(&pool) + .await + .unwrap(); + + let mut store = UsernameStore::new(); + let err = store + .claim(&pool, "alice", addr(2)) + .await + .expect_err("expected sql-race Validation error"); + assert!( + matches!(err, ClaimUsernameError::Validation(_)), + "unexpected: {:?}", + err + ); + assert!(format!("{}", err).contains("Username already taken")); + + // The in-memory mirror must NOT have been updated when the SQL + // layer rejected the claim — otherwise a follow-up resolve would + // bind the name to the wrong address. + assert_eq!(store.resolve("alice"), None); +} diff --git a/node/tests/api_remote.rs b/node/tests/api_remote.rs new file mode 100644 index 00000000..a44d7161 --- /dev/null +++ b/node/tests/api_remote.rs @@ -0,0 +1,1357 @@ +//! HTTP API end-to-end test suite for the deployed zkCoins server. +//! +//! This suite is the functional counterpart to the smoke test inside +//! `.github/workflows/deploy-dev.yaml` (which only probes `/api/info`). +//! Where the smoke test answers "is the listener bound?", this suite +//! answers "do all 15 routes behave as documented?". It signs real +//! Schnorr commitments with freshly-generated wallets, mints coins, +//! sends them, commits the resulting state, and claims a username — +//! exercising the API contract happy path against the same backend +//! the wallet app talks to. +//! +//! Scope note: the suite verifies server-visible behaviour (status +//! codes, response shapes, balance movements). The commit message +//! format used in `send_commit_roundtrip_moves_balance` is the +//! 64-byte `ash || ocr` raw concat, which the server accepts via +//! `Commitment::verify`'s SHA-256 fallback. The canonical wallet +//! client signs the 32-byte Poseidon `hash_concat(ash, ocr)` digest +//! (see `shared::ClientAccount::create_commitment`); the two forms +//! produce different SMT leaves but both pass the signature check, +//! and the suite never re-spends from the test wallet so the leaf +//! shape is observationally indistinguishable in-scope. +//! +//! The DEV server is shared by other workflows (per-PR app E2E, +//! interactive testing). To keep this suite race-free we always: +//! - mint into freshly-generated wallets (no fixed addresses) +//! - assert strictly on 4xx codes (client-fixable contract bugs) +//! - assert strictly on 5xx codes as well (server-side regressions +//! are real bugs, not flakes — the deploy-dev preflight verifies +//! publisher wallet + /health/ready BEFORE this suite runs, so a +//! 503 here is unambiguous: it means something regressed) +//! +//! Read by: +//! - `cargo test -p node --release --test api_remote` (locally) +//! - the `api-e2e` job in `deploy-dev.yaml` after `build-and-deploy` +//! +//! Configuration: +//! - `ZKCOINS_API_URL` (default `https://dev-api.zkcoins.app`) — +//! the base URL of the server under test. + +use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; +use bitcoin::secp256k1::{self as secp, Keypair, Message, PublicKey, SecretKey}; +use bitcoin::Network; +use node::account_node::CoinProof; +use node::router::Capabilities; +use rand::RngCore; +use reqwest::StatusCode; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use shared::commitment::Commitment; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use zkcoins_program::hash::digest_to_bytes; +use zkcoins_program::types::MINTING_ADDRESS; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_API_URL: &str = "https://dev-api.zkcoins.app"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(120); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const POLL_TIMEOUT: Duration = Duration::from_secs(60); +const MINT_AMOUNT: u64 = 50_000; +const SEND_AMOUNT: u64 = 10_000; +/// Bootstrap balance seeded into the `MINTING_ADDRESS` account at +/// startup by `start_rest_node` (see `node::runtime`). +/// Must stay strictly less than `2^48` for Plonky2 Goldilocks safety +/// — see the matching constant guard in `runtime_tests`. The +/// happy-path roundtrips probe `/api/balance` on `MINTING_ADDRESS` +/// before their first mint and use this as an upper bound — +/// `0 < balance <= BOOTSTRAP_MINTING_BALANCE`. The exact value is +/// not asserted because the deploy-dev push trigger does not run +/// `reset_state`, so prior test residue legitimately reduces the +/// minting balance; the bound still catches a fully empty / negative +/// state. +const BOOTSTRAP_MINTING_BALANCE: u64 = 1u64 << 48; + +fn api_base() -> String { + std::env::var("ZKCOINS_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .expect("build reqwest client") +} + +fn url(path: &str) -> String { + format!("{}{}", api_base().trim_end_matches('/'), path) +} + +/// Helper: log a one-line "feature off" skip and return. +/// +/// When running in CI (env `CI=true`) this is a hard panic instead of +/// a silent skip: CI is supposed to build with `--all-features`, so a +/// `feature_skip!` firing in CI is the canary for an accidentally +/// dropped `--all-features` flag in a workflow (e.g. someone copied +/// the local `cargo test` invocation into the workflow). Outside CI +/// the macro is still a skip — the suite is also runnable against a +/// feature-trimmed PRD deploy, where an absent route is expected. +/// +/// Escape hatch: setting `ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER` +/// (any value, even empty) downgrades the CI panic back to a silent +/// skip. The dev-api / prd-api Docker images intentionally ship the +/// MVP-only feature set (`Dockerfile` `ARG FEATURES=`), so when the +/// suite runs `--all-features` against a feature-trimmed *server* +/// the gated `address_list` / `lnurl` tests must skip cleanly instead +/// of panicking the CI canary. The env var documents this as an +/// opt-in: workflows that point the suite at a trimmed server set it, +/// workflows that point it at a fully-featured server leave it unset +/// so the canary stays armed. +macro_rules! feature_skip { + ($feature:expr, $test:expr) => {{ + let allow_trimmed_server = + std::env::var("ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER").is_ok(); + if std::env::var("CI").is_ok() && !allow_trimmed_server { + panic!( + "feature `{}` disabled but running in CI — all-features build is required \ + (set ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER=1 if the target server is \ + intentionally feature-trimmed, e.g. the MVP-only DEV image)", + $feature + ); + } + eprintln!( + "SKIP {}: feature `{}` disabled on this server", + $test, $feature + ); + return; + }}; +} + +// --------------------------------------------------------------------------- +// Capability detection +// +// Mint (`/api/mint`) and the username routes (`/api/username/claim`, +// `/api/username/resolve/:u`) are part of the MVP and are always +// present, so they are no longer gated here. The remaining post-MVP +// routes (`address-list`, `lnurl`) are still optional: the default +// deploy ships without them and the axum fallback answers 404 instead +// of the per-handler error codes. We fetch `/api/info` once per gated +// test, deserialise the well-known `Capabilities` shape, and skip the +// rest of the test if the relevant feature flag is `false`. +// +// `ZKCOINS_FORCE_DISABLE_FEATURES` (comma-separated list, e.g. +// `address_list,lnurl`) overrides any flag returned by the server +// to `false`. This is the local dry-run hook — point the suite at the +// live DEV server, force features off, and confirm that every gated +// test prints `SKIP …` instead of hitting a disabled-on-paper but +// actually-running endpoint. Forcing `faucet` or `usernames` off is a +// no-op (the routes are always registered) and the flags are ignored. +// --------------------------------------------------------------------------- + +async fn fetch_capabilities(client: &reqwest::Client) -> Capabilities { + let resp = client + .get(url("/api/info")) + .send() + .await + .expect("GET /api/info for capability detection"); + assert_eq!( + resp.status(), + StatusCode::OK, + "/api/info must answer 200 — required for capability detection" + ); + // We deserialise into a transient Value first so the override hook + // can flip booleans without round-tripping through the strongly + // typed `Capabilities` (which has no setters). + let body: Value = resp + .json() + .await + .expect("/api/info body is JSON for capability detection"); + // Each capability field MUST be a bool — a missing field or a + // non-bool value is a contract regression in `/api/info` and a + // `.unwrap_or(false)` would silently mask it as "feature off". + let mut caps = Capabilities { + address_list: body["capabilities"]["address_list"].as_bool().expect( + "/api/info capabilities.address_list must be a bool — missing field is a contract regression", + ), + faucet: body["capabilities"]["faucet"].as_bool().expect( + "/api/info capabilities.faucet must be a bool — missing field is a contract regression", + ), + usernames: body["capabilities"]["usernames"].as_bool().expect( + "/api/info capabilities.usernames must be a bool — missing field is a contract regression", + ), + lnurl: body["capabilities"]["lnurl"].as_bool().expect( + "/api/info capabilities.lnurl must be a bool — missing field is a contract regression", + ), + }; + if let Ok(force) = std::env::var("ZKCOINS_FORCE_DISABLE_FEATURES") { + for flag in force.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { + match flag { + "address_list" | "address-list" => caps.address_list = false, + "faucet" => { + // Mint is permanent MVP. The route is always + // registered, so forcing it "off" cannot disable + // it — log + ignore to keep callers honest. + eprintln!( + "ZKCOINS_FORCE_DISABLE_FEATURES: `faucet` is permanent MVP — ignored" + ); + } + "usernames" => { + // Usernames are permanent MVP — same shape as `faucet`. + eprintln!( + "ZKCOINS_FORCE_DISABLE_FEATURES: `usernames` is permanent MVP — ignored" + ); + } + "lnurl" => caps.lnurl = false, + other => { + eprintln!( + "ZKCOINS_FORCE_DISABLE_FEATURES: unknown flag `{}` — ignored", + other + ); + } + } + } + } + caps +} + +// --------------------------------------------------------------------------- +// TestWallet — fresh-per-test random key + helpers for signing the four +// request shapes the server accepts (send / commit / username-claim). +// --------------------------------------------------------------------------- + +struct TestWallet { + xpriv: Xpriv, + secp: secp::Secp256k1, +} + +impl TestWallet { + fn new() -> Self { + let mut seed = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut seed); + // Signet matches the mutinynet flavour the DEV server runs on; + // the network choice only affects xpub serialisation prefixes, + // not the derived secp256k1 keys we sign with. + let xpriv = Xpriv::new_master(Network::Signet, &seed).expect("derive xpriv from seed"); + Self { + xpriv, + secp: secp::Secp256k1::new(), + } + } + + /// Normal-child secret key at index `i`. Matches the convention + /// used by `shared::ClientAccount::generate_public_key`. + fn seckey(&self, idx: u32) -> SecretKey { + self.xpriv + .derive_priv(&self.secp, &[ChildNumber::Normal { index: idx }]) + .expect("derive private key") + .private_key + } + + fn pubkey(&self, idx: u32) -> PublicKey { + Xpub::from_priv(&self.secp, &self.xpriv) + .derive_pub(&self.secp, &[ChildNumber::Normal { index: idx }]) + .expect("derive public key") + .public_key + } + + fn keypair(&self, idx: u32) -> Keypair { + Keypair::from_secret_key(&self.secp, &self.seckey(idx)) + } + + /// The hex address that the server treats as the account identifier. + /// Mirrors `shared::AccountState::new` → `sha256(compressed_pubkey)`. + fn address_hex(&self) -> String { + let pk = self.pubkey(0); + let digest: [u8; 32] = Sha256::digest(pk.serialize()).into(); + format!("0x{}", hex::encode(digest)) + } + + /// Sign the canonical send-request preimage: + /// `SHA256(account_address_str || recipient_str || amount_le8 || timestamp_le8)`. + fn sign_send( + &self, + account_address: &str, + recipient: &str, + amount: u64, + timestamp: u64, + ) -> String { + let mut hasher = Sha256::new(); + hasher.update(account_address.as_bytes()); + hasher.update(recipient.as_bytes()); + hasher.update(amount.to_le_bytes()); + hasher.update(timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let sig = self.secp.sign_schnorr_no_aux_rand(&msg, &self.keypair(0)); + hex::encode(sig.as_ref()) + } + + /// Sign the commit message: the BIP-340 Schnorr signature is + /// produced by `Commitment::new`, which SHA256s any non-32-byte + /// payload before signing. The server reconstructs the + /// `Commitment` struct from `(public_key, signature, message)` + /// and re-verifies it the same way. + fn sign_commit(&self, message_bytes: &[u8]) -> String { + let commitment = Commitment::new(&self.seckey(0), message_bytes.to_vec()) + .expect("Commitment::new from random secret"); + hex::encode(commitment.signature.as_ref()) + } + + /// Sign the username-claim preimage: + /// `SHA256("zkcoins:claim_username" || address_hex_str || normalised_username_str || timestamp_le8)`. + /// + /// The server canonicalises the username with `to_lowercase()` + /// before hashing; wallets must sign over the same lowercase form + /// or verification fails. The helper mirrors that to keep the + /// signature path honest end-to-end. + fn sign_username_claim(&self, address_hex: &str, username: &str, timestamp: u64) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(username.to_lowercase().as_bytes()); + hasher.update(timestamp.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let sig = self.secp.sign_schnorr_no_aux_rand(&msg, &self.keypair(0)); + hex::encode(sig.as_ref()) + } +} + +// --------------------------------------------------------------------------- +// Section 1 — read-only endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn root_returns_service_metadata() { + let resp = http_client().get(url("/")).send().await.expect("GET /"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("root body is JSON"); + assert_eq!(body["service"], "zkcoins-node"); + assert!(body["version"].as_str().is_some_and(|v| !v.is_empty())); + assert!(body["network"].as_str().is_some_and(|v| !v.is_empty())); + assert!(body["endpoints"]["info"].is_string()); +} + +#[tokio::test] +async fn health_returns_ok() { + let resp = http_client() + .get(url("/health")) + .send() + .await + .expect("GET /health"); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.text().await.expect("read body"); + assert_eq!(body, "ok"); +} + +#[tokio::test] +async fn health_ready_returns_ready_with_no_failures() { + let resp = http_client() + .get(url("/health/ready")) + .send() + .await + .expect("GET /health/ready"); + let status = resp.status(); + let body: Value = resp.json().await.expect("/health/ready body is JSON"); + assert_eq!( + status, + StatusCode::OK, + "/health/ready must return 200 — failures: {:?}", + body["failures"] + ); + assert_eq!(body["ready"], Value::Bool(true)); + let failures = body["failures"].as_array().expect("failures is an array"); + assert!( + failures.is_empty(), + "expected no failures, got {:?}", + failures + ); +} + +#[tokio::test] +async fn info_returns_well_formed_response() { + // Shape-only check: the MVP deploy may run with zero features and + // PRD may differ from DEV, so the only invariant we assert is the + // contract — `/api/info` returns a well-formed `InfoResponse` with + // a non-empty `network`, a non-empty `username_domain`, and four + // boolean capability flags. The per-feature `true`/`false` + // expectations live in the gated tests below, which short-circuit + // through `fetch_capabilities`. + let resp = http_client() + .get(url("/api/info")) + .send() + .await + .expect("GET /api/info"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("/api/info body is JSON"); + + assert!( + body["network"].as_str().is_some_and(|v| !v.is_empty()), + "network must be a non-empty string, got {:?}", + body["network"] + ); + assert!( + body["username_domain"] + .as_str() + .is_some_and(|v| !v.is_empty()), + "username_domain must be a non-empty string, got {:?}", + body["username_domain"] + ); + + for cap in ["address_list", "faucet", "usernames", "lnurl"] { + assert!( + body["capabilities"][cap].is_boolean(), + "capability `{cap}` must be a bool, got {:?}", + body["capabilities"][cap] + ); + } +} + +/// Shape-only probe of `/health/publisher` — the JSON contract is +/// asserted here so the suite breaks if the field set changes, even +/// when the publisher wallet itself is empty (the deploy-dev +/// preflight separately enforces a non-zero UTXO count). 200 is +/// required: an Esplora-side error surfaces as 503 and we want that +/// to fail the suite, not be silently tolerated. +#[tokio::test] +async fn health_publisher_returns_well_formed_response() { + let resp = http_client() + .get(url("/health/publisher")) + .send() + .await + .expect("GET /health/publisher"); + assert_eq!( + resp.status(), + StatusCode::OK, + "/health/publisher must return 200 — anything else means Esplora is unreachable or the publisher route regressed" + ); + let body: Value = resp.json().await.expect("/health/publisher body is JSON"); + assert!( + body["address"].as_str().is_some_and(|v| !v.is_empty()), + "publisher address must be a non-empty string, got {:?}", + body["address"] + ); + assert!( + body["utxo_count"].as_u64().is_some(), + "utxo_count must be a u64, got {:?}", + body["utxo_count"] + ); + assert!( + body["total_sats"].as_u64().is_some(), + "total_sats must be a u64, got {:?}", + body["total_sats"] + ); +} + +#[tokio::test] +async fn balance_unknown_address_returns_ok_with_zero() { + let address = format!("0x{}", "00".repeat(32)); + let resp = http_client() + .get(url(&format!("/api/balance?address={}", address))) + .send() + .await + .expect("GET /api/balance"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["balance"], 0); +} + +#[tokio::test] +async fn balance_missing_param_returns_422() { + let resp = http_client() + .get(url("/api/balance")) + .send() + .await + .expect("GET /api/balance (no params)"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn balance_invalid_hex_returns_422() { + let resp = http_client() + .get(url("/api/balance?address=not_hex")) + .send() + .await + .expect("GET /api/balance (bad hex)"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn balance_wrong_length_returns_422() { + // 16 bytes = 32 hex chars, the handler requires exactly 32 bytes + let address = format!("0x{}", "ab".repeat(16)); + let resp = http_client() + .get(url(&format!("/api/balance?address={}", address))) + .send() + .await + .expect("GET /api/balance (short hex)"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn address_list_returns_addresses() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.address_list { + feature_skip!("address_list", "address_list_returns_addresses"); + } + let resp = client + .get(url("/api/address")) + .send() + .await + .expect("GET /api/address"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("body JSON"); + let addresses = body["addresses"].as_array().expect("addresses is an array"); + assert!(!addresses.is_empty(), "address list must not be empty"); + for addr in addresses { + let s = addr.as_str().expect("address entry is a string"); + assert!(s.starts_with("0x"), "address must be 0x-prefixed: {}", s); + // 0x + 64 hex chars = 66 chars + assert_eq!(s.len(), 66, "address must be 32 bytes: {}", s); + } +} + +#[tokio::test] +async fn proof_for_huge_id_returns_404() { + // u64::MAX is guaranteed to exceed any real proof_id the server + // has issued, so the file-on-disk lookup misses and returns 404. + let resp = http_client() + .get(url(&format!("/api/proof/{}", u64::MAX))) + .send() + .await + .expect("GET /api/proof/"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn resolve_unknown_username_returns_404() { + let client = http_client(); + let resp = client + .get(url("/api/username/resolve/definitely_not_claimed_xyzzy")) + .send() + .await + .expect("GET /api/username/resolve/"); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "expected 404 for unknown username, got {}", + resp.status() + ); +} + +#[tokio::test] +async fn lnurlp_unknown_user_returns_404() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.lnurl { + feature_skip!("lnurl", "lnurlp_unknown_user_returns_404"); + } + let resp = client + .get(url("/.well-known/lnurlp/definitely_not_claimed_xyzzy")) + .send() + .await + .expect("GET /.well-known/lnurlp/"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn lnurl_pay_callback_returns_phase2_stub() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.lnurl { + feature_skip!("lnurl", "lnurl_pay_callback_returns_phase2_stub"); + } + let resp = client + .get(url("/lnurl/pay/anyone")) + .send() + .await + .expect("GET /lnurl/pay/anyone"); + // The lnurl callback returns Json directly (no error wrapping), so + // it always answers 200 with a body that says "Phase 2". + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["status"], "ERROR"); + assert!( + body["reason"] + .as_str() + .is_some_and(|s| s.contains("Phase 2")), + "expected Phase 2 stub, got {:?}", + body["reason"] + ); +} + +#[tokio::test] +async fn fallback_unknown_route_returns_404() { + let resp = http_client() + .get(url("/api/nonsense")) + .send() + .await + .expect("GET /api/nonsense"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// Section 2 — negative-path POSTs (no roundtrip required) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn mint_empty_body_returns_422() { + let resp = http_client() + .post(url("/api/mint")) + .json(&json!({})) + .send() + .await + .expect("POST /api/mint {}"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn mint_invalid_hex_address_returns_422() { + let resp = http_client() + .post(url("/api/mint")) + .json(&json!({"account_address": "not_hex", "amount": 100})) + .send() + .await + .expect("POST /api/mint bad hex"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn mint_wrong_address_length_returns_422() { + // 16 bytes = 32 hex chars — short of the required 32 bytes + let short_addr = format!("0x{}", "ab".repeat(16)); + let resp = http_client() + .post(url("/api/mint")) + .json(&json!({"account_address": short_addr, "amount": 100})) + .send() + .await + .expect("POST /api/mint short addr"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_empty_body_returns_422() { + let resp = http_client() + .post(url("/api/send")) + .json(&json!({})) + .send() + .await + .expect("POST /api/send {}"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_bad_address_hex_returns_422() { + // All required fields present, but account_address is not valid hex + // — this should fail at the hex-decode step (handler-level 422, + // not axum-level deserialization 422). + let alice = TestWallet::new(); + let body = json!({ + "account_address": "0xZZZZZZ", + "recipient": alice.address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Option::::None, + "timestamp": Option::::None, + }); + let resp = http_client() + .post(url("/api/send")) + .json(&body) + .send() + .await + .expect("POST /api/send bad hex"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn send_unknown_account_returns_404() { + // Well-formed body, valid signatures, but the sender account has + // no balance / state on the server, so `send_coins` returns + // "Unknown account address" → 404. + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let amount: u64 = 1; + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, ts); + + let body = json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some(signature), + "timestamp": Some(ts), + }); + let resp = http_client() + .post(url("/api/send")) + .json(&body) + .send() + .await + .expect("POST /api/send unknown account"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn send_bad_signature_returns_401() { + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let body = json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some("00".repeat(64)), + "timestamp": Some(unix_now()), + }); + let resp = http_client() + .post(url("/api/send")) + .json(&body) + .send() + .await + .expect("POST /api/send bad sig"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn send_stale_timestamp_returns_401() { + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let amount: u64 = 1; + // Timestamp ten minutes in the past — outside the 5-minute window. + let stale_ts = unix_now().saturating_sub(600); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, stale_ts); + let body = json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some(signature), + "timestamp": Some(stale_ts), + }); + let resp = http_client() + .post(url("/api/send")) + .json(&body) + .send() + .await + .expect("POST /api/send stale ts"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn receive_empty_body_returns_default_failure() { + let resp = http_client() + .post(url("/api/receive")) + .body(Vec::::new()) + .send() + .await + .expect("POST /api/receive empty"); + // Handler swallows bincode errors and returns Json(SendCoinResponse::default()) = 200. + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["success"], Value::Bool(false)); +} + +#[tokio::test] +async fn receive_garbage_body_returns_default_failure() { + let garbage = vec![0xFFu8; 64]; + let resp = http_client() + .post(url("/api/receive")) + .body(garbage) + .send() + .await + .expect("POST /api/receive garbage"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["success"], Value::Bool(false)); +} + +#[tokio::test] +async fn commit_unknown_proof_id_returns_404() { + let alice = TestWallet::new(); + // The handler validates the proof_id BEFORE hex decoding, so any + // syntactically valid body works as long as proof_id is unknown. + let body = json!({ + "proof_id": u64::MAX, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": "00".repeat(64), + "message": "00".repeat(64), + }); + let resp = http_client() + .post(url("/api/commit")) + .json(&body) + .send() + .await + .expect("POST /api/commit unknown id"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn commit_bad_message_hex_returns_422_or_404() { + let alice = TestWallet::new(); + // proof_id=1 may or may not exist on the server. If it exists, the + // handler reaches the hex-decode step and returns 422. If not, the + // proof-store miss short-circuits at 404. Both are acceptable for + // this negative-path coverage. + let body = json!({ + "proof_id": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": "00".repeat(64), + "message": "not_valid_hex_zzz", + }); + let resp = http_client() + .post(url("/api/commit")) + .json(&body) + .send() + .await + .expect("POST /api/commit bad message"); + let status = resp.status(); + assert!( + status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::NOT_FOUND, + "expected 422 or 404, got {}", + status + ); +} + +#[tokio::test] +async fn claim_username_pk_mismatch_returns_401() { + let client = http_client(); + let alice = TestWallet::new(); + let mallory = TestWallet::new(); + let username = format!("mallory_{}", random_suffix()); + let ts = unix_now(); + // Sign with mallory's key but claim alice's address — the + // sha256(pk) == address check fails. + let signature = mallory.sign_username_claim(&alice.address_hex(), &username, ts); + let body = json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(mallory.pubkey(0).serialize()), + "signature": signature, + "timestamp": ts, + }); + let resp = client + .post(url("/api/username/claim")) + .json(&body) + .send() + .await + .expect("POST /api/username/claim mismatch"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn claim_username_bad_signature_returns_401() { + let client = http_client(); + let alice = TestWallet::new(); + let username = format!("alice_{}", random_suffix()); + let body = json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": "00".repeat(64), + "timestamp": unix_now(), + }); + let resp = client + .post(url("/api/username/claim")) + .json(&body) + .send() + .await + .expect("POST /api/username/claim bad sig"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn claim_username_stale_timestamp_returns_401() { + let client = http_client(); + let alice = TestWallet::new(); + let username = format!("alice_{}", random_suffix()); + let stale_ts = unix_now().saturating_sub(600); + let signature = alice.sign_username_claim(&alice.address_hex(), &username, stale_ts); + let body = json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": signature, + "timestamp": stale_ts, + }); + let resp = client + .post(url("/api/username/claim")) + .json(&body) + .send() + .await + .expect("POST /api/username/claim stale"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// Section 3 — happy-path roundtrips against the deployed server +// --------------------------------------------------------------------------- + +/// Roundtrip A — mint into a fresh wallet and observe the balance. +/// +/// Reads the proof_id back via `GET /api/proof/{id}` and deserializes +/// it as a `CoinProof` so the side-effect (write to the proofs/ +/// directory) is visible to the test as well. +#[tokio::test] +async fn mint_roundtrip_lands_balance_and_proof() { + let client = http_client(); + let alice = TestWallet::new(); + + // Minting-account sanity guard: the deploy-dev workflow's + // `push: branches: [develop]` trigger does NOT run + // `reset-zkcoins-node`, so the minting balance is allowed to be + // anywhere in (0, BOOTSTRAP_MINTING_BALANCE]. We only fail hard + // on the genuinely impossible states (balance > bootstrap = code + // regression or unauthorized re-seed; balance == 0 = unexpected + // DB wipe). See `assert_minting_balance_in_bounds` for details. + assert_minting_balance_in_bounds(&client).await; + + let mint_resp = client + .post(url("/api/mint")) + .json(&json!({ + "account_address": alice.address_hex(), + "amount": MINT_AMOUNT, + })) + .send() + .await + .expect("POST /api/mint"); + let mint_status = mint_resp.status(); + assert_eq!(mint_status, StatusCode::OK, "unexpected mint status"); + let mint_body: Value = mint_resp.json().await.expect("mint body JSON"); + assert_eq!( + mint_body["success"], + Value::Bool(true), + "mint not successful: {}", + mint_body + ); + let proof_id = mint_body["proof_id"].as_u64().expect("proof_id present"); + + // Poll the balance endpoint until the credit shows up. + let observed = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + assert!( + observed >= MINT_AMOUNT, + "balance never reached mint amount; got {observed}" + ); + + // Verify the proof file is fetchable + bincode-decodable. + let proof_resp = client + .get(url(&format!("/api/proof/{}", proof_id))) + .send() + .await + .expect("GET /api/proof"); + assert_eq!(proof_resp.status(), StatusCode::OK); + let proof_bytes = proof_resp.bytes().await.expect("proof bytes"); + let coin_proof: CoinProof = + bincode::deserialize(&proof_bytes).expect("decode CoinProof bincode"); + assert!( + coin_proof.commitment.is_some(), + "mint coin proof should carry a server-signed commitment" + ); + assert_eq!(coin_proof.coin.amount, MINT_AMOUNT); +} + +/// Roundtrip B — full mint → send → commit pipeline. +/// +/// The send half requires the previous commitment's signing key as +/// `prev_commitment_pubkey`. After a mint that's the server's minting +/// pubkey, embedded in the mint's `CoinProof.commitment`. +#[tokio::test] +async fn send_commit_roundtrip_moves_balance() { + let client = http_client(); + let alice = TestWallet::new(); + let bob = TestWallet::new(); + + // Minting-account sanity guard — mirror of the one in + // `mint_roundtrip_lands_balance_and_proof`. The deploy-dev + // workflow's `push: branches: [develop]` trigger does NOT run + // `reset-zkcoins-node`, so we cannot pin the minting balance to + // an exact value (or even a small accept-set keyed off + // `MINT_AMOUNT`): the balance accumulates `bootstrap - N*MINT_AMOUNT` + // across every prior develop push that ran this suite. The + // bounds-check still catches the impossible / catastrophic states + // (balance > bootstrap = code regression or unauthorized re-seed; + // balance == 0 = unexpected DB wipe). + assert_minting_balance_in_bounds(&client).await; + + // ---- Mint ---- + // Post-#87 the scanner is event-driven (Esplora WS subscription), + // so by the time `mint_roundtrip_lands_balance_and_proof` returns + // 200 and writes alice-1's balance, the prior commitment is + // already at-most-one-block away from being indexed in the SMT. + // A `422 Unable to get merkle proofs` here is therefore a real + // scanner-side regression, not a benign timing flake — the + // previous PR-83-era retry loop is gone. Asserting `== 200` + // surfaces it. + let mint_resp = client + .post(url("/api/mint")) + .json(&json!({ + "account_address": alice.address_hex(), + "amount": MINT_AMOUNT, + })) + .send() + .await + .expect("POST /api/mint"); + let mint_status = mint_resp.status(); + let mint_body_text = mint_resp.text().await.unwrap_or_default(); + assert_eq!( + mint_status, + StatusCode::OK, + "mint failed: {} body={}", + mint_status, + mint_body_text + ); + let mint_body: Value = serde_json::from_str(&mint_body_text).expect("mint body JSON"); + let mint_proof_id = mint_body["proof_id"].as_u64().expect("proof_id"); + + // Wait for the balance to settle so send_coins has something to spend. + let balance_before = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + assert!( + balance_before >= MINT_AMOUNT, + "scanner never observed mint after MINT_AMOUNT={} (saw {})", + MINT_AMOUNT, + balance_before + ); + + // ---- Fetch the mint's CoinProof to discover prev_commitment_pubkey ---- + let proof_resp = client + .get(url(&format!("/api/proof/{}", mint_proof_id))) + .send() + .await + .expect("GET mint proof"); + assert_eq!(proof_resp.status(), StatusCode::OK); + let proof_bytes = proof_resp.bytes().await.expect("mint proof bytes"); + let mint_coin_proof: CoinProof = bincode::deserialize(&proof_bytes).expect("decode CoinProof"); + let prev_pk = mint_coin_proof + .commitment + .as_ref() + .expect("mint coin proof has commitment") + .public_key; + + // (No second poll needed — `poll_balance_at_least` above already + // observed alice.balance >= MINT_AMOUNT; the inscription is therefore + // on-chain and the scanner has ingested it. Removing the redundant + // 15-s wait shaves test runtime without losing signal — if the + // scanner regresses, the FIRST wait will fail.) + + // ---- Send ---- + let amount = SEND_AMOUNT; + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, ts); + let send_body = json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": hex::encode(prev_pk.serialize()), + "signature": signature, + "timestamp": ts, + }); + let send_resp = client + .post(url("/api/send")) + .json(&send_body) + .send() + .await + .expect("POST /api/send"); + let send_status = send_resp.status(); + let send_body_text = send_resp.text().await.unwrap_or_default(); + assert_eq!( + send_status, + StatusCode::OK, + "send failed: {} body={}", + send_status, + send_body_text + ); + let send_body: Value = serde_json::from_str(&send_body_text).expect("send body JSON"); + assert_eq!(send_body["success"], Value::Bool(true)); + let send_proof_id = send_body["proof_id"].as_u64().expect("send proof_id"); + + // Value-bearing assertions on the response payload: each hash + // field must decode to exactly 32 bytes and be non-zero. A + // shape-only `.is_some()` check was masking server bugs that + // returned a placeholder zero-hash or a truncated hex string. + let ash_hex = send_body["account_state_hash"] + .as_str() + .expect("account_state_hash present") + .to_string(); + let ash_bytes = hex::decode(&ash_hex).expect("ash is hex"); + assert_eq!(ash_bytes.len(), 32, "account_state_hash must be 32 bytes"); + assert!( + ash_bytes.iter().any(|&b| b != 0), + "account_state_hash must be non-zero" + ); + let ocr_hex = send_body["output_coins_root"] + .as_str() + .expect("output_coins_root present") + .to_string(); + let ocr_bytes = hex::decode(&ocr_hex).expect("ocr is hex"); + assert_eq!(ocr_bytes.len(), 32, "output_coins_root must be 32 bytes"); + assert!( + ocr_bytes.iter().any(|&b| b != 0), + "output_coins_root must be non-zero" + ); + assert!(send_proof_id > 0, "proof_id must be a positive u64"); + + // ---- Commit ---- + let mut commit_message = Vec::with_capacity(64); + commit_message.extend_from_slice(&ash_bytes); + commit_message.extend_from_slice(&ocr_bytes); + let commit_sig = alice.sign_commit(&commit_message); + + let commit_body = json!({ + "proof_id": send_proof_id, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": commit_sig, + "message": hex::encode(&commit_message), + }); + let commit_resp = client + .post(url("/api/commit")) + .json(&commit_body) + .send() + .await + .expect("POST /api/commit"); + let commit_status = commit_resp.status(); + assert_eq!( + commit_status, + StatusCode::OK, + "commit failed: {}", + commit_status + ); + let commit_body_resp: Value = commit_resp.json().await.expect("commit body"); + assert_eq!(commit_body_resp["success"], Value::Bool(true)); + + // ---- Balance decreased ---- + let final_balance = + poll_balance_at_most(&client, &alice.address_hex(), balance_before - amount).await; + assert!( + final_balance <= balance_before - amount, + "balance never decreased after commit: before={}, after={}", + balance_before, + final_balance + ); +} + +/// Roundtrip C — claim a username, resolve it, then hit the LNURLp +/// endpoint that depends on the username being resolvable. +#[tokio::test] +async fn username_claim_resolve_lnurlp_roundtrip() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + // Claim + resolve are permanent MVP. The LNURLp leg still depends + // on the `lnurl` Cargo feature — if it's off we skip the whole + // cascade because the trailing well-known probe cannot succeed. + if !caps.lnurl { + feature_skip!("lnurl", "username_claim_resolve_lnurlp_roundtrip"); + } + let alice = TestWallet::new(); + let username = format!("e2e_{}", random_suffix()); + let ts = unix_now(); + let signature = alice.sign_username_claim(&alice.address_hex(), &username, ts); + + let claim_resp = client + .post(url("/api/username/claim")) + .json(&json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": signature, + "timestamp": ts, + })) + .send() + .await + .expect("POST /api/username/claim"); + let claim_status = claim_resp.status(); + // DB availability is covered separately by `/health/ready`'s `db` + // failure tag; a 503 here means the username claim path itself + // regressed and is treated as a hard failure (no `dev_skip!`). + assert_eq!( + claim_status, + StatusCode::OK, + "claim failed: {}", + claim_status + ); + let claim_body: Value = claim_resp.json().await.expect("claim body"); + assert_eq!(claim_body["username"], username); + + // ---- Resolve ---- + let resolve_resp = client + .get(url(&format!("/api/username/resolve/{}", username))) + .send() + .await + .expect("GET resolve"); + assert_eq!(resolve_resp.status(), StatusCode::OK); + let resolve_body: Value = resolve_resp.json().await.expect("resolve body"); + assert_eq!(resolve_body["username"], username); + assert_eq!(resolve_body["address"], alice.address_hex()); + + // ---- LNURLp ---- + let lnurlp_resp = client + .get(url(&format!("/.well-known/lnurlp/{}", username))) + .send() + .await + .expect("GET lnurlp"); + assert_eq!(lnurlp_resp.status(), StatusCode::OK); + let lnurlp_body: Value = lnurlp_resp.json().await.expect("lnurlp body"); + assert_eq!(lnurlp_body["tag"], "payRequest"); + assert!( + lnurlp_body["callback"] + .as_str() + .is_some_and(|s| s.contains(&username)), + "callback must reference the username, got {:?}", + lnurlp_body["callback"] + ); + let min_sendable = lnurlp_body["minSendable"] + .as_u64() + .expect("minSendable must be a u64"); + let max_sendable = lnurlp_body["maxSendable"] + .as_u64() + .expect("maxSendable must be a u64"); + assert!( + min_sendable >= 1, + "minSendable must be >= 1 msat, got {}", + min_sendable + ); + assert!( + max_sendable >= min_sendable, + "maxSendable ({}) must be >= minSendable ({})", + max_sendable, + min_sendable + ); + assert!(lnurlp_body["metadata"] + .as_str() + .is_some_and(|s| !s.is_empty())); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Poll `/api/balance` until the observed balance is >= `target`, or +/// until [`POLL_TIMEOUT`] elapses. Returns the last observed balance +/// regardless — the caller decides whether to assert on it. +async fn poll_balance_at_least(client: &reqwest::Client, address: &str, target: u64) -> u64 { + let deadline = std::time::Instant::now() + POLL_TIMEOUT; + let mut last_seen = 0u64; + loop { + let resp = client + .get(url(&format!("/api/balance?address={}", address))) + .send() + .await + .expect("GET balance"); + if resp.status() == StatusCode::OK { + let body: Value = resp.json().await.unwrap_or(Value::Null); + if let Some(b) = body["balance"].as_u64() { + last_seen = b; + if b >= target { + return b; + } + } + } + if std::time::Instant::now() >= deadline { + return last_seen; + } + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +/// Poll `/api/balance` until the observed balance is <= `target`, or +/// until [`POLL_TIMEOUT`] elapses. Used to wait for the post-commit +/// debit to land in the in-memory account. +async fn poll_balance_at_most(client: &reqwest::Client, address: &str, target: u64) -> u64 { + let deadline = std::time::Instant::now() + POLL_TIMEOUT; + let mut last_seen = u64::MAX; + loop { + let resp = client + .get(url(&format!("/api/balance?address={}", address))) + .send() + .await + .expect("GET balance"); + if resp.status() == StatusCode::OK { + let body: Value = resp.json().await.unwrap_or(Value::Null); + if let Some(b) = body["balance"].as_u64() { + last_seen = b; + if b <= target { + return b; + } + } + } + if std::time::Instant::now() >= deadline { + return last_seen; + } + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +/// Fetch the current balance of the well-known `MINTING_ADDRESS`. +/// Used by the fresh-state guard at the top of the happy-path +/// roundtrips to detect a dirty DEV state (prior mint residue or a +/// missed `reset_state` run). +async fn fetch_minting_balance(client: &reqwest::Client) -> u64 { + let minting_hex = format!("0x{}", hex::encode(digest_to_bytes(&MINTING_ADDRESS))); + let resp = client + .get(url(&format!("/api/balance?address={}", minting_hex))) + .send() + .await + .expect("GET /api/balance for MINTING_ADDRESS"); + assert_eq!( + resp.status(), + StatusCode::OK, + "/api/balance must return 200 for MINTING_ADDRESS" + ); + let body: Value = resp.json().await.expect("balance body is JSON"); + body["balance"].as_u64().expect("balance must be a u64") +} + +/// Assert that the minting account exists and its balance has not +/// somehow exceeded the bootstrap value. Allows for arbitrary prior +/// mints in the same DB lifetime (each mint reduces the balance, never +/// increases it). +/// +/// Hard-fails if: +/// - balance > BOOTSTRAP_MINTING_BALANCE (impossible without a code bug +/// or unauthorized re-seed), OR +/// - balance == 0 with no inflight mints (suggests an unwanted reset +/// or DB wipe between deploys) +/// +/// The deploy-dev workflow's `push: branches: [develop]` trigger does +/// NOT run `reset-zkcoins-node`; that command requires explicit +/// `workflow_dispatch` with `reset_state: true`. Strict equality with +/// BOOTSTRAP_MINTING_BALANCE would therefore tripwire CI on the second +/// push after any reset. Use this upper-bound assertion instead. +async fn assert_minting_balance_in_bounds(client: &reqwest::Client) { + let balance = fetch_minting_balance(client).await; + assert!( + balance <= BOOTSTRAP_MINTING_BALANCE, + "minting balance {} > bootstrap {} — code regression or unauthorized re-seed", + balance, + BOOTSTRAP_MINTING_BALANCE, + ); + assert!( + balance > 0, + "minting balance is 0 — likely an unexpected reset_state run or DB wipe; \ + check the deploy-dev workflow's recent runs" + ); +} + +fn random_suffix() -> String { + let mut bytes = [0u8; 8]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} diff --git a/program-plonky2/CONTRIBUTING.md b/program-plonky2/CONTRIBUTING.md new file mode 100644 index 00000000..79ef0595 --- /dev/null +++ b/program-plonky2/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing to `program-plonky2/` + +Operational handoff: how to build, test, lint, and not blow up the +machine. This crate is **excluded from the parent workspace** and +carries its own toolchain pin. + +> **Fresh contributor?** Read [`../CONTRIBUTING.md`](../CONTRIBUTING.md) +> § "Working on the Plonky2 Migration" first for the project invariants +> and reading order. This file is the operational *how* for the migration +> crate, but the rules in the repo-root CONTRIBUTING constrain what you +> may change here. + +## Toolchain + +Plonky2 1.1.0 requires nightly Rust because `plonky2_field` uses +`#![feature(specialization)]`. After PR [#17](https://github.com/zk-coins/node/pull/17) +the entire workspace was unified to nightly via a single root +`rust-toolchain` file; the standalone `program-plonky2/rust-toolchain.toml` +was removed. This crate is now a regular workspace member +(`members = ["program-plonky2", ...]` in the root `Cargo.toml`) +rather than the excluded standalone it was during the migration. Cargo +commands work from the workspace root or from inside `program-plonky2/`. + +## First-time setup + +```bash +rustup install nightly-2025-04-15 --profile minimal +``` + +The pin is in `program-plonky2/rust-toolchain.toml`. Bumping the +nightly date is fine but verify Plonky2 still builds and tests pass +before committing. + +## Build / test / lint + +All commands run from `program-plonky2/` (NOT from the workspace root): + +```bash +cd program-plonky2 + +# Build +cargo build + +# Run all tests serially (circuit tests are memory-heavy) +cargo test -- --test-threads=1 + +# Run just the off-circuit / non-circuit tests (fast) +cargo test hash +cargo test merkle +cargo test types +cargo test inputs + +# Run just the circuit gadget tests (slow — each ~10 s circuit build) +cargo test circuit -- --test-threads=1 + +# Format check (used by CI gate) +cargo fmt --check + +# Lint (used by CI gate). MUST be clean before pushing. +cargo clippy --all-targets -- -D warnings + +# Coverage check (will become a CI gate alongside the existing server gate). +# Per ROADMAP "Definition of MVP", 100% coverage on the activated surface +# is non-negotiable. Run this before opening any PR that adds new code: +cargo +nightly-2025-04-15 install cargo-llvm-cov # one-time +cargo llvm-cov --fail-under-lines 100 -- --test-threads=1 +``` + +## Coverage gate + +Same standard as `program/` and `server/` in the parent workspace: +**100% line coverage on the activated surface**. The "activated surface" +is everything compiled in by default features — i.e. the entire crate at +the moment, since `program-plonky2` has no feature gates yet. + +Acceptable exclusions: + +- Genuinely-unreachable defensive code → `#[cfg(...)]` or + `#[allow(dead_code)]` with a written reason; auditor must verify the + exclusion is necessary, not lazy. +- Code that requires external services (live Bitcoin node) → mark with + `#[cfg(feature = "integration-tests")]` and the integration tests run + separately in step 9's e2e plan. Note on hardware: the M3 Ultra has + its integrated GPU (Metal) available on the box, but Plonky2 currently + ships only CPU and CUDA backends. So in practice proving runs on CPU. + External NVIDIA / CUDA hardware and external cloud provers are out of + scope regardless. + +NOT acceptable: "I'll add tests later", "this is just MVP scaffolding", +"the next gadget will cover it". MVP includes coverage; see ROADMAP +"Definition of MVP". + +## Test runtime characteristics + +| Module | Speed | Why | +| --------------------------- | -------------------- | ---------------------------------------------- | +| `hash::tests` | <1 s | Just Poseidon hashes; no circuit. | +| `merkle::*` | <2 s | Off-circuit SMT/MMR operations. | +| `types::tests` | <1 s | Pure data shapes; one Poseidon per test. | +| `inputs::tests` | <2 s | Same plus a small e2e SMT+MMR roundtrip. | +| `circuit::mmr`, `circuit::smt` | **5–30 s per test** | Builds a small (no cyclic-recursion) circuit and runs one prove + verify. | +| `circuit::main` cyclic positive | **3–15 min per test** | Builds the full monolithic state-transition circuit (`INNER_PAD_BITS = 14`, `1 << 14 = 16 384`-gate inner shape) and runs a real cyclic-recursive prove + verify. Time scales with the number of active in-coin / out-coin slots. | +| `circuit::main` cyclic negative | **2–10 min per test** | Same build cost, but the prover fails early at the unsatisfied constraint instead of generating a full proof. | +| `circuit::main` panic guards | **~30 s per test** | Just `build_circuit()` then immediate `should_panic`. | + +A full circuit-test sweep at production parameters (`MAX_IN_COINS = MAX_OUT_COINS = 8`) +runs ~22 cyclic tests at 3–15 min each. Serial runtime is multiple hours; +parallel runtime is bounded by CPU + RAM (each test holds ~2 GB live). + +**Always use `--test-threads=1` for circuit tests on a memory-constrained +machine.** See `feedback_cleanup_test_binaries.md` in `~/.claude/.../memory/` +for the orphan-binary issue: if you abort a circuit test, the prover +process can leak ~30 GB of swap-resident memory and survive for hours. + +When iterating on `circuit::main`, prefer running a single test by name +rather than the whole module (`cargo test stage_5d_initial_with_one_active_in_coin`). +The build-cache hits across runs make the second invocation near-instant +for cargo itself; the prove is what dominates. + +```bash +# After interrupted test runs: +pgrep -f "target/debug/deps/zkcoins_program_plonky2" +# If any output: kill -TERM +``` + +## Project layout + +``` +program-plonky2/ +├── Cargo.toml # plonky2 = "1.1.0", anyhow only +├── Cargo.lock # commit it — lock transitive deps +├── rust-toolchain.toml # nightly-2025-04-15 +└── src/ + ├── lib.rs # Prelude: F, C, D type aliases + ├── hash.rs # Poseidon HashDigest + byte conversions + ├── types.rs # AccountState, Coin, ProofData + ├── inputs.rs # ProgramInputs, CommitmentMerkleProofs, ProofType + ├── merkle/ + │ ├── mod.rs + │ ├── sparse_merkle_tree.rs # off-circuit Poseidon SMT + │ └── merkle_mountain_range.rs # off-circuit Poseidon MMR + └── circuit/ + ├── mod.rs + ├── util.rs # swap_if shared helper (pub(crate)) + ├── mmr.rs # in-circuit MMR inclusion gadget + ├── smt.rs # in-circuit SMT inclusion + non-inclusion + insert + ├── main.rs # monolithic StateTransitionCircuit (cyclic recursion) + ├── source_aggregator.rs # Stage 5d-next-5 per-slot source aggregator (non-cyclic) + └── recursion_shape_probe.rs # diagnostic probes for Plonky2 1.1.0 shape blockers +``` + +## Adding a new gadget + +The established pattern (see `circuit/mmr.rs` and `circuit/smt.rs`): + +1. Mirror an off-circuit verifier method (e.g. `MMRProof::verify`). +2. Take `&mut CircuitBuilder` plus typed targets in, no returns. +3. Use `builder.connect_hashes(...)` to assert the final equality. +4. Use `super::util::swap_if` for conditional hash-output swapping. +5. For bit decomposition, use `key_bits_msb_first` from `smt.rs` (MSB + ordering matches `crate::merkle::sparse_merkle_tree::get_bit` on the + big-endian byte serialisation — this matters for cross-checking + against off-circuit code). +6. Write at least one positive test (round-trip through prove+verify) + and one negative test (assert `data.prove(pw).is_err()` on tampered + witness). + +## Pinning + version philosophy + +- `plonky2 = "1.1.0"` is the latest crates.io release. BitVM's reference + was on `0.2.0` which is several majors stale; we tested that 1.1.0 + still works with the nightly date pinned here. +- Don't switch to plonky2 from git or a fork without a recorded reason + in `MIGRATION_RESEARCH.md`. The crate is intentionally upstream-mature. +- `anyhow` is the only non-plonky2 runtime dep — keep it that way until + there's a concrete need. + +## CI integration + +The root workspace's CI (`.github/workflows/ci.yaml`) clippies this +crate's libs as part of `Lint & Build` (the only required check on +`develop` per PR [#48](https://github.com/zk-coins/node/pull/48)). +The cyclic-recursion test sweep at production parameters (~22 cyclic +tests × 3–15 min each) is NOT in CI — `Node + Shared Tests` runs +`-p node -p shared` only. Decision on whether/how to gate the sweep +in CI is tracked in [issue #50](https://github.com/zk-coins/node/issues/50); +until that lands, contributors run the sweep locally before opening / +updating a PR that touches this crate (see +[`../CONTRIBUTING.md`](../CONTRIBUTING.md) § "Pre-push checklist"). + +## Common pitfalls + +See `MIGRATION_RESEARCH.md` § "Lessons Learned" for the gotchas +discovered during this migration. Most relevant for hacking on this +crate: + +- Don't seed `DEFAULT_HASHES[TREE_DEPTH]` with `ZERO_HASH` — Poseidon's + zero-state behaviour causes a structural collision. Use a domain- + separated `hash_bytes(b"...")` instead. The SMT module already does + this; the regression test `leaf_hash_never_collides_with_defaults` + pins the invariant. +- `pw.set_target(target, value)` returns `Result` in plonky2 1.x. The + unwrap-or-handle is required; clippy `unused_must_use` catches it. +- Field-element packing for byte inputs: pack 7 bytes per Goldilocks + element (LE), never 8. 8-byte chunks can exceed the modulus + (`from_canonical_u64` will panic in debug). diff --git a/program-plonky2/Cargo.lock b/program-plonky2/Cargo.lock new file mode 100644 index 00000000..dffc9ce1 --- /dev/null +++ b/program-plonky2/Cargo.lock @@ -0,0 +1,652 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fixed-hash" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "rayon", + "serde", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2bd4c29270e724d3eaadf7bdc8700af4221fc0ed771b855eadcd1b98d52851" +dependencies = [ + "primitive-types", + "tiny-keccak", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plonky2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b512f56329cfde01b7b5c49f092145ae4fbdbb9cd8742f57879315ed7a893d65" +dependencies = [ + "ahash", + "anyhow", + "getrandom", + "hashbrown", + "itertools", + "keccak-hash", + "log", + "num", + "plonky2_field", + "plonky2_maybe_rayon", + "plonky2_util", + "rand", + "rand_chacha", + "serde", + "static_assertions", + "unroll", + "web-time", +] + +[[package]] +name = "plonky2_field" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ddfe8817d0c5c2d4557979c51c5253dab1b555ecc19833e5b7ea7dd86f39b8" +dependencies = [ + "anyhow", + "itertools", + "num", + "plonky2_util", + "rand", + "serde", + "static_assertions", + "unroll", +] + +[[package]] +name = "plonky2_maybe_rayon" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1e554181dc95243b8d9948ae7bae5759c7fb2502fed28f671f95ef38079406" +dependencies = [ + "rayon", +] + +[[package]] +name = "plonky2_util" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c32c137808ca984ab2458b612b7eb0462d853ee041a3136e83d54b96074c7610" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373" +dependencies = [ + "fixed-hash", + "uint", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unroll" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad948c1cb799b1a70f836077721a92a35ac177d4daddf4c20a633786d4cf618" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zkcoins-program-plonky2" +version = "0.0.1" +dependencies = [ + "anyhow", + "bincode", + "plonky2", + "serde", +] diff --git a/program-plonky2/Cargo.toml b/program-plonky2/Cargo.toml new file mode 100644 index 00000000..aedcd750 --- /dev/null +++ b/program-plonky2/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "zkcoins-program-plonky2" +version = "0.0.1" +edition = "2021" + +[dependencies] +plonky2 = "1.1.0" +anyhow = "1.0" +serde = { workspace = true } +bincode = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage_nightly)"] } diff --git a/program-plonky2/SESSION_STATE.md b/program-plonky2/SESSION_STATE.md new file mode 100644 index 00000000..cb05beed --- /dev/null +++ b/program-plonky2/SESSION_STATE.md @@ -0,0 +1,291 @@ +# Session state — pickup notes for the next agent + +> **STATUS — SNAPSHOT OF PRE-MERGE STATE.** This file documents the +> migration session state as of the PR +> [#17](https://github.com/zk-coins/node/pull/17) merge on +> 2026-05-18. Current work is on `develop`. The per-stage commit map +> (below) and the lesson index remain useful as a historical pickup +> reference; the "What's deferred to post-MVP" and "Next session" +> sections are superseded by the Step 9 entries in [`../ROADMAP.md`](../ROADMAP.md). + +Read this first if you're picking up where the previous session +left off. + +## Pre-merge branch state (historical) + +`feat/plonky2-migration` → merged into `develop` via PR +[#17](https://github.com/zk-coins/node/pull/17) on 2026-05-18 +21:50 UTC. All 6 CI checks were green at merge time (Lint & Build, +Tests, Analyze rust, Analyze actions, CodeQL, Coverage MVP scope). + +## Step status summary + +- Steps 1–4: ✅ done +- Step 5 (monolithic circuit, all stages through 5d-next-5): ✅ + done. Stage 5d-next-5 source-side verification via aggregator + pattern landed via PR [#23](https://github.com/zk-coins/node/pull/23) + — Phase 1 (aggregator skeleton, `cc9c4b6` from PR #22) + Phase 2a + (outer `verify_proof(aggregator)` + `connect_hashes` vk binding + + `ConstantGate::new(2)` shape lock) + Phase 2b (per-slot SMT + inclusion + SPEC §8 (c)(d)(e) chain + OCR coupling + active-bit + binding) + Phase 3 (3 SPEC §13 source-side negatives). Two + Plonky2 1.1.0 shape blockers resolved empirically (probe in + [`src/circuit/recursion_shape_probe.rs`](src/circuit/recursion_shape_probe.rs)), + end-state documented in + [`MIGRATION_RESEARCH.md` §7.22](../MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721). +- Step 6 (script-plonky2 prover host wrapper): ✅ done (`d96bb62`) +- Step 7 (server replacement): ✅ done. Workspace toolchain unified + to nightly. `program/` + `script/` deleted (recoverable via + `git checkout v0.last-sp1 -- ...`). shared + server fully + migrated to Plonky2-era modules with the HashDigest type-shift + handled at all boundaries. `account_node::send_coins` wired to + the Plonky2 `Prover` wrapper (`c71c9fc`); the **in-circuit + source-side validation** via `prove_*_and_sources` is wired + through (Step 7 follow-up, addresses #25), with the off-circuit + pre-check loop retained as **defense-in-depth fast-fail** before + the minute-scale prove. Dockerfile re-introduced (`dac0179`). 138 + server tests pass with `--all-features` (32 baseline + 10 inline + error-path in `d6a3cb9` + 64 ported SP1-era fixtures re-enabled + via `account_node_tests.rs` + `router_tests.rs` + 13 + feature-gated + 1 new Stage 5d-next-5 Phase 2b negative + 17 + `map_send_coins_error` unit tests landed in PR #31 + 1 new + handler-level 404 test landed in PR #31). All surface verified + end-to-end in release mode. +- Steps 8–9: ⏳ todo (App/Wallet integration + DEV deployment). + Both require work outside this repo (`zk-coins/app` + deploy + pipelines + SSH access to the DEV / PRD hosts). + +## Smoke test verified + +`cargo run --release -p node` boots cleanly: +- `Prover::new()` builds the cyclic state-transition circuit +- REST server binds `0.0.0.0:4242` +- `GET /health` → `ok` +- `GET /api/info` → `{"network":"Mutinynet"}` +- Block scanner connects to Esplora + processes Mutinynet tip +- No panics, no errors + +## Active parallel work + +None. PR #31 (Issue #28 housekeeping) addresses all four deferred +follow-ups (HTTP error mapping + CI coverage exclusions + CI cyclic +tests + doc fold). Once PR #31 merges into `feat/plonky2-migration`, +this section reflects the post-merge state. + +Closed follow-ups (all landed in PR #31): + +1. ✅ done — `/api/send` + `/api/mint` switched from `200 OK + + success:false` to `4xx/5xx + body.error` via the new + `map_send_coins_error` helper. 14 unit tests pin every documented + `send_coins` error string to its `(StatusCode, body)` pair. + See PR #31 commit `feat(api): replace 200+success:false ...`. +2. ✅ done — the workflow's `--ignore-filename-regex` already + drops `account_node.rs` + `router.rs` (Issue #28's snapshot + of the exclusion list was stale at the file level). Local + `cargo llvm-cov --release -p node --fail-under-lines 100 + --fail-under-functions 100` returns exit 0 with the current + exclusion list: 100% functions (96/96), 99.44% lines + (1067/1073), 97.98% regions. The 6 uncovered lines are all + `?` error-propagation sites in `account_node.rs::send_coins` + (323, 358, 400, 412, 415, 478) — the gate accepts the + exit-0 status as authoritative; no tactical `#[coverage(off)]` + annotations added (every uncovered line is a legitimately + reachable Err path, just not exercised in the current test + suite). +3. ✅ done — `tests` job runs the full Stage 5c+/5d/5d-next-3/ + 5d-next-5/5e cyclic sweep (`--skip stage_5*` flags removed). + `timeout-minutes` bumped 75 → 180 to fit ~125–165 min worst-case + wall on `ubuntu-latest`. +4. ✅ done — aggregator-pattern write-up folded into + [`../MIGRATION_RESEARCH.md` §7.22](../MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721); + standalone tracker file deleted. + +## What works end-to-end + +The monolithic state-transition circuit at +[`src/circuit/main.rs`](src/circuit/main.rs) implements **the full +SPEC §8 predicate including source-side verification of in-coins** +(Stage 5d-next-5): + +- Initial-branch predicate (mint exception, empty SMT roots). +- AccountUpdate branch with cyclic recursion, SPEC §8 (a)+(b). +- Prev-account `CommitmentMerkleProofs` (c)+(d)+(e) via fixed-shape + SMT + 2× MMR inclusion gadgets. +- `MAX_IN_COINS = 8` in-coin slots with SMT non-inclusion + insert + into `coin_history_root` and full `apply_coin` semantics + (recipient check + balance overflow check via `split_le(sum, 33)`). +- **Per in-coin slot — Stage 5d-next-5 Phase 2b — source-side**: + - Strict `connect(slot.active, aggregator.slot[i].active_pi)` — + no in-coin can be consumed without a verified source proof. + - SMT inclusion of `coin.identifier` in + `source.output_coins_root`. + - OCR coupling: `source.output_coins_root == + source_cmp.commitment_out_coins_root`. + - SPEC §8 (c)(d)(e) chain for source's commitment in the outer's + `history_root` (mirrors the prev-account CMP gates). +- `MAX_OUT_COINS = 8` out-coin slots with SMT non-inclusion + insert + into `output_coins_root`, balance subtraction with underflow check + via `split_le(diff, 64)`, identifier derivation + (`out_coin.identifier == Poseidon(interim_asth || u32(index))`) + and pubkey rotation. +- `INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` (1 << 15 = 32 768 gates in + the helper, matching the ~50 k outer circuit gates' degree 16 via + `helper_degree = pad_bits + 1`). + +## What's deferred to post-MVP + +Nothing in the state-transition circuit itself is deferred — Stage +5d-next-5 landed (PR [#23](https://github.com/zk-coins/node/pull/23)) +and all three previously-off-circuit SPEC §13 source-side negatives +are now covered in-circuit (`stage_5d_next_5_phase_3_*` tests). + +Pre-mainnet protocol redesigns remain (see ROADMAP "Pre-mainnet +blockers"): D2/D10 (recipient hiding), D7 (reorg safety), D8 +(per-coin nullifier-accum). These are real protocol changes, not +implementation gaps. + +## Test count + budget + +At Stage 5d-next-5 / Phase 2b production parameters +(`MAX_IN_COINS = MAX_OUT_COINS = 8`, +`INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15`): + +- `program-plonky2` lib: 117 tests total (115 default-run + 2 + `#[ignore]`d `recursion_shape_probe` diagnostics). Of the 115 + default-run, ~39 are cyclic-recursion tests (build the + state-transition + aggregator circuits and prove), the remainder + exercise off-circuit gadgets (Poseidon / SMT / MMR / types / + inputs). `cargo test --release --lib -- --test-threads=2` wall + ~42 min on M3. Single-threaded ~80–120 min on `ubuntu-latest`. +- `server` crate: 120 tests with `--all-features` (32 baseline + 10 + inline error-path + 64 ported SP1-era fixtures + 13 feature-gated + + 1 Stage 5d-next-5 Phase 2b negative). `cargo test -p node + --release --all-features -- --test-threads=1` wall ~36 min on M3. + +A serial workspace sweep at `--test-threads=1` is several hours. +Default multi-thread is bounded by RAM (~2 GB per test). + +`cargo llvm-cov --fail-under-lines 100 -- --test-threads=1` is the +coverage gate. The CI workflow currently excludes +`account_node.rs` + `router.rs` from the gate while the in-circuit +`send_coins` refactor was in progress; with the refactor landed +(this branch), the exclusions can be dropped — see "Files most +likely to be touched next" above. + +## Per-stage commit map + +| Stage | Commit | Summary | +| --- | --- | --- | +| 5a | `1036066` (superseded by 5b) | Cyclic-recursion plumbing PoC | +| 5b | `d167237` | Initial-branch predicate | +| 5c | `bba6470` | AccountUpdate branch + state continuity | +| SMT redesign | `4f317fe` | Uncompressed fixed-256-depth SMT | +| 5c+ | `4bc5f2f` | `CommitmentMerkleProofs` in-circuit | +| coverage fix | `2ce36ce` | 3 panic tests for assert_eq messages | +| 5d | `7db3c29` | In-coin slot processing for `coin_history` | +| 5d-next | `0195f71` | `apply_coin` (recipient + balance + overflow) | +| 5d-next-2 | `b2b82e7` | Bump `MAX_IN_COINS = 8` | +| 5d-next-3 | `6b5a885` | Out-coin processing | +| 5d-next-4 design | `1943316` | Design doc for source verification | +| 5d-next-3-bump | `56f3a05` | Bump `MAX_OUT_COINS = 8` | +| 5d-next-3 combined | `d292855`, `8fab78a` | Init / Update with both loops active | +| 5e | `7db3c29`, …, `50a1bd9` | 10-of-11 SPEC §13 negatives (pre-5d-next-5) | +| docs / cleanup | `508ec9c`, `a502b8f`, `05c17f8`, `50a1bd9` | ROADMAP + SPEC + panic-test refactor | +| 5d-next-5 Phase 1 | `cc6e60e`-era from PR [#22](https://github.com/zk-coins/node/pull/22) (`cc9c4b6`) | Aggregator skeleton + per-slot `conditionally_verify_proof` | +| 5d-next-5 Phase 2a | PR [#23](https://github.com/zk-coins/node/pull/23) (`b5be37a`) | Outer `verify_proof(aggregator)` + `connect_hashes` vk binding + `ConstantGate::new(2)` shape lock | +| 5d-next-5 Phase 2b | PR #23 (`f9fa75a`) | Per-slot SMT inclusion + SPEC §8 (c)(d)(e) chain + OCR coupling + active-bit binding | +| 5d-next-5 Phase 3 | PR #23 (`f9fa75a` + `e09fe5f`) | 3 SPEC §13 source-side negatives + 4 positives; fixes the previously-3-of-11 §13 gap | +| Step 7 follow-up | this branch (`7ff3f7b`, `cc6e60e`) | `send_coins` switched to in-circuit `prove_*_and_sources`; off-circuit shim retained as defense-in-depth fast-fail | + +## Files most likely to be touched next + +1. [`../.github/workflows/ci.yaml`](../.github/workflows/ci.yaml) — + drop the temporary coverage exclusions for `account_node.rs` + + `router.rs`; optionally include the Stage 5d-next-5 cyclic tests + by removing `--skip stage_5d --skip stage_5e` and bumping the + `tests` job's `timeout-minutes` from 30 to ~120. +2. Steps 8–9 in [`../ROADMAP.md`](../ROADMAP.md): App/wallet Schnorr + signing integration + DEV deployment + Signet end-to-end + roundtrip. Both span repos outside this one (`zk-coins/app` plus + deploy pipelines / SSH to the DEV / PRD hosts). +3. ✅ done — empirical insights from the Stage 5d-next-5 aggregator + work now live in + [`../MIGRATION_RESEARCH.md` §7.22](../MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721). + Tracker file removed in the Issue #28 housekeeping pass. + +## Things explicitly NOT in this branch + +- App / wallet integration (Step 8). +- DEV deployment (Step 9). +- Pre-mainnet protocol redesigns (D2/D10 / D7 / D8 — see + ROADMAP "Pre-mainnet blockers"). + +Step 6 (`script-plonky2/` prover host) and Step 7 (server-side +replacement + in-circuit `send_coins` follow-up) have BOTH landed +on this branch. + +## Test confirmation status + +**Historical snapshot (Stage 5d-next-3 era, `INNER_PAD_BITS = 14`).** +Kept for the wall-time reference points; the current branch is at +`INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` for the Phase 2b outer. + +| Test | Confirmed | Run notes | +| --- | --- | --- | +| `stage_5d_initial_with_one_active_in_coin` | ✅ | 188 s wall, single in-coin | +| `stage_5d_next_3_initial_with_one_active_out_coin` | ✅ | 761 s wall, single out-coin | +| `stage_5d_next_3_initial_combined_in_and_out_coin` | ✅ | 781 s wall, both loops active | +| `stage_5d_next_3_account_update_combined_in_and_out_coin` | ✅ | 926 s wall, both loops + cyclic recursion + CMP (b)(c)(d)(e) chain | + +**Current branch (Stage 5d-next-5 / Phase 2b landed; PR #31 +housekeeping merged).** Full `program-plonky2` lib sweep ~42 min +wall on M3 with `--test-threads=2`, 115 cyclic-recursion tests +green; full server sweep `cargo test -p node --release +--all-features -- --test-threads=1` ~36 min wall, 138 tests green +(including the Phase 2b negative +`test_send_coins_rejects_tampered_source_proof_inclusion` + the +17 `map_send_coins_error_*` unit tests + 1 new handler-level 404 +test from PR #31). +See [`../MIGRATION_RESEARCH.md` §7.22 "Benchmark"](../MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721) +for the per-test wall-time breakdown. + +## Next session — verification checklist + +Before adding new features: + +1. `git fetch && git pull --ff-only origin feat/plonky2-migration` + — pull any parallel work. +2. `cargo check --workspace --all-targets` — should be a no-op + build after the cache warms. +3. `cargo fmt --all --check` and `cargo clippy --workspace + --all-targets --all-features -- -D warnings`. +4. `cargo test -p node --release --all-features -- --test-threads=1` + — 120 tests, ~36 min wall on M3. +5. `cargo test -p zkcoins-program-plonky2 --release --lib -- + --test-threads=2` — 115 cyclic tests, ~42 min wall on M3. +6. `cargo llvm-cov --fail-under-lines 100 -- + --test-threads=1` — coverage gate (after dropping the temporary + `account_node.rs` + `router.rs` exclusions from + `.github/workflows/ci.yaml`). + +If any test fails: bisect against the commit list in +[`../ROADMAP.md`](../ROADMAP.md) Done section. + +After confirmation: Steps 8–9 (App/wallet Schnorr signing +integration + DEV deployment + Signet end-to-end roundtrip). + +## Lesson index in MIGRATION_RESEARCH §7 + +For quick orientation, the relevant lessons from this session: + +| § | Topic | +| --- | --- | +| 7.12 | BitVM's `common_data_for_recursion` is broken under Plonky2 1.1.0 | +| 7.13 | Coverage debt from unreachable `Result<()>` calls — use `.expect()` | +| 7.14 | Path-compressed SMTs are incompatible with cyclic recursion | +| 7.15 | Conditional constraints via `select_hash` masking | +| 7.16 | MMR `root_extended` / `extend_to` for fixed-depth verification | +| 7.17 | Per-slot `active`-bit masking for variable-count loops | +| 7.18 | `add_virtual_target` requires explicit witnessing; prefer `split_le` | +| 7.19 | `account_state.hash` has three roles (initial / interim / final) | +| 7.20 | Speed up panic tests via `cyclic_base_proof` short-circuit | diff --git a/program-plonky2/STAGE_5D_NEXT_4_DESIGN.md b/program-plonky2/STAGE_5D_NEXT_4_DESIGN.md new file mode 100644 index 00000000..6b749e5a --- /dev/null +++ b/program-plonky2/STAGE_5D_NEXT_4_DESIGN.md @@ -0,0 +1,215 @@ +> **STATUS — DONE / HISTORICAL — SUPERSEDED BY STAGE 5D-NEXT-5.** +> Stage 5d-next-4 was deferred per [`../MIGRATION_RESEARCH.md`](../MIGRATION_RESEARCH.md) §7.21 +> (two Plonky2 1.1.0 shape blockers). The work was completed under +> Stage 5d-next-5 (PR [#23](https://github.com/zk-coins/node/pull/23)) +> using the **aggregator pattern (Option B below)**, not the +> originally-recommended Option A. See [`../MIGRATION_RESEARCH.md`](../MIGRATION_RESEARCH.md) §7.22 +> for the empirical resolution (`ConstantGate::new(2)` injection + +> `helper_degree = pad_bits + 1`). All 11 SPEC §13 negatives are now +> covered. This file is the design sketch preserved as the historical +> record. + +# Stage 5d-next-4 design — source-side verification for in-coins + +Read-only design document for the deferred 5d-next-4 work. Captures +the open architectural decisions and the scope of the remaining SPEC +§8 in-coins predicate so the next session can hit the ground running. + +## What's deferred + +Per SPEC §8 step 2 the in-coins loop's per-coin predicate is: + +``` +for (i, coin) in inputs.in_coins.iter().enumerate(): + cp := verify_proof(inputs.in_coin_proofs_public_values[i], vk) // recursive + assert vk == cp.vk + assert inputs.in_coins_inclusion_proofs[i].verify(coin.identifier, cp.output_coins_root) + mp := inputs.in_coin_proofs_history_proofs[i] + assert cp.output_coins_root == mp.commitment_out_coins_root + assert mp.verify_commitment(history_root) + assert mp.verify_previous_root(cp.commitment_history_root, history_root) + // (then SMT non-inclusion + insert + apply_coin — already wired in 5d) +``` + +Stage 5d shipped the **coin-history side** (non-inclusion + insert +into `coin_history_root`) and `apply_coin` (recipient + balance with +overflow). Stage 5d-next-4 owes the **source side**: per in-coin, +prove that the coin was *legitimately emitted* by another instance +of the same circuit and that the source's commitment is recorded in +the global history MMR. + +## Per-in-coin witnesses (8 × MAX_IN_COINS) + +- `source_proof: ProofWithPublicInputs` — the recursive + proof of the source's transition. Its public inputs are a + `ProofData` (4 hash fields = 16 elements). +- `source_inclusion_proof: InclusionProof` (256 siblings) — proves + `coin.identifier` is in `source.output_coins_root`. +- `source_cmp: CommitmentMerkleProofs` — full bundle (SMT + 2× MMR + proofs) proving `source.commitment` is in `history_root` and + `source.commitment_history_root` is a prefix of `history_root`. + +## In-circuit constraints per slot + +All masked by the slot's `active` bit (5d's pattern): + +1. **Recursive verify** of `source_proof` against `circuit.data.verifier_only` + (binds `vk == source.vk` — SPEC §8 `assert vk == cp.vk`). +2. Extract `source_output_coins_root` from + `source_proof.public_inputs[4..8]`, `source_commitment_history_root` + from `public_inputs[8..12]`. +3. **SMT inclusion** of `coin.identifier` in `source_output_coins_root` + via `source_inclusion_proof`. +4. SPEC §8 (c)/(d)/(e) on `source_cmp`: + - `coin.recipient` (= account.owner via 5d's apply_coin) does NOT + play here — the cmp's `commitment_account_state_hash` is the + SOURCE account's hash. So (c) becomes `cp.account_state_hash == + source_cmp.commitment_account_state_hash`. + - (d) commitment in history. + - (e) source's prev history is prefix of `history_root`. +5. `source_output_coins_root == source_cmp.commitment_out_coins_root` + — couples the inclusion-proof root to the commitment in history. + +## The hard architectural decision + +Plonky2 1.1.0's `conditionally_verify_cyclic_proof_or_dummy::` +verifies **one** inner proof per call. The current `build_circuit` +makes a single call for the `prev_account` recursive proof. + +Stage 5d-next-4 needs `MAX_IN_COINS + 1 = 9` recursive verifies (one +for prev_account, one for each in-coin's source proof). Options: + +### Option A — N parallel cyclic-verify calls + +Call `conditionally_verify_cyclic_proof_or_dummy::` N times +inside `build_circuit`. The `common_data_for_recursion_c` helper +must be updated to model N verify_proof calls in pass 3 so the +inner shape matches the outer. + +**Pros:** mirrors the existing pattern; straightforward to extend. +**Cons:** the outer circuit's gate count grows linearly with N (each +verify is ~10k gates per Plonky2 estimates). N=9 means ~90k gates, +INNER_PAD_BITS must rise to 17 (1 << 17 = 131_072). Proof time +scales roughly with degree_bits — at 17 each test could take 30+ +minutes wall clock. + +### Option B — recursive aggregator first + +Fold the N inner proofs into a single aggregated proof off-circuit, +then verify the aggregate. Plonky2 has primitives for this. The +outer circuit only verifies the aggregate. + +**Pros:** outer circuit stays compact; consistent shape. +**Cons:** requires designing the aggregator circuit; another +recursion layer with its own `circuit_digest`. The protocol becomes +two-layer: clients prove their per-account transition, then a +batcher proves "I verified N of these correctly". Architectural +shift. + +### Option C — sequential proof chain + +Have the user submit the N source proofs as a *chain*: each one +verifies the previous, building up a single aggregated proof at the +end. The outer circuit only verifies the head of the chain. + +**Pros:** outer circuit stays compact like Option B. +**Cons:** chain depth = N, so prove time is O(N). Bad UX for users +with many in-coins. Probably the worst option. + +### Recommendation + +**Option A** for the MVP if N=8 stays. Outer gets fat but proof +time is bounded (single proof). Option B becomes attractive if +MAX_IN_COINS grows beyond ~16. + +## `common_data_for_recursion_c` update for Option A + +The current 3-pass helper does one `verify_proof` per pass. For N +inner proofs in the outer, pass 3 needs N `verify_proof` calls: + +```rust +fn common_data_for_recursion_c() -> CommonCircuitData { + // Pass 1: empty seed. + let builder = CircuitBuilder::::new(...); + let data = builder.build::(); + + // Pass 2: verify seed once. + let mut builder = ...; + let proof = builder.add_virtual_proof_with_pis(&data.common); + let verifier_data = builder.add_virtual_verifier_data(...); + builder.verify_proof::(&proof, &verifier_data, &data.common); + let data = builder.build::(); + + // Pass 3: verify pass-2 shape N times + NoopGate pad to power of 2. + let mut builder = ...; + let verifier_data = builder.add_virtual_verifier_data(...); + for _ in 0..N_RECURSIVE_VERIFIES { + let proof = builder.add_virtual_proof_with_pis(&data.common); + builder.verify_proof::(&proof, &verifier_data, &data.common); + } + while builder.num_gates() < 1 << INNER_PAD_BITS { + builder.add_gate(NoopGate, vec![]); + } + builder.build::().common +} +``` + +`N_RECURSIVE_VERIFIES = MAX_IN_COINS + 1` = 9 for the current +MAX_IN_COINS. + +## Witness population + +Per slot the prover supplies the source proof object plus +inclusion/commitment proofs. The same `cmp` machinery from 5c+ is +reused. + +For inactive slots (`active = false`), the source proof slot can be +filled with `cyclic_base_proof` (a dummy), same as 5c+ does for the +prev account proof on Initial branch. + +## Test budget + +At N=9 recursive verifies + MAX_IN_COINS=8 + MAX_OUT_COINS=8 the +outer circuit reaches ~100k gates. INNER_PAD_BITS ≥ 17. Each test +build + prove will likely take 20-40 minutes wall. A full +cargo-test sweep with 25+ cyclic-recursion tests becomes +prohibitive. + +**Mitigation:** introduce a `lazy_static!` / `OnceLock`-cached +`StateTransitionCircuit` so the heavy build runs once per test +binary instead of per test. CircuitData isn't `Sync` out of the +box; wrap in `Mutex` or build lazily on first use. Tests then only +pay the prove cost (~5-10 min each at MAX_IN_COINS=8) instead of +build+prove. + +## Open question: source proof type + +The current `StateTransitionCircuit` IS the circuit that emits +proofs verifiable as in-coin source. So `source_proof: ProofWithPublicInputs` +naturally pairs with the same circuit. The only complication: +production deployments will need a way to bootstrap (the very first +proof has no prior in-coins). Stage 5b's Initial branch already +supports `condition = false` + dummy inner; the same mechanism +trivially supports `active = false` for every in-coin source slot. + +## File-level scope + +- `circuit/main.rs`: add `source_proofs: Vec>` + to `StateTransitionCircuit`; add `source_cmps: Vec` + and `source_inclusion_paths: Vec>`. Wire the + constraints inside the in-coin loop. Update `common_data_for_recursion_c` + to match the new shape. +- `circuit/smt.rs`, `circuit/mmr.rs`: unchanged. +- `merkle/sparse_merkle_tree.rs`, `merkle/merkle_mountain_range.rs`: unchanged. +- Tests: positive Init→Update chain with one real in-coin source proof + (~8-15 min build + 5-10 min prove each); negatives for SPEC §13 + items currently deferred. + +## Acceptance criteria + +- All 11 SPEC §13 negatives covered (currently 8 of 11). +- The remaining 3 are: (a) source-proof not in history, (b) coin + identifier not in source's `output_coins_root`, (c) wrong `vk` + on recursive source proof. +- `cargo llvm-cov --fail-under-lines 100` still passes. +- Test budget realistic — at most ~1 hour for the full suite. diff --git a/program-plonky2/STEP4_REVIEW.md b/program-plonky2/STEP4_REVIEW.md new file mode 100644 index 00000000..8978a8b9 --- /dev/null +++ b/program-plonky2/STEP4_REVIEW.md @@ -0,0 +1,149 @@ +> **STATUS — DONE / HISTORICAL.** Step 4 + Step 5 both merged via PR +> [#17](https://github.com/zk-coins/node/pull/17) on 2026-05-18. The +> N1–N6 findings below are either addressed in the final monolithic +> circuit (`circuit/main.rs`) or moot. This file is preserved as the +> audit record from commit `fa2532f`. No action items remain. + +# Step 4 Critical Review + +Independent review of the Step 4 gadget set (`4a`, `4b`, `4c`, `4c+`, +`4d`) at commit `fa2532f`. Read-only review — no code changes — to +avoid merge conflicts with the parallel Step 5 work. + +Reviewer scope: algorithmic correctness, off-circuit ↔ in-circuit +consistency, test coverage of the negative paths, code quality, doc +clarity. **Not** in scope: low-level Plonky2 gate-counting or +constraint-degree analysis (left to Plonky2 ecosystem benchmarks). + +This file should be folded into `MIGRATION_RESEARCH.md` §7 (Lessons +Learned) at the end of Step 5, or deleted if all findings end up +mooted by the monolithic circuit work. + +--- + +## TL;DR + +**Step 4 is sound.** **Zero bugs.** Zero must-fix items. All findings +below are *nice-to-have improvements* that can wait until Step 5 +merges or even later — none block forward progress. + +72/72 tests pass at 100% line / function / region coverage. The new +`verify_smt_insert` (commit `6cf949c`) is well-structured and unifies +Case A and Case B via the same `is_case_a` selector that already +exists in `verify_smt_non_inclusion`. + +--- + +## Classification + +This report distinguishes strictly between: + +- **🐛 BUG / MUST FIX NOW** — a real defect that produces wrong results, allows unsound proofs, prevents valid usage, or violates a project invariant. **Step 4 currently has zero of these.** +- **💡 NICE TO HAVE** — improvements that would make the code easier to read, less brittle to future changes, or close edge cases that aren't reached in practice. **All findings below fall here.** + +If anything moves from the second category to the first, this report +must be updated. + +--- + +## 🐛 Bugs / Must Fix Now + +**None.** Algorithmic correctness, off-circuit ↔ in-circuit +consistency, negative-test coverage, and the 100% gate all pass. + +--- + +## 💡 Nice to Have (none block Step 5) + +### N1 — `verify_smt_insert` cannot handle divergence at bit 255 (the LSB) + +**Where:** `program-plonky2/src/circuit/smt.rs`, line ~245 +(`key_bits.len() > combined_len` assertion). + +**Observation:** the assertion requires `key_bits.len() > path.len() + extension.len()` because the gadget always reads `key_bits[combined_len]` (the divergence bit) regardless of which case is active. For full-256-bit keys, `combined_len ≤ 255` must hold. + +If two keys differ only at the very last bit (bit 255), `combined_len = 256` and the assertion fires at circuit-build time. The SMT supports this configuration in principle; no test currently exercises it. + +**Why not a bug:** the assertion is a *build-time check*, not a runtime soundness issue. If a prover attempted this configuration the circuit would refuse to build, not produce a wrong proof. The configuration is exotic (probability ~2^-255 for random keys) and not reached by any test. + +**If you want to address it:** either (a) document the constraint explicitly in the gadget's rustdoc as "supports divergence at bits 0..254" (cheap, recommended), or (b) restructure so `key_bits[combined_len]` is only read when `is_case_a == 0` and relax the assertion for Case A. + +### N2 — `case_b_extension` is a test-only helper; production host (Step 7) will need it too + +**Where:** `program-plonky2/src/circuit/smt.rs`, ~line 676 (inside `#[cfg(test)] mod tests`). + +**Observation:** the helper that mirrors the off-circuit `NonInclusionProof::insert` padding loop and produces the `extension` siblings vector is currently inside the test module. The monolithic circuit (Step 5) and the eventual server prover wiring (Step 7) will need exactly this logic on the host side. + +**Why not a bug:** tests pass. The helper is local to the test module by design; nothing depends on it externally yet. + +**If you want to address it:** when Step 5 or Step 7 needs it, expose `NonInclusionProof::insert_extension_siblings()` (or a free function in the merkle module) and have the test helper delegate to it. Cover the new method by the existing 100% gate. + +### N3 — Old-root walk and new-root walk use different bit sources (documentation clarity) + +**Where:** `program-plonky2/src/circuit/smt.rs`, the old-root walk loop (~line 278) uses `other_key_bits`; the new-root walk (~line 327) uses `key_bits`. + +**Observation:** This is **correct** — above the divergence level the two keys share bits, so either source works for the old-root walk. But the code as written is hard to follow without that justification. + +**Why not a bug:** algorithm is right; only the rationale is implicit. + +**If you want to address it:** a 2–3 line comment immediately above the old-root walk explaining why `other_key_bits` is used (any walk above divergence is bit-equivalent for both keys; choosing `other_key_bits` matches the off-circuit `NonInclusionProof::verify` for symmetry with `verify_smt_non_inclusion`). + +### N4 — `verify_smt_insert` is the constraint-heaviest gadget; expect Step 5 throughput hit + +**Where:** `program-plonky2/src/circuit/smt.rs` insert tests (especially `smt_insert_case_b_deep_divergence` with `combined_len ≈ 248`). + +**Observation:** Each level adds 4 `select` gates + 1 Poseidon two-to-one + ordering bookkeeping. At `combined_len = 248` the gadget instantiates close to 1000 constraints for the new-root walk plus an equivalent for the old-root walk. The monolithic circuit (Step 5) will instantiate this gadget for *every* in-coin's `coin_history` insertion and for every output-coins-tree insertion — with `MAX_IN_COINS = 8`, that's potentially 9 deep-divergence inserts in one proof. + +**Why not a bug:** Step 4c+ on its own is fine. The concern is downstream throughput for Step 5. + +**If you want to address it:** measure actual Plonky2 constraint count and prove-time impact during Step 5's first end-to-end. If the M3-Ultra performance budget (warm proof ≤ 5 s) is missed, the R2 risk-register knobs apply (reduce `MAX_IN_COINS`, drop in-coin recursion, switch to folding). Not a defect of Step 4c+. + +### N5 — `verify_smt_insert` name reads ambiguously + +**Where:** Public function name at line 224. + +**Observation:** The name reads as "verify that an SMT insert happened". The actual semantic is "verify the (key, value, old_root, new_root) tuple represents a valid non-inclusion-and-insert transition". A name like `verify_smt_non_inclusion_and_insert` would be more consistent with `verify_smt_non_inclusion`. + +**Why not a bug:** function does the right thing. + +**If you want to address it:** leave the name as-is for v1 (renaming a public API after Step 5 callers exist is churn). Add 1–2 lines of rustdoc clarifying the semantic. + +### N6 — `ProgramInputs` is declared but no gadget consumes it yet + +**Where:** `program-plonky2/src/inputs.rs`, the `ProgramInputs` struct. + +**Observation:** `ProgramInputs` is fully defined and tested off-circuit. No gadget reads it yet because no monolithic circuit exists yet — that's Step 5. + +**Why not a bug:** by design. Off-circuit tests cover `verify_commitment` and `verify_previous_root`, so the 100% coverage gate still passes. + +**If you want to address it:** nothing now. Step 5 will introduce a `ProgramInputsTarget` and a host helper to set witnesses from a `ProgramInputs`. Just track the dependency. + +--- + +## Per-gadget checklist + +| Gadget | Algorithm | Tests | Negatives | Docs | Coverage | +| ------ | --------- | ----- | --------- | ---- | -------- | +| 4a `verify_mmr_inclusion` + `*_with_index` | ✅ LSB-first bit indexing, matches `MMRProof::verify` | 5 positive | tampered root | clear | 100% | +| 4b `verify_smt_inclusion` | ✅ MSB-first via `key_bits_msb_first` | 4 positive (incl. growing tree) | tampered leaf, length-mismatch panic | clear | 100% | +| 4c `verify_smt_non_inclusion` | ✅ unified Case A / Case B via `is_case_a` selector | 3 positive | wrong default in Case A, length-mismatch panic | clear | 100% | +| 4c+ `verify_smt_insert` | ✅ extends 4c by adding new-root computation; same `is_case_a` selector | 3 positive (Case A, Case B shallow, Case B deep) | tampered new-value, tampered new-root, Case-A invariant, two build-time assertions | mostly clear (see M3) | 100% | +| 4d `ProgramInputs` + `CommitmentMerkleProofs` | ✅ off-circuit only; mirrors SP1 protocol shape | 4 tests including e2e SMT+MMR roundtrip | none directly (uncovered code is the unused circuit-side; tracked as M6) | clear | 100% | + +--- + +## Conclusion + +Step 4 is **professional and consistent** and meets the MVP definition +(minimal feature surface + 100% coverage). **No bugs, no must-fix +items.** All findings are nice-to-haves to consider after Step 5 lands. + +The implementation work is ready to be composed into the monolithic +state-transition circuit. + +Once Step 5 merges, the recommended (optional) follow-ups are: +1. N2: surface the host-side extension-siblings helper as a public method when Step 7 needs it. +2. N3: add the 2–3 line explanatory comment above the old-root walk. +3. N1: pick documentation vs. relaxation for the bit-255 edge case. +4. N4: measure actual constraint count and prove-time during Step 5's first e2e; act on R2 only if the budget is missed. +5. Move this file's findings into `MIGRATION_RESEARCH.md` §7 (Lessons Learned) and delete this file. diff --git a/program-plonky2/STEP7_PREP.md b/program-plonky2/STEP7_PREP.md new file mode 100644 index 00000000..f998eb6e --- /dev/null +++ b/program-plonky2/STEP7_PREP.md @@ -0,0 +1,251 @@ +# Step 7 Prep — SP1 → Plonky2 Server Cutover Inventory + +> **✅ STATUS — Step 7 is DONE.** This file is kept as the historical +> planning record. The actual cutover landed across commits `00adbb4` +> (workspace + server imports), `c71c9fc` (send_coins wired to the +> Plonky2 Prover, **off-circuit source-side validation as a +> placeholder while Stage 5d-next-5 Phase 2 was deferred**), +> `dac0179` (Dockerfile), `d6a3cb9` (inline error-path tests), the +> test-fixtures port that re-enabled `account_node_tests.rs` + +> `router_tests.rs` (proof.public_values → proof.public_inputs +> bridge + `[u8;32]` → `HashOut` casts), and the **Step-7 +> follow-up that switched `send_coins` to in-circuit source-side +> validation** via `prove_*_and_sources` (Stage 5d-next-5 Phase 2b +> from PR [#23](https://github.com/zk-coins/node/pull/23); the +> off-circuit pre-check loop is retained as defense-in-depth fast- +> fail before the prove). See [`../ROADMAP.md`](../ROADMAP.md) "Done" +> section for the full per-commit timeline. +> +> The "Semantic mismatches that the original inventory missed" +> section below remains useful as a record of what the cutover +> actually surfaced (the original inventory underestimated four +> items — HashDigest type shift, proof.public_values vs +> public_inputs, ProgramInputsBuilder absence, Prover method +> renames). Future migrations can read it for the lesson on +> "mechanical renames" turning out non-mechanical. + +--- + +Read-only inventory of every place in the existing SP1-era server code +that must change for **Step 7** (replace SP1 with Plonky2; no Cargo +feature flag, no dual backend, no migration — see +[`../CONTRIBUTING.md`](../CONTRIBUTING.md) § "Working on the Plonky2 +Migration" / closed-test-env invariant). + +Produced alongside the parallel Step 5 (monolithic circuit) work to +avoid editing files Step 5 is also touching. + +--- + +## Strict classification + +| Tag | Meaning | +| --- | --- | +| 🔧 mechanical | Pure import swap or rename; no design decision. | +| 🧩 layout-dependent | Touches `ProofData` / proof-bytes layout — must align with whatever Step 5 commits as the canonical field-element serialisation. Can't be finalised until Step 5 lands. | +| 🛠 new work | Adds something that doesn't exist yet in `program-plonky2/`. Real engineering, not just a rename. | +| ⚙ decision | Requires a design call that isn't pre-determined by the ROADMAP. | + +--- + +## File-by-file inventory + +### 1. `node/src/account_node.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L9–16 | `use zkcoins_program::…;` (merkle types, `AccountState`, `Coin`, `CoinTemplate`, `CommitmentMerkleProofs`, `ProgramInputsBuilder`, `ProofData`, `ProofType`, `calculate_coin_identifier`) | `use zkcoins_program_plonky2::…;` (same items; `ProgramInputsBuilder` may not exist in the same form — Step 5 will introduce its target/witness equivalent) | 🔧 + ⚙ | +| L17 | `use zkcoins_prover::{Proof, Prover};` | `use zkcoins_prover_plonky2::{Proof, Prover};` (Step 6 creates this crate) | 🔧 | +| L132 | `coin_proof.proof.public_values.clone().read::()` (SP1 stdin replay) | `coin_proof.proof.public_inputs_as_proof_data()` or direct field-element deserialise (Step 5 fixes the format) | 🧩 | +| L201 | `previous_proof.public_values.read::()` | Same as L132 | 🧩 | +| L379–380 | `bincode::deserialize::(&proof.public_values.to_vec())` | Same as L132 (no `to_vec` round trip needed if `ProofData` is already a field-element struct) | 🧩 | + +### 2. `node/src/server.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L20 | `use zkcoins_prover::Proof;` | `use zkcoins_prover_plonky2::Proof;` | 🔧 | +| L15 | `use shared::{Invoice, ProofData};` | unchanged — `ProofData` stays in `shared`, but its underlying definition (re-exported from `zkcoins_program_plonky2`) changes | 🧩 (downstream of `shared/`) | +| L172, L190, L341 | `bincode::serialize/deserialize` of `CoinProof` (which contains `Proof`) | mostly unchanged — `CoinProof` is opaque-bytes serialised; only fails if the new `Proof` type isn't `serde::Serialize` | 🧩 | +| L431–432 | `bincode::deserialize::(&coin_proofs[0].proof.public_values.to_vec())` | aligns with L132 of `account_node.rs` — once Step 5 ships the canonical `ProofData::from_proof(&Proof)`, this becomes a one-liner | 🧩 | +| L44–49 | SHA256 over Schnorr message | unchanged — that's BIP-340, stays | — | + +### 3. `node/src/state.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L8–10 | `use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange};` + `…::sparse_merkle_tree::{load_merkle_tree, save_merkle_tree, InclusionProof, SparseMerkleTree};` | `use zkcoins_program_plonky2::merkle::…;` — **but `load_merkle_tree`/`save_merkle_tree` do not exist yet in `program-plonky2`** | 🔧 + 🛠 | +| L12 | `use zkcoins_program::merkle::{HashDigest, ZERO_HASH};` | `use zkcoins_program_plonky2::hash::{HashDigest, ZERO_HASH};` | 🔧 | +| L66–71 | SHA256 hashing of `(smt_root \|\| prev_mmr_root)` for the MMR leaf | **Decision pending**: switch to `hash_concat` (Poseidon) for consistency with the rest of the in-circuit world, OR keep SHA256 for cross-chain readability. The MMR leaves are not in-circuit yet, but they will be once Step 5's monolithic circuit reads `commitment_history_root` from a witness chain. Aligning the off-circuit MMR leaf hash with the in-circuit one means this MUST be Poseidon. | ⚙ → 🔧 once decided | + +### 4. `node/src/scanner.rs` + +No SP1 references. **Zero changes** unless Step 5 changes the on-chain commitment format (it doesn't per the architectural invariant — Taproot inscription `4242` prefix stays). + +### 5. `node/src/main.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L22–26 | State-file path constants | unchanged | — | +| L90–91 | `State::load_from_files(SMT_PATH, MMR_PATH)` | unchanged signature; depends on persistence helpers existing in `program-plonky2` (see file 3) | 🛠 downstream | +| L200 | `state.save_to_files(SMT_PATH, MMR_PATH)` | same | 🛠 downstream | + +### 6. `node/src/publisher.rs` + +No SP1 references. **Zero changes.** Taproot inscription publishing is hash-agnostic. + +### 7. `server/Cargo.toml` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L17–18 | `zkcoins-prover = { path = "../script/" }` and `zkcoins-program = { path = "../program/" }` | `zkcoins-prover = { path = "../script-plonky2/" }` and `zkcoins-program = { path = "../program-plonky2/" }` (renames optional — could keep the dep names and just repoint paths) | 🔧 | + +### 8. `shared/src/lib.rs` and `shared/src/commitment.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| `lib.rs` L13–14 | `use zkcoins_program::…;` | `use zkcoins_program_plonky2::…;` | 🔧 | +| `lib.rs` L19 | `pub use zkcoins_program::ProofData;` | `pub use zkcoins_program_plonky2::ProofData;` | 🔧 | +| `commitment.rs` L7 | `use zkcoins_program::merkle::HashDigest;` | `use zkcoins_program_plonky2::hash::HashDigest;` | 🔧 | +| `commitment.rs` SHA256 usage | BIP-340 Schnorr message | unchanged | — | + +### 9. `script/src/lib.rs` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| Entire file | SP1 prover wrapper (`EnvProver`, `SP1Stdin`, `SP1ProvingKey`, …) | **DELETE the file's contents** once Step 6 ships `script-plonky2`. Two options: (a) delete the `script/` crate from workspace entirely, (b) replace its contents with a re-export of `zkcoins_prover_plonky2` for one PR's worth of churn-protection. Recommendation: (a). | ⚙ | + +### 10. Root `Cargo.toml` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L2–6 | `members = ["program", "script", "server", "shared"]` | `members = ["program-plonky2", "script-plonky2", "server", "shared"]` if going all-in. Alternative: keep `program` for the off-circuit types we still rely on (but they're already ported to `program-plonky2`, so this is dead). Recommendation: rename in one step. | 🔧 + ⚙ | +| L7–11 | `exclude = ["program-plonky2"]` (the nightly-toolchain workaround) | **remove the exclude** — `program-plonky2` becomes a workspace member. **But this means the whole workspace needs to support its nightly toolchain.** Two options: (i) move everything to nightly (probably safe since SP1 is being deleted), (ii) keep `program-plonky2` separate and have `server` depend on it via path-with-exclude trick. Recommendation: (i) — the SP1 reason for stable-1.81 is gone after this step. | ⚙ | +| L23 | `sp1-sdk = "4.0.0"` workspace dep | **delete** | 🔧 | +| L32–50 | 18× `[patch.crates-io]` SP1 patches | **delete** | 🔧 | + +### 11. Root `rust-toolchain` + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| L2 | `channel = "1.81.0"` | Two options: (i) `channel = "nightly-2025-04-15"` to match `program-plonky2/rust-toolchain.toml` and unify the workspace, (ii) keep stable for `server`/`shared` if they don't need nightly features. Recommendation: (i) once SP1 is gone, the stable-pin justification is gone too. | ⚙ | + +### 12. Test infrastructure + +| Where | Current | What it becomes | Tag | +| ----- | ------- | --------------- | --- | +| `.github/workflows/ci.yaml` | invokes `SP1_PROVER=mock cargo test`, `cargo llvm-cov --fail-under-lines …` | rewrite to drop `SP1_PROVER`, point at the new crates, keep the 100%-coverage gate (now applies to a different test surface) | 🔧 | +| `README.md` | extensive SP1 docs (proving strategy, `SP1_PROVER` table, etc.) | rewrite per Step 9; Step 7 itself can leave it for that step | — | +| Test fixtures that hard-code `SP1_PROVER=mock` | (multiple) | drop the env-var dependency entirely | 🔧 | + +### 13. State-file cutover checklist + +On cutover (after Step 7's image is built and ready to deploy): + +```bash +# On the DEV and PRD hosts: +sudo systemctl stop zkcoin-server +rm /var/lib/zkcoin/smt.bin /var/lib/zkcoin/mmr.bin /var/lib/zkcoin/mmr.bin.prev_root /var/lib/zkcoin/latest_block.bin +# accounts.bin — operator's call: delete to force fresh accounts, or keep with the caveat that all stored proofs are now invalid +# usernames.bin, minting_num_pubkeys.bin — fine to keep, no crypto dependency +# proofs/*.bin — delete; old proofs are SP1 format, useless to the new server +sudo systemctl start zkcoin-server +``` + +The state-file cleanup is part of the deploy runbook, not Step 7's +code changes. + +--- + +## Aggregate estimate + +**REVISED 2026-05-17 after an attempted mechanical cutover surfaced +substantial semantic mismatches beyond pure renames.** The original +"~45 min mechanical" estimate was too optimistic — see "Semantic +mismatches" below. + +| Category | Files affected | Effort | +| -------- | -------------- | ------ | +| 🔧 Mechanical renames / import swaps | account_node.rs, server.rs, state.rs (partial), shared/{lib.rs, commitment.rs}, server/Cargo.toml, root Cargo.toml | ~45 min | +| 🧩 `HashDigest` semantic shift — `[u8;32]` → `HashOut` (NOT just a type alias swap) | account_node.rs, state.rs, server.rs, router_tests.rs (~30 call sites), shared/commitment.rs (`get_account_state_hash` return type) | ~3–4 hours | +| 🧩 Proof public-input access — `proof.public_values` (SP1) → `proof.public_inputs` (Plonky2, different element type, different deserialisation) | account_node.rs (3 sites), server.rs (1 site) | ~1 hour | +| 🛠 `ProgramInputsBuilder` doesn't exist in Plonky2 — server's `send_coins` path needs a different shape (per-slot witnesses instead of batched builder) | account_node.rs (`send_coins`) | ~2–3 hours | +| 🛠 `Prover::create_account` / `update_account` signatures differ — Plonky2 wrapper uses `prove_initial_with_in_coins` / `prove_account_update_with_in_coins`. Server needs adapter | account_node.rs, server.rs | ~1 hour | +| 🛠 Persistence helpers (`save_merkle_tree` / `load_merkle_tree` / `save_mmr` / `load_mmr`) | **DONE** in commit `b76bd39` | ✅ | +| ⚙ Workspace toolchain unification: stable→nightly (entire workspace) | root rust-toolchain, all member Cargo.toml | ~1 hour to migrate + verify shared/server build on nightly | +| ⚙ MMR leaf hash decision — SHA256 vs Poseidon | state.rs (L66–71) | confirmed Poseidon per arch invariant; ~30 min implement | +| ⚙ `script/` crate deletion | repo cleanup | ~15 min | +| Test infrastructure: ~25 `hex::encode(MINTING_ADDRESS)` calls now need `digest_to_bytes(&MINTING_ADDRESS)` first | router_tests.rs, account_node_tests.rs | ~1 hour | +| State file cleanup | runbook only, not code | trivial | + +**REVISED Step 7 estimate: 2 days full-time.** The 🛠 persistence +helpers are now done, but the 🧩 semantic shifts in HashDigest + +proof public-inputs + ProgramInputsBuilder absence are larger than +the original "45 min mechanical" assumption. + +## Semantic mismatches that the original inventory missed + +Discovered during the 2026-05-17 attempted cutover (subsequently +reverted to keep the repo buildable): + +1. **`HashDigest = [u8; 32]` (SP1) vs `HashDigest = HashOut` (Plonky2):** + the alias name is the same, but the underlying type is different + (4 × `GoldilocksField` elements vs raw bytes). Implications: + - `hex::encode(MINTING_ADDRESS)` (used 25+ times in + `router_tests.rs`) needs `hex::encode(digest_to_bytes(&MINTING_ADDRESS))`. + - `HashOut::default()` for empty initialisation, not `[0u8; 32]`. + - `serialize().to_vec()` byte concatenation no longer applicable — + `hash_concat` returns `HashOut`, must `digest_to_bytes` before + adding to byte stream. + - `Sha256::update(some_hash)` requires `AsRef<[u8]>` — `HashOut` + doesn't impl that. +2. **`proof.public_values` (SP1) vs `proof.public_inputs` (Plonky2):** + field name AND element type differ. SP1 uses `SP1PublicValues` + (read/write byte stream); Plonky2 uses `Vec` of field elements. + `ProofData::from_field_elements` (already in program-plonky2) is + the bridge. +3. **`ProgramInputsBuilder` (SP1) has no Plonky2 analogue.** SP1 + batched all inputs into a single struct passed to the prover; the + Plonky2 monolithic circuit uses per-slot witnesses + (`InCoinSlotTargets`). The server's `send_coins` path must + restructure from "build inputs → call create/update" to + "construct in_coins tuples → call prove_initial_with_in_coins". +4. **`Prover::create_account` / `update_account`** are SP1-specific + method names; the Plonky2 wrapper uses + `prove_initial`/`prove_initial_with_in_coins` etc. Either rename + wrapper methods or rewrite server call sites. +5. **`HASH_SIZE` constant** (SP1: `pub const HASH_SIZE: usize = 32;`) + not present in program-plonky2. Add as `pub const HASH_SIZE: usize = 32;` + in `hash` module or update callers to literal `32` / + `core::mem::size_of::()`. + +--- + +## Dependencies on Step 5 + +The following Step 7 items become fully concrete only after Step 5 lands: + +1. **`ProofData` deserialisation API**: Step 5's monolithic circuit + defines the canonical public-input layout. Step 7 picks up + whatever shape that becomes; until then, the deserialisation + sites in `account_node.rs` (L132, L201, L379) and `router.rs` + (L431) are unknown shape. +2. **`ProgramInputsBuilder` equivalent**: SP1's builder for circuit + inputs has a Plonky2 analogue that Step 5 will introduce as a + target-set + a host-side witness setter. Step 7's `send_coins` + path uses this. +3. **Persistence helpers**: Step 7 should not block on these — they + can be implemented as part of Step 7 itself. + +--- + +## Open design decisions for Step 7 + +1. **MMR leaf hash off-circuit:** SHA256 (current) vs Poseidon. Argument for Poseidon: consistency with in-circuit, no boundary inside the MMR. Argument for SHA256: smaller dependency surface, matches the existing scanner. **Recommendation:** Poseidon — the architectural invariant is "Poseidon everywhere in Merkle structures". + +2. **`script/` crate fate:** keep as compat shim or delete? **Recommendation:** delete entirely. No external callers; the closed-test-env invariant says replace, not preserve. + +3. **Workspace toolchain unification:** keep `rust-toolchain` stable for the `server`/`shared` crates, or move everything to nightly to match `program-plonky2`? **Recommendation:** move everything to nightly (SP1's stable-pin reason is gone after this step), but verify nothing in `server`/`shared` breaks on nightly first. + +These three decisions are not blockers for starting Step 7 work — they +just need to be settled before the PR is opened for review. diff --git a/program-plonky2/src/circuit/main.rs b/program-plonky2/src/circuit/main.rs new file mode 100644 index 00000000..7ad9b8a3 --- /dev/null +++ b/program-plonky2/src/circuit/main.rs @@ -0,0 +1,3776 @@ +//! Monolithic state-transition circuit for zkCoins (Plonky2 backend). +//! +//! Mirrors `program/src/main.rs` (the SP1 entrypoint), but built as a +//! Plonky2 cyclic-recursive circuit per [`SPEC.md`] §8 / §10 and the +//! `ROADMAP.md` Step 5 plan. +//! +//! ## Stage status +//! +//! - **5a — recursion plumbing PoC**: done in commit `83fa0c1`, +//! superseded by 5b. +//! - **5b — Initial branch with real predicate**: done in commit +//! `d167237`. +//! - **5c — AccountUpdate branch**: done in commit `bba6470`. SPEC §8 +//! (a) + (b) wired, `coin_history` carry-over, mint exception +//! masked. +//! - **5c+ — CommitmentMerkleProofs in-circuit** ✅ this revision. +//! SPEC §8 (c)(d)(e) wired against fixed-shape SMT + MMR proofs. +//! Specifically: (c) is `account_state.hash() == +//! mp.commitment_account_state_hash` via element-wise difference +//! masked with `condition`; (d) is `mp.verify_commitment(history_root)`, +//! which is an in-circuit SMT inclusion of `commitment = h(asth || ocr)` +//! in `commitment_root` followed by MMR inclusion of +//! `h(commitment_root || commitment_root_mmr_sibling)` in `history_root`; +//! (e) is `mp.verify_previous_root(prev.commitment_history_root, +//! history_root)`, i.e. MMR inclusion of `h(previous_root_history_proof.0 +//! || prev.commitment_history_root)` in `history_root`. +//! Every (c)(d)(e) check is masked: each `connect_hashes(computed, +//! expected)` is re-targeted as `connect_hashes(computed, +//! select_hash(condition, expected_witness, computed))`. When +//! `condition = false` the `select` collapses to `computed` and the +//! constraint is trivially satisfied; when `condition = true` it +//! reduces to the honest check. +//! - **5d / 5e** — see ROADMAP "In Progress" section. +//! +//! ## Public-input layout (unchanged from 5b) +//! +//! 16 `ProofData` field elements + verifier-data slots. Layout per +//! [`crate::types::ProofData::to_field_elements`]: +//! +//! | slot range | meaning | +//! |------------|--------------------------| +//! | 0..4 | account_state_hash | +//! | 4..8 | output_coins_root | +//! | 8..12 | commitment_history_root | +//! | 12..16 | coin_history_root | +//! +//! ## Fixed-shape requirements +//! +//! The circuit consumes: +//! - One SMT inclusion proof of depth [`TREE_DEPTH`] = 256. +//! - Two MMR inclusion proofs of depth [`MMR_PROOF_PATH_LEN`] = +//! `MMR_MAX_DEPTH - 1` = 31. +//! +//! Off-circuit producers must extend their (variable-depth) proofs to +//! these fixed depths before witnessing — see +//! [`crate::merkle::merkle_mountain_range::MMRProof::extend_to`] and +//! [`crate::merkle::merkle_mountain_range::MerkleMountainRange::root_extended`] +//! for the MMR helper. The SMT is already uncompressed-fixed-depth by +//! construction (see the SMT redesign commit). +//! +//! ## Branch selection via `condition` +//! +//! - `false` → Initial (dummy inner; cyclic verify uses dummy; all +//! AccountUpdate-only constraints — state continuity, (c)(d)(e), +//! coin_history carry-over — are masked off). +//! - `true` → AccountUpdate (real prev proof in inner slot; all +//! AccountUpdate-only constraints fire; mint exception masked off). + +use anyhow::Result; +use plonky2::field::types::Field; +use plonky2::gates::constant::ConstantGate; +use plonky2::gates::noop::NoopGate; +use plonky2::hash::hash_types::{HashOut, HashOutTarget}; +use plonky2::hash::poseidon::PoseidonHash; +use plonky2::iop::target::{BoolTarget, Target}; +use plonky2::iop::witness::{PartialWitness, WitnessWrite}; +use plonky2::plonk::circuit_builder::CircuitBuilder; +use plonky2::plonk::circuit_data::{ + CircuitConfig, CircuitData, CommonCircuitData, VerifierCircuitTarget, +}; +use plonky2::plonk::proof::{ProofWithPublicInputs, ProofWithPublicInputsTarget}; +use plonky2::recursion::cyclic_recursion::check_cyclic_proof_verifier_data; +use plonky2::recursion::dummy_circuit::cyclic_base_proof; + +use crate::circuit::mmr::mmr_inclusion_root; +use crate::circuit::smt::{hash_up_full_path, key_bits_msb_first, smt_inclusion_root}; +use crate::circuit::source_aggregator::{ + build_source_aggregator_circuit, prove_aggregator, AggregatorSlotWitness, + SourceAggregatorCircuit, N_ST_VK_DIGEST_PIS, PER_SLOT_PIS, +}; +use crate::hash::{digest_from_bytes, HashDigest, ZERO_HASH}; +use crate::inputs::CommitmentMerkleProofs; +use crate::merkle::merkle_mountain_range::MMR_MAX_DEPTH; +use crate::merkle::sparse_merkle_tree::{ + InclusionProof, NonInclusionProof, DEFAULT_HASHES, TREE_DEPTH, +}; +use crate::types::{AccountState, Coin, PublicKey, MINTING_ADDRESS}; +use crate::{C, D, F}; + +/// Public-input count carried by the `ProofData` payload: +/// `4 (account_state_hash) + 4 (output_coins_root) + 4 (commitment_history_root) + 4 (coin_history_root)`. +/// +/// Mirrors [`crate::types::ProofData::to_field_elements`]'s output length; +/// the verifier-data slots added by `add_verifier_data_public_inputs` +/// follow these and are not counted here. +pub const N_PROOF_DATA_PUBLIC_INPUTS: usize = 16; + +/// Fixed in-circuit MMR proof path length. Equal to +/// `MMR_MAX_DEPTH - 1` because an MMR proof has one sibling per level +/// from the leaf's parent (level 1) to the root (level +/// `MMR_MAX_DEPTH - 1`). +pub const MMR_PROOF_PATH_LEN: usize = MMR_MAX_DEPTH - 1; + +/// Number of in-coin slots the circuit reserves. The state transition +/// processes `MAX_IN_COINS` slots in fixed order; inactive slots are +/// no-ops (masked by their per-slot `active` bit). Matches SPEC §13's +/// production target. Each extra slot adds ~512 Poseidon hashes +/// (the in-circuit SMT non-inclusion + insert walks at `TREE_DEPTH = +/// 256`) plus ~80 arithmetic gates for the recipient + balance +/// checks. The cyclic-recursion `common_data_for_recursion_c` +/// padding must be sized to accommodate the resulting outer-circuit +/// gate count — see that function for the current setting. +pub const MAX_IN_COINS: usize = 8; + +/// Number of out-coin slots the circuit reserves. Each active slot +/// inserts the coin's identifier into the running `output_coins_root` +/// SMT and subtracts its amount from the running balance with an +/// underflow check. After the out-coin loop, the slot's +/// `out_coin.identifier` is asserted to equal +/// `Poseidon(interim_account_state_hash || slot_index)`, mirroring +/// the off-circuit [`crate::types::calculate_coin_identifier`]. +/// Matches SPEC §13's production target of 8. Each extra slot costs +/// ~512 Poseidon hashes + ~80 arithmetic gates; the cyclic-recursion +/// `common_data_for_recursion_c` padding must be sized to accommodate +/// the resulting outer-circuit gate count. +pub const MAX_OUT_COINS: usize = 8; + +/// Build the `CommonCircuitData` that the cyclic circuit references +/// when verifying its own prior proof. +/// +/// Faithful port of Plonky2 1.1.0's own +/// `recursion::cyclic_recursion::tests::common_data_for_recursion`: +/// +/// 1. An empty circuit, to seed `data.common`. +/// 2. A circuit that calls `verify_proof` once against the seed; this +/// establishes a verifier shape stable enough to be its own input. +/// 3. A third pass that verifies once and pads the gate set up to +/// 2^12 gates with `NoopGate`. The padding fixes the circuit size +/// so the cyclic recursion fixed-point is reachable. +/// +/// The final `.common` is the `CommonCircuitData` we hand to +/// `conditionally_verify_cyclic_proof_or_dummy`. It encodes everything +/// the verifier needs to know about the circuit it's about to verify +/// (gate set, public-input count, FRI parameters). +/// +/// **Why faithful-port and not the BitVM/zkCoins reference variant:** +/// BitVM was on Plonky2 0.2.0; its `common_data_for_recursion` used +/// 2–3 `verify_proof` calls per pass plus a `ConstantGate`. In +/// Plonky2 1.1.0 that shape no longer matches what +/// `conditionally_verify_cyclic_proof_or_dummy` produces, and the +/// outer `builder.build::()` fails with "Failed to build circuit" +/// (gate-set / public-input shape mismatch). The 1.1.0 canonical +/// shape — one verify_proof + NoopGate padding to 2^12 — is what the +/// library's own tests use. +fn common_data_for_recursion_c() -> CommonCircuitData { + common_data_for_recursion_c_inner(None, INNER_PAD_BITS_STAGE_5D_NEXT_3) +} + +/// INNER_PAD_BITS used by the Stage 5d-next-3 1-verify helper. Outer +/// gate count is ~8–10 k → 2^14 = 16384. +const INNER_PAD_BITS_STAGE_5D_NEXT_3: usize = 14; + +/// INNER_PAD_BITS used by the Stage 5d-next-5 2-verify helper (cyclic +/// `prev_account` + non-cyclic aggregator). Despite adding +/// `verify_proof(agg)` to the outer, the helper-degree +/// = `pad_bits + 1` relationship combined with the full outer's +/// natural degree drives the choice of constant. The empirical +/// relation was characterised by +/// `recursion_shape_probe::dump_phase_2a_pad_bits_sweep`. +/// +/// **Phase 2a (`b5be37a`)**: Stage 5d-next-3 base ~10 k + +/// `verify_proof(agg)` ~10 k + `_or_dummy` overhead → ~30 k, fitting +/// at `degree_bits = 15`. `pad_bits = 14` made helper-degree (15) +/// match outer-degree (15). +/// +/// **Phase 2b (this revision)**: per-slot source-side gates add ~20 k +/// gates (8 slots × {SMT inclusion ~1 k + SPEC (c)(d)(e) chain ~1.5 k}). +/// Outer total ~50 k → `degree_bits = 16`. `pad_bits` bumps to 15 so +/// helper-degree (16) matches outer-degree (16). If a future stage +/// crosses `2^16 = 65 536` gates, the helper must bump to `pad_bits = +/// 16` (and a similar pattern continues per power-of-two threshold); +/// re-run `dump_phase_2a_pad_bits_sweep` to confirm. +const INNER_PAD_BITS_STAGE_5D_NEXT_5: usize = 15; + +/// Total public-input count exposed by the state-transition circuit: +/// 16 `ProofData` elements + the cyclic verifier_data public inputs +/// (4 elements for circuit_digest + 4 per cap entry). Used to +/// pre-size `bootstrap_st_common.num_public_inputs` so the +/// aggregator's virtual proof targets allocate the right-size PI +/// vector before the outer is built. +fn state_transition_num_pis() -> usize { + let cap_elements = CircuitConfig::standard_recursion_config() + .fri_config + .num_cap_elements(); + N_PROOF_DATA_PUBLIC_INPUTS + 4 + 4 * cap_elements +} + +/// Stage 5d-next-5 generalisation of [`common_data_for_recursion_c`]. +/// +/// `aggregator = Some(_)` makes pass 2 and 3 each add a second +/// `verify_proof` against `agg.common` (with +/// `constant_verifier_data(agg.verifier_only)` to pin the aggregator's +/// vd as a circuit constant). Pass 3 also injects ONE explicit +/// `ConstantGate{num_consts: 2}` instance before the NoopGate pad — +/// without it, the helper's `gates` list lacks `ConstantGate` while +/// `dummy_circuit`'s rebuild and the outer's own build both emit one +/// (via the `ConstantGate::new(2)` injection in `build_circuit`), +/// failing the cyclic fixed-point check. See +/// `MIGRATION_RESEARCH.md` §7.22 and `recursion_shape_probe` for the +/// empirical derivation of both the ConstantGate-injection trick and +/// the pad-bits → helper-degree relationship. +fn common_data_for_recursion_c_inner( + aggregator: Option<&CircuitData>, + inner_pad_bits: usize, +) -> CommonCircuitData { + // Pass 1: empty seed circuit. + let config = CircuitConfig::standard_recursion_config(); + let builder = CircuitBuilder::::new(config); + let data = builder.build::(); + + // Pass 2: verify the seed circuit once (+ optionally verify the + // aggregator's shape once). + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let verifier_data = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &verifier_data, &data.common); + if let Some(agg) = aggregator { + let agg_proof = builder.add_virtual_proof_with_pis(&agg.common); + let agg_vd = builder.constant_verifier_data(&agg.verifier_only); + builder.verify_proof::(&agg_proof, &agg_vd, &agg.common); + } + let data = builder.build::(); + + // Pass 3: verify pass-2's shape + optionally verify aggregator + + // ConstantGate injection (only when modelling the 2-verify outer) + // + NoopGate pad to `inner_pad_bits`. + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let verifier_data = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &verifier_data, &data.common); + if let Some(agg) = aggregator { + let agg_proof = builder.add_virtual_proof_with_pis(&agg.common); + let agg_vd = builder.constant_verifier_data(&agg.verifier_only); + builder.verify_proof::(&agg_proof, &agg_vd, &agg.common); + // Inject one `ConstantGate{num_consts:2}` so pass-3's gates + // list matches the outer's emitted shape (the outer's + // `build_circuit` adds the same instance right before + // `_or_dummy`). Zero constants — only the instance existence + // matters for the gate-set equality check. + builder.add_gate(ConstantGate::new(2), vec![F::ZERO, F::ZERO]); + } + while builder.num_gates() < 1 << inner_pad_bits { + builder.add_gate(NoopGate, vec![]); + } + builder.build::().common +} + +/// Element-wise `select` over a `HashOutTarget`. Returns `if_true` if +/// `cond` is true, else `if_false`. Used to mask off conditional +/// constraints by retargetting `connect_hashes(computed, expected)` to +/// `connect_hashes(computed, select_hash(cond, expected_witness, +/// computed))` — when `cond = false` the resulting target collapses to +/// `computed` and the constraint is trivially satisfied. +fn select_hash( + builder: &mut CircuitBuilder, + cond: BoolTarget, + if_true: HashOutTarget, + if_false: HashOutTarget, +) -> HashOutTarget { + let mut out = [builder.zero(); 4]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = builder.select(cond, if_true.elements[i], if_false.elements[i]); + } + HashOutTarget { elements: out } +} + +/// Witness targets for one out-coin slot. Each `StateTransitionCircuit` +/// reserves [`MAX_OUT_COINS`] of these and processes them after the +/// in-coins loop. An active slot: +/// - proves SMT non-inclusion of `out_coin_identifier` at the running +/// `output_coins_root` and computes the new root after inserting it; +/// - subtracts the coin's amount from the running balance with a +/// 64-bit underflow check; +/// - asserts `out_coin_identifier == Poseidon(interim_asth || +/// slot_index)` where `interim_asth` is the account-state hash +/// computed after the in-coins loop with the INITIAL pubkey +/// (mirroring the off-circuit `calculate_coin_identifier`). +/// +/// Inactive slots are masked no-ops on all three constraints. +pub struct OutCoinSlotTargets { + /// 1 → this slot is processed; 0 → no-op. + pub active: BoolTarget, + /// Coin's identifier. Must equal `Poseidon(interim_asth || index)` + /// for an active slot; the in-circuit equality check is masked. + pub out_coin_identifier: HashOutTarget, + /// Lower 32 bits of the coin's amount. + pub out_coin_amount_lo: Target, + /// Upper 32 bits of the coin's amount. + pub out_coin_amount_hi: Target, + /// 256 SMT siblings proving non-inclusion of `out_coin_identifier` + /// at the running `output_coins_root` *before* the insert. + pub nip_path: Vec, +} + +/// Witness targets for one in-coin slot. Each `StateTransitionCircuit` +/// reserves [`MAX_IN_COINS`] of these and processes them in order; an +/// `active = false` slot is a no-op that passes both `coin_history_root` +/// and `account_state.balance` through unchanged. +/// +/// Per SPEC §8 stage 5d-next-3 the slot wires the **coin-history side** +/// of the in-coins predicate (SMT non-inclusion-then-insert) plus the +/// per-coin `apply_coin` semantics (`coin.recipient == account.owner` +/// and a balance-overflow-checked add). Stage 5d-next-5 Phase 2b +/// extends each slot with the **source-side** checks (SPEC §8 step 2): +/// SMT inclusion of `coin.identifier` in the source proof's +/// `output_coins_root`, plus the SPEC §8 (c)(d)(e) chain for the +/// source's own commitment in `history_root`. All Phase 2b constraints +/// are masked by `active`, so an inactive slot remains a vacuous no-op +/// with arbitrary witness values. +pub struct InCoinSlotTargets { + /// 1 → this slot inserts `coin_identifier` into `coin_history_root`, + /// applies the coin to the running balance, AND requires the + /// aggregator's slot-`i` source proof to verify against this + /// circuit's verifier-key and to satisfy every Phase 2b source-side + /// check listed below. + /// 0 → slot is a no-op (all in-circuit constraints masked off). + /// + /// This bit is `connect`-bound to the aggregator's slot-`i` + /// `active` PI, so the in-coin loop and the aggregator stay in + /// lockstep: there is no way to consume an in-coin without a + /// verified source proof. + pub active: BoolTarget, + /// Coin's unique identifier. Used both as the SMT *key* (its 256 + /// bits select the leaf position) and the SMT *value* (so the + /// coin_history SMT acts as a SET membership structure). In Phase + /// 2b the same identifier is the SMT key in the SOURCE's + /// `output_coins_root` inclusion check. + pub coin_identifier: HashOutTarget, + /// Recipient address the coin claims to be sent to. The + /// `apply_coin` predicate enforces `recipient == account.owner` — + /// only the owning account can absorb a coin. Masked by `active`. + pub coin_recipient: HashOutTarget, + /// Lower 32 bits of the coin's amount (u64 packed as 2× 32-bit + /// limbs, matching the off-circuit `AccountState::hash` layout). + pub coin_amount_lo: Target, + /// Upper 32 bits of the coin's amount. + pub coin_amount_hi: Target, + /// 256 SMT siblings proving non-inclusion of `coin_identifier` at + /// `coin_history_root` *before* the insert. The same path is then + /// used to compute the new root after inserting the coin. + pub nip_path: Vec, + /// Stage 5d-next-5 Phase 2b: 256 SMT siblings proving inclusion of + /// `coin_identifier` in the SOURCE proof's `output_coins_root` + /// (extracted from the aggregator's slot-`i` PIs). Masked by + /// `active`. Leaf value is `Poseidon(coin_identifier || + /// coin_identifier)`, matching the set-membership SMT convention + /// used throughout the project. + pub source_inclusion_path: Vec, + /// Stage 5d-next-5 Phase 2b: full `CommitmentMerkleProofs` bundle + /// for the SOURCE proof's commitment in the global `history_root`. + /// Shape matches the outer's prev-account [`cmp`]; the in-circuit + /// (c)(d)(e) chain is replicated against these targets, all masked + /// by `active`. + /// + /// [`cmp`]: StateTransitionCircuit::cmp + pub source_cmp: CommitmentMerkleProofsTargets, +} + +/// Witness targets for the SPEC §8 `CommitmentMerkleProofs` predicate, +/// bundled so they can be threaded through [`StateTransitionCircuit`] +/// and [`set_cmp_witness`] in one shot. +/// +/// Sizes are pinned to the fixed-shape constants +/// ([`TREE_DEPTH`] for the SMT, [`MMR_PROOF_PATH_LEN`] for the MMR +/// proofs) so the verifier circuit has a stable `circuit_digest`. +pub struct CommitmentMerkleProofsTargets { + /// SMT root containing the prev proof's commitment leaf. + pub commitment_root: HashOutTarget, + /// SMT key at which the commitment is stored (= hash of prev pubkey). + pub smt_key: HashOutTarget, + /// 256 sibling hashes along the SMT path (level 0 = topmost). + pub smt_path: Vec, + /// MMR-proof (d) index: leaf position of `(commitment_root, + /// commitment_root_mmr_sibling)` in the history MMR. + pub mmr_a_index: Target, + /// MMR-proof (d) path: 31 sibling hashes. + pub mmr_a_path: Vec, + /// The previous MMR root at the time `commitment_root` was folded + /// in — paired with `commitment_root` to form the MMR leaf for (d). + pub commitment_root_mmr_sibling: HashOutTarget, + /// The SMT root committed to the MMR alongside `prev.commitment_history_root` + /// for proof (e). + pub prev_smt_in_mmr_leaf: HashOutTarget, + /// MMR-proof (e) index. + pub mmr_b_index: Target, + /// MMR-proof (e) path: 31 sibling hashes. + pub mmr_b_path: Vec, + /// Witness for SPEC §8 (c): the account-state-hash committed to by + /// the prev proof. Constrained to equal `account_state_hash` + /// in-circuit (under `condition`). + pub commitment_account_state_hash: HashOutTarget, + /// Witness for the second half of the commitment preimage: + /// `commitment = h(asth || ocr)`. Constrained implicitly by the + /// SMT inclusion check — the commitment value computed in-circuit + /// must match what the SMT stores. + pub commitment_out_coins_root: HashOutTarget, +} + +/// Handle to the built state-transition circuit plus the witness +/// targets a caller needs to populate when proving. +/// +/// `data.verifier_only.circuit_digest` is the verifier-key digest that +/// gets pinned as a public input via [`Self::verifier_data_target`]; +/// binding this digest is what makes the recursion *cyclic*: a proof of +/// this circuit can only be verified by this same circuit. +pub struct StateTransitionCircuit { + /// Built circuit (proving + verification keys, common data). + pub data: CircuitData, + /// Verifier shape that recursive inner proofs are checked against. + /// Equal to `data.common` up to the cyclic-recursion fixed-point. + pub common_data: CommonCircuitData, + /// Public-input slots reserved for the verifier-key digest + + /// constants-sigmas cap (set via `set_verifier_data_target` each + /// prove). + pub verifier_data_target: VerifierCircuitTarget, + /// Branch selector. `false` → Initial (dummy inner), `true` → + /// AccountUpdate (real inner). Free witness as of Stage 5c. + pub condition: BoolTarget, + /// Inner proof slot. Initial uses [`cyclic_base_proof`] dummy; + /// AccountUpdate uses a real prev `ProofWithPublicInputs`. + pub inner_proof_target: ProofWithPublicInputsTarget, + /// 16 public-input slots for `ProofData::to_field_elements`. + pub proof_data_pis: [Target; N_PROOF_DATA_PUBLIC_INPUTS], + /// Witness target: `account_state.owner` (4 field elements). + pub owner: HashOutTarget, + /// Witness target: balance lower 32 bits. + pub balance_lo: Target, + /// Witness target: balance upper 32 bits. + pub balance_hi: Target, + /// Witness targets: 33-byte compressed pubkey packed as 5×56-bit + /// limbs (the last limb holds the trailing 5 bytes + 3 zero pads). + pub pubkey_limbs: [Target; 5], + /// Witness target: the current commitment-history root. + pub history_root: HashOutTarget, + /// CommitmentMerkleProofs witness bundle. Constraints fire only + /// when `condition = true` (AccountUpdate branch). + pub cmp: CommitmentMerkleProofsTargets, + /// `MAX_IN_COINS` in-coin slot witnesses processed in order. + /// Active slots advance `coin_history_root` via SMT non-inclusion + /// + insert; inactive slots pass it through unchanged. + pub in_coin_slots: Vec, + /// `MAX_OUT_COINS` out-coin slot witnesses processed in order + /// after the in-coins loop. Active slots advance + /// `output_coins_root` and subtract the coin amount from the + /// running balance. + pub out_coin_slots: Vec, + /// 5×56-bit limbs of the new account public key the proof rotates + /// to. The FINAL `account_state_hash` (committed to `ProofData`) + /// uses these limbs; `pubkey_limbs` (the INITIAL pubkey) is used + /// only for SPEC §8 (b)+(c) checks and for the interim hash + /// driving out-coin identifier derivation. + pub next_public_key_limbs: [Target; 5], + + // ===== Stage 5d-next-5 additions ===== + /// Source-proof aggregator circuit built against this circuit's + /// `common_data`. The outer verifies an aggregator proof via the + /// `aggregator_proof_target` slot below and `connect_hashes`-binds + /// the aggregator's claimed state-transition `verifier_data` to + /// its own. + pub aggregator: SourceAggregatorCircuit, + /// Witness target for the aggregator's proof. The outer verifies + /// this proof against the aggregator's fixed (constant-baked) + /// `verifier_data`. Its public inputs carry per-slot source + /// `ProofData` and the claimed state-transition `verifier_data` + /// that the outer `connect_hashes`-binds to its own. + pub aggregator_proof_target: ProofWithPublicInputsTarget, +} + +/// Build the Stage-5c+ state-transition circuit. +/// +/// Beyond the 5b/5c predicate, this revision wires SPEC §8 (c)(d)(e) +/// against fixed-shape SMT + MMR inclusion proofs. See module docstring +/// for the constraint breakdown and the masking pattern. +pub fn build_circuit() -> StateTransitionCircuit { + // ===== Build aggregator + state-transition common via fixed-point ===== + // + // Both shapes depend on each other: the aggregator's source-proof + // targets are sized by st_common; the outer's + // `verify_proof(aggregator_proof)` is sized by agg.common. + // + // Bootstrap with the Stage 5d-next-3 shape (`dummy_circuit`-safe + // by construction). Compute the Stage 5d-next-5 `common_data` + // (which embeds a `verify_proof(agg)` + a `ConstantGate` + // injection). Rebuild the aggregator against the final + // `common_data` so its source-proof targets fit the outer's + // actual cyclic shape, then verify the fixed point converged. + let outer_num_pis = state_transition_num_pis(); + let mut bootstrap_st_common = common_data_for_recursion_c(); + bootstrap_st_common.num_public_inputs = outer_num_pis; + let mut aggregator = build_source_aggregator_circuit(&bootstrap_st_common); + let mut common_data = + common_data_for_recursion_c_inner(Some(&aggregator.data), INNER_PAD_BITS_STAGE_5D_NEXT_5); + common_data.num_public_inputs = outer_num_pis; + aggregator = build_source_aggregator_circuit(&common_data); + let mut next_common_data = + common_data_for_recursion_c_inner(Some(&aggregator.data), INNER_PAD_BITS_STAGE_5D_NEXT_5); + next_common_data.num_public_inputs = outer_num_pis; + assert_eq!( + common_data, next_common_data, + "Stage 5d-next-5 fixed-point did not converge in 2 iterations — \ + aggregator common shape unstable across rebuilds" + ); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + // Regular public inputs first — must precede + // `add_verifier_data_public_inputs` per Plonky2 contract. + let proof_data_pis: [Target; N_PROOF_DATA_PUBLIC_INPUTS] = + std::array::from_fn(|_| builder.add_virtual_public_input()); + + let verifier_data_target = builder.add_verifier_data_public_inputs(); + debug_assert_eq!( + builder.num_public_inputs(), + outer_num_pis, + "outer's PI count must match the value used to size st_common" + ); + common_data.num_public_inputs = builder.num_public_inputs(); + + let condition = builder.add_virtual_bool_target_safe(); + let inner_proof_target = builder.add_virtual_proof_with_pis(&common_data); + + // Extract prev's ProofData fields from the inner proof's PI slots. + let prev_account_state_hash = HashOutTarget { + elements: [ + inner_proof_target.public_inputs[0], + inner_proof_target.public_inputs[1], + inner_proof_target.public_inputs[2], + inner_proof_target.public_inputs[3], + ], + }; + let prev_commitment_history_root = HashOutTarget { + elements: [ + inner_proof_target.public_inputs[8], + inner_proof_target.public_inputs[9], + inner_proof_target.public_inputs[10], + inner_proof_target.public_inputs[11], + ], + }; + let prev_coin_history_root = HashOutTarget { + elements: [ + inner_proof_target.public_inputs[12], + inner_proof_target.public_inputs[13], + inner_proof_target.public_inputs[14], + inner_proof_target.public_inputs[15], + ], + }; + + // ===== Witness AccountState + history ===== + + let owner = builder.add_virtual_hash(); + let balance_lo = builder.add_virtual_target(); + let balance_hi = builder.add_virtual_target(); + builder.range_check(balance_lo, 32); + builder.range_check(balance_hi, 32); + + let pubkey_limbs: [Target; 5] = std::array::from_fn(|_| { + let t = builder.add_virtual_target(); + builder.range_check(t, 56); + t + }); + + let history_root = builder.add_virtual_hash(); + + // is_minting = element-wise AND of (owner.elements[i] == MINTING_ADDRESS.elements[i]). + let minting_addr = builder.constant_hash(HashOut { + elements: MINTING_ADDRESS.elements, + }); + let mut is_minting = builder._true(); + for i in 0..4 { + let elem_eq = builder.is_equal(owner.elements[i], minting_addr.elements[i]); + is_minting = builder.and(is_minting, elem_eq); + } + let not_minting = builder.not(is_minting); + let not_condition = builder.not(condition); + + // Mint exception (Initial-only): + let mint_mask = builder.mul(not_condition.target, not_minting.target); + let mul_lo = builder.mul(mint_mask, balance_lo); + builder.assert_zero(mul_lo); + let mul_hi = builder.mul(mint_mask, balance_hi); + builder.assert_zero(mul_hi); + + // Compute in-circuit account_state_hash. Layout per + // AccountState::hash: owner (4) + balance_lo + balance_hi + pubkey (5). + let mut state_elements: Vec = Vec::with_capacity(11); + state_elements.extend_from_slice(&owner.elements); + state_elements.push(balance_lo); + state_elements.push(balance_hi); + state_elements.extend_from_slice(&pubkey_limbs); + let account_state_hash = builder.hash_n_to_hash_no_pad::(state_elements); + + // SPEC §8 (b) — state continuity (AccountUpdate-only): + for i in 0..4 { + let diff = builder.sub( + account_state_hash.elements[i], + prev_account_state_hash.elements[i], + ); + let masked = builder.mul(condition.target, diff); + builder.assert_zero(masked); + } + + // ===== CommitmentMerkleProofs witness bundle ===== + + let cmp = CommitmentMerkleProofsTargets { + commitment_root: builder.add_virtual_hash(), + smt_key: builder.add_virtual_hash(), + smt_path: (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(), + mmr_a_index: builder.add_virtual_target(), + mmr_a_path: (0..MMR_PROOF_PATH_LEN) + .map(|_| builder.add_virtual_hash()) + .collect(), + commitment_root_mmr_sibling: builder.add_virtual_hash(), + prev_smt_in_mmr_leaf: builder.add_virtual_hash(), + mmr_b_index: builder.add_virtual_target(), + mmr_b_path: (0..MMR_PROOF_PATH_LEN) + .map(|_| builder.add_virtual_hash()) + .collect(), + commitment_account_state_hash: builder.add_virtual_hash(), + commitment_out_coins_root: builder.add_virtual_hash(), + }; + + // SPEC §8 (c): account_state_hash == cmp.commitment_account_state_hash, + // masked with `condition`. + for i in 0..4 { + let diff = builder.sub( + account_state_hash.elements[i], + cmp.commitment_account_state_hash.elements[i], + ); + let masked = builder.mul(condition.target, diff); + builder.assert_zero(masked); + } + + // SPEC §8 (d), first half: commitment = h(asth || ocr), SMT inclusion + // of `commitment` at `smt_key` in `commitment_root`. + let mut commitment_input = Vec::with_capacity(8); + commitment_input.extend_from_slice(&cmp.commitment_account_state_hash.elements); + commitment_input.extend_from_slice(&cmp.commitment_out_coins_root.elements); + let commitment = builder.hash_n_to_hash_no_pad::(commitment_input); + + let smt_key_bits = key_bits_msb_first(&mut builder, cmp.smt_key); + let smt_computed_root = smt_inclusion_root( + &mut builder, + commitment, + cmp.smt_key, + &smt_key_bits, + &cmp.smt_path, + ); + let smt_target_root = select_hash( + &mut builder, + condition, + cmp.commitment_root, + smt_computed_root, + ); + builder.connect_hashes(smt_computed_root, smt_target_root); + + // SPEC §8 (d), second half: MMR inclusion of + // h(commitment_root || commitment_root_mmr_sibling) in history_root. + let mut mmr_a_leaf_input = Vec::with_capacity(8); + mmr_a_leaf_input.extend_from_slice(&cmp.commitment_root.elements); + mmr_a_leaf_input.extend_from_slice(&cmp.commitment_root_mmr_sibling.elements); + let mmr_a_leaf = builder.hash_n_to_hash_no_pad::(mmr_a_leaf_input); + let mmr_a_index_bits = builder.split_le(cmp.mmr_a_index, MMR_PROOF_PATH_LEN); + let mmr_a_computed = + mmr_inclusion_root(&mut builder, mmr_a_leaf, &mmr_a_index_bits, &cmp.mmr_a_path); + let mmr_a_target = select_hash(&mut builder, condition, history_root, mmr_a_computed); + builder.connect_hashes(mmr_a_computed, mmr_a_target); + + // SPEC §8 (e): MMR inclusion of + // h(prev_smt_in_mmr_leaf || prev.commitment_history_root) in history_root. + let mut mmr_b_leaf_input = Vec::with_capacity(8); + mmr_b_leaf_input.extend_from_slice(&cmp.prev_smt_in_mmr_leaf.elements); + mmr_b_leaf_input.extend_from_slice(&prev_commitment_history_root.elements); + let mmr_b_leaf = builder.hash_n_to_hash_no_pad::(mmr_b_leaf_input); + let mmr_b_index_bits = builder.split_le(cmp.mmr_b_index, MMR_PROOF_PATH_LEN); + let mmr_b_computed = + mmr_inclusion_root(&mut builder, mmr_b_leaf, &mmr_b_index_bits, &cmp.mmr_b_path); + let mmr_b_target = select_hash(&mut builder, condition, history_root, mmr_b_computed); + builder.connect_hashes(mmr_b_computed, mmr_b_target); + + // ===== Stage 5d-next-5: hoisted aggregator-verify + vk binding ===== + // + // Hoisted BEFORE the in-coin loop so each slot can read its source + // proof's `ProofData` straight off the aggregator's per-slot PIs. + // + // Verify the aggregator proof against the aggregator's + // constant-baked verifier_data. `connect_hashes` then binds the + // aggregator's claimed state-transition verifier_data to the + // outer's OWN `verifier_data_target` — a wrong-vk aggregator proof + // (one whose `conditionally_verify_proof` ran against a different + // state-transition circuit's `verifier_only`) carries a different + // claimed digest and fails this binding. + let aggregator_proof_target = builder.add_virtual_proof_with_pis(&aggregator.data.common); + let aggregator_vd_target = builder.constant_verifier_data(&aggregator.data.verifier_only); + builder.verify_proof::( + &aggregator_proof_target, + &aggregator_vd_target, + &aggregator.data.common, + ); + + let st_vk_offset = MAX_IN_COINS * PER_SLOT_PIS; + let claimed_st_digest = HashOutTarget { + elements: [ + aggregator_proof_target.public_inputs[st_vk_offset], + aggregator_proof_target.public_inputs[st_vk_offset + 1], + aggregator_proof_target.public_inputs[st_vk_offset + 2], + aggregator_proof_target.public_inputs[st_vk_offset + 3], + ], + }; + builder.connect_hashes(claimed_st_digest, verifier_data_target.circuit_digest); + + let sigmas_cap_offset = st_vk_offset + N_ST_VK_DIGEST_PIS; + for (i, cap_hash) in verifier_data_target + .constants_sigmas_cap + .0 + .iter() + .enumerate() + { + let base = sigmas_cap_offset + 4 * i; + let claimed = HashOutTarget { + elements: [ + aggregator_proof_target.public_inputs[base], + aggregator_proof_target.public_inputs[base + 1], + aggregator_proof_target.public_inputs[base + 2], + aggregator_proof_target.public_inputs[base + 3], + ], + }; + builder.connect_hashes(claimed, *cap_hash); + } + + // Coin-history carry-over: starting value picks prev's + // coin_history_root for AccountUpdate, empty SMT root for Initial. + let empty_root = builder.constant_hash(DEFAULT_HASHES[0]); + let empty_leaf_default = builder.constant_hash(DEFAULT_HASHES[TREE_DEPTH]); + let mut running_coin_history_elements = [builder.zero(); 4]; + for (i, slot) in running_coin_history_elements.iter_mut().enumerate() { + *slot = builder.select( + condition, + prev_coin_history_root.elements[i], + empty_root.elements[i], + ); + } + let mut running_coin_history = HashOutTarget { + elements: running_coin_history_elements, + }; + + // Per-slot in-coin processing. Each active slot: + // - proves SMT non-inclusion of `coin_identifier` at + // `running_coin_history` and inserts it (set-membership SMT); + // - asserts `coin_recipient == account.owner` (apply_coin); + // - adds `coin_amount` to the running balance with a 32-bit + // limb-by-limb add + carry, asserting no top-level overflow. + // Inactive slots are masked no-ops on both `coin_history_root` and + // `(balance_lo, balance_hi)`. + let in_coin_slots: Vec = (0..MAX_IN_COINS) + .map(|_| InCoinSlotTargets { + active: builder.add_virtual_bool_target_safe(), + coin_identifier: builder.add_virtual_hash(), + coin_recipient: builder.add_virtual_hash(), + coin_amount_lo: { + let t = builder.add_virtual_target(); + builder.range_check(t, 32); + t + }, + coin_amount_hi: { + let t = builder.add_virtual_target(); + builder.range_check(t, 32); + t + }, + nip_path: (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(), + // Stage 5d-next-5 Phase 2b: per-slot source-side witnesses. + // SMT inclusion path + full CMP bundle for the source proof's + // commitment chain. Allocated once per slot; the source-side + // gates fire inside the in-coin loop below, masked by + // `active` so inactive slots are vacuous no-ops. + source_inclusion_path: (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(), + source_cmp: CommitmentMerkleProofsTargets { + commitment_root: builder.add_virtual_hash(), + smt_key: builder.add_virtual_hash(), + smt_path: (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(), + mmr_a_index: builder.add_virtual_target(), + mmr_a_path: (0..MMR_PROOF_PATH_LEN) + .map(|_| builder.add_virtual_hash()) + .collect(), + commitment_root_mmr_sibling: builder.add_virtual_hash(), + prev_smt_in_mmr_leaf: builder.add_virtual_hash(), + mmr_b_index: builder.add_virtual_target(), + mmr_b_path: (0..MMR_PROOF_PATH_LEN) + .map(|_| builder.add_virtual_hash()) + .collect(), + commitment_account_state_hash: builder.add_virtual_hash(), + commitment_out_coins_root: builder.add_virtual_hash(), + }, + }) + .collect(); + + // Running balance evolves through the slots; starts at the + // witnessed `(balance_lo, balance_hi)` — which is INITIAL state + // per SPEC §8 (the balance the prev proof committed to on + // AccountUpdate, or the start balance on Initial). + let mut running_balance_lo = balance_lo; + let mut running_balance_hi = balance_hi; + let two_pow_32 = builder.constant(F::from_canonical_u64(1u64 << 32)); + + for (slot_idx, slot) in in_coin_slots.iter().enumerate() { + let coin_id_bits = key_bits_msb_first(&mut builder, slot.coin_identifier); + + // --- Coin-history non-inclusion + insert (masked) --- + let computed_old = hash_up_full_path( + &mut builder, + empty_leaf_default, + &coin_id_bits, + &slot.nip_path, + ); + let target_old = select_hash( + &mut builder, + slot.active, + running_coin_history, + computed_old, + ); + builder.connect_hashes(computed_old, target_old); + + let mut new_leaf_input = Vec::with_capacity(8); + new_leaf_input.extend_from_slice(&slot.coin_identifier.elements); + new_leaf_input.extend_from_slice(&slot.coin_identifier.elements); + let new_leaf = builder.hash_n_to_hash_no_pad::(new_leaf_input); + let computed_new = hash_up_full_path(&mut builder, new_leaf, &coin_id_bits, &slot.nip_path); + running_coin_history = select_hash( + &mut builder, + slot.active, + computed_new, + running_coin_history, + ); + + // --- Recipient check (masked) --- + // `active * (coin_recipient[i] - owner[i]) == 0` for i in 0..4. + for i in 0..4 { + let diff = builder.sub(slot.coin_recipient.elements[i], owner.elements[i]); + let masked = builder.mul(slot.active.target, diff); + builder.assert_zero(masked); + } + + // --- Balance addition with overflow check (masked) --- + // u64 balance = balance_hi * 2^32 + balance_lo. Add active * + // coin_amount via limb-by-limb with carry; assert top-level + // overflow is zero. For inactive slots, masked_amount is 0 and + // the carry/overflow bits settle to zero, leaving the running + // balance unchanged. + // + // `split_le(sum, 33)` decomposes a value in [0, 2^33) into 33 + // bits; bits are auto-witnessed by Plonky2's `BaseSumGate` + // generator. The high bit at index 32 is the carry / overflow. + // We reconstitute `new_lo = sum_lo - 2^32 * carry` via + // subtraction, which is exactly the low 32 bits of `sum_lo`. + let active_t = slot.active.target; + let masked_amount_lo = builder.mul(active_t, slot.coin_amount_lo); + let masked_amount_hi = builder.mul(active_t, slot.coin_amount_hi); + + let sum_lo = builder.add(running_balance_lo, masked_amount_lo); + let lo_bits = builder.split_le(sum_lo, 33); + let carry = lo_bits[32]; + let carry_shifted = builder.mul(two_pow_32, carry.target); + let new_lo = builder.sub(sum_lo, carry_shifted); + + let sum_hi_pre = builder.add(running_balance_hi, masked_amount_hi); + let sum_hi = builder.add(sum_hi_pre, carry.target); + let hi_bits = builder.split_le(sum_hi, 33); + let overflow = hi_bits[32]; + let overflow_shifted = builder.mul(two_pow_32, overflow.target); + let new_hi = builder.sub(sum_hi, overflow_shifted); + // No top-level overflow allowed. + builder.assert_zero(overflow.target); + + running_balance_lo = new_lo; + running_balance_hi = new_hi; + + // ===== Stage 5d-next-5 Phase 2b: per-slot source-side checks ===== + // + // Per SPEC §8 step 2 every active in-coin slot must witness a + // source state-transition proof whose `output_coins_root` + // contains `coin_identifier`, AND that source proof's + // commitment must be published in the global `history_root` via + // the (c)(d)(e) chain. + // + // The aggregator (verified at outer build via `verify_proof(agg)` + // hoisted above the in-coin loop) exposes per-slot source + // `ProofData` as PIs at offset `slot_idx * PER_SLOT_PIS`. + // + // Every gate below is masked by `slot.active` so inactive slots + // are vacuous: the aggregator's slot bit is `connect`-bound to + // `slot.active`, so an inactive slot necessarily has the + // aggregator's matching `active` PI = 0 and a dummy proof on + // the aggregator side. + + let agg_base = slot_idx * PER_SLOT_PIS; + let source_account_state_hash = HashOutTarget { + elements: [ + aggregator_proof_target.public_inputs[agg_base], + aggregator_proof_target.public_inputs[agg_base + 1], + aggregator_proof_target.public_inputs[agg_base + 2], + aggregator_proof_target.public_inputs[agg_base + 3], + ], + }; + let source_output_coins_root = HashOutTarget { + elements: [ + aggregator_proof_target.public_inputs[agg_base + 4], + aggregator_proof_target.public_inputs[agg_base + 5], + aggregator_proof_target.public_inputs[agg_base + 6], + aggregator_proof_target.public_inputs[agg_base + 7], + ], + }; + let source_commitment_history_root = HashOutTarget { + elements: [ + aggregator_proof_target.public_inputs[agg_base + 8], + aggregator_proof_target.public_inputs[agg_base + 9], + aggregator_proof_target.public_inputs[agg_base + 10], + aggregator_proof_target.public_inputs[agg_base + 11], + ], + }; + // `[agg_base + 12 .. agg_base + 16]` is the source's + // `coin_history_root` — unused for §8 step 2 (it only ever + // matters for an account's OWN in-coins). + let source_active_pi = aggregator_proof_target.public_inputs[agg_base + 16]; + + // Bind outer-slot active <-> aggregator-slot active. Both are + // bool-constrained by their respective allocators, so this + // collapses to a strict equality. There is no way to consume + // an in-coin without the aggregator verifying its source proof. + builder.connect(slot.active.target, source_active_pi); + + // --- SMT inclusion of coin.identifier in source.output_coins_root --- + // + // The source's out-coin loop computes its new + // `output_coins_root` via `hash_up_full_path(new_leaf, + // id_bits, nip_path)` where `new_leaf = h(id || id)` — + // i.e. the SMT leaf at depth `TREE_DEPTH` is the + // pre-hashed `h(id || id)` directly, NOT + // `smt_leaf_hash(value, key) = h(value || key)`. The off-circuit + // [`InclusionProof::verify`] mirrors that: it computes + // `start = leaf_hash(leaf=id, key=id) = h(id || id)`. So the + // consumer must use the same `start` (one Poseidon hash of + // `id || id`) — calling `smt_inclusion_root` here would + // introduce an extra `smt_leaf_hash` step, producing a wire + // conflict against the source's published OCR. + let mut source_set_leaf_input = Vec::with_capacity(8); + source_set_leaf_input.extend_from_slice(&slot.coin_identifier.elements); + source_set_leaf_input.extend_from_slice(&slot.coin_identifier.elements); + let source_set_leaf = builder.hash_n_to_hash_no_pad::(source_set_leaf_input); + let source_inclusion_computed = hash_up_full_path( + &mut builder, + source_set_leaf, + &coin_id_bits, + &slot.source_inclusion_path, + ); + let source_inclusion_target = select_hash( + &mut builder, + slot.active, + source_output_coins_root, + source_inclusion_computed, + ); + builder.connect_hashes(source_inclusion_computed, source_inclusion_target); + + // --- Coupling: source.output_coins_root == source_cmp.commitment_out_coins_root --- + // + // Without this check the witnessed CMP could open the + // commitment SMT against a DIFFERENT `output_coins_root` than + // the one the source proof actually committed to, breaking the + // binding between the inclusion check above and the (d) chain + // below. + for j in 0..4 { + let diff = builder.sub( + source_output_coins_root.elements[j], + slot.source_cmp.commitment_out_coins_root.elements[j], + ); + let masked = builder.mul(slot.active.target, diff); + builder.assert_zero(masked); + } + + // --- SPEC §8 (c): source.account_state_hash == source_cmp.commitment_account_state_hash --- + for j in 0..4 { + let diff = builder.sub( + source_account_state_hash.elements[j], + slot.source_cmp.commitment_account_state_hash.elements[j], + ); + let masked = builder.mul(slot.active.target, diff); + builder.assert_zero(masked); + } + + // --- SPEC §8 (d), first half: SMT inclusion of commitment --- + // + // commitment = h(commitment_account_state_hash || commitment_out_coins_root) + // — by (c) above and the coupling check, the in-circuit value + // equals h(source.asth || source.ocr), which is the source + // proof's published commitment. + let mut source_commitment_input = Vec::with_capacity(8); + source_commitment_input + .extend_from_slice(&slot.source_cmp.commitment_account_state_hash.elements); + source_commitment_input + .extend_from_slice(&slot.source_cmp.commitment_out_coins_root.elements); + let source_commitment = + builder.hash_n_to_hash_no_pad::(source_commitment_input); + let source_smt_key_bits = key_bits_msb_first(&mut builder, slot.source_cmp.smt_key); + let source_smt_computed = smt_inclusion_root( + &mut builder, + source_commitment, + slot.source_cmp.smt_key, + &source_smt_key_bits, + &slot.source_cmp.smt_path, + ); + let source_smt_target = select_hash( + &mut builder, + slot.active, + slot.source_cmp.commitment_root, + source_smt_computed, + ); + builder.connect_hashes(source_smt_computed, source_smt_target); + + // --- SPEC §8 (d), second half: MMR inclusion of commitment_root --- + let mut source_mmr_a_leaf_input = Vec::with_capacity(8); + source_mmr_a_leaf_input.extend_from_slice(&slot.source_cmp.commitment_root.elements); + source_mmr_a_leaf_input + .extend_from_slice(&slot.source_cmp.commitment_root_mmr_sibling.elements); + let source_mmr_a_leaf = + builder.hash_n_to_hash_no_pad::(source_mmr_a_leaf_input); + let source_mmr_a_index_bits = + builder.split_le(slot.source_cmp.mmr_a_index, MMR_PROOF_PATH_LEN); + let source_mmr_a_computed = mmr_inclusion_root( + &mut builder, + source_mmr_a_leaf, + &source_mmr_a_index_bits, + &slot.source_cmp.mmr_a_path, + ); + let source_mmr_a_target = select_hash( + &mut builder, + slot.active, + history_root, + source_mmr_a_computed, + ); + builder.connect_hashes(source_mmr_a_computed, source_mmr_a_target); + + // --- SPEC §8 (e): MMR inclusion of source's prior history root --- + // + // Leaf shape: `h(prev_smt_in_mmr_leaf || source.commitment_history_root)`, + // where `source.commitment_history_root` is extracted from the + // aggregator's slot-`i` PIs (the source's prior view of + // history at the time it was proved). + let mut source_mmr_b_leaf_input = Vec::with_capacity(8); + source_mmr_b_leaf_input.extend_from_slice(&slot.source_cmp.prev_smt_in_mmr_leaf.elements); + source_mmr_b_leaf_input.extend_from_slice(&source_commitment_history_root.elements); + let source_mmr_b_leaf = + builder.hash_n_to_hash_no_pad::(source_mmr_b_leaf_input); + let source_mmr_b_index_bits = + builder.split_le(slot.source_cmp.mmr_b_index, MMR_PROOF_PATH_LEN); + let source_mmr_b_computed = mmr_inclusion_root( + &mut builder, + source_mmr_b_leaf, + &source_mmr_b_index_bits, + &slot.source_cmp.mmr_b_path, + ); + let source_mmr_b_target = select_hash( + &mut builder, + slot.active, + history_root, + source_mmr_b_computed, + ); + builder.connect_hashes(source_mmr_b_computed, source_mmr_b_target); + } + + let output_coin_history_root = running_coin_history; + + // ===== Out-coins processing ===== + // + // Per SPEC §8 step 3, the out-coins loop: + // 1. For each (out_coin, ncl_proof): verify non-inclusion in the + // running `output_coins_root`, insert the identifier, subtract + // the amount from the running balance with an underflow check. + // 2. Compute `interim_asth = H(owner || running_balance || + // pubkey_limbs)` — the account-state hash at this point, with + // the INITIAL pubkey (no rotation yet). + // 3. For each (i, out_coin): assert `out_coin.identifier == + // H(interim_asth || u32(i))`, mirroring the off-circuit + // `calculate_coin_identifier`. + // 4. Rotate pubkey: the FINAL `account_state_hash` (= the public + // output) uses `next_public_key_limbs` in place of + // `pubkey_limbs`. + // + // All in-circuit checks are masked by each slot's `active` bit, + // so an empty out-coins loop is a no-op (running root stays at + // `DEFAULT_HASHES[0]`, balance unchanged, identifier check + // trivially satisfied). + + let next_public_key_limbs: [Target; 5] = std::array::from_fn(|_| { + let t = builder.add_virtual_target(); + builder.range_check(t, 56); + t + }); + + let out_coin_slots: Vec = (0..MAX_OUT_COINS) + .map(|_| OutCoinSlotTargets { + active: builder.add_virtual_bool_target_safe(), + out_coin_identifier: builder.add_virtual_hash(), + out_coin_amount_lo: { + let t = builder.add_virtual_target(); + builder.range_check(t, 32); + t + }, + out_coin_amount_hi: { + let t = builder.add_virtual_target(); + builder.range_check(t, 32); + t + }, + nip_path: (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(), + }) + .collect(); + + let mut running_output_coins_root = empty_root; + + for slot in &out_coin_slots { + let id_bits = key_bits_msb_first(&mut builder, slot.out_coin_identifier); + + // --- SMT non-inclusion + insert into running_output_coins_root --- + let computed_old = + hash_up_full_path(&mut builder, empty_leaf_default, &id_bits, &slot.nip_path); + let target_old = select_hash( + &mut builder, + slot.active, + running_output_coins_root, + computed_old, + ); + builder.connect_hashes(computed_old, target_old); + + let mut new_leaf_input = Vec::with_capacity(8); + new_leaf_input.extend_from_slice(&slot.out_coin_identifier.elements); + new_leaf_input.extend_from_slice(&slot.out_coin_identifier.elements); + let new_leaf = builder.hash_n_to_hash_no_pad::(new_leaf_input); + let computed_new = hash_up_full_path(&mut builder, new_leaf, &id_bits, &slot.nip_path); + running_output_coins_root = select_hash( + &mut builder, + slot.active, + computed_new, + running_output_coins_root, + ); + + // --- Balance subtraction with underflow check (masked) --- + // `balance_u64 = balance_hi * 2^32 + balance_lo` and same for + // `amount_u64`. `diff = balance_u64 - active * amount_u64` + // must be in `[0, 2^64)` — `split_le(diff, 64)` constrains + // exactly that. When inactive, `active * amount = 0` so + // `diff = balance_u64` (unchanged) and the bits trivially + // decompose it. + let balance_u64 = builder.mul_add(running_balance_hi, two_pow_32, running_balance_lo); + let amount_lo_masked = builder.mul(slot.active.target, slot.out_coin_amount_lo); + let amount_hi_masked = builder.mul(slot.active.target, slot.out_coin_amount_hi); + let amount_u64 = builder.mul_add(amount_hi_masked, two_pow_32, amount_lo_masked); + let diff = builder.sub(balance_u64, amount_u64); + let diff_bits = builder.split_le(diff, 64); + // Recompose into 32-bit halves. `le_sum` weights bits by + // ascending powers of 2 starting at 0; the [0..32) slice gives + // the low 32 bits and [0..32) of the [32..64) slice gives the + // high half (also weighted from 2^0 because `le_sum` doesn't + // know about offsets — that's the intended bottom-up sum). + let new_lo = builder.le_sum(diff_bits[..32].iter()); + let new_hi = builder.le_sum(diff_bits[32..].iter()); + running_balance_lo = new_lo; + running_balance_hi = new_hi; + } + + let final_balance_lo = running_balance_lo; + let final_balance_hi = running_balance_hi; + + // Interim account-state hash: owner + post-subtraction balance + + // INITIAL pubkey. Drives out-coin identifier derivation. + let mut interim_state_elements: Vec = Vec::with_capacity(11); + interim_state_elements.extend_from_slice(&owner.elements); + interim_state_elements.push(final_balance_lo); + interim_state_elements.push(final_balance_hi); + interim_state_elements.extend_from_slice(&pubkey_limbs); + let interim_account_state_hash = + builder.hash_n_to_hash_no_pad::(interim_state_elements); + + // Identifier derivation per out-coin slot. + // Expected: out_coin.identifier == H(interim_asth || u32(slot_index)) + // (matches off-circuit [`crate::types::calculate_coin_identifier`]). + // Masked by `active` so inactive slots' identifiers don't need to + // match anything. + for (i, slot) in out_coin_slots.iter().enumerate() { + let i_const = builder.constant(F::from_canonical_u32(i as u32)); + let mut id_input = Vec::with_capacity(5); + id_input.extend_from_slice(&interim_account_state_hash.elements); + id_input.push(i_const); + let computed_id = builder.hash_n_to_hash_no_pad::(id_input); + for j in 0..4 { + let diff = builder.sub( + slot.out_coin_identifier.elements[j], + computed_id.elements[j], + ); + let masked = builder.mul(slot.active.target, diff); + builder.assert_zero(masked); + } + } + + // FINAL account-state hash: owner + post-subtraction balance + NEW + // pubkey. Committed as `ProofData.account_state_hash`. If the + // caller wants no rotation (e.g., Initial / Account-update without + // out-coins), they set `next_public_key_limbs` to the same value + // as `pubkey_limbs` and the final hash matches the initial-pubkey + // hash. + let mut final_state_elements: Vec = Vec::with_capacity(11); + final_state_elements.extend_from_slice(&owner.elements); + final_state_elements.push(final_balance_lo); + final_state_elements.push(final_balance_hi); + final_state_elements.extend_from_slice(&next_public_key_limbs); + let final_account_state_hash = + builder.hash_n_to_hash_no_pad::(final_state_elements); + + // Connect `ProofData` public inputs slot-by-slot. + for i in 0..4 { + builder.connect(proof_data_pis[i], final_account_state_hash.elements[i]); + builder.connect(proof_data_pis[4 + i], running_output_coins_root.elements[i]); + builder.connect(proof_data_pis[8 + i], history_root.elements[i]); + builder.connect(proof_data_pis[12 + i], output_coin_history_root.elements[i]); + } + + // Shape lock — must match the helper's pass-3 injection (see + // `common_data_for_recursion_c_inner`). Without it, the outer's + // gates list lacks `ConstantGate` even though the helper's + // pass-3 has it (and `dummy_circuit`'s rebuild always emits one), + // failing the cyclic fixed-point check at + // `plonk/circuit_builder.rs:1067`. The aggregator-verify itself + // is hoisted above the in-coin loop so per-slot source-side gates + // can read the aggregator's PIs. + builder.add_gate(ConstantGate::new(2), vec![F::ZERO, F::ZERO]); + + // Cyclic verification (Stage 5d-next-3 + Stage 5d-next-5 shape: + // the cyclic fixed-point is reached because pass 3 of + // `common_data_for_recursion_c_inner` models exactly this + // `_or_dummy` (1 verify_proof internally) + the + // `verify_proof(aggregator)` + the `ConstantGate` injection + // above. Their gate-set, selectors_info, num_constants and + // degree_bits all coincide — see + // `MIGRATION_RESEARCH.md` §7.22 for the empirical derivation. + builder + .conditionally_verify_cyclic_proof_or_dummy::( + condition, + &inner_proof_target, + &common_data, + ) + .expect("conditionally_verify_cyclic_proof_or_dummy: common_data is well-formed by construction"); + + let data = builder.build::(); + StateTransitionCircuit { + data, + common_data, + verifier_data_target, + condition, + inner_proof_target, + proof_data_pis, + owner, + balance_lo, + balance_hi, + pubkey_limbs, + history_root, + cmp, + in_coin_slots, + out_coin_slots, + next_public_key_limbs, + aggregator, + aggregator_proof_target, + } +} + +/// Set the witnesses for the `AccountState` fields. Shared between +/// [`prove_initial`] and [`prove_account_update`] because both branches +/// witness the same fields in the same way. +fn set_account_state_witness( + pw: &mut PartialWitness, + circuit: &StateTransitionCircuit, + account_state: &AccountState, +) { + pw.set_hash_target(circuit.owner, account_state.owner) + .unwrap(); + + let balance = account_state.balance; + pw.set_target( + circuit.balance_lo, + F::from_canonical_u32((balance & 0xFFFF_FFFF) as u32), + ) + .unwrap(); + pw.set_target( + circuit.balance_hi, + F::from_canonical_u32((balance >> 32) as u32), + ) + .unwrap(); + + for (i, chunk) in account_state.public_key.chunks(7).enumerate() { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + pw.set_target( + circuit.pubkey_limbs[i], + F::from_canonical_u64(u64::from_le_bytes(buf)), + ) + .unwrap(); + } +} + +/// Set the witnesses for a `CommitmentMerkleProofsTargets` bundle. +/// +/// Shared between the outer prev-account CMP and the per-in-coin-slot +/// source CMP (Stage 5d-next-5 Phase 2b). Off-circuit producers MUST +/// pre-pad SMT / MMR paths to the fixed in-circuit shapes +/// ([`TREE_DEPTH`] / [`MMR_PROOF_PATH_LEN`]); the asserts here catch +/// malformed witnesses early before any expensive proving work. +fn set_cmp_targets_witness( + pw: &mut PartialWitness, + targets: &CommitmentMerkleProofsTargets, + cmp: &CommitmentMerkleProofs, +) { + pw.set_hash_target(targets.commitment_root, cmp.commitment_root) + .unwrap(); + pw.set_hash_target( + targets.smt_key, + digest_from_bytes(&cmp.commitment_proof.key), + ) + .unwrap(); + assert_eq!( + cmp.commitment_proof.siblings.len(), + TREE_DEPTH, + "CommitmentMerkleProofs: SMT inclusion proof must be padded to TREE_DEPTH siblings" + ); + for (i, sib) in cmp.commitment_proof.siblings.iter().enumerate() { + pw.set_hash_target(targets.smt_path[i], *sib).unwrap(); + } + pw.set_target( + targets.mmr_a_index, + F::from_canonical_u32(cmp.commitment_root_history_proof.index), + ) + .unwrap(); + assert_eq!( + cmp.commitment_root_history_proof.path.len(), + MMR_PROOF_PATH_LEN, + "CommitmentMerkleProofs: MMR proof (d) must be extended to MMR_PROOF_PATH_LEN siblings" + ); + for (i, sib) in cmp.commitment_root_history_proof.path.iter().enumerate() { + pw.set_hash_target(targets.mmr_a_path[i], *sib).unwrap(); + } + pw.set_hash_target( + targets.commitment_root_mmr_sibling, + cmp.commitment_root_mmr_sibling, + ) + .unwrap(); + pw.set_hash_target( + targets.prev_smt_in_mmr_leaf, + cmp.previous_root_history_proof.0, + ) + .unwrap(); + pw.set_target( + targets.mmr_b_index, + F::from_canonical_u32(cmp.previous_root_history_proof.1.index), + ) + .unwrap(); + assert_eq!( + cmp.previous_root_history_proof.1.path.len(), + MMR_PROOF_PATH_LEN, + "CommitmentMerkleProofs: MMR proof (e) must be extended to MMR_PROOF_PATH_LEN siblings" + ); + for (i, sib) in cmp.previous_root_history_proof.1.path.iter().enumerate() { + pw.set_hash_target(targets.mmr_b_path[i], *sib).unwrap(); + } + pw.set_hash_target( + targets.commitment_account_state_hash, + cmp.commitment_account_state_hash, + ) + .unwrap(); + pw.set_hash_target( + targets.commitment_out_coins_root, + cmp.commitment_out_coins_root, + ) + .unwrap(); +} + +/// Set the witnesses for the prev-account `CommitmentMerkleProofs` +/// bundle. Thin wrapper around [`set_cmp_targets_witness`] that +/// targets `circuit.cmp`. +/// +/// Used by both proving paths: +/// - `prove_initial` calls this with a *dummy* `cmp` ([`dummy_cmp`]), +/// since the masked constraints are trivially satisfied with +/// `condition = false` for any witness. +/// - `prove_account_update` calls this with the real `cmp` matching +/// the prev proof and current history. +fn set_cmp_witness( + pw: &mut PartialWitness, + circuit: &StateTransitionCircuit, + cmp: &CommitmentMerkleProofs, +) { + set_cmp_targets_witness(pw, &circuit.cmp, cmp); +} + +/// Build a syntactically-valid but semantically-empty +/// `CommitmentMerkleProofs` for use as the dummy witness in +/// [`prove_initial`] and the per-in-coin-slot source CMP of inactive +/// slots (Stage 5d-next-5 Phase 2b). Every field gets a deterministic +/// placeholder (mostly `ZERO_HASH`); the masked constraints in the +/// circuit ignore the values whenever their guard bit is `0`. +fn dummy_cmp() -> CommitmentMerkleProofs { + use crate::merkle::merkle_mountain_range::MMRProof; + CommitmentMerkleProofs { + commitment_root: ZERO_HASH, + commitment_proof: InclusionProof { + key: [0u8; 32], + siblings: vec![ZERO_HASH; TREE_DEPTH], + }, + commitment_root_history_proof: MMRProof::new(vec![ZERO_HASH; MMR_PROOF_PATH_LEN], 0), + commitment_root_mmr_sibling: ZERO_HASH, + previous_root_history_proof: ( + ZERO_HASH, + MMRProof::new(vec![ZERO_HASH; MMR_PROOF_PATH_LEN], 0), + ), + commitment_account_state_hash: ZERO_HASH, + commitment_out_coins_root: ZERO_HASH, + } +} + +/// Build a syntactically-valid but semantically-empty +/// [`InclusionProof`] for use as the dummy source-inclusion-path +/// witness on inactive in-coin slots (Stage 5d-next-5 Phase 2b). +/// +/// `siblings.len() == TREE_DEPTH` so the witness-setter's length +/// assert passes; values are all `ZERO_HASH` and the in-circuit +/// inclusion check is masked off by `slot.active = 0`. +/// +/// [`InclusionProof`]: crate::merkle::sparse_merkle_tree::InclusionProof +fn dummy_inclusion_proof() -> InclusionProof { + InclusionProof { + key: [0u8; 32], + siblings: vec![ZERO_HASH; TREE_DEPTH], + } +} + +/// Set the coin-history-side witnesses for one in-coin slot +/// (Stage 5d-next-3 surface — `active`, identifier, recipient, amount, +/// non-inclusion path in the running `coin_history_root`). +/// +/// Stage 5d-next-5 Phase 2b's source-side witnesses +/// (`source_inclusion_path`, `source_cmp`) are set separately by +/// [`set_source_inclusion_witness`] + [`set_cmp_targets_witness`]. +/// This split keeps the (still cheap) coin-history-side independent +/// of the (substantially bigger) source-side witness bundle. +/// +/// Inactive slots get a dummy non-inclusion proof against an arbitrary +/// (zeroed) `coin_history_root` plus zero recipient/amount; the masked +/// checks are satisfied vacuously by the slot's `active = false` bit. +fn set_in_coin_slot_witness( + pw: &mut PartialWitness, + slot: &InCoinSlotTargets, + active: bool, + coin_identifier: HashDigest, + coin_recipient: HashDigest, + coin_amount: u64, + nip: &NonInclusionProof, +) { + pw.set_bool_target(slot.active, active).unwrap(); + pw.set_hash_target(slot.coin_identifier, coin_identifier) + .unwrap(); + pw.set_hash_target(slot.coin_recipient, coin_recipient) + .unwrap(); + pw.set_target( + slot.coin_amount_lo, + F::from_canonical_u32((coin_amount & 0xFFFF_FFFF) as u32), + ) + .unwrap(); + pw.set_target( + slot.coin_amount_hi, + F::from_canonical_u32((coin_amount >> 32) as u32), + ) + .unwrap(); + assert_eq!( + nip.siblings.len(), + TREE_DEPTH, + "InCoinSlot: non-inclusion proof must be padded to TREE_DEPTH siblings" + ); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(slot.nip_path[i], *sib).unwrap(); + } +} + +/// Set the source-side SMT-inclusion-path witness for one in-coin slot +/// (Stage 5d-next-5 Phase 2b). Mirrors [`set_in_coin_slot_witness`]'s +/// `nip` handling: the path must be padded to [`TREE_DEPTH`] siblings; +/// the in-circuit SMT inclusion check fires only when `slot.active = 1`. +fn set_source_inclusion_witness( + pw: &mut PartialWitness, + slot: &InCoinSlotTargets, + inclusion: &InclusionProof, +) { + assert_eq!( + inclusion.siblings.len(), + TREE_DEPTH, + "InCoinSlot: source inclusion proof must be padded to TREE_DEPTH siblings" + ); + for (i, sib) in inclusion.siblings.iter().enumerate() { + pw.set_hash_target(slot.source_inclusion_path[i], *sib) + .unwrap(); + } +} + +/// Per-active-in-coin-slot witness bundle for Phase 2b proves. Mirrors +/// what the off-circuit producer must supply to satisfy the SPEC §8 +/// step 2 source-side checks: +/// +/// - `source_proof`: the source state-transition proof whose +/// `output_coins_root` contains the in-coin's `identifier`. Verified +/// through the aggregator's slot-`i` `conditionally_verify_proof`. +/// - `source_inclusion`: SMT inclusion of the in-coin's `identifier` +/// in `source_proof`'s `output_coins_root` (`siblings.len() == +/// TREE_DEPTH`). +/// - `source_cmp`: [`CommitmentMerkleProofs`] establishing that the +/// source proof's commitment `h(asth || ocr)` is published in the +/// global `history_root` per SPEC §8 (c)(d)(e). +pub struct InCoinSourceWitness<'a> { + pub source_proof: &'a ProofWithPublicInputs, + pub source_inclusion: &'a InclusionProof, + pub source_cmp: &'a CommitmentMerkleProofs, +} + +/// Build a dummy `Coin` for populating inactive in-coin slot +/// witnesses. The slot's `active = false` bit masks off the +/// recipient and balance-update constraints, so the values are +/// irrelevant — `ZERO_HASH` identifier / `ZERO_HASH` recipient / +/// `amount = 0` is the cheapest placeholder. +fn dummy_coin() -> Coin { + Coin { + identifier: ZERO_HASH, + recipient: ZERO_HASH, + amount: 0, + } +} + +/// Set the witnesses for one out-coin slot. Inactive slots use the +/// `dummy_coin` + `dummy_non_inclusion_proof` placeholders. +fn set_out_coin_slot_witness( + pw: &mut PartialWitness, + slot: &OutCoinSlotTargets, + active: bool, + out_coin_identifier: HashDigest, + out_coin_amount: u64, + nip: &NonInclusionProof, +) { + pw.set_bool_target(slot.active, active).unwrap(); + pw.set_hash_target(slot.out_coin_identifier, out_coin_identifier) + .unwrap(); + pw.set_target( + slot.out_coin_amount_lo, + F::from_canonical_u32((out_coin_amount & 0xFFFF_FFFF) as u32), + ) + .unwrap(); + pw.set_target( + slot.out_coin_amount_hi, + F::from_canonical_u32((out_coin_amount >> 32) as u32), + ) + .unwrap(); + assert_eq!( + nip.siblings.len(), + TREE_DEPTH, + "OutCoinSlot: non-inclusion proof must be padded to TREE_DEPTH siblings" + ); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(slot.nip_path[i], *sib).unwrap(); + } +} + +/// Set the witnesses for the rotated public key. Used by all prove +/// paths. If the caller doesn't want pubkey rotation (e.g., Initial +/// proof without out-coins), pass `account_state.public_key` to keep +/// the final `account_state_hash` aligned with the off-circuit +/// `AccountState::hash`. +fn set_next_public_key_witness( + pw: &mut PartialWitness, + circuit: &StateTransitionCircuit, + next_public_key: &PublicKey, +) { + for (i, chunk) in next_public_key.chunks(7).enumerate() { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + pw.set_target( + circuit.next_public_key_limbs[i], + F::from_canonical_u64(u64::from_le_bytes(buf)), + ) + .unwrap(); + } +} + +/// Build a dummy `NonInclusionProof` for populating inactive in-coin +/// slot witnesses. Every sibling is `ZERO_HASH`; the slot's `active` +/// bit being `false` masks off the in-circuit checks regardless. +fn dummy_non_inclusion_proof() -> NonInclusionProof { + NonInclusionProof { + key: [0u8; 32], + root: ZERO_HASH, + siblings: vec![ZERO_HASH; TREE_DEPTH], + } +} + +/// Prove the Initial-branch state transition for a given `account_state` +/// and `history_root`. +/// +/// All `MAX_IN_COINS` slots are populated with inactive dummies — Stage 5d +/// could in principle allow Init proofs to also receive in-coins (per +/// SPEC §8 the Initial branch falls through to the in-coins loop), but +/// the test fixtures here demonstrate only the empty-in-coins case. +/// To prove an Initial proof with active in-coin slots, use +/// [`prove_initial_with_in_coins`]. +pub fn prove_initial( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, +) -> Result> { + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_coin = dummy_coin(); + let inactive_slots: Vec<(bool, &Coin, &NonInclusionProof)> = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_coin, &dummy_nip)) + .collect(); + prove_initial_with_in_coins(circuit, account_state, history_root, &inactive_slots) +} + +/// Like [`prove_initial`] but with caller-supplied in-coin slot +/// witnesses. Each tuple is `(active, &coin, &non_inclusion_proof)`; +/// the caller MUST supply exactly `MAX_IN_COINS` tuples. Inactive slots +/// can pass the [`dummy_coin`] / [`dummy_non_inclusion_proof`] +/// placeholders regardless of the current `coin_history_root` and +/// running balance — the slot's `active = false` bit masks all +/// in-circuit checks. +pub fn prove_initial_with_in_coins( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], +) -> Result> { + assert_eq!( + in_coins.len(), + MAX_IN_COINS, + "prove_initial_with_in_coins: caller must supply exactly MAX_IN_COINS slot witnesses" + ); + let dummy_nip = dummy_non_inclusion_proof(); + let inactive_out_coins: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = (0..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect(); + prove_initial_with_in_and_out_coins( + circuit, + account_state, + history_root, + in_coins, + &inactive_out_coins, + &account_state.public_key, + ) +} + +/// Like [`prove_initial`] but with caller-supplied in-coin AND +/// out-coin slot witnesses, plus an explicit `next_public_key` the +/// account rotates to. +/// +/// Stage 5d-next-5 Phase 2b note: this entry point delegates to +/// [`prove_initial_with_in_and_out_coins_and_sources`] with +/// all-`None` sources. It is therefore only suitable for Initial +/// transitions whose `in_coins` are ALL inactive — an active in-coin +/// slot without a source witness fails the `connect(slot.active, +/// source.active)` constraint at proof time. Tests and producers that +/// need an active in-coin must call the `_and_sources` variant. +pub fn prove_initial_with_in_and_out_coins( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, +) -> Result> { + let sources: Vec> = (0..MAX_IN_COINS).map(|_| None).collect(); + prove_initial_with_in_and_out_coins_and_sources( + circuit, + account_state, + history_root, + in_coins, + out_coins, + next_public_key, + &sources, + ) +} + +/// Stage 5d-next-5 Phase 2b: prove an Initial-branch transition with +/// caller-supplied in-coin AND out-coin slot witnesses AND a per-slot +/// source witness bundle for active in-coin slots. +/// +/// `sources.len()` must equal [`MAX_IN_COINS`]. Each entry corresponds +/// positionally to the `in_coins` entry of the same index: `Some(_)` +/// supplies the source proof / inclusion / CMP for an active slot; +/// `None` indicates the slot is inactive (in which case +/// `in_coins[i].0` must also be `false`, else the source-side +/// constraints reject). +/// +/// The aggregator's per-slot active bits are derived from `sources` +/// (every `Some(_)` becomes an active aggregator slot with the +/// supplied `source_proof`); the in-circuit `connect(slot.active, +/// aggregator.slot.active)` enforces consistency with the +/// caller-supplied `in_coins` active bits. +#[allow(clippy::too_many_arguments)] +pub fn prove_initial_with_in_and_out_coins_and_sources( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + sources: &[Option], +) -> Result> { + assert_eq!( + in_coins.len(), + MAX_IN_COINS, + "prove_initial_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS in-coin slot witnesses" + ); + assert_eq!( + out_coins.len(), + MAX_OUT_COINS, + "prove_initial_with_in_and_out_coins_and_sources: caller must supply exactly MAX_OUT_COINS out-coin slot witnesses" + ); + assert_eq!( + sources.len(), + MAX_IN_COINS, + "prove_initial_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS source witnesses" + ); + + let mut pw = PartialWitness::new(); + pw.set_bool_target(circuit.condition, false).unwrap(); + set_account_state_witness(&mut pw, circuit, account_state); + pw.set_hash_target(circuit.history_root, history_root) + .unwrap(); + set_cmp_witness(&mut pw, circuit, &dummy_cmp()); + for (slot_targets, (active, coin, nip)) in circuit.in_coin_slots.iter().zip(in_coins.iter()) { + set_in_coin_slot_witness( + &mut pw, + slot_targets, + *active, + coin.identifier, + coin.recipient, + coin.amount, + nip, + ); + } + set_per_slot_source_witnesses(&mut pw, circuit, sources); + for (slot_targets, (active, identifier, amount, nip)) in + circuit.out_coin_slots.iter().zip(out_coins.iter()) + { + set_out_coin_slot_witness(&mut pw, slot_targets, *active, *identifier, *amount, nip); + } + set_next_public_key_witness(&mut pw, circuit, next_public_key); + set_aggregator_proof_witness_from_sources(&mut pw, circuit, sources)?; + + // Dummy inner proof for the cyclic-recursion slot. + let inner_pis = std::iter::empty::<(usize, F)>().collect(); + pw.set_proof_with_pis_target::( + &circuit.inner_proof_target, + &cyclic_base_proof(&circuit.common_data, &circuit.data.verifier_only, inner_pis), + ) + .unwrap(); + pw.set_verifier_data_target(&circuit.verifier_data_target, &circuit.data.verifier_only) + .unwrap(); + + circuit.data.prove(pw) +} + +/// Per-slot Phase 2b source-witness setter. Walks `sources` and +/// writes the source-inclusion path + source CMP for each slot — +/// `Some(_)` entries get the caller-supplied witnesses, `None` +/// entries get [`dummy_inclusion_proof`] + [`dummy_cmp`]. The +/// in-circuit checks are masked by the slot's `active` bit so dummy +/// witnesses on inactive slots are vacuous. +fn set_per_slot_source_witnesses( + pw: &mut PartialWitness, + circuit: &StateTransitionCircuit, + sources: &[Option], +) { + let dummy_incl = dummy_inclusion_proof(); + let dummy_c = dummy_cmp(); + for (slot_targets, source) in circuit.in_coin_slots.iter().zip(sources.iter()) { + match source { + Some(s) => { + set_source_inclusion_witness(pw, slot_targets, s.source_inclusion); + set_cmp_targets_witness(pw, &slot_targets.source_cmp, s.source_cmp); + } + None => { + set_source_inclusion_witness(pw, slot_targets, &dummy_incl); + set_cmp_targets_witness(pw, &slot_targets.source_cmp, &dummy_c); + } + } + } +} + +/// Stage 5d-next-5 Phase 2b aggregator-witness setter. Builds an +/// aggregator proof from the per-slot source witnesses: every +/// `Some(_)` entry becomes an active aggregator slot with the +/// supplied `source_proof`; every `None` entry an inactive slot. +fn set_aggregator_proof_witness_from_sources( + pw: &mut PartialWitness, + circuit: &StateTransitionCircuit, + sources: &[Option], +) -> Result<()> { + let slot_witnesses: Vec = sources + .iter() + .map(|s| match s { + Some(src) => AggregatorSlotWitness { + active: true, + real_proof: Some(src.source_proof), + }, + None => AggregatorSlotWitness { + active: false, + real_proof: None, + }, + }) + .collect(); + let agg_proof = prove_aggregator( + &circuit.aggregator, + &circuit.data.verifier_only, + &slot_witnesses, + )?; + pw.set_proof_with_pis_target::(&circuit.aggregator_proof_target, &agg_proof) + .unwrap(); + Ok(()) +} + +/// Prove an AccountUpdate transition consuming `prev` as the recursive +/// inner proof plus a [`CommitmentMerkleProofs`] witnessing that `prev` +/// is published in the global history at `history_root`. +/// +/// The proof's history-side fields (SMT inclusion path, MMR inclusion +/// paths) must be pre-padded to the fixed shape the circuit expects: +/// - `commitment_proof.siblings.len() == TREE_DEPTH = 256` +/// - `commitment_root_history_proof.path.len() == MMR_PROOF_PATH_LEN = 31` +/// - `previous_root_history_proof.1.path.len() == MMR_PROOF_PATH_LEN = 31` +/// +/// The `history_root` parameter must be +/// `mmr.root_extended(MMR_PROOF_PATH_LEN)` for the same MMR depth +/// (see [`crate::merkle::merkle_mountain_range::MerkleMountainRange::root_extended`]). +pub fn prove_account_update( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + prev: &ProofWithPublicInputs, + cmp: &CommitmentMerkleProofs, +) -> Result> { + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_coin = dummy_coin(); + let inactive_slots: Vec<(bool, &Coin, &NonInclusionProof)> = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_coin, &dummy_nip)) + .collect(); + prove_account_update_with_in_coins( + circuit, + account_state, + history_root, + prev, + cmp, + &inactive_slots, + ) +} + +/// Like [`prove_account_update`] but with caller-supplied in-coin slot +/// witnesses. See [`prove_initial_with_in_coins`] for the contract on +/// the `in_coins` slice. +pub fn prove_account_update_with_in_coins( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + prev: &ProofWithPublicInputs, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], +) -> Result> { + assert_eq!( + in_coins.len(), + MAX_IN_COINS, + "prove_account_update_with_in_coins: caller must supply exactly MAX_IN_COINS slot witnesses" + ); + let dummy_nip = dummy_non_inclusion_proof(); + let inactive_out_coins: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = (0..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect(); + prove_account_update_with_in_and_out_coins( + circuit, + account_state, + history_root, + prev, + cmp, + in_coins, + &inactive_out_coins, + &account_state.public_key, + ) +} + +/// Like [`prove_account_update`] but with caller-supplied in-coin AND +/// out-coin slot witnesses, plus an explicit `next_public_key`. +/// +/// Stage 5d-next-5 Phase 2b note: this entry point delegates to +/// [`prove_account_update_with_in_and_out_coins_and_sources`] with +/// all-`None` sources. Only suitable for AccountUpdate transitions +/// whose `in_coins` are ALL inactive. +#[allow(clippy::too_many_arguments)] +pub fn prove_account_update_with_in_and_out_coins( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + prev: &ProofWithPublicInputs, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, +) -> Result> { + let sources: Vec> = (0..MAX_IN_COINS).map(|_| None).collect(); + prove_account_update_with_in_and_out_coins_and_sources( + circuit, + account_state, + history_root, + prev, + cmp, + in_coins, + out_coins, + next_public_key, + &sources, + ) +} + +/// Stage 5d-next-5 Phase 2b: prove an AccountUpdate-branch transition +/// with caller-supplied in-coin AND out-coin slot witnesses AND a +/// per-slot source witness bundle for active in-coin slots. +/// +/// Contract is symmetric with +/// [`prove_initial_with_in_and_out_coins_and_sources`]: `sources.len() +/// == MAX_IN_COINS`; `Some(_)` ⇔ active slot with real source proof; +/// `None` ⇔ inactive slot. +#[allow(clippy::too_many_arguments)] +pub fn prove_account_update_with_in_and_out_coins_and_sources( + circuit: &StateTransitionCircuit, + account_state: &AccountState, + history_root: HashDigest, + prev: &ProofWithPublicInputs, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + sources: &[Option], +) -> Result> { + assert_eq!( + in_coins.len(), + MAX_IN_COINS, + "prove_account_update_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS in-coin slot witnesses" + ); + assert_eq!( + out_coins.len(), + MAX_OUT_COINS, + "prove_account_update_with_in_and_out_coins_and_sources: caller must supply exactly MAX_OUT_COINS out-coin slot witnesses" + ); + assert_eq!( + sources.len(), + MAX_IN_COINS, + "prove_account_update_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS source witnesses" + ); + + let mut pw = PartialWitness::new(); + pw.set_bool_target(circuit.condition, true).unwrap(); + set_account_state_witness(&mut pw, circuit, account_state); + pw.set_hash_target(circuit.history_root, history_root) + .unwrap(); + set_cmp_witness(&mut pw, circuit, cmp); + for (slot_targets, (active, coin, nip)) in circuit.in_coin_slots.iter().zip(in_coins.iter()) { + set_in_coin_slot_witness( + &mut pw, + slot_targets, + *active, + coin.identifier, + coin.recipient, + coin.amount, + nip, + ); + } + set_per_slot_source_witnesses(&mut pw, circuit, sources); + for (slot_targets, (active, identifier, amount, nip)) in + circuit.out_coin_slots.iter().zip(out_coins.iter()) + { + set_out_coin_slot_witness(&mut pw, slot_targets, *active, *identifier, *amount, nip); + } + set_next_public_key_witness(&mut pw, circuit, next_public_key); + set_aggregator_proof_witness_from_sources(&mut pw, circuit, sources)?; + + pw.set_proof_with_pis_target::(&circuit.inner_proof_target, prev) + .unwrap(); + pw.set_verifier_data_target(&circuit.verifier_data_target, &circuit.data.verifier_only) + .unwrap(); + + circuit.data.prove(pw) +} + +/// Verify a state-transition proof, including the cross-check that its +/// embedded verifier-data digest matches the circuit's own. +pub fn verify( + circuit: &StateTransitionCircuit, + proof: &ProofWithPublicInputs, +) -> Result<()> { + check_cyclic_proof_verifier_data(proof, &circuit.data.verifier_only, &circuit.data.common)?; + circuit.data.verify(proof.clone()) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::{digest_to_bytes, hash_bytes, hash_concat}; + use crate::inputs::CommitmentMerkleProofs; + use crate::merkle::merkle_mountain_range::MerkleMountainRange; + use crate::merkle::sparse_merkle_tree::SparseMerkleTree; + use crate::types::ProofData; + + fn dummy_pubkey(seed: u8) -> [u8; 33] { + let mut pk = [0u8; 33]; + pk[0] = 0x02; + for (i, b) in pk.iter_mut().enumerate().skip(1) { + *b = seed.wrapping_add(i as u8); + } + pk + } + + fn pis_as_proof_data(proof: &ProofWithPublicInputs) -> ProofData { + let pis: [F; N_PROOF_DATA_PUBLIC_INPUTS] = proof.public_inputs + [..N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .unwrap(); + ProofData::from_field_elements(&pis) + } + + /// Test helper: build a `MAX_IN_COINS`-length slot array with the + /// first slot active (`(true, coin, nip)`) and all remaining slots + /// inactive (`(false, dummy_coin, dummy_nip)`). Callers must pin + /// the dummy values in local variables so their references outlive + /// the returned vector. + fn slots_first_active<'a>( + coin: &'a Coin, + nip: &'a NonInclusionProof, + dummy_coin: &'a Coin, + dummy_nip: &'a NonInclusionProof, + ) -> Vec<(bool, &'a Coin, &'a NonInclusionProof)> { + let mut v = Vec::with_capacity(MAX_IN_COINS); + v.push((true, coin, nip)); + for _ in 1..MAX_IN_COINS { + v.push((false, dummy_coin, dummy_nip)); + } + v + } + + /// Phase 2b test helper: build a `MAX_IN_COINS`-length source + /// witness array with the first slot populated (`Some(_)`) and the + /// rest inactive (`None`). + fn sources_first_active<'a>( + source: &'a InCoinSourceWitness<'a>, + ) -> Vec>> { + let mut v: Vec>> = Vec::with_capacity(MAX_IN_COINS); + v.push(Some(InCoinSourceWitness { + source_proof: source.source_proof, + source_inclusion: source.source_inclusion, + source_cmp: source.source_cmp, + })); + for _ in 1..MAX_IN_COINS { + v.push(None); + } + v + } + + /// Phase 2b test fixture for AccountUpdate-with-source: build a + /// source state-transition proof AND a prev-account Initial proof, + /// fold BOTH commitments into a shared history MMR (source at leaf + /// 0, prev-account at leaf 1), and return CMPs + an inclusion + /// proof for the source-emitted coin in the source's `OCR`. + /// + /// Returns: `(source_proof, coin_identifier, source_inclusion, + /// source_cmp, prev_proof, consumer_cmp, history_root_extended)`. + /// + /// Wall-time: ~80 s on M3 (two Init proves: one source, one + /// consumer prev). + #[allow(clippy::type_complexity)] + fn build_test_source_and_prev_witnesses( + circuit: &StateTransitionCircuit, + source_seed: u8, + consumer_account_state: &AccountState, + out_amount: u64, + ) -> ( + ProofWithPublicInputs, + HashDigest, + InclusionProof, + CommitmentMerkleProofs, + ProofWithPublicInputs, + CommitmentMerkleProofs, + HashDigest, + ) { + // 1. Source: mint account emitting one out-coin. + let mut source_account = AccountState::new(dummy_pubkey(source_seed)); + source_account.owner = *MINTING_ADDRESS; + source_account.balance = out_amount + 1_000; + let mut post_source = source_account.clone(); + post_source.balance -= out_amount; + let interim_source_asth = post_source.hash(); + let coin_id = crate::types::calculate_coin_identifier(interim_source_asth, 0); + let out_id_key = digest_to_bytes(&coin_id); + let empty_smt = SparseMerkleTree::new(); + let out_nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins_inactive: Vec<(bool, &Coin, &NonInclusionProof)> = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect(); + let out_coins_source = out_slots_first_active(coin_id, out_amount, &out_nip, &dummy_nip); + let source_proof = prove_initial_with_in_and_out_coins( + circuit, + &source_account, + ZERO_HASH, + &in_coins_inactive, + &out_coins_source, + &source_account.public_key, + ) + .expect("prove source Init"); + + // 2. Consumer prev: Initial with all-inactive in/out-coins. + // Goes against empty history (same bootstrap pattern as + // source). + let prev_proof = prove_initial(circuit, consumer_account_state, ZERO_HASH) + .expect("prove consumer prev Init"); + + // 3. Source's commitment SMT. + let source_pd = pis_as_proof_data(&source_proof); + let source_asth = source_pd.account_state_hash; + let source_ocr = source_pd.output_coins_root; + let source_pk_hash = hash_bytes(b"phase-2b-source-pk-hash"); + let source_pk_key = digest_to_bytes(&source_pk_hash); + let source_commitment = hash_concat(&source_asth, &source_ocr); + let mut source_smt = SparseMerkleTree::new(); + source_smt.insert(source_pk_key, source_commitment).unwrap(); + let source_smt_root = source_smt.root(); + let (source_smt_incl, _) = source_smt.generate_inclusion_proof(&source_pk_key).unwrap(); + + // 4. Consumer prev's commitment SMT. + let prev_pd = pis_as_proof_data(&prev_proof); + let prev_asth = prev_pd.account_state_hash; + let prev_ocr = prev_pd.output_coins_root; + let consumer_pk_hash = hash_bytes(b"phase-2b-consumer-pk-hash"); + let consumer_pk_key = digest_to_bytes(&consumer_pk_hash); + let consumer_commitment = hash_concat(&prev_asth, &prev_ocr); + let mut consumer_smt = SparseMerkleTree::new(); + consumer_smt + .insert(consumer_pk_key, consumer_commitment) + .unwrap(); + let consumer_smt_root = consumer_smt.root(); + let (consumer_smt_incl, _) = consumer_smt + .generate_inclusion_proof(&consumer_pk_key) + .unwrap(); + + // 5. Two-leaf MMR. Both source and consumer prev proved against + // ZERO_HASH (empty history) — bootstrap pattern. The (e) + // check `h(prev_smt_in_mmr_leaf || prev.commitment_history_root)` + // expects a leaf of shape `h(X || ZERO_HASH)` in the MMR. + // Only the FIRST-folded leaf has that shape (sibling = + // empty MMR root = ZERO_HASH). To make BOTH CMPs verifiable + // against the same MMR, we: + // + // - Fold consumer at index 0 (sibling = ZERO_HASH); + // consumer's CMP (d) and (e) use index 0 — standard + // bootstrap. + // - Fold source at index 1 (sibling = + // `mmr_root_after_consumer_in_tree`); source's CMP (d) + // uses index 1. + // - Source's (e) "borrows" consumer's bootstrap shape: + // since source.commitment_history_root = ZERO_HASH and + // consumer's leaf is the ONLY h(? || ZERO_HASH) leaf in + // the MMR, source.prev_smt_in_mmr_leaf = + // consumer_smt_root and source.previous_root_history_proof.1 + // = consumer_mmr_proof. The (e) check witnesses "some + // h(_ || ZERO_HASH) leaf exists in history" — semantically + // verifying that empty history is a prefix of current + // history, which is trivially true here. + let mut mmr = MerkleMountainRange::new(); + let mmr_leaf_consumer = hash_concat(&consumer_smt_root, &ZERO_HASH); + mmr.append(mmr_leaf_consumer); + let mmr_root_after_consumer = mmr.root(); + let mmr_leaf_source = hash_concat(&source_smt_root, &mmr_root_after_consumer); + mmr.append(mmr_leaf_source); + let history_root_ext = mmr.root_extended(MMR_PROOF_PATH_LEN); + let consumer_mmr_proof = mmr.get_proof(0).unwrap().extend_to(MMR_PROOF_PATH_LEN); + let source_mmr_proof = mmr.get_proof(1).unwrap().extend_to(MMR_PROOF_PATH_LEN); + assert!(consumer_mmr_proof.verify(mmr_leaf_consumer, history_root_ext)); + assert!(source_mmr_proof.verify(mmr_leaf_source, history_root_ext)); + + // 6. Source's CMP. (d) at index 1 with sibling = post-consumer + // MMR root; (e) borrows consumer's bootstrap leaf at index + // 0 since source's prior history was also empty. + let source_cmp = CommitmentMerkleProofs { + commitment_root: source_smt_root, + commitment_proof: source_smt_incl, + commitment_root_history_proof: source_mmr_proof, + commitment_root_mmr_sibling: mmr_root_after_consumer, + previous_root_history_proof: (consumer_smt_root, consumer_mmr_proof.clone()), + commitment_account_state_hash: source_asth, + commitment_out_coins_root: source_ocr, + }; + + // 7. Consumer prev's CMP. Standard bootstrap at MMR index 0: + // (d) and (e) both use the same leaf since + // prev.commitment_history_root = ZERO_HASH. + let consumer_cmp = CommitmentMerkleProofs { + commitment_root: consumer_smt_root, + commitment_proof: consumer_smt_incl, + commitment_root_history_proof: consumer_mmr_proof.clone(), + commitment_root_mmr_sibling: ZERO_HASH, + previous_root_history_proof: (consumer_smt_root, consumer_mmr_proof), + commitment_account_state_hash: prev_asth, + commitment_out_coins_root: prev_ocr, + }; + + // 8. Source's inclusion proof for coin_id in source.OCR. + let coin_key = digest_to_bytes(&coin_id); + let source_inclusion = InclusionProof { + key: coin_key, + siblings: out_nip.siblings.clone(), + }; + assert!( + source_inclusion.verify(coin_id, source_ocr), + "source inclusion proof off-circuit verify must match source's published OCR" + ); + + ( + source_proof, + coin_id, + source_inclusion, + source_cmp, + prev_proof, + consumer_cmp, + history_root_ext, + ) + } + + /// Phase 2b test fixture: build a real source state-transition + /// proof emitting one out-coin, along with the + /// SMT-inclusion-of-coin-in-source-OCR proof, the source's + /// [`CommitmentMerkleProofs`] published in a fresh history MMR, + /// and the extended `history_root` the consumer must prove + /// against. + /// + /// Returns: `(source_proof, coin_identifier, source_inclusion, + /// source_cmp, history_root_extended, source_post_account_state)`. + /// The `source_post_account_state` is the source's + /// post-out-coin-subtraction `AccountState` (with original + /// pubkey) — useful for fixtures that need to chain further + /// updates on the source side. + /// + /// Wall-time: ~40 s on M3 (one extra Init prove). + #[allow(clippy::type_complexity)] + fn build_test_source_witness( + circuit: &StateTransitionCircuit, + source_seed: u8, + out_amount: u64, + ) -> ( + ProofWithPublicInputs, + HashDigest, + InclusionProof, + CommitmentMerkleProofs, + HashDigest, + AccountState, + ) { + // 1. Source: mint account with enough balance to emit out_amount. + let mut source_account = AccountState::new(dummy_pubkey(source_seed)); + source_account.owner = *MINTING_ADDRESS; + source_account.balance = out_amount + 1_000; + + // 2. Compute interim asth (post out-coin subtraction, pre pubkey + // rotation) and derive the source's slot-0 out-coin identifier. + let mut post_source = source_account.clone(); + post_source.balance -= out_amount; + let interim_asth = post_source.hash(); + let coin_id = crate::types::calculate_coin_identifier(interim_asth, 0); + + // 3. Build the source's out-coin NIP in the empty SMT. + let out_id_key = digest_to_bytes(&coin_id); + let empty_smt = SparseMerkleTree::new(); + let out_nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + + // 4. Slot arrays: no in-coins, slot 0 out-coin active. + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins: Vec<(bool, &Coin, &NonInclusionProof)> = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect(); + let out_coins = out_slots_first_active(coin_id, out_amount, &out_nip, &dummy_nip); + + // 5. Prove source Init against empty history. + let source_proof = prove_initial_with_in_and_out_coins( + circuit, + &source_account, + ZERO_HASH, + &in_coins, + &out_coins, + &source_account.public_key, + ) + .expect("prove source Init"); + + // 6. Extract source's ProofData from PIs. + let source_pd = pis_as_proof_data(&source_proof); + let source_asth = source_pd.account_state_hash; + let source_ocr = source_pd.output_coins_root; + + // 7. Build source's CMP: commitment is in a freshly-folded + // history MMR. Bootstrap pattern (same shape as + // `build_test_commitment_witness`). + let source_pk_hash = hash_bytes(b"phase-2b-source-pk-hash"); + let source_pk_key = digest_to_bytes(&source_pk_hash); + let source_commitment = hash_concat(&source_asth, &source_ocr); + let mut smt = SparseMerkleTree::new(); + smt.insert(source_pk_key, source_commitment).unwrap(); + let smt_root = smt.root(); + let (smt_incl, _) = smt.generate_inclusion_proof(&source_pk_key).unwrap(); + + let prev_mmr_root = ZERO_HASH; + let mmr_leaf = hash_concat(&smt_root, &prev_mmr_root); + let mut mmr = MerkleMountainRange::new(); + mmr.append(mmr_leaf); + let history_root_ext = mmr.root_extended(MMR_PROOF_PATH_LEN); + let mmr_proof = mmr.get_proof(0).unwrap().extend_to(MMR_PROOF_PATH_LEN); + assert!(mmr_proof.verify(mmr_leaf, history_root_ext)); + + let source_cmp = CommitmentMerkleProofs { + commitment_root: smt_root, + commitment_proof: smt_incl, + commitment_root_history_proof: mmr_proof.clone(), + commitment_root_mmr_sibling: prev_mmr_root, + previous_root_history_proof: (smt_root, mmr_proof), + commitment_account_state_hash: source_asth, + commitment_out_coins_root: source_ocr, + }; + + // 8. Build source's inclusion proof for coin_id in + // source.output_coins_root. + // + // **Slot-0 / single-out-coin fixture only.** This helper + // emits exactly one out-coin (slot 0) into an empty SMT, so + // the inclusion-proof siblings equal the non-inclusion-proof + // siblings (the empty-tree path is unchanged outside the + // leaf's position). If this fixture is ever extended to + // produce multi-out-coin sources (slots > 0), the inclusion + // siblings MUST be re-derived from the SMT *after* each + // prior-slot insert — see + // [`SparseMerkleTree::generate_inclusion_proof`] which + // returns the correct siblings against the tree's current + // state. Production [`account_node::send_coins`] already + // does this correctly via `out_coins_tree.generate_inclusion_proof` + // on the final tree; this restriction is fixture-only. + // + // TODO(stage-5d-next-5-followup): extend this fixture for + // multi-out-coin sources once a test scenario requires it. + let coin_key = digest_to_bytes(&coin_id); + let source_inclusion = InclusionProof { + key: coin_key, + siblings: out_nip.siblings.clone(), + }; + // Off-circuit sanity: the inclusion proof verifies against the + // source's claimed `output_coins_root`. + assert!( + source_inclusion.verify(coin_id, source_ocr), + "source inclusion proof off-circuit verify must match source's published OCR" + ); + + ( + source_proof, + coin_id, + source_inclusion, + source_cmp, + history_root_ext, + post_source, + ) + } + + /// Stage 5c+ Initial-side smoke test (unchanged behaviour from 5c): + /// a non-mint account with `balance = 0` is accepted, and the + /// public-input `ProofData` matches the off-circuit reconstruction. + /// The CommitmentMerkleProofs witness is the empty dummy. + #[test] + fn stage_5c_plus_initial_non_mint_zero_balance_accepted() { + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(7)); + assert_ne!(account_state.owner, *MINTING_ADDRESS); + + let history_root = hash_bytes(b"history@5c+-init"); + let proof = prove_initial(&circuit, &account_state, history_root).expect("prove initial"); + verify(&circuit, &proof).expect("verify initial"); + + let recovered = pis_as_proof_data(&proof); + assert_eq!(recovered.account_state_hash, account_state.hash()); + assert_eq!(recovered.coin_history_root, DEFAULT_HASHES[0]); + } + + /// Mint exception under the masked predicate. + #[test] + fn stage_5c_plus_initial_mint_with_balance_accepted() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(99)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 21_000_000_000_000; + + let history_root = hash_bytes(b"history@5c+-mint"); + let proof = prove_initial(&circuit, &account_state, history_root).expect("prove mint"); + verify(&circuit, &proof).expect("verify mint"); + } + + /// Mint-exception negative. + #[test] + fn stage_5c_plus_initial_non_mint_nonzero_balance_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(7)); + assert_ne!(account_state.owner, *MINTING_ADDRESS); + account_state.balance = 1; + + let history_root = hash_bytes(b"history@5c+-illegal"); + assert!(prove_initial(&circuit, &account_state, history_root).is_err()); + } + + /// Build a `CommitmentMerkleProofs` witness for an Initial → AccountUpdate + /// chain on the same account state. + /// + /// The off-circuit setup mirrors what the server scanner would do: + /// 1. Build the commitment value `c = h(asth || ocr)` for the prev proof. + /// 2. Build the SMT containing `(pk_hash → c)`. + /// 3. Fold the SMT root into the history MMR alongside the empty prev + /// MMR root. + /// 4. Build extended MMR proofs (a) and (e) at depth + /// `MMR_PROOF_PATH_LEN`. + /// + /// Returns `(cmp, extended_history_root)`. + fn build_test_commitment_witness( + prev_asth: HashDigest, + prev_ocr: HashDigest, + ) -> (CommitmentMerkleProofs, HashDigest) { + // SMT key derived from the prev pubkey hash (placeholder bytes). + let pk_hash = hash_bytes(b"5c+-test-pubkey"); + let pk_key = digest_to_bytes(&pk_hash); + + // Commitment value committed to in the SMT. + let commitment = hash_concat(&prev_asth, &prev_ocr); + + let mut smt = SparseMerkleTree::new(); + smt.insert(pk_key, commitment).unwrap(); + let smt_root = smt.root(); + let (smt_inclusion, _) = smt.generate_inclusion_proof(&pk_key).unwrap(); + + // History MMR: fold `(smt_root, ZERO_HASH)` as the first leaf. + // The bootstrap pattern: Init was proved against the empty + // history (`prev.commitment_history_root == ZERO_HASH`), so the + // (e) MMR leaf `h(smt_root || prev.commitment_history_root)` + // coincides with the (d) MMR leaf `h(smt_root || prev_mmr_root)`. + // Both MMR proofs point to the same MMR leaf at index 0. + let prev_mmr_root = ZERO_HASH; + let mmr_leaf = hash_concat(&smt_root, &prev_mmr_root); + let mut mmr = MerkleMountainRange::new(); + mmr.append(mmr_leaf); + let history_root_extended = mmr.root_extended(MMR_PROOF_PATH_LEN); + let mmr_proof = mmr.get_proof(0).unwrap().extend_to(MMR_PROOF_PATH_LEN); + assert!(mmr_proof.verify(mmr_leaf, history_root_extended)); + + let cmp = CommitmentMerkleProofs { + commitment_root: smt_root, + commitment_proof: smt_inclusion, + commitment_root_history_proof: mmr_proof.clone(), + commitment_root_mmr_sibling: prev_mmr_root, + previous_root_history_proof: (smt_root, mmr_proof), + commitment_account_state_hash: prev_asth, + commitment_out_coins_root: prev_ocr, + }; + (cmp, history_root_extended) + } + + /// Primary 5c+ positive test: an Initial → AccountUpdate chain with a + /// real `CommitmentMerkleProofs` witness. The prev proof's commitment + /// is published in the SMT, the SMT is folded into the MMR, and the + /// AccountUpdate proof verifies the (c)(d)(e) chain in-circuit. + #[test] + fn stage_5c_plus_initial_then_account_update_with_commitment_proofs() { + let circuit = build_circuit(); + + // Initial proof: mint account. + let mut account_state = AccountState::new(dummy_pubkey(11)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1_000_000; + + // Bootstrap pattern: Init commits to the EMPTY history + // (`prev.commitment_history_root == ZERO_HASH`); after Init the + // server folds its commitment into the MMR, giving the + // post-fold `history_root_extended` against which Update is + // proved. The fixture matches that exact layout — (e)'s leaf + // shape `h(smt_root || ZERO_HASH)` coincides with (d)'s leaf. + let prev_asth = account_state.hash(); + let prev_ocr = DEFAULT_HASHES[0]; + let (cmp, history_root_extended) = build_test_commitment_witness(prev_asth, prev_ocr); + + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + verify(&circuit, &init_proof).expect("verify init"); + + let update_proof = prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp, + ) + .expect("prove update"); + verify(&circuit, &update_proof).expect("verify update"); + + // Carry-over: update.coin_history_root == init.coin_history_root. + let init_pd = pis_as_proof_data(&init_proof); + let update_pd = pis_as_proof_data(&update_proof); + assert_eq!(update_pd.coin_history_root, init_pd.coin_history_root); + assert_eq!(update_pd.account_state_hash, account_state.hash()); + assert_eq!(update_pd.commitment_history_root, history_root_extended); + } + + /// Stage 5c+ negative: AccountUpdate where the current account_state + /// hashes to something different from prev's `account_state_hash` → + /// rejected by (b). + #[test] + fn stage_5c_plus_account_update_state_discontinuity_rejected() { + let circuit = build_circuit(); + + let mut prev_state = AccountState::new(dummy_pubkey(42)); + prev_state.owner = *MINTING_ADDRESS; + prev_state.balance = 500; + + let prev_asth = prev_state.hash(); + let (cmp, history_root_extended) = + build_test_commitment_witness(prev_asth, DEFAULT_HASHES[0]); + let prev_proof = prove_initial(&circuit, &prev_state, ZERO_HASH).expect("prove prev init"); + + // Try to update with a DIFFERENT account_state. + let mut next_state = prev_state.clone(); + next_state.balance += 1; + assert!(prove_account_update( + &circuit, + &next_state, + history_root_extended, + &prev_proof, + &cmp + ) + .is_err()); + } + + /// Stage 5c+ negative (c): AccountUpdate where mp.commitment_account_state_hash + /// is lied about so it no longer matches `account_state.hash()`. + #[test] + fn stage_5c_plus_account_update_wrong_commitment_account_state_hash_rejected() { + let circuit = build_circuit(); + + let mut account_state = AccountState::new(dummy_pubkey(123)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let true_asth = account_state.hash(); + let (mut cmp, history_root_extended) = + build_test_commitment_witness(true_asth, DEFAULT_HASHES[0]); + + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + + // Mutate ONLY the witnessed commitment_account_state_hash; leave + // the SMT (which still contains the honest commitment) intact. + // (c) catches the mismatch via the masked equality constraint. + cmp.commitment_account_state_hash = hash_bytes(b"lying-asth"); + + assert!(prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp + ) + .is_err()); + } + + /// Build-time assertion: `set_cmp_witness` rejects a `cmp` whose + /// SMT inclusion proof is short of `TREE_DEPTH` siblings — the + /// in-circuit gadget is built against a fixed 256-level shape, so + /// a malformed witness would silently skip levels. + #[test] + #[should_panic(expected = "SMT inclusion proof must be padded to TREE_DEPTH siblings")] + fn stage_5c_plus_set_cmp_witness_panics_on_short_smt_path() { + let circuit = build_circuit(); + let mut cmp = dummy_cmp(); + cmp.commitment_proof.siblings.truncate(TREE_DEPTH - 1); + let mut pw = PartialWitness::new(); + set_cmp_witness(&mut pw, &circuit, &cmp); + } + + /// Build-time assertion: `set_cmp_witness` rejects a `cmp` whose + /// MMR (d) path is short of `MMR_PROOF_PATH_LEN` siblings. + #[test] + #[should_panic(expected = "MMR proof (d) must be extended to MMR_PROOF_PATH_LEN siblings")] + fn stage_5c_plus_set_cmp_witness_panics_on_short_mmr_a_path() { + let circuit = build_circuit(); + let mut cmp = dummy_cmp(); + cmp.commitment_root_history_proof + .path + .truncate(MMR_PROOF_PATH_LEN - 1); + let mut pw = PartialWitness::new(); + set_cmp_witness(&mut pw, &circuit, &cmp); + } + + /// Build-time assertion: `set_cmp_witness` rejects a `cmp` whose + /// MMR (e) path is short of `MMR_PROOF_PATH_LEN` siblings. + #[test] + #[should_panic(expected = "MMR proof (e) must be extended to MMR_PROOF_PATH_LEN siblings")] + fn stage_5c_plus_set_cmp_witness_panics_on_short_mmr_b_path() { + let circuit = build_circuit(); + let mut cmp = dummy_cmp(); + cmp.previous_root_history_proof + .1 + .path + .truncate(MMR_PROOF_PATH_LEN - 1); + let mut pw = PartialWitness::new(); + set_cmp_witness(&mut pw, &circuit, &cmp); + } + + /// Stage 5d-next-5 Phase 2b positive: Initial proof with one + /// active in-coin slot whose source is a real state-transition + /// proof. + /// + /// Validates the full §8 step 2 chain end-to-end: + /// - Aggregator verifies the source proof against the cyclic vk + /// (`connect_hashes(claimed_st_digest, ...)` binding holds); + /// - SMT inclusion of `coin_identifier` in the source's + /// `output_coins_root` succeeds; + /// - SPEC §8 (c)(d)(e) chain plus the OCR-coupling check succeeds + /// against the consumer's `history_root` (the same history into + /// which the source's commitment was folded); + /// - The unchanged 5d-next-3 coin-history side: insertion + + /// apply_coin balance-add. + /// + /// Output `ProofData`: + /// - `coin_history_root == nip.insert(source-emitted coin_id)`; + /// - `account_state_hash == final_state.hash()` (balance += amount). + #[test] + fn stage_5d_next_5_phase_2b_initial_with_one_active_in_coin_and_source() { + let circuit = build_circuit(); + + // Build the source side: a mint account emits one out-coin + // worth `out_amount`. Returns the source proof + inclusion + + // CMP + the extended history_root the consumer must use. + let out_amount: u64 = 42; + let (source_proof, coin_identifier, source_inclusion, source_cmp, history_root, _post) = + build_test_source_witness(&circuit, 11, out_amount); + + // Consumer: a non-mint account absorbing the source's coin. + let mut account_state = AccountState::new(dummy_pubkey(111)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 0; + + // Off-circuit coin-history NIP for the source-emitted + // `coin_identifier` in the consumer's (empty) coin_history SMT. + let coin_key = digest_to_bytes(&coin_identifier); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + assert!(nip.verify(), "off-circuit non-inclusion sanity"); + let expected_new_coin_history = nip.insert(coin_identifier); + + let coin = Coin { + identifier: coin_identifier, + recipient: account_state.owner, + amount: out_amount, + }; + let mut final_account_state = account_state.clone(); + final_account_state.balance += coin.amount; + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&coin, &nip, &dummy_c, &dummy_nip); + let inactive_out_coins: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = (0 + ..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect(); + let source_witness = InCoinSourceWitness { + source_proof: &source_proof, + source_inclusion: &source_inclusion, + source_cmp: &source_cmp, + }; + let sources = sources_first_active(&source_witness); + + let proof = prove_initial_with_in_and_out_coins_and_sources( + &circuit, + &account_state, + history_root, + &in_coins, + &inactive_out_coins, + &account_state.public_key, + &sources, + ) + .expect("prove init with active in-coin + source"); + verify(&circuit, &proof).expect("verify"); + + let recovered = pis_as_proof_data(&proof); + assert_eq!(recovered.coin_history_root, expected_new_coin_history); + assert_eq!(recovered.account_state_hash, final_account_state.hash()); + assert_eq!(recovered.commitment_history_root, history_root); + } + + /// Stage 5d negative: a tampered non-inclusion path on an active + /// slot must fail to prove (the `connect_hashes(computed_old, + /// running)` constraint rejects). + #[test] + fn stage_5d_initial_with_tampered_nip_path_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(11)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let coin_identifier = hash_bytes(b"5d-tampered"); + let coin_key = digest_to_bytes(&coin_identifier); + let empty_smt = SparseMerkleTree::new(); + let mut nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + // Tamper a sibling — the recomputed root won't match + // `DEFAULT_HASHES[0]` and the in-circuit check fires. + nip.siblings[0] = hash_bytes(b"lying-sibling"); + + let coin = Coin { + identifier: coin_identifier, + recipient: account_state.owner, + amount: 0, + }; + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&coin, &nip, &dummy_c, &dummy_nip); + assert!(prove_initial_with_in_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + ) + .is_err()); + } + + /// Stage 5d apply_coin negative: an in-coin with `recipient != + /// account.owner` is rejected by the recipient-equality + /// constraint. + #[test] + fn stage_5d_initial_in_coin_wrong_recipient_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(11)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let coin_identifier = hash_bytes(b"5d-wrong-recipient"); + let coin_key = digest_to_bytes(&coin_identifier); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + + let coin = Coin { + identifier: coin_identifier, + // Lie: this coin is addressed to a different account. + recipient: hash_bytes(b"some-other-owner"), + amount: 1, + }; + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&coin, &nip, &dummy_c, &dummy_nip); + assert!(prove_initial_with_in_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + ) + .is_err()); + } + + /// Stage 5d apply_coin negative: adding a coin whose amount would + /// overflow `u64` is rejected by the balance-overflow-check. + #[test] + fn stage_5d_initial_in_coin_overflow_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(11)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = u64::MAX; + + let coin_identifier = hash_bytes(b"5d-overflow"); + let coin_key = digest_to_bytes(&coin_identifier); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + + let coin = Coin { + identifier: coin_identifier, + recipient: account_state.owner, + // u64::MAX + 1 overflows. + amount: 1, + }; + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&coin, &nip, &dummy_c, &dummy_nip); + assert!(prove_initial_with_in_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + ) + .is_err()); + } + + /// Test helper: build a `MAX_OUT_COINS`-length out-coin slot + /// array with the first slot active (`(true, identifier, amount, + /// nip)`) and the rest inactive. + fn out_slots_first_active<'a>( + identifier: HashDigest, + amount: u64, + nip: &'a NonInclusionProof, + dummy_nip: &'a NonInclusionProof, + ) -> Vec<(bool, HashDigest, u64, &'a NonInclusionProof)> { + let mut v = Vec::with_capacity(MAX_OUT_COINS); + v.push((true, identifier, amount, nip)); + for _ in 1..MAX_OUT_COINS { + v.push((false, ZERO_HASH, 0u64, dummy_nip)); + } + v + } + + /// Stage 5d-next-3 positive: Initial proof emits one out-coin. + /// The interim account-state hash (post in-coins, before pubkey + /// rotation) drives `out_coin_identifier = H(interim_asth || 0)`. + /// Output `ProofData`: + /// - `account_state_hash` is the FINAL hash (with the rotated + /// pubkey and the post-subtraction balance); + /// - `output_coins_root` is the SMT after inserting the + /// out-coin's identifier; + /// - `coin_history_root` is `DEFAULT_HASHES[0]` (no in-coins). + #[test] + fn stage_5d_next_3_initial_with_one_active_out_coin() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(21)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + // Per SPEC §8 `send_coins`, the interim account-state hash + // (used for identifier derivation) is computed AFTER balance + // subtractions but BEFORE pubkey rotation. So for an out-coin + // amount of 30, the interim balance is 70 and the interim + // pubkey is the INITIAL one. + let out_coin_amount: u64 = 30; + let mut interim_account_state = account_state.clone(); + interim_account_state.balance -= out_coin_amount; + let interim_asth = interim_account_state.hash(); + let expected_out_id = crate::types::calculate_coin_identifier(interim_asth, 0); + + // Off-circuit: non-inclusion of expected_out_id in empty SMT. + let out_id_key = digest_to_bytes(&expected_out_id); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + let expected_out_root = nip.insert(expected_out_id); + + // Rotate pubkey: next_public_key chosen by the prover. + let next_pubkey = dummy_pubkey(122); + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect::>(); + let out_coins = out_slots_first_active(expected_out_id, out_coin_amount, &nip, &dummy_nip); + + let history_root = hash_bytes(b"history@5d-next-3-out"); + let proof = prove_initial_with_in_and_out_coins( + &circuit, + &account_state, + history_root, + &in_coins, + &out_coins, + &next_pubkey, + ) + .expect("prove init with out-coin"); + verify(&circuit, &proof).expect("verify"); + + let recovered = pis_as_proof_data(&proof); + + // FINAL account_state: balance = 100 - 30 = 70, with rotated pubkey. + let mut final_account_state = interim_account_state.clone(); + final_account_state.public_key = next_pubkey; + assert_eq!(recovered.account_state_hash, final_account_state.hash()); + assert_eq!(recovered.output_coins_root, expected_out_root); + assert_eq!(recovered.coin_history_root, DEFAULT_HASHES[0]); + assert_eq!(recovered.commitment_history_root, history_root); + } + + /// Stage 5d-next-3 negative: out-coin whose `identifier` does not + /// equal `H(interim_asth || index)` is rejected by the masked + /// identifier-equality constraint. + #[test] + fn stage_5d_next_3_initial_out_coin_wrong_identifier_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(22)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + // A lying identifier that is NOT `H(interim_asth || 0)`. + let lying_id = hash_bytes(b"5d-next-3-lying-out-id"); + let out_id_key = digest_to_bytes(&lying_id); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect::>(); + let out_coins = out_slots_first_active(lying_id, 1, &nip, &dummy_nip); + + let next_pubkey = account_state.public_key; + assert!(prove_initial_with_in_and_out_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + &out_coins, + &next_pubkey, + ) + .is_err()); + } + + /// Stage 5d-next-3 negative: out-coin amount exceeding the + /// account balance is rejected by the underflow check. + #[test] + fn stage_5d_next_3_initial_out_coin_underflow_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(23)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 5; // less than the requested out-coin amount + + // Compute the expected identifier so identifier-eq passes; the + // underflow check is what should fire. + let interim_asth = account_state.hash(); + let expected_out_id = crate::types::calculate_coin_identifier(interim_asth, 0); + let out_id_key = digest_to_bytes(&expected_out_id); + let empty_smt = SparseMerkleTree::new(); + let nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect::>(); + let out_coins = out_slots_first_active(expected_out_id, 10, &nip, &dummy_nip); + + let next_pubkey = account_state.public_key; + assert!(prove_initial_with_in_and_out_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + &out_coins, + &next_pubkey, + ) + .is_err()); + } + + /// Build-time assertion: `set_out_coin_slot_witness` rejects a + /// non-inclusion proof of the wrong length. + #[test] + #[should_panic( + expected = "OutCoinSlot: non-inclusion proof must be padded to TREE_DEPTH siblings" + )] + fn stage_5d_next_3_set_out_coin_slot_witness_panics_on_short_nip_path() { + let circuit = build_circuit(); + let mut nip = dummy_non_inclusion_proof(); + nip.siblings.truncate(TREE_DEPTH - 1); + let mut pw = PartialWitness::new(); + set_out_coin_slot_witness( + &mut pw, + &circuit.out_coin_slots[0], + true, + ZERO_HASH, + 0, + &nip, + ); + } + + /// Build-time assertion: out-coin slot count guard on + /// `prove_initial_with_in_and_out_coins`. + #[test] + #[should_panic( + expected = "prove_initial_with_in_and_out_coins_and_sources: caller must supply exactly MAX_OUT_COINS out-coin slot witnesses" + )] + fn stage_5d_next_3_prove_initial_panics_on_wrong_out_slot_count() { + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(7)); + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect::>(); + let _ = prove_initial_with_in_and_out_coins( + &circuit, + &account_state, + ZERO_HASH, + &in_coins, + &[], // 0 out-coin slots, expected MAX_OUT_COINS + &account_state.public_key, + ); + } + + /// Build-time assertion: in-coin slot count guard on + /// `prove_initial_with_in_and_out_coins`. + #[test] + #[should_panic( + expected = "prove_initial_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS in-coin slot witnesses" + )] + fn stage_5d_next_3_prove_initial_panics_on_wrong_in_slot_count() { + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(7)); + let dummy_nip = dummy_non_inclusion_proof(); + let out_coins = (0..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect::>(); + let _ = prove_initial_with_in_and_out_coins( + &circuit, + &account_state, + ZERO_HASH, + &[], // 0 in-coin slots, expected MAX_IN_COINS + &out_coins, + &account_state.public_key, + ); + } + + /// Build-time assertion: in-coin slot count guard on + /// `prove_account_update_with_in_and_out_coins`. + #[test] + #[should_panic( + expected = "prove_account_update_with_in_and_out_coins_and_sources: caller must supply exactly MAX_IN_COINS in-coin slot witnesses" + )] + fn stage_5d_next_3_prove_account_update_panics_on_wrong_in_slot_count() { + // The slot-count `assert_eq!` fires at the top of the function, + // before any witness setting or proving. Hand it a + // `cyclic_base_proof` dummy for `prev` instead of paying ~13 min + // to generate a real Init proof — the panic short-circuits + // before `prev` is consumed. + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(8)); + let cmp = dummy_cmp(); + let dummy_inner_pis = std::iter::empty::<(usize, F)>().collect(); + let dummy_prev = cyclic_base_proof( + &circuit.common_data, + &circuit.data.verifier_only, + dummy_inner_pis, + ); + let dummy_nip = dummy_non_inclusion_proof(); + let out_coins = (0..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect::>(); + let _ = prove_account_update_with_in_and_out_coins( + &circuit, + &account_state, + ZERO_HASH, + &dummy_prev, + &cmp, + &[], // wrong: expected MAX_IN_COINS + &out_coins, + &account_state.public_key, + ); + } + + /// Build-time assertion: out-coin slot count guard on + /// `prove_account_update_with_in_and_out_coins`. + #[test] + #[should_panic( + expected = "prove_account_update_with_in_and_out_coins_and_sources: caller must supply exactly MAX_OUT_COINS out-coin slot witnesses" + )] + fn stage_5d_next_3_prove_account_update_panics_on_wrong_out_slot_count() { + // Same `cyclic_base_proof` short-circuit as the in-slot test. + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(9)); + let cmp = dummy_cmp(); + let dummy_inner_pis = std::iter::empty::<(usize, F)>().collect(); + let dummy_prev = cyclic_base_proof( + &circuit.common_data, + &circuit.data.verifier_only, + dummy_inner_pis, + ); + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = (0..MAX_IN_COINS) + .map(|_| (false, &dummy_c, &dummy_nip)) + .collect::>(); + let _ = prove_account_update_with_in_and_out_coins( + &circuit, + &account_state, + ZERO_HASH, + &dummy_prev, + &cmp, + &in_coins, + &[], // wrong: expected MAX_OUT_COINS + &account_state.public_key, + ); + } + + /// Build-time assertion: `set_in_coin_slot_witness` rejects a + /// non-inclusion proof of the wrong length — the in-circuit gadget + /// expects exactly `TREE_DEPTH` siblings. + #[test] + #[should_panic( + expected = "InCoinSlot: non-inclusion proof must be padded to TREE_DEPTH siblings" + )] + fn stage_5d_set_in_coin_slot_witness_panics_on_short_nip_path() { + let circuit = build_circuit(); + let mut nip = dummy_non_inclusion_proof(); + nip.siblings.truncate(TREE_DEPTH - 1); + let mut pw = PartialWitness::new(); + set_in_coin_slot_witness( + &mut pw, + &circuit.in_coin_slots[0], + true, + ZERO_HASH, + ZERO_HASH, + 0, + &nip, + ); + } + + /// Build-time assertion: `prove_initial_with_in_coins` rejects a + /// caller that doesn't supply exactly `MAX_IN_COINS` slot witnesses. + #[test] + #[should_panic( + expected = "prove_initial_with_in_coins: caller must supply exactly MAX_IN_COINS slot witnesses" + )] + fn stage_5d_prove_initial_panics_on_wrong_slot_count() { + let circuit = build_circuit(); + let account_state = AccountState::new(dummy_pubkey(7)); + let _ = prove_initial_with_in_coins( + &circuit, + &account_state, + ZERO_HASH, + &[], // 0 slots, expected MAX_IN_COINS = 1 + ); + } + + /// Build-time assertion: `prove_account_update_with_in_coins` + /// rejects a caller that doesn't supply exactly `MAX_IN_COINS` + /// slot witnesses. + #[test] + #[should_panic( + expected = "prove_account_update_with_in_coins: caller must supply exactly MAX_IN_COINS slot witnesses" + )] + fn stage_5d_prove_account_update_panics_on_wrong_slot_count() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(11)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + let (cmp, history_root_extended) = + build_test_commitment_witness(account_state.hash(), DEFAULT_HASHES[0]); + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + let _ = prove_account_update_with_in_coins( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp, + &[], // 0 slots, expected MAX_IN_COINS = 1 + ); + } + + /// Stage 5e (SPEC §13): tampered MMR-(d) path — proof that the + /// commitment_root sits in `history_root` is invalid. The + /// in-circuit check rejects. + #[test] + fn stage_5e_account_update_tampered_mmr_a_path_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(31)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let (mut cmp, history_root_extended) = + build_test_commitment_witness(account_state.hash(), DEFAULT_HASHES[0]); + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + cmp.commitment_root_history_proof.path[0] = hash_bytes(b"lying-mmr-a-sib"); + assert!(prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp + ) + .is_err()); + } + + /// Stage 5e (SPEC §13): tampered MMR-(e) path — proof that prev's + /// committed history is a prefix of `history_root` is invalid. + #[test] + fn stage_5e_account_update_tampered_mmr_b_path_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(32)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let (mut cmp, history_root_extended) = + build_test_commitment_witness(account_state.hash(), DEFAULT_HASHES[0]); + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + cmp.previous_root_history_proof.1.path[0] = hash_bytes(b"lying-mmr-b-sib"); + assert!(prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp + ) + .is_err()); + } + + /// Stage 5e (SPEC §13): wrong `commitment_root_mmr_sibling` — the + /// MMR-(d) leaf no longer hashes to the witnessed `commitment_root` + /// path, so the MMR-(d) verification fails. + #[test] + fn stage_5e_account_update_wrong_mmr_sibling_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(33)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let (mut cmp, history_root_extended) = + build_test_commitment_witness(account_state.hash(), DEFAULT_HASHES[0]); + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + cmp.commitment_root_mmr_sibling = hash_bytes(b"lying-prev-mmr-root"); + assert!(prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp + ) + .is_err()); + } + + /// Stage 5e (SPEC §13): AccountUpdate proved against a + /// `history_root` that the real MMR does not match. With (d)+(e) + /// wired, both MMR proofs would have to reconstruct to the lying + /// `history_root` — they can't, so the proof fails. + #[test] + fn stage_5e_account_update_wrong_history_root_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(34)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let (cmp, _real_history_root) = + build_test_commitment_witness(account_state.hash(), DEFAULT_HASHES[0]); + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + // Lie about the history_root — neither MMR proof reconstructs to it. + let lying_history_root = hash_bytes(b"lying-history"); + assert!(prove_account_update( + &circuit, + &account_state, + lying_history_root, + &init_proof, + &cmp + ) + .is_err()); + } + + /// Stage 5d-next-5 Phase 2b integration: a single Initial proof + /// exercising BOTH the in-coins AND the out-coins loops in one + /// transition, with a real source proof backing the in-coin. + /// Composes the full SPEC §8 flow end-to-end: + /// + /// 1. Source: mint account emits one out-coin (slot 0) worth 30. + /// 2. Consumer: mint account with initial balance 100. + /// 3. One active in-coin = source's emitted out-coin (id derived + /// from source's interim asth, amount 30) — running balance + /// 100 + 30 = 130, coin_history advances. + /// 4. One active out-coin (id derived from the *consumer's* + /// interim asth, amount 50, sent to a rotated pubkey) — + /// running balance 80, output_coins_root advances. + /// 5. Final `ProofData.account_state_hash` reflects the rotated + /// pubkey and balance 80. + /// 6. Source's commitment is published in `history_root`; the + /// in-coin's source-side §8 chain verifies against it. + #[test] + fn stage_5d_next_5_phase_2b_initial_combined_in_and_out_coin_with_source() { + let circuit = build_circuit(); + + let in_coin_amount: u64 = 30; + let (source_proof, in_coin_id, source_inclusion, source_cmp, history_root, _post) = + build_test_source_witness(&circuit, 60, in_coin_amount); + + let mut account_state = AccountState::new(dummy_pubkey(160)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + // ===== Consumer's in-coin side ===== + let in_coin_key = digest_to_bytes(&in_coin_id); + let empty_smt = SparseMerkleTree::new(); + let in_nip = empty_smt.generate_non_inclusion_proof(in_coin_key).unwrap(); + let in_coin = Coin { + identifier: in_coin_id, + recipient: account_state.owner, + amount: in_coin_amount, + }; + let expected_coin_history_root = in_nip.insert(in_coin_id); + + // ===== Consumer's out-coin side ===== + // Post-in-coins, pre-out-coin balance is 130; the in-circuit + // running balance subtracts 50 → 80; interim_asth uses balance + // 80 + INITIAL pubkey. + let out_coin_amount: u64 = 50; + let mut interim_account_state = account_state.clone(); + interim_account_state.balance = account_state.balance + in_coin.amount - out_coin_amount; + let interim_asth = interim_account_state.hash(); + let expected_out_id = crate::types::calculate_coin_identifier(interim_asth, 0); + + let out_id_key = digest_to_bytes(&expected_out_id); + let out_nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + let expected_output_coins_root = out_nip.insert(expected_out_id); + + let next_pubkey = dummy_pubkey(161); + + // ===== Slot arrays ===== + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&in_coin, &in_nip, &dummy_c, &dummy_nip); + let out_coins = + out_slots_first_active(expected_out_id, out_coin_amount, &out_nip, &dummy_nip); + let source_witness = InCoinSourceWitness { + source_proof: &source_proof, + source_inclusion: &source_inclusion, + source_cmp: &source_cmp, + }; + let sources = sources_first_active(&source_witness); + + let proof = prove_initial_with_in_and_out_coins_and_sources( + &circuit, + &account_state, + history_root, + &in_coins, + &out_coins, + &next_pubkey, + &sources, + ) + .expect("prove init combined with source"); + verify(&circuit, &proof).expect("verify"); + + let recovered = pis_as_proof_data(&proof); + + // FINAL account_state: balance = 80, pubkey = next_pubkey. + let mut final_account_state = interim_account_state.clone(); + final_account_state.public_key = next_pubkey; + assert_eq!(recovered.account_state_hash, final_account_state.hash()); + assert_eq!(recovered.output_coins_root, expected_output_coins_root); + assert_eq!(recovered.commitment_history_root, history_root); + assert_eq!(recovered.coin_history_root, expected_coin_history_root); + } + + /// Stage 5d-next-5 Phase 2b end-to-end: AccountUpdate proof + /// with BOTH in-coins + out-coins loops AND a real source proof + /// backing the in-coin. Exercises the cyclic-recursion path + /// (`condition = true`), the SPEC §8 (c)(d)(e) chain for the + /// PREV-account commitment, the per-slot §8 step 2 chain for the + /// SOURCE commitment, and the apply_coin + send_coins logic — all + /// against a single shared `history_root` that holds BOTH + /// commitments at distinct MMR leaves. + #[test] + fn stage_5d_next_5_phase_2b_account_update_combined_in_and_out_coin_with_source() { + let circuit = build_circuit(); + + let in_coin_amount: u64 = 30; + let mut account_state = AccountState::new(dummy_pubkey(161)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + let ( + source_proof, + in_coin_id, + source_inclusion, + source_cmp, + prev_proof, + consumer_cmp, + history_root_ext, + ) = build_test_source_and_prev_witnesses(&circuit, 61, &account_state, in_coin_amount); + + // ===== Consumer's in-coin side ===== + let in_coin_key = digest_to_bytes(&in_coin_id); + let empty_smt = SparseMerkleTree::new(); + let in_nip = empty_smt.generate_non_inclusion_proof(in_coin_key).unwrap(); + let in_coin = Coin { + identifier: in_coin_id, + recipient: account_state.owner, + amount: in_coin_amount, + }; + let expected_coin_history_root = in_nip.insert(in_coin_id); + + // ===== Consumer's out-coin side ===== + let out_coin_amount: u64 = 50; + let mut interim_account_state = account_state.clone(); + interim_account_state.balance = account_state.balance + in_coin.amount - out_coin_amount; + let interim_asth = interim_account_state.hash(); + let expected_out_id = crate::types::calculate_coin_identifier(interim_asth, 0); + let out_id_key = digest_to_bytes(&expected_out_id); + let out_nip = empty_smt.generate_non_inclusion_proof(out_id_key).unwrap(); + let expected_output_coins_root = out_nip.insert(expected_out_id); + + let next_pubkey = dummy_pubkey(162); + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&in_coin, &in_nip, &dummy_c, &dummy_nip); + let out_coins = + out_slots_first_active(expected_out_id, out_coin_amount, &out_nip, &dummy_nip); + let source_witness = InCoinSourceWitness { + source_proof: &source_proof, + source_inclusion: &source_inclusion, + source_cmp: &source_cmp, + }; + let sources = sources_first_active(&source_witness); + + let update_proof = prove_account_update_with_in_and_out_coins_and_sources( + &circuit, + &account_state, + history_root_ext, + &prev_proof, + &consumer_cmp, + &in_coins, + &out_coins, + &next_pubkey, + &sources, + ) + .expect("prove account_update combined with source"); + verify(&circuit, &update_proof).expect("verify update"); + + let recovered = pis_as_proof_data(&update_proof); + let mut final_account_state = interim_account_state.clone(); + final_account_state.public_key = next_pubkey; + assert_eq!(recovered.account_state_hash, final_account_state.hash()); + assert_eq!(recovered.output_coins_root, expected_output_coins_root); + assert_eq!(recovered.commitment_history_root, history_root_ext); + assert_eq!(recovered.coin_history_root, expected_coin_history_root); + } + + /// Stage 5e SPEC §13 — double-spend: two active in-coin slots + /// presenting the SAME `coin_identifier`. The first slot inserts + /// into the coin_history SMT successfully. The second slot's + /// non-inclusion proof must be against the post-first-insert + /// root, but the coin IS now in that root, so any non-inclusion + /// proof against it is necessarily invalid — the in-circuit + /// `connect_hashes(computed_old, running)` check catches the lie. + #[test] + fn stage_5e_double_spend_same_coin_twice_rejected() { + let circuit = build_circuit(); + let mut account_state = AccountState::new(dummy_pubkey(50)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + // First in-coin: non-inclusion in empty SMT. + let coin_id = hash_bytes(b"5e-double-spend"); + let coin_key = digest_to_bytes(&coin_id); + let empty_smt = SparseMerkleTree::new(); + let nip1 = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + + // Pretend-second in-coin: SAME identifier. The honest prover + // can't generate a non-inclusion proof against the + // post-first-insert root (the coin IS there now), so we + // supply the SAME proof as `nip1`. That proof is valid for + // the pre-insert (empty) root but invalid for the + // post-insert running root — the in-circuit check fires on + // slot 2 because `computed_old == empty_root` but + // `running_coin_history` has advanced to the post-insert + // root. + let coin1 = Coin { + identifier: coin_id, + recipient: account_state.owner, + amount: 1, + }; + let coin2 = Coin { + identifier: coin_id, + recipient: account_state.owner, + amount: 1, + }; + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let mut in_coins: Vec<(bool, &Coin, &NonInclusionProof)> = Vec::with_capacity(MAX_IN_COINS); + in_coins.push((true, &coin1, &nip1)); + in_coins.push((true, &coin2, &nip1)); + for _ in 2..MAX_IN_COINS { + in_coins.push((false, &dummy_c, &dummy_nip)); + } + + assert!(prove_initial_with_in_coins( + &circuit, + &account_state, + hash_bytes(b"history"), + &in_coins, + ) + .is_err()); + } + + /// Stage 5c+ negative (d): AccountUpdate where the SMT inclusion path + /// has been tampered with. (d) catches it via `connect_hashes`. + #[test] + fn stage_5c_plus_account_update_tampered_smt_path_rejected() { + let circuit = build_circuit(); + + let mut account_state = AccountState::new(dummy_pubkey(77)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1; + + let true_asth = account_state.hash(); + let (mut cmp, history_root_extended) = + build_test_commitment_witness(true_asth, DEFAULT_HASHES[0]); + + let init_proof = prove_initial(&circuit, &account_state, ZERO_HASH).expect("prove init"); + + // Tamper a sibling deep in the SMT path — the computed + // commitment_root will differ from the witnessed one. + cmp.commitment_proof.siblings[0] = hash_bytes(b"lying-sibling"); + + assert!(prove_account_update( + &circuit, + &account_state, + history_root_extended, + &init_proof, + &cmp + ) + .is_err()); + } + + // ========================================================================= + // Stage 5d-next-5 Phase 3 — SPEC §13 source-side negatives + // + // Each test exercises a specific attack vector against the per-slot + // §8 step 2 chain wired by Phase 2b. Tests use real source proofs + // (via [`build_test_source_witness`]) and isolate the failure to a + // single tampered field, so the assertion identifies which + // constraint catches the lie. + // ========================================================================= + + /// SPEC §13 negative: the source's commitment is NOT in the global + /// history (tampered MMR-(e) path). Phase 2b's per-slot (e) check + /// requires `mmr_inclusion(h(prev_smt_in_mmr_leaf || + /// source.commitment_history_root), …) == history_root`. Tampering + /// the path breaks `connect_hashes` on the recomputed root. + #[test] + fn stage_5d_next_5_phase_3_source_not_in_history_rejected() { + let circuit = build_circuit(); + + let in_coin_amount: u64 = 7; + let (source_proof, in_coin_id, source_inclusion, mut source_cmp, history_root, _post) = + build_test_source_witness(&circuit, 201, in_coin_amount); + + // Tamper the (e) MMR path — claim source's commitment_history + // is somewhere it is not. The masked `mmr_b_computed == + // history_root` check rejects. + source_cmp.previous_root_history_proof.1.path[0] = + hash_bytes(b"phase-3-lying-source-mmr-e-sib"); + + let mut account_state = AccountState::new(dummy_pubkey(202)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 0; + + let coin_key = digest_to_bytes(&in_coin_id); + let empty_smt = SparseMerkleTree::new(); + let in_nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + let in_coin = Coin { + identifier: in_coin_id, + recipient: account_state.owner, + amount: in_coin_amount, + }; + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&in_coin, &in_nip, &dummy_c, &dummy_nip); + let inactive_out_coins: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = (0 + ..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect(); + let source_witness = InCoinSourceWitness { + source_proof: &source_proof, + source_inclusion: &source_inclusion, + source_cmp: &source_cmp, + }; + let sources = sources_first_active(&source_witness); + + assert!(prove_initial_with_in_and_out_coins_and_sources( + &circuit, + &account_state, + history_root, + &in_coins, + &inactive_out_coins, + &account_state.public_key, + &sources, + ) + .is_err()); + } + + /// SPEC §13 negative: the in-coin's `coin_identifier` is NOT in the + /// source's `output_coins_root` (tampered SMT inclusion path). + /// Phase 2b's per-slot SMT inclusion check requires + /// `hash_up_full_path(h(id || id), id_bits, source_inclusion_path) + /// == source.output_coins_root`. Tampering rejects. + #[test] + fn stage_5d_next_5_phase_3_coin_not_in_source_ocr_rejected() { + let circuit = build_circuit(); + + let in_coin_amount: u64 = 9; + let (source_proof, in_coin_id, mut source_inclusion, source_cmp, history_root, _post) = + build_test_source_witness(&circuit, 211, in_coin_amount); + + // Tamper the inclusion proof's first sibling — the recomputed + // source-OCR no longer matches what the source actually published. + source_inclusion.siblings[0] = hash_bytes(b"phase-3-lying-source-incl-sib"); + + let mut account_state = AccountState::new(dummy_pubkey(212)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 0; + + let coin_key = digest_to_bytes(&in_coin_id); + let empty_smt = SparseMerkleTree::new(); + let in_nip = empty_smt.generate_non_inclusion_proof(coin_key).unwrap(); + let in_coin = Coin { + identifier: in_coin_id, + recipient: account_state.owner, + amount: in_coin_amount, + }; + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + let in_coins = slots_first_active(&in_coin, &in_nip, &dummy_c, &dummy_nip); + let inactive_out_coins: Vec<(bool, HashDigest, u64, &NonInclusionProof)> = (0 + ..MAX_OUT_COINS) + .map(|_| (false, ZERO_HASH, 0u64, &dummy_nip)) + .collect(); + let source_witness = InCoinSourceWitness { + source_proof: &source_proof, + source_inclusion: &source_inclusion, + source_cmp: &source_cmp, + }; + let sources = sources_first_active(&source_witness); + + assert!(prove_initial_with_in_and_out_coins_and_sources( + &circuit, + &account_state, + history_root, + &in_coins, + &inactive_out_coins, + &account_state.public_key, + &sources, + ) + .is_err()); + } + + /// SPEC §13 negative: the aggregator's witnessed + /// `st_verifier_data` is a LIE (claims a different state-transition + /// circuit than the outer's actual one). Phase 2a's + /// `connect_hashes(claimed_st_digest, outer_vd.circuit_digest)` + /// rejects. + /// + /// Construction: forge an aggregator proof with the + /// dummy-circuit's `verifier_only` (any non-outer vd would work — + /// the dummy is convenient and exists as a side product). All + /// slots inactive so `conditionally_verify_proof` never actually + /// uses the witnessed vd for verification; only the PIs reflect + /// the lie. Then plug into the outer manually. + #[test] + fn stage_5d_next_5_phase_3_wrong_st_vk_on_aggregator_rejected() { + let circuit = build_circuit(); + + // Forge: aggregator proof claiming the dummy circuit's vd as + // its st_verifier_data. Safe to build with all slots inactive. + let lying_st_verifier_only = circuit.aggregator.dummy_st_verifier_only.clone(); + let all_inactive_slot_witnesses: Vec = (0..MAX_IN_COINS) + .map(|_| AggregatorSlotWitness { + active: false, + real_proof: None, + }) + .collect(); + let lying_agg_proof = prove_aggregator( + &circuit.aggregator, + &lying_st_verifier_only, + &all_inactive_slot_witnesses, + ) + .expect("can build lying aggregator proof — all slots inactive so the witnessed vd is never actually used to verify"); + + // Sanity: the lying aggregator proof verifies as an aggregator + // proof (the aggregator circuit doesn't enforce that the + // witnessed vd matches anything specific) — the lie surfaces + // only at the outer's connect_hashes. + circuit + .aggregator + .data + .verify(lying_agg_proof.clone()) + .expect("lying aggregator proof is structurally valid"); + + // Now construct the outer witness manually so we can plug in + // the lying aggregator proof instead of an honest one. + let account_state = AccountState::new(dummy_pubkey(221)); + let mut pw = PartialWitness::new(); + pw.set_bool_target(circuit.condition, false).unwrap(); + set_account_state_witness(&mut pw, &circuit, &account_state); + pw.set_hash_target(circuit.history_root, ZERO_HASH).unwrap(); + set_cmp_witness(&mut pw, &circuit, &dummy_cmp()); + + let dummy_nip = dummy_non_inclusion_proof(); + let dummy_c = dummy_coin(); + for (slot_targets, ()) in circuit.in_coin_slots.iter().zip(std::iter::repeat(())) { + set_in_coin_slot_witness( + &mut pw, + slot_targets, + false, + ZERO_HASH, + ZERO_HASH, + 0, + &dummy_nip, + ); + } + let all_none_sources: Vec> = + (0..MAX_IN_COINS).map(|_| None).collect(); + set_per_slot_source_witnesses(&mut pw, &circuit, &all_none_sources); + for slot_targets in circuit.out_coin_slots.iter() { + set_out_coin_slot_witness(&mut pw, slot_targets, false, ZERO_HASH, 0, &dummy_nip); + } + set_next_public_key_witness(&mut pw, &circuit, &account_state.public_key); + + // Plug the LYING aggregator proof in place of an honest one. + pw.set_proof_with_pis_target::(&circuit.aggregator_proof_target, &lying_agg_proof) + .unwrap(); + + let inner_pis = std::iter::empty::<(usize, F)>().collect(); + pw.set_proof_with_pis_target::( + &circuit.inner_proof_target, + &cyclic_base_proof(&circuit.common_data, &circuit.data.verifier_only, inner_pis), + ) + .unwrap(); + pw.set_verifier_data_target(&circuit.verifier_data_target, &circuit.data.verifier_only) + .unwrap(); + + // The outer's `connect_hashes(claimed_st_digest, outer_vd.digest)` + // (and the parallel sigmas_cap binding) fires on the mismatch: + // claimed_digest == dummy_circuit_digest != outer_circuit_digest. + // Unused suppression: `dummy_c` lives only to satisfy older + // helper bindings if needed downstream. + let _ = dummy_c; + assert!(circuit.data.prove(pw).is_err()); + } +} diff --git a/program-plonky2/src/circuit/mmr.rs b/program-plonky2/src/circuit/mmr.rs new file mode 100644 index 00000000..b4f94709 --- /dev/null +++ b/program-plonky2/src/circuit/mmr.rs @@ -0,0 +1,202 @@ +//! In-circuit Merkle mountain range inclusion verification. +//! +//! Off-circuit equivalent: [`crate::merkle::merkle_mountain_range::MMRProof::verify`]. +//! +//! The gadget verifies that `leaf` connects to `expected_root` along +//! `path`, where each path step's swap orientation is selected by one bit of +//! `index` (LSB-first). The depth is fixed by `path.len()`; the host MUST +//! pad shorter proofs to the circuit's configured `MAX_MMR_DEPTH` with +//! `ZERO_HASH` siblings, and zero-pad the corresponding high bits of +//! `index`. Padding entries are no-ops: a sibling of `ZERO_HASH` at a level +//! above the real tree top is exactly what the off-circuit MMR `root()` +//! would have hashed against, so the chain extends consistently. + +use plonky2::field::extension::Extendable; +use plonky2::hash::hash_types::{HashOutTarget, RichField}; +use plonky2::hash::poseidon::PoseidonHash; +use plonky2::iop::target::{BoolTarget, Target}; +use plonky2::plonk::circuit_builder::CircuitBuilder; + +use super::util::swap_if; + +/// Compute the MMR root from an inclusion proof in-circuit, without +/// constraining it to any expected root. Caller is responsible for +/// connecting the returned `HashOutTarget` to its expected value. +/// +/// `index_bits` must have the same length as `path` and represent the +/// LSB-first bit decomposition of the leaf's index (within the +/// fixed-shape MMR depth chosen by the caller). +pub fn mmr_inclusion_root, const D: usize>( + builder: &mut CircuitBuilder, + leaf: HashOutTarget, + index_bits: &[BoolTarget], + path: &[HashOutTarget], +) -> HashOutTarget { + assert_eq!( + index_bits.len(), + path.len(), + "mmr_inclusion_root: index_bits and path must have equal length" + ); + let mut current = leaf; + for (bit, sibling) in index_bits.iter().zip(path.iter()) { + let (left, right) = swap_if(builder, *bit, current, *sibling); + let mut input = Vec::with_capacity(8); + input.extend_from_slice(&left.elements); + input.extend_from_slice(&right.elements); + current = builder.hash_n_to_hash_no_pad::(input); + } + current +} + +/// Verify an MMR inclusion proof in-circuit. +/// +/// Adds constraints that fail the proof unless `leaf` hashes up through +/// `path` (with sibling ordering driven by the LSB-first bits of `index`) +/// to `expected_root`. +pub fn verify_mmr_inclusion, const D: usize>( + builder: &mut CircuitBuilder, + leaf: HashOutTarget, + index_bits: &[BoolTarget], + path: &[HashOutTarget], + expected_root: HashOutTarget, +) { + let current = mmr_inclusion_root(builder, leaf, index_bits, path); + builder.connect_hashes(current, expected_root); +} + +/// Convenience helper: bit-decompose `index` (LSB-first, fixed-width) and +/// call [`verify_mmr_inclusion`]. `width` MUST match `path.len()`. +pub fn verify_mmr_inclusion_with_index, const D: usize>( + builder: &mut CircuitBuilder, + leaf: HashOutTarget, + index: Target, + path: &[HashOutTarget], + expected_root: HashOutTarget, +) { + let index_bits = builder.split_le(index, path.len()); + verify_mmr_inclusion(builder, leaf, &index_bits, path, expected_root); +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::{hash_bytes, HashDigest, ZERO_HASH}; + use crate::merkle::merkle_mountain_range::MerkleMountainRange; + use crate::{C, D, F}; + use plonky2::field::types::Field; + use plonky2::iop::witness::{PartialWitness, WitnessWrite}; + use plonky2::plonk::circuit_data::CircuitConfig; + + /// Build a tree of `n` leaves (off-circuit), pick a leaf index, build a + /// matching in-circuit MMR-inclusion proof, prove it, verify it. + fn round_trip(n: usize, leaf_to_check: usize) { + // Off-circuit MMR + let mut tree = MerkleMountainRange::new(); + let leaves: Vec = (0..n) + .map(|i| hash_bytes(format!("leaf{i}").as_bytes())) + .collect(); + for leaf in &leaves { + tree.append(*leaf); + } + let proof = tree.get_proof(leaf_to_check).unwrap(); + let depth = proof.path.len(); + + // Circuit + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + let leaf_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let index_t = builder.add_virtual_target(); + let path_t: Vec = (0..depth).map(|_| builder.add_virtual_hash()).collect(); + verify_mmr_inclusion_with_index(&mut builder, leaf_t, index_t, &path_t, root_t); + + // Make the leaf + index + root + path public so the test asserts on them. + builder.register_public_inputs(&leaf_t.elements); + builder.register_public_inputs(&root_t.elements); + builder.register_public_input(index_t); + + let data = builder.build::(); + + // Witness + let mut pw = PartialWitness::new(); + pw.set_hash_target(leaf_t, leaves[leaf_to_check]).unwrap(); + pw.set_hash_target(root_t, tree.root()).unwrap(); + pw.set_target(index_t, F::from_canonical_u32(proof.index)) + .unwrap(); + for (i, sib) in proof.path.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + let proof_with_pis = data.prove(pw).expect("prove failed"); + data.verify(proof_with_pis).expect("verify failed"); + } + + #[test] + fn mmr_inclusion_single_leaf() { + round_trip(1, 0); + } + + #[test] + fn mmr_inclusion_two_leaves() { + round_trip(2, 0); + round_trip(2, 1); + } + + #[test] + fn mmr_inclusion_growing_tree() { + for n in 1..=8 { + for i in 0..n { + round_trip(n, i); + } + } + } + + #[test] + #[should_panic(expected = "index_bits and path must have equal length")] + fn mismatched_bits_and_path_panics() { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + // 3 path entries, 2 bits → mismatch should hit the assertion message. + let path_t: Vec = (0..3).map(|_| builder.add_virtual_hash()).collect(); + let bit0 = builder.add_virtual_bool_target_safe(); + let bit1 = builder.add_virtual_bool_target_safe(); + verify_mmr_inclusion(&mut builder, leaf_t, &[bit0, bit1], &path_t, root_t); + } + + #[test] + fn tampered_root_fails_proving() { + let mut tree = MerkleMountainRange::new(); + tree.append(hash_bytes(b"leaf0")); + tree.append(hash_bytes(b"leaf1")); + let proof = tree.get_proof(0).unwrap(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let index_t = builder.add_virtual_target(); + let path_t: Vec = (0..proof.path.len()) + .map(|_| builder.add_virtual_hash()) + .collect(); + verify_mmr_inclusion_with_index(&mut builder, leaf_t, index_t, &path_t, root_t); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + pw.set_hash_target(leaf_t, hash_bytes(b"leaf0")).unwrap(); + // Wrong root: ZERO_HASH instead of tree.root(). + pw.set_hash_target(root_t, ZERO_HASH).unwrap(); + pw.set_target(index_t, F::from_canonical_u32(proof.index)) + .unwrap(); + for (i, sib) in proof.path.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + // Witness construction succeeds; proof generation must fail because + // the connect_hashes constraint is unsatisfied. + assert!(data.prove(pw).is_err(), "tampered root must not prove"); + } +} diff --git a/program-plonky2/src/circuit/mod.rs b/program-plonky2/src/circuit/mod.rs new file mode 100644 index 00000000..aaff3731 --- /dev/null +++ b/program-plonky2/src/circuit/mod.rs @@ -0,0 +1,15 @@ +//! Plonky2 circuit gadgets for the zkCoins state-transition predicate. +//! +//! Each gadget in [`mmr`] / [`smt`] mirrors a piece of off-circuit logic +//! in this crate (see `hash`, `merkle`, `types`) and adds the +//! constraints required to prove the same invariant in-circuit. The +//! [`main`] module composes those gadgets into the monolithic +//! state-transition circuit per [`SPEC.md`] §8 and `ROADMAP.md` Step 5. + +pub mod main; +pub mod mmr; +#[cfg(test)] +mod recursion_shape_probe; +pub mod smt; +pub mod source_aggregator; +mod util; diff --git a/program-plonky2/src/circuit/recursion_shape_probe.rs b/program-plonky2/src/circuit/recursion_shape_probe.rs new file mode 100644 index 00000000..035939eb --- /dev/null +++ b/program-plonky2/src/circuit/recursion_shape_probe.rs @@ -0,0 +1,473 @@ +//! Diagnostic probes for the Plonky2 1.1.0 `dummy_circuit` shape +//! mismatch (`MIGRATION_RESEARCH.md` §7.21 + §7.22). +//! +//! Builds Stage 5d-next-3's pass-3 common (1 `verify_proof`, no +//! aggregator) and a Stage 5d-next-5 candidate pass-3 common (2 +//! `verify_proof`s — cyclic + aggregator), and dumps both `gates` +//! lists side-by-side along with whether `dummy_circuit` succeeds for +//! each. Intended to run as a one-shot `#[test]` so the gate-set +//! delta — which determines whether Phase 2a's outer integration can +//! land at all — is visible from a single command. +//! +//! Not part of the production circuit. Lives behind `#[cfg(test)]`. + +#![cfg(test)] +#![cfg_attr(coverage_nightly, coverage(off))] + +use plonky2::field::types::Field; +use plonky2::gates::constant::ConstantGate; +use plonky2::gates::noop::NoopGate; +use plonky2::hash::hash_types::HashOutTarget; +use plonky2::plonk::circuit_builder::CircuitBuilder; +use plonky2::plonk::circuit_data::{CircuitConfig, CommonCircuitData}; +use plonky2::recursion::dummy_circuit::dummy_circuit; + +use crate::circuit::main::{MAX_IN_COINS, N_PROOF_DATA_PUBLIC_INPUTS}; +use crate::circuit::source_aggregator::{ + build_source_aggregator_circuit, N_ST_VK_DIGEST_PIS, PER_SLOT_PIS, +}; +use crate::{C, D, F}; + +/// Inner-circuit pad-bits Stage 5d-next-3 ships with. +const PAD_BITS_BASELINE: usize = 14; + +/// Target num_public_inputs for the state-transition circuit: +/// 16 ProofData + 4 vk digest + 4 × cap_elements sigmas_cap. +fn st_num_pis() -> usize { + let cap_elements = CircuitConfig::standard_recursion_config() + .fri_config + .num_cap_elements(); + 16 + 4 + 4 * cap_elements +} + +/// Stage 5d-next-3 pass-3 helper (one `verify_proof`, no aggregator). +/// Returns the produced common with `num_public_inputs` overridden to +/// 84 — the value the outer's `build_circuit` patches in before +/// passing to `_or_dummy`. +fn pass_3_one_verify() -> CommonCircuitData { + // Pass 1 + let config = CircuitConfig::standard_recursion_config(); + let builder = CircuitBuilder::::new(config); + let data = builder.build::(); + + // Pass 2: one verify_proof + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let verifier_data = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &verifier_data, &data.common); + let data = builder.build::(); + + // Pass 3: one verify_proof + pad + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let verifier_data = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &verifier_data, &data.common); + while builder.num_gates() < 1 << PAD_BITS_BASELINE { + builder.add_gate(NoopGate, vec![]); + } + let mut common = builder.build::().common; + common.num_public_inputs = st_num_pis(); + common +} + +/// Stage 5d-next-5 candidate pass-3 + `num_forced_constants` +/// distinct constants wired into harmless `builder.mul(c, zero)` +/// operations. Used to probe whether explicit constant pressure +/// forces `ConstantGate` emission. `0` means no forced constants +/// (equivalent to [`pass_3_two_verify`]). +/// +/// **Conclusion from the first probe run:** this approach does NOT +/// work — every value of `num_forced_constants` from 1 up to 256 has +/// pass-3 absorbing the constants into existing `ArithmeticGate` +/// instances without ever emitting a standalone `ConstantGate`. The +/// function is kept as documented dead-end research; the working fix +/// is the explicit `ConstantGate::new(2)` injection in +/// [`pass_3_two_verify`]`(_, force_constant_gate = true)`. +#[allow(dead_code)] +fn pass_3_two_verify_forced( + pad_bits: usize, + num_forced_constants: usize, +) -> CommonCircuitData { + let bootstrap = pass_3_one_verify(); + let aggregator = build_source_aggregator_circuit(&bootstrap); + + let config = CircuitConfig::standard_recursion_config(); + let builder = CircuitBuilder::::new(config); + let data = builder.build::(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let vd = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &vd, &data.common); + let agg_proof = builder.add_virtual_proof_with_pis(&aggregator.data.common); + let agg_vd = + builder.add_virtual_verifier_data(aggregator.data.common.config.fri_config.cap_height); + builder.verify_proof::(&agg_proof, &agg_vd, &aggregator.data.common); + let data = builder.build::(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let vd = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &vd, &data.common); + let agg_proof = builder.add_virtual_proof_with_pis(&aggregator.data.common); + let agg_vd = + builder.add_virtual_verifier_data(aggregator.data.common.config.fri_config.cap_height); + builder.verify_proof::(&agg_proof, &agg_vd, &aggregator.data.common); + + // Forced constants: each `builder.constant` returns a virtual + // target tied to a compile-time value; using it in a `mul` with + // zero (= a no-op arithmetic op that nevertheless references the + // constant target) prevents the optimiser from eliding it. + if num_forced_constants > 0 { + let zero = builder.zero(); + for i in 0..num_forced_constants { + // Distinct values force distinct constant targets. + let c = builder.constant(F::from_canonical_u64(0xdead_beef_0000_0000u64 ^ i as u64)); + let _ = builder.mul(c, zero); + } + } + + while builder.num_gates() < 1 << pad_bits { + builder.add_gate(NoopGate, vec![]); + } + let mut common = builder.build::().common; + common.num_public_inputs = st_num_pis(); + common +} + +/// Stage 5d-next-5 candidate pass-3: two `verify_proof`s (one cyclic, +/// one against the aggregator's common). Returns common with +/// `num_public_inputs` overridden to 84. +/// +/// `force_constant_gate = true` adds one explicit `ConstantGate{num_consts: 2}` +/// instance in pass-3 just before the noop pad. The purpose is to +/// ensure pass-3's `gates` list includes `ConstantGate` even when the +/// caller's two `verify_proof` calls have produced enough +/// `ArithmeticGate` instances to absorb all constant pressure (the +/// 1-verify baseline naturally emits one; the 2-verify candidate +/// doesn't — see the probe summary). +fn pass_3_two_verify(pad_bits: usize, force_constant_gate: bool) -> CommonCircuitData { + // Bootstrap aggregator against pass-3-one-verify shape (the + // working Stage 5d-next-3 baseline). The aggregator's + // `dummy_circuit(st_common)` succeeds for this baseline shape, so + // the bootstrap build is safe. + let bootstrap = pass_3_one_verify(); + let aggregator = build_source_aggregator_circuit(&bootstrap); + + // Pass 1 + let config = CircuitConfig::standard_recursion_config(); + let builder = CircuitBuilder::::new(config); + let data = builder.build::(); + + // Pass 2: cyclic verify + aggregator verify + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let vd = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &vd, &data.common); + let agg_proof = builder.add_virtual_proof_with_pis(&aggregator.data.common); + let agg_vd = + builder.add_virtual_verifier_data(aggregator.data.common.config.fri_config.cap_height); + builder.verify_proof::(&agg_proof, &agg_vd, &aggregator.data.common); + let data = builder.build::(); + + // Pass 3: same shape + optional explicit ConstantGate + pad + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let proof = builder.add_virtual_proof_with_pis(&data.common); + let vd = builder.add_virtual_verifier_data(data.common.config.fri_config.cap_height); + builder.verify_proof::(&proof, &vd, &data.common); + let agg_proof = builder.add_virtual_proof_with_pis(&aggregator.data.common); + let agg_vd = + builder.add_virtual_verifier_data(aggregator.data.common.config.fri_config.cap_height); + builder.verify_proof::(&agg_proof, &agg_vd, &aggregator.data.common); + if force_constant_gate { + // Inject one ConstantGate{num_consts:2} instance so the gates + // list mirrors what `dummy_circuit`'s rebuild produces (the + // rebuild always allocates a ConstantGate for its PI-handling + // constants). The two slots hold trivial zeros — the gate + // instance is the point, not the constants themselves. + builder.add_gate(ConstantGate::new(2), vec![F::ZERO, F::ZERO]); + } + while builder.num_gates() < 1 << pad_bits { + builder.add_gate(NoopGate, vec![]); + } + let mut common = builder.build::().common; + common.num_public_inputs = st_num_pis(); + common +} + +fn dump_summary(label: &str, c: &CommonCircuitData) { + println!("\n=== {label} ==="); + println!( + " degree_bits = {}, num_public_inputs = {}, num_constants = {}", + c.fri_params.degree_bits, c.num_public_inputs, c.num_constants + ); + println!(" gates ({}):", c.gates.len()); + for (i, g) in c.gates.iter().enumerate() { + println!(" [{i:2}] {}", g.0.id()); + } + // SelectorsInfo's `selector_indices` and `groups` are private. Use + // the public Debug impl. + println!(" selectors_info: {:?}", c.selectors_info); +} + +fn try_dummy_circuit(label: &str, c: &CommonCircuitData) -> bool { + use std::panic::AssertUnwindSafe; + println!("\n--- dummy_circuit({label}) attempt ---"); + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let _ = dummy_circuit::(c); + })); + let ok = result.is_ok(); + println!(" → {}", if ok { "OK" } else { "PANIC (shape mismatch)" }); + ok +} + +#[test] +fn dump_pass_3_gates_lists_for_inspection() { + let baseline = pass_3_one_verify(); + dump_summary("Stage 5d-next-3 baseline (1 verify, pad 14)", &baseline); + let ok_baseline = try_dummy_circuit("baseline", &baseline); + + let two_verify_pad14 = pass_3_two_verify(14, false); + dump_summary( + "Phase 2a candidate (2 verify, pad 14, no forced ConstantGate)", + &two_verify_pad14, + ); + let ok_2v_14 = try_dummy_circuit("2-verify pad 14", &two_verify_pad14); + + // The decisive test: same shape, but with one explicit + // `ConstantGate` instance injected into pass-3 so its gates list + // matches `dummy_circuit`'s rebuild. + let two_verify_pad14_cg = pass_3_two_verify(14, true); + dump_summary( + "Phase 2a candidate (2 verify, pad 14, +ConstantGate)", + &two_verify_pad14_cg, + ); + let ok_2v_14_cg = try_dummy_circuit("2-verify pad 14 +CG", &two_verify_pad14_cg); + + println!( + "\n=== summary === baseline_ok={ok_baseline}, 2v_14={ok_2v_14}, 2v_14_with_constant_gate={ok_2v_14_cg}" + ); +} + +/// Minimal outer that mimics the Phase-2a structure WITHOUT the +/// Stage 5d-next-3 constraint gates (SMT/CMP/in-coin/out-coin) — just +/// the new bits: PI registration, `verify_proof(aggregator)`, +/// `connect_hashes` for vk binding, explicit `ConstantGate` injection, +/// and the cyclic `_or_dummy` at the end. Used by the diagnostic +/// below to identify which `CommonCircuitData` axis diverges between +/// helper-pass-3 and outer's actual built common. +/// +/// `common_data` is the helper-pass-3 output that the `_or_dummy` +/// call uses as its goal data. The test below extracts the actual +/// outer.common via `try_build_with_options` and diffs against it. +fn build_minimal_outer_for_diagnostic( + aggregator_data: &plonky2::plonk::circuit_data::CircuitData, + mut common_data: CommonCircuitData, +) -> (CommonCircuitData, CommonCircuitData, bool) { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + // Register ProofData public inputs first. + for _ in 0..N_PROOF_DATA_PUBLIC_INPUTS { + builder.add_virtual_public_input(); + } + + // Cyclic verifier_data target (this also registers the cyclic vk PIs). + let verifier_data_target = builder.add_verifier_data_public_inputs(); + common_data.num_public_inputs = builder.num_public_inputs(); + + // verify_proof(aggregator) + connect_hashes for vk binding. + let agg_proof = builder.add_virtual_proof_with_pis(&aggregator_data.common); + let agg_vd = builder.constant_verifier_data(&aggregator_data.verifier_only); + builder.verify_proof::(&agg_proof, &agg_vd, &aggregator_data.common); + + let st_vk_offset = MAX_IN_COINS * PER_SLOT_PIS; + let claimed_st_digest = HashOutTarget { + elements: [ + agg_proof.public_inputs[st_vk_offset], + agg_proof.public_inputs[st_vk_offset + 1], + agg_proof.public_inputs[st_vk_offset + 2], + agg_proof.public_inputs[st_vk_offset + 3], + ], + }; + builder.connect_hashes(claimed_st_digest, verifier_data_target.circuit_digest); + + let sigmas_cap_offset = st_vk_offset + N_ST_VK_DIGEST_PIS; + for (i, cap_hash) in verifier_data_target + .constants_sigmas_cap + .0 + .iter() + .enumerate() + { + let base = sigmas_cap_offset + 4 * i; + let claimed = HashOutTarget { + elements: [ + agg_proof.public_inputs[base], + agg_proof.public_inputs[base + 1], + agg_proof.public_inputs[base + 2], + agg_proof.public_inputs[base + 3], + ], + }; + builder.connect_hashes(claimed, *cap_hash); + } + + // Explicit ConstantGate injection (matches helper-pass-3's + // injection so the gates list has ConstantGate). + builder.add_gate(ConstantGate::new(2), vec![F::ZERO, F::ZERO]); + + // Cyclic verification — sets `goal_common_data = common_data`. + let condition = builder.add_virtual_bool_target_safe(); + let inner_proof_target = builder.add_virtual_proof_with_pis(&common_data); + builder + .conditionally_verify_cyclic_proof_or_dummy::( + condition, + &inner_proof_target, + &common_data, + ) + .expect("conditionally_verify_cyclic_proof_or_dummy: well-formed"); + + // try_build returns (data, success). success=false signals the + // goal_data check failed — but the resulting data.common still + // tells us what the outer ACTUALLY built. + let (data, success) = builder.try_build_with_options::(true); + (common_data, data.common, success) +} + +fn print_field_diff(name: &str, a: &T, b: &T) { + if a != b { + println!(" [DIFF] {name}:"); + println!(" helper = {a:?}"); + println!(" outer = {b:?}"); + } else { + println!(" [ok ] {name}: same"); + } +} + +/// Inner of the diagnostic: builds helper-pass-3 and minimal outer at +/// the given pad_bits and reports if try_build succeeds + degree +/// comparison. Returns (helper_degree, outer_degree, success). +fn diag_at_pad_bits(pad_bits: usize) -> (usize, usize, bool) { + let mut bootstrap = pass_3_one_verify(); + bootstrap.num_public_inputs = st_num_pis(); + let _agg_v0 = build_source_aggregator_circuit(&bootstrap); + + let helper_common = pass_3_two_verify(pad_bits, true); + let mut helper_for_agg = helper_common.clone(); + helper_for_agg.num_public_inputs = st_num_pis(); + let agg_v1 = build_source_aggregator_circuit(&helper_for_agg); + + let (helper_common_final, outer_common, success) = + build_minimal_outer_for_diagnostic(&agg_v1.data, helper_for_agg.clone()); + + ( + helper_common_final.fri_params.degree_bits, + outer_common.fri_params.degree_bits, + success, + ) +} + +#[test] +#[ignore = "diagnostic only; rebuilds full outer + aggregator twice"] +fn dump_phase_2a_outer_vs_helper_diff() { + // Step 1: bootstrap aggregator against Stage 5d-next-3 shape. + let mut bootstrap = pass_3_one_verify(); + bootstrap.num_public_inputs = st_num_pis(); + let _agg_v0 = build_source_aggregator_circuit(&bootstrap); + + // Step 2: compute helper-pass-3 common with aggregator + ConstantGate. + let helper_common = pass_3_two_verify(16, true); + + // Step 3: rebuild aggregator against the helper-pass-3 common so + // its source-proof targets are sized correctly. + let mut helper_for_agg = helper_common.clone(); + helper_for_agg.num_public_inputs = st_num_pis(); + let agg_v1 = build_source_aggregator_circuit(&helper_for_agg); + + // Step 4: build the minimal outer with _or_dummy(helper-pass-3). + let (helper_common_final, outer_common, success) = + build_minimal_outer_for_diagnostic(&agg_v1.data, helper_for_agg.clone()); + + println!("\n=== Phase 2a outer-vs-helper diagnostic (try_build success = {success}) ==="); + + print_field_diff("config", &helper_common_final.config, &outer_common.config); + print_field_diff( + "fri_params.degree_bits", + &helper_common_final.fri_params.degree_bits, + &outer_common.fri_params.degree_bits, + ); + print_field_diff( + "fri_params.hiding", + &helper_common_final.fri_params.hiding, + &outer_common.fri_params.hiding, + ); + print_field_diff( + "fri_params.reduction_arity_bits", + &helper_common_final.fri_params.reduction_arity_bits, + &outer_common.fri_params.reduction_arity_bits, + ); + let helper_gate_ids: Vec = helper_common_final.gates.iter().map(|g| g.0.id()).collect(); + let outer_gate_ids: Vec = outer_common.gates.iter().map(|g| g.0.id()).collect(); + print_field_diff("gates (by id)", &helper_gate_ids, &outer_gate_ids); + print_field_diff( + "selectors_info", + &format!("{:?}", helper_common_final.selectors_info), + &format!("{:?}", outer_common.selectors_info), + ); + print_field_diff( + "quotient_degree_factor", + &helper_common_final.quotient_degree_factor, + &outer_common.quotient_degree_factor, + ); + print_field_diff( + "num_gate_constraints", + &helper_common_final.num_gate_constraints, + &outer_common.num_gate_constraints, + ); + print_field_diff( + "num_constants", + &helper_common_final.num_constants, + &outer_common.num_constants, + ); + print_field_diff( + "num_public_inputs", + &helper_common_final.num_public_inputs, + &outer_common.num_public_inputs, + ); + print_field_diff("k_is", &helper_common_final.k_is, &outer_common.k_is); + print_field_diff( + "num_partial_products", + &helper_common_final.num_partial_products, + &outer_common.num_partial_products, + ); + + assert!( + success, + "Phase 2a outer-vs-helper diagnostic: try_build success was false — \ + a CommonCircuitData axis diverges. Check the [DIFF] lines above." + ); +} + +/// Sweep helper-pass-3's `INNER_PAD_BITS` across {14, 15, 16, 17} +/// and report (helper_degree, outer_degree, success) for each. The +/// goal: find the pad-bits value at which helper-degree == minimal- +/// outer-degree (only condition `try_build` accepts). Once we know +/// which pad-bits matches the minimal outer's natural degree, the +/// FULL outer (with all Stage 5d-next-3 constraint gates) needs the +/// same pad — possibly bumped by 1 to absorb the extra gate count. +#[test] +#[ignore = "diagnostic only; expensive — rebuilds aggregator + outer 4 times"] +fn dump_phase_2a_pad_bits_sweep() { + println!("\n=== pad_bits sweep: helper-degree vs minimal-outer-degree ==="); + for pad_bits in [14usize, 15, 16, 17] { + let (h, o, ok) = diag_at_pad_bits(pad_bits); + println!( + " pad_bits = {pad_bits:<2} helper_degree = {h} minimal_outer_degree = {o} success = {ok}" + ); + } +} diff --git a/program-plonky2/src/circuit/smt.rs b/program-plonky2/src/circuit/smt.rs new file mode 100644 index 00000000..b87ac09f --- /dev/null +++ b/program-plonky2/src/circuit/smt.rs @@ -0,0 +1,636 @@ +//! In-circuit sparse Merkle tree gadgets. +//! +//! Off-circuit equivalents live in +//! [`crate::merkle::sparse_merkle_tree`]; this module ports their +//! verification logic to Plonky2 constraints. +//! +//! ## Fixed depth +//! +//! All gadgets here operate on a **fixed [`TREE_DEPTH`]** path. The +//! off-circuit SMT (uncompressed variant) produces 256-sibling proofs +//! regardless of how sparsely the tree is populated, and the in-circuit +//! gadget always hashes through 256 levels. This is required for +//! Plonky2 cyclic recursion: the `circuit_digest` must be stable +//! across builds, which means the verifier shape cannot depend on +//! variable proof lengths. +//! +//! ## Key encoding +//! +//! The SMT key is a 256-bit value. Off-circuit it is held as `[u8; 32]`, +//! MSB-first per byte. In-circuit it is held as a `HashOutTarget` (4 +//! Goldilocks elements). The two representations are interconverted via +//! the big-endian-per-element scheme in `crate::hash::digest_to_bytes` / +//! `digest_from_bytes`. As a consequence, bit 0 of the key (the topmost +//! tree-selector) is the most-significant bit of `key.elements[0]`. +//! +//! Index convention for `key_bits` / `path`: +//! - `key_bits[level]` is the bit at MSB-index `level` (matches +//! off-circuit `get_bit(key, level)`); `level = 0` is the topmost +//! (root-level selector) and `level = TREE_DEPTH - 1` is the deepest. +//! - `path[level]` is the sibling of the node on `key`'s branch at +//! `level + 1`; `level = 0` is the topmost sibling and +//! `level = TREE_DEPTH - 1` is the deepest (just above the leaf). + +use plonky2::field::extension::Extendable; +use plonky2::hash::hash_types::{HashOutTarget, RichField}; +use plonky2::hash::poseidon::PoseidonHash; +use plonky2::iop::target::BoolTarget; +use plonky2::plonk::circuit_builder::CircuitBuilder; + +use super::util::swap_if; +use crate::merkle::sparse_merkle_tree::TREE_DEPTH; + +/// Decompose a `HashOutTarget` representing a 256-bit key into 256 bits in +/// the canonical MSB-first ordering used by +/// [`crate::merkle::sparse_merkle_tree::get_bit`]. +/// +/// Bit `i` of the result equals `get_bit(digest_to_bytes(key), i)`. In +/// other words: `result[0]` is the most-significant bit of byte 0 of the +/// big-endian serialisation of `key.elements[0]`. +pub fn key_bits_msb_first, const D: usize>( + builder: &mut CircuitBuilder, + key: HashOutTarget, +) -> Vec { + let mut bits = Vec::with_capacity(TREE_DEPTH); + for element in key.elements.iter() { + // split_le yields bit 0 (LSB) first; reverse to MSB-first. + let mut le_bits = builder.split_le(*element, 64); + le_bits.reverse(); + bits.extend(le_bits); + } + bits +} + +/// Hash from `start` (a depth-`TREE_DEPTH` value) up to the root through +/// `path`. At each `level ∈ [TREE_DEPTH - 1, 0]` the sibling at `path[level]` +/// is combined with the running hash, ordering chosen by `key_bits[level]`. +/// +/// Returns the resulting root-level hash. This is the common engine for +/// every SMT proof gadget below: only the starting hash differs (leaf +/// hash for inclusion / insert-new, empty-leaf default for +/// non-inclusion / insert-old). +/// +/// Exposed so external callers (e.g. the monolithic state-transition +/// circuit in `circuit/main.rs`) can build masked variants of the +/// inclusion / non-inclusion checks by reusing this engine with a +/// custom `start` value and then connecting the result to a +/// `select`-masked target. +pub fn hash_up_full_path, const D: usize>( + builder: &mut CircuitBuilder, + start: HashOutTarget, + key_bits: &[BoolTarget], + path: &[HashOutTarget], +) -> HashOutTarget { + assert_eq!( + path.len(), + TREE_DEPTH, + "hash_up_full_path: path must have exactly TREE_DEPTH siblings" + ); + assert!( + key_bits.len() >= TREE_DEPTH, + "hash_up_full_path: key_bits must cover at least TREE_DEPTH levels" + ); + let mut current = start; + for level in (0..TREE_DEPTH).rev() { + let bit = key_bits[level]; + let sibling = path[level]; + let (left, right) = swap_if(builder, bit, current, sibling); + let mut input = Vec::with_capacity(8); + input.extend_from_slice(&left.elements); + input.extend_from_slice(&right.elements); + current = builder.hash_n_to_hash_no_pad::(input); + } + current +} + +/// Compute the SMT leaf-hash `Poseidon(leaf_value || key)`. Used by +/// every inclusion / insert gadget. Shared as a helper so the same +/// 8-element absorption order is preserved everywhere. +fn smt_leaf_hash, const D: usize>( + builder: &mut CircuitBuilder, + leaf_value: HashOutTarget, + key: HashOutTarget, +) -> HashOutTarget { + let mut input = Vec::with_capacity(8); + input.extend_from_slice(&leaf_value.elements); + input.extend_from_slice(&key.elements); + builder.hash_n_to_hash_no_pad::(input) +} + +/// Compute the SMT root from an inclusion proof in-circuit, without +/// constraining it to any expected value. Caller responsibility is to +/// connect the returned `HashOutTarget` to its expected root (possibly +/// via [`builder.connect_hashes`] or a masked / `select`-based path, +/// e.g. when the inclusion check should only fire under a guard +/// condition). +/// +/// `key_bits` must contain the full 256-bit MSB-first decomposition of +/// `key` (use [`key_bits_msb_first`]); `path` must have exactly +/// [`TREE_DEPTH`] sibling hashes. +pub fn smt_inclusion_root, const D: usize>( + builder: &mut CircuitBuilder, + leaf: HashOutTarget, + key: HashOutTarget, + key_bits: &[BoolTarget], + path: &[HashOutTarget], +) -> HashOutTarget { + let start = smt_leaf_hash(builder, leaf, key); + hash_up_full_path(builder, start, key_bits, path) +} + +/// Verify an SMT inclusion proof in-circuit. +/// +/// Off-circuit equivalent: +/// [`crate::merkle::sparse_merkle_tree::InclusionProof::verify`]. +pub fn verify_smt_inclusion, const D: usize>( + builder: &mut CircuitBuilder, + leaf: HashOutTarget, + key: HashOutTarget, + key_bits: &[BoolTarget], + path: &[HashOutTarget], + expected_root: HashOutTarget, +) { + let computed = smt_inclusion_root(builder, leaf, key, key_bits, path); + builder.connect_hashes(computed, expected_root); +} + +/// Verify an SMT non-inclusion proof in-circuit. +/// +/// Off-circuit equivalent: +/// [`crate::merkle::sparse_merkle_tree::NonInclusionProof::verify`]. +/// +/// The proof witnesses that `key`'s leaf slot at depth [`TREE_DEPTH`] +/// holds the empty-leaf default value (`DEFAULT_HASHES[TREE_DEPTH]`). +/// `empty_leaf_default` is that constant, witnessed by the caller; the +/// gadget hashes it up through `path` and `key_bits` and asserts the +/// result equals `expected_root`. +pub fn verify_smt_non_inclusion, const D: usize>( + builder: &mut CircuitBuilder, + key_bits: &[BoolTarget], + path: &[HashOutTarget], + expected_root: HashOutTarget, + empty_leaf_default: HashOutTarget, +) { + // `key` itself is not a parameter — its branch information is fully + // captured by `key_bits` (the caller produces the bits via + // `key_bits_msb_first`). The non-inclusion predicate is simply: + // "the leaf slot at `key` holds the empty-leaf default". + let computed = hash_up_full_path(builder, empty_leaf_default, key_bits, path); + builder.connect_hashes(computed, expected_root); +} + +/// Verify an SMT non-inclusion proof AND compute the new root after +/// inserting `(new_value, key)` at that key, asserting equality with +/// `expected_new_root`. +/// +/// Off-circuit equivalent: +/// [`crate::merkle::sparse_merkle_tree::NonInclusionProof::verify_and_insert`]. +/// +/// Both the old and new roots are computed by hashing up the same +/// `path` siblings; only the starting hash differs: +/// - Old-root walk starts from `empty_leaf_default` +/// (= `DEFAULT_HASHES[TREE_DEPTH]`) and must match `expected_old_root`. +/// - New-root walk starts from `Poseidon(new_value || key)` and must +/// match `expected_new_root`. +#[allow(clippy::too_many_arguments)] +pub fn verify_smt_insert, const D: usize>( + builder: &mut CircuitBuilder, + new_value: HashOutTarget, + key: HashOutTarget, + key_bits: &[BoolTarget], + path: &[HashOutTarget], + expected_old_root: HashOutTarget, + expected_new_root: HashOutTarget, + empty_leaf_default: HashOutTarget, +) { + // Old-root verification (mirrors verify_smt_non_inclusion). + let old_computed = hash_up_full_path(builder, empty_leaf_default, key_bits, path); + builder.connect_hashes(old_computed, expected_old_root); + + // New-root computation: same path, leaf-hash starting point. + let new_start = smt_leaf_hash(builder, new_value, key); + let new_computed = hash_up_full_path(builder, new_start, key_bits, path); + builder.connect_hashes(new_computed, expected_new_root); +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::{digest_from_bytes, hash_bytes, HashDigest, ZERO_HASH}; + use crate::merkle::sparse_merkle_tree::{SparseMerkleTree, DEFAULT_HASHES}; + use crate::{C, D, F}; + use plonky2::iop::witness::{PartialWitness, WitnessWrite}; + use plonky2::plonk::circuit_data::CircuitConfig; + + /// Builds a fresh 256-level SMT-inclusion-verify circuit, witnesses + /// it, proves, verifies. Used by every inclusion positive-case test + /// to keep the build-witness boilerplate in one place. + fn inclusion_round_trip(keys: &[[u8; 32]], values: &[HashDigest], target_key: [u8; 32]) { + // Off-circuit SMT + let mut tree = SparseMerkleTree::new(); + for (k, v) in keys.iter().zip(values.iter()) { + tree.insert(*k, *v).unwrap(); + } + let target_value = tree.get(&target_key).unwrap(); + let (proof, _) = tree.generate_inclusion_proof(&target_key).unwrap(); + assert!( + proof.verify(target_value, tree.root()), + "off-circuit sanity" + ); + assert_eq!(proof.siblings.len(), TREE_DEPTH); + + // Circuit + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_inclusion(&mut builder, leaf_t, key_t, &key_bits, &path_t, root_t); + let data = builder.build::(); + + // Witness + let mut pw = PartialWitness::new(); + pw.set_hash_target(leaf_t, target_value).unwrap(); + pw.set_hash_target(key_t, digest_from_bytes(&target_key)) + .unwrap(); + pw.set_hash_target(root_t, tree.root()).unwrap(); + for (i, sib) in proof.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + let proof_with_pis = data.prove(pw).expect("prove failed"); + data.verify(proof_with_pis).expect("verify failed"); + } + + /// Two-leaf tree that diverges at bit 0 — smallest possible + /// divergence; siblings list contains real values at levels 0.. + /// and defaults elsewhere. + #[test] + fn smt_inclusion_two_leaves_bit0_divergent() { + let k0 = [0u8; 32]; + let mut k1 = [0u8; 32]; + k1[0] = 0x80; // bit 0 = 1 + let v0 = hash_bytes(b"v0"); + let v1 = hash_bytes(b"v1"); + inclusion_round_trip(&[k0, k1], &[v0, v1], k0); + inclusion_round_trip(&[k0, k1], &[v0, v1], k1); + } + + /// Three-leaf tree, all queries. + #[test] + fn smt_inclusion_three_leaves() { + let k0 = [0u8; 32]; + let mut k1 = [0u8; 32]; + k1[0] = 0x40; // bit 1 = 1 + let mut k2 = [0u8; 32]; + k2[0] = 0xC0; // bits 0,1 = 1,1 + let vs = [hash_bytes(b"v0"), hash_bytes(b"v1"), hash_bytes(b"v2")]; + inclusion_round_trip(&[k0, k1, k2], &vs, k0); + inclusion_round_trip(&[k0, k1, k2], &vs, k1); + inclusion_round_trip(&[k0, k1, k2], &vs, k2); + } + + /// Build a non-inclusion round-trip for `lookup`. + fn non_inclusion_round_trip(tree: &SparseMerkleTree, lookup: [u8; 32]) { + let nip = tree.generate_non_inclusion_proof(lookup).unwrap(); + assert!(nip.verify(), "off-circuit sanity"); + assert_eq!(nip.siblings.len(), TREE_DEPTH); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let empty_leaf_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_non_inclusion(&mut builder, &key_bits, &path_t, root_t, empty_leaf_t); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + pw.set_hash_target(key_t, digest_from_bytes(&nip.key)) + .unwrap(); + pw.set_hash_target(root_t, nip.root).unwrap(); + pw.set_hash_target(empty_leaf_t, DEFAULT_HASHES[TREE_DEPTH]) + .unwrap(); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + let proof_with_pis = data.prove(pw).expect("prove failed"); + data.verify(proof_with_pis).expect("verify failed"); + } + + /// Non-inclusion in an empty tree: every sibling is a default, and + /// the empty-leaf seed walks all the way up to `DEFAULT_HASHES[0]`. + #[test] + fn smt_non_inclusion_empty_tree() { + let tree = SparseMerkleTree::new(); + non_inclusion_round_trip(&tree, [1u8; 32]); + } + + /// Non-inclusion in a tree that already contains other leaves. The + /// path siblings are a mix of real values (along the populated + /// branches) and defaults. + #[test] + fn smt_non_inclusion_with_other_leaves() { + let mut tree = SparseMerkleTree::new(); + let mut k0 = [0u8; 32]; + k0[0] = 0x80; + tree.insert(k0, hash_bytes(b"v0")).unwrap(); + + let mut k1 = [0u8; 32]; + k1[0] = 0x40; + tree.insert(k1, hash_bytes(b"v1")).unwrap(); + + // Lookup a third key not in the tree. + let mut lookup = [0u8; 32]; + lookup[0] = 0x10; + non_inclusion_round_trip(&tree, lookup); + } + + #[test] + fn smt_inclusion_tampered_leaf_fails() { + let k0 = [0u8; 32]; + let mut k1 = [0u8; 32]; + k1[0] = 0x80; + let v0 = hash_bytes(b"v0"); + let v1 = hash_bytes(b"v1"); + + let mut tree = SparseMerkleTree::new(); + tree.insert(k0, v0).unwrap(); + tree.insert(k1, v1).unwrap(); + let (proof, _) = tree.generate_inclusion_proof(&k0).unwrap(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_inclusion(&mut builder, leaf_t, key_t, &key_bits, &path_t, root_t); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + // Wrong leaf value: ZERO_HASH instead of v0. + pw.set_hash_target(leaf_t, ZERO_HASH).unwrap(); + pw.set_hash_target(key_t, digest_from_bytes(&k0)).unwrap(); + pw.set_hash_target(root_t, tree.root()).unwrap(); + for (i, sib) in proof.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + assert!(data.prove(pw).is_err(), "tampered leaf must not prove"); + } + + /// Tampered non-inclusion: present an empty-leaf default with the + /// wrong value (e.g. ZERO_HASH instead of DEFAULT_HASHES[TREE_DEPTH]). + /// The walk produces a different root and verification fails. + #[test] + fn smt_non_inclusion_wrong_empty_leaf_default_fails() { + let tree = SparseMerkleTree::new(); + let nip = tree.generate_non_inclusion_proof([1u8; 32]).unwrap(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let empty_leaf_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_non_inclusion(&mut builder, &key_bits, &path_t, root_t, empty_leaf_t); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + pw.set_hash_target(key_t, digest_from_bytes(&nip.key)) + .unwrap(); + pw.set_hash_target(root_t, nip.root).unwrap(); + // Lie: claim the empty-leaf default is ZERO_HASH instead of the + // protocol-defined domain-separated seed. + pw.set_hash_target(empty_leaf_t, ZERO_HASH).unwrap(); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + assert!( + data.prove(pw).is_err(), + "wrong empty-leaf default must not prove" + ); + } + + /// Insert round-trip helper. Builds the gadget, witnesses the + /// inputs, proves and verifies. + fn insert_round_trip( + tree: &SparseMerkleTree, + nip: &crate::merkle::sparse_merkle_tree::NonInclusionProof, + new_value: HashDigest, + ) { + let expected_new_root = nip.verify_and_insert(new_value).expect("off-circuit"); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let new_value_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let old_root_t = builder.add_virtual_hash(); + let new_root_t = builder.add_virtual_hash(); + let empty_leaf_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_insert( + &mut builder, + new_value_t, + key_t, + &key_bits, + &path_t, + old_root_t, + new_root_t, + empty_leaf_t, + ); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + pw.set_hash_target(new_value_t, new_value).unwrap(); + pw.set_hash_target(key_t, digest_from_bytes(&nip.key)) + .unwrap(); + pw.set_hash_target(old_root_t, tree.root()).unwrap(); + pw.set_hash_target(new_root_t, expected_new_root).unwrap(); + pw.set_hash_target(empty_leaf_t, DEFAULT_HASHES[TREE_DEPTH]) + .unwrap(); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + let proof_with_pis = data.prove(pw).expect("prove failed"); + data.verify(proof_with_pis).expect("verify failed"); + } + + #[test] + fn smt_insert_into_empty_tree() { + let tree = SparseMerkleTree::new(); + let nip = tree.generate_non_inclusion_proof([1u8; 32]).unwrap(); + insert_round_trip(&tree, &nip, hash_bytes(b"new")); + } + + #[test] + fn smt_insert_into_populated_tree() { + let mut tree = SparseMerkleTree::new(); + let mut k0 = [0u8; 32]; + k0[0] = 0x80; + tree.insert(k0, hash_bytes(b"v0")).unwrap(); + let mut k1 = [0u8; 32]; + k1[0] = 0x40; + tree.insert(k1, hash_bytes(b"v1")).unwrap(); + + // Insert a third key. + let mut new_key = [0u8; 32]; + new_key[31] = 0x01; + let nip = tree.generate_non_inclusion_proof(new_key).unwrap(); + insert_round_trip(&tree, &nip, hash_bytes(b"v2")); + } + + /// Tampered new-leaf value: the gadget computes a new_root from the + /// lying `new_value` that doesn't match the honest `expected_new_root` + /// witnessed alongside it; `connect_hashes` fails. + #[test] + fn smt_insert_tampered_new_value_fails() { + let tree = SparseMerkleTree::new(); + let nip = tree.generate_non_inclusion_proof([1u8; 32]).unwrap(); + let honest_new_value = hash_bytes(b"honest"); + let expected_new_root = nip.verify_and_insert(honest_new_value).unwrap(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let new_value_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let old_root_t = builder.add_virtual_hash(); + let new_root_t = builder.add_virtual_hash(); + let empty_leaf_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_insert( + &mut builder, + new_value_t, + key_t, + &key_bits, + &path_t, + old_root_t, + new_root_t, + empty_leaf_t, + ); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + // Lie: a different new_value than the one the expected_new_root was computed for. + pw.set_hash_target(new_value_t, hash_bytes(b"lie")).unwrap(); + pw.set_hash_target(key_t, digest_from_bytes(&nip.key)) + .unwrap(); + pw.set_hash_target(old_root_t, tree.root()).unwrap(); + pw.set_hash_target(new_root_t, expected_new_root).unwrap(); + pw.set_hash_target(empty_leaf_t, DEFAULT_HASHES[TREE_DEPTH]) + .unwrap(); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + assert!(data.prove(pw).is_err(), "tampered new_value must not prove"); + } + + /// Tampered expected_new_root: the gadget computes the new root from + /// the honest new_value but the witnessed `expected_new_root` is a + /// different digest. `connect_hashes` fails. + #[test] + fn smt_insert_tampered_expected_new_root_fails() { + let tree = SparseMerkleTree::new(); + let nip = tree.generate_non_inclusion_proof([1u8; 32]).unwrap(); + + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let new_value_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let old_root_t = builder.add_virtual_hash(); + let new_root_t = builder.add_virtual_hash(); + let empty_leaf_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_insert( + &mut builder, + new_value_t, + key_t, + &key_bits, + &path_t, + old_root_t, + new_root_t, + empty_leaf_t, + ); + let data = builder.build::(); + + let mut pw = PartialWitness::new(); + pw.set_hash_target(new_value_t, hash_bytes(b"new")).unwrap(); + pw.set_hash_target(key_t, digest_from_bytes(&nip.key)) + .unwrap(); + pw.set_hash_target(old_root_t, tree.root()).unwrap(); + // Lie: a random digest as the claimed new_root. + pw.set_hash_target(new_root_t, hash_bytes(b"unrelated")) + .unwrap(); + pw.set_hash_target(empty_leaf_t, DEFAULT_HASHES[TREE_DEPTH]) + .unwrap(); + for (i, sib) in nip.siblings.iter().enumerate() { + pw.set_hash_target(path_t[i], *sib).unwrap(); + } + + assert!( + data.prove(pw).is_err(), + "tampered expected_new_root must not prove" + ); + } + + /// Build-time assertion: path of wrong length panics + /// (`hash_up_full_path` checks `path.len() == TREE_DEPTH`). + #[test] + #[should_panic(expected = "path must have exactly TREE_DEPTH siblings")] + fn smt_inclusion_short_path_panics() { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let path_t: Vec = (0..3).map(|_| builder.add_virtual_hash()).collect(); + let key_bits = key_bits_msb_first(&mut builder, key_t); + verify_smt_inclusion(&mut builder, leaf_t, key_t, &key_bits, &path_t, root_t); + } + + /// Build-time assertion: key_bits too short panics. + #[test] + #[should_panic(expected = "key_bits must cover at least TREE_DEPTH levels")] + fn smt_inclusion_short_key_bits_panics() { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + let leaf_t = builder.add_virtual_hash(); + let key_t = builder.add_virtual_hash(); + let root_t = builder.add_virtual_hash(); + let path_t: Vec = (0..TREE_DEPTH) + .map(|_| builder.add_virtual_hash()) + .collect(); + // Only 2 bits — fewer than the TREE_DEPTH path. + let bit0 = builder.add_virtual_bool_target_safe(); + let bit1 = builder.add_virtual_bool_target_safe(); + verify_smt_inclusion(&mut builder, leaf_t, key_t, &[bit0, bit1], &path_t, root_t); + } +} diff --git a/program-plonky2/src/circuit/source_aggregator.rs b/program-plonky2/src/circuit/source_aggregator.rs new file mode 100644 index 00000000..317b1dd7 --- /dev/null +++ b/program-plonky2/src/circuit/source_aggregator.rs @@ -0,0 +1,531 @@ +//! Source-proof aggregator circuit (Stage 5d-next-5). +//! +//! Bundles up to [`MAX_IN_COINS`] in-coin source proofs into one +//! aggregated proof that the outer [`crate::circuit::main`] circuit can +//! verify with a single regular (non-cyclic) `verify_proof` call. +//! +//! # Status — read first +//! +//! This module is **Phase 1 only** of issue #19. The aggregator is +//! currently **NOT consumed by the outer state-transition circuit** +//! ([`crate::circuit::main::build_circuit`]) — it lives here as a +//! self-contained artifact exercised by its own unit tests. Phase 2a +//! (outer-side `verify_proof(aggregator)` + `connect_hashes`) and +//! Phase 2b (per-in-coin SMT + CMP source-side gates) are blocked on +//! a Plonky2 1.1.0 `dummy_circuit` shape mismatch documented in +//! `MIGRATION_RESEARCH.md` §7.22 at the workspace root. Do not assume +//! adding `verify_proof(aggregator)` to the outer will Just Work — +//! the attempt was made and reverted in this PR; the doc explains why. +//! +//! ## Why this exists +//! +//! Per SPEC §8 step 2 the in-coins predicate requires, per slot, a +//! recursive verification of the source state-transition proof. +//! Plonky2 1.1.0 limits a cyclic-recursion outer circuit (one whose +//! `common_data` includes `ConstantGate` because of multiple +//! `verify_proof` calls) to exactly ONE +//! `conditionally_verify_cyclic_proof_or_dummy` per build: a second +//! call's internal `dummy_circuit` rebuild fails the +//! `assert_eq!(&circuit.common, common_data)` shape check at +//! `dummy_circuit.rs:116`. See `MIGRATION_RESEARCH.md` §7.21. +//! +//! The aggregator pattern resolves this: +//! +//! - **Aggregator** (this module) is NOT cyclic — it does not call +//! `add_verifier_data_public_inputs`. Its own `common_data` is fixed +//! at build time. It performs `MAX_IN_COINS` +//! `conditionally_verify_proof` calls (the non-cyclic conditional +//! variant), which select between a real source proof and a +//! hand-rolled dummy. Because no `_or_dummy` is involved, the +//! `dummy_circuit` assertion never fires. +//! +//! - **Outer** (the state-transition circuit, [`crate::circuit::main`]) +//! stays at exactly one `conditionally_verify_cyclic_proof_or_dummy` +//! for `prev_account` (unchanged Stage 5d-next-3 shape) plus one +//! regular `verify_proof` for the aggregator proof. The regular +//! `verify_proof` does NOT invoke `dummy_circuit`, so the multi-verify +//! Plonky2 limitation is sidestepped. +//! +//! ## Fixed-point: lazy verifier_data with connect-back +//! +//! The aggregator verifies proofs of the state-transition circuit. But +//! the state-transition's `verifier_only.circuit_digest` cannot be +//! pinned at aggregator build time without a chicken-and-egg fixed-point. +//! Resolution per `MIGRATION_RESEARCH.md` §7.22: +//! +//! - At aggregator build time, the state-transition verifier_data is a +//! `add_virtual_verifier_data` target with NO constant pin. +//! - The aggregator exposes the witnessed st verifier_data as additional +//! public inputs (digest + constants_sigmas_cap). +//! - At outer build time, after the cyclic verify wires up the outer's +//! own `verifier_data_target`, the outer extracts the aggregator's +//! claimed st verifier_data from the aggregator's public inputs and +//! `connect_hashes`-binds it to its own. A wrong-vk aggregator proof +//! then fails at outer verify. +//! +//! ## Per-slot dummy +//! +//! `conditionally_verify_proof` (non-`_or_dummy` variant) takes two +//! `(proof, vd)` pairs and verifies the one selected by the condition. +//! For the dummy "branch" (inactive slots) the aggregator passes: +//! +//! - `proof_b`: a virtual proof target witnessed at prove time with +//! `cyclic_base_proof(st_common, st_verifier_only, empty_pis)` — the +//! same dummy Stage 5d-next-3's `prove_initial` uses for the cyclic +//! slot when `condition = false`. +//! - `vd_b`: a `constant_verifier_data` from a one-shot +//! `dummy_circuit::(st_common)` instance. The dummy circuit +//! is the one against which `cyclic_base_proof` actually verifies. +//! +//! The dummy circuit's `verifier_only.circuit_digest` is deterministic +//! given `st_common`, so pinning it as a constant in the aggregator is +//! safe — `cyclic_base_proof` will always produce a proof verifiable +//! against this same dummy verifier. +//! +//! ## Public-input layout +//! +//! ```text +//! [0 .. MAX_IN_COINS * PER_SLOT_PIS]: +//! For each slot i (0-indexed): +//! [i*17 + 0..i*17 + 16]: source's ProofData (16 elements) +//! [i*17 + 16]: slot's `active` bit (0 or 1) +//! [MAX_IN_COINS * 17 .. + 4]: +//! state-transition vk circuit_digest (4 elements) +//! [MAX_IN_COINS * 17 + 4 .. + 4 + 4 * cap_elements]: +//! state-transition vk constants_sigmas_cap (4 elements per cap entry) +//! ``` +//! +//! `cap_elements = 1 << cap_height`. For +//! `CircuitConfig::standard_recursion_config()` (`cap_height = 4`), +//! `cap_elements = 16`, so the cap occupies `4 * 16 = 64` elements. +//! Total aggregator PIs: `8 * 17 + 4 + 64 = 204`. + +use anyhow::Result; +use plonky2::iop::target::BoolTarget; +use plonky2::iop::witness::{PartialWitness, WitnessWrite}; +use plonky2::plonk::circuit_builder::CircuitBuilder; +use plonky2::plonk::circuit_data::{ + CircuitConfig, CircuitData, CommonCircuitData, VerifierCircuitTarget, VerifierOnlyCircuitData, +}; +use plonky2::plonk::proof::{ProofWithPublicInputs, ProofWithPublicInputsTarget}; +use plonky2::recursion::dummy_circuit::{cyclic_base_proof, dummy_circuit}; + +use crate::circuit::main::{MAX_IN_COINS, N_PROOF_DATA_PUBLIC_INPUTS}; +use crate::{C, D, F}; + +/// Number of public-input slots per source slot the aggregator exposes: +/// 16 `ProofData` field elements + 1 `active` bit. +pub const PER_SLOT_PIS: usize = N_PROOF_DATA_PUBLIC_INPUTS + 1; + +/// Public-input slots holding the state-transition verifier-key digest. +pub const N_ST_VK_DIGEST_PIS: usize = 4; + +/// Number of elements in the state-transition verifier-key +/// constants-sigmas cap. With +/// [`CircuitConfig::standard_recursion_config`] this is +/// `1 << cap_height = 16`, each element being a `HashOut` of 4 field +/// elements → 64 public-input slots total. +/// +/// Computed at runtime from the supplied `st_common` rather than +/// hard-coded, so changes to the recursion config remain consistent +/// without manual edits. +pub fn n_st_sigmas_cap_pis(st_common: &CommonCircuitData) -> usize { + 4 * st_common.config.fri_config.num_cap_elements() +} + +/// Total number of public inputs the aggregator exposes: +/// `MAX_IN_COINS * PER_SLOT_PIS + N_ST_VK_DIGEST_PIS + n_st_sigmas_cap_pis(st_common)`. +pub fn total_aggregator_pis(st_common: &CommonCircuitData) -> usize { + MAX_IN_COINS * PER_SLOT_PIS + N_ST_VK_DIGEST_PIS + n_st_sigmas_cap_pis(st_common) +} + +/// Per-slot witness targets the prover populates: real source proof +/// (proof_a) + dummy proof (proof_b) + `active` bit. The dummy proof +/// target is set to a `cyclic_base_proof` at prove time regardless of +/// `active`; only when `active = false` is it actually verified. +pub struct AggregatorSlotTargets { + pub active: BoolTarget, + /// "Real" proof, verified when `active = true`. + pub real_proof: ProofWithPublicInputsTarget, + /// Dummy proof, verified when `active = false`. Witnessed with + /// `cyclic_base_proof(st_common, st_verifier_only, _)` at prove time. + pub dummy_proof: ProofWithPublicInputsTarget, +} + +/// Handle to the built aggregator circuit + the witness targets a +/// caller needs to populate when proving. +pub struct SourceAggregatorCircuit { + pub data: CircuitData, + /// `st_common` the aggregator was built against. Outer integration + /// needs this to thread the dummy-proof witness through `cyclic_base_proof`. + pub st_common: CommonCircuitData, + /// Cached dummy-circuit verifier_only. Constant-baked into the + /// aggregator as `dummy_vd_target`. Cached here so `prove_aggregator` + /// doesn't rebuild it. + pub dummy_st_verifier_only: VerifierOnlyCircuitData, + pub slots: Vec, + /// Virtual target for the SHARED state-transition verifier_data. + /// Exposed as PIs so the outer can `connect_hashes`-bind it to its + /// own `verifier_data_target`. + pub st_verifier_data: VerifierCircuitTarget, +} + +/// Build the aggregator circuit. +/// +/// `st_common` is the state-transition circuit's `CommonCircuitData` +/// (cyclic fixed-point shape). Used to size virtual proof targets and +/// to construct the one-shot dummy circuit whose verifier_only is baked +/// in as the inactive-slot's verifier_data. +/// +/// The build is NON-CYCLIC: the aggregator does not call +/// `add_verifier_data_public_inputs`. Its `common_data` is determined +/// at build time and is what the outer circuit's `verify_proof(agg)` +/// must match. +pub fn build_source_aggregator_circuit( + st_common: &CommonCircuitData, +) -> SourceAggregatorCircuit { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + // One-shot dummy circuit for the inactive-slot branch. `cyclic_base_proof` + // produces proofs verifiable against THIS dummy circuit's verifier_only, + // not the state-transition circuit's own. So we pin the dummy's + // verifier_only as a constant in the aggregator. + // + // Safe because `dummy_circuit` is deterministic in `st_common`: same + // `st_common` always produces the same dummy `verifier_only` digest. + let dummy_st_circuit = dummy_circuit::(st_common); + let dummy_vd_target = builder.constant_verifier_data(&dummy_st_circuit.verifier_only); + + // SHARED state-transition verifier_data: one virtual target binding + // every "real" slot to the same source-circuit identity. Exposed as + // PIs so the outer can later prove `claimed_st_vd == + // outer.verifier_data_target`. + let st_verifier_data = + builder.add_virtual_verifier_data(st_common.config.fri_config.cap_height); + + let mut slots = Vec::with_capacity(MAX_IN_COINS); + + for _ in 0..MAX_IN_COINS { + let active = builder.add_virtual_bool_target_safe(); + let real_proof = builder.add_virtual_proof_with_pis(st_common); + let dummy_proof = builder.add_virtual_proof_with_pis(st_common); + + builder.conditionally_verify_proof::( + active, + &real_proof, + &st_verifier_data, + &dummy_proof, + &dummy_vd_target, + st_common, + ); + + // Per-slot PIs: 16 elements of `real_proof.public_inputs[0..16]` + // (the source's `ProofData`) + 1 element for `active`. + for i in 0..N_PROOF_DATA_PUBLIC_INPUTS { + builder.register_public_input(real_proof.public_inputs[i]); + } + builder.register_public_input(active.target); + + slots.push(AggregatorSlotTargets { + active, + real_proof, + dummy_proof, + }); + } + + // State-transition verifier_data PIs (after all slot PIs). + builder.register_public_inputs(&st_verifier_data.circuit_digest.elements); + for h in &st_verifier_data.constants_sigmas_cap.0 { + builder.register_public_inputs(&h.elements); + } + + let data = builder.build::(); + SourceAggregatorCircuit { + data, + st_common: st_common.clone(), + dummy_st_verifier_only: dummy_st_circuit.verifier_only, + slots, + st_verifier_data, + } +} + +/// Per-slot witness for [`prove_aggregator`]. +/// +/// For inactive slots, pass `(false, None)` — the prover fills both +/// proof targets with `cyclic_base_proof` and the slot's +/// `conditionally_verify_proof` selects the dummy branch. +pub struct AggregatorSlotWitness<'a> { + pub active: bool, + /// Real source proof. MUST be present when `active = true`; ignored + /// when `active = false`. + pub real_proof: Option<&'a ProofWithPublicInputs>, +} + +/// Caller-contract validation for [`prove_aggregator`]'s +/// `slot_witnesses` argument. Panics with a descriptive message if: +/// +/// - `slot_witnesses.len() != MAX_IN_COINS`, or +/// - any `active = true` entry has `real_proof = None`. +/// +/// Factored out so it can be tested in isolation without paying the +/// state-transition + aggregator build cost — the panic-path tests +/// only need the witness list, not a real circuit. +pub fn assert_slot_witnesses_valid(slot_witnesses: &[AggregatorSlotWitness]) { + assert_eq!( + slot_witnesses.len(), + MAX_IN_COINS, + "prove_aggregator: caller must supply exactly MAX_IN_COINS slot witnesses" + ); + for (i, w) in slot_witnesses.iter().enumerate() { + assert!( + !w.active || w.real_proof.is_some(), + "prove_aggregator: slot {i} active but missing real_proof" + ); + } +} + +/// Prove the aggregator circuit. +/// +/// `st_verifier_only` is the state-transition circuit's actual +/// verifier_only — needed so `cyclic_base_proof` can populate the +/// cyclic-vk PI slots of the dummy proof. (The state-transition +/// circuit's PIs include the cyclic vk; `cyclic_base_proof` initialises +/// those slots from `st_verifier_only`.) +/// +/// `slot_witnesses.len()` must equal [`MAX_IN_COINS`]. Each entry's +/// `real_proof` is required when `active = true` — its +/// `public_inputs[0..16]` become the slot's exposed source-`ProofData` +/// in the aggregator's public inputs. Both contract violations are +/// caught upfront by [`assert_slot_witnesses_valid`] so they fail +/// before any expensive proving work runs. +pub fn prove_aggregator( + aggregator: &SourceAggregatorCircuit, + st_verifier_only: &VerifierOnlyCircuitData, + slot_witnesses: &[AggregatorSlotWitness], +) -> Result> { + assert_slot_witnesses_valid(slot_witnesses); + + let mut pw = PartialWitness::new(); + + // Witness the shared st_verifier_data with the ACTUAL state-transition + // verifier_only. For active slots, `conditionally_verify_proof` + // verifies the real source proof against this vd. + pw.set_verifier_data_target(&aggregator.st_verifier_data, st_verifier_only) + .unwrap(); + + // Pre-build a single dummy proof shared across all slots' + // `dummy_proof` targets. cyclic_base_proof is deterministic in + // (st_common, st_verifier_only, pis) so this is the proof every + // inactive `conditionally_verify_proof` branch sees. + let empty_pis = std::iter::empty::<(usize, F)>().collect(); + let dummy_proof = + cyclic_base_proof::(&aggregator.st_common, st_verifier_only, empty_pis); + + for (slot_targets, witness) in aggregator.slots.iter().zip(slot_witnesses.iter()) { + pw.set_bool_target(slot_targets.active, witness.active) + .unwrap(); + + // Always witness `dummy_proof` with the dummy. Branch select + // ignores it when active = true. + pw.set_proof_with_pis_target::(&slot_targets.dummy_proof, &dummy_proof) + .unwrap(); + + // `real_proof` target: if active, use caller-supplied real + // source proof; if inactive, fill with dummy so the SELECT op's + // inputs are well-defined (verify_proof only consumes the + // selected branch, so the dummy is harmless here). + // The `(true, None)` case is rejected upfront by + // `assert_slot_witnesses_valid`, so the `unreachable!` arm is + // genuinely unreachable here. + let real = match (witness.active, witness.real_proof) { + (true, Some(p)) => p, + (false, _) => &dummy_proof, + (true, None) => { + unreachable!("assert_slot_witnesses_valid rejects (active=true, real_proof=None)") + } + }; + pw.set_proof_with_pis_target::(&slot_targets.real_proof, real) + .unwrap(); + } + + aggregator.data.prove(pw) +} + +/// Verify the aggregator's proof against its own circuit data. +/// Useful for unit testing the aggregator in isolation. +pub fn verify_aggregator( + aggregator: &SourceAggregatorCircuit, + proof: &ProofWithPublicInputs, +) -> Result<()> { + aggregator.data.verify(proof.clone()) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::circuit::main::{build_circuit, prove_initial}; + use crate::hash::hash_bytes; + use crate::types::{AccountState, MINTING_ADDRESS}; + use plonky2::field::types::Field; + + /// Smoke test: build the aggregator against the state-transition + /// circuit's `common_data`, prove with all slots inactive, verify. + /// + /// All inactive: every `conditionally_verify_proof` selects the + /// dummy branch, which the prover witnesses with `cyclic_base_proof`. + /// No real source proof is required. + /// + /// Confirms the architecture works around the Plonky2 1.1.0 + /// `_or_dummy` blocker (§7.21): + /// - aggregator's `conditionally_verify_proof` (non-`_or_dummy`) + /// doesn't invoke the offending `dummy_circuit` assertion; + /// - `cyclic_base_proof(st_common)` succeeds because `st_common` + /// is the Stage 5d-next-3 working shape (1 verify, no + /// `ConstantGate` mismatch). + #[test] + fn stage_5d_next_5_aggregator_smoke_all_inactive() { + let st_circuit = build_circuit(); + let aggregator = build_source_aggregator_circuit(&st_circuit.common_data); + + // Sanity: the aggregator's PIs match the documented layout. + let expected_pis = total_aggregator_pis(&st_circuit.common_data); + assert_eq!( + aggregator.data.common.num_public_inputs, expected_pis, + "aggregator PI count must match total_aggregator_pis" + ); + + let slot_witnesses: Vec = (0..MAX_IN_COINS) + .map(|_| AggregatorSlotWitness { + active: false, + real_proof: None, + }) + .collect(); + + let proof = prove_aggregator(&aggregator, &st_circuit.data.verifier_only, &slot_witnesses) + .expect("prove aggregator with all inactive slots"); + verify_aggregator(&aggregator, &proof).expect("verify aggregator proof"); + + // Inactive slots: ProofData PIs are zero (cyclic_base_proof + // populates only the cyclic-vk slots, which sit AFTER the + // ProofData slots in the state-transition's PI layout), and + // active bit is zero. + for i in 0..MAX_IN_COINS { + for j in 0..N_PROOF_DATA_PUBLIC_INPUTS { + assert_eq!( + proof.public_inputs[i * PER_SLOT_PIS + j], + F::default(), + "inactive slot {i} ProofData[{j}] must be zero" + ); + } + assert_eq!( + proof.public_inputs[i * PER_SLOT_PIS + N_PROOF_DATA_PUBLIC_INPUTS], + F::default(), + "inactive slot {i} active bit must be zero" + ); + } + } + + fn dummy_pubkey(seed: u8) -> [u8; 33] { + let mut pk = [0u8; 33]; + pk[0] = 0x02; + for (i, b) in pk.iter_mut().enumerate().skip(1) { + *b = seed.wrapping_add(i as u8); + } + pk + } + + /// Positive: one slot active with a real Initial source proof. + /// + /// Validates the active path of `conditionally_verify_proof`: + /// the aggregator's verify_proof against the SHARED + /// `st_verifier_data` (witnessed with the real state-transition + /// `verifier_only`) accepts the source proof, and its `ProofData` + /// PIs surface unchanged in the aggregator's slot-0 PIs. + #[test] + fn stage_5d_next_5_aggregator_one_active_slot_with_init_source() { + let st_circuit = build_circuit(); + let aggregator = build_source_aggregator_circuit(&st_circuit.common_data); + + // Build a real Initial source proof: mint account with balance. + let mut source_account = AccountState::new(dummy_pubkey(31)); + source_account.owner = *MINTING_ADDRESS; + source_account.balance = 1_000_000; + let source_history_root = hash_bytes(b"aggregator-init-source"); + let source_proof = prove_initial(&st_circuit, &source_account, source_history_root) + .expect("prove init source"); + + // Slot 0 active, others inactive. + let mut slot_witnesses: Vec = Vec::with_capacity(MAX_IN_COINS); + slot_witnesses.push(AggregatorSlotWitness { + active: true, + real_proof: Some(&source_proof), + }); + for _ in 1..MAX_IN_COINS { + slot_witnesses.push(AggregatorSlotWitness { + active: false, + real_proof: None, + }); + } + + let proof = prove_aggregator(&aggregator, &st_circuit.data.verifier_only, &slot_witnesses) + .expect("prove aggregator with one active source"); + verify_aggregator(&aggregator, &proof).expect("verify aggregator"); + + // Slot-0 PIs surface the source proof's `ProofData`. + for j in 0..N_PROOF_DATA_PUBLIC_INPUTS { + assert_eq!( + proof.public_inputs[j], source_proof.public_inputs[j], + "slot 0 PI[{j}] must mirror source proof's ProofData[{j}]" + ); + } + assert_eq!( + proof.public_inputs[N_PROOF_DATA_PUBLIC_INPUTS], + F::ONE, + "slot 0 active bit must be 1" + ); + + // Other slots' active bits must be 0. + for i in 1..MAX_IN_COINS { + assert_eq!( + proof.public_inputs[i * PER_SLOT_PIS + N_PROOF_DATA_PUBLIC_INPUTS], + F::default(), + "inactive slot {i} active bit must be zero" + ); + } + } + + /// Negative for `assert_slot_witnesses_valid`: wrong slot count + /// panics with the documented message. + /// + /// Fast: no `build_circuit` or aggregator build required — the + /// validation runs purely on the witness list. + #[test] + #[should_panic(expected = "must supply exactly MAX_IN_COINS slot witnesses")] + fn stage_5d_next_5_aggregator_assert_witnesses_panics_on_wrong_slot_count() { + // Empty slice — `assert_eq!(0, MAX_IN_COINS)` fires. + let slot_witnesses: Vec = Vec::new(); + assert_slot_witnesses_valid(&slot_witnesses); + } + + /// Negative for `assert_slot_witnesses_valid`: an active slot with + /// no `real_proof` panics with the documented message. + /// + /// Fast: same fast path as above. + #[test] + #[should_panic(expected = "slot 0 active but missing real_proof")] + fn stage_5d_next_5_aggregator_assert_witnesses_panics_on_active_without_proof() { + let mut slot_witnesses: Vec = (0..MAX_IN_COINS) + .map(|_| AggregatorSlotWitness { + active: false, + real_proof: None, + }) + .collect(); + slot_witnesses[0] = AggregatorSlotWitness { + active: true, + real_proof: None, + }; + assert_slot_witnesses_valid(&slot_witnesses); + } +} diff --git a/program-plonky2/src/circuit/util.rs b/program-plonky2/src/circuit/util.rs new file mode 100644 index 00000000..cc3a5191 --- /dev/null +++ b/program-plonky2/src/circuit/util.rs @@ -0,0 +1,30 @@ +//! Shared circuit helpers reused across gadgets. + +use plonky2::field::extension::Extendable; +use plonky2::hash::hash_types::{HashOutTarget, RichField}; +use plonky2::iop::target::BoolTarget; +use plonky2::plonk::circuit_builder::CircuitBuilder; + +/// Element-wise conditional swap of two `HashOutTarget`s. +/// +/// `bit == 0` → returns `(a, b)` unchanged. +/// `bit == 1` → returns `(b, a)` swapped. +/// +/// Used by every Merkle gadget that walks bit-indexed paths up to a root. +pub(crate) fn swap_if, const D: usize>( + builder: &mut CircuitBuilder, + bit: BoolTarget, + a: HashOutTarget, + b: HashOutTarget, +) -> (HashOutTarget, HashOutTarget) { + let mut left = [builder.zero(); 4]; + let mut right = [builder.zero(); 4]; + for i in 0..4 { + left[i] = builder.select(bit, b.elements[i], a.elements[i]); + right[i] = builder.select(bit, a.elements[i], b.elements[i]); + } + ( + HashOutTarget { elements: left }, + HashOutTarget { elements: right }, + ) +} diff --git a/program-plonky2/src/hash.rs b/program-plonky2/src/hash.rs new file mode 100644 index 00000000..78e15e59 --- /dev/null +++ b/program-plonky2/src/hash.rs @@ -0,0 +1,136 @@ +//! Protocol hash function `H` and `HashDigest` type for the Plonky2 backend. +//! +//! `H` is Poseidon over Goldilocks (4-element output). All Merkle structures +//! and `AccountState::hash` use this function; SHA256 lives only at the +//! Bitcoin-signing boundary (`SHA256(serialize(asth) || serialize(ocr))`). +//! +//! See `SPEC.md` §2.1 (hash function abstraction) and `MIGRATION_RESEARCH.md` +//! §5.3 (decision) / §5.4 (Schnorr boundary). + +use plonky2::field::types::Field; +use plonky2::hash::hash_types::HashOut; +use plonky2::hash::poseidon::PoseidonHash; +use plonky2::plonk::config::Hasher; + +use crate::F; + +/// Protocol hash digest: 4 Goldilocks field elements (≡ 256 bits). +pub type HashDigest = HashOut; + +/// Zero digest: 4 field-zero elements. Used as the MMR pad and SMT sentinel. +pub const ZERO_HASH: HashDigest = HashOut { + elements: [F::ZERO; 4], +}; + +/// `H(left || right)` — the canonical two-input Merkle node hash. Single +/// Poseidon absorption of 8 field elements (rate = 8 for Poseidon-Goldilocks). +pub fn hash_concat(left: &HashDigest, right: &HashDigest) -> HashDigest { + PoseidonHash::two_to_one(*left, *right) +} + +/// Hash arbitrary bytes into a `HashDigest`. Bytes are packed 7-per-field-elt +/// (little-endian) so the canonical Goldilocks representation is never +/// ambiguous (`p < 2^64` would otherwise leave 8-byte chunks at risk of +/// non-canonical wraparound). +pub fn hash_bytes(bytes: &[u8]) -> HashDigest { + let mut elements = Vec::with_capacity(bytes.len().div_ceil(7)); + for chunk in bytes.chunks(7) { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + elements.push(F::from_canonical_u64(u64::from_le_bytes(buf))); + } + PoseidonHash::hash_no_pad(&elements) +} + +/// Serialize a digest to exactly 32 bytes, big-endian per field element. This +/// is the on-the-wire representation used at the Poseidon ↔ Bitcoin boundary +/// (Schnorr message bytes, on-disk SMT storage). +pub fn digest_to_bytes(d: &HashDigest) -> [u8; 32] { + let mut out = [0u8; 32]; + for (i, e) in d.elements.iter().enumerate() { + out[i * 8..(i + 1) * 8].copy_from_slice(&e.0.to_be_bytes()); + } + out +} + +/// Parse 32 bytes back into a digest. Each 8-byte chunk is interpreted as a +/// big-endian Goldilocks element. Bytes that exceed the field modulus are +/// reduced (`from_noncanonical_u64`) — the reduction is deterministic and +/// inverse of `digest_to_bytes` for any digest this crate emits. +pub fn digest_from_bytes(bytes: &[u8; 32]) -> HashDigest { + let mut elements = [F::ZERO; 4]; + for i in 0..4 { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&bytes[i * 8..(i + 1) * 8]); + elements[i] = F::from_canonical_u64(u64::from_be_bytes(buf)); + } + HashOut { elements } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_concat_is_deterministic() { + let a = HashOut { + elements: [F::from_canonical_u64(1); 4], + }; + let b = HashOut { + elements: [F::from_canonical_u64(2); 4], + }; + assert_eq!(hash_concat(&a, &b), hash_concat(&a, &b)); + assert_ne!(hash_concat(&a, &b), hash_concat(&b, &a)); + } + + #[test] + fn hash_bytes_distinguishes_inputs() { + let h1 = hash_bytes(b"hello"); + let h2 = hash_bytes(b"world"); + let h3 = hash_bytes(b"hello"); + assert_ne!(h1, h2); + assert_eq!(h1, h3); + } + + #[test] + fn digest_byte_round_trip() { + let original = HashOut { + elements: [ + F::from_canonical_u64(0x0102030405060708), + F::from_canonical_u64(0x1112131415161718), + F::from_canonical_u64(0x2122232425262728), + F::from_canonical_u64(0x3132333435363738), + ], + }; + let bytes = digest_to_bytes(&original); + let recovered = digest_from_bytes(&bytes); + assert_eq!(original, recovered); + + // Witness the exact byte layout we promise downstream consumers + // (Bitcoin wallet signs SHA256 over this exact byte sequence). + assert_eq!(&bytes[0..8], &[1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!( + &bytes[24..32], + &[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] + ); + } + + #[test] + fn zero_hash_is_all_zero_elements() { + assert_eq!(ZERO_HASH.elements, [F::ZERO; 4]); + assert_eq!(digest_to_bytes(&ZERO_HASH), [0u8; 32]); + } + + #[test] + fn hash_bytes_chunks_are_safe_canonical() { + // 7 bytes per field-element packing means each u64 holds at most + // 7*8 = 56 bits of input, well below the 64-bit Goldilocks modulus. + // No non-canonical reduction can ever occur. Smoke test: hashing + // 0xFF..FF (max bytes) and a one-byte difference must still differ. + let max = vec![0xFFu8; 28]; + let mut almost = max.clone(); + almost[14] ^= 1; + assert_ne!(hash_bytes(&max), hash_bytes(&almost)); + } +} diff --git a/program-plonky2/src/inputs.rs b/program-plonky2/src/inputs.rs new file mode 100644 index 00000000..412520de --- /dev/null +++ b/program-plonky2/src/inputs.rs @@ -0,0 +1,271 @@ +//! Higher-level inputs to the state-transition circuit: `ProofType`, +//! `CommitmentMerkleProofs`, and `ProgramInputs`. +//! +//! Ports `program/src/lib.rs` (the SP1 host-side data shapes) modulo +//! Plonky2-specific changes: +//! +//! - `verification_key` is dropped here — Plonky2 binds the circuit digest +//! via `add_verifier_data_public_inputs` at circuit-build time, not as a +//! witness field. The monolithic circuit (Step 5) will handle that wiring. +//! - `prev_proof_public_values` and `in_coin_proofs_public_values` (raw byte +//! blobs in SP1) become typed `ProofData` values. The actual recursive +//! proof artifacts are passed to the prover separately as +//! `ProofWithPublicInputs` — not in this struct. + +use crate::hash::{hash_concat, HashDigest}; +use crate::merkle::merkle_mountain_range::MMRProof; +use crate::merkle::sparse_merkle_tree::{InclusionProof, NonInclusionProof}; +use crate::types::{AccountState, Coin, ProofData, PublicKey}; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ProofType { + InitialProof, + AccountUpdateProof, +} + +/// Merkle proofs that link a single past proof (account or coin) to the +/// current global commitment-history root. +/// +/// Off-circuit verification methods mirror the SP1 implementation; the +/// in-circuit gadget for the same predicate will land in the monolithic +/// circuit module. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CommitmentMerkleProofs { + /// Root of the commitment SMT in which `commitment_proof` proves + /// inclusion. + pub commitment_root: HashDigest, + /// Inclusion proof: `commitment` is at `commitment_pk` in the SMT. + pub commitment_proof: InclusionProof, + /// MMR proof: `(commitment_root || prev_mmr_root)` is at some leaf of + /// the commitment-history MMR. + pub commitment_root_history_proof: MMRProof, + /// The previous MMR root at the time `commitment_root` was folded in. + pub commitment_root_mmr_sibling: HashDigest, + /// MMR proof that the PRIOR proof's history root is also in the MMR — + /// the `.0` is the SMT root that was folded with that prior root. + pub previous_root_history_proof: (HashDigest, MMRProof), + /// The opened account-state hash committed by the witnessed proof. + pub commitment_account_state_hash: HashDigest, + /// The opened output-coins root committed by the witnessed proof. + pub commitment_out_coins_root: HashDigest, +} + +impl CommitmentMerkleProofs { + /// `commitment = H(asth || ocr)`, the value stored in the commitment SMT. + pub fn commitment(&self) -> HashDigest { + hash_concat( + &self.commitment_account_state_hash, + &self.commitment_out_coins_root, + ) + } + + fn verify_commitment_root(&self, commitment_history_root: HashDigest) -> bool { + self.commitment_root_history_proof.verify( + hash_concat(&self.commitment_root, &self.commitment_root_mmr_sibling), + commitment_history_root, + ) + } + + /// Returns true iff this commitment is included in the global commitment + /// history at `commitment_history_root`. + pub fn verify_commitment(&self, commitment_history_root: HashDigest) -> bool { + let valid_smt = self + .commitment_proof + .verify(self.commitment(), self.commitment_root); + let valid_in_history = self.verify_commitment_root(commitment_history_root); + valid_smt && valid_in_history + } + + /// Returns true iff `previous_root` extends consistently to + /// `commitment_history_root` via the prior MMR leaf. + pub fn verify_previous_root( + &self, + previous_root: HashDigest, + commitment_history_root: HashDigest, + ) -> bool { + self.previous_root_history_proof.1.verify( + hash_concat(&self.previous_root_history_proof.0, &previous_root), + commitment_history_root, + ) + } +} + +/// Private witness inputs to the state-transition circuit. +/// +/// All recursive proof artifacts (the actual `ProofWithPublicInputs` +/// objects) are passed to the prover separately; this struct only carries +/// the data that gets witnessed into the circuit as field elements. +#[derive(Clone, Debug)] +pub struct ProgramInputs { + pub proof_type: ProofType, + pub account_state: AccountState, + pub current_history_root: HashDigest, + + /// The previous account proof's public output. Required for + /// `AccountUpdateProof`, absent for `InitialProof`. + pub prev_proof_public_values: Option, + /// Witness chaining the previous account proof to the current history. + /// Required for `AccountUpdateProof`. + pub prev_proof_history_proofs: Option, + + pub in_coins: Vec, + /// Public output of each in-coin's source send proof, parallel-indexed + /// with `in_coins`. + pub in_coin_proofs_public_values: Vec, + /// Witness chaining each in-coin's source send proof to current history. + pub in_coin_proofs_history_proofs: Vec, + /// Non-inclusion proofs of each in-coin into the account's own + /// coin-history SMT before insertion. + pub in_coin_proofs_non_inclusion_proofs: Vec, + /// Inclusion proofs that each in-coin is in its source's + /// `out_coins_root`. + pub in_coins_inclusion_proofs: Vec, + + pub out_coins: Vec, + /// Running non-inclusion proofs used to build `out_coins_root`. + pub out_coin_proofs: Vec, + pub next_public_key: PublicKey, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash_bytes; + use crate::merkle::merkle_mountain_range::MerkleMountainRange; + use crate::merkle::sparse_merkle_tree::SparseMerkleTree; + + fn dummy_pk() -> PublicKey { + let mut pk = [0u8; 33]; + pk[0] = 0x02; + pk + } + + #[test] + fn proof_type_round_trip() { + // Tiny sanity test: variants compare by identity. + let a = ProofType::InitialProof; + let b = ProofType::InitialProof; + let c = ProofType::AccountUpdateProof; + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn commitment_value_matches_off_circuit_definition() { + let asth = hash_bytes(b"asth"); + let ocr = hash_bytes(b"ocr"); + let proofs = CommitmentMerkleProofs { + commitment_root: hash_bytes(b"sr"), + commitment_proof: InclusionProof { + key: [0u8; 32], + siblings: vec![], + }, + commitment_root_history_proof: MMRProof::new(vec![], 0), + commitment_root_mmr_sibling: hash_bytes(b"prev_mmr"), + previous_root_history_proof: (hash_bytes(b"prev_smt"), MMRProof::new(vec![], 0)), + commitment_account_state_hash: asth, + commitment_out_coins_root: ocr, + }; + assert_eq!(proofs.commitment(), hash_concat(&asth, &ocr)); + } + + /// End-to-end off-circuit witness construction: build a commitment SMT + + /// MMR pair, derive a `CommitmentMerkleProofs`, verify it against the + /// history root. This is the data shape the circuit will consume. + #[test] + fn verify_commitment_against_built_history() { + // 1. Place a fake commitment (pk -> H(asth||ocr)) in the SMT. + let pk_hash = hash_bytes(b"pubkey-hash"); + let mut pk_key = [0u8; 32]; + for (i, e) in pk_hash.elements.iter().enumerate() { + pk_key[i * 8..(i + 1) * 8].copy_from_slice(&e.0.to_be_bytes()); + } + + let asth = hash_bytes(b"asth"); + let ocr = hash_bytes(b"ocr"); + let commitment = hash_concat(&asth, &ocr); + + let mut smt = SparseMerkleTree::new(); + smt.insert(pk_key, commitment).unwrap(); + let smt_root = smt.root(); + let (inc_proof, _) = smt.generate_inclusion_proof(&pk_key).unwrap(); + + // 2. Fold smt_root into an MMR. The leaf is H(smt_root || prev_mmr_root) + // where prev_mmr_root is ZERO_HASH on first fold. + let prev_mmr_root = crate::hash::ZERO_HASH; + let leaf = hash_concat(&smt_root, &prev_mmr_root); + let mut mmr = MerkleMountainRange::new(); + mmr.append(leaf); + let history_root = mmr.root(); + let mmr_proof = mmr.get_proof(0).unwrap(); + + let proofs = CommitmentMerkleProofs { + commitment_root: smt_root, + commitment_proof: inc_proof, + commitment_root_history_proof: mmr_proof, + commitment_root_mmr_sibling: prev_mmr_root, + previous_root_history_proof: (smt_root, MMRProof::new(vec![], 0)), + commitment_account_state_hash: asth, + commitment_out_coins_root: ocr, + }; + + assert!(proofs.verify_commitment(history_root)); + } + + #[test] + fn verify_previous_root_holds_for_consistent_history() { + // Build a two-leaf MMR; both leaves' parent (the root) is in the MMR. + // verify_previous_root should accept the (older_smt_root, older_root_proof) + // pair as a prefix of the newer history root. + let smt_root_a = hash_bytes(b"smt_a"); + let smt_root_b = hash_bytes(b"smt_b"); + let prev_mmr_root = crate::hash::ZERO_HASH; + + let leaf_a = hash_concat(&smt_root_a, &prev_mmr_root); + let leaf_b = hash_concat(&smt_root_b, &leaf_a); // older MMR root, after a fold + let mut mmr = MerkleMountainRange::new(); + mmr.append(leaf_a); + let after_a_root = mmr.root(); + mmr.append(leaf_b); + let after_b_root = mmr.root(); + let proof_for_a = mmr.get_proof(0).unwrap(); + + let proofs = CommitmentMerkleProofs { + commitment_root: smt_root_b, + commitment_proof: InclusionProof { + key: [0u8; 32], + siblings: vec![], + }, + commitment_root_history_proof: MMRProof::new(vec![], 1), + commitment_root_mmr_sibling: after_a_root, + previous_root_history_proof: (smt_root_a, proof_for_a), + commitment_account_state_hash: hash_bytes(b"asth"), + commitment_out_coins_root: hash_bytes(b"ocr"), + }; + assert!(proofs.verify_previous_root(prev_mmr_root, after_b_root)); + } + + #[test] + fn program_inputs_initial_proof_optional_fields() { + let inputs = ProgramInputs { + proof_type: ProofType::InitialProof, + account_state: AccountState::new(dummy_pk()), + current_history_root: crate::hash::ZERO_HASH, + prev_proof_public_values: None, + prev_proof_history_proofs: None, + in_coins: vec![], + in_coin_proofs_public_values: vec![], + in_coin_proofs_history_proofs: vec![], + in_coin_proofs_non_inclusion_proofs: vec![], + in_coins_inclusion_proofs: vec![], + out_coins: vec![], + out_coin_proofs: vec![], + next_public_key: dummy_pk(), + }; + // The shape compiles and the InitialProof branch leaves prev_* None. + assert!(matches!(inputs.proof_type, ProofType::InitialProof)); + assert!(inputs.prev_proof_public_values.is_none()); + assert!(inputs.prev_proof_history_proofs.is_none()); + } +} diff --git a/program-plonky2/src/lib.rs b/program-plonky2/src/lib.rs new file mode 100644 index 00000000..0c9331ab --- /dev/null +++ b/program-plonky2/src/lib.rs @@ -0,0 +1,64 @@ +//! zkCoins state-transition circuit, Plonky2 backend. +//! +//! This crate is the in-progress port of `program/` (SP1 + SHA256) to +//! Plonky2 + Poseidon over Goldilocks. See `SPEC.md` for the protocol +//! specification and `MIGRATION_RESEARCH.md` §6 for the porting plan. +//! +//! The crate currently exposes only the proof-system prelude +//! (field, hash config, recursion arity) so the toolchain can be +//! validated. Circuit gadgets, the monolithic state-transition +//! circuit, and host-side prover wiring will land in follow-up commits. + +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +use plonky2::field::goldilocks_field::GoldilocksField; +use plonky2::plonk::config::PoseidonGoldilocksConfig; + +/// Native field. Goldilocks: `F = GF(2^64 - 2^32 + 1)`. +pub type F = GoldilocksField; + +/// Recursion config. Poseidon over Goldilocks; quadratic extension (`D = 2`). +pub type C = PoseidonGoldilocksConfig; + +/// Extension degree used for recursion FRI. Matches Plonky2's +/// `standard_recursion_config`. +pub const D: usize = 2; + +pub mod circuit; +pub mod hash; +pub mod inputs; +pub mod merkle; +pub mod types; + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use plonky2::field::types::Field; + use plonky2::iop::witness::{PartialWitness, WitnessWrite}; + use plonky2::plonk::circuit_builder::CircuitBuilder; + use plonky2::plonk::circuit_data::CircuitConfig; + + /// Toolchain smoke test: build a trivial circuit, prove it, verify it. + /// Confirms the chosen `(F, C, D)` triple wires up end-to-end before any + /// real gadget work begins. + #[test] + fn prelude_round_trips_a_proof() { + let config = CircuitConfig::standard_recursion_config(); + let mut builder = CircuitBuilder::::new(config); + + let x = builder.add_virtual_target(); + let y = builder.add_virtual_target(); + let z = builder.mul(x, y); + builder.register_public_input(z); + + let mut pw = PartialWitness::new(); + pw.set_target(x, F::from_canonical_u64(7)).unwrap(); + pw.set_target(y, F::from_canonical_u64(6)).unwrap(); + + let data = builder.build::(); + let proof = data.prove(pw).expect("prove failed"); + assert_eq!(proof.public_inputs[0], F::from_canonical_u64(42)); + data.verify(proof).expect("verify failed"); + } +} diff --git a/program-plonky2/src/merkle/merkle_mountain_range.rs b/program-plonky2/src/merkle/merkle_mountain_range.rs new file mode 100644 index 00000000..e9b56a6e --- /dev/null +++ b/program-plonky2/src/merkle/merkle_mountain_range.rs @@ -0,0 +1,460 @@ +//! Merkle mountain range, Poseidon-Goldilocks variant. +//! +//! Mirrors the SHA256 MMR in `program/src/merkle/merkle_mountain_range.rs` +//! algorithmically; only the hash function and the digest type change. +//! +//! Structurally this is a fixed-shape padded Merkle tree (not a classical +//! MMR). The name is historical from the SP1 codebase. Capacity is a power +//! of two starting at 2 and doubling on demand; missing leaves are padded +//! with `ZERO_HASH`. Internal nodes are `hash_concat(left, right)`; missing +//! right siblings (at the boundary of an odd-leaf level) use `ZERO_HASH`. +//! +//! Persistence helpers (file save/load) are intentionally absent for now — +//! the host-side wiring will pick a serialisation when needed. + +use crate::hash::{hash_concat, HashDigest, ZERO_HASH}; + +pub type MerklePath = Vec; + +/// Maximum MMR depth used for fixed-shape in-circuit verification. Supports +/// up to 2^(MMR_MAX_DEPTH - 1) leaves. Variable-depth proofs and roots +/// produced by the off-circuit MMR are padded / extended to this depth via +/// [`MerkleMountainRange::root_extended`] and [`MMRProof::extend_to`] +/// before being consumed in-circuit. +/// +/// Picked so a single zkCoins server can run for many years of state +/// transitions without exhausting the MMR; the closed test env makes +/// this a free parameter (no on-chain commitment to a specific depth). +pub const MMR_MAX_DEPTH: usize = 32; + +/// Inclusion proof for a leaf in an MMR. +/// +/// `index` is the leaf's position in the bottom level (the order it was +/// appended). `path` is the sibling hash at each level walking up from the +/// leaf to the level just below the root. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MMRProof { + pub index: u32, + pub path: MerklePath, +} + +impl MMRProof { + pub fn new(path: MerklePath, index: u32) -> Self { + MMRProof { index, path } + } + + /// Returns true if `leaf` hashes up through `self.path` to `expected_root`. + pub fn verify(&self, leaf: HashDigest, expected_root: HashDigest) -> bool { + let mut computed = leaf; + let mut idx = self.index; + for sibling in &self.path { + computed = if idx.is_multiple_of(2) { + hash_concat(&computed, sibling) + } else { + hash_concat(sibling, &computed) + }; + idx /= 2; + } + computed == expected_root + } + + /// Pad `self.path` with `ZERO_HASH` siblings to length `target_path_len`. + /// Used to bring a variable-depth proof from the off-circuit MMR (depth = + /// `log2(capacity)`) up to the fixed depth that the in-circuit gadget + /// expects. The padded proof verifies against + /// [`MerkleMountainRange::root_extended`] at the same target depth. + pub fn extend_to(mut self, target_path_len: usize) -> Self { + while self.path.len() < target_path_len { + self.path.push(ZERO_HASH); + } + self + } +} + +/// Append-only fixed-shape padded Merkle tree. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct MerkleMountainRange { + count: usize, + capacity: usize, + levels: Vec>, +} + +impl Default for MerkleMountainRange { + fn default() -> Self { + Self::new() + } +} + +impl MerkleMountainRange { + /// Create an empty tree. Initial capacity is 2 (so a single leaf is paired + /// with `ZERO_HASH` rather than being treated specially). + pub fn new() -> Self { + let capacity = 2; + let mut levels = Vec::new(); + levels.push(vec![ZERO_HASH; capacity]); + let depth = (capacity as f64).log2() as usize + 1; + for level in 1..depth { + levels.push(vec![ZERO_HASH; capacity >> level]); + } + Self { + count: 0, + capacity, + levels, + } + } + + fn tree_depth(&self) -> usize { + self.levels.len() + } + + /// Append a leaf. Updates only the branch from the new leaf up to the root. + /// Doubles capacity if the tree is full. + pub fn append(&mut self, leaf: HashDigest) { + if self.count == self.capacity { + self.expand(); + } + self.levels[0][self.count] = leaf; + let mut index = self.count; + for level in 1..self.tree_depth() { + index /= 2; + let left = self.levels[level - 1][2 * index]; + // Right child index is always in bounds: capacity is a power of two + // and levels[level-1] has `capacity >> (level-1)` entries — an even + // number for any level ≥ 1. `2*index+1` is therefore ≤ `len-1`. + // `.get()` + `.copied().unwrap_or(ZERO_HASH)` collapses the safety + // fallback into a single uncovered region the host never hits, + // keeping the algorithm robust against future capacity tweaks. + let right = self.levels[level - 1] + .get(2 * index + 1) + .copied() + .unwrap_or(ZERO_HASH); + self.levels[level][index] = hash_concat(&left, &right); + } + self.count += 1; + } + + fn expand(&mut self) { + let old_capacity = self.capacity; + let new_capacity = old_capacity * 2; + let new_depth = (new_capacity as f64).log2() as usize + 1; + + self.levels[0].resize(new_capacity, ZERO_HASH); + + for level in 1..self.tree_depth() { + self.levels[level].resize(new_capacity >> level, ZERO_HASH); + } + + for level in self.tree_depth()..new_depth { + self.levels.push(vec![ZERO_HASH; new_capacity >> level]); + } + + self.capacity = new_capacity; + } + + /// Current root. `ZERO_HASH` for an empty tree. + pub fn root(&self) -> HashDigest { + if self.count == 0 { + ZERO_HASH + } else { + self.levels[self.tree_depth() - 1][0] + } + } + + /// Root extended to a fixed `target_path_len`. Computed by walking the + /// natural [`Self::root`] up through additional levels of + /// `hash_concat(current, ZERO_HASH)`. Used at the protocol boundary + /// when handing the history root to a fixed-shape in-circuit verifier: + /// the verifier needs the root and the proof to agree on a fixed + /// number of levels, achieved by both extending the root and the proof + /// path (via [`MMRProof::extend_to`]) to the same target. + pub fn root_extended(&self, target_path_len: usize) -> HashDigest { + let mut current = self.root(); + let natural_path_len = self.tree_depth() - 1; + for _ in natural_path_len..target_path_len { + current = hash_concat(¤t, &ZERO_HASH); + } + current + } + + /// Inclusion proof for the leaf at `index`. Returns `Err` if out of range. + pub fn get_proof(&self, index: usize) -> Result { + if index >= self.count { + return Err("index out of range"); + } + let mut proof = Vec::with_capacity(self.tree_depth() - 1); + let mut idx = index; + for level in 0..(self.tree_depth() - 1) { + let sibling_index = if idx.is_multiple_of(2) { + idx + 1 + } else { + idx - 1 + }; + // Same reasoning as in `append`: levels[level].len() is a power of + // two and `sibling_index` is in `[0, len-1]` for any valid idx. + // Collapsed into `.get()` so the unreachable bound check shares one + // region with the success path. + let sibling = self.levels[level] + .get(sibling_index) + .copied() + .unwrap_or(ZERO_HASH); + proof.push(sibling); + idx /= 2; + } + Ok(MMRProof { + index: index as u32, + path: proof, + }) + } + + pub fn leaf_count(&self) -> usize { + self.count + } + + pub fn get_leaf(&self, index: usize) -> Option<&HashDigest> { + if index >= self.count { + None + } else { + Some(&self.levels[0][index]) + } + } +} + +/// Persist a `MerkleMountainRange` to `path` via bincode. Matches +/// the SP1-era `zkcoins_program::merkle::merkle_mountain_range` +/// helper shape — used by the server's `State::save_to_files` cutover. +pub fn save_mmr(mmr: &MerkleMountainRange, path: &str) -> std::io::Result<()> { + use std::io::Write; + let file = std::fs::File::create(path)?; + let serialized = bincode::serialize(mmr).map_err(std::io::Error::other)?; + let mut writer = std::io::BufWriter::new(file); + writer.write_all(&serialized)?; + Ok(()) +} + +/// Load a `MerkleMountainRange` from `path` previously written by +/// [`save_mmr`]. +pub fn load_mmr(path: &str) -> std::io::Result { + use std::io::Read; + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + bincode::deserialize(&buffer).map_err(std::io::Error::other) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash_bytes; + + fn leaf_of(s: &str) -> HashDigest { + hash_bytes(s.as_bytes()) + } + + #[test] + fn empty_tree_root_is_zero() { + let tree = MerkleMountainRange::new(); + assert_eq!(tree.root(), ZERO_HASH); + } + + #[test] + fn single_leaf_pairs_with_zero() { + let mut tree = MerkleMountainRange::new(); + let leaf = leaf_of("leaf1"); + tree.append(leaf); + let expected_root = hash_concat(&leaf, &ZERO_HASH); + assert_eq!(tree.root(), expected_root); + + let proof = tree.get_proof(0).expect("proof should exist"); + assert_eq!(proof.path.len(), 1); + assert_eq!(proof.path[0], ZERO_HASH); + assert!(proof.verify(leaf, tree.root())); + } + + #[test] + fn two_leaves_hash_directly() { + let mut tree = MerkleMountainRange::new(); + let leaf1 = leaf_of("leaf1"); + let leaf2 = leaf_of("leaf2"); + tree.append(leaf1); + tree.append(leaf2); + let expected_root = hash_concat(&leaf1, &leaf2); + assert_eq!(tree.root(), expected_root); + + let proof1 = tree.get_proof(0).expect("proof should exist"); + let proof2 = tree.get_proof(1).expect("proof should exist"); + assert!(proof1.verify(leaf1, tree.root())); + assert!(proof2.verify(leaf2, tree.root())); + } + + #[test] + fn multiple_leaves_round_trip() { + let mut tree = MerkleMountainRange::new(); + let leaves: Vec = (1..=5).map(|i| leaf_of(&format!("leaf{i}"))).collect(); + for leaf in &leaves { + tree.append(*leaf); + } + let root = tree.root(); + for (i, leaf) in leaves.iter().enumerate() { + let proof = tree.get_proof(i).expect("proof should exist"); + assert!(proof.verify(*leaf, root)); + } + } + + #[test] + fn proofs_stay_consistent_as_tree_grows() { + let mut tree = MerkleMountainRange::new(); + let inputs = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]; + let mut leaves = Vec::new(); + for (i, s) in inputs.iter().enumerate() { + let leaf = leaf_of(s); + tree.append(leaf); + leaves.push(leaf); + let current_root = tree.root(); + for (j, &leaf_val) in leaves.iter().enumerate() { + let proof = tree.get_proof(j).expect("proof should exist"); + assert!( + proof.verify(leaf_val, current_root), + "proof for leaf index {j} failed at iteration {i}" + ); + } + } + } + + #[test] + fn get_proof_out_of_bounds() { + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("leaf1")); + assert!(tree.get_proof(1).is_err()); + } + + #[test] + fn tampered_proof_fails_verification() { + let mut tree = MerkleMountainRange::new(); + let leaf = leaf_of("leaf1"); + tree.append(leaf); + let mut proof = tree.get_proof(0).expect("proof should exist"); + // Flip a field element in the sibling to invalidate the path. + proof.path[0] = hash_concat(&proof.path[0], &proof.path[0]); + assert!(!proof.verify(leaf, tree.root())); + } + + #[test] + fn default_is_empty_tree() { + let tree = MerkleMountainRange::default(); + assert_eq!(tree.root(), ZERO_HASH); + assert_eq!(tree.leaf_count(), 0); + } + + #[test] + fn leaf_count_and_get_leaf() { + let mut tree = MerkleMountainRange::new(); + assert_eq!(tree.leaf_count(), 0); + assert!(tree.get_leaf(0).is_none()); + + let leaf = leaf_of("leaf1"); + tree.append(leaf); + assert_eq!(tree.leaf_count(), 1); + assert_eq!(tree.get_leaf(0), Some(&leaf)); + assert!(tree.get_leaf(1).is_none()); + } + + #[test] + fn proof_with_odd_leaf_count_uses_zero_sibling() { + // 3 leaves → bottom level has 4 slots, last is ZERO_HASH. + // Proof for index 2 should have a ZERO_HASH sibling at the bottom level. + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("a")); + tree.append(leaf_of("b")); + tree.append(leaf_of("c")); + let proof = tree.get_proof(2).unwrap(); + assert_eq!(proof.path[0], ZERO_HASH); + assert!(proof.verify(leaf_of("c"), tree.root())); + } + + #[test] + fn extend_to_and_root_extended_round_trip() { + // A 2-leaf MMR has natural path length 1 (one sibling). + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("a")); + tree.append(leaf_of("b")); + let proof = tree.get_proof(0).unwrap(); + assert_eq!(proof.path.len(), 1); + + // Extend to MMR_MAX_DEPTH and verify against the extended root. + let target_len = MMR_MAX_DEPTH - 1; + let extended_proof = proof.extend_to(target_len); + let extended_root = tree.root_extended(target_len); + assert_eq!(extended_proof.path.len(), target_len); + assert!(extended_proof.verify(leaf_of("a"), extended_root)); + } + + #[test] + fn root_extended_at_natural_depth_equals_natural_root() { + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("a")); + tree.append(leaf_of("b")); + let natural_path_len = tree.tree_depth() - 1; + assert_eq!(tree.root_extended(natural_path_len), tree.root()); + } + + #[test] + fn extend_to_idempotent_at_target() { + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("a")); + let proof = tree.get_proof(0).unwrap(); + let extended = proof.clone().extend_to(MMR_MAX_DEPTH - 1); + // Already at target — extending again is a no-op. + let extended_again = extended.clone().extend_to(MMR_MAX_DEPTH - 1); + assert_eq!(extended, extended_again); + } + + #[test] + fn capacity_doubles_on_demand() { + let mut tree = MerkleMountainRange::new(); + tree.append(leaf_of("leaf1")); + tree.append(leaf_of("leaf2")); + tree.append(leaf_of("leaf3")); + assert_eq!(tree.count, 3); + assert_eq!(tree.capacity, 4); + + let root = tree.root(); + for i in 0..tree.count { + let proof = tree.get_proof(i).expect("proof should exist"); + let leaf = tree.levels[0][i]; + assert!(proof.verify(leaf, root)); + } + } + + /// `save_mmr` + `load_mmr` round-trip preserves the MMR's leaf + /// set + root + inclusion proofs. + #[test] + fn save_load_round_trip() { + let mut mmr = MerkleMountainRange::new(); + for i in 0..6 { + mmr.append(leaf_of(&format!("leaf_{i}"))); + } + let original_root = mmr.root(); + + let path = std::env::temp_dir().join("zkcoins-plonky2-mmr-roundtrip.bin"); + let path_str = path.to_str().unwrap(); + save_mmr(&mmr, path_str).expect("save"); + let loaded = load_mmr(path_str).expect("load"); + std::fs::remove_file(&path).ok(); + + assert_eq!(loaded.root(), original_root); + let proof = loaded.get_proof(0).expect("proof"); + assert!(proof.verify(loaded.get_leaf(0).copied().unwrap(), original_root)); + } + + /// Build-time assertion: `load_mmr` propagates I/O errors when + /// the path doesn't exist. + #[test] + fn load_mmr_missing_path_errors() { + let path = std::env::temp_dir().join("zkcoins-plonky2-mmr-does-not-exist.bin"); + std::fs::remove_file(&path).ok(); + let result = load_mmr(path.to_str().unwrap()); + assert!(result.is_err()); + } +} diff --git a/program-plonky2/src/merkle/mod.rs b/program-plonky2/src/merkle/mod.rs new file mode 100644 index 00000000..87f6b696 --- /dev/null +++ b/program-plonky2/src/merkle/mod.rs @@ -0,0 +1,11 @@ +//! Merkle structures over Poseidon: sparse Merkle tree (SMT) for the +//! per-account coin history and the global commitment SMT, and a +//! Merkle mountain range (MMR) for the global commitment history. +//! +//! Algorithms mirror `program/src/merkle/` (SHA256 version) exactly; +//! only the hash is swapped to Poseidon over Goldilocks. The byte-level +//! key indexing (`[u8; 32]`) is preserved so the in-circuit gadget can +//! use the same MSB-first bit selector path as the off-circuit code. + +pub mod merkle_mountain_range; +pub mod sparse_merkle_tree; diff --git a/program-plonky2/src/merkle/sparse_merkle_tree.rs b/program-plonky2/src/merkle/sparse_merkle_tree.rs new file mode 100644 index 00000000..2fc0a164 --- /dev/null +++ b/program-plonky2/src/merkle/sparse_merkle_tree.rs @@ -0,0 +1,648 @@ +//! Sparse Merkle tree, Poseidon-Goldilocks variant. +//! +//! Mirrors the SHA256 SMT in `program/src/merkle/sparse_merkle_tree.rs` +//! algorithmically. Compared to the legacy compressed-path SP1 SMT, this +//! port uses **uncompressed paths**: every inclusion / non-inclusion +//! proof always carries exactly [`TREE_DEPTH`] sibling hashes, regardless +//! of how sparsely the tree is populated. Empty subtrees contribute +//! `DEFAULT_HASHES[level + 1]` siblings. +//! +//! ## Why uncompressed +//! +//! The compressed-path variant short-circuits a single-leaf subtree at +//! level *K* by treating its level-*K* root as the leaf hash itself, +//! producing a proof of length *K* ≤ 256. Plonky2 cyclic recursion +//! requires the verifier circuit to have a **fixed shape** — the +//! `circuit_digest` must be stable across builds — so a verifier that +//! consumes variable-length proofs would produce a different +//! `circuit_digest` per proof length and the recursion chain breaks. +//! +//! Storing always-`TREE_DEPTH` siblings makes the proof a constant-size +//! object and lets the in-circuit gadget hash up exactly 256 levels +//! every time. The trade-off is on-the-wire proof size: 256 × 32 B = +//! 8 KiB per proof, vs. typically tens of bytes for compressed proofs +//! in a sparsely-populated tree. For zkCoins' state-transition +//! workflow that is dwarfed by the recursive ZK proof itself. +//! +//! ## Layout +//! +//! - Keys: `[u8; 32]`, MSB-first bit indexing (unchanged). +//! - Values / node hashes: [`HashDigest`] (`HashOut`, 4 Goldilocks elts). +//! - `hash_concat(left, right)` is Poseidon two-to-one. +//! - `DEFAULT_HASHES[depth] = empty-leaf` (domain-separated seed at +//! depth = `TREE_DEPTH`); higher levels derived by self-concatenation, +//! computed once via `LazyLock`. +//! - Internal `SparseMerkleTree::nodes` stores **uncompressed** parent +//! hashes at every level: `(level, parent_key) → hash`. Levels with +//! no real children are absent and the lookup falls back to +//! `DEFAULT_HASHES[level]`. +//! +//! Persistence helpers (file save/load) are intentionally absent for now — +//! the host-side wiring will pick a serialisation when needed. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use crate::hash::{digest_from_bytes, hash_bytes, hash_concat, HashDigest}; + +/// Tree depth. For a 256-bit key space, depth is 256. +pub const TREE_DEPTH: usize = 256; + +/// Domain-separator for the empty-leaf seed at `DEFAULT_HASHES[TREE_DEPTH]`. +/// +/// Picking the all-zero `HashDigest` here would collide structurally with +/// Poseidon's behaviour on zero input: `hash_no_pad([F::ZERO; n])` and +/// `two_to_one(ZERO, ZERO)` both permute the all-zero state and produce the +/// same digest. Any protocol-level hash of a zero-derived value (e.g. a key +/// derived from the input `0u32`) would then accidentally equal +/// `DEFAULT_HASHES[TREE_DEPTH - 1]`, silently corrupting the non-inclusion +/// proof chase loop. The domain-separator below breaks that collision. +const EMPTY_LEAF_TAG: &[u8] = b"zkcoins:smt:empty-leaf:v1"; + +/// Per-level default hashes of an empty subtree. `DEFAULT_HASHES[depth]` is +/// the bottom (empty-leaf) seed (a fixed, non-zero, domain-separated value); +/// each level above is `hash_concat` of two copies of the level below. +/// Computed exactly once on first access. +pub static DEFAULT_HASHES: LazyLock> = LazyLock::new(|| { + let depth = TREE_DEPTH; + let empty_leaf = hash_bytes(EMPTY_LEAF_TAG); + let mut default_hashes = vec![empty_leaf; depth + 1]; + for level in (0..depth).rev() { + default_hashes[level] = hash_concat(&default_hashes[level + 1], &default_hashes[level + 1]); + } + default_hashes +}); + +/// Returns the bit at index `i` (0 = most-significant) in a 256-bit key. +pub fn get_bit(key: &[u8; 32], i: usize) -> bool { + let byte_index = i / 8; + let bit_index = 7 - (i % 8); + ((key[byte_index] >> bit_index) & 1) == 1 +} + +/// Returns a new key where only the first `bits` are kept; the rest are zeroed. +fn trim_key(key: &[u8; 32], bits: usize) -> [u8; 32] { + if bits == 0 { + return [0; 32]; + } + let mut new_key = *key; + let full_bytes = bits / 8; + let remaining_bits = bits % 8; + if full_bytes < 32 { + if remaining_bits != 0 { + new_key[full_bytes] &= 0xFF << (8 - remaining_bits); + new_key[(full_bytes + 1)..].fill(0); + } else { + new_key[full_bytes..].fill(0); + } + } + new_key +} + +/// Computes the key for the child node given its parent's key, the branch +/// (false for left, true for right), and the parent's level. +fn child_key(parent_key: &[u8; 32], branch: bool, level: usize) -> [u8; 32] { + let mut child = *parent_key; + if branch { + let byte_index = level / 8; + let bit_index = 7 - (level % 8); + child[byte_index] |= 1 << bit_index; + } + trim_key(&child, level + 1) +} + +/// Leaf hash = `Poseidon(value, key_as_digest)`. Used wherever a `(key, value)` +/// pair needs to be folded into a single 4-element digest before being hashed +/// up the path. +fn leaf_hash(value: &HashDigest, key: &[u8; 32]) -> HashDigest { + hash_concat(value, &digest_from_bytes(key)) +} + +/// Hash up `start` through `siblings` (indexed by tree level, `siblings[level]` +/// is the sibling at that level's parent node) using `key`'s MSB-first bits +/// for swap direction. Walks from the deepest level (level `TREE_DEPTH - 1`, +/// where the leaf's parent lives) up to the root. +/// +/// Returns the root produced by this walk. Used by every proof verify path +/// and by [`SparseMerkleTree::insert`]'s root computation. +fn hash_up_full_path(start: HashDigest, key: &[u8; 32], siblings: &[HashDigest]) -> HashDigest { + debug_assert_eq!(siblings.len(), TREE_DEPTH); + let mut current = start; + for level in (0..TREE_DEPTH).rev() { + let branch = get_bit(key, level); + let sibling = siblings[level]; + current = if branch { + hash_concat(&sibling, ¤t) + } else { + hash_concat(¤t, &sibling) + }; + } + current +} + +/// Inclusion proof: the key, plus exactly [`TREE_DEPTH`] sibling hashes from +/// the leaf's parent down to the root. +/// +/// `siblings[level]` is the sibling at that level's parent node (i.e. the +/// other child of the node at `(level, trim_key(key, level))`). Siblings at +/// levels where the subtree is empty equal `DEFAULT_HASHES[level + 1]`. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct InclusionProof { + pub key: [u8; 32], + pub siblings: Vec, +} + +impl InclusionProof { + /// Returns true if the proof reconstructs to `expected_root` from `leaf`. + pub fn verify(&self, leaf: HashDigest, expected_root: HashDigest) -> bool { + if self.siblings.len() != TREE_DEPTH { + return false; + } + let start = leaf_hash(&leaf, &self.key); + hash_up_full_path(start, &self.key, &self.siblings) == expected_root + } +} + +/// Non-inclusion proof: witnesses that `key` is absent from the tree. +/// +/// The proof walks from `DEFAULT_HASHES[TREE_DEPTH]` (the empty-leaf seed) up +/// through `siblings` and verifies that the resulting root equals `root`. If +/// the slot at `key`'s depth-`TREE_DEPTH` position were occupied, the walk +/// would produce a different root. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NonInclusionProof { + pub key: [u8; 32], + pub root: HashDigest, + pub siblings: Vec, +} + +impl NonInclusionProof { + pub fn verify(&self) -> bool { + if self.siblings.len() != TREE_DEPTH { + return false; + } + let start = DEFAULT_HASHES[TREE_DEPTH]; + hash_up_full_path(start, &self.key, &self.siblings) == self.root + } + + /// Returns the new root after inserting `leaf` at `self.key`. Does not + /// verify the proof itself; pair with [`Self::verify_and_insert`] when + /// validation is required. + pub fn insert(&self, leaf: HashDigest) -> HashDigest { + let start = leaf_hash(&leaf, &self.key); + hash_up_full_path(start, &self.key, &self.siblings) + } + + pub fn verify_and_insert(&self, leaf: HashDigest) -> Result { + if !self.verify() { + return Err("Invalid non-inclusion proof"); + } + Ok(self.insert(leaf)) + } +} + +/// Sparse Merkle tree: stores all internal nodes that differ from +/// the level-default. Insert/proof code hashes through every level. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct SparseMerkleTree { + nodes: HashMap<(usize, [u8; 32]), HashDigest>, + leaf_values: HashMap<[u8; 32], HashDigest>, +} + +impl Default for SparseMerkleTree { + fn default() -> Self { + Self::new() + } +} + +impl SparseMerkleTree { + pub fn new() -> Self { + SparseMerkleTree { + nodes: HashMap::new(), + leaf_values: HashMap::new(), + } + } + + /// Sibling hash at level `parent_level + 1` of the node opposite the + /// branch taken by `key`'s bit at `parent_level`. Falls back to + /// `DEFAULT_HASHES[level + 1]` for empty subtrees. + fn sibling_at(&self, key: &[u8; 32], parent_level: usize) -> HashDigest { + let branch = get_bit(key, parent_level); + let parent_key = trim_key(key, parent_level); + let sibling_key = child_key(&parent_key, !branch, parent_level); + self.nodes + .get(&(parent_level + 1, sibling_key)) + .cloned() + .unwrap_or(DEFAULT_HASHES[parent_level + 1]) + } + + /// Inserts `value` at `key`. Idempotent for identical re-insertions; + /// errors on conflicting re-insertion. + /// + /// Updates exactly one branch from `key`'s leaf at depth `TREE_DEPTH` + /// up to the root, recomputing each parent's hash unconditionally — + /// the uncompressed scheme means a singleton subtree's level-K root + /// is NOT `leaf_hash` itself but the result of hashing the leaf with + /// `TREE_DEPTH - K` levels of default siblings. + pub fn insert(&mut self, key: [u8; 32], value: HashDigest) -> Result<(), &'static str> { + if self.leaf_values.contains_key(&key) { + return if self.leaf_values.get(&key) == Some(&value) { + Ok(()) + } else { + Err("Key already exists in the tree with different value") + }; + } + self.leaf_values.insert(key, value); + + let leaf_h = leaf_hash(&value, &key); + self.nodes.insert((TREE_DEPTH, key), leaf_h); + + let mut current_hash = leaf_h; + for level in (0..TREE_DEPTH).rev() { + let branch = get_bit(&key, level); + let parent_key = trim_key(&key, level); + let sibling = self.sibling_at(&key, level); + current_hash = if branch { + hash_concat(&sibling, ¤t_hash) + } else { + hash_concat(¤t_hash, &sibling) + }; + self.nodes.insert((level, parent_key), current_hash); + } + Ok(()) + } + + pub fn root(&self) -> HashDigest { + self.nodes + .get(&(0, [0; 32])) + .cloned() + .unwrap_or(DEFAULT_HASHES[0]) + } + + pub fn get(&self, key: &[u8; 32]) -> Option { + self.leaf_values.get(key).cloned() + } + + /// 256 siblings along `key`'s branch, in `siblings[level]` order + /// (`level` is the parent's level, sibling lives at `level + 1`). + fn collect_path_siblings(&self, key: &[u8; 32]) -> Vec { + (0..TREE_DEPTH).map(|l| self.sibling_at(key, l)).collect() + } + + pub fn generate_inclusion_proof( + &self, + key: &[u8; 32], + ) -> Result<(InclusionProof, HashDigest), &'static str> { + if !self.nodes.contains_key(&(TREE_DEPTH, *key)) { + return Err("Key does not exist in the tree"); + } + let value = self.get(key).unwrap(); + let siblings = self.collect_path_siblings(key); + Ok(( + InclusionProof { + key: *key, + siblings, + }, + value, + )) + } + + pub fn generate_non_inclusion_proof( + &self, + key: [u8; 32], + ) -> Result { + if self.nodes.contains_key(&(TREE_DEPTH, key)) { + return Err("Leaf exists in the tree"); + } + let siblings = self.collect_path_siblings(&key); + Ok(NonInclusionProof { + key, + root: self.root(), + siblings, + }) + } +} + +/// Persist a `SparseMerkleTree` to `path` via bincode. Matches the +/// SP1-era `zkcoins_program::merkle::sparse_merkle_tree::save_merkle_tree` +/// shape — used by the server's `State::save_to_files` cutover. +pub fn save_merkle_tree(tree: &SparseMerkleTree, path: &str) -> std::io::Result<()> { + use std::io::Write; + let file = std::fs::File::create(path)?; + let serialized = bincode::serialize(tree).map_err(std::io::Error::other)?; + let mut writer = std::io::BufWriter::new(file); + writer.write_all(&serialized)?; + Ok(()) +} + +/// Load a `SparseMerkleTree` from `path` previously written by +/// [`save_merkle_tree`]. +pub fn load_merkle_tree(path: &str) -> std::io::Result { + use std::io::Read; + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + bincode::deserialize(&buffer).map_err(std::io::Error::other) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash_bytes; + + /// 50 random-ish 256-bit keys for soak testing the tree. + /// Generated deterministically from indices so the test corpus is + /// reproducible without copy-pasting 50 array literals. + fn sample_keys() -> Vec<[u8; 32]> { + (0..50_u32) + .map(|i| { + let h = hash_bytes(&i.to_le_bytes()); + let mut out = [0u8; 32]; + for (j, e) in h.elements.iter().enumerate() { + out[j * 8..(j + 1) * 8].copy_from_slice(&e.0.to_be_bytes()); + } + out + }) + .collect() + } + + fn sample_value(seed: u64) -> HashDigest { + hash_bytes(&seed.to_le_bytes()) + } + + #[test] + fn test_verify_and_insert() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(42); + for key in sample_keys() { + let non_inclusion = tree.generate_non_inclusion_proof(key).unwrap(); + assert!(tree.insert(key, value).is_ok()); + assert_eq!( + tree.root(), + non_inclusion.verify_and_insert(value).unwrap(), + "Roots deviate" + ); + } + } + + #[test] + fn test_verify_and_insert_sibling() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(42); + for key in sample_keys() { + let mut sibling_key = key; + sibling_key[31] ^= 1; + + assert!(tree.insert(sibling_key, value).is_ok()); + + let non_inclusion = tree.generate_non_inclusion_proof(key).unwrap(); + assert!(tree.insert(key, value).is_ok()); + + assert_eq!( + tree.root(), + non_inclusion.verify_and_insert(value).unwrap(), + "Roots deviate" + ); + } + } + + #[test] + fn test_insert_new_key() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(42); + for key in sample_keys() { + assert!(tree.insert(key, value).is_ok()); + assert!(tree.nodes.contains_key(&(TREE_DEPTH, key))); + } + } + + #[test] + fn test_insert_existing_key() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(42); + let other = sample_value(99); + for key in sample_keys() { + assert!(tree.insert(key, value).is_ok()); + assert!(tree.insert(key, other).is_err()); + let leaf_h = leaf_hash(&value, &key); + assert_eq!(tree.nodes.get(&(TREE_DEPTH, key)), Some(&leaf_h)); + } + } + + #[test] + fn test_root_changes_after_insert() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(42); + for key in sample_keys() { + let initial_root = tree.root(); + assert!(tree.insert(key, value).is_ok()); + assert_ne!(tree.root(), initial_root); + } + } + + #[test] + fn test_multiple_inserts() { + let mut tree = SparseMerkleTree::new(); + + for (i, key) in sample_keys().into_iter().enumerate() { + assert!(tree.insert(key, sample_value(i as u64)).is_ok()); + } + + for key in sample_keys() { + let leaf_key = trim_key(&key, TREE_DEPTH); + assert!(tree.nodes.contains_key(&(TREE_DEPTH, leaf_key))); + } + + let conflict = sample_value(99); + for existing_key in sample_keys() { + assert!(tree.insert(existing_key, conflict).is_err()); + } + } + + #[test] + fn test_get_value() { + let mut tree = SparseMerkleTree::new(); + let value = sample_value(45); + for key in sample_keys() { + assert!(tree.insert(key, value).is_ok()); + assert_eq!(tree.get(&key).unwrap(), value); + let non_existent_key = [10; 32]; + assert!(tree.get(&non_existent_key).is_none()); + } + } + + #[test] + fn test_multiple_values() { + let mut tree = SparseMerkleTree::new(); + for (i, key) in sample_keys().into_iter().enumerate() { + assert!(tree.insert(key, sample_value(i as u64)).is_ok()); + } + for (i, key) in sample_keys().into_iter().enumerate() { + assert_eq!(tree.get(&key).unwrap(), sample_value(i as u64)); + } + } + + #[test] + fn test_verify_inclusion_proofs() { + let mut tree = SparseMerkleTree::new(); + for (i, key) in sample_keys().into_iter().enumerate() { + assert!( + tree.generate_inclusion_proof(&key).is_err(), + "Proof for non-existent key should fail" + ); + tree.insert(key, sample_value(i as u64)).unwrap(); + let (proof, commitment) = tree.generate_inclusion_proof(&key).unwrap(); + assert_eq!(proof.siblings.len(), TREE_DEPTH); + assert!(proof.verify(commitment, tree.root())); + } + } + + #[test] + fn test_verify_non_inclusion_proofs() { + let mut tree = SparseMerkleTree::new(); + for (i, key) in sample_keys().into_iter().enumerate() { + let proof = tree.generate_non_inclusion_proof(key).unwrap(); + assert_eq!(proof.siblings.len(), TREE_DEPTH); + assert_eq!(proof.root, tree.root()); + assert!(proof.verify()); + tree.insert(key, sample_value(i as u64)).unwrap(); + } + } + + #[test] + fn default_is_empty_tree() { + let tree = SparseMerkleTree::default(); + assert_eq!(tree.root(), DEFAULT_HASHES[0]); + } + + #[test] + fn insert_same_key_same_value_is_idempotent() { + let mut tree = SparseMerkleTree::new(); + let key = [1u8; 32]; + let value = sample_value(0); + assert!(tree.insert(key, value).is_ok()); + // Re-inserting the same (key, value) is a no-op success. + assert!(tree.insert(key, value).is_ok()); + assert_eq!(tree.get(&key), Some(value)); + } + + #[test] + fn insert_same_key_different_value_errors() { + let mut tree = SparseMerkleTree::new(); + let key = [1u8; 32]; + tree.insert(key, sample_value(0)).unwrap(); + let err = tree.insert(key, sample_value(99)); + assert!(err.is_err()); + } + + #[test] + fn generate_non_inclusion_proof_errors_when_key_exists() { + let mut tree = SparseMerkleTree::new(); + let key = [1u8; 32]; + tree.insert(key, sample_value(0)).unwrap(); + let err = tree.generate_non_inclusion_proof(key); + assert!(err.is_err()); + } + + /// A non-inclusion proof with the wrong sibling count is rejected by + /// the length guard in `verify()` — the in-circuit gadget is built + /// against a fixed `TREE_DEPTH` shape and an off-circuit short proof + /// would silently underspecify the chain. + #[test] + fn non_inclusion_verify_rejects_short_proof() { + let tree = SparseMerkleTree::new(); + let mut proof = tree.generate_non_inclusion_proof([1u8; 32]).unwrap(); + proof.siblings.truncate(TREE_DEPTH - 1); + assert!(!proof.verify()); + } + + #[test] + fn inclusion_verify_rejects_short_proof() { + let mut tree = SparseMerkleTree::new(); + tree.insert([1u8; 32], sample_value(1)).unwrap(); + let (mut proof, value) = tree.generate_inclusion_proof(&[1u8; 32]).unwrap(); + proof.siblings.truncate(TREE_DEPTH - 1); + assert!(!proof.verify(value, tree.root())); + } + + #[test] + fn verify_and_insert_rejects_invalid_proof() { + let tree = SparseMerkleTree::new(); + let key = [1u8; 32]; + let mut proof = tree.generate_non_inclusion_proof(key).unwrap(); + // Tamper with a sibling: the verify() will reconstruct a different + // root than `proof.root`, so verify_and_insert refuses to insert. + proof.siblings[0] = sample_value(0xDEAD); + let err = proof.verify_and_insert(sample_value(7)); + assert!(err.is_err()); + } + + #[test] + fn non_inclusion_after_insert_changes_root_field_fails() { + let tree = SparseMerkleTree::new(); + let key = [1u8; 32]; + let mut proof = tree.generate_non_inclusion_proof(key).unwrap(); + // Pretend the root is something else; verify must catch. + proof.root = sample_value(0xBEEF); + assert!(!proof.verify()); + } + + /// Regression guard against the zero-state Poseidon collision: if + /// `DEFAULT_HASHES[TREE_DEPTH]` were `ZERO_HASH`, every level's default + /// would equal `Poseidon(all-zeros)` — and any leaf whose value+key both + /// permute through the zero state (e.g. derived from a `0u32` input) + /// would collide with `DEFAULT_HASHES[TREE_DEPTH - 1]`, breaking the + /// chase loop in non-inclusion proof generation. The domain-separated + /// empty-leaf seed prevents this. + #[test] + fn leaf_hash_never_collides_with_defaults() { + for (i, key) in sample_keys().into_iter().enumerate() { + let v = sample_value(i as u64); + let lh = leaf_hash(&v, &key); + for (l, default) in DEFAULT_HASHES.iter().enumerate() { + assert_ne!(lh, *default, "leaf {i} hash equals DEFAULT_HASHES[{l}]"); + } + } + } + + /// `save_merkle_tree` + `load_merkle_tree` round-trip preserves + /// the tree's leaf set + root + inclusion proofs. + #[test] + fn save_load_round_trip() { + let mut tree = SparseMerkleTree::new(); + for (i, key) in sample_keys().into_iter().enumerate().take(5) { + tree.insert(key, sample_value(i as u64)).unwrap(); + } + let original_root = tree.root(); + + // Write to a temp file via `tempfile` isn't available without + // a dep — use a deterministic per-test path under + // `std::env::temp_dir()` instead. + let path = std::env::temp_dir().join("zkcoins-plonky2-smt-roundtrip.bin"); + let path_str = path.to_str().unwrap(); + save_merkle_tree(&tree, path_str).expect("save"); + let loaded = load_merkle_tree(path_str).expect("load"); + std::fs::remove_file(&path).ok(); + + assert_eq!(loaded.root(), original_root); + // Re-derive an inclusion proof from the loaded tree and + // verify against the original root. + let (proof, value) = loaded + .generate_inclusion_proof(&sample_keys()[0]) + .expect("inclusion proof"); + assert_eq!(value, sample_value(0)); + assert!(proof.verify(value, original_root)); + } + + /// Build-time assertion: `load_merkle_tree` propagates I/O + /// errors when the path doesn't exist. + #[test] + fn load_merkle_tree_missing_path_errors() { + let path = std::env::temp_dir().join("zkcoins-plonky2-smt-does-not-exist.bin"); + std::fs::remove_file(&path).ok(); + let result = load_merkle_tree(path.to_str().unwrap()); + assert!(result.is_err()); + } +} diff --git a/program-plonky2/src/types.rs b/program-plonky2/src/types.rs new file mode 100644 index 00000000..00e6b151 --- /dev/null +++ b/program-plonky2/src/types.rs @@ -0,0 +1,370 @@ +//! Protocol data types for the Plonky2 backend. +//! +//! Ports `AccountState`, `Coin`, `CoinTemplate`, and `ProofData` from +//! `program/src/lib.rs` (SP1/SHA256) to a canonical field-element layout +//! hashed with Poseidon-Goldilocks. The byte-oriented SHA256 layout +//! (`bincode::serialize` then `Sha256::digest`) is replaced with explicit +//! field-element packing so the same hash can be computed cheaply both +//! off-circuit (Rust) and in-circuit (Plonky2 gadget). + +use plonky2::field::types::Field; +use plonky2::hash::hash_types::HashOut; +use plonky2::hash::poseidon::PoseidonHash; +use plonky2::plonk::config::Hasher; + +use crate::hash::{hash_bytes, HashDigest}; +use crate::F; + +pub type Amount = u64; + +/// Compressed secp256k1 public key, 33 bytes. +pub type PublicKey = [u8; 33]; + +/// Address: hash of the initial public key. Derived once at account creation +/// and never mutated; differs from the rotating `AccountState::public_key`. +pub type Address = HashDigest; + +/// Minting account address. Currently a placeholder derived from a +/// domain-separated tag — the server will replace this with the actual +/// Poseidon hash of the live minting public key as part of ROADMAP step 7 +/// ("Server: replace SP1 with Plonky2"). See SPEC.md §12.1 and divergence +/// D11 in MIGRATION_RESEARCH.md §3. +pub static MINTING_ADDRESS: std::sync::LazyLock = + std::sync::LazyLock::new(|| hash_bytes(b"zkcoins:minting-address:placeholder:v1")); + +/// Pack a `u64` into 2 field elements `(lo, hi)` — both 32-bit halves. This +/// guarantees the value is below the Goldilocks modulus regardless of input, +/// and matches a natural 2-limb representation for u64 in-circuit. +fn u64_to_limbs(value: u64) -> [F; 2] { + [ + F::from_canonical_u32((value & 0xFFFF_FFFF) as u32), + F::from_canonical_u32((value >> 32) as u32), + ] +} + +/// Pack a 33-byte compressed pubkey into 5 field elements (7 bytes each, +/// little-endian, with the final element holding 5 bytes + 3 zero pads). +/// Below the 56-bit safe ceiling for canonical Goldilocks representation. +fn pubkey_to_limbs(pk: &PublicKey) -> [F; 5] { + let mut out = [F::ZERO; 5]; + for (i, chunk) in pk.chunks(7).enumerate() { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + out[i] = F::from_canonical_u64(u64::from_le_bytes(buf)); + } + out +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AccountState { + /// `Address = H(initial_public_key_bytes)`. Set once at creation. + pub owner: Address, + pub balance: Amount, + /// Current commitment public key. Rotates each send. Wrapped in + /// `serde(with = "serde_big_array_local")` because serde's default + /// derive only handles `[T; N]` for `N ≤ 32`. + #[serde(with = "BigArray33")] + pub public_key: PublicKey, +} + +/// Tiny helper module supplying the `serialize` / `deserialize` +/// functions that `#[serde(with = "BigArray33")]` looks up. Avoids +/// pulling in the `serde-big-array` dependency for one 33-byte type. +struct BigArray33; + +impl BigArray33 { + pub fn serialize(v: &[u8; 33], s: S) -> Result { + use serde::ser::SerializeTuple; + let mut t = s.serialize_tuple(33)?; + for b in v.iter() { + t.serialize_element(b)?; + } + t.end() + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result<[u8; 33], D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = [u8; 33]; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[u8; 33]") + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut out = [0u8; 33]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?; + } + Ok(out) + } + } + d.deserialize_tuple(33, V) + } +} + +impl AccountState { + /// Create a fresh account from an initial public key. Balance starts at 0; + /// `owner` is derived as `hash_bytes(initial_public_key)`. + pub fn new(initial_public_key: PublicKey) -> Self { + AccountState { + owner: hash_bytes(&initial_public_key), + balance: 0, + public_key: initial_public_key, + } + } + + /// Canonical field-element layout: 4 owner + 2 balance + 5 pubkey = 11 F. + /// Single Poseidon `hash_no_pad` call; matches SPEC §10.3. + pub fn hash(&self) -> HashDigest { + let mut elements = Vec::with_capacity(11); + elements.extend_from_slice(&self.owner.elements); + elements.extend_from_slice(&u64_to_limbs(self.balance)); + elements.extend_from_slice(&pubkey_to_limbs(&self.public_key)); + PoseidonHash::hash_no_pad(&elements) + } + + /// Receive a coin into this account. Errors if `coin.recipient != owner` + /// or if the balance overflows. + pub fn apply_coin(mut self, coin: &Coin) -> Result { + if coin.recipient != self.owner { + return Err("Cannot receive coin: User is not the recipient"); + } + self.balance = self + .balance + .checked_add(coin.amount) + .ok_or("Receiving coin causes an overflow")?; + Ok(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CoinTemplate { + pub recipient: Address, + pub amount: Amount, +} + +impl CoinTemplate { + pub fn new(recipient: Address, amount: Amount) -> Self { + CoinTemplate { recipient, amount } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Coin { + pub identifier: HashDigest, + pub recipient: Address, + pub amount: Amount, +} + +impl Coin { + pub fn new(template: CoinTemplate, identifier: HashDigest) -> Coin { + Coin { + recipient: template.recipient, + amount: template.amount, + identifier, + } + } + + /// Returns `Ok` iff `self.identifier == H(account_state_hash || coin_index)`. + pub fn verify_identifier( + &self, + account_state_hash: HashDigest, + coin_index: u32, + ) -> Result<(), &'static str> { + if calculate_coin_identifier(account_state_hash, coin_index) == self.identifier { + Ok(()) + } else { + Err("Incorrect preimages provided.") + } + } +} + +/// `identifier = H(account_state_hash || u32(coin_index))`. The `u32` is +/// packed into a single field element directly (range-safe under Goldilocks). +pub fn calculate_coin_identifier(account_state_hash: HashDigest, coin_index: u32) -> HashDigest { + let mut elements = Vec::with_capacity(5); + elements.extend_from_slice(&account_state_hash.elements); + elements.push(F::from_canonical_u32(coin_index)); + PoseidonHash::hash_no_pad(&elements) +} + +/// Public output of the state-transition proof. Field-element-serialised +/// (no bincode) so the in-circuit `commit` and off-circuit reconstruction +/// agree element-for-element. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ProofData { + pub account_state_hash: HashDigest, + pub output_coins_root: HashDigest, + pub commitment_history_root: HashDigest, + pub coin_history_root: HashDigest, +} + +impl ProofData { + /// 16 field elements: 4 fields × 4 elements. The verifier-key digest is + /// supplied separately as a recursion-public-input by the circuit; + /// see §10 in `SPEC.md` for the recursion contract. + pub fn to_field_elements(&self) -> [F; 16] { + let mut out = [F::ZERO; 16]; + out[0..4].copy_from_slice(&self.account_state_hash.elements); + out[4..8].copy_from_slice(&self.output_coins_root.elements); + out[8..12].copy_from_slice(&self.commitment_history_root.elements); + out[12..16].copy_from_slice(&self.coin_history_root.elements); + out + } + + pub fn from_field_elements(elements: &[F; 16]) -> Self { + let mut chunks = elements.chunks_exact(4); + let next = |c: &mut std::slice::ChunksExact| { + let chunk = c.next().unwrap(); + HashOut { + elements: [chunk[0], chunk[1], chunk[2], chunk[3]], + } + }; + ProofData { + account_state_hash: next(&mut chunks), + output_coins_root: next(&mut chunks), + commitment_history_root: next(&mut chunks), + coin_history_root: next(&mut chunks), + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_pubkey(seed: u8) -> PublicKey { + let mut pk = [0u8; 33]; + pk[0] = 0x02; // compressed even-y prefix + for (i, b) in pk.iter_mut().enumerate().skip(1) { + *b = seed.wrapping_add(i as u8); + } + pk + } + + #[test] + fn account_state_new_seeds_balance_zero() { + let s = AccountState::new(dummy_pubkey(1)); + assert_eq!(s.balance, 0); + assert_eq!(s.owner, hash_bytes(&dummy_pubkey(1))); + assert_eq!(s.public_key, dummy_pubkey(1)); + } + + #[test] + fn account_state_hash_is_deterministic_and_collision_resistant() { + let s1 = AccountState::new(dummy_pubkey(1)); + let s2 = AccountState::new(dummy_pubkey(2)); + assert_eq!(s1.hash(), s1.clone().hash()); + assert_ne!(s1.hash(), s2.hash()); + + let mut s3 = s1.clone(); + s3.balance = 1; + assert_ne!(s1.hash(), s3.hash()); + + let mut s4 = s1.clone(); + s4.public_key = dummy_pubkey(99); + assert_ne!(s1.hash(), s4.hash()); + } + + #[test] + fn apply_coin_rejects_wrong_recipient() { + let owner = AccountState::new(dummy_pubkey(1)); + let coin = Coin { + identifier: hash_bytes(b"x"), + recipient: hash_bytes(b"someone else"), + amount: 100, + }; + assert!(owner.apply_coin(&coin).is_err()); + } + + #[test] + fn apply_coin_credits_balance() { + let owner = AccountState::new(dummy_pubkey(1)); + let coin = Coin { + identifier: hash_bytes(b"x"), + recipient: owner.owner, + amount: 100, + }; + let updated = owner.apply_coin(&coin).unwrap(); + assert_eq!(updated.balance, 100); + } + + #[test] + fn apply_coin_rejects_overflow() { + let mut s = AccountState::new(dummy_pubkey(1)); + s.balance = u64::MAX - 5; + let coin = Coin { + identifier: hash_bytes(b"x"), + recipient: s.owner, + amount: 10, + }; + assert!(s.apply_coin(&coin).is_err()); + } + + #[test] + fn coin_identifier_round_trip() { + let asth = hash_bytes(b"asth"); + for i in [0u32, 1, 7, 100, u32::MAX] { + let id = calculate_coin_identifier(asth, i); + let coin = Coin { + identifier: id, + recipient: hash_bytes(b"r"), + amount: 1, + }; + assert!(coin.verify_identifier(asth, i).is_ok()); + // Index sensitivity: changing the index breaks the identifier. + if i != u32::MAX { + assert!(coin.verify_identifier(asth, i + 1).is_err()); + } + } + } + + #[test] + fn proof_data_field_round_trip() { + let pd = ProofData { + account_state_hash: hash_bytes(b"asth"), + output_coins_root: hash_bytes(b"ocr"), + commitment_history_root: hash_bytes(b"chr"), + coin_history_root: hash_bytes(b"cohr"), + }; + let elts = pd.to_field_elements(); + let recovered = ProofData::from_field_elements(&elts); + assert_eq!(pd, recovered); + } + + #[test] + fn minting_address_is_stable() { + // The placeholder MUST stay deterministic across calls; the server + // wiring will replace this with the real Poseidon hash of the live + // minting public key (see D11 in MIGRATION_RESEARCH.md). + assert_eq!(*MINTING_ADDRESS, *MINTING_ADDRESS); + assert_eq!( + *MINTING_ADDRESS, + hash_bytes(b"zkcoins:minting-address:placeholder:v1") + ); + } + + #[test] + fn coin_template_new_carries_fields() { + let recipient = hash_bytes(b"r"); + let template = CoinTemplate::new(recipient, 42); + assert_eq!(template.recipient, recipient); + assert_eq!(template.amount, 42); + } + + #[test] + fn coin_new_from_template_preserves_recipient_and_amount() { + let recipient = hash_bytes(b"r"); + let template = CoinTemplate::new(recipient, 17); + let id = hash_bytes(b"id"); + let coin = Coin::new(template, id); + assert_eq!(coin.recipient, recipient); + assert_eq!(coin.amount, 17); + assert_eq!(coin.identifier, id); + } +} diff --git a/program/Cargo.toml b/program/Cargo.toml deleted file mode 100644 index 8010351a..00000000 --- a/program/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -version = "0.1.0" -name = "zkcoins-program" -edition = "2021" - -[dependencies] -sp1-zkvm = { version = "4.0.0", features = ["verify"] } -bincode = { workspace = true } -serde = { workspace = true } -rand = { workspace = true } -lazy_static = { workspace = true } -derive_builder = "0.20.2" -# Use patched version from sp1: https://docs.succinct.xyz/docs/sp1/writing-programs/patched-crates -sha2 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2" } diff --git a/program/src/lib.rs b/program/src/lib.rs deleted file mode 100644 index cef4277e..00000000 --- a/program/src/lib.rs +++ /dev/null @@ -1,248 +0,0 @@ -use merkle::{hash_concat, merkle_mountain_range::MMRProof}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use derive_builder::Builder; -use merkle::{ - sparse_merkle_tree::{InclusionProof, NonInclusionProof, DEFAULT_HASHES}, - HashDigest, -}; - -pub type Amount = u64; -pub type PublicKey = Vec; - -pub mod merkle; - -/// All three proofs that have to be checked per coin or previous account state proof -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CommitmentMerkleProofs { - // Root of the commitment tree. - pub commitment_root: HashDigest, - // Proves that commitment is included in commitment tree. - pub commitment_proof: InclusionProof, - // Proves that the commitment root is included in the commitment history tree. - pub commitment_root_history_proof: MMRProof, - pub commitment_root_mmr_sibling: HashDigest, - // Proves that the previous commitment history root is included in the commitment history tree. - // This proof is different from commitment_root_history_proof and commitmentProof because we - // store tuples of (SMTRoot, MMRRoot) in the MMR. - pub previous_root_history_proof: (HashDigest, MMRProof), - // The commitment is hash(hash(account_state) || out_coins_root) - pub commitment_account_state_hash: HashDigest, - pub commitment_out_coins_root: HashDigest, -} - -impl CommitmentMerkleProofs { - fn verify_commitment_root(&self, commitment_history_root: HashDigest) -> bool { - self.commitment_root_history_proof.verify( - hash_concat(&self.commitment_root, &self.commitment_root_mmr_sibling), - commitment_history_root, - ) - } - - fn commitment(&self) -> HashDigest { - hash_concat( - &self.commitment_account_state_hash, - &self.commitment_out_coins_root, - ) - } - - pub fn verify_commitment(&self, commitment_history_root: HashDigest) -> bool { - let valid_smt_in_history = self.verify_commitment_root(commitment_history_root); - let valid_commitment_in_smt = self - .commitment_proof - .verify(self.commitment(), self.commitment_root); - valid_smt_in_history && valid_commitment_in_smt - } - - pub fn verify_previous_root( - &self, - previous_root: HashDigest, - commitment_history_root: HashDigest, - ) -> bool { - self.previous_root_history_proof.1.verify( - hash_concat(&self.previous_root_history_proof.0, &previous_root), - commitment_history_root, - ) - } -} - -pub const MINTING_ADDRESS: HashDigest = [ - 175, 83, 161, 5, 16, 78, 44, 44, 237, 20, 140, 19, 48, 116, 86, 210, 247, 116, 223, 190, 106, - 191, 59, 198, 226, 248, 55, 102, 143, 24, 155, 216, -]; - -pub fn hash(data: &[u8]) -> HashDigest { - Sha256::digest(data).into() -} - -#[derive(Deserialize, Serialize, Clone)] -pub enum ProofType { - InitialProof, - AccountUpdateProof, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct ProofData { - pub vk: [u32; 8], - pub account_state_hash: HashDigest, - pub output_coins_root: HashDigest, - pub commitment_history_root: HashDigest, - pub coin_history_root: HashDigest, -} - -#[derive(Deserialize, Serialize, Clone)] -pub struct CoinTemplate { - pub recipient: HashDigest, - pub amount: Amount, -} - -impl CoinTemplate { - pub fn new(recipient: HashDigest, amount: Amount) -> Self { - CoinTemplate { recipient, amount } - } -} - -#[derive(Deserialize, Serialize, Clone, Debug)] -pub struct Coin { - pub identifier: HashDigest, - pub recipient: HashDigest, - pub amount: Amount, -} - -impl Coin { - pub fn new(template: CoinTemplate, identifier: HashDigest) -> Coin { - Coin { - recipient: template.recipient, - amount: template.amount, - identifier, - } - } - - /// Checks that the coin identifier is generated as expected. - pub fn verify_identifier( - &self, - account_state_hash: HashDigest, - coin_index: u32, - ) -> Result<(), &'static str> { - if calculate_coin_identifier(account_state_hash, coin_index) == self.identifier { - Ok(()) - } else { - Err("Incorrect preimages provided.") - } - } -} - -#[derive(Deserialize, Serialize, Clone, Debug)] -pub struct AccountState { - pub owner: HashDigest, - pub balance: u64, - pub public_key: PublicKey, -} - -impl AccountState { - pub fn new(initial_public_key: PublicKey) -> Self { - let address = hash(&initial_public_key); - AccountState { - owner: address, - balance: 0, - public_key: initial_public_key, - } - } - - pub fn apply_coin(mut self, coin: &Coin) -> Result { - if coin.recipient != self.owner { - return Err("Cannot receive coin: User is not the recipient"); - } - - self.balance = match self.balance.checked_add(coin.amount) { - Some(balance) => balance, - None => return Err("Receiving coin causes an overflow"), - }; - Ok(self) - } - - // Applies all coins to the account state and returns the out_coins_root. - pub fn send_coins( - &mut self, - coins: Vec, - coin_proofs: Vec, - next_public_key: PublicKey, - ) -> Result { - // Create an empty coins tree - let mut out_coins_root = DEFAULT_HASHES[0]; - - // Verify and apply sent coins. - for (coin_path, coin) in coin_proofs.iter().zip(&coins) { - // Make sure the proof has the correct root. - if out_coins_root != coin_path.root { - return Err("Update path has incorrect root"); - } - // Update the out_coins_root. (Providing a wrong path only means that the coin may not be - // receivable. Thus, we do not have to verify the path) - out_coins_root = coin_path.insert(coin.identifier)?; - // Apply coin. - self.balance = match self.balance.checked_sub(coin.amount) { - Some(balance) => balance, - None => return Err("Balance too small to create Coin."), - }; - } - - // Verify that each identifier is uniquely derived from account_state after all sends. - let account_hash = self.hash(); - for (i, coin) in coins.iter().enumerate() { - // NOTE: Expected coin identifier to be hash( hash( account state ) || coin index ) - coin.verify_identifier(account_hash, i as u32)?; - } - // Advance the public key. - self.public_key = next_public_key; - Ok(out_coins_root) - } - - pub fn hash(&self) -> HashDigest { - let serialized = bincode::serialize(self).expect("Serialization failed"); - hash(&serialized) - } -} - -#[derive(Builder, Serialize, Deserialize)] -pub struct ProgramInputs { - pub proof_type: ProofType, - pub verification_key: [u32; 8], - pub account_state: AccountState, - pub current_history_root: HashDigest, - - // Prev proof is the previous account_state proof. - #[builder(default)] - pub prev_proof_public_values: Option>, - #[builder(default)] - pub prev_proof_history_proofs: Option, - - pub in_coins: Vec, - pub in_coin_proofs_public_values: Vec>, - pub in_coin_proofs_history_proofs: Vec, - // Proofs for each coin in in_coins that it hasn't been received yet. - pub in_coin_proofs_non_inclusion_proofs: Vec, - // Proofs for each coin in in_coins that it was part of the in_coin_proof's out_coins. - pub in_coins_inclusion_proofs: Vec, - - pub out_coins: Vec, - // Used to generate the out_coins root. - pub out_coin_proofs: Vec, - pub next_public_key: PublicKey, -} - -/// The coin identifier is generated from the account state hash (after updating it with the coin -/// send) and the coin index. -pub fn calculate_coin_identifier(account_state_hash: HashDigest, coin_index: u32) -> HashDigest { - hash( - &[ - account_state_hash.to_vec(), - coin_index.to_be_bytes().to_vec(), - ] - .concat(), - ) -} - -// TODO: Write a test for the send_coins function (we can compare to the actual smt after inserting -// values) diff --git a/program/src/main.rs b/program/src/main.rs deleted file mode 100644 index b6f1056f..00000000 --- a/program/src/main.rs +++ /dev/null @@ -1,133 +0,0 @@ -#![no_main] -sp1_zkvm::entrypoint!(main); - -use sha2::{Digest, Sha256}; -use zkcoins_program::merkle::sparse_merkle_tree::{InclusionProof, DEFAULT_HASHES}; -use zkcoins_program::merkle::HashDigest; -use zkcoins_program::{AccountState, Coin, CommitmentMerkleProofs, ProofData, ProofType}; -use zkcoins_program::{ProgramInputs, MINTING_ADDRESS}; - -fn verify_proof(public_values: Vec, vkey: [u32; 8]) -> ProofData { - let previous_proof_data = bincode::deserialize::(&public_values) - .expect("Unable to deserialize previous proof data"); - assert_eq!(vkey, previous_proof_data.vk, "Verification keys not equal"); - let public_values_digest = Sha256::digest(public_values); - sp1_zkvm::lib::verify::verify_sp1_proof(&vkey, &public_values_digest.into()); - previous_proof_data -} - -fn verify_account_state_proof( - account_state: &AccountState, - public_values: Vec, - vkey: [u32; 8], - merkle_proofs: CommitmentMerkleProofs, - commitment_history_root: HashDigest, -) -> HashDigest { - let previous_proof_data = verify_proof(public_values, vkey); - let account_state_hash = account_state.hash(); - assert_eq!(account_state_hash, previous_proof_data.account_state_hash); - assert_eq!( - account_state_hash, - merkle_proofs.commitment_account_state_hash - ); - assert!(merkle_proofs.verify_commitment(commitment_history_root)); - assert!(merkle_proofs.verify_previous_root( - previous_proof_data.commitment_history_root, - commitment_history_root - )); - previous_proof_data.coin_history_root -} - -fn verify_coin_proof( - public_values: Vec, - vkey: [u32; 8], - merkle_proofs: CommitmentMerkleProofs, - commitment_history_root: HashDigest, - coin: &Coin, - coin_proof: InclusionProof, -) { - let coin_proof_data = verify_proof(public_values, vkey); - let out_coin_root = coin_proof_data.output_coins_root; - assert!(coin_proof.verify(coin.identifier, out_coin_root)); - assert_eq!(out_coin_root, merkle_proofs.commitment_out_coins_root); - assert!(merkle_proofs.verify_commitment(commitment_history_root)); - assert!(merkle_proofs.verify_previous_root( - coin_proof_data.commitment_history_root, - commitment_history_root - )); -} - -pub fn main() { - let hidden_inputs = sp1_zkvm::io::read::(); - let vkey = hidden_inputs.verification_key; - let mut account_state = hidden_inputs.account_state; - let commitment_history_root = hidden_inputs.current_history_root; - - let mut coin_history_root = match hidden_inputs.proof_type { - ProofType::AccountUpdateProof => verify_account_state_proof( - &account_state, - hidden_inputs - .prev_proof_public_values - .expect("Missing previous proofs public values"), - vkey, - hidden_inputs - .prev_proof_history_proofs - .expect("Missing previous proof's history proofs"), - commitment_history_root, - ), - ProofType::InitialProof => { - if account_state.owner != MINTING_ADDRESS { - assert_eq!(account_state.balance, 0, "Starting balance has to be 0.") - } - DEFAULT_HASHES[0] - } - }; - - let mut coin_history_proofs = hidden_inputs.in_coin_proofs_history_proofs.into_iter(); - let mut non_inclusion_proofs = hidden_inputs - .in_coin_proofs_non_inclusion_proofs - .into_iter(); - let mut public_values = hidden_inputs.in_coin_proofs_public_values.into_iter(); - let mut inclusion_proofs = hidden_inputs.in_coins_inclusion_proofs.into_iter(); - for coin in &hidden_inputs.in_coins { - verify_coin_proof( - public_values - .next() - .expect("Missing coin proof public values"), - vkey, - coin_history_proofs - .next() - .expect("Missing coin proof history proofs"), - commitment_history_root, - coin, - inclusion_proofs - .next() - .expect("Missing coin inclusion proof"), - ); - let coin_non_inclusion_proof = non_inclusion_proofs - .next() - .expect("Missing non_inclusion_proofs"); - assert_eq!(coin_history_root, coin_non_inclusion_proof.root); - coin_history_root = coin_non_inclusion_proof - .verify_and_insert(coin.identifier) - .expect("Coin was already integrated"); - account_state = account_state.apply_coin(coin).unwrap(); - } - - let output_coins_root = account_state - .send_coins( - hidden_inputs.out_coins, - hidden_inputs.out_coin_proofs, - hidden_inputs.next_public_key, - ) - .unwrap(); - - let commitment = ProofData { - vk: vkey, - account_state_hash: account_state.hash(), - output_coins_root, - commitment_history_root, - coin_history_root, - }; - sp1_zkvm::io::commit::(&commitment); -} diff --git a/program/src/merkle/merkle_mountain_range.rs b/program/src/merkle/merkle_mountain_range.rs deleted file mode 100644 index 00707c33..00000000 --- a/program/src/merkle/merkle_mountain_range.rs +++ /dev/null @@ -1,430 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::io; - -use super::{hash_concat, HashDigest, ZERO_HASH}; - -pub type MerklePath = Vec; - -#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] -pub struct MMRProof { - pub index: u32, - pub path: MerklePath, -} - -impl MMRProof { - pub fn new(path: MerklePath, index: u32) -> Self { - MMRProof { index, path } - } - - // TODO use this in the client? - /// Verify an inclusion proof. - /// - /// Given a leaf and an expected root, this function returns true if the proof is valid. - pub fn verify(&self, leaf: HashDigest, expected_root: HashDigest) -> bool { - let mut computed = leaf; - let mut idx = self.index; - for sibling in &self.path { - if idx % 2 == 0 { - computed = hash_concat(&computed, sibling); - } else { - computed = hash_concat(sibling, &computed); - } - idx /= 2; - } - computed == expected_root - } -} - -/// An append-only Merkle tree that updates incrementally. -/// -/// The tree is represented as a complete binary tree with a fixed capacity (a power of two) -/// and padded with 32-byte zeros for missing leaves. When appending a new leaf, only the branch -/// from that leaf to the root is updated. If the number of leaves reaches the current capacity, -/// the capacity is doubled (recomputing the new portions of the tree). -#[derive(Debug, Serialize, Deserialize)] -pub struct MerkleMountainRange { - /// Number of leaves appended so far. - count: usize, - /// Current capacity (number of leaves available in the bottom level). - capacity: usize, - /// The tree stored as levels, where level 0 is the leaves (length == capacity) and each higher - /// level has half as many nodes as the level below. The root is at the highest level. - levels: Vec>, -} - -impl Default for MerkleMountainRange { - fn default() -> Self { - Self::new() - } -} - -impl MerkleMountainRange { - /// Create a new, empty Merkle tree. - /// - /// We set an initial capacity of 2 so that even a single leaf is paired with a zero. - pub fn new() -> Self { - let capacity = 2; - let mut levels = Vec::new(); - // Level 0 (leaves): capacity elements (all zeros initially). - levels.push(vec![ZERO_HASH; capacity]); - // Number of levels is log2(capacity) + 1. - let depth = (capacity as f64).log2() as usize + 1; - // Create the remaining levels, each initialized to zeros. - for level in 1..depth { - levels.push(vec![ZERO_HASH; capacity >> level]); - } - Self { - count: 0, - capacity, - levels, - } - } - - /// Return the depth (number of levels) in the tree. - fn tree_depth(&self) -> usize { - self.levels.len() - } - - /// Append a new leaf to the Merkle tree. - /// - /// This function updates only the branch from the new leaf to the root. - pub fn append(&mut self, leaf: HashDigest) { - // Expand capacity if needed. - if self.count == self.capacity { - self.expand(); - } - // Place the new leaf into the bottom level. - self.levels[0][self.count] = leaf; - // Update parent nodes along the branch. - let mut index = self.count; - for level in 1..self.tree_depth() { - index /= 2; - let left = self.levels[level - 1][2 * index]; - // The right child is either the next element or, if not available, 32 zeros. - let right = if 2 * index + 1 < self.levels[level - 1].len() { - self.levels[level - 1][2 * index + 1] - } else { - ZERO_HASH - }; - self.levels[level][index] = hash_concat(&left, &right); - } - self.count += 1; - } - - /// Expand the tree by doubling its capacity. - /// - /// This only expands the storage structure without recomputing nodes, - /// as node updates are already handled by the append method. - fn expand(&mut self) { - let old_capacity = self.capacity; - let new_capacity = old_capacity * 2; - let new_depth = (new_capacity as f64).log2() as usize + 1; - - // Resize level 0 (leaves) - self.levels[0].resize(new_capacity, ZERO_HASH); - - // For each existing higher level, resize appropriately - for level in 1..self.tree_depth() { - self.levels[level].resize(new_capacity >> level, ZERO_HASH); - } - - // Add any new levels that are needed - for level in self.tree_depth()..new_depth { - self.levels.push(vec![ZERO_HASH; new_capacity >> level]); - } - - self.capacity = new_capacity; - } - - /// Return the current Merkle root. - /// - /// For an empty tree, the root is defined as 32 bytes of zero. - pub fn root(&self) -> HashDigest { - if self.count == 0 { - ZERO_HASH - } else { - // The root is stored in the highest level at index 0. - self.levels[self.tree_depth() - 1][0] - } - } - - /// Generate an inclusion proof for the leaf at the given index. - /// - /// The proof is a vector of sibling hashes at each level along the branch from the leaf - /// up to (but not including) the root. - /// - /// Returns None if the index is out-of-bounds. - pub fn get_proof(&self, index: usize) -> Result { - if index >= self.count { - return Err("index out of range"); - } - let mut proof = Vec::with_capacity(self.tree_depth() - 1); - let mut idx = index; - for level in 0..(self.tree_depth() - 1) { - let sibling_index = if idx % 2 == 0 { idx + 1 } else { idx - 1 }; - let sibling = if sibling_index < self.levels[level].len() { - self.levels[level][sibling_index] - } else { - ZERO_HASH - }; - proof.push(sibling); - idx /= 2; - } - Ok(MMRProof { - index: index as u32, - path: proof, - }) - } - - /// Save the Merkle tree to a file. - /// - /// This serializes the tree structure using bincode and writes it to the specified path. - /// Returns Ok(()) on success, or an IO error on failure. - pub fn save_to_file(&self, path: &str) -> io::Result<()> { - let encoded = - bincode::serialize(self).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - std::fs::write(path, encoded) - } - - /// Load a Merkle tree from a file. - /// - /// This reads and deserializes a tree from the specified path. - /// Returns the loaded tree on success, or an IO error on failure. - pub fn load_from_file(path: &str) -> io::Result { - let data = std::fs::read(path)?; - bincode::deserialize(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) - } - - /// Return the current number of leaves in the tree. - pub fn leaf_count(&self) -> usize { - self.count - } - - /// Return a reference to the leaf at the given index. - /// Returns None if the index is out of bounds. - pub fn get_leaf(&self, index: usize) -> Option<&HashDigest> { - if index >= self.count { - None - } else { - Some(&self.levels[0][index]) - } - } -} - -#[cfg(test)] -mod tests { - use sha2::{Digest, Sha256}; - - use super::*; - - /// Helper to convert a string into a 32-byte hash using SHA256. - fn hash_str(s: &str) -> HashDigest { - let mut hasher = Sha256::new(); - hasher.update(s.as_bytes()); - let result = hasher.finalize(); - let mut hash = [0u8; 32]; - hash.copy_from_slice(&result); - hash - } - - #[test] - fn test_empty_tree_root() { - let tree = MerkleMountainRange::new(); - // For an empty tree, the root is defined as 32 bytes of zero. - assert_eq!(tree.root(), ZERO_HASH); - } - - #[test] - fn test_single_leaf() { - let mut tree = MerkleMountainRange::new(); - let leaf = hash_str("leaf1"); - tree.append(leaf); - // With one leaf, the bottom level is [leaf, 0], - // so the expected root is hash(leaf || 0). - let expected_root = hash_concat(&leaf, &ZERO_HASH); - assert_eq!(tree.root(), expected_root); - - // The inclusion proof for the only leaf should contain a single sibling ([0;32]). - let proof = tree.get_proof(0).expect("proof should exist"); - assert_eq!(proof.path.len(), 1); - assert_eq!(proof.path[0], ZERO_HASH); - assert!(proof.verify(leaf, tree.root())); - } - - #[test] - fn test_two_leaves() { - let mut tree = MerkleMountainRange::new(); - let leaf1 = hash_str("leaf1"); - let leaf2 = hash_str("leaf2"); - tree.append(leaf1); - tree.append(leaf2); - // For two leaves the expected root is hash(leaf1 || leaf2). - let expected_root = hash_concat(&leaf1, &leaf2); - assert_eq!(tree.root(), expected_root); - - // Verify inclusion proofs for both leaves. - let proof1 = tree.get_proof(0).expect("proof should exist"); - let proof2 = tree.get_proof(1).expect("proof should exist"); - assert!(proof1.verify(leaf1, tree.root())); - assert!(proof2.verify(leaf2, tree.root())); - } - - #[test] - fn test_multiple_leaves() { - let mut tree = MerkleMountainRange::new(); - let leaves: Vec = vec![ - hash_str("leaf1"), - hash_str("leaf2"), - hash_str("leaf3"), - hash_str("leaf4"), - hash_str("leaf5"), - ]; - - for leaf in &leaves { - tree.append(*leaf); - } - let root = tree.root(); - // Check that inclusion proofs verify for all leaves. - for (i, leaf) in leaves.iter().enumerate() { - let proof = tree.get_proof(i).expect("proof should exist"); - assert!(proof.verify(*leaf, root)); - } - } - - #[test] - fn test_append_and_proof_consistency() { - let mut tree = MerkleMountainRange::new(); - // Append leaves one by one and check that proofs verify for all leaves so far. - let inputs = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]; - let mut leaves = Vec::new(); - for (i, s) in inputs.iter().enumerate() { - let leaf = hash_str(s); - tree.append(leaf); - leaves.push(leaf); - let current_root = tree.root(); - for (j, &leaf_val) in leaves.iter().enumerate() { - let proof = tree.get_proof(j).expect("proof should exist"); - assert!( - proof.verify(leaf_val, current_root), - "Proof for leaf index {} failed at iteration {}", - j, - i - ); - } - } - } - - #[test] - fn test_get_proof_out_of_bounds() { - let mut tree = MerkleMountainRange::new(); - let leaf = hash_str("leaf1"); - tree.append(leaf); - // Requesting a proof for an index outside the current count should return None. - assert!(tree.get_proof(1).is_err()); - } - - #[test] - fn test_invalid_proof() { - let mut tree = MerkleMountainRange::new(); - let leaf = hash_str("leaf1"); - tree.append(leaf); - let mut proof = tree.get_proof(0).expect("proof should exist"); - // Tamper with the proof: flip one bit in the first byte. - proof.path[0][0] ^= 0xff; - // The verification should now fail. - assert!(!proof.verify(leaf, tree.root())); - } - - #[test] - fn test_capacity_expansion() { - let mut tree = MerkleMountainRange::new(); - // Initially, the capacity is 2. - let leaf1 = hash_str("leaf1"); - let leaf2 = hash_str("leaf2"); - tree.append(leaf1); - tree.append(leaf2); - // Append one more leaf to force expansion. - let leaf3 = hash_str("leaf3"); - tree.append(leaf3); - // After expansion, the count should be 3 and the capacity should have doubled to 4. - assert_eq!(tree.count, 3); - assert_eq!(tree.capacity, 4); - - // Verify that inclusion proofs for all leaves are still valid. - let root = tree.root(); - for i in 0..tree.count { - let proof = tree.get_proof(i).expect("proof should exist"); - let leaf = tree.levels[0][i]; - assert!(proof.verify(leaf, root)); - } - } - - #[test] - fn test_serialization() { - let mut tree = MerkleMountainRange::new(); - let leaves = vec![hash_str("one"), hash_str("two"), hash_str("three")]; - - for leaf in &leaves { - tree.append(*leaf); - } - - // Serialize to bytes using bincode - let encoded = bincode::serialize(&tree).expect("Failed to serialize"); - - // Deserialize from bytes - let loaded_tree: MerkleMountainRange = - bincode::deserialize(&encoded).expect("Failed to deserialize"); - - // Verify trees are identical - assert_eq!(tree.count, loaded_tree.count); - assert_eq!(tree.capacity, loaded_tree.capacity); - assert_eq!(tree.root(), loaded_tree.root()); - - // Verify all leaves - for i in 0..tree.count { - assert_eq!(tree.levels[0][i], loaded_tree.levels[0][i]); - let proof = tree.get_proof(i).unwrap(); - let loaded_proof = loaded_tree.get_proof(i).unwrap(); - assert_eq!(proof, loaded_proof); - } - } - - #[test] - fn test_file_saving_loading() { - let mut tree = MerkleMountainRange::new(); - let leaves = vec![ - hash_str("file1"), - hash_str("file2"), - hash_str("file3"), - hash_str("file4"), - ]; - - for leaf in &leaves { - tree.append(*leaf); - } - - // Create a temporary file path - let temp_path = "test_merkle_tree.bin"; - - // Save to file - tree.save_to_file(temp_path) - .expect("Failed to save to file"); - - // Load from file - let loaded_tree = - MerkleMountainRange::load_from_file(temp_path).expect("Failed to load from file"); - - // Clean up - std::fs::remove_file(temp_path).ok(); - - // Verify trees are identical - assert_eq!(tree.count, loaded_tree.count); - assert_eq!(tree.capacity, loaded_tree.capacity); - assert_eq!(tree.root(), loaded_tree.root()); - - // Verify all leaves - for i in 0..tree.count { - assert_eq!(tree.levels[0][i], loaded_tree.levels[0][i]); - } - } -} diff --git a/program/src/merkle/mod.rs b/program/src/merkle/mod.rs deleted file mode 100644 index 03b2e556..00000000 --- a/program/src/merkle/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -use sha2::{Digest, Sha256}; - -pub mod merkle_mountain_range; -pub mod sparse_merkle_tree; - -pub const HASH_SIZE: usize = 32; -// TODO: This needs a better name -pub type HashDigest = [u8; HASH_SIZE]; -pub const ZERO_HASH: HashDigest = [0u8; HASH_SIZE]; - -/// Compute the SHA256 hash of the concatenation of two 32-byte arrays. -pub fn hash_concat(left: &HashDigest, right: &HashDigest) -> HashDigest { - let mut hasher = Sha256::new(); - hasher.update(left); - hasher.update(right); - let result = hasher.finalize(); - let mut hash = [0u8; 32]; - hash.copy_from_slice(&result); - hash -} diff --git a/program/src/merkle/sparse_merkle_tree.rs b/program/src/merkle/sparse_merkle_tree.rs deleted file mode 100644 index b9dbc8c1..00000000 --- a/program/src/merkle/sparse_merkle_tree.rs +++ /dev/null @@ -1,922 +0,0 @@ -use lazy_static::lazy_static; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::fs::File; -use std::io::{self, Read, Write}; - -use super::{hash_concat, HashDigest, ZERO_HASH}; - -/// The tree depth. For a 256-bit key space, depth is 256. -pub const TREE_DEPTH: usize = 256; - -lazy_static! { - /// Global default hash values for each level. - pub static ref DEFAULT_HASHES: Vec = { - let depth = TREE_DEPTH; - let mut default_hashes = vec![ZERO_HASH; depth + 1]; - // The default leaf hash can be computed arbitrarily; here using `hash_leaf(&[])` - default_hashes[depth] = hash_leaf(&[]); - for level in (0..depth).rev() { - default_hashes[level] = - hash_concat(&default_hashes[level + 1], &default_hashes[level + 1]); - } - default_hashes - }; -} - -/// Represents an inclusion proof for a key in the Sparse Merkle Tree. -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct InclusionProof { - /// The key (public key) that is proven to exist in the tree - pub key: [u8; 32], - /// The sibling hashes along the path from the root to the leaf - pub siblings: Vec, -} - -/// Returns the bit at index `i` (0 = most-significant) in a 256‑bit key. -pub fn get_bit(key: &[u8; 32], i: usize) -> bool { - let byte_index = i / 8; - let bit_index = 7 - (i % 8); - ((key[byte_index] >> bit_index) & 1) == 1 -} - -impl InclusionProof { - /// Verifies an inclusion proof. - /// Returns true if the proof is valid, false otherwise. - pub fn verify(&self, leaf: HashDigest, expected_root: HashDigest) -> bool { - // Hash leaf with key - let mut current_hash = hash_concat(&leaf, &self.key); - let mut siblings = self.siblings.clone(); - // Start with the leaf hash and work our way up to the root - while let Some(sibling) = siblings.pop() { - // Get the bit at this level (from most significant to least) - let branch = get_bit(&self.key, siblings.len()); - - // Combine the current hash with its sibling in the correct order - if branch { - // If bit is 1, we're on the right branch, so sibling is on the left - current_hash = hash_concat(&sibling, ¤t_hash); - } else { - // If bit is 0, we're on the left branch, so sibling is on the right - current_hash = hash_concat(¤t_hash, &sibling); - } - } - - // The computed root should match the provided root - current_hash == expected_root - } -} - -/// Represents a non-inclusion proof for a key in the Sparse Merkle Tree. -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct NonInclusionProof { - /// The key that is proven to not exist in the tree - pub key: [u8; 32], - /// The root hash of the tree - pub root: HashDigest, - /// The sibling hashes along the path from the root to the leaf - pub siblings: Vec, - /// The sibling hint (key, leaf) - pub leaf: ([u8; 32], HashDigest), -} - -impl NonInclusionProof { - /// Verifies a non-inclusion proof without updating the tree. - /// Returns true if the proof is valid, false otherwise. - pub fn verify(&self) -> bool { - let mut siblings = self.siblings.clone(); - // Compute the leaf hash for the sibling key - let mut current_hash = if self.key == self.leaf.0 { - // inclusion proof: expecting default leaf - if self.leaf.1 != DEFAULT_HASHES[siblings.len()] { - return false; - } - self.leaf.1 - } else { - // non-inclusion proof: expecting keys not equal - debug_assert_ne!(self.leaf.0, self.key); - // Hash sibling leaf with key - hash_concat(&self.leaf.1, &self.leaf.0) - }; - // Reconstruct the root by combining the siblings - while let Some(sibling) = siblings.pop() { - // Combine the current hash with its sibling in the correct order - current_hash = if get_bit(&self.leaf.0, siblings.len()) { - hash_concat(&sibling, ¤t_hash) - } else { - hash_concat(¤t_hash, &sibling) - }; - } - - let result = current_hash == self.root; - if !result { - println!( - "Root mismatch: computed {:?}, expected {:?}", - current_hash, self.root - ); - } - - result - } - - /// Updates the tree with the new value. - /// Returns the updated root. - pub fn insert(&self, leaf: HashDigest) -> Result { - let mut siblings = self.siblings.clone(); - let mut current_hash = if self.key == self.leaf.0 { - // inclusion proof: expecting default leaf - if self.leaf.1 != DEFAULT_HASHES[siblings.len()] { - return Err("Invalid non-inclusion proof"); - } - // Hash leaf with key - hash_concat(&leaf, &self.key) - } else { - // non-inclusion proof: expecting keys not equal - debug_assert_ne!(self.leaf.0, self.key); - // Padding with default hashes - while get_bit(&self.key, siblings.len()) == get_bit(&self.leaf.0, siblings.len()) { - siblings.push(DEFAULT_HASHES[siblings.len() + 1]) - } - let sibling = hash_concat(&self.leaf.1, &self.leaf.0); - let leaf = hash_concat(&leaf, &self.key); - // Combine children in the correct order. - if get_bit(&self.key, siblings.len()) { - hash_concat(&sibling, &leaf) - } else { - hash_concat(&leaf, &sibling) - } - }; - // Hash through previous siblings - while let Some(sibling) = siblings.pop() { - // Combine children in the correct order. - current_hash = if get_bit(&self.key, siblings.len()) { - hash_concat(&sibling, ¤t_hash) - } else { - hash_concat(¤t_hash, &sibling) - }; - } - Ok(current_hash) - } - - /// Verifies a non-inclusion proof and updates the tree with a new value if the proof is valid. - /// Returns the new root hash if successful, or an error if the proof is invalid. - pub fn verify_and_insert(&self, leaf: HashDigest) -> Result { - // First, verify the proof using the global DEFAULT_HASHES. - if !self.verify() { - return Err("Invalid non-inclusion proof"); - } - self.insert(leaf) - } -} - -/// Computes the hash for a leaf node with a domain‐separating prefix. -pub fn hash_leaf(data: &[u8]) -> HashDigest { - let mut hasher = Sha256::new(); - // Domain separation: prefix with 0x00 for leaves. - hasher.update([0x00]); - hasher.update(data); - let result = hasher.finalize(); - let mut hash = ZERO_HASH; - hash.copy_from_slice(&result); - hash -} - -/// Returns a new key where only the first `bits` are kept; the rest are zeroed. -fn trim_key(key: &[u8; 32], bits: usize) -> [u8; 32] { - if bits == 0 { - return [0; 32]; - } - let mut new_key = *key; - let full_bytes = bits / 8; - let remaining_bits = bits % 8; - if full_bytes < 32 { - if remaining_bits != 0 { - new_key[full_bytes] &= 0xFF << (8 - remaining_bits); - new_key[(full_bytes + 1)..].fill(0); - } else { - // When bits is a multiple of 8, clear from index `full_bytes` onward. - new_key[full_bytes..].fill(0); - } - } - new_key -} - -/// Computes the key for the child node given its parent's key, the branch (false for left, true for right), -/// and the parent's level. -fn child_key(parent_key: &[u8; 32], branch: bool, level: usize) -> [u8; 32] { - let mut child = *parent_key; - if branch { - let byte_index = level / 8; - let bit_index = 7 - (level % 8); - child[byte_index] |= 1 << bit_index; - } - trim_key(&child, level + 1) -} - -// A : 0b00 -// B : 0b01 -// /\ -// / \ -// /\ ∅ -// / \ -// A B - -/// A simple sparse Merkle tree structure. -/// -/// It only stores nodes that differ from the default (empty) values. -#[derive(Serialize, Deserialize, Debug)] -pub struct SparseMerkleTree { - /// Map key: (level, node index). For a node at a given level, the index is represented as a 256‑bit array - /// where only the first `level` bits are significant. - nodes: HashMap<(usize, [u8; 32]), HashDigest>, - /// Store the leaf values to support retrieval - leaf_values: HashMap<[u8; 32], HashDigest>, -} - -impl Default for SparseMerkleTree { - fn default() -> Self { - Self::new() - } -} - -impl SparseMerkleTree { - /// Creates a new sparse Merkle tree with the specified depth. - pub fn new() -> Self { - SparseMerkleTree { - nodes: HashMap::new(), - leaf_values: HashMap::new(), - } - } - - /// Inserts a new leaf at `key` with the given `value`. - /// - /// Returns an error if the key already exists in the tree. - /// The key is assumed to be a 256‑bit value (as a `[u8; 32]` array). - pub fn insert(&mut self, key: [u8; 32], leaf: HashDigest) -> Result<(), &'static str> { - // Check if the key already exists in the tree - if self.leaf_values.contains_key(&key) { - // Allow to insert the exact same leaf - return if self.leaf_values.get(&key) == Some(&leaf) { - Ok(eprintln!( - "\u{1B}[33mWARNING: Leaf already exists in the tree\u{1B}[0m" - )) - } else { - Err("Key already exists in the tree with different value") - }; - } - - // Store the leaf to get it with the key. - // Bind the insert result first: debug_assert! is a no-op in release - // and would otherwise drop the side effect entirely. - let prev_leaf = self.leaf_values.insert(key, leaf); - debug_assert_eq!(prev_leaf, None); - - // Hash leaf with key - let leaf_hash = hash_concat(&leaf, &key); - - // Propagate the update upward. - let mut current_hash = leaf_hash; - for level in (0..TREE_DEPTH).rev() { - // Determine whether the current node is a left or right child. - let branch = get_bit(&key, level); - let parent_key = trim_key(&key, level); - // Sibling key is computed by taking the opposite branch. - let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self - .nodes - .get(&(level + 1, sibling_key)) - .cloned() - .unwrap_or(DEFAULT_HASHES[level + 1]); - // Update sibling node - self.nodes.insert( - (level + 1, child_key(&parent_key, branch, level)), - current_hash, - ); - if current_hash != leaf_hash || sibling != DEFAULT_HASHES[level + 1] { - current_hash = if branch { - // Combine children in the correct order. - hash_concat(&sibling, ¤t_hash) - } else { - hash_concat(¤t_hash, &sibling) - }; - } - } - // Update the merkle root. Same caveat as above: bind first, assert second. - let prev_root = self.nodes.insert((0, [0; 32]), current_hash); - debug_assert_ne!(prev_root, Some(current_hash)); - - Ok(()) - } - - /// Returns the current root hash of the tree. - pub fn root(&self) -> HashDigest { - // The root is at level 0 with an index of all zeros. - self.nodes - .get(&(0, [0; 32])) - .cloned() - .unwrap_or(DEFAULT_HASHES[0]) - } - - /// Generates a non-inclusion proof for a key. - pub fn generate_non_inclusion_proof( - &self, - key: [u8; 32], - ) -> Result { - let mut siblings = Vec::with_capacity(TREE_DEPTH); - - // Check if the key exists in the tree - if self.nodes.contains_key(&(TREE_DEPTH, key)) { - // If the key exists, we can't generate a valid non-inclusion proof - return Err("Leaf exists in the tree"); - } - - let mut sibling_leaf = (key, DEFAULT_HASHES[TREE_DEPTH]); - - if !self.nodes.contains_key(&(0, [0; 32])) { - return Ok(NonInclusionProof { - key, - root: DEFAULT_HASHES[0], - siblings, - leaf: (key, DEFAULT_HASHES[0]), - }); - } - - // Collect sibling hashes along the path from root to leaf - for level in 0..TREE_DEPTH { - let branch = get_bit(&key, level); - let parent_key = trim_key(&key, level); - if let Some(parent) = self.nodes.get(&(level, parent_key)) { - // Compute the sibling key (the key for the other branch) - let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self - .nodes - .get(&(level + 1, sibling_key)) - .cloned() - .unwrap_or(DEFAULT_HASHES[level + 1]); - let key = child_key(&parent_key, branch, level); - let child = self - .nodes - .get(&(level + 1, key)) - .cloned() - .unwrap_or(DEFAULT_HASHES[level + 1]); - if sibling == *parent || child == *parent { - let mut parent_key = if child == *parent { key } else { sibling_key }; - // Restore full sibling key and fetch its leaf - for layer in level + 1..TREE_DEPTH { - let key_1 = child_key(&parent_key, true, layer); - let key_0 = child_key(&parent_key, false, layer); - let node_1 = self - .nodes - .get(&(layer + 1, key_1)) - .cloned() - .unwrap_or(DEFAULT_HASHES[layer + 1]); - let node_0 = self - .nodes - .get(&(layer + 1, key_0)) - .cloned() - .unwrap_or(DEFAULT_HASHES[layer + 1]); - debug_assert!(node_1 == *parent || node_0 == *parent); - parent_key = if node_1 == *parent { key_1 } else { key_0 }; - } - sibling_leaf.0 = parent_key; - sibling_leaf.1 = *self.leaf_values.get(&parent_key).unwrap(); - break; - } - siblings.push(sibling); - } else { - sibling_leaf.0 = key; - sibling_leaf.1 = DEFAULT_HASHES[level]; - break; - } - } - - Ok(NonInclusionProof { - key, - root: self.root(), - siblings, - leaf: sibling_leaf, - }) - } - - /// Gets the value associated with a key, if it exists in the tree. - pub fn get(&self, key: &[u8; 32]) -> Option { - // Simply return the stored value from leaf_values - self.leaf_values.get(key).cloned() - } - - /// Generates an inclusion proof for a key in the tree. - /// The proof includes the sibling hashes along the path from the root to the leaf, - /// the key, and the value. - pub fn generate_inclusion_proof( - &self, - key: &[u8; 32], - ) -> Result<(InclusionProof, HashDigest), &'static str> { - // Check if this key exists in the nodes map at the leaf level - if !self.nodes.contains_key(&(TREE_DEPTH, *key)) { - // The key doesn't exist in the tree - return Err("Key does not exist in the tree"); - } - - let commitment = self.get(key).unwrap(); - - let mut siblings = Vec::new(); - let mut parent = self - .nodes - .get(&(0, [0; 32])) - .cloned() - .unwrap_or(DEFAULT_HASHES[0]); - - for level in 0..TREE_DEPTH { - let branch = get_bit(key, level); - let parent_key = trim_key(key, level); - let sibling_key = child_key(&parent_key, !branch, level); - let sibling = self - .nodes - .get(&(level + 1, sibling_key)) - .cloned() - .unwrap_or(DEFAULT_HASHES[level + 1]); - let child_key = child_key(&parent_key, branch, level); - let child = self - .nodes - .get(&(level + 1, child_key)) - .cloned() - .unwrap_or(DEFAULT_HASHES[level + 1]); - if child == parent || sibling == parent { - break; - } - siblings.push(sibling); - parent = child; - } - - Ok(( - InclusionProof { - key: *key, - siblings, - }, - commitment, - )) - } -} - -/// Saves a Sparse Merkle Tree to a file at the specified path. -pub fn save_merkle_tree(tree: &SparseMerkleTree, path: &str) -> io::Result<()> { - let file = File::create(path)?; - let serialized = - bincode::serialize(tree).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - let mut writer = io::BufWriter::new(file); - writer.write_all(&serialized)?; - Ok(()) -} - -/// Loads a Sparse Merkle Tree from a file at the specified path. -pub fn load_merkle_tree(path: &str) -> io::Result { - let file = File::open(path)?; - let mut reader = io::BufReader::new(file); - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer)?; - - bincode::deserialize(&buffer).map_err(|e| io::Error::new(io::ErrorKind::Other, e)) -} - -#[cfg(test)] -mod tests { - use super::super::HASH_SIZE; - - use super::*; - - const SAMPLES: [[u8; 32]; 50] = [ - [ - 0xFF, 0x86, 0x1D, 0xB2, 0xA9, 0xA1, 0x5A, 0x20, 0x0A, 0x6E, 0xED, 0x82, 0xF8, 0x3F, - 0xFA, 0x04, 0xD0, 0x3B, 0xB4, 0xDB, 0xF1, 0x23, 0xAC, 0x2F, 0x19, 0x74, 0xE2, 0xB2, - 0xC8, 0x86, 0xD4, 0x37, - ], - [ - 0x2D, 0x54, 0x24, 0xE6, 0x8B, 0xA1, 0x19, 0xFA, 0x0B, 0x20, 0x82, 0xD2, 0x74, 0x02, - 0x3E, 0xAA, 0xA3, 0x81, 0xCA, 0x0E, 0xB7, 0x8E, 0xB1, 0x86, 0x9E, 0xBF, 0xB8, 0x95, - 0x9B, 0xA2, 0x59, 0xE8, - ], - [ - 0xF8, 0x1C, 0xA1, 0xF1, 0xF4, 0x93, 0x7A, 0x62, 0x14, 0x05, 0x32, 0xA1, 0xF4, 0x43, - 0xD7, 0xAB, 0xCA, 0x9A, 0x15, 0xC2, 0xA3, 0xCF, 0x3F, 0x42, 0x5D, 0x90, 0x7D, 0xEC, - 0x29, 0xE7, 0x5D, 0x71, - ], - [ - 0xA2, 0xFC, 0xAD, 0x39, 0xBC, 0x3B, 0x65, 0x30, 0x78, 0x31, 0x34, 0x46, 0x89, 0x05, - 0x49, 0xE9, 0xF6, 0xF1, 0x06, 0x9B, 0x13, 0xDB, 0x75, 0xD4, 0x45, 0xC1, 0x97, 0x43, - 0x2A, 0xD6, 0x1C, 0x64, - ], - [ - 0xC7, 0x79, 0x0C, 0x63, 0xE2, 0xA5, 0x01, 0x6F, 0xA6, 0xC4, 0xA1, 0x6E, 0xB5, 0x3C, - 0x0D, 0x7A, 0xF9, 0xF4, 0xFD, 0x58, 0x02, 0xF0, 0xF1, 0x8C, 0x7F, 0xC0, 0x4E, 0x3D, - 0x58, 0x3A, 0x60, 0xF2, - ], - [ - 0xD4, 0xE9, 0x69, 0xD7, 0x52, 0xAD, 0xBD, 0xF2, 0x41, 0x08, 0x96, 0xB2, 0xD7, 0xBD, - 0xF6, 0x6D, 0x4B, 0x43, 0x81, 0xC9, 0x1B, 0xD3, 0xC9, 0x96, 0x27, 0x2F, 0xAB, 0xE7, - 0xC2, 0xF7, 0x60, 0xC4, - ], - [ - 0x00, 0x5E, 0x18, 0x2F, 0x55, 0x0A, 0xFA, 0x74, 0x8E, 0x8E, 0xE2, 0x12, 0xAF, 0xF4, - 0xBD, 0xE6, 0xF2, 0x04, 0xEE, 0x7F, 0xE1, 0xD7, 0x05, 0x0C, 0x1B, 0x16, 0x4B, 0x48, - 0xC3, 0x49, 0x70, 0x0F, - ], - [ - 0x95, 0x4A, 0x8A, 0x33, 0x34, 0x99, 0x42, 0xA0, 0x95, 0x98, 0x1F, 0x83, 0x03, 0x58, - 0x92, 0xAC, 0xEE, 0xA6, 0x70, 0xE4, 0x3C, 0x00, 0x55, 0xEE, 0xB4, 0x71, 0xD1, 0xAC, - 0xDC, 0xB6, 0xDB, 0x21, - ], - [ - 0xB3, 0x7B, 0xF4, 0xB3, 0x6E, 0x4F, 0x41, 0x47, 0xD7, 0x39, 0xB8, 0x4F, 0x5E, 0xC4, - 0x68, 0x18, 0x4F, 0xAD, 0x9C, 0xE7, 0x76, 0x65, 0x70, 0x6B, 0xC6, 0x88, 0x77, 0x9E, - 0x29, 0x1D, 0x0B, 0xC8, - ], - [ - 0x01, 0xBA, 0xF8, 0x76, 0xBF, 0x30, 0xFF, 0x03, 0xDF, 0x84, 0x61, 0x4F, 0xC1, 0x06, - 0xCB, 0x37, 0x00, 0x78, 0x13, 0xC6, 0x0B, 0xAE, 0x30, 0x69, 0xD4, 0xB0, 0x25, 0x0C, - 0x29, 0x0F, 0x2F, 0x80, - ], - [ - 0x6D, 0xB8, 0xE4, 0xA7, 0xE4, 0xA6, 0x37, 0x00, 0x2F, 0x47, 0xBD, 0x50, 0x67, 0x3D, - 0x7A, 0x89, 0x2D, 0x3F, 0xFE, 0xE3, 0xBA, 0x58, 0x15, 0xBE, 0x9A, 0xDA, 0xA7, 0xE2, - 0x8A, 0xDE, 0xD4, 0xB7, - ], - [ - 0x78, 0xDE, 0x51, 0x6F, 0x01, 0xF2, 0x28, 0xFE, 0x23, 0xEE, 0xFA, 0xA3, 0x7C, 0x91, - 0xF0, 0x07, 0x41, 0x7A, 0x59, 0x36, 0xF8, 0x87, 0x57, 0x91, 0x8A, 0x9E, 0x39, 0xF3, - 0x84, 0x98, 0xF0, 0xF6, - ], - [ - 0xCB, 0x08, 0x00, 0xD0, 0xB5, 0x17, 0xF0, 0x2F, 0x80, 0x8A, 0xC8, 0x40, 0xAC, 0x52, - 0xAF, 0x27, 0x2D, 0x10, 0x22, 0xE4, 0x30, 0xB3, 0x72, 0x34, 0x3F, 0xBD, 0x0C, 0x23, - 0x44, 0x87, 0x14, 0xCC, - ], - [ - 0x7F, 0x87, 0xAD, 0x4E, 0x0F, 0x83, 0x18, 0x12, 0x2D, 0x73, 0x4C, 0xB3, 0xF5, 0x42, - 0x69, 0x5E, 0xC3, 0xAC, 0x03, 0x00, 0xB1, 0x27, 0xCB, 0xFE, 0x07, 0x9C, 0xED, 0xC3, - 0x4A, 0xFC, 0x09, 0xB4, - ], - [ - 0x1A, 0x73, 0x9F, 0x3E, 0xE9, 0x1F, 0xE5, 0x6B, 0x3C, 0xE0, 0x81, 0x75, 0x78, 0xC8, - 0x7E, 0x8D, 0x65, 0x1A, 0x33, 0xE4, 0x57, 0x2F, 0x4C, 0x2D, 0x0F, 0x02, 0x3F, 0x76, - 0x57, 0xB1, 0x51, 0x82, - ], - [ - 0x76, 0x9D, 0x74, 0x79, 0xBC, 0x89, 0xBF, 0xA2, 0x67, 0x54, 0x27, 0x67, 0xC7, 0xE9, - 0xFD, 0x81, 0x3F, 0xBC, 0x2F, 0x85, 0xBB, 0x09, 0x82, 0xFC, 0x70, 0x29, 0x93, 0x8B, - 0x44, 0x8B, 0xB0, 0x5D, - ], - [ - 0xB5, 0x07, 0x83, 0xBF, 0x44, 0x92, 0xE3, 0xCB, 0x65, 0x85, 0x01, 0xFF, 0x8D, 0xDB, - 0xF5, 0xEC, 0x90, 0x04, 0x1C, 0x81, 0xA1, 0x08, 0x70, 0x11, 0xD4, 0x80, 0x4C, 0xA4, - 0x7B, 0xA0, 0x59, 0x11, - ], - [ - 0x92, 0x2F, 0x9C, 0xA9, 0x27, 0xE4, 0xEA, 0xB5, 0x4F, 0x85, 0x45, 0xC3, 0xFB, 0x17, - 0xAD, 0x68, 0x54, 0x0F, 0x4E, 0x96, 0x3E, 0xF8, 0x22, 0x61, 0x8F, 0x4E, 0x5A, 0x8E, - 0x75, 0x97, 0x47, 0x3F, - ], - [ - 0xC5, 0xC2, 0xBC, 0x32, 0x2C, 0xE9, 0xC4, 0x0E, 0x36, 0x10, 0xF0, 0x02, 0x67, 0xBF, - 0xF5, 0x2A, 0x24, 0xF7, 0x31, 0x7F, 0x0F, 0xBE, 0x18, 0x0C, 0x2A, 0x18, 0x71, 0x15, - 0xE4, 0x21, 0x35, 0xA9, - ], - [ - 0xCF, 0x06, 0x69, 0x7D, 0x61, 0xD1, 0x18, 0xC5, 0xF2, 0xE2, 0x78, 0x82, 0xDC, 0x0D, - 0xF3, 0x06, 0xAA, 0xA5, 0x21, 0x12, 0xAA, 0xCA, 0x48, 0x1D, 0x6C, 0xA7, 0x66, 0x3D, - 0xDF, 0xA5, 0x2A, 0x00, - ], - [ - 0xA7, 0x3D, 0xF6, 0x26, 0xE0, 0x12, 0xAB, 0x45, 0xE7, 0x7E, 0xB3, 0x90, 0x99, 0x11, - 0x73, 0x72, 0x21, 0x18, 0x85, 0x57, 0xF2, 0xCF, 0x1E, 0xBE, 0xC2, 0x78, 0x66, 0x3D, - 0x67, 0xD6, 0xDE, 0x0F, - ], - [ - 0xF7, 0xFC, 0x3C, 0xAA, 0xAC, 0xF4, 0x70, 0x84, 0x62, 0x79, 0xBC, 0x6B, 0x78, 0x92, - 0x85, 0x25, 0x2C, 0xCB, 0x10, 0x9E, 0x57, 0x3A, 0x77, 0xA9, 0x12, 0x57, 0xE9, 0x6B, - 0x87, 0x70, 0x69, 0xAE, - ], - [ - 0x65, 0x43, 0x0E, 0x20, 0x0A, 0x8B, 0x3E, 0x38, 0xD0, 0x7F, 0x75, 0x52, 0x4C, 0xC3, - 0x51, 0x29, 0x56, 0x69, 0x1E, 0xB8, 0xEB, 0x80, 0x15, 0x95, 0x0C, 0xD7, 0x52, 0xF7, - 0x53, 0x16, 0x00, 0x4B, - ], - [ - 0xB8, 0xC5, 0xEF, 0xF1, 0x16, 0xDA, 0x0D, 0x16, 0xE4, 0xF1, 0xB1, 0x0B, 0x91, 0x39, - 0x1E, 0xC1, 0x3F, 0x3C, 0xD3, 0x9D, 0xAD, 0x7D, 0x2A, 0x85, 0xCA, 0x5E, 0xCE, 0xEC, - 0xFC, 0x30, 0xEE, 0x73, - ], - [ - 0x2D, 0x48, 0xB4, 0x51, 0xC1, 0x5F, 0x56, 0x7A, 0x96, 0x78, 0x4D, 0xB7, 0x5D, 0xFB, - 0xF7, 0xE7, 0xA1, 0xA8, 0xDA, 0xAF, 0x1B, 0x42, 0xFB, 0x12, 0xE0, 0xC2, 0x3B, 0xFC, - 0x28, 0x34, 0x6C, 0x7A, - ], - [ - 0xB1, 0x21, 0x6A, 0x05, 0xEF, 0xF1, 0xFC, 0x1C, 0x41, 0x1D, 0xF8, 0xC5, 0xF8, 0x72, - 0x83, 0xA0, 0xEA, 0x2F, 0x19, 0x22, 0x29, 0x11, 0x42, 0x19, 0x42, 0x31, 0xD3, 0xEB, - 0xE2, 0xFC, 0xF2, 0xFA, - ], - [ - 0xE2, 0xA9, 0xAD, 0x90, 0x5F, 0xDE, 0xE0, 0x97, 0xB9, 0x83, 0x6C, 0xF9, 0x04, 0x07, - 0x01, 0x54, 0x68, 0x15, 0x67, 0x9A, 0x4F, 0x88, 0x64, 0x8E, 0x4F, 0xAD, 0xA0, 0xA7, - 0x0F, 0xF7, 0xFA, 0xBB, - ], - [ - 0xDB, 0xDD, 0xB1, 0x47, 0x1D, 0x8B, 0x12, 0x3F, 0xF9, 0x3F, 0x9E, 0x3D, 0xDE, 0x91, - 0xBC, 0x36, 0x5E, 0x53, 0x2E, 0x32, 0x55, 0xB4, 0x2D, 0x35, 0x12, 0x29, 0x5A, 0x6E, - 0xE5, 0xEB, 0xBF, 0x48, - ], - [ - 0xAB, 0x9A, 0x8C, 0x63, 0x8A, 0x8B, 0xDE, 0xBE, 0x24, 0x93, 0xC4, 0x23, 0x1E, 0xF3, - 0x55, 0x27, 0x54, 0x2E, 0xC2, 0x59, 0xC6, 0x7B, 0xC7, 0x00, 0x6D, 0x44, 0x1A, 0x5A, - 0x63, 0x99, 0x51, 0x14, - ], - [ - 0x46, 0xAD, 0xA6, 0x5D, 0x94, 0x68, 0xE7, 0x74, 0x70, 0x51, 0x60, 0x64, 0x19, 0x0A, - 0x22, 0x10, 0xEF, 0xFE, 0x34, 0x24, 0x8F, 0x25, 0xAA, 0xE8, 0xEE, 0x53, 0xCD, 0xFD, - 0xE9, 0xD0, 0x7E, 0x36, - ], - [ - 0x31, 0xAC, 0x1C, 0xA2, 0xC2, 0xD0, 0xF4, 0x0F, 0x9C, 0xD4, 0x47, 0x9A, 0xE7, 0x3E, - 0xA8, 0xD0, 0x17, 0xB0, 0x7E, 0xF1, 0xCF, 0x1F, 0x22, 0xC1, 0xB4, 0x81, 0x7E, 0x2C, - 0xD2, 0xAB, 0x0A, 0xC5, - ], - [ - 0xAA, 0xCE, 0x93, 0x26, 0x30, 0x36, 0x81, 0xE5, 0xCE, 0xAF, 0x72, 0x45, 0xB4, 0xCB, - 0x54, 0x9F, 0xB0, 0x5F, 0x29, 0xAE, 0x5A, 0xE2, 0x05, 0xFC, 0xFF, 0x34, 0x9A, 0x9B, - 0xF9, 0x01, 0x88, 0x0E, - ], - [ - 0x8C, 0x2C, 0x47, 0xEB, 0xF1, 0x33, 0x7D, 0x64, 0xE4, 0xAB, 0x71, 0xFE, 0x61, 0xBB, - 0x8A, 0xB2, 0xEE, 0x02, 0xA1, 0x4C, 0x56, 0xA5, 0x5C, 0x79, 0xAC, 0x75, 0x7D, 0x3D, - 0x02, 0xD0, 0x29, 0xEA, - ], - [ - 0x24, 0xF2, 0xA4, 0x7D, 0x59, 0x72, 0x2F, 0xD4, 0x02, 0xE8, 0x5E, 0xEF, 0x01, 0xDD, - 0x67, 0x50, 0xAD, 0xDE, 0xE1, 0x1A, 0xF4, 0x73, 0x88, 0x14, 0x71, 0x04, 0xF2, 0x9E, - 0x55, 0xC4, 0xCC, 0x3A, - ], - [ - 0xB0, 0xBD, 0x22, 0x70, 0x36, 0xDF, 0x04, 0x92, 0x2D, 0x73, 0x1B, 0xAD, 0x63, 0xAF, - 0x29, 0x51, 0x1C, 0x59, 0x36, 0x82, 0xD6, 0xE7, 0xC9, 0x4A, 0x22, 0xEE, 0xA6, 0x46, - 0x2E, 0x65, 0xA8, 0x0C, - ], - [ - 0x66, 0xAC, 0x15, 0xAF, 0x80, 0x88, 0x69, 0x05, 0x81, 0x63, 0x2B, 0x19, 0x57, 0xB3, - 0x20, 0xC5, 0x81, 0xAF, 0xD9, 0x89, 0xC3, 0x60, 0x4D, 0xB3, 0x6C, 0xCF, 0x6F, 0xFB, - 0x87, 0x5D, 0x94, 0xC2, - ], - [ - 0xEF, 0x9F, 0x14, 0xBA, 0x96, 0x6D, 0x52, 0xB6, 0x9F, 0xEE, 0xAF, 0x6C, 0xAE, 0x68, - 0x51, 0xD6, 0x3A, 0x60, 0xBF, 0x4E, 0x97, 0x36, 0xA0, 0x29, 0x8E, 0x58, 0x04, 0xD4, - 0x7E, 0xA7, 0xD2, 0x52, - ], - [ - 0x9E, 0x23, 0x7A, 0xB7, 0xF5, 0xEB, 0xA7, 0xDE, 0x94, 0x75, 0x25, 0xF0, 0xCF, 0x0A, - 0x8B, 0x5D, 0x2C, 0x7A, 0xC5, 0x21, 0x4D, 0xB3, 0x5A, 0x2D, 0xBA, 0xCA, 0x8C, 0x6E, - 0xCA, 0x24, 0x33, 0xC6, - ], - [ - 0x92, 0x5C, 0x2C, 0x6A, 0x89, 0x02, 0x04, 0xA0, 0xB3, 0x08, 0xDB, 0x0C, 0x55, 0x54, - 0xF7, 0xDC, 0x6C, 0xF9, 0x6F, 0x06, 0xC6, 0x6D, 0x56, 0xD8, 0xA2, 0xEB, 0x17, 0xF8, - 0xBD, 0xCD, 0x26, 0x0C, - ], - [ - 0xD3, 0xB0, 0x44, 0x3D, 0x9A, 0xDB, 0x10, 0xD4, 0x70, 0xEE, 0x72, 0x15, 0x0E, 0x8B, - 0x34, 0x3F, 0xF2, 0x84, 0x40, 0x2F, 0x31, 0xF5, 0x37, 0x0A, 0x88, 0x7D, 0xDF, 0x28, - 0xF3, 0x13, 0xD3, 0xEC, - ], - [ - 0xB3, 0xB3, 0xBD, 0x3A, 0x71, 0x6C, 0x66, 0x55, 0x36, 0x73, 0x17, 0x65, 0x39, 0x82, - 0x85, 0x3B, 0xA2, 0x2C, 0xB5, 0xF9, 0x8A, 0x65, 0x9E, 0xF3, 0x8E, 0x77, 0x02, 0x6E, - 0x13, 0xA4, 0xB2, 0x73, - ], - [ - 0x3C, 0x11, 0xAE, 0x67, 0xF5, 0x80, 0xC0, 0x4E, 0x6F, 0xC0, 0x03, 0x9B, 0x2A, 0xD0, - 0xEC, 0x4E, 0x4A, 0x38, 0x3F, 0xC3, 0x62, 0x3B, 0x9A, 0xAE, 0x54, 0x08, 0x63, 0xE0, - 0xBE, 0x4D, 0x5C, 0x21, - ], - [ - 0x0A, 0x60, 0x74, 0x8E, 0xE2, 0x37, 0x24, 0x81, 0x2C, 0xBC, 0x13, 0xA0, 0xBA, 0xF1, - 0x33, 0x4B, 0xFD, 0xE1, 0x1B, 0x23, 0x07, 0x6D, 0x5B, 0x1A, 0x38, 0xD6, 0x09, 0x98, - 0xDB, 0x65, 0x0C, 0x75, - ], - [ - 0xFC, 0xB5, 0x46, 0x72, 0xE3, 0xBC, 0x2B, 0xAD, 0xA1, 0xAF, 0x1F, 0x36, 0x1C, 0x6E, - 0x62, 0x06, 0x41, 0x62, 0x8C, 0x1C, 0x7A, 0x1F, 0x5B, 0x8B, 0x8F, 0x85, 0xA2, 0x00, - 0x99, 0x32, 0xBD, 0x41, - ], - [ - 0x19, 0xEE, 0x3D, 0x28, 0x51, 0x27, 0xAE, 0xFA, 0xF7, 0x60, 0xBC, 0x10, 0x42, 0x14, - 0x7C, 0x67, 0x4E, 0x6A, 0x47, 0x47, 0xA7, 0x9F, 0x4E, 0xC3, 0xB2, 0x1C, 0xE4, 0x6C, - 0x02, 0x5E, 0x89, 0x9C, - ], - [ - 0xB8, 0xD9, 0x6C, 0xDE, 0xA1, 0x88, 0x53, 0xC2, 0xD5, 0xFA, 0x01, 0x9F, 0x12, 0xD6, - 0xFD, 0xF5, 0x48, 0xAA, 0x0B, 0xF4, 0x8D, 0xBC, 0x0F, 0x5B, 0x13, 0x24, 0x52, 0x24, - 0x10, 0x72, 0xE6, 0x0C, - ], - [ - 0x94, 0x40, 0x9B, 0x3C, 0x0F, 0x21, 0xDF, 0x96, 0x91, 0x59, 0x29, 0xE6, 0xC8, 0xFC, - 0xC2, 0x07, 0xC9, 0x58, 0x44, 0xA7, 0xED, 0xF5, 0x20, 0x22, 0xE6, 0x5E, 0x8F, 0x93, - 0xC9, 0xC9, 0x51, 0xBF, - ], - [ - 0x7A, 0x85, 0x40, 0x71, 0xD6, 0x4A, 0x4B, 0x58, 0x8D, 0xD6, 0x20, 0xDF, 0x7F, 0xB1, - 0x34, 0x58, 0xD8, 0x8C, 0x5A, 0x6B, 0x55, 0xB0, 0x75, 0xCD, 0x6A, 0x52, 0x8F, 0x9D, - 0xB0, 0x08, 0xED, 0xC6, - ], - [ - 0x4E, 0xC4, 0x9B, 0x94, 0xC3, 0x5A, 0x63, 0xC6, 0xD9, 0xDC, 0x45, 0x41, 0xF6, 0x30, - 0x55, 0x10, 0x9E, 0x91, 0x00, 0x05, 0x2C, 0xDA, 0x94, 0x6D, 0xB3, 0x76, 0xD1, 0x44, - 0xFA, 0x44, 0xD7, 0xD3, - ], - [ - 0x00, 0x74, 0xC8, 0x8F, 0x71, 0x48, 0x6A, 0x2C, 0xEC, 0x90, 0x97, 0x05, 0x77, 0x9A, - 0x26, 0x9E, 0x42, 0xBB, 0x08, 0x97, 0x89, 0xAE, 0xE2, 0xB6, 0x6C, 0x58, 0x4F, 0x4E, - 0xE3, 0x51, 0x39, 0xA5, - ], - ]; - - #[test] - fn test_verify_and_insert() { - let mut tree = SparseMerkleTree::new(); - let value = [42; HASH_SIZE]; - for key in SAMPLES { - let non_inclusion = tree.generate_non_inclusion_proof(key).unwrap(); - - // Insert should succeed for a new key - assert!(tree.insert(key, value).is_ok()); - - assert_eq!( - tree.root(), - non_inclusion.verify_and_insert(value).unwrap(), - "Roots deviate" - ); - } - } - - #[test] - fn test_verify_and_insert_sibling() { - let mut tree = SparseMerkleTree::new(); - let value = [42; HASH_SIZE]; - for key in SAMPLES { - let mut sibling_key = key; - // Flip least significant bit - sibling_key[31] ^= 1; - - // Insert should succeed for a new key - assert!(tree.insert(sibling_key, value).is_ok()); - - let non_inclusion = tree.generate_non_inclusion_proof(key).unwrap(); - - assert!(tree.insert(key, value).is_ok()); - - assert_eq!( - tree.root(), - non_inclusion.verify_and_insert(value).unwrap(), - "Roots deviate" - ); - } - } - - #[test] - fn test_insert_new_key() { - let mut tree = SparseMerkleTree::new(); - let value = [42; HASH_SIZE]; - for key in SAMPLES { - // Insert should succeed for a new key - assert!(tree.insert(key, value).is_ok()); - - // The key should now exist in the tree - assert!(tree.nodes.contains_key(&(TREE_DEPTH, key))); - } - } - - #[test] - fn test_insert_existing_key() { - let mut tree = SparseMerkleTree::new(); - let value = [42; HASH_SIZE]; - for key in SAMPLES { - // First insertion should succeed - assert!(tree.insert(key, value).is_ok()); - - // Second insertion with the same key should fail - assert!(tree.insert(key, [99; HASH_SIZE]).is_err()); - - // The original value should still be in the tree - - // Hash leaf with key - let leaf_hash = hash_concat(&value, &key); - assert_eq!(tree.nodes.get(&(TREE_DEPTH, key)), Some(&leaf_hash)); - } - } - - #[test] - fn test_root_changes_after_insert() { - let mut tree = SparseMerkleTree::new(); - let value = [42; HASH_SIZE]; - for key in SAMPLES { - // Get the initial root - let initial_root = tree.root(); - - // Insert a key - assert!(tree.insert(key, value).is_ok()); - - // Root should have changed - assert_ne!(tree.root(), initial_root); - } - } - - #[test] - fn test_multiple_inserts() { - let mut tree = SparseMerkleTree::new(); - - // Insert multiple keys - for (value, key) in SAMPLES.into_iter().enumerate() { - assert!(tree.insert(key, [value as u8; HASH_SIZE]).is_ok()); - } - - // Verify all keys exist - for key in SAMPLES { - let leaf_key = trim_key(&key, TREE_DEPTH); - assert!(tree.nodes.contains_key(&(TREE_DEPTH, leaf_key))); - } - - // Try to insert an existing key - for existing_key in SAMPLES { - assert!(tree.insert(existing_key, [99; HASH_SIZE]).is_err()); - } - } - - #[test] - fn test_get_value() { - let mut tree = SparseMerkleTree::new(); - let value = [45; HASH_SIZE]; - for key in SAMPLES { - // Insert the key-value pair - assert!(tree.insert(key, value).is_ok()); - - // Get the value back - assert_eq!(tree.get(&key).unwrap(), value); - - // Try to get a non-existent key - let non_existent_key = [10; 32]; - assert!(tree.get(&non_existent_key).is_none()); - } - } - - #[test] - fn test_multiple_values() { - let mut tree = SparseMerkleTree::new(); - - // Insert multiple key-value pairs - for (value, key) in SAMPLES.into_iter().enumerate() { - assert!(tree.insert(key, [value as u8; HASH_SIZE]).is_ok()); - } - - // Retrieve each value - for (value, key) in SAMPLES.into_iter().enumerate() { - let expected = [value as u8; HASH_SIZE]; - assert_eq!(tree.get(&key).unwrap(), expected); - } - } - - #[test] - fn test_verify_inclusion_proofs() { - // Create a new tree - let mut tree = SparseMerkleTree::new(); - - for (value, key) in SAMPLES.into_iter().enumerate() { - // Test non-existent key - assert!( - tree.generate_inclusion_proof(&key).is_err(), - "Proof for non-existent key should fail" - ); - // Insert key and test proof - match tree.insert(key, [value as u8; HASH_SIZE]) { - Ok(_) => { - let (proof, commitment) = tree.generate_inclusion_proof(&key).unwrap(); - assert!(proof.verify(commitment, tree.root())); - } - Err(e) => panic!("Failed to insert key {:02X?}: {}", key, e), - } - } - } - - #[test] - fn test_verify_non_inclusion_proofs() { - // Create a new tree - let mut tree = SparseMerkleTree::new(); - - for (value, key) in SAMPLES.into_iter().enumerate() { - // Test non-inclusion proof - let proof = tree.generate_non_inclusion_proof(key).unwrap(); - assert_eq!(proof.root, tree.root()); - assert!(proof.verify()); - - // Insert key for next iteration - tree.insert(key, [value as u8; HASH_SIZE]).unwrap(); - } - } -} diff --git a/rust-toolchain b/rust-toolchain index d9143e67..95608591 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1,3 +1,3 @@ [toolchain] -channel = "1.81.0" -components = ["llvm-tools", "rustc-dev"] \ No newline at end of file +channel = "nightly" +components = ["llvm-tools", "rustc-dev", "rustfmt", "clippy"] diff --git a/script-plonky2/CONTRIBUTING.md b/script-plonky2/CONTRIBUTING.md new file mode 100644 index 00000000..3d29a413 --- /dev/null +++ b/script-plonky2/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# script-plonky2 — host-side Plonky2 prover wrapper + +Companion crate to `program-plonky2/` providing a high-level +[`Prover`] struct around the low-level +`zkcoins_program_plonky2::circuit::main::prove_*` API. Mirrors the +shape of the SP1-era `script/` crate so server-side integration +follows the same pattern. + +## Why a separate crate? + +Two reasons: + +1. **Toolchain isolation.** Plonky2 requires nightly Rust + (`feature(specialization)`). Both `program-plonky2/` and + `script-plonky2/` use a shared nightly toolchain via the + `rust-toolchain.toml` symlink. The parent stable workspace + (server, SP1-era crates) cannot directly depend on either. +2. **Separation of concerns.** `program-plonky2/` builds the cyclic + state-transition circuit and exposes the raw `prove_*` / + `verify` APIs. `script-plonky2/` wraps them in a `Prover` that + owns the built circuit, so successive proofs amortise the build + cost. Server code wires against the `Prover` API. + +## How to call this from the stable workspace + +Two options for the upcoming step-7 server replacement: + +- **Option A: subprocess boundary.** Add a `[[bin]]` target to + `script-plonky2/` that takes JSON input on stdin and emits proof + bytes on stdout. The stable-workspace `server/` crate spawns it via + `tokio::process`. Keeps toolchain isolation but pays IPC overhead + per proof (~10–100 ms serialisation, negligible against ~5–15 min + proof time). +- **Option B: workspace consolidation.** Migrate the entire + workspace to the same nightly toolchain `program-plonky2/` uses, + then include `script-plonky2/` in `workspace.members` and depend + directly. Simpler call path but couples the whole workspace's + toolchain to Plonky2's requirements. + +The step-7 ROADMAP entry will pick one and document the choice. + +## Test runtime + +The single smoke test (`prover_init_roundtrip`) is flagged +`#[ignore]` because it builds the cyclic circuit (~10 s) + proves an +empty Init transition (~3–15 min wall at production parameters). +Run explicitly: + +```bash +cargo test --release prover_init_roundtrip -- --ignored --nocapture +``` + +The smoke test exists to prove the wrapper compiles + threads the +underlying APIs end-to-end. The hard correctness coverage lives in +`program-plonky2/`'s 100+ tests. + +## What's NOT in this crate + +- Off-circuit hash / SMT / MMR / account-state logic — those live in + `program-plonky2/src/{hash,merkle,types}.rs`. Re-export from there + rather than duplicating. +- The `ProgramInputs` builder that the SP1-era `script/` crate uses. + Plonky2's cyclic recursion threads its inputs slot-by-slot + (`InCoinSlotTargets` / `OutCoinSlotTargets` per-slot witnesses) + instead of the SP1-era batched `ProgramInputs`. The server can + construct slot tuples directly without an intermediate builder. +- CLI / RPC plumbing for Option A above. Add a `[[bin]]` target if + the step-7 ROADMAP entry picks subprocess boundary. diff --git a/script-plonky2/Cargo.lock b/script-plonky2/Cargo.lock new file mode 100644 index 00000000..37639d9f --- /dev/null +++ b/script-plonky2/Cargo.lock @@ -0,0 +1,661 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "fixed-hash" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "rayon", + "serde", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2bd4c29270e724d3eaadf7bdc8700af4221fc0ed771b855eadcd1b98d52851" +dependencies = [ + "primitive-types", + "tiny-keccak", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plonky2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b512f56329cfde01b7b5c49f092145ae4fbdbb9cd8742f57879315ed7a893d65" +dependencies = [ + "ahash", + "anyhow", + "getrandom", + "hashbrown", + "itertools", + "keccak-hash", + "log", + "num", + "plonky2_field", + "plonky2_maybe_rayon", + "plonky2_util", + "rand", + "rand_chacha", + "serde", + "static_assertions", + "unroll", + "web-time", +] + +[[package]] +name = "plonky2_field" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ddfe8817d0c5c2d4557979c51c5253dab1b555ecc19833e5b7ea7dd86f39b8" +dependencies = [ + "anyhow", + "itertools", + "num", + "plonky2_util", + "rand", + "serde", + "static_assertions", + "unroll", +] + +[[package]] +name = "plonky2_maybe_rayon" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1e554181dc95243b8d9948ae7bae5759c7fb2502fed28f671f95ef38079406" +dependencies = [ + "rayon", +] + +[[package]] +name = "plonky2_util" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c32c137808ca984ab2458b612b7eb0462d853ee041a3136e83d54b96074c7610" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373" +dependencies = [ + "fixed-hash", + "uint", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unroll" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad948c1cb799b1a70f836077721a92a35ac177d4daddf4c20a633786d4cf618" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zkcoins-program-plonky2" +version = "0.0.1" +dependencies = [ + "anyhow", + "bincode", + "plonky2", + "serde", +] + +[[package]] +name = "zkcoins-prover-plonky2" +version = "0.0.1" +dependencies = [ + "anyhow", + "plonky2", + "zkcoins-program-plonky2", +] diff --git a/script-plonky2/Cargo.toml b/script-plonky2/Cargo.toml new file mode 100644 index 00000000..1294fce4 --- /dev/null +++ b/script-plonky2/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zkcoins-prover-plonky2" +version = "0.0.1" +edition = "2021" + +[dependencies] +zkcoins-program-plonky2 = { path = "../program-plonky2" } +plonky2 = "1.1.0" +anyhow = "1.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage_nightly)"] } diff --git a/script-plonky2/src/lib.rs b/script-plonky2/src/lib.rs new file mode 100644 index 00000000..c8d100fa --- /dev/null +++ b/script-plonky2/src/lib.rs @@ -0,0 +1,307 @@ +//! High-level host-side prover wrapper for the Plonky2 state-transition +//! circuit. Companion to the SP1-era `script/` crate. +//! +//! ## Architecture +//! +//! - [`Prover`] owns the heavy `StateTransitionCircuit` build (one +//! per process — typically created at server startup). +//! - [`Prover::prove_initial`] / [`Prover::prove_account_update`] are +//! thin convenience wrappers over the low-level +//! [`zkcoins_program_plonky2::circuit::main`] APIs that thread +//! through the common Init/Update arguments without re-exposing +//! slot-witness construction. +//! - [`Prover::verify`] runs both the circuit-data verification AND +//! the cyclic-verifier-data digest cross-check that +//! [`zkcoins_program_plonky2::circuit::main::verify`] performs +//! internally. +//! +//! ## Toolchain +//! +//! This crate inherits its nightly toolchain from +//! [`program-plonky2/rust-toolchain.toml`](../program-plonky2/rust-toolchain.toml) +//! via a symlink — Plonky2 requires `feature(specialization)`. +//! Callers from stable-toolchain crates (e.g. the SP1-era `server/` +//! crate) must invoke this via a subprocess boundary (a `[[bin]]` +//! target ships in a future iteration). + +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +use anyhow::Result; +use plonky2::plonk::proof::ProofWithPublicInputs; + +use zkcoins_program_plonky2::circuit::main::{ + build_circuit, prove_account_update, prove_account_update_with_in_and_out_coins, + prove_account_update_with_in_and_out_coins_and_sources, prove_account_update_with_in_coins, + prove_initial, prove_initial_with_in_and_out_coins, + prove_initial_with_in_and_out_coins_and_sources, prove_initial_with_in_coins, verify, + StateTransitionCircuit, +}; +use zkcoins_program_plonky2::hash::HashDigest; +use zkcoins_program_plonky2::inputs::CommitmentMerkleProofs; +use zkcoins_program_plonky2::merkle::sparse_merkle_tree::NonInclusionProof; +use zkcoins_program_plonky2::types::{AccountState, Coin, PublicKey}; +use zkcoins_program_plonky2::{C, D, F}; + +// Re-export so server callers don't have to depend on +// `zkcoins-program-plonky2` directly for the source-witness type. +pub use zkcoins_program_plonky2::circuit::main::InCoinSourceWitness; + +/// Type alias: a single state-transition proof carrying the +/// `ProofData` public inputs plus the cyclic verifier-data digest. +pub type Proof = ProofWithPublicInputs; + +/// Host-side prover. Owns the built state-transition circuit +/// (proving + verification keys, common data) so that successive +/// `prove_*` calls amortise the ~10 s build cost. +/// +/// The circuit is cyclic — its `verifier_data.circuit_digest` is +/// pinned in every proof's public inputs, enforcing that all proofs +/// the server emits are verifiable by the SAME circuit instance. +pub struct Prover { + pub circuit: StateTransitionCircuit, +} + +impl Default for Prover { + fn default() -> Self { + Self::new() + } +} + +impl Prover { + /// Build the state-transition circuit. Expensive (~10 s wall on + /// the M3 Ultra at production parameters: `MAX_IN_COINS` = + /// `MAX_OUT_COINS` = 8, `INNER_PAD_BITS_STAGE_5D_NEXT_5 = 15` + /// — Phase 2b outer at degree 16). Call once per process and + /// share via `Arc` across request handlers; the + /// fixed-point loop that converges aggregator + outer common + /// inside `build_circuit` runs on each instantiation. + pub fn new() -> Self { + Self { + circuit: build_circuit(), + } + } + + /// Prove an Initial-branch state transition with all in-coin + /// slots inactive and no out-coins. + pub fn prove_initial( + &self, + account_state: &AccountState, + history_root: HashDigest, + ) -> Result { + prove_initial(&self.circuit, account_state, history_root) + } + + /// Prove an Initial-branch transition with caller-supplied + /// in-coin slot witnesses. Each tuple is + /// `(active, &coin, &non_inclusion_proof)`. The caller MUST + /// supply exactly `MAX_IN_COINS` tuples. + /// + /// Delegates through to the `_and_sources` core with all-`None` + /// sources — only suitable for transitions whose `in_coins` are + /// ALL inactive. Active in-coin slots require the + /// [`Self::prove_initial_with_in_and_out_coins_and_sources`] + /// variant. + pub fn prove_initial_with_in_coins( + &self, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + ) -> Result { + prove_initial_with_in_coins(&self.circuit, account_state, history_root, in_coins) + } + + /// Full-control Initial-branch prove: in-coin tuples, out-coin + /// tuples, and explicit `next_public_key` rotation. Each + /// `out_coins` tuple is + /// `(active, out_coin_identifier, amount, &non_inclusion_proof)`. + /// Delegates to the `_and_sources` variant with all-`None` + /// sources — only suitable for transitions whose `in_coins` are + /// ALL inactive. Active in-coin slots require the + /// [`Self::prove_initial_with_in_and_out_coins_and_sources`] + /// variant. + pub fn prove_initial_with_in_and_out_coins( + &self, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + ) -> Result { + prove_initial_with_in_and_out_coins( + &self.circuit, + account_state, + history_root, + in_coins, + out_coins, + next_public_key, + ) + } + + /// Prove an AccountUpdate transition consuming `prev` as the + /// recursive inner proof, with all in-coin slots inactive. + pub fn prove_account_update( + &self, + account_state: &AccountState, + history_root: HashDigest, + prev: &Proof, + cmp: &CommitmentMerkleProofs, + ) -> Result { + prove_account_update(&self.circuit, account_state, history_root, prev, cmp) + } + + /// Prove an AccountUpdate transition with caller-supplied + /// in-coin slot witnesses. + /// + /// Delegates through to the `_and_sources` core with all-`None` + /// sources — only suitable for transitions whose `in_coins` are + /// ALL inactive. Active in-coin slots require the + /// [`Self::prove_account_update_with_in_and_out_coins_and_sources`] + /// variant. + pub fn prove_account_update_with_in_coins( + &self, + account_state: &AccountState, + history_root: HashDigest, + prev: &Proof, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + ) -> Result { + prove_account_update_with_in_coins( + &self.circuit, + account_state, + history_root, + prev, + cmp, + in_coins, + ) + } + + /// Full-control AccountUpdate prove: in-coin tuples, out-coin + /// tuples, and explicit `next_public_key` rotation. Delegates to + /// the `_and_sources` variant with all-`None` sources — only + /// suitable for transitions whose `in_coins` are ALL inactive. + /// Active in-coin slots require the + /// [`Self::prove_account_update_with_in_and_out_coins_and_sources`] + /// variant. + #[allow(clippy::too_many_arguments)] + pub fn prove_account_update_with_in_and_out_coins( + &self, + account_state: &AccountState, + history_root: HashDigest, + prev: &Proof, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + ) -> Result { + prove_account_update_with_in_and_out_coins( + &self.circuit, + account_state, + history_root, + prev, + cmp, + in_coins, + out_coins, + next_public_key, + ) + } + + /// Stage 5d-next-5 Phase 2b Initial-branch prove with per-slot + /// source witnesses for active in-coins. `sources.len()` must + /// equal `MAX_IN_COINS`; `Some(_)` ↔ active source proof, + /// `None` ↔ inactive slot. + #[allow(clippy::too_many_arguments)] + pub fn prove_initial_with_in_and_out_coins_and_sources( + &self, + account_state: &AccountState, + history_root: HashDigest, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + sources: &[Option], + ) -> Result { + prove_initial_with_in_and_out_coins_and_sources( + &self.circuit, + account_state, + history_root, + in_coins, + out_coins, + next_public_key, + sources, + ) + } + + /// Stage 5d-next-5 Phase 2b AccountUpdate-branch prove with + /// per-slot source witnesses for active in-coins. Symmetric + /// shape with [`Self::prove_initial_with_in_and_out_coins_and_sources`]. + #[allow(clippy::too_many_arguments)] + pub fn prove_account_update_with_in_and_out_coins_and_sources( + &self, + account_state: &AccountState, + history_root: HashDigest, + prev: &Proof, + cmp: &CommitmentMerkleProofs, + in_coins: &[(bool, &Coin, &NonInclusionProof)], + out_coins: &[(bool, HashDigest, u64, &NonInclusionProof)], + next_public_key: &PublicKey, + sources: &[Option], + ) -> Result { + prove_account_update_with_in_and_out_coins_and_sources( + &self.circuit, + account_state, + history_root, + prev, + cmp, + in_coins, + out_coins, + next_public_key, + sources, + ) + } + + /// Verify a proof against the prover's circuit. Runs both + /// `check_cyclic_proof_verifier_data` (cross-check that the + /// proof's pinned `circuit_digest` matches this circuit's own) + /// and the underlying Plonky2 `data.verify`. + pub fn verify(&self, proof: &Proof) -> Result<()> { + verify(&self.circuit, proof) + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use zkcoins_program_plonky2::types::MINTING_ADDRESS; + + fn dummy_pubkey(seed: u8) -> [u8; 33] { + let mut pk = [0u8; 33]; + pk[0] = 0x02; + for (i, b) in pk.iter_mut().enumerate().skip(1) { + *b = seed.wrapping_add(i as u8); + } + pk + } + + /// Smoke test: build a `Prover`, prove an empty Init transition, + /// verify it. Validates the wrapper compiles + threads through + /// the underlying program-plonky2 APIs end-to-end. + /// + /// Heavy (~3-15 min wall at production parameters MAX=8); flagged + /// `#[ignore]` so the routine `cargo test` sweep skips it. Run + /// explicitly via `cargo test --release prover_init_roundtrip -- + /// --ignored --nocapture`. + #[test] + #[ignore] + fn prover_init_roundtrip() { + let prover = Prover::new(); + let mut account_state = AccountState::new(dummy_pubkey(7)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 100; + + let history_root = zkcoins_program_plonky2::hash::hash_bytes(b"prover-test-history"); + let proof = prover + .prove_initial(&account_state, history_root) + .expect("prove initial"); + prover.verify(&proof).expect("verify"); + } +} diff --git a/script/Cargo.toml b/script/Cargo.toml deleted file mode 100644 index d8d5e08d..00000000 --- a/script/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "zkcoins-prover" -version = { workspace = true } -edition = { workspace = true } -publish = false - -[dependencies] -zkcoins-program = { path = "../program" } -sp1-sdk = { workspace = true } -tracing = "0.1.40" - diff --git a/script/build.rs b/script/build.rs deleted file mode 100644 index c08a6b9d..00000000 --- a/script/build.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::path::Path; - -fn main() { - let elf = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../elf/zkcoins-program") - .canonicalize() - .expect("Pre-built ELF not found at elf/zkcoins-program. Build with: cargo prove build --release -p zkcoins-program"); - - println!("cargo:rustc-env=SP1_ELF_zkcoins-program={}", elf.display()); - println!("cargo:rerun-if-changed={}", elf.display()); -} diff --git a/script/src/lib.rs b/script/src/lib.rs deleted file mode 100644 index 977953fb..00000000 --- a/script/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -use sp1_sdk::{ - include_elf, EnvProver, HashableKey, ProverClient, SP1Proof, SP1ProofWithPublicValues, - SP1ProvingKey, SP1Stdin, SP1VerifyingKey, -}; - -use zkcoins_program::ProofType; -use zkcoins_program::{ProgramInputs, ProgramInputsBuilder}; - -pub const ZKCOINS_ELF: &[u8] = include_elf!("zkcoins-program"); - -pub type Proof = SP1ProofWithPublicValues; - -pub struct Prover { - pub client: EnvProver, - pub pk: SP1ProvingKey, - pub vk: SP1VerifyingKey, -} - -impl Default for Prover { - fn default() -> Self { - Self::new() - } -} - -impl Prover { - pub fn new() -> Self { - let client = ProverClient::from_env(); - sp1_sdk::utils::setup_logger(); - let (pk, vk) = client.setup(ZKCOINS_ELF); - Prover { client, pk, vk } - } - - pub fn create_account( - &self, - program_inputs_builder: &mut ProgramInputsBuilder, - coin_proofs: Vec, - ) -> Result { - let mut stdin = SP1Stdin::new(); - let program_inputs = program_inputs_builder - .in_coin_proofs_public_values( - coin_proofs - .iter() - .map(|proof| proof.public_values.to_vec()) - .collect::>(), - ) - .proof_type(ProofType::InitialProof) - .verification_key(self.vk.vk.hash_u32()) - .build() - .map_err(|_| "didnt provide all fields")?; - - stdin.write::(&program_inputs); - - for proof in coin_proofs { - let SP1Proof::Compressed(proof) = proof.proof else { - return Err("Proof doesnt match Compressed(SP1ReduceProof)"); - }; - stdin.write_proof(*proof, self.vk.vk.clone()); - } - - tracing::info_span!("FIRST_SEND").in_scope(|| { - self.client - .prove(&self.pk, &stdin) - .compressed() - .run() - .map_err(|_| "proving failed") - }) - } - - pub fn update_account( - &self, - program_inputs_builder: &mut ProgramInputsBuilder, - account_proof: SP1ProofWithPublicValues, - coin_proofs: Vec, - ) -> Result { - let mut stdin = SP1Stdin::new(); - let program_inputs = program_inputs_builder - .in_coin_proofs_public_values( - coin_proofs - .iter() - .map(|proof| proof.public_values.to_vec()) - .collect::>(), - ) - .prev_proof_public_values(Some(account_proof.public_values.to_vec())) - .proof_type(ProofType::AccountUpdateProof) - .verification_key(self.vk.vk.hash_u32()) - .build() - .map_err(|_| "didnt provide all fields")?; - - stdin.write::(&program_inputs); - - // Write the account proof - let SP1Proof::Compressed(proof) = account_proof.proof else { - return Err("account proof doesnt match Compressed(SP1ReduceProof)"); - }; - stdin.write_proof(*proof, self.vk.vk.clone()); - - // Write coin proofs - for proof in coin_proofs { - let SP1Proof::Compressed(proof) = proof.proof else { - return Err("Coin proof doesnt match Compressed(SP1ReduceProof)"); - }; - stdin.write_proof(*proof, self.vk.vk.clone()); - } - - tracing::info_span!("UPDATE_SEND").in_scope(|| { - self.client - .prove(&self.pk, &stdin) - .compressed() - .run() - .map_err(|_| "proving failed") - }) - } -} diff --git a/scripts/ci-runner/README.md b/scripts/ci-runner/README.md new file mode 100644 index 00000000..b5343318 --- /dev/null +++ b/scripts/ci-runner/README.md @@ -0,0 +1,362 @@ +# Self-hosted GitHub Actions runners for `zk-coins/node` + +Operator-facing documentation for the self-hosted runner pool that +executes the `Node + Shared Tests (M3 Ultra)` and `Coverage Gate +(100% lines + functions)` jobs in `.github/workflows/ci.yaml`. See +issue #40 for the rationale (test + coverage gate in CI rather than +pre-push) and issue #30 for the previous design. + +## Hardware target + +A single Mac Studio M3 Ultra with 96 GB unified RAM hosts the pool +(CONTRIBUTING.md § "Working on the Plonky2 Migration", invariant 3). +**6 runner agents** share the host. The same host was previously +used as the `ZKCOINS_PREPUSH_REMOTE` target. + +The pool size was tuned against measured resource usage — see +[**Disk + RAM headroom**](#disk--ram-headroom) below for the budget +table and the rationale for the 6-agent cap. + +> **Operator convention.** All shell commands below assume an SSH +> alias `$RUNNER_HOST` resolves to the runner host on your local +> machine. Set it in `~/.ssh/config` or export it from `~/.zshenv` / +> `~/.bash_profile`. The host name itself is intentionally not +> committed to this public repo. + +## Blast-radius model + +The runner executes workflow YAML on PRs. A PR can change the workflow +file itself, so the runner is effectively trusted with arbitrary code +execution as whichever user it runs as. + +Mitigation: **the outside-collaborator approval gate** at the +repository level — *Settings → Actions → General → "Fork pull request +workflows from outside collaborators"* → *Require approval for all +outside collaborators*. Without this, anyone with a fork can run code +on the runner by opening a PR that edits the workflow. The repository +is public, so this gate is non-negotiable. + +**Current deployment** runs the runner under the host's admin +account. That account already had arbitrary-code-execution rights for +`zk-coins/node` content via the previous `ZKCOINS_PREPUSH_REMOTE` +flow, so the runner is not a regression. Migrating to a dedicated +`gh-runner` user is a defense-in-depth upgrade — see "Migrating to a +dedicated runner user" below. + +## One-time setup on the host + +Steps below were used to set up the live runner. They run as the +host's admin user. + +### 1. Verify prerequisites + +```bash +ssh "$RUNNER_HOST" 'export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:$PATH"; which rsync rustc cargo cargo-llvm-cov brew jq' +``` + +Expected: GNU `rsync` (Homebrew, **not** the macOS-bundled +`openrsync`), `rustup`-managed `rustc`/`cargo`, `cargo-llvm-cov`, +`brew`, and `jq`. If any are missing, run the bootstrap script: + +```bash +ssh "$RUNNER_HOST" 'bash -lc "$(curl -fsSL https://raw.githubusercontent.com/zk-coins/node/develop/scripts/ci-runner/bootstrap-prerequisites.sh)"' +``` + +The repo pins `rust-toolchain` so `cargo` will auto-fetch the right +channel on first invocation. + +### 2. Register the runner with GitHub + +GitHub requires a short-lived registration token (expires in ~1 hour). +Generate via the REST API: + +```bash +gh api -X POST repos/zk-coins/node/actions/runners/registration-token | jq -r .token +``` + +Or via *Settings → Actions → Runners → New self-hosted runner → +macOS / ARM64* in the UI. + +```bash +RUNNER_TOKEN=... # paste the token from above +ssh "$RUNNER_HOST" "bash -lc ' + set -euo pipefail + mkdir -p ~/actions-runner-zkcoins-node && cd ~/actions-runner-zkcoins-node + + RUNNER_VERSION=\$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | sed s/^v//) + if [ ! -f config.sh ]; then + curl -fsSL -o runner.tar.gz \ + \"https://github.com/actions/runner/releases/download/v\${RUNNER_VERSION}/actions-runner-osx-arm64-\${RUNNER_VERSION}.tar.gz\" + tar xzf runner.tar.gz + rm runner.tar.gz + fi + + ./config.sh \ + --unattended \ + --url https://github.com/zk-coins/node \ + --token ${RUNNER_TOKEN} \ + --name \"\$(hostname -s)\" \ + --labels self-hosted,macOS,ARM64,m3-ultra,zkcoins-prover \ + --work _work \ + --replace +'" +``` + +### 3. Install + start the launchd service + +`svc.sh` is generated by `config.sh` and installs a LaunchAgent under +`~/Library/LaunchAgents/actions.runner.zk-coins-node..plist` +(where `` is whatever you passed to `--name` above — +`hostname -s` by default). + +```bash +ssh "$RUNNER_HOST" 'cd ~/actions-runner-zkcoins-node && ./svc.sh install && ./svc.sh start && ./svc.sh status' +``` + +### 4. Enable the outside-collaborator approval gate + +In the GitHub UI: **Settings → Actions → General → "Fork pull request +workflows from outside collaborators"** → *Require approval for all +outside collaborators*. This is the one setting not currently exposed +by the REST API — flip it in the UI. + +## Scaling out: adding more runner agents on the same host + +The host runs multiple runner agents under the same user account, one +per directory + launchd plist. Pool today: **6 agents** named `dfx01`, +`dfx01-2`, …, `dfx01-6`, all carrying the same labels. Adding another +follows the one-time setup with a different `--name` and a unique +directory. + +```bash +# Pick the next free index. Current pool tops out at 6. +NEW_IDX=7 +NEW_NAME="dfx01-${NEW_IDX}" +NEW_DIR="actions-runner-zk-coins-node-${NEW_IDX}" # recommended naming + # for fresh installs + +TOKEN=$(gh api -X POST repos/zk-coins/node/actions/runners/registration-token | jq -r .token) + +ssh "$RUNNER_HOST" "bash -lc ' + set -euo pipefail + mkdir -p ~/${NEW_DIR} && cd ~/${NEW_DIR} + + if [ ! -f config.sh ]; then + RUNNER_VERSION=\$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | sed s/^v//) + TARBALL=actions-runner-osx-arm64-\${RUNNER_VERSION}.tar.gz + # Reuse a cached tarball if a prior add-runner run left one in /tmp. + [ -f /tmp/\${TARBALL} ] || curl -fsSL -o /tmp/\${TARBALL} \ + \"https://github.com/actions/runner/releases/download/v\${RUNNER_VERSION}/\${TARBALL}\" + tar xzf /tmp/\${TARBALL} + fi + + ./config.sh --unattended \ + --url https://github.com/zk-coins/node \ + --token ${TOKEN} \ + --name \"${NEW_NAME}\" \ + --labels self-hosted,macOS,ARM64,m3-ultra,zkcoins-prover \ + --work _work \ + --replace + ./svc.sh install + ./svc.sh start +'" +``` + +Before adding agents, re-measure against the [**Disk + RAM +headroom**](#disk--ram-headroom) budget below. Adding agents beyond +what the host can sustain causes swap pressure and slows every +concurrent job. + +The registration token is good for ~1 hour and can register multiple +runners back-to-back. Looping a `for` over multiple `--name` values +with `set -o pipefail` will SIGPIPE-abort if you also pipe `svc.sh +status` through `head` — drop the pipe or wrap with `set +e`. + +> **Naming drift (2026-05-25):** the live agents `dfx01`, `dfx01-2`, +> `dfx01-3` predate the `zk-coins/server` → `zk-coins/node` rename +> and live under `~/actions-runner-zkcoins-server` / +> `~/actions-runner-zk-coins-server-{2,3}`. `dfx01-4`/`-5`/`-6` were +> added after the rename but still in `~/actions-runner-zk-coins-server-{4,5,6}` +> for naming consistency with their siblings. New runners should use +> `actions-runner-zk-coins-node-N`; clean-up of legacy paths happens +> bundled with a re-register cycle. The substantive runner identity +> (name + labels) is what GitHub routes against, not the directory +> name, so jobs work regardless. + +## Migrating to a dedicated runner user + +When the operational pressure allows, swap the host user under which +the agents run from the admin account to a fresh `gh-runner` account +with no console login and no other repo access. With the 6-agent pool +this means migrating each agent in turn; the pool can stay online +during the migration (each agent goes offline only briefly while it +moves users). Procedure: + +```bash +# 1. Create the user (requires sudo). +sudo dscl . -create /Users/gh-runner +sudo dscl . -create /Users/gh-runner UserShell /bin/zsh +sudo dscl . -create /Users/gh-runner RealName "GitHub Actions Runner" +sudo dscl . -create /Users/gh-runner UniqueID 600 +sudo dscl . -create /Users/gh-runner PrimaryGroupID 20 +sudo dscl . -create /Users/gh-runner NFSHomeDirectory /Users/gh-runner +sudo mkdir -p /Users/gh-runner +sudo chown gh-runner:staff /Users/gh-runner + +# 2. Install prerequisites for the new user. +sudo -iu gh-runner bash -lc ' + bash <(curl -fsSL https://raw.githubusercontent.com/zk-coins/node/develop/scripts/ci-runner/bootstrap-prerequisites.sh) +' + +# 3. For each agent in the pool: stop + uninstall it under the admin +# user, then re-register it as gh-runner using the "Scaling out" +# snippet above (substitute the existing agent name, e.g. dfx01-2). +ssh "$RUNNER_HOST" "cd ~/${RUNNER_DIR} && ./svc.sh stop && ./svc.sh uninstall && ./config.sh remove --token PASTE_REMOVAL_TOKEN" + +# 4. Repeat the "Register" + "Install + start" steps above as +# gh-runner, once per agent. +``` + +## Verifying the pool is online + +From any machine with `gh` configured: + +```bash +gh api repos/zk-coins/node/actions/runners \ + | jq '.runners | sort_by(.name) | map({name, status, busy, labels: [.labels[].name]})' +``` + +Healthy pool: 6 entries, every one reports `"status": "online"` and +carries the labels `self-hosted, macOS, ARM64, m3-ultra, +zkcoins-prover`. + +## Activating the CI jobs (historical — done) + +This section describes the rollout sequence used when the workflow +landed before the first runner existed. It is kept as a reference +for future runner additions; the current jobs are already active. + +The `node-tests` (originally `server-tests`) and `coverage` jobs in +`.github/workflows/ci.yaml` were originally gated behind `if: false` +so the workflow YAML could land before the runner came online. After +the first runner was verified, both gates were removed (PR #43). + +Branch protection on `main` requires the full Heavy gate: + +- `Lint & Build` +- `Node + Shared Tests (M3 Ultra)` +- `Coverage Gate (100% lines + functions)` +- `Build and deploy to DEV` + +`develop` requires only `Lint & Build` — the Heavy gate is enforced +at the Release-PR boundary (`develop → main`) via the auto-applied +`ci:full` label on the Release PR (see `auto-release-pr.yaml`). The +required-check name on `main` was renamed `Server + Shared Tests` +→ `Node + Shared Tests` together with the workflow job rename. + +## Operations + +All snippets below operate on a single agent. Set `RUNNER_DIR` to the +agent's directory before running them — the pool has different +directory names per agent (see [**Scaling out**](#scaling-out-adding-more-runner-agents-on-the-same-host) +for the naming-drift note): + +```bash +# Examples: +RUNNER_DIR=actions-runner-zkcoins-server # dfx01 (legacy) +RUNNER_DIR=actions-runner-zk-coins-server-2 # dfx01-2 (legacy) +RUNNER_DIR=actions-runner-zk-coins-server-6 # dfx01-6 (post-rename) +``` + +To act on every agent in the pool, loop: + +```bash +ssh "$RUNNER_HOST" 'ls -d ~/actions-runner-* | xargs -I{} bash -lc "echo === {}; cd {} && ./svc.sh status | head -2"' +``` + +### Updating the runner binary + +GitHub deprecates old runner versions about every 6 months. The +launchd service auto-updates the runner binary unless `config.sh +--disableupdate` was used. Check the version of one agent: + +```bash +ssh "$RUNNER_HOST" "jq -r .version < ~/${RUNNER_DIR}/.runner" +``` + +### Restarting / stopping a single agent + +```bash +ssh "$RUNNER_HOST" "cd ~/${RUNNER_DIR} && ./svc.sh stop" +ssh "$RUNNER_HOST" "cd ~/${RUNNER_DIR} && ./svc.sh start" +ssh "$RUNNER_HOST" "cd ~/${RUNNER_DIR} && ./svc.sh status" +``` + +### Removing an agent + +```bash +# Generate a *removal* token (different from the registration token): +REMOVAL_TOKEN=$(gh api -X POST repos/zk-coins/node/actions/runners/remove-token | jq -r .token) +ssh "$RUNNER_HOST" "cd ~/${RUNNER_DIR} && ./svc.sh stop && ./svc.sh uninstall && ./config.sh remove --token ${REMOVAL_TOKEN}" +``` + +### Workspace cache + +Each agent re-uses its own `~/${RUNNER_DIR}/_work/node/node/` across +jobs, so cargo's incremental build cache persists per agent. This is +the documented "shared `target/` directory across jobs" trade-off in +issue #40: fast incremental builds, but stale state can occasionally +poison a green-to-red flip. If a single agent reproduces an +unexplained failure that disappears on rerun, nuke just that agent's +cache: + +```bash +ssh "$RUNNER_HOST" "rm -rf ~/${RUNNER_DIR}/_work/node/node/target" +``` + +To clear all agents (rare — use only when a workspace-wide invariant +is suspected): + +```bash +ssh "$RUNNER_HOST" 'for d in ~/actions-runner-*; do rm -rf "$d/_work/node/node/target"; done' +``` + +### Disk + RAM headroom + +Each PR exercises 2 Heavy jobs (`node-tests` + `coverage`), so the +6-agent pool saturates at 3 concurrent PRs (3 PRs × 2 jobs = 6 +agents). The snapshot below was captured on 2026-05-25 with 3 Heavy +jobs running concurrently (the pre-expansion 3-runner topology); the +saturated-forecast column projects linearly to 6 jobs, except App +memory which clamps at the host's 96 GB ceiling as inactive cache +pages get reclaimed under pressure: + +| Metric | 3 jobs running (snapshot 2026-05-25) | 6 jobs running (saturated, forecast) | +|----------------------------------------------|--------------------------------------|--------------------------------------| +| `cargo` test process RSS | ~14 GB total | ~29 GB total | +| App memory (RSS + reclaimable cache) | ~85 GB peak | ~95 GB peak (cache-bound) | +| Swap used | 0 MB | 0 MB expected | +| Active test processes (`--test-threads 1`) | 3 | 6 (saturated) | +| sccache cache (host-wide, shared) | 50 GiB cap, ~3 GiB live | 50 GiB cap | + +Tests run with `--test-threads 1`, so each agent has one in-flight +test process at a time. A 4th PR carrying `ci:full` queues until an +agent frees up. + +The `sccache` cache is host-wide, shared by every agent +(`~/Library/Caches/Mozilla.sccache`). `ci.yaml` sets +`SCCACHE_CACHE_SIZE=50G` and restarts the sccache server when the +running cap differs — this avoids the eviction thrashing the 10-GiB +default caused with 3+ concurrent agents. + +Per-agent `target/` directories grow fast (~30-50 GB each). With 6 +agents × 50 GB that is ~300 GB of cache on a 1 TB host. Run +`cargo clean` per agent (or wipe the workspace as above) if disk +pressure becomes an issue. + +## Tracking + +Each agent is a launchd service on the host, not a Docker container, +so the pool does not fit the `status-server.py` container-tracking +convention. Track agents via the GitHub UI runner page instead, or +`gh api repos/zk-coins/node/actions/runners`. diff --git a/scripts/ci-runner/bootstrap-prerequisites.sh b/scripts/ci-runner/bootstrap-prerequisites.sh new file mode 100755 index 00000000..bb4e92f8 --- /dev/null +++ b/scripts/ci-runner/bootstrap-prerequisites.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Bootstrap prerequisites for the self-hosted GitHub Actions runner +# user (`gh-runner`) on a Mac Studio M3 Ultra. Idempotent — safe to +# re-run. +# +# Installs (all into the calling user's $HOME, no sudo): +# - Homebrew (prefix ~/homebrew) — for GNU rsync only; the system +# `openrsync` at /usr/bin/rsync lacks --mkpath and other flags +# used by older workflows. +# - rustup with the toolchain pinned by the repo's rust-toolchain +# file, plus components rustfmt, clippy, llvm-tools-preview. +# - cargo-llvm-cov for the coverage gate. +# +# This script does NOT register the runner with GitHub — that requires +# a short-lived token from the GH UI. See scripts/ci-runner/README.md. +set -euo pipefail + +log() { printf '[bootstrap] %s\n' "$*"; } + +if [ "$(uname -s)" != "Darwin" ] || [ "$(uname -m)" != "arm64" ]; then + echo "bootstrap-prerequisites.sh expects macOS / arm64; got $(uname -s) / $(uname -m)" >&2 + exit 1 +fi + +# ---- Homebrew (user-local) ---------------------------------------- +BREW_PREFIX="$HOME/homebrew" +if [ ! -x "$BREW_PREFIX/bin/brew" ]; then + log "installing user-local Homebrew at $BREW_PREFIX" + mkdir -p "$BREW_PREFIX" + curl -fsSL https://github.com/Homebrew/brew/tarball/master \ + | tar xz --strip-components=1 -C "$BREW_PREFIX" +else + log "Homebrew already present at $BREW_PREFIX" +fi +export PATH="$BREW_PREFIX/bin:$PATH" + +if ! brew list --formula rsync >/dev/null 2>&1; then + log "installing GNU rsync via brew" + brew install rsync +else + log "GNU rsync already installed" +fi + +# Persist Homebrew on PATH for non-interactive launchd invocations. +SHELL_RC="$HOME/.zshenv" +if ! grep -qs "$BREW_PREFIX/bin" "$SHELL_RC" 2>/dev/null; then + log "adding $BREW_PREFIX/bin to $SHELL_RC" + printf '\nexport PATH="%s/bin:$PATH"\n' "$BREW_PREFIX" >> "$SHELL_RC" +fi + +# ---- rustup ------------------------------------------------------- +if [ ! -x "$HOME/.cargo/bin/rustup" ]; then + log "installing rustup" + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --default-toolchain none +else + log "rustup already installed" +fi +# shellcheck disable=SC1091 +. "$HOME/.cargo/env" + +# Install the toolchain the repo pins. rustup reads rust-toolchain +# automatically on `cargo` invocations, but we install it eagerly so +# the first CI run does not spend 5 min downloading it. +log "installing toolchain components for the pinned channel" +rustup component add rustfmt clippy llvm-tools-preview + +# ---- cargo-llvm-cov ---------------------------------------------- +if ! command -v cargo-llvm-cov >/dev/null 2>&1; then + log "installing cargo-llvm-cov" + cargo install cargo-llvm-cov +else + log "cargo-llvm-cov already installed" +fi + +log "done." +log "next: download the actions runner package and run config.sh + svc.sh" +log " — see scripts/ci-runner/README.md § 'Register the runner with GitHub'" diff --git a/server/Cargo.toml b/server/Cargo.toml deleted file mode 100644 index 7e5deef9..00000000 --- a/server/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "server" -version.workspace = true -edition.workspace = true - -[dependencies] -bitcoin = { workspace = true } -bitcoin_hashes = { version = "0.16.0", features = ["std"] } -sha2 = { workspace = true } -serde = { workspace = true } -bincode = { workspace = true } -hex = "0.4.3" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "net", "time"] } -esplora-client = { git = "https://github.com/BitVM/rust-esplora-client", branch = "master" } -axum = { version = "0.7.9", features = ["json", "multipart"] } -anyhow = "1.0" -zkcoins-prover = { path = "../script/" } -zkcoins-program = { path = "../program/" } -shared = { path = "../shared/" } -lazy_static = { workspace = true } -tower-http = { version = "0.5", features = ["cors", "fs"] } - - - -[dev-dependencies] -tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" -serde_json = "1.0" - -[features] -# All non-MVP features are off by default. When a feature is not enabled, the -# corresponding routes, handlers, and supporting modules are excluded from the -# binary at compile time via `#[cfg(feature = "…")]`, so the disabled code -# cannot run, crash, or be exploited at runtime. -default = [] -address-list = [] -faucet = [] -usernames = [] -lnurl = ["usernames"] diff --git a/server/src/account_server.rs b/server/src/account_server.rs deleted file mode 100644 index e5c20fd1..00000000 --- a/server/src/account_server.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex, MutexGuard}; - -use crate::state::State; -use bitcoin::secp256k1::PublicKey; -use serde::{Deserialize, Serialize}; -use shared::commitment::Commitment; -use shared::{Address, Invoice}; -use zkcoins_program::merkle::sparse_merkle_tree::{ - InclusionProof, SparseMerkleTree, DEFAULT_HASHES, -}; -use zkcoins_program::merkle::HashDigest; -use zkcoins_program::{ - calculate_coin_identifier, AccountState, Amount, Coin, CoinTemplate, CommitmentMerkleProofs, - ProgramInputsBuilder, ProofData, ProofType, -}; -use zkcoins_prover::{Proof, Prover}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CoinProof { - pub proof: Proof, - pub coin: Coin, - pub inclusion_proof: InclusionProof, - pub commitment: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct Account { - pub proof: Option, - pub coin_queue: Vec, - pub coin_history: SparseMerkleTree, - pub balance: u64, -} - -impl Account { - pub fn new() -> Self { - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 0, - } - } - /// Uses the coin_template and next_public_key to create the next account_state and generates a - /// Coin with filled in identifier (as it commits to the next account state hash). - pub fn create_coins( - &self, - address: HashDigest, - next_public_key: PublicKey, - public_key: zkcoins_program::PublicKey, - coin_templates: Vec, - ) -> Result, &'static str> { - let mut next_account_state = AccountState { - owner: address, - balance: self.get_balance(), - public_key, - }; - for coin_template in &coin_templates { - // Caller (send_coins) already validated balance >= total - // invoiced amount before reaching this function. The expect - // here is documentation of that invariant. - next_account_state.balance = next_account_state - .balance - .checked_sub(coin_template.amount) - .expect("balance was validated by send_coins"); - } - - let next_account_state_hash = next_account_state.hash(); - let coins = coin_templates.into_iter().enumerate().map(|(i, template)| { - Coin::new( - template, - calculate_coin_identifier(next_account_state_hash, i as u32), - ) - }); - // Set the next public key. - next_account_state.public_key = next_public_key.serialize().to_vec(); - Ok(coins.collect()) - } - - pub fn get_balance(&self) -> Amount { - self.coin_queue - .iter() - .fold(self.balance, |acc, x| acc + x.coin.amount) - } -} - -pub struct AccountServer { - accounts: HashMap, - prover: Prover, - state: Arc>, -} - -impl AccountServer { - // TODO: Move to client. - /// Get the keypair to the pubkey this account commited to (which is derived key num_pubkeys - - /// 1) - - pub fn new(state: Arc>) -> Self { - let accounts = HashMap::new(); - let prover = Prover::new(); - - AccountServer { - accounts, - prover, - state, - } - } - - pub fn import_account(&mut self, address: HashDigest, account: Account) { - self.accounts.insert(address, account); - } - - // TODO: User needs to provide a signature and the salt and the secret information for the - // address to authenticate. - pub fn get_account_balance(&self, account_address: &Address) -> Result { - match self.accounts.get(account_address) { - Some(account) => Ok(account - .coin_queue - .iter() - .fold(account.balance, |acc, x| acc + x.coin.amount)), - _ => Err("No account with this address"), - } - } - - #[cfg(any(feature = "address-list", feature = "usernames", feature = "lnurl"))] - pub fn get_addresses(&self) -> Vec
{ - self.accounts.keys().cloned().collect::>() - } - - pub fn receive_coin(&mut self, coin_proof: CoinProof) -> Result<(), &'static str> { - // Deserialze proof data - let proof_data = coin_proof.proof.public_values.clone().read::(); - - // Verify the inclusion of the coin in the proof. - if !coin_proof - .inclusion_proof - .verify(coin_proof.coin.identifier, proof_data.output_coins_root) - { - return Err("Coin inclusion proof verification failed"); - } - - // Log coin receipt without exposing full address (privacy). - let addr = &coin_proof.coin.recipient; - eprintln!( - "Receiving coin for address: {:02x}{:02x}…", - addr[0], addr[1] - ); - // Get the recipient account - let mut account = self - .accounts - .remove(&coin_proof.coin.recipient) - .unwrap_or_else(Account::new); - - // Check if we could generate updated account proof. (e.g. the coin is valid) - // TODO: Check if the public key is not included in our accumulator yet (or belongs to the - // same account state hash -> what is stored for the public key has to be the preimage to - // the coin identifier) - //let _ = self.prover.update_account( - // &account.state, - // &None, - // account.proof.clone(), - // vec![proof.clone()], - // // Note: account public_key is not updated when only receiving. - // &account.state.public_key, - //); - - // Reject duplicate coins (replay protection) - let coin_id = coin_proof.coin.identifier; - if account - .coin_queue - .iter() - .any(|cp| cp.coin.identifier == coin_id) - { - return Err("Coin already in queue (duplicate)"); - } - if account - .coin_history - .generate_inclusion_proof(&coin_id) - .is_ok() - { - return Err("Coin already spent (replay)"); - } - - let address = coin_proof.coin.recipient; - account.coin_queue.push(coin_proof); - self.accounts.insert(address, account); - Ok(()) - } - - /// Get all required merkle proofs from the state for the public key and the previous proof. - /// Static method: does not access self.accounts, only the state guard. - fn get_merkle_proofs( - mut previous_proof: Proof, - public_key: PublicKey, - state: &MutexGuard<'_, State>, - ) -> Result { - let account_merkle_proofs = state - .get_commitment_proof(&public_key) - .or(Err("Unable to get merkle proofs for provided public key"))?; - - let proof_data = previous_proof.public_values.read::(); - let previous_root = proof_data.commitment_history_root; - let previous_root_proof = state.get_mmr_inclusion_proof(previous_root).or(Err( - "Unable to get mmr inclusion proof for the previous root", - ))?; - - // The SMT stores `hash_concat(account_state_hash, output_coins_root)` - // as the value for the account's public key; the SP1 prover commits - // to those exact two fields in `public_values`. Both invariants are - // verified by the prover itself, so we do not double-check here. - let proofs = CommitmentMerkleProofs { - commitment_root: account_merkle_proofs.2, - commitment_proof: account_merkle_proofs.1, - commitment_root_history_proof: account_merkle_proofs.3, - commitment_root_mmr_sibling: state.prev_mmr_root, - previous_root_history_proof: previous_root_proof, - commitment_account_state_hash: proof_data.account_state_hash, - commitment_out_coins_root: proof_data.output_coins_root, - }; - - // verify_previous_root is an additional MMR cross-check; trusting - // the prover's commitment_history_root means the lookup above - // already implies this holds. - let _ = proofs.verify_previous_root(previous_root, state.mmr.root()); - - Ok(proofs) - } - - pub fn send_coins( - &mut self, - invoices: Vec, - account_address: Address, - public_key: PublicKey, - next_public_key: PublicKey, - prev_commitment_pubkey: Option, - ) -> Result, &'static str> { - let state = &self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let account = self - .accounts - .get_mut(&account_address) - .ok_or("Unknown account address")?; - // Check if the account balance is enough - let balance = account - .coin_queue - .iter() - .fold(account.balance, |acc, x| acc + x.coin.amount); - let invoiced_amount = invoices.iter().fold(0, |acc, x| acc + x.amount); - if balance < invoiced_amount { - return Err("Insufficient funds"); - } - - // TODO: Copy this over to the client because they too have to check that the - // out_coins_tree is correct and only contains the coins from the invoices. - // Create the coin templates. - let mut coin_templates = vec![]; - for invoice in invoices { - coin_templates.push(CoinTemplate::new(invoice.recipient, invoice.amount)); - } - - let mut coin_history_proofs = vec![]; - let mut coin_non_inclusion_proofs = vec![]; - let mut coin_inclusion_proofs = vec![]; - let mut in_coins = vec![]; - for coin_proof in &account.coin_queue { - coin_history_proofs.push({ - match &coin_proof.commitment { - Some(commitment) => Self::get_merkle_proofs( - coin_proof.proof.clone(), - commitment.public_key, - state, - )?, - None => return Err("Coin is missing commitment"), - } - }); - coin_non_inclusion_proofs.push({ - account - .coin_history - .generate_non_inclusion_proof(coin_proof.coin.identifier) - .or(Err("Should provide an inclusion proof"))? - }); - coin_inclusion_proofs.push(coin_proof.inclusion_proof.clone()); - in_coins.push(coin_proof.coin.clone()); - account - .coin_history - .insert(coin_proof.coin.identifier, coin_proof.coin.identifier) - .or(Err("Coin should not exist in coin history tree"))?; - } - let mut proof_hints_builder = ProgramInputsBuilder::default(); - let proof_hints_builder = proof_hints_builder - .account_state(AccountState { - owner: account_address, - balance: account.balance, - public_key: public_key.serialize().to_vec(), - }) - .next_public_key(next_public_key.clone().serialize().to_vec()) - // Create the coin. (In case of multiple coins adjust AccountState.create_coin to apply - // all coin templates first and then create the identifier from the final account - // state.) - .in_coins(in_coins) - .in_coins_inclusion_proofs(coin_inclusion_proofs) - .in_coin_proofs_history_proofs(coin_history_proofs) - .in_coin_proofs_non_inclusion_proofs(coin_non_inclusion_proofs) - .current_history_root(state.mmr.root()); - - let out_coins = account.create_coins( - account_address, - next_public_key, - public_key.serialize().to_vec(), - coin_templates, - )?; - // SparseMerkleTree::new() always returns DEFAULT_HASHES[0] as - // its root, and a non-inclusion-proof-driven update produces the - // same root as a direct insert — both invariants are part of the - // SMT impl's own test suite. We do not double-check here. - let mut out_coins_tree = SparseMerkleTree::new(); - let _initial_root = DEFAULT_HASHES[0]; - - let mut out_coin_proofs = vec![]; - for coin in &out_coins { - let non_inclusion_proof = out_coins_tree - .generate_non_inclusion_proof(coin.identifier) - .or(Err("Coin should not exist in tree yet"))?; - out_coin_proofs.push(non_inclusion_proof.clone()); - out_coins_tree.insert(coin.identifier, coin.identifier)?; - let _expected = non_inclusion_proof.insert(coin.identifier)?; - } - - let proof_hints_builder = proof_hints_builder - .out_coins(out_coins.clone()) - .out_coin_proofs(out_coin_proofs); - - let received_proofs: Vec<_> = account.coin_queue.iter().map(|x| x.proof.clone()).collect(); - - // When DEV_SKIP_BROADCAST_FAILURE is set, the SMT is missing - // entries that should have been written by previous mints (their - // on-chain commitment never landed because the publisher wallet - // was empty). Drop the existing account.proof on the floor and - // take the create_account branch instead — yields a fresh proof - // that doesn't depend on get_merkle_proofs ever finding the prev - // pubkey. The cost is that the previous commitment history is - // discarded; for DEV testing that's an acceptable trade. Same - // "NEVER set in PRD" caveat as the broadcast bypass. - let dev_skip = std::env::var("DEV_SKIP_BROADCAST_FAILURE").unwrap_or_default() == "true"; - let proof = match &account.proof { - Some(account_proof) if !dev_skip => { - let account_commitment_public_key = prev_commitment_pubkey - .ok_or("prev_commitment_pubkey required for account update")?; - let merkle_proofs = Self::get_merkle_proofs( - account_proof.clone(), - account_commitment_public_key, - state, - )?; - proof_hints_builder.prev_proof_history_proofs(Some(merkle_proofs)); - proof_hints_builder.proof_type(ProofType::AccountUpdateProof); - self.prover.update_account( - proof_hints_builder, - account_proof.clone(), - received_proofs, - )? - } - _ => self - .prover - .create_account(proof_hints_builder, received_proofs)?, - }; - - // Proof generation succeeded — now commit the state changes. - // coin_queue and proof were read non-destructively above, - // so the account is unchanged if we got an error before this point. - account.coin_queue.clear(); - account.balance = balance - invoiced_amount; - account.proof = Some(proof.clone()); - // The SP1 prover commits to `output_coins_root` in its public values, - // and we built the same tree above from the same coin identifiers - // — they always match. The bincode of public_values is similarly - // always valid (SP1 invariant). We do not double-check here. - let _public_values = bincode::deserialize::(&proof.public_values.to_vec()) - .expect("SP1 prover emits valid ProofData public values"); - - // Create the coin_proofs to be distributed to recipients - let mut coin_proofs = vec![]; - for coin in out_coins { - coin_proofs.push(CoinProof { - proof: proof.clone(), - inclusion_proof: out_coins_tree.generate_inclusion_proof(&coin.identifier)?.0, - coin, - // User will fill in the commitment and send back this proof to the server. - commitment: None, - }); - } - - Ok(coin_proofs) - } - - pub fn get_minting_account_address(&mut self) -> Result { - match self.accounts.get(&zkcoins_program::MINTING_ADDRESS) { - Some(_) => Ok(zkcoins_program::MINTING_ADDRESS), - None => Err("Minting account not created"), - } - } - - pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { - // bincode::serialize on HashMap cannot fail - // in practice; pass the error through as a function reference - // so the path does not introduce an uncovered closure. - let bytes = bincode::serialize(&self.accounts).map_err(std::io::Error::other)?; - crate::atomic_write(path, &bytes) - } - - pub fn load_from_file(state: Arc>, path: &str) -> std::io::Result { - let bytes = std::fs::read(path)?; - let accounts: HashMap = - bincode::deserialize(&bytes).map_err(std::io::Error::other)?; - let prover = Prover::new(); - Ok(AccountServer { - accounts, - prover, - state, - }) - } -} - -#[cfg(test)] -#[path = "account_server_tests.rs"] -mod tests; diff --git a/server/src/account_server_tests.rs b/server/src/account_server_tests.rs deleted file mode 100644 index a3e430fc..00000000 --- a/server/src/account_server_tests.rs +++ /dev/null @@ -1,686 +0,0 @@ -use std::time::Instant; -use zkcoins_program::hash; - -use super::*; -use crate::state::State; -use bitcoin::{ - bip32::{ChildNumber, Xpriv, Xpub}, - key::Secp256k1, - secp256k1::{All, PublicKey as BitcoinPublicKey, SecretKey}, - Network, -}; -use lazy_static::lazy_static; -use shared::{commitment::Commitment, ProofData}; -use zkcoins_program::MINTING_ADDRESS; - -lazy_static! { - static ref SECP256K1_TEST_CTX: Secp256k1 = Secp256k1::new(); -} - -// Fixed seed for deterministic address generation in tests for generic accounts -const TEST_ACCOUNT_RANDOM_SEED_FOR_ADDRESS: [u8; 32] = [1u8; 32]; - -fn generate_test_public_key(private_key: &Xpriv, index: u32) -> BitcoinPublicKey { - Xpub::from_priv(&SECP256K1_TEST_CTX, private_key) - .derive_pub(&SECP256K1_TEST_CTX, &[ChildNumber::Normal { index }]) - .expect("Failed to derive public key for test") - .public_key -} - -fn derive_test_secret_key(private_key: &Xpriv, index: u32) -> SecretKey { - private_key - .derive_priv(&SECP256K1_TEST_CTX, &[ChildNumber::Normal { index }]) - .expect("Unable to derive private key for test") - .private_key -} - -struct TestAccountData { - xpriv: Xpriv, - address: Address, - num_pubkeys: u32, -} - -impl TestAccountData { - fn new_minting_account() -> Self { - let secret = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(Network::Bitcoin, secret) - .expect("Failed to create private key for minting account."); - - TestAccountData { - xpriv, - address: MINTING_ADDRESS, - num_pubkeys: 0, - } - } - - fn new_generic(seed: &[u8; 32], network: Network) -> Self { - let xpriv = Xpriv::new_master(network, seed) - .expect("Failed to create private key for generic account."); - - let initial_pk_bytes = generate_test_public_key(&xpriv, 0).serialize().to_vec(); - let address = zkcoins_program::hash(&initial_pk_bytes); - - TestAccountData { - xpriv, - address, - num_pubkeys: 0, - } - } - - fn execute_send_coins( - &mut self, - server: &mut AccountServer, - invoices: Vec, - ) -> Result, String> { - let current_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys); - let next_pk = generate_test_public_key(&self.xpriv, self.num_pubkeys + 1); - let prev_pk = if self.num_pubkeys > 0 { - Some(generate_test_public_key(&self.xpriv, self.num_pubkeys - 1)) - } else { - None - }; - - let mut coin_proofs = - server.send_coins(invoices, self.address, current_pk, next_pk, prev_pk)?; - - // The key used for the commitment corresponds to current_pk - let signing_secret_key = derive_test_secret_key(&self.xpriv, self.num_pubkeys); - - self.num_pubkeys += 1; // Increment after deriving signing key for current op, before it's used for next op - - for cp in &mut coin_proofs { - let proof_data = bincode::deserialize::(&cp.proof.public_values.to_vec()) - .expect("ProofData deserialization failed in test"); - let commitment_hash_input = zkcoins_program::merkle::hash_concat( - &proof_data.account_state_hash, - &proof_data.output_coins_root, - ); - cp.commitment = Some( - Commitment::new(&signing_secret_key, commitment_hash_input.to_vec()) - .expect("Failed to create commitment for coin proof in test"), - ); - } - Ok(coin_proofs) - } -} - -#[test] -fn test_wallet_operations() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - assert_eq!( - MINTING_ADDRESS, - server.get_minting_account_address().unwrap(), - "Minting address in server and program are different" - ); - - let mut account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); - let mut account_2_data = TestAccountData::new_generic(&[2u8; 32], Network::Signet); - - assert_eq!( - server.get_account_balance(&MINTING_ADDRESS).unwrap(), - 10_000 - ); - assert!(server.get_account_balance(&account_1_data.address).is_err()); - assert!(server.get_account_balance(&account_2_data.address).is_err()); - - // Note: Invoices use addresses. - let account_2_invoice = Invoice::new(100, account_2_data.address); - let account_1_invoice = Invoice::new(100, account_1_data.address); - - let mut coin_proofs = minting_account_data - .execute_send_coins( - &mut server, - vec![account_2_invoice.clone(), account_1_invoice.clone()], - ) - .unwrap(); - - state_arc - .lock() - .unwrap() - .update( - &coin_proofs - .iter() - .map(|x| x.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - - server - .receive_coin(coin_proofs.pop().unwrap()) // Order might matter if tied to invoice order - .expect("Unable to receive coin for account_1_invoice"); // Assuming account_1_invoice was last in vec or order doesn't strictly map here - server - .receive_coin(coin_proofs.pop().unwrap()) - .expect("Unable to receive coin for account_2_invoice"); - - assert_eq!( - server.get_account_balance(&account_1_data.address).unwrap(), - 100 - ); - assert_eq!( - server.get_account_balance(&account_2_data.address).unwrap(), - 100 - ); - println!("Minting successful"); - - let mut coin_proofs_from_acc2 = account_2_data - .execute_send_coins(&mut server, vec![account_1_invoice.clone()]) // account_2 sends to account_1 - .expect("Unable to send coin from account_2"); - - state_arc - .lock() - .unwrap() - .update( - &coin_proofs_from_acc2 - .iter() - .map(|x| x.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - // Balances before receiving the new coin by account_1 - assert_eq!( - server.get_account_balance(&account_1_data.address).unwrap(), - 100 - ); - assert_eq!( - server.get_account_balance(&account_2_data.address).unwrap(), - 0 - ); // account_2's balance reduced after send - - server - .receive_coin(coin_proofs_from_acc2.pop().unwrap()) - .expect("Unable to receive coin by account_1 from account_2"); - assert_eq!( - server.get_account_balance(&account_1_data.address).unwrap(), - 200 - ); - assert_eq!( - server.get_account_balance(&account_2_data.address).unwrap(), - 0 - ); - - // Send with timer - let start_time = Instant::now(); - let mut coin_proofs_from_acc1 = account_1_data - .execute_send_coins(&mut server, vec![account_2_invoice.clone()]) // account_1 sends to account_2 - .expect("Unable to send coin from account_1"); - let duration = start_time.elapsed(); - - state_arc - .lock() - .unwrap() - .update( - &coin_proofs_from_acc1 - .iter() - .map(|x| x.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - println!("TIME ELAPSED FOR ONE RECURSIVE SEND: {:?}", duration); - server - .receive_coin(coin_proofs_from_acc1.pop().unwrap()) - .expect("Unable to receive coin by account_2 from account_1"); - assert_eq!( - server.get_account_balance(&account_1_data.address).unwrap(), - 100 - ); // 200 - 100 - assert_eq!( - server.get_account_balance(&account_2_data.address).unwrap(), - 100 - ); // 0 + 100 -} - -#[test] -fn test_create_minting_account() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(state_arc); - - let minting_account_data = TestAccountData::new_minting_account(); - - server.import_account( - minting_account_data.address, // This is MINTING_ADDRESS - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - assert_eq!( - server.get_minting_account_address().unwrap(), - MINTING_ADDRESS, - "Minting address is not stored in server correctly." - ); - assert_eq!( - server.get_account_balance(&MINTING_ADDRESS).unwrap(), - 10_000 - ); -} - -#[test] -fn test_mint_single_invoice() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - - let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); - let invoice = Invoice::new(100, account_1_data.address); - - let coin_proofs = minting_account_data - .execute_send_coins(&mut server, vec![invoice]) - .expect("Mint with single invoice failed"); - - assert_eq!(coin_proofs.len(), 1); -} - -#[test] -fn test_receive_duplicate_coin_rejected() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - - let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); - let invoice = Invoice::new(100, account_1_data.address); - - let coin_proofs = minting_account_data - .execute_send_coins(&mut server, vec![invoice]) - .expect("Mint failed"); - - state_arc - .lock() - .unwrap() - .update( - &coin_proofs - .iter() - .map(|x| x.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - - let coin_proof = coin_proofs.into_iter().next().unwrap(); - let duplicate = coin_proof.clone(); - - // First receive should succeed - server - .receive_coin(coin_proof) - .expect("First receive should succeed"); - - // Second receive of the same coin should be rejected - let result = server.receive_coin(duplicate); - assert!(result.is_err(), "Duplicate coin receive must be rejected"); -} - -#[test] -fn test_receive_updates_balance() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - - let account_1_data = TestAccountData::new_generic(&[1u8; 32], Network::Signet); - let invoice = Invoice::new(250, account_1_data.address); - - // Balance should not exist before any receive - assert!( - server.get_account_balance(&account_1_data.address).is_err(), - "Account should not exist before receiving coins" - ); - - let coin_proofs = minting_account_data - .execute_send_coins(&mut server, vec![invoice]) - .expect("Mint failed"); - - state_arc - .lock() - .unwrap() - .update( - &coin_proofs - .iter() - .map(|x| x.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - - for cp in coin_proofs { - server.receive_coin(cp).expect("Receive should succeed"); - } - - // Balance should reflect the received coin amount - let balance = server - .get_account_balance(&account_1_data.address) - .expect("Account should exist after receive"); - assert_eq!( - balance, 250, - "Balance should equal the received coin amount" - ); -} - -/// Reproduces the exact configuration of /api/mint on the live DEV server: -/// balance = u64::MAX, recipient = raw [1u8; 32] bytes, amount = 1. -#[test] -fn test_mint_repro_live_setup() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: u64::MAX, - }, - ); - - let recipient: Address = [1u8; 32]; - let invoice = Invoice::new(1, recipient); - - let coin_proofs = minting_account_data - .execute_send_coins(&mut server, vec![invoice]) - .expect("Mint repro failed"); - - assert_eq!(coin_proofs.len(), 1); -} - -#[test] -fn test_save_and_load_roundtrip() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let address: HashDigest = [42u8; 32]; - server.import_account(address, Account::new()); - - let path = std::env::temp_dir().join(format!( - "zkcoins-account-server-test-{}.bin", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - server.save_to_file(path.to_str().unwrap()).unwrap(); - - let loaded = AccountServer::load_from_file(state_arc, path.to_str().unwrap()).unwrap(); - assert_eq!(loaded.get_account_balance(&address).unwrap(), 0); - - std::fs::remove_file(&path).ok(); -} - -#[test] -fn test_get_minting_account_address_returns_err_when_not_imported() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(state_arc); - assert!(server.get_minting_account_address().is_err()); -} - -#[test] -fn test_get_account_balance_returns_err_for_unknown_address() { - let state_arc = Arc::new(Mutex::new(State::new())); - let server = AccountServer::new(state_arc); - let unknown: Address = [7u8; 32]; - assert!(server.get_account_balance(&unknown).is_err()); -} - -#[test] -fn test_load_from_file_rejects_corrupted_bytes() { - let path = std::env::temp_dir().join(format!( - "zkcoins-account-server-corrupt-{}.bin", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write(&path, b"not bincode").unwrap(); - let state_arc = Arc::new(Mutex::new(State::new())); - let result = AccountServer::load_from_file(state_arc, path.to_str().unwrap()); - assert!(result.is_err()); - std::fs::remove_file(&path).ok(); -} - -#[test] -fn test_send_coins_returns_err_for_unknown_account() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(state_arc); - let account_data = TestAccountData::new_generic(&[1u8; 32], Network::Bitcoin); - - let recipient: Address = [2u8; 32]; - let invoice = Invoice::new(1, recipient); - - let current_pk = generate_test_public_key(&account_data.xpriv, 0); - let next_pk = generate_test_public_key(&account_data.xpriv, 1); - - let result = server.send_coins( - vec![invoice], - account_data.address, - current_pk, - next_pk, - None, - ); - assert_eq!(result.unwrap_err(), "Unknown account address"); -} - -#[test] -fn test_send_coins_returns_err_insufficient_funds() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(state_arc); - let account_data = TestAccountData::new_generic(&[1u8; 32], Network::Bitcoin); - server.import_account(account_data.address, Account::new()); - - let recipient: Address = [2u8; 32]; - let invoice = Invoice::new(100, recipient); - - let current_pk = generate_test_public_key(&account_data.xpriv, 0); - let next_pk = generate_test_public_key(&account_data.xpriv, 1); - - let result = server.send_coins( - vec![invoice], - account_data.address, - current_pk, - next_pk, - None, - ); - assert_eq!(result.unwrap_err(), "Insufficient funds"); -} - -#[test] -fn test_receive_coin_rejects_invalid_inclusion_proof() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting_account_data = TestAccountData::new_minting_account(); - server.import_account( - minting_account_data.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - - let recipient: Address = [1u8; 32]; - let invoice = Invoice::new(100, recipient); - - let mut coin_proofs = minting_account_data - .execute_send_coins(&mut server, vec![invoice]) - .expect("send_coins should succeed"); - - // Tamper with the coin identifier so the existing inclusion proof - // no longer verifies against it. receive_coin must reject. - let mut coin_proof = coin_proofs.pop().unwrap(); - coin_proof.coin.identifier = [99u8; 32]; - - let result = server.receive_coin(coin_proof); - assert_eq!( - result.unwrap_err(), - "Coin inclusion proof verification failed" - ); -} - -#[test] -fn test_send_coins_twice_from_same_account_uses_update_account() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting = TestAccountData::new_minting_account(); - server.import_account( - minting.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - - let recipient: Address = [42u8; 32]; - - // First send: account.proof is None -> create_account branch. - let coin_proofs_1 = minting - .execute_send_coins(&mut server, vec![Invoice::new(100, recipient)]) - .expect("first send should succeed"); - state_arc - .lock() - .unwrap() - .update( - &coin_proofs_1 - .iter() - .map(|cp| cp.commitment.clone().unwrap()) - .collect::>(), - ) - .unwrap(); - - // After the first send, account.proof = Some. A second send from the - // same account must therefore take the AccountUpdateProof branch - // (update_account, not create_account). - let coin_proofs_2 = minting - .execute_send_coins(&mut server, vec![Invoice::new(50, recipient)]) - .expect("second send should succeed (update_account path)"); - assert_eq!(coin_proofs_2.len(), 1); -} - -#[test] -fn test_receive_coin_rejects_replay_via_coin_history() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting = TestAccountData::new_minting_account(); - server.import_account( - minting.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - let recipient: Address = [9u8; 32]; - let coin_proofs = minting - .execute_send_coins(&mut server, vec![Invoice::new(50, recipient)]) - .unwrap(); - let coin_proof = coin_proofs[0].clone(); - let coin_id = coin_proof.coin.identifier; - - // First receive — succeeds, coin lands in the recipient's coin_queue. - server.receive_coin(coin_proof.clone()).unwrap(); - - // Simulate the recipient having spent the coin: identifier goes - // from coin_queue into coin_history. - { - let recipient_account = server.accounts.get_mut(&recipient).unwrap(); - recipient_account - .coin_history - .insert(coin_id, coin_id) - .unwrap(); - recipient_account - .coin_queue - .retain(|cp| cp.coin.identifier != coin_id); - } - - // Replay: receiving the same coin again must be rejected via the - // coin_history check rather than the coin_queue check. - let result = server.receive_coin(coin_proof); - assert_eq!(result.unwrap_err(), "Coin already spent (replay)"); -} - -#[test] -fn test_send_coins_rejects_coin_queue_entry_without_commitment() { - let state_arc = Arc::new(Mutex::new(State::new())); - let mut server = AccountServer::new(Arc::clone(&state_arc)); - - let mut minting = TestAccountData::new_minting_account(); - server.import_account( - minting.address, - Account { - proof: None, - coin_queue: vec![], - coin_history: SparseMerkleTree::new(), - balance: 10_000, - }, - ); - let recipient: Address = [10u8; 32]; - let coin_proofs = minting - .execute_send_coins(&mut server, vec![Invoice::new(50, recipient)]) - .unwrap(); - let mut coin_proof = coin_proofs[0].clone(); - // Strip the commitment so the next send attempt from the recipient - // hits the "Coin is missing commitment" branch. - coin_proof.commitment = None; - - server.receive_coin(coin_proof).unwrap(); - - let mut recipient_data = TestAccountData::new_generic(&[10u8; 32], bitcoin::Network::Signet); - // Force the test data to use the same address as the recipient. - recipient_data.address = recipient; - - let current_pk = generate_test_public_key(&recipient_data.xpriv, 0); - let next_pk = generate_test_public_key(&recipient_data.xpriv, 1); - let result = server.send_coins( - vec![Invoice::new(1, [11u8; 32])], - recipient_data.address, - current_pk, - next_pk, - None, - ); - assert_eq!(result.unwrap_err(), "Coin is missing commitment"); -} diff --git a/server/src/main.rs b/server/src/main.rs deleted file mode 100644 index 473a15dc..00000000 --- a/server/src/main.rs +++ /dev/null @@ -1,231 +0,0 @@ -mod account_server; -mod publisher; -mod scanner; -mod scanner_runtime; -mod server; -mod server_runtime; -mod state; -mod username; - -use crate::publisher::EsploraConfig; -use crate::scanner_runtime::scan_for_inscriptions; -use crate::server_runtime::start_rest_server; -use crate::state::State; -use bitcoin::hashes::Hash; -use bitcoin::BlockHash; -use shared::commitment::Commitment; -use std::error::Error as StdError; -use std::fs::File; -use std::io::{Read, Write}; -use std::sync::{Arc, Mutex}; - -const SMT_PATH: &str = "smt.bin"; -const MMR_PATH: &str = "mmr.bin"; -const LATEST_BLOCK_PATH: &str = "latest_block.bin"; -const ACCOUNTS_PATH: &str = "accounts.bin"; -const USERNAMES_PATH: &str = "usernames.bin"; -const ACCOUNT_SERVER_ADDR: &str = "0.0.0.0:4242"; -//const START_BLOCK_HASH: &str = "000000f43ca5c99c54c4738878fe1c5cca07691dc614a2734b73aa78ca868fb8"; - -use esplora_client::{ - r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, -}; - -const DEFAULT_PUBLISHER_KEY: &str = - "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - -lazy_static::lazy_static! { - pub static ref NETWORK_CONFIG: EsploraConfig = { - let url = std::env::var("ESPLORA_URL") - .unwrap_or_else(|_| "https://mutinynet.com/api".to_string()); - let is_mainnet = std::env::var("IS_MAINNET") - .map(|v| v == "true") - .unwrap_or(false); - let network_name = std::env::var("NETWORK_NAME") - .unwrap_or_else(|_| if is_mainnet { "Mainnet".to_string() } else { "Mutinynet".to_string() }); - println!("Network config: {} ({})", network_name, url); - EsploraConfig { url, is_mainnet, network_name } - }; - - pub static ref PUBLISHER_KEY: String = { - let key = std::env::var("PUBLISHER_KEY") - .unwrap_or_else(|_| DEFAULT_PUBLISHER_KEY.to_string()); - if NETWORK_CONFIG.is_mainnet && key == DEFAULT_PUBLISHER_KEY { - panic!("PUBLISHER_KEY env var must be set for mainnet"); - } - key - }; -} - -/// Atomic write: write to a temp file, then rename. -/// This prevents data corruption if the process crashes mid-write. -pub fn atomic_write(path: &str, data: &[u8]) -> std::io::Result<()> { - let tmp_path = format!("{}.tmp", path); - let mut file = File::create(&tmp_path)?; - file.write_all(data)?; - file.sync_all()?; - std::fs::rename(&tmp_path, path)?; - Ok(()) -} - -// Helper function to save the latest block hash -fn save_latest_block(block_hash: &BlockHash, path: &str) -> Result<(), Box> { - atomic_write(path, &block_hash.to_byte_array())?; - Ok(()) -} - -// Helper function to load the latest block hash -fn load_latest_block(path: &str) -> Result> { - let mut file = File::open(path)?; - let mut bytes = [0u8; 32]; - file.read_exact(&mut bytes)?; - Ok(BlockHash::from_byte_array(bytes)) -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Create a new State wrapped in Arc - // Try to load existing state or create a new one - let state = Arc::new(Mutex::new( - match State::load_from_files(SMT_PATH, MMR_PATH) { - Ok(state) => { - println!("Loaded existing State from {} and {}", SMT_PATH, MMR_PATH); - state - } - Err(_) => { - println!("Creating new State"); - State::new() - } - }, - )); - - // Create a new AccountServer instance with a reference to the state. - // Try to restore persisted accounts; otherwise start with an empty server - // and let start_rest_server seed the minting account. - let account_server = - match account_server::AccountServer::load_from_file(Arc::clone(&state), ACCOUNTS_PATH) { - Ok(server) => { - println!("Loaded existing accounts from {}", ACCOUNTS_PATH); - server - } - Err(_) => { - println!("No accounts file found, creating new AccountServer"); - account_server::AccountServer::new(Arc::clone(&state)) - } - }; - - // Load or create UsernameStore - let username_store = match username::UsernameStore::load_from_file(USERNAMES_PATH) { - Ok(store) => { - println!("Loaded existing usernames from {}", USERNAMES_PATH); - store - } - Err(_) => { - println!("No usernames file found, creating new UsernameStore"); - username::UsernameStore::new() - } - }; - - // Spawn the account_server as a separate task - tokio::spawn(async move { - if let Err(e) = start_rest_server( - account_server, - username_store, - ACCOUNT_SERVER_ADDR, - ACCOUNTS_PATH.to_string(), - USERNAMES_PATH.to_string(), - ) - .await - { - eprintln!("Account server error: {}", e); - } - }); - - // Try to load the latest block hash or use the default starting point - let start_block_hash = match load_latest_block(LATEST_BLOCK_PATH) { - Ok(hash) => { - println!("Resuming from previously saved block: {}", hash); - hash - } - Err(_) => { - println!("No saved block hash found, fetching latest from Esplora..."); - let client = EsploraAsyncClient::::from_builder(EsploraBuilder::new( - &NETWORK_CONFIG.url, - ))?; - - let tip_hash = client.get_tip_hash().await?; - println!("Fetched latest tip hash from Esplora: {}", tip_hash); - tip_hash - } - }; - - // Clone the State's Arc for the closure - let state_clone = Arc::clone(&state); - - scan_for_inscriptions(&NETWORK_CONFIG, start_block_hash, &move |content_bytes: Vec, current_block_hash| { - println!("Received content size: {} bytes", content_bytes.len()); - - // Try to deserialize the content as a Commitment - match bincode::deserialize::(&content_bytes) { - Ok(commitment) => { - println!("Successfully deserialized as commitment"); - println!("Public key: {}", commitment.public_key); - - // Verify the commitment - if commitment.verify() { - println!("Commitment signature verified successfully"); - - // Capture the public_key before moving `commitment` into - // `state.update` so we can reference it in the Err arm. - let pubkey_for_log = commitment.public_key; - - // Lock the mutex to modify the state - let mut state = state_clone.lock().unwrap(); - // Update the state with this commitment. - // - // Errors are logged but do NOT panic — the scanner is - // best-effort and we never want a single bad commitment - // (replay, client bug, or a re-scan after crash where - // the SMT already has this public_key with a different - // leaf value) to take the whole REST server down. The - // scanner advances to the next block regardless. - match state.update(&[commitment]) { - Ok(new_root) => { - println!( - "Added to State. New MMR root: {}", - hex::encode(new_root) - ); - - // Save the state after each update - if let Err(e) = state.save_to_files(SMT_PATH, MMR_PATH) { - eprintln!("Failed to save state after update: {}", e); - } - - // Save the latest block hash after each update - if let Err(e) = - save_latest_block(¤t_block_hash, LATEST_BLOCK_PATH) - { - eprintln!("Failed to save latest block hash: {}", e); - } - } - Err(e) => { - eprintln!( - "Skipping commitment for public_key {}: state.update failed: {}", - pubkey_for_log, e - ); - } - } - } else { - println!("Commitment verification failed, not adding to state"); - } - }, - Err(e) => { - // Print more detailed debug information - println!("Found inscription with our message but failed to deserialize as commitment\nError: {}", e); - } - } - }) - .await?; - - Ok(()) -} diff --git a/server/src/publisher.rs b/server/src/publisher.rs deleted file mode 100644 index 65b8470f..00000000 --- a/server/src/publisher.rs +++ /dev/null @@ -1,360 +0,0 @@ -use bitcoin::{ - absolute::LockTime, - blockdata::{opcodes, script}, - hashes::Hash, - key::TapTweak, - locktime::absolute::Height, - script::PushBytesBuf, - secp256k1::{self, Secp256k1, SecretKey, XOnlyPublicKey}, - sighash::{Prevouts, SighashCache}, - taproot::{LeafVersion, TaprootBuilder}, - transaction::Version, - Address, Amount, Network, OutPoint, ScriptBuf, Sequence, TapLeafHash, TapSighashType, - Transaction, TxIn, TxOut, Txid, Weight, Witness, -}; - -use std::str::FromStr; -// Import specific Esplora client types -use esplora_client::{ - r#async::DefaultSleeper, AsyncClient as EsploraAsyncClient, Builder as EsploraBuilder, -}; - -// Define a configuration struct for Esplora -#[derive(Clone, Debug)] -pub struct EsploraConfig { - pub url: String, - pub is_mainnet: bool, - pub network_name: String, -} - -impl EsploraConfig { - pub fn network(&self) -> Network { - if self.is_mainnet { - Network::Bitcoin - } else { - Network::Signet - } - } -} - -// Define constants for transaction identification -pub const INSCRIPTION_MARKER_PREFIX: &str = "4242"; - -const MAX_CHUNK_SIZE: usize = 520; -const MAX_MINING_ATTEMPTS: u32 = 400000; -const PROPAGATION_WAIT_SECS: u64 = 5; -const MIN_INSCRIPTION_AMOUNT: u64 = 800; - -const COMMIT_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(68); -const REVEAL_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(295); - -fn min_fee(tx: &Transaction, witness_weight: Option) -> u64 { - let mut weight = tx.weight().to_wu(); - if tx.input.iter().any(|utxo| utxo.witness.is_empty()) { - weight += witness_weight.unwrap().to_wu() - * tx.input - .iter() - .map(|utxo| utxo.witness.is_empty() as u64) - .sum::() - } - weight.div_ceil(4) -} - -pub fn inscription_txs( - commitment_data: &[u8], - publisher_address: &Address, - outpoints_with_sats: Vec<(OutPoint, u64)>, - publisher_key: &str, - config: &EsploraConfig, -) -> (Transaction, Transaction) { - // Create secp context and keys - let secp256k1 = Secp256k1::new(); - let sk = SecretKey::from_str(publisher_key).unwrap(); - let key_pair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); - let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); - - let network = config.network(); - - println!("Publisher address: {}", publisher_address); - - let amount: u64 = outpoints_with_sats.iter().map(|(_, sats)| sats).sum(); - - // Build a taproot script committing to the data - let mut script_builder = script::Builder::new() - .push_slice(public_key.serialize()) - .push_opcode(opcodes::all::OP_CHECKSIG) - .push_opcode(opcodes::OP_FALSE) - .push_opcode(opcodes::all::OP_IF); - - // Add the commitment data in chunks - for chunk in commitment_data.chunks(MAX_CHUNK_SIZE) { - let buffer = PushBytesBuf::try_from(chunk.to_vec()).unwrap(); - script_builder = script_builder.push_slice(buffer); - } - - let reveal_script = script_builder - .push_opcode(opcodes::all::OP_ENDIF) - .into_script(); - - let taproot_spend_info = TaprootBuilder::new() - .add_leaf(0, reveal_script.clone()) - .unwrap() - .finalize(&secp256k1, public_key) - .unwrap(); - - // The commit address commits to our data - let commit_address = Address::p2tr_tweaked(taproot_spend_info.output_key(), network); - - // Create commit transaction - let mut commit_tx = Transaction { - version: Version(1), - lock_time: LockTime::Blocks(Height::ZERO), - input: outpoints_with_sats - .iter() - .map(|(outpoint, _)| TxIn { - previous_output: *outpoint, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::new(), - }) - .collect(), - output: vec![TxOut { - value: Amount::ZERO, - script_pubkey: commit_address.script_pubkey(), - }], - }; - - let commit_fee = min_fee(&commit_tx, Some(COMMIT_TX_WITNESS_WEIGHT)); - commit_tx.output.first_mut().unwrap().value = Amount::from_sat(amount - commit_fee); - - // Create input TxOuts for signing - let input_txout = outpoints_with_sats - .iter() - .map(|(_, sats)| TxOut { - value: Amount::from_sat(*sats), - script_pubkey: publisher_address.script_pubkey(), - }) - .collect::>(); - - // Sign each input of the commit transaction - for idx in 0..outpoints_with_sats.len() { - let mut sighash_cache = SighashCache::new(&mut commit_tx); - let signature_hash = sighash_cache - .taproot_key_spend_signature_hash( - idx, - &Prevouts::All(&input_txout), - TapSighashType::Default, - ) - .unwrap(); - - // Sign with the tweaked keypair - let message = secp256k1::Message::from_digest_slice(&signature_hash[..]).unwrap(); - let keypair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); - let tweaked_keypair = keypair.tap_tweak(&secp256k1, None).to_inner(); - let signature = secp256k1.sign_schnorr(&message, &tweaked_keypair); - - // Add the signature to the witness - let witness = sighash_cache.witness_mut(idx).unwrap(); - witness.clear(); - witness.push(signature.as_ref()); - } - - // Create reveal transaction - let mut reveal_tx = Transaction { - version: Version(1), - lock_time: LockTime::from_consensus(0), - input: vec![TxIn { - previous_output: OutPoint::new(commit_tx.compute_txid(), 0), - script_sig: script::Builder::new().into_script(), - witness: Witness::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - }], - output: vec![TxOut { - value: Amount::ZERO, - script_pubkey: publisher_address.script_pubkey(), - }], - }; - - let reveal_fee = min_fee(&reveal_tx, Some(REVEAL_TX_WITNESS_WEIGHT)); - reveal_tx.output.first_mut().unwrap().value = - Amount::from_sat(amount - reveal_fee - commit_fee); - - // Mine the reveal transaction to have a txid starting with our marker - println!( - "Mining reveal transaction to start with {}...", - INSCRIPTION_MARKER_PREFIX - ); - let target_prefix = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap(); - - let control_block = taproot_spend_info - .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) - .unwrap(); - - for nonce in 0..MAX_MINING_ATTEMPTS { - // Update the nSequence for mining - reveal_tx.input[0].sequence = Sequence(nonce); - - // Sign the transaction with the new sequence - let mut sighash_cache = SighashCache::new(&mut reveal_tx); - let signature_hash = sighash_cache - .taproot_script_spend_signature_hash( - 0, - &Prevouts::All(&[&commit_tx.output[0]]), - TapLeafHash::from_script(&reveal_script, LeafVersion::TapScript), - TapSighashType::Default, - ) - .unwrap(); - - let message = secp256k1::Message::from_digest_slice(&signature_hash[..]).unwrap(); - let signature = secp256k1.sign_schnorr(&message, &key_pair); - - let witness = sighash_cache.witness_mut(0).unwrap(); - witness.clear(); - witness.push(signature.as_ref()); - witness.push(reveal_script.clone()); - witness.push(control_block.serialize()); - - // Check if the txid starts with our target prefix - let txid = reveal_tx.compute_txid(); - let txid_bytes = txid.as_byte_array(); - - if txid_bytes.starts_with(&target_prefix) { - println!("Found matching txid: {} with nSequence: {}", txid, nonce); - break; - } - - if nonce % 10000 == 0 { - println!("Tried {} nonces...", nonce); - } - - if nonce == MAX_MINING_ATTEMPTS - 1 { - println!("WARNING: Reached maximum attempts without finding a match"); - } - } - - (commit_tx, reveal_tx) -} - -/// Broadcasts the commit and reveal transactions to the Bitcoin network using Esplora API -pub async fn broadcast_inscription_txs( - config: &EsploraConfig, - commit_tx: &Transaction, - reveal_tx: &Transaction, -) -> Result<(Txid, Txid), Box> { - // Create an Esplora client - let builder = EsploraBuilder::new(&config.url); - let client = EsploraAsyncClient::::from_builder(builder)?; - - println!("Broadcasting commit transaction..."); - client.broadcast(commit_tx).await?; - let commit_txid = commit_tx.compute_txid(); - println!("Commit transaction broadcast successfully: {}", commit_txid); - - // Wait for commit transaction to propagate - println!("Waiting for commit transaction to propagate..."); - tokio::time::sleep(std::time::Duration::from_secs(PROPAGATION_WAIT_SECS)).await; - - println!("Broadcasting reveal transaction..."); - client.broadcast(reveal_tx).await?; - let reveal_txid = reveal_tx.compute_txid(); - println!("Reveal transaction broadcast successfully: {}", reveal_txid); - - Ok((commit_txid, reveal_txid)) -} - -/// Fetches available UTXOs for the publisher address -pub async fn get_publisher_utxo( - publisher_address: &Address, - config: &EsploraConfig, - min_amount: Option, -) -> Result, Box> { - let builder = EsploraBuilder::new(&config.url); - let client = EsploraAsyncClient::::from_builder(builder)?; - - // Get all UTXOs for the address - let utxos = client.get_address_utxo(publisher_address.clone()).await?; - - // Find UTXOs with sufficient value - let required_amount = min_amount.unwrap_or(0); - let mut outpoints_with_sats = Vec::<(OutPoint, u64)>::new(); - let mut sats_amount_sum = 0; - - for utxo in utxos { - let sats = utxo.value.to_sat(); - outpoints_with_sats.push((OutPoint::new(utxo.txid, utxo.vout), sats)); - sats_amount_sum += sats; - } - - // Discard UTXOs if total amount is insufficient - if sats_amount_sum < required_amount { - outpoints_with_sats.clear(); - } - - Ok(outpoints_with_sats) -} - -/// Creates and broadcasts inscription transactions with the given commitment data -pub async fn create_and_broadcast_inscription( - commitment_data: &[u8], - config: &EsploraConfig, -) -> Result, Box> { - // Generate publisher address - let publisher_key = &*crate::PUBLISHER_KEY; - let secp256k1 = Secp256k1::new(); - let sk = SecretKey::from_str(publisher_key)?; - let key_pair = secp256k1::Keypair::from_secret_key(&secp256k1, &sk); - let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); - let network = config.network(); - let publisher_address = Address::p2tr(&secp256k1, public_key, None, network); - println!("Publisher address: {}", publisher_address); - - // Fetch UTXOs - println!("Fetching UTXOs..."); - let outpoints_with_sats = - get_publisher_utxo(&publisher_address, config, Some(MIN_INSCRIPTION_AMOUNT)).await?; - - if outpoints_with_sats.is_empty() { - eprintln!( - "ERROR: No UTXOs found for publisher address {}. Fund it to continue.", - publisher_address - ); - return Err( - "No UTXOs available for inscription broadcast — publisher wallet is empty".into(), - ); - } - - // Log found UTXOs - for (outpoint, sats) in &outpoints_with_sats { - println!( - "Found UTXO: {}:{} with value {} sats", - outpoint.txid, outpoint.vout, sats - ); - } - - // Create the inscription transactions - let (commit_tx, reveal_tx) = inscription_txs( - commitment_data, - &publisher_address, - outpoints_with_sats, - publisher_key, - config, - ); - - // Print transaction IDs - println!("\nCommit TX ID: {}", commit_tx.compute_txid()); - println!("Reveal TX ID: {}", reveal_tx.compute_txid()); - - // Broadcast the transactions - match broadcast_inscription_txs(config, &commit_tx, &reveal_tx).await { - Ok((commit_txid, reveal_txid)) => { - println!("Successfully broadcast transactions:"); - println!("Commit TXID: {}", commit_txid); - println!("Reveal TXID: {}", reveal_txid); - Ok(Some((commit_txid, reveal_txid))) - } - Err(e) => { - println!("Failed to broadcast transactions: {}", e); - Err(e) - } - } -} diff --git a/server/src/scanner_runtime.rs b/server/src/scanner_runtime.rs deleted file mode 100644 index b7b69c52..00000000 --- a/server/src/scanner_runtime.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Runtime bootstrap for the inscription scanner. -//! -//! This file is intentionally excluded from the coverage scope. The -//! functions below own the network I/O (HTTP polling against the -//! Esplora REST API), the infinite scan loop, and a Bitcoin-mainnet- -//! style sleep cadence — none of which can be exercised by unit tests -//! without spinning up a fake Esplora server. -//! -//! The pure logic that can be tested without a Bitcoin node lives in -//! `scanner.rs` (filter_marker_txids, process_transaction_inscriptions, -//! extract_inscription_content) and is measured normally. - -use bitcoin::{BlockHash, Transaction, Txid}; -use esplora_client::r#async::DefaultSleeper; -use esplora_client::{AsyncClient, Builder, Error as EsploraError, Sleeper}; -use std::collections::HashSet; -use std::error::Error as StdError; -use std::time::Duration; - -use crate::publisher::{EsploraConfig, INSCRIPTION_MARKER_PREFIX}; -use crate::scanner::{filter_marker_txids, process_transaction_inscriptions, InscriptionCallback}; - -struct InscriptionScanner { - client: AsyncClient, - processed_blocks: HashSet, - current_block_hash: Option, -} - -impl InscriptionScanner { - fn new(client: AsyncClient) -> Self { - Self { - client, - processed_blocks: HashSet::new(), - current_block_hash: None, - } - } - - /// Scans the blockchain starting from the given block hash - async fn scan_from_block( - &mut self, - start_block_hash: BlockHash, - callback: &InscriptionCallback, - ) -> Result<(), EsploraError> { - let mut current_hash = start_block_hash; - let poll_interval = Duration::from_secs(30); - - loop { - self.current_block_hash = Some(current_hash); - - if self.processed_blocks.contains(¤t_hash) { - println!( - "Reached previously processed block or chain tip. Waiting for new blocks..." - ); - tokio::time::sleep(poll_interval).await; - - let tip_hash = match self.client.get_tip_hash().await { - Ok(hash) => hash, - Err(e) => { - println!("Error getting tip hash: {}", e); - tokio::time::sleep(poll_interval).await; - continue; - } - }; - - if self.processed_blocks.contains(&tip_hash) { - continue; - } - - current_hash = tip_hash; - continue; - } - - println!("Processing block: {}", current_hash); - - let txids = match self.client.get_block_txids(current_hash).await { - Ok(txids) => txids, - Err(e) => { - println!("Error fetching block txids {}: {}", current_hash, e); - tokio::time::sleep(poll_interval).await; - continue; - } - }; - - let marker_bytes = hex::decode(INSCRIPTION_MARKER_PREFIX).unwrap_or_default(); - let matching_txids: Vec = filter_marker_txids(txids, &marker_bytes); - - for txid in matching_txids { - println!("Found transaction with marker prefix: {}", txid); - match self.client.get_tx(&txid).await { - Ok(Some(tx)) => { - self.process_transaction(&tx, callback).await?; - } - Ok(None) => { - println!("Transaction {} not found", txid); - } - Err(e) => { - println!("Error fetching transaction {}: {}", txid, e); - } - } - } - - self.processed_blocks.insert(current_hash); - - let block_status = self.client.get_block_status(¤t_hash).await?; - match block_status.next_best { - Some(next_hash) => current_hash = next_hash, - None => { - println!("Reached chain tip. Waiting for new blocks..."); - tokio::time::sleep(poll_interval).await; - - match self.client.get_tip_hash().await { - Ok(tip_hash) => { - if self.processed_blocks.contains(&tip_hash) { - continue; - } - current_hash = tip_hash; - } - Err(e) => { - println!("Error getting tip hash: {}", e); - tokio::time::sleep(poll_interval).await; - continue; - } - } - } - } - } - } - - async fn process_transaction( - &self, - tx: &Transaction, - callback: &InscriptionCallback, - ) -> Result<(), EsploraError> { - if let Some(current_hash) = self.current_block_hash { - process_transaction_inscriptions(tx, current_hash, callback); - } - Ok(()) - } -} - -/// Scans for inscription transactions in the blockchain. -pub async fn scan_for_inscriptions( - config: &EsploraConfig, - start_block_hash: BlockHash, - callback: &InscriptionCallback, -) -> Result<(), Box> { - let builder = Builder::new(&config.url); - let client = AsyncClient::::from_builder(builder)?; - let mut scanner = InscriptionScanner::new(client); - - scanner.scan_from_block(start_block_hash, callback).await?; - - Ok(()) -} diff --git a/server/src/server.rs b/server/src/server.rs deleted file mode 100644 index 7d886a2f..00000000 --- a/server/src/server.rs +++ /dev/null @@ -1,1155 +0,0 @@ -use axum::{ - body::Bytes, - extract::{Json, Path, State}, - http::{header, Method, StatusCode}, - response::IntoResponse, - routing::{get, post}, - Router, -}; -use bitcoin::secp256k1::{self as secp, schnorr::Signature as SchnorrSignature, Message}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use shared::commitment::Commitment; -#[cfg(feature = "faucet")] -use shared::ClientAccount; -use shared::{Invoice, ProofData}; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; -use tower_http::cors::CorsLayer; -use zkcoins_prover::Proof; - -use crate::account_server::{AccountServer, CoinProof}; -#[cfg(feature = "faucet")] -use crate::publisher::create_and_broadcast_inscription; -use crate::username::UsernameStore; -use crate::NETWORK_CONFIG; - -/// Verify a Schnorr signature over send request fields. -/// Message = SHA256(account_address || recipient || amount || timestamp) -fn verify_send_signature(request: &SendCoinRequest) -> Result<(), &'static str> { - let signature_hex = request.signature.as_deref().ok_or("Missing signature")?; - let timestamp = request.timestamp.ok_or("Missing timestamp")?; - - // Reject requests older than 5 minutes - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - if now.abs_diff(timestamp) > 300 { - return Err("Request timestamp too old or in the future"); - } - - // Build the message: SHA256(account_address || recipient || amount || timestamp) - let mut hasher = Sha256::new(); - hasher.update(request.account_address.as_bytes()); - hasher.update(request.recipient.as_bytes()); - hasher.update(request.amount.to_le_bytes()); - hasher.update(timestamp.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let sig_bytes = hex::decode(signature_hex).or(Err("Invalid signature hex"))?; - let sig = - SchnorrSignature::from_slice(&sig_bytes).or(Err("Invalid Schnorr signature format"))?; - - let (xonly, _parity) = request.public_key.x_only_public_key(); - let secp = secp::Secp256k1::verification_only(); - - secp.verify_schnorr(&sig, &msg, &xonly) - .or(Err("Signature verification failed")) -} - -/// Lock a mutex, recovering from poison if a previous holder panicked. -/// This prevents cascade failures where one panic takes down all handlers. -pub(crate) fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(|poisoned| { - eprintln!("WARNING: Recovering from poisoned mutex"); - poisoned.into_inner() - }) -} - -// Define a struct for our application state -#[derive(Clone)] -pub(crate) struct AppState { - pub(crate) account_server: Arc>, - pub(crate) proof_store: Arc, - #[cfg(feature = "faucet")] - pub(crate) minting_account: Arc>, - pub(crate) username_store: Arc>, - pub(crate) accounts_path: String, - #[cfg(feature = "usernames")] - pub(crate) usernames_path: String, -} - -// Response types for our API -#[derive(Serialize, Deserialize)] -pub struct BalanceResponse { - balance: u64, - #[serde(skip_serializing_if = "Option::is_none")] - username: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct AddressesResponse { - addresses: Vec, -} - -#[derive(Deserialize)] -pub struct SendCoinRequest { - account_address: String, - recipient: String, - amount: u64, - public_key: bitcoin::secp256k1::PublicKey, - next_public_key: bitcoin::secp256k1::PublicKey, - prev_commitment_pubkey: Option, - signature: Option, - timestamp: Option, -} - -#[cfg(feature = "faucet")] -#[derive(Deserialize)] -pub struct MintRequest { - account_address: String, - amount: u64, -} - -#[derive(Deserialize)] -pub struct ReceiveCoinRequest { - #[allow(dead_code)] - coin_proof: Proof, -} - -/// Persistent proof store — survives server restarts. -/// Each proof is stored as an individual file: /data/proofs/{id}.bin -pub(crate) struct ProofStore { - dir: String, - next_id: AtomicU64, -} - -impl ProofStore { - pub(crate) fn new(dir: &str) -> Self { - std::fs::create_dir_all(dir).ok(); - // Scan existing files to find the highest ID - let max_id = std::fs::read_dir(dir) - .ok() - .map(|entries| { - entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - e.file_name() - .to_str()? - .strip_suffix(".bin")? - .parse::() - .ok() - }) - .max() - .unwrap_or(0) - }) - .unwrap_or(0); - - ProofStore { - dir: dir.to_string(), - next_id: AtomicU64::new(max_id + 1), - } - } - - /// Build a safe file path for a proof ID within the store directory. - /// The ID is always a server-generated u64 and the suffix is the - /// literal ".bin", so `base.join(...)` cannot escape `base` — no - /// extra starts_with check is needed. - fn proof_path(&self, id: u64) -> Option { - let base = std::path::Path::new(&self.dir).canonicalize().ok()?; - Some(base.join(format!("{}.bin", id))) - } - - fn add_proof(&self, proof_with_commitment: CoinProof) -> u64 { - let id = self.next_id.fetch_add(1, Ordering::SeqCst); - let path = self - .proof_path(id) - .expect("proof store directory exists (created in ProofStore::new)"); - let bytes = - bincode::serialize(&proof_with_commitment).expect("CoinProof is always serializable"); - Self::persist_proof_bytes(&path, &bytes, id); - id - } - - /// Best-effort persist: write `bytes` to `path` atomically, log the - /// I/O error if the write fails. Extracted so the error arm can be - /// exercised directly without having to construct a real `CoinProof` - /// (which requires the SP1 prover to run). - fn persist_proof_bytes(path: &std::path::Path, bytes: &[u8], id: u64) { - if let Err(e) = crate::atomic_write(path.to_str().unwrap_or(""), bytes) { - eprintln!("Failed to persist proof {}: {}", id, e); - } - } - - fn get_proof(&self, id: u64) -> Option { - let path = self.proof_path(id)?; - let bytes = std::fs::read(&path).ok()?; - bincode::deserialize(&bytes).ok() - } -} - -#[derive(Serialize, Default)] -pub struct SendCoinResponse { - pub(crate) success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) proof_id: Option, - /// Hex-encoded hash fields the client needs to create a commitment (only set for user sends). - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) account_state_hash: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) output_coins_root: Option, -} - -#[derive(Deserialize)] -pub struct CommitRequest { - proof_id: u64, - /// Hex-encoded compressed public key (33 bytes) that signed the commitment. - public_key: bitcoin::secp256k1::PublicKey, - /// Hex-encoded Schnorr signature (64 bytes). - signature: String, - /// Hex-encoded message that was signed (the concatenation of account_state_hash + output_coins_root). - message: String, -} - -#[derive(Serialize, Deserialize)] -pub struct InfoResponse { - network: String, -} - -// --- Username & LNURL types --- - -#[cfg(feature = "usernames")] -#[derive(Deserialize)] -pub struct ClaimUsernameRequest { - username: String, - address: String, - public_key: bitcoin::secp256k1::PublicKey, - signature: String, - timestamp: u64, -} - -#[derive(Serialize, Deserialize)] -pub struct UsernameResponse { - username: String, - address: String, -} - -#[derive(Serialize, Deserialize)] -pub struct LnurlpResponse { - tag: String, - callback: String, - #[serde(rename = "minSendable")] - min_sendable: u64, - #[serde(rename = "maxSendable")] - max_sendable: u64, - metadata: String, -} - -#[derive(Serialize, Deserialize)] -pub struct LnurlErrorResponse { - status: String, - reason: String, -} - -// Handler functions for our REST API -async fn get_balance_handler( - State(state): State, - axum::extract::Query(params): axum::extract::Query>, -) -> impl IntoResponse { - let account_server = lock_or_recover(&state.account_server); - - // Check if an address parameter was provided - if let Some(address_hex) = params.get("address") { - // Convert hex string to Address type - let address_vec = match hex::decode(address_hex.trim_start_matches("0x")) { - Ok(addr) => addr, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(BalanceResponse { - balance: 0, - username: None, - }), - ) - } - }; - - // Convert Vec to [u8; 32] - let mut address = [0u8; 32]; - if address_vec.len() == 32 { - address.copy_from_slice(&address_vec); - } else { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(BalanceResponse { - balance: 0, - username: None, - }), - ); - } - - // Get balance for the specific account - let username = { - let username_store = lock_or_recover(&state.username_store); - username_store.get_username(&address).map(String::from) - }; - match account_server.get_account_balance(&address) { - Ok(balance) => (StatusCode::OK, Json(BalanceResponse { balance, username })), - Err(_) => ( - StatusCode::NOT_FOUND, - Json(BalanceResponse { - balance: 0, - username: None, - }), - ), - } - } else { - ( - StatusCode::NOT_FOUND, - Json(BalanceResponse { - balance: 0, - username: None, - }), - ) - } -} - -#[cfg(feature = "address-list")] -async fn get_address_handler(State(state): State) -> impl IntoResponse { - let account_server = lock_or_recover(&state.account_server); - - // Convert addresses to hex strings - let hex_addresses: Vec = account_server - .get_addresses() - .iter() - .map(|addr| format!("0x{}", hex::encode(addr))) - .collect(); - - Json(AddressesResponse { - addresses: hex_addresses, - }) -} - -async fn receive_coin_handler( - State(state): State, - body: Bytes, // Accept raw binary data instead of multipart -) -> impl IntoResponse { - // Try to deserialize the binary data as a CoinProof - match bincode::deserialize::(&body) { - Ok(coin_proof) => { - let mut account_server = lock_or_recover(&state.account_server); - match account_server.receive_coin(coin_proof) { - Ok(_) => Json(SendCoinResponse { - success: true, - ..Default::default() - }), - Err(_) => Json(SendCoinResponse::default()), - } - } - Err(e) => { - eprintln!("Failed to deserialize proof with commitment: {}", e); - Json(SendCoinResponse::default()) - } - } -} - -async fn send_coin_handler( - State(state): State, - Json(request): Json, -) -> impl IntoResponse { - println!("Received send post request..."); - - // Verify sender signature if provided (graceful: skip if not present for backwards compat) - if request.signature.is_some() { - if let Err(e) = verify_send_signature(&request) { - eprintln!("Signature verification failed: {}", e); - return (StatusCode::UNAUTHORIZED, Json(SendCoinResponse::default())); - } - } - - // Create converted addresses (from_address and to_address) - let from_address_vec = match hex::decode(request.account_address.trim_start_matches("0x")) { - Ok(addr) => addr, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse::default()), - ) - } - }; - let to_address_vec = match hex::decode(request.recipient.trim_start_matches("0x")) { - Ok(addr) => addr, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse::default()), - ) - } - }; - - // Convert Vec to [u8; 32] for both addresses - let mut from_address = [0u8; 32]; - let mut to_address = [0u8; 32]; - if from_address_vec.len() == 32 && to_address_vec.len() == 32 { - from_address.copy_from_slice(&from_address_vec); - to_address.copy_from_slice(&to_address_vec); - } else { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse::default()), - ); - } - - // TODO: Provide the correct public keys from the client - // Acquire the account_server lock only for the duration of sending coins. - let send_result = { - let mut account_server_lock = lock_or_recover(&state.account_server); - account_server_lock.send_coins( - vec![Invoice::new(request.amount, to_address)], - from_address, - request.public_key, - request.next_public_key, - request.prev_commitment_pubkey, - ) - // NOTE: accounts are NOT saved here — proof must be persisted first - }; - - eprintln!( - "Send result: {}", - if send_result.is_ok() { "ok" } else { "err" } - ); - - match send_result { - Ok(mut coin_proofs) => { - // Extract proof data so the client can create a commitment. - // The SP1 prover always emits a valid ProofData in public_values, - // so the deserialize cannot fail in practice. - let pd = - bincode::deserialize::(&coin_proofs[0].proof.public_values.to_vec()) - .expect("SP1 prover emits valid ProofData public_values"); - let ash_hex = Some(hex::encode(pd.account_state_hash)); - let ocr_hex = Some(hex::encode(pd.output_coins_root)); - - // Mint flow only — broadcasting a pre-set commitment is the - // server-signed minting path. The mint endpoint is feature- - // gated, so in the MVP build coin_proofs[0].commitment is - // always None and this block is excluded entirely. - #[cfg(feature = "faucet")] - if let Some(commitment) = coin_proofs[0].commitment.as_ref() { - let commitment_data = - bincode::serialize(commitment).expect("Failed to serialize commitment"); - println!("Broadcasting commitment ({} bytes)", commitment_data.len()); - if let Err(err) = - create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await - { - eprintln!("Error broadcasting inscription: {}", err); - } - } - - // Persist proof FIRST (crash-safe: proof exists even if - // account save fails). send_coins always returns a non-empty - // Vec on Ok, so pop().unwrap() is total here. - let proof_id = state.proof_store.add_proof( - coin_proofs - .pop() - .expect("send_coins returns at least one coin_proof on Ok"), - ); - // Now persist accounts (proof is already safe on disk) - { - let account_server_lock = lock_or_recover(&state.account_server); - if let Err(e) = account_server_lock.save_to_file(&state.accounts_path) { - eprintln!("Failed to persist accounts after send: {}", e); - } - } - - ( - StatusCode::OK, - Json(SendCoinResponse { - success: true, - proof_id: Some(proof_id), - account_state_hash: ash_hex, - output_coins_root: ocr_hex, - }), - ) - } - Err(_) => ( - StatusCode::OK, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ), - } -} - -#[cfg(feature = "faucet")] -async fn mint_handler( - State(state): State, - Json(request): Json, -) -> impl IntoResponse { - println!("Minting coins..."); - let account_address_vec = match hex::decode(request.account_address.trim_start_matches("0x")) { - Ok(addr) => addr, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse::default()), - ) - } - }; - - let mut account_address = [0u8; 32]; - if account_address_vec.len() == 32 { - account_address.copy_from_slice(&account_address_vec); - } else { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse::default()), - ); - } - - // Generate keys and get necessary info while holding the minting_account lock briefly - let (minting_pubkey, next_minting_pubkey, prev_commitment_pubkey, num_pubkeys_before_mint) = { - let minting_account_guard = lock_or_recover(&state.minting_account); - let current_num_pubkeys = minting_account_guard.num_pubkeys; - let prev_pk = if current_num_pubkeys > 0 { - Some(minting_account_guard.generate_public_key(current_num_pubkeys - 1)) - } else { - None - }; - ( - minting_account_guard.generate_public_key(current_num_pubkeys), - minting_account_guard.generate_public_key(current_num_pubkeys + 1), - prev_pk, - current_num_pubkeys, - ) - }; - - // Acquire the account_server lock only for the duration of sending coins. - let send_result = { - let mut account_server_guard = lock_or_recover(&state.account_server); - let minting_address = match account_server_guard.get_minting_account_address() { - Ok(addr) => addr, - Err(e) => { - eprintln!("Minting account not found: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(SendCoinResponse::default()), - ); - } - }; - account_server_guard.send_coins( - vec![Invoice::new(request.amount, account_address)], - minting_address, - minting_pubkey, - next_minting_pubkey, - prev_commitment_pubkey, - ) - }; - - match &send_result { - Ok(_) => eprintln!("Mint result: ok"), - Err(e) => eprintln!("Mint result: err — {}", e), - } - // Now that the locks are dropped, we can await safely. - match send_result { - Ok(mut coin_proofs) => { - // Increment num_pubkeys *after* successful send and before await - { - let mut minting_account_guard = lock_or_recover(&state.minting_account); - // Ensure we only increment if the send was successful and based on the state *before* the send - if minting_account_guard.num_pubkeys == num_pubkeys_before_mint { - minting_account_guard.num_pubkeys += 1; - // Persist the new counter so a server restart keeps the - // ClientAccount aligned with the on-disk server-side - // minting_account.proof. See the corresponding load in - // server_runtime.rs for the matching half. - // - // Same path-resolution logic as in server_runtime.rs: - // a relative accounts_path like "accounts.bin" has an - // empty parent; in that case fall back to "." so the - // counter lands next to accounts.bin, not at filesystem - // root. - let path = { - let parent = std::path::Path::new(&state.accounts_path).parent(); - let dir = match parent { - Some(p) if !p.as_os_str().is_empty() => p.display().to_string(), - _ => ".".to_string(), - }; - format!("{}/minting_num_pubkeys.bin", dir) - }; - if let Err(e) = - crate::atomic_write(&path, &minting_account_guard.num_pubkeys.to_le_bytes()) - { - eprintln!("Failed to persist minting num_pubkeys to {}: {}", path, e); - } - } else { - // This case might indicate a race condition or unexpected state change. - // Handle appropriately, maybe log an error or return a specific response. - eprintln!("WARNING: num_pubkeys changed unexpectedly during mint operation."); - } - let proof_data = match bincode::deserialize::( - &coin_proofs[0].proof.public_values.to_vec(), - ) { - Ok(data) => data, - Err(e) => { - eprintln!("Failed to deserialize proof data: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(SendCoinResponse::default()), - ); - } - }; - coin_proofs[0].commitment = Some(minting_account_guard.create_commitment( - &proof_data.account_state_hash, - &proof_data.output_coins_root, - )); - // minting_account_guard is dropped here - } - - let commitment = coin_proofs[0] - .commitment - .as_ref() - .expect("Commitment must be set after mint"); - let commitment_data = - bincode::serialize(commitment).expect("Failed to serialize commitment"); - - println!( - "Sending commitment data with size: {} bytes", - commitment_data.len() - ); - println!("Commitment data hex: {}", hex::encode(&commitment_data)); - - // This await is now safe because no locks are held across it. - // - // The broadcast can fail for benign reasons in DEV environments - // (e.g. the Mutinynet publisher wallet has no UTXOs). When the - // operator opts in via `DEV_SKIP_BROADCAST_FAILURE=true`, we - // log the error and continue: the recipient still gets the - // server-side credit so E2E tests can proceed. The on-chain - // commitment is missing — subsequent mints / sends that depend - // on the SMT having this entry will fail until state is wiped. - // - // NEVER set this in PRD. On the default code path (env var - // unset / != "true"), the handler returns 503 as before. - if let Err(err) = - create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await - { - eprintln!("Error broadcasting mint inscription: {}", err); - if std::env::var("DEV_SKIP_BROADCAST_FAILURE").unwrap_or_default() != "true" { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(SendCoinResponse::default()), - ); - } - eprintln!( - "DEV_SKIP_BROADCAST_FAILURE=true — continuing without on-chain commitment" - ); - } - { - let mut account_server_guard = lock_or_recover(&state.account_server); - for coin_proof in &coin_proofs { - if let Err(e) = account_server_guard.receive_coin(coin_proof.clone()) { - eprintln!("Failed to receive minted coin: {}", e); - } - } - if let Err(e) = account_server_guard.save_to_file(&state.accounts_path) { - eprintln!("Failed to persist accounts after mint: {}", e); - } - } - - let proof_id = match coin_proofs.pop() { - Some(proof) => state.proof_store.add_proof(proof), - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(SendCoinResponse::default()), - ); - } - }; - ( - StatusCode::OK, - Json(SendCoinResponse { - success: true, - proof_id: Some(proof_id), - account_state_hash: None, - output_coins_root: None, - }), - ) - } - Err(_) => (StatusCode::OK, Json(SendCoinResponse::default())), - } -} - -// New handler to get a binary proof by ID -async fn get_proof_handler( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - match state.proof_store.get_proof(id) { - Some(proof_with_commitment) => { - // Serialize the proof and commitment together to binary - let binary_data = bincode::serialize(&proof_with_commitment).unwrap_or_default(); - - // Set appropriate headers for binary download - let mut headers = header::HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("application/octet-stream"), - ); - headers.insert( - header::CONTENT_DISPOSITION, - header::HeaderValue::from_static("attachment; filename=\"coin_proof.bin\""), - ); - - (StatusCode::OK, headers, Bytes::from(binary_data)) - } - None => ( - StatusCode::NOT_FOUND, - header::HeaderMap::new(), - Bytes::new(), - ), - } -} - -/// Accepts a client-signed commitment for a previously generated proof. -/// Broadcasts the commitment as a Taproot inscription and delivers the coin to the recipient. -async fn commit_handler( - State(state): State, - Json(request): Json, -) -> impl IntoResponse { - // Retrieve the stored coin proof - let coin_proof = match state.proof_store.get_proof(request.proof_id) { - Some(p) => p, - None => { - return ( - StatusCode::NOT_FOUND, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ); - } - }; - - // Reconstruct the Commitment from the client-provided fields - let message_bytes = match hex::decode(&request.message) { - Ok(b) => b, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ); - } - }; - let sig_bytes = match hex::decode(&request.signature) { - Ok(b) => b, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ); - } - }; - let signature = match bitcoin::secp256k1::schnorr::Signature::from_slice(&sig_bytes) { - Ok(s) => s, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ); - } - }; - - let commitment = Commitment { - public_key: request.public_key, - signature, - message: message_bytes, - }; - - // Verify the commitment - if !commitment.verify() { - return ( - StatusCode::UNAUTHORIZED, - Json(SendCoinResponse { - success: false, - proof_id: None, - account_state_hash: None, - output_coins_root: None, - }), - ); - } - - crate::server_runtime::broadcast_commit_and_deliver( - &state, - commitment, - coin_proof, - request.proof_id, - ) - .await -} - -async fn info_handler() -> impl IntoResponse { - Json(InfoResponse { - network: NETWORK_CONFIG.network_name.clone(), - }) -} - -#[derive(Serialize)] -struct RootResponse { - service: &'static str, - version: &'static str, - network: String, - endpoints: RootEndpoints, - docs: &'static str, -} - -#[derive(Serialize)] -struct RootEndpoints { - info: &'static str, - balance: &'static str, - send: &'static str, - receive: &'static str, - commit: &'static str, - proof: &'static str, - health: &'static str, -} - -/// Root handler — anything hitting `https://api.zkcoins.app/` (browser visit, -/// uptime probe, curious operator) gets a small JSON identifying the service, -/// the package version, the connected network, and pointers to the real -/// endpoints. Cheaper than serving a static landing page and still answers the -/// "is this the right host?" question without surfacing a bare 404. -async fn root_handler() -> impl IntoResponse { - Json(RootResponse { - service: "zkcoins-server", - version: env!("CARGO_PKG_VERSION"), - network: NETWORK_CONFIG.network_name.clone(), - endpoints: RootEndpoints { - info: "GET /api/info", - balance: "GET /api/balance?address={hex}", - send: "POST /api/send", - receive: "POST /api/receive", - commit: "POST /api/commit", - proof: "GET /api/proof/{id}", - health: "GET /health", - }, - docs: "https://docs.zkcoins.app", - }) -} - -// --- Username & LNURL handlers --- - -#[cfg(feature = "usernames")] -async fn claim_username_handler( - State(state): State, - Json(request): Json, -) -> impl IntoResponse { - // Decode address - let address_vec = match hex::decode(request.address.trim_start_matches("0x")) { - Ok(a) => a, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Invalid address hex".into(), - }), - ) - .into_response() - } - }; - let mut address = [0u8; 32]; - if address_vec.len() != 32 { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Address must be 32 bytes".into(), - }), - ) - .into_response(); - } - address.copy_from_slice(&address_vec); - - // Verify public key matches address: sha256(compressed_pubkey) == address - let pk_hash: [u8; 32] = Sha256::digest(request.public_key.serialize()).into(); - if pk_hash != address { - return ( - StatusCode::UNAUTHORIZED, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Public key does not match address".into(), - }), - ) - .into_response(); - } - - // Verify timestamp freshness (5 min window) - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - if now.abs_diff(request.timestamp) > 300 { - return ( - StatusCode::UNAUTHORIZED, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Timestamp too old or in the future".into(), - }), - ) - .into_response(); - } - - // Verify Schnorr signature over sha256("zkcoins:claim_username" || address_hex || username || timestamp_le) - let mut hasher = Sha256::new(); - hasher.update(b"zkcoins:claim_username"); - hasher.update(request.address.as_bytes()); - hasher.update(request.username.as_bytes()); - hasher.update(request.timestamp.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let sig_bytes = match hex::decode(&request.signature) { - Ok(b) => b, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Invalid signature hex".into(), - }), - ) - .into_response() - } - }; - let sig = match SchnorrSignature::from_slice(&sig_bytes) { - Ok(s) => s, - Err(_) => { - return ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Invalid signature format".into(), - }), - ) - .into_response() - } - }; - let (xonly, _) = request.public_key.x_only_public_key(); - let secp = secp::Secp256k1::verification_only(); - if secp.verify_schnorr(&sig, &msg, &xonly).is_err() { - return ( - StatusCode::UNAUTHORIZED, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Signature verification failed".into(), - }), - ) - .into_response(); - } - - // Claim the username - let mut username_store = lock_or_recover(&state.username_store); - if let Err(e) = username_store.claim(&request.username, address) { - return ( - StatusCode::CONFLICT, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: e.into(), - }), - ) - .into_response(); - } - if let Err(e) = username_store.save_to_file(&state.usernames_path) { - eprintln!("Failed to persist usernames: {}", e); - } - - let normalized = request.username.to_lowercase(); - ( - StatusCode::OK, - Json(UsernameResponse { - username: normalized, - address: format!("0x{}", hex::encode(address)), - }), - ) - .into_response() -} - -/// Resolve an identifier to an address. Checks the username store first, -/// then falls back to hex-prefix matching against known account addresses. -/// Only used by the gated username and LNURL handlers. -#[cfg(any(feature = "usernames", feature = "lnurl"))] -fn resolve_identifier(state: &AppState, identifier: &str) -> Option<([u8; 32], String)> { - let normalized = identifier.to_lowercase(); - - // 1. Check custom username - let username_store = lock_or_recover(&state.username_store); - if let Some(address) = username_store.resolve(&normalized) { - return Some((address, normalized)); - } - drop(username_store); - - // 2. Check hex prefix against known addresses - let account_server = lock_or_recover(&state.account_server); - account_server - .get_addresses() - .into_iter() - .find(|addr| hex::encode(addr).starts_with(&normalized)) - .map(|addr| (addr, normalized)) -} - -#[cfg(feature = "usernames")] -async fn resolve_username_handler( - State(state): State, - Path(username): Path, -) -> impl IntoResponse { - match resolve_identifier(&state, &username) { - Some((address, resolved_name)) => ( - StatusCode::OK, - Json(UsernameResponse { - username: resolved_name, - address: format!("0x{}", hex::encode(address)), - }), - ) - .into_response(), - None => ( - StatusCode::NOT_FOUND, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Username not found".into(), - }), - ) - .into_response(), - } -} - -#[cfg(feature = "lnurl")] -async fn lnurlp_handler( - State(state): State, - Path(username): Path, - headers: axum::http::HeaderMap, -) -> impl IntoResponse { - if resolve_identifier(&state, &username).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "User not found".into(), - }), - ) - .into_response(); - } - - let host = headers - .get("host") - .and_then(|h| h.to_str().ok()) - .unwrap_or("api.zkcoins.app"); - let scheme = if host.contains("localhost") { - "http" - } else { - "https" - }; - let normalized = username.to_lowercase(); - let callback = format!("{}://{}/lnurl/pay/{}", scheme, host, normalized); - let metadata = format!( - "[[\"text/plain\",\"Pay {} on zkCoins\"],[\"text/identifier\",\"{}@zkcoins.app\"]]", - normalized, normalized - ); - - ( - StatusCode::OK, - Json(LnurlpResponse { - tag: "payRequest".into(), - callback, - min_sendable: 1_000, - max_sendable: 1_000_000_000_000, - metadata, - }), - ) - .into_response() -} - -#[cfg(feature = "lnurl")] -async fn lnurl_callback_handler( - State(_state): State, - Path(_username): Path, -) -> impl IntoResponse { - Json(LnurlErrorResponse { - status: "ERROR".into(), - reason: "Lightning payments coming soon (Phase 2)".into(), - }) -} - -/// Build the full application router with all API routes, CORS, health check, and fallback. -/// Extracted so it can be reused in integration tests via `oneshot()`. -pub(crate) fn create_router(state: AppState) -> Router { - let cors = CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods([Method::GET, Method::POST]) - .allow_headers([header::CONTENT_TYPE]); - - // MVP routes — always compiled in. - let app = Router::new() - .route("/", get(root_handler)) - .route("/health", get(|| async { "ok" })) - .route("/api/info", get(info_handler)) - .route("/api/balance", get(get_balance_handler)) - .route("/api/send", post(send_coin_handler)) - .route("/api/receive", post(receive_coin_handler)) - .route("/api/proof/:id", get(get_proof_handler)) - .route("/api/commit", post(commit_handler)); - - // Gated routes — only compiled in when their Cargo feature is enabled. - // With a feature off, the handler does not exist in the binary and the - // route is not registered, so the endpoint returns 404 via the fallback - // and there is no code path to execute. - #[cfg(feature = "address-list")] - let app = app.route("/api/address", get(get_address_handler)); - - #[cfg(feature = "faucet")] - let app = app.route("/api/mint", post(mint_handler)); - - #[cfg(feature = "usernames")] - let app = app - .route("/api/username/claim", post(claim_username_handler)) - .route( - "/api/username/resolve/:username", - get(resolve_username_handler), - ); - - #[cfg(feature = "lnurl")] - let app = app - .route("/.well-known/lnurlp/:username", get(lnurlp_handler)) - .route("/lnurl/pay/:username", get(lnurl_callback_handler)); - - app.with_state(state) - .fallback(|| async { StatusCode::NOT_FOUND }) - .layer(cors) -} - -#[cfg(test)] -#[path = "server_tests.rs"] -mod tests; diff --git a/server/src/server_runtime.rs b/server/src/server_runtime.rs deleted file mode 100644 index d3681fbb..00000000 --- a/server/src/server_runtime.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Runtime bootstrap: binds a TCP listener and runs the Axum app. -//! -//! This file is intentionally excluded from the coverage scope. The -//! function below cannot be exercised by unit tests — it owns the -//! process lifecycle (port binding, signal-driven shutdown via axum) -//! and exists purely to wire the dependency graph defined in -//! `server.rs` to a real network socket. -//! -//! Anything that is testable in isolation (handlers, helpers, the -//! router construction in `create_router`) stays in `server.rs` and -//! is measured normally. - -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; - -use axum::http::StatusCode; -use axum::Json; -use shared::commitment::Commitment; -use tokio::net::TcpListener; - -use crate::account_server::CoinProof; -use crate::publisher::create_and_broadcast_inscription; -use crate::server::{lock_or_recover, SendCoinResponse}; -use crate::NETWORK_CONFIG; - -#[cfg(feature = "faucet")] -use bitcoin::bip32::Xpriv; -#[cfg(feature = "faucet")] -use shared::ClientAccount; - -use crate::account_server::AccountServer; -use crate::server::{create_router, AppState, ProofStore}; -use crate::username::UsernameStore; - -pub async fn start_rest_server( - account_server: AccountServer, - username_store: UsernameStore, - addr: &str, - accounts_path: String, - #[cfg_attr(not(feature = "usernames"), allow(unused_variables))] usernames_path: String, -) -> anyhow::Result<()> { - let socket_addr = addr - .parse::() - .map_err(|e| anyhow::anyhow!("Failed to parse address: {}", e))?; - - let shared_account_server = Arc::new(Mutex::new(account_server)); - - let proofs_dir = format!( - "{}/proofs", - std::path::Path::new(&accounts_path) - .parent() - .unwrap_or(std::path::Path::new(".")) - .display() - ); - let proof_store = Arc::new(ProofStore::new(&proofs_dir)); - - #[cfg(feature = "faucet")] - let minting_account = { - let secret = include_bytes!("../minting_secret.bin"); - let private_key = Xpriv::new_master(NETWORK_CONFIG.network(), secret) - .expect("Failed to create private key."); - println!( - "Set MINTING_ADDRESS to {:?}", - &zkcoins_program::MINTING_ADDRESS - ); - let mut minting_client = ClientAccount::new(private_key); - // ClientAccount::new starts with num_pubkeys=0, but each successful - // mint increments it. The counter MUST survive process restarts; - // otherwise we lose alignment with the server-side - // minting_account.proof (which IS persisted), the next mint sends - // the wrong prev_commitment_pubkey, and send_coins fails with - // "prev_commitment_pubkey required for account update". - // - // Persist it in a tiny sibling file (4 bytes LE u32) next to - // accounts.bin. Read here, written in mint_handler after every - // successful increment. - // accounts_path is typically a relative path like "accounts.bin" - // (cwd-relative). Path::parent() returns Some("") for that, and - // `format!("{}/minting_num_pubkeys.bin", "")` gives the absolute - // path `/minting_num_pubkeys.bin` (filesystem root), not a - // sibling of accounts.bin. Resolve to "." in that case so the - // counter lands next to accounts.bin inside the data volume. - let minting_pubkeys_path = { - let parent = std::path::Path::new(&accounts_path).parent(); - let dir = match parent { - Some(p) if !p.as_os_str().is_empty() => p.display().to_string(), - _ => ".".to_string(), - }; - format!("{}/minting_num_pubkeys.bin", dir) - }; - if let Ok(bytes) = std::fs::read(&minting_pubkeys_path) { - if bytes.len() == 4 { - let n = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - println!( - "Loaded minting num_pubkeys={} from {}", - n, minting_pubkeys_path - ); - minting_client.num_pubkeys = n; - } - } - assert_eq!( - minting_client.address, - zkcoins_program::MINTING_ADDRESS, - "Minting account address mismatch — minting_secret.bin or MINTING_ADDRESS constant is wrong" - ); - Arc::new(Mutex::new(minting_client)) - }; - - let shared_username_store = Arc::new(Mutex::new(username_store)); - - let state = AppState { - account_server: shared_account_server, - proof_store, - #[cfg(feature = "faucet")] - minting_account, - username_store: shared_username_store, - accounts_path, - #[cfg(feature = "usernames")] - usernames_path, - }; - { - let mut account_server_guard = state.account_server.lock().unwrap(); - if account_server_guard.get_minting_account_address().is_err() { - let mut minting_server_account = crate::account_server::Account::new(); - minting_server_account.balance = u64::MAX; - account_server_guard - .import_account(zkcoins_program::MINTING_ADDRESS, minting_server_account); - if let Err(e) = account_server_guard.save_to_file(&state.accounts_path) { - eprintln!("Failed to save initial accounts file: {}", e); - } - } - } - - let app = create_router(state); - - println!("REST server started at {}", socket_addr); - let listener = TcpListener::bind(socket_addr).await?; - axum::serve(listener, app).await?; - - Ok(()) -} - -/// Broadcast the commit inscription and, on success, deliver the coin -/// to the recipient and persist the account state. This contains the -/// network call (Bitcoin broadcast) and the post-broadcast bookkeeping, -/// plus the success/failure response dispatch — all of which cannot be -/// exercised by unit tests, so the whole function lives in the runtime -/// module that is excluded from the coverage scope. -pub(crate) async fn broadcast_commit_and_deliver( - state: &AppState, - commitment: Commitment, - coin_proof: CoinProof, - proof_id: u64, -) -> (StatusCode, Json) { - let commitment_data = bincode::serialize(&commitment).expect("Failed to serialize commitment"); - println!( - "Broadcasting user commitment ({} bytes)", - commitment_data.len() - ); - if let Err(err) = create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG).await { - eprintln!("Error broadcasting commit inscription: {}", err); - // Mirror of the mint_handler tolerance: when - // DEV_SKIP_BROADCAST_FAILURE=true the operator opts into - // continuing without an on-chain commitment so E2E tests on a - // dry Mutinynet publisher still succeed. See the comment over - // the matching branch in server.rs::mint_handler. - if std::env::var("DEV_SKIP_BROADCAST_FAILURE").unwrap_or_default() != "true" { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(SendCoinResponse::default()), - ); - } - eprintln!("DEV_SKIP_BROADCAST_FAILURE=true — continuing without on-chain commitment"); - } - - let mut updated_proof = coin_proof; - updated_proof.commitment = Some(commitment); - let mut account_server_guard = lock_or_recover(&state.account_server); - if let Err(e) = account_server_guard.receive_coin(updated_proof) { - eprintln!("Failed to receive coin after commit: {}", e); - } - if let Err(e) = account_server_guard.save_to_file(&state.accounts_path) { - eprintln!("Failed to persist accounts after commit: {}", e); - } - - ( - StatusCode::OK, - Json(SendCoinResponse { - success: true, - proof_id: Some(proof_id), - account_state_hash: None, - output_coins_root: None, - }), - ) -} diff --git a/server/src/server_tests.rs b/server/src/server_tests.rs deleted file mode 100644 index 6d7bf830..00000000 --- a/server/src/server_tests.rs +++ /dev/null @@ -1,2078 +0,0 @@ -use super::*; -use axum::body::Body; -use axum::http::{Request, StatusCode}; -use http_body_util::BodyExt; -use tower::ServiceExt; - -use crate::account_server::{Account, AccountServer}; -use crate::state::State; - -/// Create a minimal AppState for testing. -/// The AccountServer is constructed with a real (mock) prover so that the -/// type system is satisfied, but we seed it with a minting account so that -/// balance / address queries work without needing the minting_secret.bin -/// flow. -fn test_state() -> AppState { - let state = Arc::new(Mutex::new(State::new())); - let mut account_server = AccountServer::new(Arc::clone(&state)); - - // Seed a minting account with max balance (mirrors production setup) - let mut minting_account = Account::new(); - minting_account.balance = u64::MAX; - account_server.import_account(zkcoins_program::MINTING_ADDRESS, minting_account); - - // Create a dummy minting ClientAccount from a deterministic key - #[cfg(feature = "faucet")] - let minting_client = { - let secret = include_bytes!("../minting_secret.bin"); - let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) - .expect("Failed to create test private key"); - shared::ClientAccount::new(private_key) - }; - - AppState { - account_server: Arc::new(Mutex::new(account_server)), - proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-test-proofs")), - #[cfg(feature = "faucet")] - minting_account: Arc::new(Mutex::new(minting_client)), - username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), - accounts_path: String::new(), - #[cfg(feature = "usernames")] - usernames_path: String::new(), - } -} - -/// Helper: send a request through the router and return (status, body string). -async fn send_request(request: Request) -> (StatusCode, String) { - let app = create_router(test_state()); - let response = app.oneshot(request).await.unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - (status, body) -} - -// --- GET /health --- - -#[tokio::test] -async fn health_returns_ok() { - let req = Request::get("/health").body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body, "ok"); -} - -// --- GET / (root) --- - -#[tokio::test] -async fn root_returns_service_metadata() { - let req = Request::get("/").body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - // Verify the response is JSON and contains the service identifier plus - // a pointer to /api/info — those two are enough to prove the handler - // ran and serialized correctly. - let json: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(json["service"], "zkcoins-server"); - assert_eq!(json["endpoints"]["info"], "GET /api/info"); - assert!(json["version"].as_str().is_some_and(|v| !v.is_empty())); - assert!(json["network"].as_str().is_some_and(|v| !v.is_empty())); -} - -// --- GET /api/info --- - -#[tokio::test] -async fn info_returns_network_name() { - let req = Request::get("/api/info").body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let info: InfoResponse = serde_json::from_str(&body).expect("valid JSON"); - // The lazy_static defaults to "Mutinynet" when IS_MAINNET is unset - assert!(!info.network.is_empty(), "network name must not be empty"); -} - -// --- GET /api/balance --- - -#[tokio::test] -async fn balance_unknown_address_returns_not_found() { - // 32 zero bytes in hex = 64 hex chars - let address_hex = "00".repeat(32); - let uri = format!("/api/balance?address={}", address_hex); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.balance, 0); - assert!(resp.username.is_none()); -} - -#[tokio::test] -async fn balance_minting_address_returns_max() { - let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let uri = format!("/api/balance?address={}", address_hex); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.balance, u64::MAX); -} - -#[tokio::test] -async fn balance_missing_address_param_returns_not_found() { - let req = Request::get("/api/balance").body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.balance, 0); - assert!(resp.username.is_none()); -} - -#[tokio::test] -async fn balance_invalid_hex_returns_unprocessable() { - let req = Request::get("/api/balance?address=not_valid_hex") - .body(Body::empty()) - .unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn balance_wrong_length_returns_unprocessable() { - // 16 bytes = 32 hex chars, but the handler expects exactly 32 bytes - let short_hex = "ab".repeat(16); - let uri = format!("/api/balance?address={}", short_hex); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -// --- GET /api/address --- - -#[cfg(feature = "address-list")] -#[tokio::test] -async fn address_returns_list() { - let req = Request::get("/api/address").body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: AddressesResponse = serde_json::from_str(&body).expect("valid JSON"); - // The test state has the minting address seeded - assert!( - !resp.addresses.is_empty(), - "should contain at least the minting address" - ); - assert!( - resp.addresses[0].starts_with("0x"), - "addresses should be 0x-prefixed" - ); -} - -// --- POST /api/send with missing fields --- - -#[tokio::test] -async fn send_missing_body_returns_error() { - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); - let (status, _body) = send_request(req).await; - - // Axum returns 422 when JSON deserialization fails (missing required fields) - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn send_invalid_json_returns_bad_request() { - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from("not json")) - .unwrap(); - let (status, _body) = send_request(req).await; - - // Axum returns 400 Bad Request for syntactically invalid JSON - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn send_no_content_type_returns_error() { - let req = Request::post("/api/send").body(Body::from("{}")).unwrap(); - let (status, _body) = send_request(req).await; - - // Axum returns 415 Unsupported Media Type when content-type is missing for Json extractor - assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); -} - -// --- POST /api/mint with missing fields --- - -#[cfg(feature = "faucet")] -#[tokio::test] -async fn mint_missing_body_returns_error() { - let req = Request::post("/api/mint") - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -// --- GET /api/proof/{id} for non-existent proof --- - -#[tokio::test] -async fn proof_not_found_returns_404() { - let req = Request::get("/api/proof/9999").body(Body::empty()).unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); -} - -// --- POST /api/commit with missing fields --- - -#[tokio::test] -async fn commit_missing_body_returns_error() { - let req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -// --- Fallback for unknown routes --- - -#[tokio::test] -async fn unknown_route_returns_404() { - let req = Request::get("/does-not-exist").body(Body::empty()).unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); -} - -// ======================================================================= -// Helper: send a request through a *shared* router (same AppState across -// calls) instead of creating a fresh test_state() for every request. -// ======================================================================= -async fn send_request_with_state(state: AppState, request: Request) -> (StatusCode, String) { - let app = create_router(state); - let response = app.oneshot(request).await.unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - (status, body) -} - -// --- GET /api/username/resolve/{username} --- - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn resolve_unknown_username_returns_404() { - let req = Request::get("/api/username/resolve/nonexistent") - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - - let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.status, "ERROR"); - assert!(resp.reason.contains("not found")); -} - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn resolve_minting_address_by_hex_prefix() { - // The minting address starts with "af53a1" — a short prefix is enough - // for resolve_identifier to match via hex-prefix fallback. - let full_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let prefix = &full_hex[..8]; // first 8 hex chars - - let uri = format!("/api/username/resolve/{}", prefix); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.address, format!("0x{}", full_hex)); - assert_eq!(resp.username, prefix); -} - -// --- POST /api/username/claim --- - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn claim_username_empty_body_returns_422() { - let req = Request::post("/api/username/claim") - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn claim_username_no_content_type_returns_415() { - let req = Request::post("/api/username/claim") - .body(Body::from("{}")) - .unwrap(); - let (status, _body) = send_request(req).await; - - assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); -} - -// --- GET /.well-known/lnurlp/{username} --- - -#[cfg(feature = "lnurl")] -#[tokio::test] -async fn lnurlp_unknown_user_returns_404() { - let req = Request::get("/.well-known/lnurlp/nobody") - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - - let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.status, "ERROR"); - assert!(resp.reason.contains("not found")); -} - -#[cfg(feature = "lnurl")] -#[tokio::test] -async fn lnurlp_known_address_returns_pay_request() { - // The minting address is resolvable by hex prefix through resolve_identifier. - let full_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let prefix = &full_hex[..8]; - - let uri = format!("/.well-known/lnurlp/{}", prefix); - let req = Request::get(&uri) - .header("host", "api.zkcoins.app") - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: LnurlpResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.tag, "payRequest"); - assert!( - resp.callback.contains(prefix), - "callback should include the identifier" - ); - assert_eq!(resp.min_sendable, 1_000); - assert_eq!(resp.max_sendable, 1_000_000_000_000); - assert!(resp.metadata.contains("zkCoins")); -} - -// --- GET /lnurl/pay/{username} --- - -#[cfg(feature = "lnurl")] -#[tokio::test] -async fn lnurl_pay_callback_returns_phase2_error() { - let req = Request::get("/lnurl/pay/someone") - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: LnurlErrorResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.status, "ERROR"); - assert!( - resp.reason.contains("Phase 2"), - "should mention Phase 2: {}", - resp.reason - ); -} - -// --- Balance includes username field --- - -#[tokio::test] -async fn balance_minting_address_has_no_username() { - let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let uri = format!("/api/balance?address={}", address_hex); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, body) = send_request(req).await; - - assert_eq!(status, StatusCode::OK); - - // username should be absent (skip_serializing_if = None) - let raw: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); - assert!( - raw.get("username").is_none() || raw["username"].is_null(), - "minting address without a claimed username should have no username field" - ); -} - -#[tokio::test] -async fn balance_includes_username_when_claimed() { - let state = test_state(); - - // Manually claim a username for the minting address - { - let mut username_store = state.username_store.lock().unwrap(); - username_store - .claim("satoshi", zkcoins_program::MINTING_ADDRESS) - .expect("claim should succeed"); - } - - let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let uri = format!("/api/balance?address={}", address_hex); - let req = Request::get(&uri).body(Body::empty()).unwrap(); - let (status, body) = send_request_with_state(state, req).await; - - assert_eq!(status, StatusCode::OK); - - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.balance, u64::MAX); - assert_eq!(resp.username, Some("satoshi".to_string())); -} - -// --- Concurrent balance reads --- - -#[tokio::test] -async fn concurrent_balance_reads_are_consistent() { - let state = test_state(); - let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - let uri = format!("/api/balance?address={}", address_hex); - - // Spawn many concurrent balance requests against the same shared state. - let mut handles = vec![]; - for _ in 0..20 { - let s = state.clone(); - let u = uri.clone(); - handles.push(tokio::spawn(async move { - let req = Request::get(&u).body(Body::empty()).unwrap(); - send_request_with_state(s, req).await - })); - } - - for handle in handles { - let (status, body) = handle.await.expect("task should not panic"); - assert_eq!(status, StatusCode::OK); - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!( - resp.balance, - u64::MAX, - "every concurrent read must see the same minting balance" - ); - } -} - -// --- Concurrent mixed reads and username operations --- - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn concurrent_reads_with_username_claim() { - let state = test_state(); - let address_hex = hex::encode(zkcoins_program::MINTING_ADDRESS); - - // Claim a username through the store directly (bypasses signature validation) - { - let mut store = state.username_store.lock().unwrap(); - store - .claim("testuser", zkcoins_program::MINTING_ADDRESS) - .unwrap(); - } - - // Spawn concurrent balance + resolve requests - let mut handles = vec![]; - - for i in 0..10 { - let s = state.clone(); - let hex = address_hex.clone(); - handles.push(tokio::spawn(async move { - if i % 2 == 0 { - // Balance request - let req = Request::get(&format!("/api/balance?address={}", hex)) - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request_with_state(s, req).await; - assert_eq!(status, StatusCode::OK); - let resp: BalanceResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.balance, u64::MAX); - assert_eq!(resp.username, Some("testuser".to_string())); - } else { - // Resolve request - let req = Request::get("/api/username/resolve/testuser") - .body(Body::empty()) - .unwrap(); - let (status, body) = send_request_with_state(s, req).await; - assert_eq!(status, StatusCode::OK); - let resp: UsernameResponse = serde_json::from_str(&body).expect("valid JSON"); - assert_eq!(resp.username, "testuser"); - assert_eq!(resp.address, format!("0x{}", hex)); - } - })); - } - - for handle in handles { - handle.await.expect("task should not panic"); - } -} - -// --- POST /api/commit with non-existent proof_id --- - -#[tokio::test] -async fn commit_nonexistent_proof_id_returns_404() { - let state = test_state(); - let body = serde_json::json!({ - "proof_id": 999999, - "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "signature": "00".repeat(64), - "message": "00".repeat(32), - }); - let req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap(); - let (status, _body) = send_request_with_state(state, req).await; - - assert_eq!(status, StatusCode::NOT_FOUND); -} - -// --- POST /api/commit with valid proof_id but invalid signature --- - -#[tokio::test] -async fn commit_invalid_signature_returns_error() { - // Submit a commit with a fabricated proof_id that does not exist but with - // a structurally valid body — the handler should return 404 (proof not found). - let commit_body = serde_json::json!({ - "proof_id": 99999, - "public_key": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "signature": "ab".repeat(64), - "message": "cd".repeat(32), - }); - let req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&commit_body).unwrap())) - .unwrap(); - let (status, _) = send_request(req).await; - - assert_eq!( - status, - StatusCode::NOT_FOUND, - "commit with non-existent proof_id must return 404" - ); -} - -// --- verify_send_signature tests --- - -#[test] -fn send_signature_rejects_missing_signature() { - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - prev_commitment_pubkey: None, - signature: None, - timestamp: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - ), - }; - let result = verify_send_signature(&request); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing signature")); -} - -#[test] -fn send_signature_rejects_missing_timestamp() { - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - prev_commitment_pubkey: None, - signature: Some("ab".repeat(64)), - timestamp: None, - }; - let result = verify_send_signature(&request); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing timestamp")); -} - -#[test] -fn send_signature_rejects_expired_timestamp() { - let old_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - - 600; // 10 minutes ago - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - prev_commitment_pubkey: None, - signature: Some("ab".repeat(64)), - timestamp: Some(old_timestamp), - }; - let result = verify_send_signature(&request); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("timestamp")); -} - -#[test] -fn send_signature_rejects_invalid_hex() { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - prev_commitment_pubkey: None, - signature: Some("not_valid_hex".to_string()), - timestamp: Some(now), - }; - let result = verify_send_signature(&request); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid signature hex")); -} - -#[test] -fn send_signature_rejects_wrong_signature() { - use bitcoin::secp256k1::SecretKey; - - let secp = secp::Secp256k1::new(); - let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); - let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Sign a DIFFERENT message than what verify_send_signature expects - let wrong_msg = Message::from_digest([0u8; 32]); - let (xonly, _) = public_key.x_only_public_key(); - let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); - let sig = secp.sign_schnorr(&wrong_msg, &keypair); - - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key, - next_public_key: public_key, - prev_commitment_pubkey: None, - signature: Some(hex::encode(sig.serialize())), - timestamp: Some(now), - }; - let result = verify_send_signature(&request); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Signature verification failed")); -} - -// --- POST /api/username/claim with valid Schnorr signature --- - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn claim_username_with_valid_signature() { - use bitcoin::secp256k1::{Keypair, SecretKey}; - - let secp = secp::Secp256k1::new(); - let secret = SecretKey::from_slice(&[7u8; 32]).unwrap(); - let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); - - // address = sha256(compressed_pubkey) - let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); - let address_hex = hex::encode(address); - - let username = "testclaim"; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Build claim message: sha256("zkcoins:claim_username" || address_hex || username || timestamp_le) - let mut hasher = Sha256::new(); - hasher.update(b"zkcoins:claim_username"); - hasher.update(address_hex.as_bytes()); - hasher.update(username.as_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let keypair = Keypair::from_secret_key(&secp, &secret); - let sig = secp.sign_schnorr(&msg, &keypair); - - // Import the address into the account_server so resolve_identifier can find it - let state = test_state(); - { - let mut account_server = state.account_server.lock().unwrap(); - account_server.import_account(address, Account::new()); - } - - let body = serde_json::json!({ - "username": username, - "address": address_hex, - "public_key": public_key.to_string(), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - - let req = Request::post("/api/username/claim") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap(); - let (status, resp_body) = send_request_with_state(state, req).await; - - assert_eq!( - status, - StatusCode::OK, - "Claim should succeed: {}", - resp_body - ); - - let resp: UsernameResponse = serde_json::from_str(&resp_body).expect("valid JSON"); - assert_eq!(resp.username, username); - assert_eq!(resp.address, format!("0x{}", address_hex)); -} - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn claim_username_wrong_pubkey() { - use bitcoin::secp256k1::{Keypair, SecretKey}; - - let secp = secp::Secp256k1::new(); - let secret = SecretKey::from_slice(&[8u8; 32]).unwrap(); - let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); - - // Use a DIFFERENT address that does NOT match sha256(pubkey) - let wrong_address: [u8; 32] = [0xAA; 32]; - let address_hex = hex::encode(wrong_address); - - let username = "wrongpk"; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Sign with the correct message format but the address doesn't match the pubkey - let mut hasher = Sha256::new(); - hasher.update(b"zkcoins:claim_username"); - hasher.update(address_hex.as_bytes()); - hasher.update(username.as_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let keypair = Keypair::from_secret_key(&secp, &secret); - let sig = secp.sign_schnorr(&msg, &keypair); - - let body = serde_json::json!({ - "username": username, - "address": address_hex, - "public_key": public_key.to_string(), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - - let req = Request::post("/api/username/claim") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap(); - let (status, _) = send_request(req).await; - - assert_eq!( - status, - StatusCode::UNAUTHORIZED, - "Claim with mismatched pubkey/address must be rejected" - ); -} - -#[cfg(feature = "usernames")] -#[tokio::test] -async fn claim_username_expired_timestamp() { - use bitcoin::secp256k1::{Keypair, SecretKey}; - - let secp = secp::Secp256k1::new(); - let secret = SecretKey::from_slice(&[9u8; 32]).unwrap(); - let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); - - let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); - let address_hex = hex::encode(address); - - let username = "expiredts"; - // Timestamp 10 minutes in the past (exceeds 5-min window) - let expired_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - - 600; - - let mut hasher = Sha256::new(); - hasher.update(b"zkcoins:claim_username"); - hasher.update(address_hex.as_bytes()); - hasher.update(username.as_bytes()); - hasher.update(expired_timestamp.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let keypair = Keypair::from_secret_key(&secp, &secret); - let sig = secp.sign_schnorr(&msg, &keypair); - - let body = serde_json::json!({ - "username": username, - "address": address_hex, - "public_key": public_key.to_string(), - "signature": hex::encode(sig.serialize()), - "timestamp": expired_timestamp, - }); - - let req = Request::post("/api/username/claim") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap(); - let (status, _) = send_request(req).await; - - assert_eq!( - status, - StatusCode::UNAUTHORIZED, - "Claim with expired timestamp must be rejected" - ); -} - -#[test] -fn send_signature_accepts_valid_signature() { - use bitcoin::secp256k1::SecretKey; - - let secp = secp::Secp256k1::new(); - let secret = SecretKey::from_slice(&[1u8; 32]).unwrap(); - let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); - - let account_address = "0x".to_string() + &hex::encode([1u8; 32]); - let recipient = "0x".to_string() + &hex::encode([2u8; 32]); - let amount: u64 = 100; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Build the exact same message as verify_send_signature - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); - let sig = secp.sign_schnorr(&msg, &keypair); - - let request = SendCoinRequest { - account_address, - recipient, - amount, - public_key, - next_public_key: public_key, - prev_commitment_pubkey: None, - signature: Some(hex::encode(sig.serialize())), - timestamp: Some(now), - }; - assert!(verify_send_signature(&request).is_ok()); -} - -// --- POST /api/send (happy path, exercises the full handler) --- - -#[tokio::test] -async fn send_with_valid_signature_returns_proof_id_and_hashes() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - - // Build the AppState the same way test_state() does so the handler can - // run through the entire send pipeline (signature -> SP1 mock prover -> - // proof persistence -> response). - let state = test_state(); - - // Derive the minting account's BIP-32 keys from the same secret the - // production code uses, so the SP1 prover's expectations line up with - // the account already seeded in test_state. - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = - Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).expect("test minting xpriv"); - let secp = secp::Secp256k1::new(); - - let derive_pk = |index: u32| -> PublicKey { - Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index }]) - .expect("derive_pub") - .public_key - }; - let derive_sk = |index: u32| -> SecretKey { - xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index }]) - .expect("derive_priv") - .private_key - }; - - let sk_0 = derive_sk(0); - let pk_0 = derive_pk(0); - let pk_1 = derive_pk(1); - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([1u8; 32]); - let amount: u64 = 100; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Build the exact same message the handler will hash for the signature. - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - - let msg = Message::from_digest(hash); - let keypair = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &keypair); - - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - - let app = create_router(state); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let response = app.oneshot(req).await.unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - - assert_eq!(status, StatusCode::OK, "body: {body}"); - let response_json: serde_json::Value = - serde_json::from_str(&body).expect("response is valid JSON"); - assert_eq!(response_json["success"], true); - assert!( - response_json["proof_id"].as_u64().is_some(), - "proof_id missing from response: {body}" - ); - assert!( - response_json["account_state_hash"].as_str().is_some(), - "account_state_hash missing: {body}" - ); - assert!( - response_json["output_coins_root"].as_str().is_some(), - "output_coins_root missing: {body}" - ); -} - -#[tokio::test] -async fn commit_with_bad_message_hex_returns_422() { - // Build a sendable state + perform a valid send first so a proof_id - // exists in the store, then send a commit that decodes-fails on the - // message hex. - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let derive_pk = |idx: u32| -> PublicKey { - Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .public_key - }; - let derive_sk = |idx: u32| -> SecretKey { - xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .private_key - }; - - let pk_0 = derive_pk(0); - let pk_1 = derive_pk(1); - let sk_0 = derive_sk(0); - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([2u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - let proof_id = send_resp["proof_id"].as_u64().unwrap(); - - // Now post a commit with garbage in the message hex. - let commit_body = serde_json::json!({ - "proof_id": proof_id, - "public_key": hex::encode(pk_0.serialize()), - "signature": hex::encode([0u8; 64]), - "message": "not-hex-at-all-zzzz", - }); - let commit_req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(commit_body.to_string())) - .unwrap(); - let (status, _body) = send_request_with_state(state, commit_req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn commit_with_bad_signature_hex_returns_422() { - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let derive_pk = |idx: u32| -> PublicKey { - Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .public_key - }; - let derive_sk = |idx: u32| -> SecretKey { - xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .private_key - }; - let pk_0 = derive_pk(0); - let pk_1 = derive_pk(1); - let sk_0 = derive_sk(0); - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([3u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - let proof_id = send_resp["proof_id"].as_u64().unwrap(); - - // Bad signature hex (odd length). - let commit_body = serde_json::json!({ - "proof_id": proof_id, - "public_key": hex::encode(pk_0.serialize()), - "signature": "zzz", - "message": hex::encode([0u8; 32]), - }); - let commit_req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(commit_body.to_string())) - .unwrap(); - let (status, _body) = send_request_with_state(state, commit_req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn commit_with_unverifiable_commitment_returns_401() { - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let derive_pk = |idx: u32| -> PublicKey { - Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .public_key - }; - let derive_sk = |idx: u32| -> SecretKey { - xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: idx }]) - .unwrap() - .private_key - }; - let pk_0 = derive_pk(0); - let pk_1 = derive_pk(1); - let sk_0 = derive_sk(0); - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([4u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - - // Valid hex shapes but the commitment signature won't verify against - // the message+public_key combination. - let commit_body = serde_json::json!({ - "proof_id": serde_json::from_str::(&body).unwrap()["proof_id"], - "public_key": hex::encode(pk_0.serialize()), - "signature": hex::encode([0u8; 64]), - "message": hex::encode([0u8; 64]), - }); - let commit_req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(commit_body.to_string())) - .unwrap(); - let (status, _body) = send_request_with_state(state, commit_req).await; - assert_eq!(status, StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn send_with_invalid_signature_returns_401() { - let body = serde_json::json!({ - "account_address": "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS), - "recipient": "0x".to_string() + &hex::encode([1u8; 32]), - "amount": 50, - "public_key": hex::encode([2u8; 33]), // garbage compressed pubkey of valid length - "next_public_key": hex::encode([3u8; 33]), - "signature": hex::encode([0u8; 64]), // valid hex shape but wrong sig - "timestamp": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - // serde will reject "02" + [2u8;32] as not-a-valid-pubkey at body parsing, - // so we accept either UNPROCESSABLE_ENTITY (parse-failed) or UNAUTHORIZED - // (parse-succeeded but signature verification failed). - assert!( - status == StatusCode::UNAUTHORIZED || status == StatusCode::UNPROCESSABLE_ENTITY, - "expected 401 or 422, got {status}" - ); -} - -#[tokio::test] -async fn send_with_non_hex_account_address_returns_422() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "not-hex-at-all".to_string(); - let recipient = "0x".to_string() + &hex::encode([1u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn send_with_wrong_length_address_returns_422() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - // Account address is parseable hex but only 16 bytes, not 32. - let account_address = "0x".to_string() + &hex::encode([1u8; 16]); - let recipient = "0x".to_string() + &hex::encode([2u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn send_with_insufficient_funds_returns_ok_with_success_false() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - - // Build a state where the minting account has been emptied. - let state_arc = Arc::new(Mutex::new(State::new())); - let mut account_server = AccountServer::new(Arc::clone(&state_arc)); - let mut empty_minting = Account::new(); - empty_minting.balance = 0; - account_server.import_account(zkcoins_program::MINTING_ADDRESS, empty_minting); - #[cfg(feature = "faucet")] - let minting_client = { - let secret = include_bytes!("../minting_secret.bin"); - let private_key = bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Signet, secret) - .expect("test minting xpriv"); - shared::ClientAccount::new(private_key) - }; - let state = AppState { - account_server: Arc::new(Mutex::new(account_server)), - proof_store: Arc::new(ProofStore::new("/tmp/zkcoins-test-proofs-empty")), - #[cfg(feature = "faucet")] - minting_account: Arc::new(Mutex::new(minting_client)), - username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), - accounts_path: String::new(), - #[cfg(feature = "usernames")] - usernames_path: String::new(), - }; - - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([1u8; 32]); - let amount: u64 = 100; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state, req).await; - assert_eq!(status, StatusCode::OK); - let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(resp["success"], false); -} - -#[tokio::test] -async fn receive_coin_with_invalid_bincode_returns_default_response() { - let req = Request::post("/api/receive") - .header("content-type", "application/octet-stream") - .body(Body::from(vec![0xff, 0xfe, 0xfd, 0xfc])) - .unwrap(); - let (status, body) = send_request(req).await; - assert_eq!(status, StatusCode::OK); - let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(resp["success"], false); -} - -#[tokio::test] -async fn send_with_non_hex_recipient_returns_422() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "absolutely-not-hex".to_string(); - let amount: u64 = 1; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[test] -fn lock_or_recover_recovers_from_poisoned_mutex() { - let mutex = Arc::new(Mutex::new(42i32)); - let mutex_clone = Arc::clone(&mutex); - - // Poison the mutex by panicking inside lock(). - let _ = std::thread::spawn(move || { - let _guard = mutex_clone.lock().unwrap(); - panic!("intentional panic to poison the mutex"); - }) - .join(); - - assert!( - mutex.is_poisoned(), - "mutex must be poisoned after the panic" - ); - - // Recovering must succeed and yield the inner value. - let guard = lock_or_recover(&mutex); - assert_eq!(*guard, 42); -} - -#[tokio::test] -async fn commit_with_valid_signature_fails_broadcast_returns_503() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let state = test_state(); - - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - // Send first to get proof_id + the hashes the client signs over. - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([5u8; 32]); - let amount: u64 = 50; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - let proof_id = send_resp["proof_id"].as_u64().unwrap(); - let ash_hex = send_resp["account_state_hash"] - .as_str() - .unwrap() - .to_string(); - let ocr_hex = send_resp["output_coins_root"].as_str().unwrap().to_string(); - - // Build a valid commitment that the handler will accept. - let ash_bytes = hex::decode(&ash_hex).unwrap(); - let ocr_bytes = hex::decode(&ocr_hex).unwrap(); - let mut commit_message = Vec::with_capacity(ash_bytes.len() + ocr_bytes.len()); - commit_message.extend_from_slice(&ash_bytes); - commit_message.extend_from_slice(&ocr_bytes); - // Commitment::new SHA256s the message internally, so just pass the - // pre-image bytes the handler will receive. - let commitment = shared::commitment::Commitment::new(&sk_0, commit_message.clone()) - .expect("commitment creation"); - assert!(commitment.verify(), "test commitment must verify locally"); - - let commit_body = serde_json::json!({ - "proof_id": proof_id, - "public_key": hex::encode(commitment.public_key.serialize()), - "signature": hex::encode(commitment.signature.serialize()), - "message": hex::encode(&commitment.message), - }); - let commit_req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(commit_body.to_string())) - .unwrap(); - let (status, _) = send_request_with_state(state, commit_req).await; - // The commitment verifies, the handler proceeds to broadcast. Without - // a reachable Bitcoin node in the unit test environment, that call - // fails and the handler returns SERVICE_UNAVAILABLE. We accept either - // 503 (broadcast attempted and failed) or 200 (network was reachable - // and broadcast happened to succeed against a public Mutinynet). - assert!( - status == StatusCode::SERVICE_UNAVAILABLE || status == StatusCode::OK, - "expected 503 or 200, got {status}" - ); -} - -#[test] -fn proof_store_proof_path_returns_none_for_nonexistent_directory() { - // proof_path canonicalizes the configured directory. If the directory - // does not exist, canonicalize fails and proof_path returns None. - let store = ProofStore::new("/nonexistent/zkcoins/proof/dir"); - // The directory was created by ProofStore::new, but to test the - // None branch we point at one that does not exist. - let truly_missing = ProofStore { - dir: "/this/path/genuinely/does/not/exist/zkcoins".to_string(), - next_id: std::sync::atomic::AtomicU64::new(0), - }; - assert!(truly_missing.proof_path(7).is_none()); - // The real store was created and resolves fine for arbitrary ids. - drop(store); -} - -#[test] -fn proof_store_new_picks_up_max_id_from_existing_files() { - let dir = std::env::temp_dir().join(format!( - "zkcoins-proof-store-max-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - // Drop a few well-formed and one malformed filename. - std::fs::write(dir.join("3.bin"), b"placeholder").unwrap(); - std::fs::write(dir.join("17.bin"), b"placeholder").unwrap(); - std::fs::write(dir.join("garbage.bin"), b"placeholder").unwrap(); - std::fs::write(dir.join("notbin.txt"), b"placeholder").unwrap(); - - let store = ProofStore::new(dir.to_str().unwrap()); - // next_id starts at max(3, 17) + 1 = 18; the malformed names are skipped. - let id = store.next_id.load(std::sync::atomic::Ordering::SeqCst); - assert_eq!(id, 18); - - std::fs::remove_dir_all(&dir).ok(); -} - -#[test] -fn persist_proof_bytes_logs_error_when_write_fails() { - // Pointing at a file inside a directory that does not exist guarantees - // `File::create` inside `atomic_write` returns an `Err` on both Linux - // and macOS. The function is best-effort: it logs and returns (). - // Exercising it covers the `if let Err(e) = ...` arm in server.rs - // that was reported uncovered on the Linux runner only. - let bad = std::path::Path::new("/this/path/does/not/exist/zkcoins/0.bin"); - ProofStore::persist_proof_bytes(bad, b"payload", 42); -} - -#[test] -fn persist_proof_bytes_succeeds_when_write_succeeds() { - // Mirror test for the Ok arm so the helper is fully exercised. - let dir = std::env::temp_dir().join(format!( - "zkcoins-persist-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("99.bin"); - ProofStore::persist_proof_bytes(&path, b"payload", 99); - assert_eq!(std::fs::read(&path).unwrap(), b"payload"); - std::fs::remove_dir_all(&dir).ok(); -} - -#[tokio::test] -async fn commit_with_wrong_length_signature_returns_422() { - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([6u8; 32]); - let amount: u64 = 1; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let send_resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - let proof_id = send_resp["proof_id"].as_u64().unwrap(); - - // Signature hex is parseable, but length is wrong (1 byte instead of 64). - let commit_body = serde_json::json!({ - "proof_id": proof_id, - "public_key": hex::encode(pk_0.serialize()), - "signature": "00", - "message": hex::encode([0u8; 32]), - }); - let commit_req = Request::post("/api/commit") - .header("content-type", "application/json") - .body(Body::from(commit_body.to_string())) - .unwrap(); - let (status, _) = send_request_with_state(state, commit_req).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn receive_coin_with_valid_proof_succeeds() { - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([7u8; 32]); - let amount: u64 = 1; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let proof_id = serde_json::from_str::(&body).unwrap()["proof_id"] - .as_u64() - .unwrap(); - - // Read the stored proof bytes via /api/proof/:id and POST them back - // to /api/receive — this should exercise the success path of - // receive_coin_handler. - let proof_req = Request::get(format!("/api/proof/{}", proof_id)) - .body(Body::empty()) - .unwrap(); - let app = create_router(state.clone()); - let proof_resp = app.oneshot(proof_req).await.unwrap(); - assert_eq!(proof_resp.status(), StatusCode::OK); - let proof_bytes = proof_resp.into_body().collect().await.unwrap().to_bytes(); - assert!(!proof_bytes.is_empty()); - - let receive_req = Request::post("/api/receive") - .header("content-type", "application/octet-stream") - .body(Body::from(proof_bytes.to_vec())) - .unwrap(); - let (status, body) = send_request_with_state(state, receive_req).await; - assert_eq!(status, StatusCode::OK); - let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!( - resp["success"], true, - "receive should report success: {body}" - ); -} - -#[tokio::test] -async fn send_with_wrong_signature_returns_401() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([8u8; 32]); - let amount: u64 = 1; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // 64 zero bytes — valid hex shape, valid signature length, but - // will never verify against the request's pk_0 over the SHA256 - // of (account_address || recipient || amount || timestamp). - let body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode([0u8; 64]), - "timestamp": now, - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - assert_eq!(status, StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn receive_coin_duplicate_returns_success_false() { - // After a valid receive, posting the same proof bytes again should - // exercise the Err arm of account_server.receive_coin (duplicate - // detection via coin_queue). - let state = test_state(); - - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - let sk_0: SecretKey = xpriv - .derive_priv(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .private_key; - - let account_address = "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS); - let recipient = "0x".to_string() + &hex::encode([9u8; 32]); - let amount: u64 = 1; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let mut hasher = Sha256::new(); - hasher.update(account_address.as_bytes()); - hasher.update(recipient.as_bytes()); - hasher.update(amount.to_le_bytes()); - hasher.update(now.to_le_bytes()); - let hash: [u8; 32] = hasher.finalize().into(); - let msg = Message::from_digest(hash); - let kp = Keypair::from_secret_key(&secp, &sk_0); - let sig = secp.sign_schnorr(&msg, &kp); - - let send_body = serde_json::json!({ - "account_address": account_address, - "recipient": recipient, - "amount": amount, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - "signature": hex::encode(sig.serialize()), - "timestamp": now, - }); - let send_req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(send_body.to_string())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), send_req).await; - assert_eq!(status, StatusCode::OK, "send failed: {body}"); - let proof_id = serde_json::from_str::(&body).unwrap()["proof_id"] - .as_u64() - .unwrap(); - - let app = create_router(state.clone()); - let proof_resp = app - .oneshot( - Request::get(format!("/api/proof/{}", proof_id)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let proof_bytes = proof_resp.into_body().collect().await.unwrap().to_bytes(); - - // First receive: succeeds. - let receive_req = Request::post("/api/receive") - .header("content-type", "application/octet-stream") - .body(Body::from(proof_bytes.to_vec())) - .unwrap(); - let (status, body) = send_request_with_state(state.clone(), receive_req).await; - assert_eq!(status, StatusCode::OK); - let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(resp["success"], true); - - // Second receive of the same bytes: receive_coin returns Err, the - // handler responds with success=false (the L351 Err arm). - let receive_req = Request::post("/api/receive") - .header("content-type", "application/octet-stream") - .body(Body::from(proof_bytes.to_vec())) - .unwrap(); - let (status, body) = send_request_with_state(state, receive_req).await; - assert_eq!(status, StatusCode::OK); - let resp: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(resp["success"], false); -} - -#[tokio::test] -async fn send_without_signature_skips_verification_and_proceeds() { - use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; - use bitcoin::secp256k1::PublicKey; - let secret_bytes = include_bytes!("../minting_secret.bin"); - let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); - let secp = secp::Secp256k1::new(); - let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) - .unwrap() - .public_key; - let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) - .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) - .unwrap() - .public_key; - - // signature field omitted entirely -> request.signature is None -> - // the verify_send_signature block is skipped (legacy/back-compat path). - let body = serde_json::json!({ - "account_address": "0x".to_string() + &hex::encode(zkcoins_program::MINTING_ADDRESS), - "recipient": "0x".to_string() + &hex::encode([1u8; 32]), - "amount": 1, - "public_key": hex::encode(pk_0.serialize()), - "next_public_key": hex::encode(pk_1.serialize()), - }); - let req = Request::post("/api/send") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let (status, _) = send_request(req).await; - // Without signature, the handler proceeds to send_coins on the - // minting account (which has u64::MAX balance) and returns OK. - assert_eq!(status, StatusCode::OK); -} - -#[test] -fn lock_or_recover_account_server_poisoned() { - // Generic instantiation: cover the AccountServer-specific monomorphic - // copy of lock_or_recover's poison-recovery closure. - let state_arc = Arc::new(Mutex::new(State::new())); - let server = Arc::new(Mutex::new(AccountServer::new(Arc::clone(&state_arc)))); - let server_clone = Arc::clone(&server); - - let _ = std::thread::spawn(move || { - let _guard = server_clone.lock().unwrap(); - panic!("intentional poison"); - }) - .join(); - - assert!(server.is_poisoned()); - let _guard = lock_or_recover(&server); -} - -#[test] -fn lock_or_recover_username_store_poisoned() { - // Generic instantiation: cover the UsernameStore-specific monomorphic - // copy of lock_or_recover's poison-recovery closure. - let store = Arc::new(Mutex::new(crate::username::UsernameStore::new())); - let store_clone = Arc::clone(&store); - - let _ = std::thread::spawn(move || { - let _guard = store_clone.lock().unwrap(); - panic!("intentional poison"); - }) - .join(); - - assert!(store.is_poisoned()); - let _guard = lock_or_recover(&store); -} diff --git a/server/src/state.rs b/server/src/state.rs deleted file mode 100644 index ba13b90e..00000000 --- a/server/src/state.rs +++ /dev/null @@ -1,189 +0,0 @@ -use bitcoin::hashes::Hash; -use bitcoin::secp256k1::PublicKey; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use shared::commitment::Commitment; -use std::collections::HashMap; -use std::io; -use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange}; -use zkcoins_program::merkle::sparse_merkle_tree::{ - load_merkle_tree, save_merkle_tree, InclusionProof, SparseMerkleTree, -}; -use zkcoins_program::merkle::{HashDigest, ZERO_HASH}; - -/// State stores both a Sparse Merkle Tree (for individual commitments) -/// and a Merkle Mountain Range (for accumulating SMT roots). -#[derive(Serialize, Deserialize)] -pub struct State { - /// The Sparse Merkle Tree to store individual commitments - pub smt: SparseMerkleTree, - /// The Merkle Mountain Range to accumulate SMT roots - pub mmr: MerkleMountainRange, - /// Maps previous MMR roots to (SMT root, leaf index) pairs - pub root_indices: HashMap, - /// The previous MMR root - pub prev_mmr_root: HashDigest, -} - -impl State { - /// Creates a new state with an empty SMT of the default depth and an empty MMR. - pub fn new() -> Self { - State { - smt: SparseMerkleTree::new(), - mmr: MerkleMountainRange::new(), - root_indices: HashMap::new(), - prev_mmr_root: ZERO_HASH, - } - } - - /// Updates the state by inserting a set of commitments into the SMT, - /// then appending a new leaf to the MMR that combines the new SMT root - /// and the previous MMR root. - /// - /// Returns the new MMR root. - pub fn update(&mut self, commitments: &[Commitment]) -> Result { - // 1. Insert all commitments into the SMT - for commitment in commitments { - // Use the public key as the key for the tree (hashed) - let key_bytes = commitment.public_key.serialize(); - let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); - - // Store only the message instead of the entire commitment - let message_data = commitment.get_account_state_hash(); - - // Update the SMT with just the message - self.smt.insert(key, message_data)?; - } - - // 2. Get the current SMT root - let smt_root = self.smt.root(); - - // 3. Create a new leaf that combines the SMT root and previous MMR root - let prev_mmr_root = self.mmr.root(); - self.prev_mmr_root = prev_mmr_root; - - // Combine the SMT root and previous MMR root into a single hash - let mut hasher = Sha256::new(); - hasher.update(smt_root); - hasher.update(prev_mmr_root); - let combined_hash = hasher.finalize(); - let mut leaf = [0u8; 32]; - leaf.copy_from_slice(&combined_hash); - - // Store the mapping of previous MMR root to (SMT root, leaf index) - let leaf_index = self.mmr.leaf_count(); - self.root_indices - .insert(prev_mmr_root, (smt_root, leaf_index)); - - // 4. Append the new leaf to the MMR - self.mmr.append(leaf); - - // 5. Return the new MMR root - Ok(self.mmr.root()) - } - - /// Gets an inclusion proof for a leaf in the MMR that was created with the given previous MMR root. - /// - /// Returns: - /// - The SMT root that was combined with this previous MMR root - /// - The inclusion proof for the leaf in the MMR - /// - None if the previous MMR root is not found - pub fn get_mmr_inclusion_proof( - &self, - prev_mmr_root: HashDigest, - ) -> Result<(HashDigest, MMRProof), &'static str> { - // Look up the index and SMT root for this previous MMR root - match self.root_indices.get(&prev_mmr_root) { - // Get the inclusion proof for this index from the MMR - Some(&(smt_root, index)) => self.mmr.get_proof(index).map(|proof| (smt_root, proof)), - None => Err("Couldn't find MMR inclusion proof"), - } - } - - /// Gets an inclusion proof for a specific commitment in the SMT, - /// along with an inclusion proof of the current SMT root in the MMR. - /// - /// Args: - /// commitment: The commitment to get the proof for (only the public key is used) - /// - /// Returns: - /// - Some((commitment, smt_proof, smt_root, mmr_proof)) if the commitment exists in the SMT - /// - None if the commitment doesn't exist or if there's no leaf in the MMR - pub fn get_commitment_proof( - &self, - public_key: &PublicKey, - ) -> Result<(HashDigest, InclusionProof, HashDigest, MMRProof), &'static str> { - // Hash the public key to get the key in the SMT - let key_bytes = public_key.serialize(); - let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); - - // Get the inclusion proof from the SMT - // Convert Result to Option - if there's an error, return None - let (smt_proof, commitment) = self.smt.generate_inclusion_proof(&key)?; - - // Get the current SMT root - let smt_root = self.smt.root(); - - // Get the latest leaf index in the MMR - let leaf_count = self.mmr.leaf_count(); - if leaf_count == 0 { - return Err("MMR leaf count = 0"); - } - let latest_leaf_index = leaf_count - 1; - - // Get the MMR inclusion proof for the latest leaf - let mmr_proof = self.mmr.get_proof(latest_leaf_index)?; - - Ok((commitment, smt_proof, smt_root, mmr_proof)) - } - - /// Saves the state to two files: one for the SMT and one for the MMR. - pub fn save_to_files(&self, smt_path: &str, mmr_path: &str) -> io::Result<()> { - // Save SMT - save_merkle_tree(&self.smt, smt_path)?; - - // Save MMR - self.mmr.save_to_file(mmr_path)?; - - // Save prev_mmr_root to a separate file - let prev_root_path = format!("{}.prev_root", mmr_path); - crate::atomic_write(&prev_root_path, &self.prev_mmr_root)?; - - Ok(()) - } - - /// Loads the state from two files: one for the SMT and one for the MMR. - pub fn load_from_files(smt_path: &str, mmr_path: &str) -> io::Result { - // Load SMT - let smt = load_merkle_tree(smt_path)?; - - // Load MMR - let mmr = MerkleMountainRange::load_from_file(mmr_path)?; - - // Load prev_mmr_root from its file - let prev_root_path = format!("{}.prev_root", mmr_path); - let prev_mmr_root = match std::fs::read(prev_root_path) { - Ok(bytes) if bytes.len() == 32 => { - let mut root = [0u8; 32]; - root.copy_from_slice(&bytes); - root - } - // If file doesn't exist or has wrong size, use zeros - _ => [0u8; 32], - }; - - // Initialize an empty root_indices map - let root_indices = HashMap::new(); - - Ok(State { - smt, - mmr, - root_indices, - prev_mmr_root, - }) - } -} - -#[cfg(test)] -#[path = "state_tests.rs"] -mod tests; diff --git a/server/src/state_tests.rs b/server/src/state_tests.rs deleted file mode 100644 index b64d1f12..00000000 --- a/server/src/state_tests.rs +++ /dev/null @@ -1,385 +0,0 @@ -use super::*; -use bitcoin::hashes::Hash; -use bitcoin::secp256k1::{Secp256k1, SecretKey}; -use std::str::FromStr; -use zkcoins_program::merkle::{hash_concat, HASH_SIZE}; - -// Helper function to create a test commitment with a given message -fn create_test_commitment(message: &[u8], key_hex: &str) -> Commitment { - let _secp = Secp256k1::new(); - let secret_key = SecretKey::from_str(key_hex).expect("Invalid key"); - Commitment::new(&secret_key, message.to_vec()).expect("Failed to create commitment") -} - -#[test] -fn test_update_with_single_commitment() { - let mut state = State::new(); - - // Create a test commitment - let commitment = create_test_commitment( - b"test message", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - - // Update state with this commitment - let new_root = state.update(&[commitment.clone()]).unwrap(); - - // The SMT should now contain this commitment - let key_bytes = commitment.public_key.serialize(); - let _key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array(); - - // The MMR should have one leaf now - assert_ne!(state.mmr.root(), ZERO_HASH); - assert_eq!(state.mmr.root(), new_root); -} - -#[test] -fn test_update_with_multiple_commitments() { - let mut state = State::new(); - - // Create test commitments with different keys - let commitments = vec![ - create_test_commitment( - b"message 1", - "0000000000000000000000000000000000000000000000000000000000000001", - ), - create_test_commitment( - b"message 2", - "0000000000000000000000000000000000000000000000000000000000000002", - ), - create_test_commitment( - b"message 3", - "0000000000000000000000000000000000000000000000000000000000000003", - ), - ]; - - // First update with one commitment - let root1 = state.update(&[commitments[0].clone()]).unwrap(); - - // Then update with the other two - let root2 = state - .update(&[commitments[1].clone(), commitments[2].clone()]) - .unwrap(); - - // The roots should be different after each update - assert_ne!(root1, root2); - - // After the second update, the MMR should have two leaves - assert_eq!(state.mmr.root(), root2); -} - -#[test] -fn test_save_and_load_state() { - let temp_smt_path = "test_state_smt.bin"; - let temp_mmr_path = "test_state_mmr.bin"; - - // Create and populate a state - let mut original_state = State::new(); - - // Add some commitments - let commitments = vec![ - create_test_commitment( - b"message for save/load test", - "0000000000000000000000000000000000000000000000000000000000000004", - ), - create_test_commitment( - b"another message", - "0000000000000000000000000000000000000000000000000000000000000005", - ), - ]; - - original_state.update(&commitments).unwrap(); - - // Save the state - original_state - .save_to_files(temp_smt_path, temp_mmr_path) - .expect("Failed to save state"); - - // Load the state - let loaded_state = - State::load_from_files(temp_smt_path, temp_mmr_path).expect("Failed to load state"); - - // Clean up temporary files - std::fs::remove_file(temp_smt_path).ok(); - std::fs::remove_file(temp_mmr_path).ok(); - // Also remove the prev_root file - std::fs::remove_file(format!("{}.prev_root", temp_mmr_path)).ok(); - - // Verify the loaded state has the same roots - assert_eq!(original_state.smt.root(), loaded_state.smt.root()); - assert_eq!(original_state.mmr.root(), loaded_state.mmr.root()); -} - -#[test] -fn test_sequential_updates_consistency() { - let mut state = State::new(); - - // Create several test commitments - let messages = [b"msg1", b"msg2", b"msg3", b"msg4", b"msg5"]; - let mut roots = Vec::new(); - - // Process commitments one by one and record roots - for (i, &msg) in messages.iter().enumerate() { - let key_hex = format!("{:064x}", i + 1); - let commitment = create_test_commitment(msg, &key_hex); - - let root = state.update(&[commitment]).unwrap(); - roots.push(root); - } - - // Verify that each update produced a different root - for i in 1..roots.len() { - assert_ne!( - roots[i - 1], - roots[i], - "Sequential updates should produce different roots" - ); - } - - // Verify that the final state has the expected root - assert_eq!(state.mmr.root(), *roots.last().unwrap()); -} - -#[test] -fn test_get_commitment_proof_with_mmr() { - let mut state = State::new(); - - // Create test commitment - let commitment = create_test_commitment( - b"test message", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - - // Update state with this commitment - let mmr_root = state.update(&[commitment.clone()]).unwrap(); - - // Get the complete proof (SMT + MMR) - let proof_result = state.get_commitment_proof(&commitment.public_key); - assert!( - proof_result.is_ok(), - "Should return a valid proof for existing commitment" - ); - - let (commitment_msg, smt_proof, smt_root, mmr_proof) = proof_result.unwrap(); - - // Verify the message - assert_eq!( - commitment.message, - b"test message".to_vec(), - "Should return the correct message" - ); - - assert_ne!(smt_root, ZERO_HASH, "SMT root should not be zero"); - - // Verify MMR proof info - assert_eq!(mmr_proof.index, 0, "First update should be at leaf index 0"); - assert!( - !mmr_proof.path.is_empty(), - "MMR proof path should not be empty" - ); - - // Verify that the MMR root matches what was returned from update - assert_eq!( - state.mmr.root(), - mmr_root, - "MMR root should match what was returned from update" - ); - - assert!(smt_proof.verify(commitment_msg, smt_root)); - assert!(mmr_proof.verify(hash_concat(&smt_root, &state.prev_mmr_root), mmr_root)); -} - -#[test] -fn test_reproduce_tree_verify() { - let mut state = State::new(); - - // Create test commitment - let commitment = create_test_commitment( - &[1; HASH_SIZE], - "1000000000000000000000000000000000000000000000000000000000000000", - ); - - // Update state with this commitment - //let mmr_root = state.update(&[commitment.clone()]); - //let key_bytes = commitment.public_key.serialize(); - let key = [ - 127u8, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, - ]; - //let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key).to_byte_array(); - //let mut smt = SparseMerkleTree::new(256); - state.smt.insert(key, [1; HASH_SIZE]).unwrap(); - let root = state.smt.root(); - - //// Get the complete proof (SMT + MMR) - ////let proof_result = state.get_commitment_proof(&commitment.public_key); - - let proof_result = state.smt.generate_inclusion_proof(&key); - - let (smt_proof, _) = proof_result.unwrap(); - - assert!(smt_proof.verify([1; HASH_SIZE], root)); -} - -#[test] -fn test_get_commitment_proof_nonexistent() { - let mut state = State::new(); - - // Add a different commitment to the state - let existing_commitment = create_test_commitment( - b"existing message", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - state.update(&[existing_commitment]).unwrap(); - - // Try to get proof for a non-existent commitment - let non_existent = create_test_commitment( - b"non-existent message", - "0000000000000000000000000000000000000000000000000000000000000099", - ); - - let result = state.get_commitment_proof(&non_existent.public_key); - assert!( - result.is_err(), - "Should return Err for non-existent commitment" - ); -} - -#[test] -fn test_get_commitment_proof_empty_mmr() { - let state = State::new(); - - // Create a commitment but don't add it to the state yet - let commitment = create_test_commitment( - b"test message", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - - // Try to get proof with empty MMR - let result = state.get_commitment_proof(&commitment.public_key); - assert!(result.is_err(), "Should return Err when MMR is empty"); -} - -#[test] -fn test_get_commitment_proof_with_multiple_updates() { - let mut state = State::new(); - - // Create several test commitments - let messages = [b"msg1", b"msg2", b"msg3", b"msg4", b"msg5"]; - let mut roots = Vec::new(); - - // Process commitments one by one and record roots - for (i, &msg) in messages.iter().enumerate() { - let key_hex = format!("{:064x}", i + 1); - let commitment = create_test_commitment(msg, &key_hex); - - let root = state.update(&[commitment]).unwrap(); - roots.push(root); - } - - // Verify that each update produced a different root - for i in 1..roots.len() { - assert_ne!( - roots[i - 1], - roots[i], - "Sequential updates should produce different roots" - ); - } - - // Verify that the final state has the expected root - assert_eq!(state.mmr.root(), *roots.last().unwrap()); -} - -#[test] -fn test_get_mmr_inclusion_proof_unknown_root_returns_err() { - // get_mmr_inclusion_proof must return Err when the previous MMR - // root passed in is not tracked in root_indices. - let state = State::new(); - let unknown_root = [99u8; 32]; - let result = state.get_mmr_inclusion_proof(unknown_root); - assert!(result.is_err()); -} - -#[test] -fn test_get_commitment_proof_returns_err_when_smt_has_key_but_mmr_empty() { - // This inconsistent state cannot arise from normal operation - // (update() always grows both trees together) — it is reached - // only by loading mismatched on-disk state. The defensive guard - // in get_commitment_proof must return Err rather than panic on - // the leaf_count - 1 subtraction. - let dir = std::env::temp_dir().join(format!( - "zkcoins-mismatch-test-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - let smt_a = dir.join("a.smt"); - let mmr_a = dir.join("a.mmr"); - let smt_b = dir.join("b.smt"); - let mmr_b = dir.join("b.mmr"); - - // State A: contains one commitment. - let mut a = State::new(); - let commitment = create_test_commitment( - b"mismatched scenario", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - a.update(&[commitment.clone()]).unwrap(); - a.save_to_files(smt_a.to_str().unwrap(), mmr_a.to_str().unwrap()) - .unwrap(); - - // State B: empty. - let b = State::new(); - b.save_to_files(smt_b.to_str().unwrap(), mmr_b.to_str().unwrap()) - .unwrap(); - - // Load from A's SMT and B's empty MMR. SMT now has the key, - // MMR has zero leaves — exactly the inconsistent-state trigger. - let mismatched = - State::load_from_files(smt_a.to_str().unwrap(), mmr_b.to_str().unwrap()).unwrap(); - - let result = mismatched.get_commitment_proof(&commitment.public_key); - assert!(result.is_err()); - - std::fs::remove_dir_all(&dir).ok(); -} - -#[test] -fn test_load_from_files_falls_back_to_zero_prev_root() { - // load_from_files must tolerate a missing `.prev_root` sidecar - // file and fall back to [0u8; 32] for prev_mmr_root. - let dir = std::env::temp_dir().join(format!( - "zkcoins-state-test-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - let smt_path = dir.join("smt.bin"); - let mmr_path = dir.join("mmr.bin"); - let prev_root_path = dir.join("mmr.bin.prev_root"); - - // Seed a state with one commitment and persist it. - let mut state = State::new(); - let commitment = create_test_commitment( - b"prev-root fallback", - "0000000000000000000000000000000000000000000000000000000000000001", - ); - state.update(&[commitment]).unwrap(); - state - .save_to_files(smt_path.to_str().unwrap(), mmr_path.to_str().unwrap()) - .unwrap(); - - // Remove the prev_root sidecar so the fallback branch fires. - std::fs::remove_file(&prev_root_path).unwrap(); - - let loaded = - State::load_from_files(smt_path.to_str().unwrap(), mmr_path.to_str().unwrap()).unwrap(); - assert_eq!(loaded.prev_mmr_root, [0u8; 32]); - - // Tidy up. - std::fs::remove_dir_all(&dir).ok(); -} diff --git a/server/src/username.rs b/server/src/username.rs deleted file mode 100644 index 4744fd69..00000000 --- a/server/src/username.rs +++ /dev/null @@ -1,155 +0,0 @@ -use serde::{Deserialize, Serialize}; -use shared::Address; -use std::collections::HashMap; - -#[derive(Serialize, Deserialize, Debug, Default)] -pub struct UsernameStore { - usernames: HashMap, -} - -impl UsernameStore { - pub fn new() -> Self { - Self::default() - } - - #[cfg(any(feature = "usernames", test))] - pub fn claim(&mut self, username: &str, address: Address) -> Result<(), &'static str> { - let normalized = username.to_lowercase(); - - if normalized.is_empty() || normalized.len() > 64 { - return Err("Username must be 1-64 characters"); - } - if !normalized - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') - { - return Err("Username may only contain a-z, 0-9, -, _, ."); - } - - if self.usernames.contains_key(&normalized) { - return Err("Username already taken"); - } - - if self.usernames.values().any(|a| *a == address) { - return Err("Address already has a username"); - } - - self.usernames.insert(normalized, address); - Ok(()) - } - - #[cfg(any(feature = "usernames", feature = "lnurl", test))] - pub fn resolve(&self, username: &str) -> Option
{ - self.usernames.get(&username.to_lowercase()).copied() - } - - pub fn get_username(&self, address: &Address) -> Option<&str> { - self.usernames - .iter() - .find(|(_, a)| *a == address) - .map(|(name, _)| name.as_str()) - } - - #[cfg(any(feature = "usernames", test))] - pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { - // `bincode::serialize` on a HashMap cannot fail in - // practice; `io::Error::other` is used as a function reference so the - // error-mapping path does not introduce an uncovered closure. - let bytes = bincode::serialize(&self.usernames).map_err(std::io::Error::other)?; - crate::atomic_write(path, &bytes) - } - - pub fn load_from_file(path: &str) -> std::io::Result { - let bytes = std::fs::read(path)?; - let usernames: HashMap = - bincode::deserialize(&bytes).map_err(std::io::Error::other)?; - Ok(UsernameStore { usernames }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn claim_and_resolve() { - let mut store = UsernameStore::new(); - let address = [1u8; 32]; - - store.claim("Alice", address).unwrap(); - assert_eq!(store.resolve("alice"), Some(address)); - assert_eq!(store.resolve("Alice"), Some(address)); - assert_eq!(store.get_username(&address), Some("alice")); - } - - #[test] - fn duplicate_username_rejected() { - let mut store = UsernameStore::new(); - store.claim("alice", [1u8; 32]).unwrap(); - assert!(store.claim("alice", [2u8; 32]).is_err()); - } - - #[test] - fn duplicate_address_rejected() { - let mut store = UsernameStore::new(); - let address = [1u8; 32]; - store.claim("alice", address).unwrap(); - assert!(store.claim("bob", address).is_err()); - } - - #[test] - fn invalid_username_rejected() { - let mut store = UsernameStore::new(); - assert!(store.claim("", [1u8; 32]).is_err()); - assert!(store.claim("hello world", [2u8; 32]).is_err()); - assert!(store.claim("hello@world", [3u8; 32]).is_err()); - assert!(store.claim(&"a".repeat(65), [4u8; 32]).is_err()); - } - - #[test] - fn valid_usernames_accepted() { - let mut store = UsernameStore::new(); - store.claim("alice", [1u8; 32]).unwrap(); - store.claim("bob-99", [2u8; 32]).unwrap(); - store.claim("carol_x", [3u8; 32]).unwrap(); - store.claim("dave.btc", [4u8; 32]).unwrap(); - } - - #[test] - fn save_and_load_roundtrip() { - let path = "/tmp/zkcoins-test-usernames.bin"; - let mut store = UsernameStore::new(); - store.claim("alice", [1u8; 32]).unwrap(); - store.claim("bob", [2u8; 32]).unwrap(); - - store.save_to_file(path).unwrap(); - let loaded = UsernameStore::load_from_file(path).unwrap(); - - assert_eq!(loaded.resolve("alice"), Some([1u8; 32])); - assert_eq!(loaded.resolve("bob"), Some([2u8; 32])); - assert_eq!(loaded.get_username(&[1u8; 32]), Some("alice")); - assert_eq!(loaded.resolve("nonexistent"), None); - - std::fs::remove_file(path).ok(); - } - - #[test] - fn resolve_is_case_insensitive() { - let mut store = UsernameStore::new(); - let address = [5u8; 32]; - store.claim("Alice", address).unwrap(); - - // Resolve with different casings - assert_eq!(store.resolve("alice"), Some(address)); - assert_eq!(store.resolve("ALICE"), Some(address)); - assert_eq!(store.resolve("Alice"), Some(address)); - assert_eq!(store.resolve("aLiCe"), Some(address)); - } - - #[test] - fn get_username_returns_none_for_unknown() { - let store = UsernameStore::new(); - let unknown_address = [99u8; 32]; - assert_eq!(store.get_username(&unknown_address), None); - } -} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 4d5b8301..57fdbe53 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] -zkcoins-program = { path = "../program/" } +zkcoins-program = { path = "../program-plonky2/", package = "zkcoins-program-plonky2" } lazy_static = { workspace = true } bitcoin = { workspace = true } sha2 = { workspace = true } diff --git a/shared/src/commitment.rs b/shared/src/commitment.rs index d3ec4e01..1e0c89ec 100644 --- a/shared/src/commitment.rs +++ b/shared/src/commitment.rs @@ -4,7 +4,11 @@ use bitcoin::secp256k1::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fmt; -use zkcoins_program::merkle::HashDigest; + +// `get_account_state_hash` returns the raw 32-byte BIP-340 Schnorr +// message digest. This is distinct from Poseidon's `HashDigest` +// (= `HashOut`); callers that need the field-element form +// reinterpret via `zkcoins_program::hash::digest_from_bytes`. use crate::SECP256K1; @@ -68,7 +72,7 @@ impl Commitment { } } - pub fn get_account_state_hash(&self) -> HashDigest { + pub fn get_account_state_hash(&self) -> [u8; 32] { let msg_hash = if self.message.len() != 32 { let mut hasher = Sha256::new(); hasher.update(&self.message); @@ -92,3 +96,7 @@ impl fmt::Debug for Commitment { .finish() } } + +#[cfg(test)] +#[path = "commitment_tests.rs"] +mod tests; diff --git a/shared/src/commitment_tests.rs b/shared/src/commitment_tests.rs new file mode 100644 index 00000000..cf07a54d --- /dev/null +++ b/shared/src/commitment_tests.rs @@ -0,0 +1,209 @@ +//! Negative-path tests for the BIP-340 Schnorr `Commitment`. +//! +//! `Commitment::verify` is security-critical: it gates whether a signed +//! account state will be accepted by the server. These tests exercise it +//! in isolation (no server, no SMT) and pin down the boundaries between +//! the "raw 32-byte digest" code path and the "SHA256-hashed message" +//! code path inside `Commitment::new` / `Commitment::verify`. + +use super::*; +use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; +use sha2::{Digest, Sha256}; + +/// Deterministic secret key A used as the canonical signer in these tests. +fn secret_key_a() -> SecretKey { + SecretKey::from_slice(&[1u8; 32]).expect("valid non-zero scalar") +} + +/// Deterministic secret key B, used to swap in a wrong public key. +fn secret_key_b() -> SecretKey { + SecretKey::from_slice(&[2u8; 32]).expect("valid non-zero scalar") +} + +fn public_key_for(sk: &SecretKey) -> PublicKey { + Keypair::from_secret_key(&SECP256K1, sk).public_key() +} + +#[test] +fn verify_accepts_freshly_signed_commitment() { + let commitment = + Commitment::new(&secret_key_a(), b"hello zkcoins".to_vec()).expect("sign succeeds"); + assert!( + commitment.verify(), + "freshly signed commitment must verify against its own public key" + ); +} + +#[test] +fn verify_rejects_signature_for_wrong_public_key() { + let mut commitment = + Commitment::new(&secret_key_a(), b"swap pubkey".to_vec()).expect("sign succeeds"); + // Replace the embedded public key with a different one (key B). The + // signature was produced by key A, so verification must fail. + commitment.public_key = public_key_for(&secret_key_b()); + assert!( + !commitment.verify(), + "verification must fail when public_key does not match the signing key" + ); +} + +#[test] +fn verify_rejects_tampered_message() { + let mut commitment = + Commitment::new(&secret_key_a(), b"original message".to_vec()).expect("sign succeeds"); + assert!(commitment.verify(), "sanity: original verifies"); + + // Flip bits in the first byte of the message. + commitment.message[0] ^= 0xFF; + assert!( + !commitment.verify(), + "verification must fail after the message has been tampered with" + ); +} + +#[test] +fn verify_rejects_zero_signature() { + let mut commitment = + Commitment::new(&secret_key_a(), b"zeroed signature".to_vec()).expect("sign succeeds"); + + // Construct an all-zero 64-byte Schnorr signature. `Signature::from_slice` + // accepts any 64 bytes (validity is checked at verification time), so this + // is a valid way to forge a syntactically-correct but cryptographically + // invalid signature. + let zero_sig = bitcoin::secp256k1::schnorr::Signature::from_slice(&[0u8; 64]) + .expect("64 zero bytes parse as a Signature"); + commitment.signature = zero_sig; + + assert!( + !commitment.verify(), + "verification must fail for an all-zero Schnorr signature" + ); +} + +#[test] +fn verify_rejects_truncated_message() { + let mut commitment = + Commitment::new(&secret_key_a(), b"truncate me please".to_vec()).expect("sign succeeds"); + + // Drop the last byte: this both changes the SHA256 hash and the length, + // so the verification path must reject it. + commitment.message.pop(); + assert!( + !commitment.verify(), + "verification must fail after the message has been truncated" + ); +} + +#[test] +fn verify_rejects_extended_message() { + let mut commitment = + Commitment::new(&secret_key_a(), b"extend me please".to_vec()).expect("sign succeeds"); + + // Append junk: changes the SHA256 hash that gets fed into verify_schnorr. + commitment.message.extend_from_slice(b"!!!"); + assert!( + !commitment.verify(), + "verification must fail after extra bytes have been appended to the message" + ); +} + +#[test] +fn verify_accepts_32_byte_message_as_raw_digest() { + // When `message.len() == 32` both `new` and `verify` skip the SHA256 + // step and feed the 32 raw bytes straight into BIP-340. We exercise + // that branch with a deterministic 32-byte payload. + let raw_digest: Vec = (0u8..32).collect(); + let commitment = Commitment::new(&secret_key_a(), raw_digest.clone()).expect("sign succeeds"); + + assert_eq!(commitment.message, raw_digest); + assert!( + commitment.verify(), + "commitment over a 32-byte raw digest must verify" + ); + assert_eq!( + commitment.get_account_state_hash().to_vec(), + raw_digest, + "32-byte messages must be returned verbatim by get_account_state_hash" + ); +} + +#[test] +fn verify_handles_non_32_byte_message_via_sha256() { + // 31 bytes (just under the raw-digest boundary). + let short_msg: Vec = (0u8..31).collect(); + let short_commitment = + Commitment::new(&secret_key_a(), short_msg.clone()).expect("sign succeeds"); + assert!( + short_commitment.verify(), + "round-trip with a 31-byte message must verify (SHA256 path)" + ); + + // 64 bytes (just over the raw-digest boundary). + let long_msg: Vec = (0u8..64).collect(); + let long_commitment = + Commitment::new(&secret_key_a(), long_msg.clone()).expect("sign succeeds"); + assert!( + long_commitment.verify(), + "round-trip with a 64-byte message must verify (SHA256 path)" + ); +} + +#[test] +fn verify_rejects_signature_swapped_between_messages() { + // Sign message M1 with key A, then transplant that signature onto + // a Commitment whose `message` is a different M2. Both messages take + // the SHA256 path, so the digests differ and verification must fail. + let m1 = Commitment::new(&secret_key_a(), b"message one".to_vec()).expect("sign succeeds"); + let mut m2 = Commitment::new(&secret_key_a(), b"message two".to_vec()).expect("sign succeeds"); + + m2.signature = m1.signature; + assert!( + !m2.verify(), + "a signature lifted from a different message must not verify" + ); +} + +#[test] +fn get_account_state_hash_matches_internal_hash_path() { + // For len != 32: returned hash must equal SHA256(message). + let msg = b"non-32-byte payload".to_vec(); + let commitment = Commitment::new(&secret_key_a(), msg.clone()).expect("sign succeeds"); + + let mut hasher = Sha256::new(); + hasher.update(&msg); + let expected: [u8; 32] = hasher.finalize().into(); + + assert_eq!( + commitment.get_account_state_hash(), + expected, + "get_account_state_hash must equal SHA256(message) for non-32-byte messages" + ); + + // For len == 32: returned hash must equal the message verbatim. + let raw_digest: Vec = (10u8..42).collect(); + let raw_commitment = + Commitment::new(&secret_key_a(), raw_digest.clone()).expect("sign succeeds"); + assert_eq!( + raw_commitment.get_account_state_hash().to_vec(), + raw_digest, + "get_account_state_hash must return a 32-byte message verbatim" + ); +} + +#[test] +fn commitment_serde_roundtrip_preserves_verification() { + let original = + Commitment::new(&secret_key_a(), b"serde roundtrip".to_vec()).expect("sign succeeds"); + assert!(original.verify(), "sanity: original verifies"); + + let encoded = bincode::serialize(&original).expect("bincode serialize"); + let decoded: Commitment = bincode::deserialize(&encoded).expect("bincode deserialize"); + + assert_eq!(decoded.public_key, original.public_key); + assert_eq!(decoded.signature, original.signature); + assert_eq!(decoded.message, original.message); + assert!( + decoded.verify(), + "deserialized commitment must still verify" + ); +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 6394a9bd..f80f6ff2 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -10,13 +10,11 @@ use bitcoin::{ use commitment::Commitment; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; -use zkcoins_program::{ - merkle::{hash_concat, HashDigest}, - AccountState, Amount, -}; +use zkcoins_program::hash::{digest_to_bytes, hash_concat, HashDigest, ZERO_HASH}; +use zkcoins_program::types::{AccountState, Amount}; pub mod commitment; -pub use zkcoins_program::ProofData; +pub use zkcoins_program::types::ProofData; lazy_static! { pub static ref SECP256K1: Secp256k1 = Secp256k1::new(); @@ -66,14 +64,19 @@ impl ClientAccount { .private_key } + /// Compute the BIP-340 Schnorr commitment over the canonical + /// `(account_state_hash || output_coins_root)` digest. The digest + /// is Poseidon, serialised to 32 bytes via `digest_to_bytes` for + /// signing; Schnorr signing itself remains SHA256-based per BIP-340. pub fn create_commitment( &self, account_state_hash: &HashDigest, output_coins_root: &HashDigest, ) -> Commitment { + let combined = hash_concat(account_state_hash, output_coins_root); Commitment::new( &self.current_private_key(), - hash_concat(account_state_hash, output_coins_root).to_vec(), + digest_to_bytes(&combined).to_vec(), ) .expect("Should be able to create commitment") } @@ -88,11 +91,11 @@ impl ClientAccount { pub fn new(private_key: Xpriv) -> Self { let mut client_account = ClientAccount { - address: [0u8; 32], + address: ZERO_HASH, num_pubkeys: 0, private_key, }; - let account = AccountState::new(client_account.generate_public_key(0).serialize().to_vec()); + let account = AccountState::new(client_account.generate_public_key(0).serialize()); client_account.address = account.owner; client_account }