Skip to content

fix(server): prepare-then-commit mint to prevent state desync - #90

Merged
TaprootFreak merged 2 commits into
developfrom
fix/mint-state-desync-prepare-then-commit
May 23, 2026
Merged

fix(server): prepare-then-commit mint to prevent state desync#90
TaprootFreak merged 2 commits into
developfrom
fix/mint-state-desync-prepare-then-commit

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

Closes #89.

Problem

mint_handler advanced minting_meta.num_pubkeys, mutated the in-memory minting account, and persisted recipient state before attempting the on-chain inscription broadcast. When the broadcast failed (publisher empty, Esplora 5xx, WS timeout, etc.) the server's bookkeeping had already moved on, but the SMT/MMR never received the commitment — every subsequent mint and send for the same minting-account-pubkey-N then returned 422 with either:

  • Unable to get merkle proofs for provided public key, or
  • Unable to get mmr inclusion proof for the previous root

Once tripped, the only known recovery was a full DEV-state wipe.

Fix — prepare → broadcast → commit

  1. Snapshot phase: under the minting_account guard, read N = num_pubkeys and derive (pubkey_N, pubkey_{N+1}, pubkey_{N-1}). Release.
  2. Proof phase: clone the minting account and run prepare_mint (a new clone-based wrapper around the existing send_coins logic) against the clone — no mutation of self.accounts.
  3. Broadcast phase: create_and_broadcast_inscription. On Err503, NO state advanced anywhere.
  4. Commit phase (broadcast OK only): single sqlx::Transaction with an optimistic UPDATE: UPDATE minting_meta SET num_pubkeys = N+1 WHERE id = 1 AND num_pubkeys = N. On rows_affected() == 0503 "Concurrent mint detected". After tx commit: re-acquire the lock, atomically swap the mutated minting snapshot into account_server via commit_mint(...), and apply receive_coin (additive) on each recipient — no import_account that would overwrite concurrent updates. Persist each recipient with an independent db::upsert_account (mirrors the broadcast_commit_and_deliver shape for /api/send). Return 200.

Startup invariant check

In server_runtime::start_rest_server, before binding the listener:

For i in 0..num_pubkeys, assert state.get_commitment_proof(derive_public_key(i)) is Ok. Refuse to start on first miss with a CRITICAL log line naming the recovery procedure (reset_state workflow).

To avoid a false-positive when a recent mint hasn't yet been ingested by the scanner, the check awaits a bounded scanner-settle window: up to SCANNER_INITIAL_SETTLE_TIMEOUT_MS (default 90 s, env-overridable) for the scanner to make at least one successful state.update call. The signal is an Arc<AtomicU64> incremented on every successful state.update from the scanner callback (main.rs). On timeout, the SMT check runs against the current state anyway — better a loud false-positive than a silent zombie.

On bootstrap failure, main.rs now calls std::process::exit(1) so the orchestrator crash-loops cleanly (matches the existing panic-hook semantics).

What's not changed

  • commit_handler and broadcast_commit_and_deliver already do broadcast-first-then-receive_coin. Audited and confirmed. Documented as an invariant in code comments.
  • send_handler has no on-chain broadcast — it returns a CoinProof that clients submit via /api/commit. Nothing to refactor.

Tests

  • mint_broadcast_failure_does_not_advance_num_pubkeys — failed broadcast → num_pubkeys stays at Some(0).
  • mint_broadcast_failure_does_not_mutate_minting_account — in-memory state unchanged after failed broadcast.
  • mint_retry_after_broadcast_failure_succeeds — first call: dead Esplora → 503; second call: working Esplora → 200, num_pubkeys == 1, single inscription on mock.
  • concurrent_mints_only_one_commits — deterministic: pre-seeded stale minting_meta row triggers the loser branch with 503 "Concurrent mint detected".
  • startup_invariant_rejects_when_num_pubkeys_exceeds_smt — bootstraps with num_pubkeys = 5 + empty SMT, asserts start_rest_server errors with the CRITICAL message.
  • Existing mint_broadcast_failure_returns_503 was updated with state-unchanged assertions it was previously missing.
  • Existing mint_upsert_account_failure_logs_and_returns_ok was renamed/rewritten to mint_commit_tx_failure_returns_503 to reflect the new hard-fail semantics on commit-tx errors.

Companion change

DFXServer/server@6b0506c removes the orphan DEV_SKIP_BROADCAST_FAILURE=true env var from infrastructure/dfxdev/zkcoins/docker-compose.yaml and adds a reset_state runbook entry to the README. The env var was no longer read by the binary (since PR #73); this PR closes the desync class underneath, so the orphan line can finally go.

Test plan

  • CI green (lint + heavy tests + coverage gate).
  • After merge: DEV deploy → mint_roundtrip + send_commit_roundtrip against DEV pass cleanly. No retries on Unable to get merkle proofs or Unable to get mmr inclusion proof because the underlying desync can no longer happen.
  • Trigger a deliberate broadcast failure (e.g. drain publisher wallet) → /api/mint returns 503 → subsequent /api/mint after refunding the publisher succeeds with num_pubkeys advanced by exactly one.

@TaprootFreak
TaprootFreak marked this pull request as ready for review May 23, 2026 19:04
@TaprootFreak TaprootFreak added the ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra) label May 23, 2026
PR #87 merged with the nested `if let Some(block) { if let Some(hash)
{ if let Ok(h) = BlockHash::from_str(hash) { ... } } }` shape. The
closing brace at line 37 (the path where `block.id` is a string but
not a valid hex) reads as covered by the
`parse_ws_frame_returns_empty_when_block_id_is_invalid_hex` test in
local `cargo test`, but llvm-cov's region tracking reports the
closing-brace region as untaken — the Coverage Gate flags
`server/src/scanner_ws_parse.rs:37` as the only uncovered line.

Flatten the block arm to a single Option chain
(`block.get("id").and_then(...).and_then(...).map(...).unwrap_or_default()`).
Behavior is identical across every input shape the existing tests
cover; LLVM's region tracking collapses cleanly because the closing
brace no longer exists as a distinct sub-region.

Affects PR #18 (Release: develop -> main) and any open PR based on
develop (e.g. #90).
@TaprootFreak
TaprootFreak force-pushed the fix/mint-state-desync-prepare-then-commit branch from 00ab010 to ec4ce59 Compare May 23, 2026 19:32
@TaprootFreak
TaprootFreak changed the base branch from develop to fix/scanner-ws-parse-coverage May 23, 2026 19:32
TaprootFreak added a commit that referenced this pull request May 23, 2026
)

PR #87 merged with the nested `if let Some(block) { if let Some(hash)
{ if let Ok(h) = BlockHash::from_str(hash) { ... } } }` shape. The
closing brace at line 37 (the path where `block.id` is a string but
not a valid hex) reads as covered by the
`parse_ws_frame_returns_empty_when_block_id_is_invalid_hex` test in
local `cargo test`, but llvm-cov's region tracking reports the
closing-brace region as untaken — the Coverage Gate flags
`server/src/scanner_ws_parse.rs:37` as the only uncovered line.

Flatten the block arm to a single Option chain
(`block.get("id").and_then(...).and_then(...).map(...).unwrap_or_default()`).
Behavior is identical across every input shape the existing tests
cover; LLVM's region tracking collapses cleanly because the closing
brace no longer exists as a distinct sub-region.

Affects PR #18 (Release: develop -> main) and any open PR based on
develop (e.g. #90).
@TaprootFreak
TaprootFreak force-pushed the fix/mint-state-desync-prepare-then-commit branch from ec4ce59 to 8636dcf Compare May 23, 2026 20:59
@TaprootFreak
TaprootFreak changed the base branch from fix/scanner-ws-parse-coverage to develop May 23, 2026 21:03
@TaprootFreak
TaprootFreak force-pushed the fix/mint-state-desync-prepare-then-commit branch from 8636dcf to a414fdf Compare May 23, 2026 21:51
mint_handler advanced minting_meta.num_pubkeys, mutated the in-memory
minting account, and persisted recipient state BEFORE attempting the
on-chain inscription broadcast. When the broadcast failed (publisher
empty, Esplora 5xx, WS timeout, etc.) the server's bookkeeping had
already moved on, but the SMT/MMR never received the commitment — every
subsequent mint and send for the same minting-account-pubkey-N then
returned 422 with either "Unable to get merkle proofs for provided
public key" or "Unable to get mmr inclusion proof for the previous
root". Once tripped, the only known recovery was a full DEV-state wipe.

Refactor into prepare -> broadcast -> commit:

1. Snapshot: under minting_account guard, read N + derive pubkeys.
2. Proof: clone the minting account and run send_coins against the
   clone — no mutation of self.accounts.
3. Broadcast: create_and_broadcast_inscription. Err -> 503, no state
   advanced anywhere.
4. Commit (broadcast OK): single sqlx tx with an optimistic
   UPDATE minting_meta SET num_pubkeys = N+1 WHERE id = 1 AND
   num_pubkeys = N so concurrent mints can't both commit; on row
   count 0 -> 503 "Concurrent mint detected". After tx commit, swap
   the mutated snapshot into account_server, advance num_pubkeys
   in-memory, persist the MintProof. Return 200.

Startup invariant check (server_runtime): for i in 0..num_pubkeys
assert get_commitment_proof(derive_public_key(i)) is Ok. Refuse to
start on first miss with a CRITICAL log line that names the
recovery procedure (reset_state workflow). No flag override.

Tests: four new mint-broadcast-failure tests assert num_pubkeys
unchanged + in-memory state unchanged + retry succeeds + concurrent
mints serialize. The existing mint_broadcast_failure_returns_503
test gained the missing state-unchanged assertions it was missing.
A new server_runtime test asserts the startup check rejects a
desynced state.

commit_handler audit: broadcast-first-then-receive_coin pattern
already correct; documented as an invariant in code.

Closes #89.
@TaprootFreak
TaprootFreak force-pushed the fix/mint-state-desync-prepare-then-commit branch from a414fdf to f17d8f2 Compare May 23, 2026 22:12
@TaprootFreak
TaprootFreak merged commit aed1bb2 into develop May 23, 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.

Mint state-desync class: num_pubkeys advances past on-chain commitments after absorbed publisher errors

1 participant