Skip to content

consume_notes canonicalization stalls minutes under concurrent same-account proposals (verify_state commitment mismatch burns the 600s grace) + 409 ConflictPendingDelta churn #303

Description

@WiktorStarczewski

Summary

When a guardian-co-signed account performs concurrent / rapidly-overlapping transactions (e.g. a consume_notes while the same account is also being mutated by another in-flight tx), canonicalization of the pending delta stalls for minutes — up to the full submission_grace_period_seconds (600s in prod) — and the account is locked the whole time, returning 409 ConflictPendingDelta to every new proposal. Serializing operations per account makes the problem disappear entirely.

This was found while integrating the guardian into the Miden Wallet (hosted guardian.openzeppelin.com, Miden 0.15 testnet, @openzeppelin/guardian-client@0.15.0-rc.0). Code references below are against guardian@89591a3 (current main).

Measurements (these rule out the obvious suspects)

  • Testnet block production: ~2.9 s/block (fast).
  • Guardian HTTP latency: /pubkey ~0.2 s; every /state, /delta/proposal, /delta request ~125–170 ms (fast).
  • Yet consume finalization (time from POST /delta/proposal until the co-signed delta is canonical / visible via /state) under a concurrent workload: p50 ~5.5 s but p90 = 216 s, max = 586 s, and 13% of POST /delta/proposal returned 409.

So it's neither network latency nor block time — it's canonicalization waiting on a commitment that never matches.

Root cause (as I read the source)

  1. POST /delta makes the delta a Candidate and sets has_pending_candidate = true. Any subsequent POST /delta/proposal for that account is rejected with 409 ConflictPendingDeltacrates/server/src/services/push_delta_proposal.rs:79 (has_pending_candidate) → :91 (return Err(GuardianError::ConflictPendingDelta)).
  2. The canonicalization worker (every check_interval_seconds = 10) calls verify_stateget_account_commitment() RPC and finalizes only when the on-chain commitment equals the guardian's locally-computed expected commitmentcrates/server/src/network/miden/mod.rs:108, crates/server/src/jobs/canonicalization/processor.rs:151-171.
  3. On a mismatch within the grace period, it just defers without consuming retry budget — processor.rs:174-189 (candidate_age_seconds < self.submission_grace_period_seconds"Delta verification failed during submission grace period; will retry without consuming retry budget"). Prod config: CanonicalizationConfig::new(10, 48).with_submission_grace_period_seconds(600)crates/server/src/main.rs:40.

The trap: the expected commitment is computed for base_state + this_delta. If the account legitimately advances on-chain via another tx before this candidate is verified, the actual commitment is base_state + other_delta(s) + this_delta (or a different ordering) and never equals the guardian's expectation — so the candidate burns the entire 600 s grace and is then discarded, blocking the account (409) the whole time.

Controlled experiment (concurrency is the trigger)

Identical network + hosted guardian; only the wallet's operation pattern changed:

metric concurrent workload serialized (one op per account at a time)
409 ConflictPendingDelta 16 / 127 (13%) 0 / 32 (0%)
/delta/proposal finalize gap p90 / max 216 s / 586 s 43.5 s / 57.4 s
/state polls (client waiting) 1,879 298
result crawled, never converged passed, balances conserved, settled in 4 s

Suggested direction

When verify_state fails because the account has advanced past the expected commitment (vs. a genuine verification failure), canonicalization could re-derive the expected commitment from the current on-chain account state and re-check, rather than waiting out the grace period against a now-stale expectation. At minimum, distinguishing "account moved on / superseded" from "invalid" would let the server release has_pending_candidate quickly instead of holding the account locked for up to 600 s. Happy to provide the full request/timeline traces.

Environment

  • guardian: hosted guardian.openzeppelin.com; source main@89591a3
  • client: @openzeppelin/guardian-client@0.15.0-rc.0, @openzeppelin/miden-multisig-client
  • chain: Miden 0.15 testnet (rpc.testnet.miden.io, blocks ~2.9 s)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions