Skip to content

fix(commit): sync SMT update in broadcast_commit_and_deliver - #152

Merged
TaprootFreak merged 1 commit into
stagingfrom
fix/send-commit-sync-smt-update
May 31, 2026
Merged

fix(commit): sync SMT update in broadcast_commit_and_deliver#152
TaprootFreak merged 1 commit into
stagingfrom
fix/send-commit-sync-smt-update

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

Summary

Brings the Send-Commit path in line with Mint-Commit Phase E: the SMT integration now completes synchronously inside broadcast_commit_and_deliver, before /api/commit returns 200. Eliminates the race window where a follow-up send reads `account.commitment_public_key` from server state but finds no matching SMT entry yet (because the scanner hasn't yet observed the on-chain inscription).

Closes the latent root cause behind the deterministic-on-#150 failure of `second_send_roundtrip_succeeds_without_prev_commitment_pubkey_field` — the test was always race-prone, Mutinynet latency only made it visible on one specific run. Code diff between green run (e0e256f) and red run (fc31964) was 0 server bytes; the test landed on the wrong side of the timing window.

What changed

Architectural symmetry

Path SMT update Synchronous?
`mint_handler` (before) inline Phase E after broadcast
`commit_handler` (before) only via scanner, async on-chain observation
`commit_handler` (after) shared `apply_commit_and_persist_phase_e` helper, called from `broadcast_commit_and_deliver`

DRY helper

Extracted Phase E into `router.rs::apply_commit_and_persist_phase_e(state, commitment, commit_txid, flow_label)`, returning a structured `PhaseEFailure` enum (`StateUpdate` | `DurablePersist`). Both call sites (mint and commit) map the variant onto their own flow-specific error string so existing tests + KNOWN_SERVER_ERRORS stay unchanged.

Scanner symmetry

`should_skip_scanner_state_update` already gates on `pending_inscriptions.status='complete'`. Since the new commit-Phase-E sets that marker in the same atomic Postgres transaction as the state advance, the scanner becomes a redundant observer for our own send-commits (identical to its existing role for mint-commits). External recovery inscriptions still flow through the scanner path unchanged.

Files

  • `node/src/router.rs` (+173/-141) — new helper + refactored mint_handler to call it
  • `node/src/runtime.rs` (+68/-16) — `broadcast_commit_and_deliver` now invokes Phase E after broadcast; also switched to `state.esplora_config` for test-mockability (production unchanged, same value as `NETWORK_CONFIG`)
  • `node/src/router_tests.rs` (+443) — two new commit-Phase-E tests (happy path with SMT observation + atomic-rollback). The in-process state-update collision case is covered once via mint and applies symmetrically to commit through the shared helper.

Verification (local)

  • `cargo build -p node` ✅
  • `cargo build -p node --tests` ✅ (zero warnings)
  • `cargo test --no-run -p node` ✅
  • `cargo clippy -p node --lib --bins -- -D warnings` ✅
  • `cargo fmt --check` ✅

(Heavy nextest / Coverage Gate is CI-only per repo convention.)

Test plan

  • CI green (heavy jobs trigger on Release-PR via `ci:full`, not here)
  • After merge → promote staging → develop: re-run the deploy-dev pipeline and verify `second_send_roundtrip_succeeds_without_prev_commitment_pubkey_field` is now deterministically green
  • After deploy-dev: PR Release: develop -> main #150 (Release: develop → main) should rerun green for the same reason

PR target

`staging` per the new `feature/* → staging → develop → main` flow introduced in #148.

Bring the send-commit path in line with the mint-commit Phase E so the
SMT integration completes synchronously before /api/commit returns
200, closing the race window where a follow-up send reads
`account.commitment_public_key` from server state but finds no
matching SMT entry yet (the async scanner has not observed the
on-chain inscription).

The race surfaced as 422 "Unable to get merkle proofs for provided
public key" in the regression test
`second_send_roundtrip_succeeds_without_prev_commitment_pubkey_field`
when run against dev-api.zkcoins.app: a wallet that chains /api/send
+ /api/commit + /api/send hit the second send before the scanner
finished its ~20 s reveal-observation lap on Mutinynet. There was no
server code regression — the send path had always relied exclusively
on the scanner to integrate the commit, while the mint path already
did it inline after the broadcast (Phase E, router.rs::mint_handler).
The asymmetry was latent and Mutinynet latency made it visible.

Changes:

* Extract the Phase E body (state.update + atomic
  persist_state_and_mark_complete_tx) into a shared helper
  `apply_commit_and_persist_phase_e` in router.rs. The helper takes
  a `flow_label` for logging and returns a structured
  `PhaseEFailure` so the two call sites can preserve their existing
  flow-specific public error strings ("mint broadcast..." vs
  "commit broadcast...").
* `mint_handler` now delegates Phase E to the helper. Behaviour is
  byte-identical for happy path and both Err arms; existing tests
  (`mint_handler_advances_state_synchronously_with_broadcast`,
  `mint_handler_atomic_tx_rollback_leaves_state_and_row_consistent`,
  `mint_handler_in_process_state_advance_collision_returns_503`)
  continue to pass.
* `broadcast_commit_and_deliver` now invokes the helper synchronously
  between the Bitcoin broadcast and the recipient `receive_coin`
  mutation. On failure: 503, no retry, no fallback — scanner-replay
  remains the single source of repair, exactly as in the mint flow.
* `broadcast_commit_and_deliver` also switches from the process-wide
  `NETWORK_CONFIG` lazy_static to `state.esplora_config` so the
  send-commit path becomes testable with a wiremock Esplora, matching
  the testability shape already in place for `mint_handler`.
  Production behaviour is unchanged because `start_rest_node` clones
  `NETWORK_CONFIG` into that slot.
* Update the `commit_handler` doc to describe the new Phase E
  symmetry and remove the stale "no analogue of the mint state-desync
  class here" sentence.
* Add two new tests in `router_tests.rs` mirroring the existing mint
  Phase E coverage:
  - `commit_handler_advances_state_synchronously_with_broadcast` —
    happy path: /api/send + /api/commit with mocked accepting Esplora
    and live Postgres, then verify SMT contains pk_0, MMR
    leaf_count == 1, root_indices has the new entry, and
    pending_inscriptions row sits at `complete` so
    `should_skip_scanner_state_update` fires.
  - `commit_handler_atomic_tx_rollback_leaves_state_and_row_consistent`
    — install a trigger that fails the in-tx UPDATE to `complete`,
    assert 503 with the expected error substring, and verify on-disk
    SMT/MMR/root_index stays untouched and the row stays at
    `reveal_broadcast` for scanner-replay to integrate from chain.

Lock topology and crash-recovery contract are preserved verbatim
(both documented in the helper's docstring). The scanner remains the
authoritative path for external recovery inscriptions but is now a
redundant observer for our own send commits too — exactly as it
already was for mint commits.
@TaprootFreak
TaprootFreak marked this pull request as ready for review May 31, 2026 12:10
@TaprootFreak TaprootFreak added the ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra) label May 31, 2026
@TaprootFreak
TaprootFreak merged commit dd5bc28 into staging May 31, 2026
12 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