Skip to content

Promote: staging -> develop - #200

Merged
TaprootFreak merged 7 commits into
developfrom
staging
Jun 4, 2026
Merged

Promote: staging -> develop#200
TaprootFreak merged 7 commits into
developfrom
staging

Conversation

@github-actions

@github-actions github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Automatic Promote PR

Commits: 1 new commit(s)

  • Review all changes
  • Verify CI passes
  • Merge to promote staging to develop (deploys to DEV)

… subset parallelism (#196)

The "DB Subset Tests" CI job runs a narrow nextest selection
(`db::tests` + `job_store::tests` + `router::tests::jobs_*` + ...)
under `--test-threads 8`. In that job the five SSE tests
`router::tests::jobs_endpoint_tests::jobs_stream_*` intermittently
fail with `create: PoolTimedOut` after running >100 s, while the full
coverage gate (same `--test-threads 8`, on the same SHA) keeps them
green.

Root cause is migration-replay contention, not raw connection
exhaustion. Every test that calls `crate::test_db::setup_pool()`
CREATEs a fresh per-test schema and replays the full migration suite
(16 DDL files: tables, triggers, views) into the single shared
`postgres:17` container. Postgres serialises concurrent DDL on its
system catalogs, so when the DB subset packs the migration-replaying
tests together and eight run at once, each `setup_pool()` stretches
from <1 s to tens of seconds. The `jobs_stream_*` tests additionally
hold their pool across deliberate sleep/timeout windows, so under that
contention their connection acquisition exceeds the pool's 60 s
`acquire_timeout` and surfaces as `PoolTimedOut`. The full gate stays
green because the same heavy tests are interleaved across the entire
suite rather than clustered. Peak server connections stay ~14/100
throughout, confirming the bottleneck is DDL catalog locking.

Fix: add a workspace-root `.config/nextest.toml` test-group that caps
the `router::tests::jobs_endpoint_tests` module at 2 concurrent
threads. This bounds simultaneous migration replays for the heaviest
module so connection acquisition stays well under the 60 s timeout,
while keeping useful parallelism for the rest of the suite. The config
is honoured by both `cargo nextest run` (the subset gates) and
`cargo llvm-cov nextest` (the coverage gate), carries no coverage
semantics, and touches no test pool — the deliberately-narrow
error-path `dead_pool` (`max_connections(1)` / 50 ms timeout) keeps
exercising its `PoolTimedOut` arms verbatim.

Verified locally: the exact DB-subset selection now passes 239/239
twice with no `PoolTimedOut` (the `jobs_stream_*` tests drop from ~34 s
to ~15 s each), and the 100% line + function coverage gate is
unchanged.
* docs: add decentralization roadmap (run-your-own-node, SPEC-anchored)

* docs: rename to DECENTRALIZATION_ROADMAP.md (ROADMAP.md already taken)

* docs(roadmap): set decentralization as the current focus (fold in S1-S7 + D2/D7/D8)

* docs(roadmap): set decentralization as the current focus (fold in S1-S7 + D2/D7/D8)
@TaprootFreak TaprootFreak added ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra) ci:db DB Subset Tests on M3 Ultra (~15 min). Exclusive with ci:full. labels Jun 4, 2026
@TaprootFreak
TaprootFreak marked this pull request as ready for review June 4, 2026 15:09
…the jobs API (#198)

The jobs-API admit handlers (`POST /api/jobs/mint`, `POST /api/jobs/send`)
require the `Idempotency-Key` request header (`read_idempotency_key`). A
browser sending that header triggers a CORS preflight (OPTIONS), but the
router's `CorsLayer` only allowed `Content-Type` in
`Access-Control-Allow-Headers`. The preflight therefore failed and the
web frontend could not mint or send.

Add `idempotency-key` to the CORS `allow_headers` list so the preflight
succeeds. `HeaderName::from_static` requires the lowercase form.

Cover the fix with a CORS preflight test (`OPTIONS /api/jobs/mint` with
`Access-Control-Request-Headers: idempotency-key`) asserting the response
echoes both `idempotency-key` and `content-type` in
`Access-Control-Allow-Headers`.
@TaprootFreak TaprootFreak removed the ci:db DB Subset Tests on M3 Ultra (~15 min). Exclusive with ci:full. label Jun 4, 2026
…#195)

* feat(jobs): expose account_state_hash + output_coins_root on awaiting_signature job result

A pure-TypeScript wallet must know account_state_hash (ash) and
output_coins_root (ocr) to sign the send commitment, but until now the
awaiting_signature JobStatus carried only proof_id. The hashes were
reachable solely via GET /api/proof/{id} as a binary bincode CoinProof
blob that only Rust/wasm can decode — breaking the thin-client rule
(wallet = key only, trusts the node, no heavy client-side logic).

This change writes ash + ocr as lowercase hex into the job result when a
send job transitions to awaiting_signature, so GET /api/jobs/:id and the
SSE stream surface them under result.account_state_hash /
result.output_coins_root — the exact keys @zkcoins/sdk's pay() reads.

ash/ocr come from the same source the completed mint/commit results use:
ProofData::from_field_elements over the send proof's public inputs,
hex-encoded via digest_to_bytes. Extraction is factored into a shared
flow::send_commit_hashes helper that mint_flow, send_flow, and
commit_flow all call, so the hex is bit-identical to what
createCommitment expects and commit_flow re-derives.

Purely additive: completed result shape, proof_id top-level field, and
the JobStatusResponse wire schema (result is already free-form JSON) are
unchanged. No new endpoint, env var, or migration. set_awaiting_signature
stores the result in the existing response_body column (the terminal
complete body overwrites it later); the GET handler and SSE initial frame
now surface result for awaiting_signature in addition to completed, and a
post-restart resume re-publishes the persisted hashes.

Tests: api_remote send roundtrip asserts the awaiting_signature result
hex equals the proof-decoded ash/ocr; job_store + router unit tests cover
the new persistence and snapshot paths (100% line + function gate green).

* docs(jobs): correct response_body field comment for awaiting_signature
Add a typed, lowercase string enum field `bitcoin_network` to the
/api/info response with exactly two variants: "mainnet" and
"mutinynet". The value is derived from the existing
NETWORK_CONFIG.is_mainnet flag via a pure, unit-testable helper
(bitcoin_network_label) — no new env var, no new config source.

The free-text `network` field (e.g. "Mainnet"/"Mutinynet" from
NETWORK_CONFIG.network_name) is retained unchanged for backward
compatibility; bitcoin_network is additive. This fixes the latent
case-mismatch foot-gun documented for the wallet/SDK, which should
switch behaviour on the typed identifier rather than matching the
operator-overridable free-text label.

Register BitcoinNetwork as a ToSchema component in the OpenAPI spec.
Cover both helper arms with a unit test, assert the field in the
existing /api/info handler tests, add OpenAPI smoke drift guards, and
add a no-fallback contract assertion in the api_remote E2E suite.
* feat(state): self-heal persisted proofs on circuit change

The Plonky2 state-transition circuit is cyclic: every proof is fed back
as the recursive inner proof on the next transition. When a circuit
change breaks recursion, persisted account proofs become incompatible
and the next mint/send aborts witness generation with a "Partition ...
was set twice with different values" copy-constraint conflict, surfaced
to the wallet as "prove failed". This took DEV down and required a
manual reset-zkcoins-node.

Add a boot-time self-heal that detects the incompatibility and resets
the proof-dependent state to genesis (the documented tabula rasa,
permitted in the closed test env), storing the live circuit digest so
subsequent boots are an O(1) comparison.

Detector. Two stages: (1) compare the persisted circuit_digest against
the live one — the cheap steady-state fast path; (2) on the adoption
boundary (no digest recorded yet) run a canary recursion: recurse a
persisted proof through the live circuit's AccountUpdate branch with the
real commitment-merkle witnesses from the loaded state. Stale ⇒ reset.

Why the canary and not Prover::verify / a digest comparison alone:
verified against the live DEV dump, the breakage does NOT change the
verifier-key circuit_digest. Plonky2's circuit_digest hashes the
constants/sigmas cap + domain separator + degree but NOT the gate
constraints (upstream circuit_builder.rs "TODO: This should also include
an encoding of gate constraints"). The DEV proofs' embedded digest was
byte-identical to the current build's and Prover::verify passed on them,
yet the recursive prove still failed. Only running the real recursion
reproduces the failure.

The digest is deterministic across separate builds of identical circuit
code (no nonce/timestamp), so a digest CHANGE still reliably signals a
circuit change — it just has a blind spot for constraint-only changes
that the canary closes.

- migration 0015: singleton circuit_digest_meta table
- db: load/store circuit digest + transactional proof-dependent reset
- account_node: CanaryOutcome + canary_recursion (real AccountUpdate
  recursion probe) + take_prover for the post-reset reload
- self_heal: reset_decision (pure, exhaustively unit-tested) +
  heal_circuit_digest orchestrator
- main: build prover once, load state+accounts, heal, reload from
  genesis on reset (prover reused — circuit built once)

* fix(state): use real account state in self-heal canary to avoid false-positive reset

The boot self-heal canary recursed a persisted proof through the live
circuit's AccountUpdate branch with a SYNTHETIC surrounding AccountState
({ owner: ZERO_HASH, balance: 0 }). That state violates the §8(b)/(c)
state-continuity constraints, so the canary returning Ok relied on the
fragile Plonky2 invariant that arithmetic gate constraints are not
evaluated at witness/prove time. Worse, there was no proof the canary
returns Compatible (not a false Stale -> genesis wipe -> production data
loss) on a genuinely compatible but digest-less DB — the first-boot case
of every existing node adopting this fix when no breaking change occurred.

Rebuild the REAL account state, exactly as the production prove path
(account_state_for_prove): owner = account address, balance =
account.balance, public_key = the account's CURRENT key. The current key
is NOT the persisted commitment_public_key: the circuit commits
ProofData.account_state_hash as final_account_state_hash, which embeds
the producing transition's next_public_key (the key it rotated TO). By
the rotation chain that equals the next transition's public_key; for the
minting account it is generate_public_key(derive_num_pubkeys_from_smt()).
commitment_public_key is still used, but only to look the commitment up
in the SMT via get_merkle_proofs (mirroring send_coins_inner's prev_cmp).

The boot path supplies the current-key resolver, reconstructed from the
same compile-time minting secret the node already uses and resolved off
the SMT the canary already holds (the resolver MUST NOT re-lock state —
the canary holds it, and a re-lock deadlocks the non-reentrant guard).
With the real state both §8(b)/(c) are satisfiable for a compatible
proof, so the only remaining prove-time failure path is the recursion
copy-constraint set_proof_with_pis imposes on the inner proof — exactly
what a breaking circuit change violates. Err => Stale no longer depends
on which constraints Plonky2 evaluates at prove time. Prover::verify
stays out of the detector.

Verified by a live boot-gate in BOTH directions: a stale DEV dump still
resets to genesis (Canary Stale -> Reset, post-reset mint completes), and
a genuinely compatible digest-less DB now baselines without wiping
(Canary Compatible -> Baseline, accounts preserved, mint completes).

Also:
- canary_recursion: document the append-only proof-data PI-slot
  assumption (a future circuit change reordering the first
  N_PROOF_DATA_PUBLIC_INPUTS slots would make get_merkle_proofs Err for
  every sample -> NoSample -> Baseline -> no reset despite staleness, a
  False Negative). Emit a tracing::warn when proof-carrying accounts
  exist but all are skipped. NoSample stays Baseline (the data-loss-safe
  direction for benign state gaps), not Stale, by design.
- reset_proof_dependent_state_tx: state the exact wipe set, note
  usernames is intentionally preserved (not proof-dependent), and note
  coin_proof_store (migration 0008) is unused schema groundwork with a
  MIGRATION_RESEARCH note to add it to the reset if the DB-backed
  ProofStore bootstrap later lands.
@TaprootFreak
TaprootFreak merged commit 24a3863 into develop Jun 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant