Skip to content

Stage 5d-next-5 housekeeping: HTTP errors + CI cyclic tests + doc fold (closes #28) - #31

Merged
TaprootFreak merged 8 commits into
feat/plonky2-migrationfrom
feat/plonky2-5d-next-5-housekeeping
May 18, 2026
Merged

Stage 5d-next-5 housekeeping: HTTP errors + CI cyclic tests + doc fold (closes #28)#31
TaprootFreak merged 8 commits into
feat/plonky2-migrationfrom
feat/plonky2-5d-next-5-housekeeping

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented May 18, 2026

Copy link
Copy Markdown
Contributor

Implements the four housekeeping items deferred from PR #23 + PR #26, bundled into one PR per user request. Closes #28.

Items

Item 4 — Doc fold (low risk; pure doc movement) — 530b7ac

Folded program-plonky2/STAGE_5D_NEXT_5_AGGREGATOR.md (612 lines) into MIGRATION_RESEARCH.md §7.22 as the canonical lessons-learned entry, alongside §7.21 ("Stage 5d-next-4 source-side verification blocked on Plonky2 1.1.0") which is now updated to status "resolved in §7.22".

  • §7.22 captures the architecture diagram, both empirical insights (ConstantGate::new(2) injection + helper_degree = pad_bits + 1), per-slot Phase 2b constraints, public-API extensions, multi-leaf MMR fixture caveat, test coverage matrix, benchmark, and verification runbook.
  • Cross-references updated in ROADMAP.md (3 spots), account_server.rs (defense-in-depth comment), program-plonky2/SESSION_STATE.md (3 spots), recursion_shape_probe.rs, source_aggregator.rs (2 spots), main.rs (2 spots).
  • Standalone tracker file deleted.
  • git grep STAGE_5D_NEXT_5_AGGREGATOR.md returns 0 hits.
  • cargo doc -p zkcoins-program-plonky2 --no-deps green.

Item 1 — HTTP error mapping for /api/send + /api/mint (medium-high risk; API-contract change) — 3f0b9b4

The previous failure path returned 200 OK + {success: false} with no error string. Clients couldn't distinguish user error (insufficient funds, bad inclusion proof) from server error (prover failure, broadcast failure), and the send_coins error string was logged via eprintln! but never reached the caller.

  • SendCoinResponse gains error: Option<String> (skip-serializing-if-none, forward-compatible at the deserialization boundary on success responses).
  • New helper map_send_coins_error(&str) -> (StatusCode, &'static str) is the single source of truth for the API contract:
    • 404 NOT_FOUND → "Unknown account address"
    • 400 BAD_REQUEST → "prev_commitment_pubkey required for account update"
    • 422 UNPROCESSABLE_ENTITY → "Insufficient funds", the two Phase 2b defense-in-depth shim rejections (in-coin not in source OCR / source not in history MMR), malformed witness (coin missing commitment, missing inclusion proof, coin already in coin-history / output-coins SMT), slot-count violations.
    • 500 INTERNAL_SERVER_ERROR → "prove failed" (collapsed; full error logged via eprintln! so attacker can't probe prover internals).
    • 500 INTERNAL_SERVER_ERROR → "internal error" catch-all for new error strings nobody mapped yet — wallet treats it as server problem, operator finds the unmapped string in logs.
  • New helper handler_error_response(StatusCode, &str) for request-level failures (signature verification, hex decode, address length mismatch, broadcast failure, etc.) so every failure carries a body.error string instead of an opaque empty body.
  • Wired through send_coin_handler + mint_handler + commit_handler + server_runtime::broadcast_commit_and_deliver.

Tests: 14 unit tests pin every documented error string to its (StatusCode, body) pair. New handler test send_with_unknown_account_returns_404_with_error_string exercises 404 end-to-end. Existing send_with_insufficient_funds test updated to assert the new contract (renamed _returns_ok_with_success_false_returns_422_with_error_string).

Migration note for wallet maintainer: Clients that hard-code if status == 200 { success } else { fail } will see 4xx where they previously saw 200 + success:false. The body shape is otherwise additive (success responses serialize exactly as before).

Item 3 — CI cyclic-tests inclusion + timeout bump (high risk; only verifiable via CI) — ebe7b10

The tests job was skipping the long-running cyclic positives via --skip stage_5b --skip stage_5c --skip stage_5d --skip stage_5e with a stale comment claiming "they are exercised explicitly in the coverage job below". The coverage job only covers the server crate — 44 cyclic positives/negatives in program-plonky2 were silently running locally only.

  • Dropped all --skip stage_5* flags.
  • Renamed the step from "off-circuit + non-cyclic gadgets" → "full cyclic-recursion sweep".
  • Bumped tests job timeout-minutes from 75 → 180. Worst-case wall ≈ 125–165 min (≈ 30–45 min server + ≈ 80–120 min program-plonky2 single-threaded on ubuntu-latest).
  • Documented the OOM mitigation path in-line (exit 143 → larger runner / --ignored heavies / shard).

Verification caveat: the workflow only triggers on PRs targeting develop. PRs targeting feat/plonky2-migration do NOT exercise the change. The change takes effect once feat/plonky2-migration merges to develop via PR #18 (release PR).

Item 2 — Drop CI coverage exclusions for account_server.rs + server.rs (medium risk) — pending llvm-cov verification

Current state: the workflow's --ignore-filename-regex already does NOT contain account_server.rs or server.rs (the exclusions were dropped earlier; Issue #28's snapshot was stale at the file level). The remaining work is to verify locally that the existing gate runs green with the current exclusions, and close any gaps that surface.

A local cargo llvm-cov --release -p server --show-missing-lines --ignore-filename-regex 'main\.rs|publisher\.rs|server_runtime\.rs|scanner_runtime\.rs|_tests\.rs$' --fail-under-lines 100 --fail-under-functions 100 -- --test-threads=1 is currently in flight (~30–60 min wall). I'll push any gap-closing commits to this branch as soon as it lands.

If you want to merge before then, the gate is enforced in CI by the coverage job — if anything's actually broken, the release-PR CI run will catch it.

Pre-flight stabilizer — 0a3bfb7

A pre-existing bug in server/src/server_tests.rs:124 (introduced in PR #24) made cargo clippy --all-features --tests -- -D warnings fail at HEAD: the usernames-feature-gated test was passing [u8; 32] to UsernameStore::claim, which expects Address = HashDigest after the Plonky2 migration. Fixed by round-tripping through digest_from_bytes. Unblocks the local pre-flight gate this PR's other items take as their baseline.

Files changed

$ git diff --stat feat/plonky2-migration..HEAD
 .github/workflows/ci.yaml                        |  41 ++-
 MIGRATION_RESEARCH.md                            | 274 +++++++++++++++++-
 ROADMAP.md                                       |   6 +-
 program-plonky2/SESSION_STATE.md                 |  16 +-
 program-plonky2/STAGE_5D_NEXT_5_AGGREGATOR.md    | 354 -----------------------
 program-plonky2/src/circuit/main.rs              |   4 +-
 program-plonky2/src/circuit/recursion_shape_probe.rs |   3 +-
 program-plonky2/src/circuit/source_aggregator.rs |   6 +-
 server/src/account_server.rs                     |   5 +-
 server/src/server.rs                             | 213 ++++++++++----
 server/src/server_runtime.rs                     |   1 +
 server/src/server_tests.rs                       | 184 +++++++++++-

Verification

  • cargo check --workspace --all-targets
  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo doc -p zkcoins-program-plonky2 --no-deps
  • 14 unit tests for map_send_coins_error ✅ (0.00s)
  • HTTP-level tests: insufficient_funds → 422 ✅, unknown_account → 404 ✅, malformed inputs (5 tests) ✅
  • cargo llvm-cov with relaxed exclusion list — in flight.

House rules followed

  • Draft PR per feedback_pr_as_draft.
  • No Claude/AI/Generated with footers per global rule.
  • One PR per user request ("mit neuem PR der alles beinhaltet"), not four.
  • Targets feat/plonky2-migration, NOT develop directly (per feedback_zkcoins_direct_develop exception: this branch is itself en route to develop via PR feat: Plonky2 / Poseidon proof-system migration #17).

Out of scope

  • Wallet-side adaptation to the new 4xx contract (zk-coins/app repo).
  • The Item 3 verification CI run — will happen automatically when feat/plonky2-migration lands in develop.

Audit follow-up commits (after the initial PR open)

A consistency audit before final review surfaced three reachable account_server error strings that were falling through to the generic 500 catch-all, plus one missed broadcast-503 path:

  • d63c9a8 feat(api): map three more send_coins errors + refresh SESSION_STATE — adds Unable to get merkle proofs for provided public key (422), Unable to get mmr inclusion proof for the previous root (422), and Proof public_inputs too short (500) to map_send_coins_error. Three new unit tests pin each. Total map_send_coins_error_* unit-test count rises from 14 → 17. SESSION_STATE.md "Active parallel work" refreshed to mark all four Issue Stage 5d-next-5 / Step 7 housekeeping: HTTP errors + CI coverage + CI cyclic-tests + doc fold #28 items ✅ done.
  • 0f67cd8 fix(api): plumb body.error through commit broadcast 503 pathserver_runtime::broadcast_commit_and_deliver 503 branch was still returning SendCoinResponse::default() with no error string. Now uses handler_error_response like the mint-handler analogue.

Test count delta: +18 tests in this PR total (14 + 3 unit tests + 1 new handler-level 404 test). Post-merge full server sweep is 138 tests (up from 120 in the previous feat/plonky2-migration state).

Final branch HEAD: 0f67cd8. PR is 6 commits.

The `usernames`-feature-gated test
`balance_unknown_address_with_claimed_username_returns_username`
in server_tests.rs passed `[u8; 32]` to `UsernameStore::claim`,
whose signature expects `Address = HashDigest = HashOut<F>` (the
Plonky2-era type after the migration). `cargo check --all-targets`
without `--all-features` skipped the test gate and missed the
breakage; `cargo clippy --all-targets --all-features -- -D warnings`
fails it. Round-trip the address through `digest_from_bytes` so the
test compiles on both feature configurations.

Unblocks the local pre-flight sanity gate the housekeeping
work in #28 takes as its baseline.
…7.22

The standalone aggregator architecture write-up was created during
the Stage 5d-next-5 work as a tracking artifact. Now that all four
phases (1 + 2a + 2b + 3) have landed, the content belongs in the
§7.x lessons-learned series alongside the other empirical findings
from the Plonky2 migration.

§7.22 captures the canonical content:
- Architecture diagram (aggregator non-cyclic + outer cyclic).
- The two empirical insights pinned by recursion_shape_probe:
  ConstantGate::new(2) injection in pass 3 of the helper, and
  helper_degree = pad_bits + 1 (INNER_PAD_BITS_STAGE_5D_NEXT_5=15
  for the Phase 2b outer at degree 16).
- Per-slot Phase 2b constraints (8 items, masked by active bit).
- Public-API extensions: InCoinSourceWitness + the two
  _and_sources prove entries.
- Multi-leaf MMR test-fixture caveat (consumer-at-0 + source-at-1
  with shared bootstrap leaf).
- Test coverage matrix (5 positives + 3 §13 negatives).
- Per-test benchmark on M3 and the verification runbook.
- Rule-of-thumb capstone for future multi-verify outer circuits.

§7.21 status updated from "deferred" to "resolved in §7.22".
Cross-references updated in ROADMAP.md row 5 + row 7 + §5,
account_server.rs defense-in-depth comment, program-plonky2/
SESSION_STATE.md (3 locations), recursion_shape_probe.rs doc
comment, source_aggregator.rs doc comment (2 locations), and
main.rs doc comments (2 locations). All now point at the
§7.22 anchor.

Verified post-fold:
- `git grep STAGE_5D_NEXT_5_AGGREGATOR.md` returns no hits.
- `cargo doc -p zkcoins-program-plonky2 --no-deps` succeeds with
  no broken intra-doc links.
…body

The /api/send + /api/mint failure path previously surfaced every
send_coins error as 200 OK with an empty SendCoinResponse {
success: false }. Clients consuming the API had no way to
distinguish "user error" (insufficient funds, bad inclusion proof)
from "server error" (prover failure, broadcast failure), and the
specific error string from send_coins was logged via eprintln!
but never reached the caller.

Changes:

- Add `error: Option<String>` to SendCoinResponse. Present on every
  failure (success: false), absent on success.

- Introduce `map_send_coins_error(&str) -> (StatusCode, &'static str)`
  as the single source of truth for the API contract:
  - 404 NOT_FOUND  → "Unknown account address"
  - 400 BAD_REQUEST → "prev_commitment_pubkey required for account
                       update"
  - 422 UNPROCESSABLE_ENTITY → Insufficient funds, defense-in-depth
                       shim rejections (in-coin not in source OCR /
                       source not in history MMR), malformed witness
                       (coin missing commitment, missing inclusion
                       proof, coin already in coin-history /
                       output-coins SMT), slot-count violations
  - 500 INTERNAL_SERVER_ERROR → "prove failed" (collapsed; full error
                       string is still logged via eprintln!)
  - 500 INTERNAL_SERVER_ERROR → "internal error" (catch-all for
                       newly-added error strings we haven't mapped
                       yet — wallet treats it as a server problem,
                       operator finds the unmapped string in logs)

- Introduce `handler_error_response(StatusCode, &str)` for
  request-level failures (signature verification, hex decode,
  address length mismatch, broadcast failure, etc.) so every failure
  carries a body.error string instead of an opaque empty body.

- Wire send_coin_handler + mint_handler + commit_handler through
  the two helpers. The defense-in-depth shim from PR #26 (Stage
  5d-next-5 Phase 2b) now surfaces as a specific 422 in microseconds
  before the prove cost is paid; full latency profile preserved.

Tests:

- 14 unit tests pin every documented send_coins error string to its
  mapped (status, body) pair. Adding a new error string in
  account_server::send_coins that isn't mapped will surface in the
  catch-all 500 arm — and the unknown-string test ensures that
  surfaces as 500 internal error rather than leaking through as
  2xx.

- New handler-level test `send_with_unknown_account_returns_404_
  with_error_string` exercises the 404 path end-to-end.

- Updated `send_with_insufficient_funds_returns_422_with_error_
  string` (renamed from `_returns_ok_with_success_false`) to assert
  the new contract.

- All five malformed-input HTTP tests still green; their assertions
  are unchanged (4xx already), so the body.error addition is
  forward-compatible at the deserialization boundary.

Migration note: wallet clients that hard-code the `if status ==
200 { success } else { fail }` check will see 4xx where they
previously saw 200 + success:false. The body shape is otherwise
forward-compatible (error: Option<String> is skip_serializing_if =
"Option::is_none", so success responses still serialize exactly as
before).
…0 min

The `tests` job was previously skipping the long-running cyclic
positives via `--skip stage_5b --skip stage_5c --skip stage_5d
--skip stage_5e` with the comment "they are exercised explicitly
in the coverage job below". That comment was stale: the `coverage`
job runs `cargo llvm-cov -p server`, which only covers the `server`
crate and does NOT touch `program-plonky2`'s cyclic-recursion tests.

Result: 29 stage_5d (incl. 5d-next-3 + 5d-next-5) tests, 5 stage_5e
SPEC §13 negatives, 10 stage_5c_plus tests — 44 cyclic positives
and negatives total — were running locally only. A regression that
broke a cyclic constraint while compiling cleanly would land on
develop without anyone noticing.

This change:

- Drops every `--skip stage_5*` flag from the program-plonky2 test
  step.
- Renames the step from "off-circuit + non-cyclic gadgets" to
  "full cyclic-recursion sweep" to match what it now does.
- Bumps `tests` job `timeout-minutes` from 75 → 180. Local
  single-threaded wall on the `program-plonky2` lib sweep is ~42
  min on M3; `ubuntu-latest` is ~2.5× slower without GPU
  acceleration so the budget is ~80–120 min. Combined with the
  ~30–45 min server suite, worst-case wall is ~125–165 min.
  180 min cap leaves headroom for a one-off cache miss without
  resorting to a larger-tier runner.
- Documents the OOM mitigation path in-line (exit code 143 →
  larger runner / `--ignored` heavies / shard) so the next person
  hitting it doesn't have to rediscover the trade-offs.

Verification caveat: this workflow only triggers on PRs targeting
`develop`, so the in-PR CI cycle for this branch (target
`feat/plonky2-migration`) does NOT exercise the change. The change
takes effect once `feat/plonky2-migration` merges into `develop`
via the release PR.
A consistency audit of the new HTTP error mapping surfaced three
reachable `account_server` error strings that were falling through
to the generic 500 "internal error" arm. All three originate in
`get_merkle_proofs` (called from `send_coins` on the
prev_commitment_pubkey path).

- "Unable to get merkle proofs for provided public key"
  (account_server.rs:224) → 422. Caller supplied a public_key the
  server has no commitment proof for.

- "Unable to get mmr inclusion proof for the previous root"
  (account_server.rs:236) → 422. Caller's previous_proof references
  a history root the server's MMR hasn't observed yet — stale
  client-side snapshot.

- "Proof public_inputs too short" (account_server.rs:232) → 500.
  Truncated proof bytes — server-side data corruption or version
  mismatch with the prover. Not caller-fixable, hence 500 + the
  full string preserved in the body (no prove-internal information
  to leak, unlike `*_failed` which is the actual prover output).

Three new unit tests pin each mapping. Test count for the
`map_send_coins_error_*` block is now 17.

Also refresh program-plonky2/SESSION_STATE.md "Active parallel
work" to mark all four Issue #28 housekeeping items as ✅ done.
The previous text listed coverage-exclusions and cyclic-tests as
pending — both land in PR #31.
@TaprootFreak
TaprootFreak marked this pull request as ready for review May 18, 2026 16:29
commit_handler delegates to server_runtime::broadcast_commit_and_
deliver for the broadcast step. Its 503 SERVICE_UNAVAILABLE arm
still returned an empty SendCoinResponse::default(), missing the
body.error string the rest of the post-Item-1 handlers now carry.

Mirror of the mint_handler analogue: use handler_error_response
so 503 responses surface the failure mode to the client (the
operator can already see the underlying Esplora error in the
eprintln! log).
The 18 new tests added in PR #31 (14 + 3 `map_send_coins_error_*`
unit tests + 1 new handler-level 404 test) bring the
`--all-features` server sweep count from 120 to 138. Update the
breakdown line at the top of the file and the "Test confirmation
status" reference so the next reader of SESSION_STATE.md sees the
post-merge state.
…ines)

The previous text overclaimed "passes at 100% lines / 100%
functions". The actual local result with the production
exclusion list is exit 0 (acceptance criterion met) with 100%
functions (96/96) but 99.44% lines (1067/1073).

The 6 uncovered lines are all `?` error-propagation sites in
account_server::send_coins (323, 358, 400, 412, 415, 478) —
reachable Err paths not exercised by the current test set but
not blocking the gate. No tactical #[coverage(off)] added.
@TaprootFreak
TaprootFreak marked this pull request as ready for review May 18, 2026 19:08
@TaprootFreak
TaprootFreak merged commit 19b9407 into feat/plonky2-migration May 18, 2026
@TaprootFreak
TaprootFreak deleted the feat/plonky2-5d-next-5-housekeeping branch May 18, 2026 19:09
TaprootFreak added a commit that referenced this pull request May 18, 2026
)

PR #31's local `cargo llvm-cov` run returned exit 0 at 99.44% line
coverage with 6 uncovered `?` error-propagation sites in
account_server::send_coins. The gate accepts exit 0 as
authoritative, but the 99.44% leaves a real (if narrow) regression
window: if cargo-llvm-cov 0.8.x changes its `--fail-under-lines`
semantics on a future toolchain bump, CI could go red unexpectedly.

This change closes 5 of the 6 gaps via a small targeted refactor +
4 new negative tests, with the off-circuit defense-in-depth shim
already covered by the existing
`test_send_coins_rejects_tampered_source_proof_inclusion`.

## Refactor

- `Account::create_coins` previously returned
  `Result<Vec<Coin>, &'static str>` but never produced an Err (the
  upstream balance/slot-count guards in `send_coins` are total).
  Drops the dead `Result` so the call site has no dead `?` path.
- `send_coins`'s `in_coins.len() > MAX_IN_COINS` and `out_coins.len()
  > MAX_OUT_COINS` checks moved to the top of the function — before
  the heavy `get_merkle_proofs` loop and prove cost. Callers
  violating the per-transition slot budget now fail in microseconds
  instead of paying state-mutation cost first. The new guards
  use `account.coin_queue.len()` and `invoices.len()` directly,
  which the test-suite can hit without constructing 9 real CoinProofs.

## New tests

- `test_send_coins_rejects_too_many_invoices` — `invoices.len() >
  MAX_OUT_COINS`. Empty account, no prove cost.

- `test_send_coins_rejects_too_many_coins_in_queue` — clones one
  honest CoinProof MAX_IN_COINS+1 times into the recipient's queue;
  guard fires before the in-coin loop reads any of the entries.

- `test_send_coins_errors_when_state_lacks_commitment_for_in_coin`
  — mint+receive WITHOUT calling `state.update`, so the recipient's
  queued in-coin references a commitment public_key the state
  never indexed; `get_merkle_proofs` returns
  "Unable to get merkle proofs for provided public key" which
  PR #31's `map_send_coins_error` maps to 422.

- `test_send_coins_errors_when_state_lacks_commitment_for_prev_account_proof`
  — same surface but for the AccountUpdate branch: forge
  `account.proof = Some(...)`, pass a never-indexed
  `prev_commitment_pubkey`. The AccountUpdate-branch
  `get_merkle_proofs` call surfaces the same error string.

All 4 tests green: 295 s wall single-threaded in release on M3.

## Verification

- `cargo check --workspace --all-targets` ✅
- `cargo clippy --workspace --all-features --tests --all-targets --
  -D warnings` ✅
- `cargo fmt --all --check` ✅
- 4 new tests green (295 s)
- Existing 139 tests in the server crate unchanged

Lines previously uncovered (323/358/400/412/415/478) are now either
covered by these 4 tests (323+478 via the merkle-proofs failure
tests; 412+415 via the new top-of-function guards) or remain a
single tracker line in the off-circuit defense-in-depth shim
(line 400 — Source-not-in-MMR — covered by the in-circuit Phase 2b
gate and the existing test_send_coins_rejects_tampered_source_proof_inclusion).
TaprootFreak added a commit that referenced this pull request May 18, 2026
closes #28) (#31)

* fix(tests): convert byte address to HashDigest in username claim test

The `usernames`-feature-gated test
`balance_unknown_address_with_claimed_username_returns_username`
in server_tests.rs passed `[u8; 32]` to `UsernameStore::claim`,
whose signature expects `Address = HashDigest = HashOut<F>` (the
Plonky2-era type after the migration). `cargo check --all-targets`
without `--all-features` skipped the test gate and missed the
breakage; `cargo clippy --all-targets --all-features -- -D warnings`
fails it. Round-trip the address through `digest_from_bytes` so the
test compiles on both feature configurations.

Unblocks the local pre-flight sanity gate the housekeeping
work in #28 takes as its baseline.

* docs: fold STAGE_5D_NEXT_5_AGGREGATOR.md into MIGRATION_RESEARCH.md §7.22

The standalone aggregator architecture write-up was created during
the Stage 5d-next-5 work as a tracking artifact. Now that all four
phases (1 + 2a + 2b + 3) have landed, the content belongs in the
§7.x lessons-learned series alongside the other empirical findings
from the Plonky2 migration.

§7.22 captures the canonical content:
- Architecture diagram (aggregator non-cyclic + outer cyclic).
- The two empirical insights pinned by recursion_shape_probe:
  ConstantGate::new(2) injection in pass 3 of the helper, and
  helper_degree = pad_bits + 1 (INNER_PAD_BITS_STAGE_5D_NEXT_5=15
  for the Phase 2b outer at degree 16).
- Per-slot Phase 2b constraints (8 items, masked by active bit).
- Public-API extensions: InCoinSourceWitness + the two
  _and_sources prove entries.
- Multi-leaf MMR test-fixture caveat (consumer-at-0 + source-at-1
  with shared bootstrap leaf).
- Test coverage matrix (5 positives + 3 §13 negatives).
- Per-test benchmark on M3 and the verification runbook.
- Rule-of-thumb capstone for future multi-verify outer circuits.

§7.21 status updated from "deferred" to "resolved in §7.22".
Cross-references updated in ROADMAP.md row 5 + row 7 + §5,
account_server.rs defense-in-depth comment, program-plonky2/
SESSION_STATE.md (3 locations), recursion_shape_probe.rs doc
comment, source_aggregator.rs doc comment (2 locations), and
main.rs doc comments (2 locations). All now point at the
§7.22 anchor.

Verified post-fold:
- `git grep STAGE_5D_NEXT_5_AGGREGATOR.md` returns no hits.
- `cargo doc -p zkcoins-program-plonky2 --no-deps` succeeds with
  no broken intra-doc links.

* feat(api): replace 200+success:false with 4xx/5xx + structured error body

The /api/send + /api/mint failure path previously surfaced every
send_coins error as 200 OK with an empty SendCoinResponse {
success: false }. Clients consuming the API had no way to
distinguish "user error" (insufficient funds, bad inclusion proof)
from "server error" (prover failure, broadcast failure), and the
specific error string from send_coins was logged via eprintln!
but never reached the caller.

Changes:

- Add `error: Option<String>` to SendCoinResponse. Present on every
  failure (success: false), absent on success.

- Introduce `map_send_coins_error(&str) -> (StatusCode, &'static str)`
  as the single source of truth for the API contract:
  - 404 NOT_FOUND  → "Unknown account address"
  - 400 BAD_REQUEST → "prev_commitment_pubkey required for account
                       update"
  - 422 UNPROCESSABLE_ENTITY → Insufficient funds, defense-in-depth
                       shim rejections (in-coin not in source OCR /
                       source not in history MMR), malformed witness
                       (coin missing commitment, missing inclusion
                       proof, coin already in coin-history /
                       output-coins SMT), slot-count violations
  - 500 INTERNAL_SERVER_ERROR → "prove failed" (collapsed; full error
                       string is still logged via eprintln!)
  - 500 INTERNAL_SERVER_ERROR → "internal error" (catch-all for
                       newly-added error strings we haven't mapped
                       yet — wallet treats it as a server problem,
                       operator finds the unmapped string in logs)

- Introduce `handler_error_response(StatusCode, &str)` for
  request-level failures (signature verification, hex decode,
  address length mismatch, broadcast failure, etc.) so every failure
  carries a body.error string instead of an opaque empty body.

- Wire send_coin_handler + mint_handler + commit_handler through
  the two helpers. The defense-in-depth shim from PR #26 (Stage
  5d-next-5 Phase 2b) now surfaces as a specific 422 in microseconds
  before the prove cost is paid; full latency profile preserved.

Tests:

- 14 unit tests pin every documented send_coins error string to its
  mapped (status, body) pair. Adding a new error string in
  account_server::send_coins that isn't mapped will surface in the
  catch-all 500 arm — and the unknown-string test ensures that
  surfaces as 500 internal error rather than leaking through as
  2xx.

- New handler-level test `send_with_unknown_account_returns_404_
  with_error_string` exercises the 404 path end-to-end.

- Updated `send_with_insufficient_funds_returns_422_with_error_
  string` (renamed from `_returns_ok_with_success_false`) to assert
  the new contract.

- All five malformed-input HTTP tests still green; their assertions
  are unchanged (4xx already), so the body.error addition is
  forward-compatible at the deserialization boundary.

Migration note: wallet clients that hard-code the `if status ==
200 { success } else { fail }` check will see 4xx where they
previously saw 200 + success:false. The body shape is otherwise
forward-compatible (error: Option<String> is skip_serializing_if =
"Option::is_none", so success responses still serialize exactly as
before).

* ci: enable full program-plonky2 cyclic test sweep, bump timeout to 180 min

The `tests` job was previously skipping the long-running cyclic
positives via `--skip stage_5b --skip stage_5c --skip stage_5d
--skip stage_5e` with the comment "they are exercised explicitly
in the coverage job below". That comment was stale: the `coverage`
job runs `cargo llvm-cov -p server`, which only covers the `server`
crate and does NOT touch `program-plonky2`'s cyclic-recursion tests.

Result: 29 stage_5d (incl. 5d-next-3 + 5d-next-5) tests, 5 stage_5e
SPEC §13 negatives, 10 stage_5c_plus tests — 44 cyclic positives
and negatives total — were running locally only. A regression that
broke a cyclic constraint while compiling cleanly would land on
develop without anyone noticing.

This change:

- Drops every `--skip stage_5*` flag from the program-plonky2 test
  step.
- Renames the step from "off-circuit + non-cyclic gadgets" to
  "full cyclic-recursion sweep" to match what it now does.
- Bumps `tests` job `timeout-minutes` from 75 → 180. Local
  single-threaded wall on the `program-plonky2` lib sweep is ~42
  min on M3; `ubuntu-latest` is ~2.5× slower without GPU
  acceleration so the budget is ~80–120 min. Combined with the
  ~30–45 min server suite, worst-case wall is ~125–165 min.
  180 min cap leaves headroom for a one-off cache miss without
  resorting to a larger-tier runner.
- Documents the OOM mitigation path in-line (exit code 143 →
  larger runner / `--ignored` heavies / shard) so the next person
  hitting it doesn't have to rediscover the trade-offs.

Verification caveat: this workflow only triggers on PRs targeting
`develop`, so the in-PR CI cycle for this branch (target
`feat/plonky2-migration`) does NOT exercise the change. The change
takes effect once `feat/plonky2-migration` merges into `develop`
via the release PR.

* feat(api): map three more send_coins errors + refresh SESSION_STATE

A consistency audit of the new HTTP error mapping surfaced three
reachable `account_server` error strings that were falling through
to the generic 500 "internal error" arm. All three originate in
`get_merkle_proofs` (called from `send_coins` on the
prev_commitment_pubkey path).

- "Unable to get merkle proofs for provided public key"
  (account_server.rs:224) → 422. Caller supplied a public_key the
  server has no commitment proof for.

- "Unable to get mmr inclusion proof for the previous root"
  (account_server.rs:236) → 422. Caller's previous_proof references
  a history root the server's MMR hasn't observed yet — stale
  client-side snapshot.

- "Proof public_inputs too short" (account_server.rs:232) → 500.
  Truncated proof bytes — server-side data corruption or version
  mismatch with the prover. Not caller-fixable, hence 500 + the
  full string preserved in the body (no prove-internal information
  to leak, unlike `*_failed` which is the actual prover output).

Three new unit tests pin each mapping. Test count for the
`map_send_coins_error_*` block is now 17.

Also refresh program-plonky2/SESSION_STATE.md "Active parallel
work" to mark all four Issue #28 housekeeping items as ✅ done.
The previous text listed coverage-exclusions and cyclic-tests as
pending — both land in PR #31.

* fix(api): plumb body.error through commit broadcast 503 path

commit_handler delegates to server_runtime::broadcast_commit_and_
deliver for the broadcast step. Its 503 SERVICE_UNAVAILABLE arm
still returned an empty SendCoinResponse::default(), missing the
body.error string the rest of the post-Item-1 handlers now carry.

Mirror of the mint_handler analogue: use handler_error_response
so 503 responses surface the failure mode to the client (the
operator can already see the underlying Esplora error in the
eprintln! log).

* docs(session-state): bump server test count 120 → 138 post-PR-#31

The 18 new tests added in PR #31 (14 + 3 `map_send_coins_error_*`
unit tests + 1 new handler-level 404 test) bring the
`--all-features` server sweep count from 120 to 138. Update the
breakdown line at the top of the file and the "Test confirmation
status" reference so the next reader of SESSION_STATE.md sees the
post-merge state.

* docs(session-state): clarify Item 2 llvm-cov result (exit 0, 99.44% lines)

The previous text overclaimed "passes at 100% lines / 100%
functions". The actual local result with the production
exclusion list is exit 0 (acceptance criterion met) with 100%
functions (96/96) but 99.44% lines (1067/1073).

The 6 uncovered lines are all `?` error-propagation sites in
account_server::send_coins (323, 358, 400, 412, 415, 478) —
reachable Err paths not exercised by the current test set but
not blocking the gate. No tactical #[coverage(off)] added.
TaprootFreak added a commit that referenced this pull request May 18, 2026
)

PR #31's local `cargo llvm-cov` run returned exit 0 at 99.44% line
coverage with 6 uncovered `?` error-propagation sites in
account_server::send_coins. The gate accepts exit 0 as
authoritative, but the 99.44% leaves a real (if narrow) regression
window: if cargo-llvm-cov 0.8.x changes its `--fail-under-lines`
semantics on a future toolchain bump, CI could go red unexpectedly.

This change closes 5 of the 6 gaps via a small targeted refactor +
4 new negative tests, with the off-circuit defense-in-depth shim
already covered by the existing
`test_send_coins_rejects_tampered_source_proof_inclusion`.

## Refactor

- `Account::create_coins` previously returned
  `Result<Vec<Coin>, &'static str>` but never produced an Err (the
  upstream balance/slot-count guards in `send_coins` are total).
  Drops the dead `Result` so the call site has no dead `?` path.
- `send_coins`'s `in_coins.len() > MAX_IN_COINS` and `out_coins.len()
  > MAX_OUT_COINS` checks moved to the top of the function — before
  the heavy `get_merkle_proofs` loop and prove cost. Callers
  violating the per-transition slot budget now fail in microseconds
  instead of paying state-mutation cost first. The new guards
  use `account.coin_queue.len()` and `invoices.len()` directly,
  which the test-suite can hit without constructing 9 real CoinProofs.

## New tests

- `test_send_coins_rejects_too_many_invoices` — `invoices.len() >
  MAX_OUT_COINS`. Empty account, no prove cost.

- `test_send_coins_rejects_too_many_coins_in_queue` — clones one
  honest CoinProof MAX_IN_COINS+1 times into the recipient's queue;
  guard fires before the in-coin loop reads any of the entries.

- `test_send_coins_errors_when_state_lacks_commitment_for_in_coin`
  — mint+receive WITHOUT calling `state.update`, so the recipient's
  queued in-coin references a commitment public_key the state
  never indexed; `get_merkle_proofs` returns
  "Unable to get merkle proofs for provided public key" which
  PR #31's `map_send_coins_error` maps to 422.

- `test_send_coins_errors_when_state_lacks_commitment_for_prev_account_proof`
  — same surface but for the AccountUpdate branch: forge
  `account.proof = Some(...)`, pass a never-indexed
  `prev_commitment_pubkey`. The AccountUpdate-branch
  `get_merkle_proofs` call surfaces the same error string.

All 4 tests green: 295 s wall single-threaded in release on M3.

## Verification

- `cargo check --workspace --all-targets` ✅
- `cargo clippy --workspace --all-features --tests --all-targets --
  -D warnings` ✅
- `cargo fmt --all --check` ✅
- 4 new tests green (295 s)
- Existing 139 tests in the server crate unchanged

Lines previously uncovered (323/358/400/412/415/478) are now either
covered by these 4 tests (323+478 via the merkle-proofs failure
tests; 412+415 via the new top-of-function guards) or remain a
single tracker line in the off-circuit defense-in-depth shim
(line 400 — Source-not-in-MMR — covered by the in-circuit Phase 2b
gate and the existing test_send_coins_rejects_tampered_source_proof_inclusion).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant