feat(client): distinct ApplyTransactionAfterSubmitFailed error + one retry - #2059
Conversation
Between submit_proven_transaction succeeding and apply_transaction
completing, the transaction is live on chain but the local store hasn't
recorded the consumption. If apply fails (transient IDB issue, worker
connection reset), callers previously saw a generic ClientError and had
no way to distinguish it from pre-submit failures — tempting them to
retry, which the node would reject because the nullifiers are already
consumed.
Now: the apply call is retried once, and if it still fails the error is
surfaced as ApplyTransactionAfterSubmitFailed { tx_id, submission_height,
source }. The hint explicitly tells the caller the tx landed on chain,
don't retry, and sync will reconcile note states via ConsumedExternal.
There was a problem hiding this comment.
I think I would overall discard these changes. The suggestion does not really apply, and the user can implement their own recovery mechanisms by using the split transaction APIs (not only for the local store, but for submission or proving as well). A middle ground may be to return an error variant that wraps the store update so that the user can later try to apply it again, but not sure I would do that either.
Alternatively, if there is a quota to stores such as IndexedDB, could it be configured? Could the retry mechanism be applied at the store level? Lastly, would a solution such as this one solve the case where there is a page reload like the description suggests?
| // From this point on, the transaction is live on the network: the | ||
| // sender's account state has changed and the input note | ||
| // nullifiers will be recorded in the nullifier SMT. If | ||
| // apply_transaction fails (e.g. transient IDB write error), the | ||
| // local state disagrees with chain until the next sync | ||
| // reconciles it. | ||
| // | ||
| // Try apply once more before surfacing a distinct error that | ||
| // tells the caller "the tx landed on chain, don't retry it." | ||
| // A small subset of apply failures (IDB quota spike, connection | ||
| // reset on the store worker) clear immediately. |
There was a problem hiding this comment.
This is not fully correct: the sync may not reconcile state if the account is private, so we should avoid suggesting this.
Additionally, the transaction might have reached the network at this point but this tells us nothing about whether the new state of notes and accounts will actually get finalized, so I wouldn't directly suggest that either.
There was a problem hiding this comment.
Also nit (feel free to disregard): the comment is a bit verbose. I'd go with something like
// The transaction is already on-chain at this point. If
// `apply_transaction` fails, local state may diverge from the network's state.
//
// Retry once before returning an error that makes clear the transaction
// was submitted, since some failures may be transient.There was a problem hiding this comment.
Addressed in 8c30fab — same response as on the hint thread below. The comment and hint text now only claim "the node accepted it into the mempool" rather than "it's on-chain and will reconcile." For private accounts specifically, sync cannot restore state from the network at all, so the pending TransactionStoreUpdate attached to the error is now the authoritative recovery path (not sync).
There was a problem hiding this comment.
Done in 8c30fab — shortened the comment to 4 lines, in the spirit of your version but also updated to match the new flow (build the update once, retry the write, attach the pending update on failure).
| // | ||
| // Try apply once more before surfacing a distinct error that | ||
| // tells the caller "the tx landed on chain, don't retry it." | ||
| // A small subset of apply failures (IDB quota spike, connection |
There was a problem hiding this comment.
Is IDB IndexedDB here? If so, what kind of quotas are there? Mostly out of curiosity
There was a problem hiding this comment.
Yes, IndexedDB. Per-origin quotas in browsers are split into a soft and hard limit:
- Safari: ~1 GB per origin soft; prompts the user when approaching it. Also evicts aggressively under storage pressure (especially for sites not added to the home screen).
- Chromium: soft cap of 60% of total disk; hard cap at 80%. In practice tabs can fail writes well below that if the profile is large or under memory pressure.
- Firefox: 10% of available disk per group, capped at 10 GB.
The writes we've seen fail weren't about hitting the quota — they were transient failures where the next write in the same session succeeded (service worker killed mid-transaction, the OS paging out IDB under pressure, the wallet's stress suite doing a page reload mid-commit, etc.). Hence the single in-memory retry before we surface the error: cheap, resolves the transient class, and doesn't paper over genuinely full quotas.
Configuring the quota isn't really a lever we have on the web (it's browser policy, not our storage layer). Applying retry at the store level is reasonable for idempotent single-row writes, but the apply step is multi-table (accounts + notes + tags + future notes + tx record) and the right retry boundary is the whole TransactionStoreUpdate, which is what this PR retries.
| "Transaction {tx_id} was submitted to the network at block \ | ||
| {submission_height} but the local apply step (which writes the new \ | ||
| note states and account commitment to the store) failed. The on-chain \ | ||
| effect is permanent — do NOT resubmit. Run `sync` to reconcile local \ | ||
| state; input notes consumed by this transaction will be detected via \ | ||
| their nullifiers and transitioned to ConsumedExternal automatically." | ||
| ), |
There was a problem hiding this comment.
Same as above: this is not fully correct
There was a problem hiding this comment.
Fixed in 8c30fab — the hint message no longer promises sync reconciliation or "on-chain permanence." It now:
- Says the tx was accepted by the node (mempool admission, not finality).
- Points the caller at the attached
pending_updatefield and tells them to re-apply viaClient::apply_transaction_updaterather than trust sync. - Warns that resubmitting will fail because the mempool-accepted copy has already consumed the nullifiers.
This avoids both the finality overstatement and the private-account issue you flagged.
| apply_transaction failed when writing local state. The on-chain effect is durable; the \ | ||
| next successful sync will reconcile note states via ConsumedExternal. Do NOT retry the \ |
There was a problem hiding this comment.
Same here, the on-chain effect may indeed not be durable, and also the sync may reconcile notes but will make private accounts corrupt because their state cannot be retrieved from the network
There was a problem hiding this comment.
You're right on both points. Pushed 8c30fab which:
- drops the "on-chain effect is durable" language — replaced with "accepted by the node at block N" (mempool admission, not finality)
- drops the "sync will reconcile via ConsumedExternal" promise — this is wrong for private accounts, which sync can only detect as mismatched (→ locked), not restore from the network
Instead the variant now attaches the pending TransactionStoreUpdate, so the caller has a concrete recovery path independent of account privacy: persist it, re-apply later via apply_transaction_update. That's the middle ground you suggested in the top-level review.
…ending update Per PR #2059 review feedback: - Remove overclaiming language. 'On-chain effect is durable' and 'sync will reconcile via ConsumedExternal' are not true in general: submit success means mempool admission (not finality), and sync cannot reconcile private accounts from the network. - Attach the pending TransactionStoreUpdate to the error so callers can retry the apply step themselves via apply_transaction_update, which is the recovery path the reviewer identified as the right middle ground. - Build the update once and retry apply_transaction_update (not apply_transaction) to avoid rebuilding the update on retry.
|
Thanks for the thorough review. All three of your technical concerns were correct, and I pushed 8c30fab which restructures the PR around your middle-ground suggestion rather than the original "trust sync" framing. What changed
On whether to keep this at allI hear you on "users can do this themselves with the split APIs." That's true — the execute / prove / submit / apply surface is already public. But the wallet's stress suite showed the default-path users (the ones calling Happy to go further (or less far) — e.g. drop the built-in retry and just keep the distinct variant with the attached update; or go the other direction and gate it behind an explicit opt-in. Let me know which direction you'd prefer. IDB quota sub-threadAnswered inline — short version: the failures we saw weren't quota-related but transient (service worker churn, page reload mid-commit), which is why a single in-memory retry helps. Genuine quota exhaustion would fail both attempts and still surface |
…-submit-failed # Conflicts: # CHANGELOG.md
Holding the unboxed update across the apply_transaction_update retry inflated submit_new_transaction's future above clippy's 16 KiB threshold (failing on miden-bench). Box it locally; the ownership transfer into the error variant on failure no longer needs a separate Box::new.
| if let Err(second_err) = self.apply_transaction_update((*tx_update).clone()).await { | ||
| info!( | ||
| "apply_transaction_update failed twice for submitted tx {tx_id}; \ | ||
| returning ApplyTransactionAfterSubmitFailed with the pending update \ | ||
| attached. First error: {first_err}" | ||
| ); | ||
| return Err(ClientError::ApplyTransactionAfterSubmitFailed { | ||
| tx_id, | ||
| submission_height, | ||
| pending_update: tx_update, | ||
| source: Box::new(second_err), | ||
| }); | ||
| } |
There was a problem hiding this comment.
I think I still disagree with trying to apply the update again. You mention:
(service worker killed mid-transaction, the OS paging out IDB under pressure, the wallet's stress suite doing a page reload mid-commit, etc.
Retrying once does not help with any of these, does it? I also don't think these are precisely the right problems to solve: Why do we need to solve for a problem related to the wallet's stress suite which decides to do a reload? There isn't much you can do if the process exits at any specific point (power goes out, user closes tab, OS evicts process). It feels a bit like cargo culting. Lastly, does retrying immediately after having failed a write make sense? Or should there be a small delay, etc? Because we'd need to make these general assumptions I'd rather just return the store update and let the user handle it. A wallet or app implementation should already have the tools to do something that makes sense for their environment, even without returning the new error variant (although I think this one is an improvement).
I think a better approach could be to do 2 separate DB writes: one for a "pending insert" and then a "commit" one, but this probably requires a larger refactor
There was a problem hiding this comment.
- Removed the in-process retry, the main objection ("retrying once does not help with any of these [failure modes]") — gone. apply_transaction is now called once; no retry loop.
- Attached the pending TransactionStoreUpdate to the error. This is the lighter-weight version of his "just return the store update and let the user handle it" idea — the variant now carries pending_update: Box and the docstring + ErrorHint tell callers to re-apply via apply_transaction_update, not resubmit.
…SubmitFailed Per igamigo's review on PR #2059: - Drop the second apply_transaction_update attempt. As igamigo noted, the failure modes the retry was supposed to cover (SW killed mid-commit, page reload, OS process eviction, power loss) all terminate the process — an in-memory retry can't help. Callers that legitimately need backoff or policy-driven recovery already have everything they need via the attached TransactionStoreUpdate. - Remove the now-redundant tx_id and submission_height fields from ApplyTransactionAfterSubmitFailed. TransactionStoreUpdate already carries both (via executed_transaction().id() and submission_height()), so the error message and ErrorHint pull them out of pending_update instead. - Dropped the now-unused TransactionId/BlockNumber imports in errors.rs. - Updated the CHANGELOG entry to no longer claim a retry happens.
igamigo
left a comment
There was a problem hiding this comment.
I think this is a better approach now. Still I would strive to make the hints and comment a bit more correct.
| #[error( | ||
| "transaction {} was accepted by the node at block {} but the local store update failed. \ | ||
| The pending store update is attached and can be re-applied later via \ | ||
| `apply_transaction_update`; resubmitting the same transaction will be rejected because \ | ||
| the nullifiers are already consumed by the mempool-accepted copy.", | ||
| pending_update.executed_transaction().id(), | ||
| pending_update.submission_height() | ||
| )] |
There was a problem hiding this comment.
I think we should avoid giving out too much internal details, but also they should be more correct: really, the nullifiers are not the only problem here. In fact, there may be no nullifiers involved in the transaction at all.
Rather, we should say that if the original transaction was accepted by the mempool and has not expired (and/or was finalized in a block), a duplicate submission would fail because the state of the account (and/or the network's state) was already mutated.
| update is attached to this error as `pending_update`; you can re-apply \ | ||
| it later via `Client::apply_transaction_update`. Do NOT resubmit the \ | ||
| same transaction: its nullifiers are already consumed by the \ | ||
| mempool-accepted copy, so the node will reject the retry." |
|
|
||
| ### Features | ||
|
|
||
| * [FEATURE] Distinct `ApplyTransactionAfterSubmitFailed` error variant for the case where `submit_proven_transaction` succeeds but the local store update fails. The error carries the pending `TransactionStoreUpdate` so callers can persist it and re-apply later via `apply_transaction_update` without resubmitting (which the node would reject, since the tx's nullifiers are already consumed by the mempool-accepted copy) ([#2059](https://github.com/0xMiden/miden-client/pull/2059)). |
… framing Per @igamigo's May 15 review: the hint and error text claimed the node would reject resubmission because 'nullifiers are already consumed'. That's not fully correct — not every transaction even involves nullifiers, and the more general invariant is that the account (and network) state has already been mutated by the mempool-accepted copy, which is what makes a duplicate submission fail. Updated three places to use the more general framing: - The Display impl on `ApplyTransactionAfterSubmitFailed` (#[error(...)] attribute on the variant) - The `ErrorHint` message for the same variant - The CHANGELOG entry Also tightened the rejection condition: a resubmit is only rejected if the original is still in the mempool or has been finalized in a block — a tx that expired without making it into a block can be resubmitted, and the new text acknowledges that.
|
@igamigo addressed your last-round feedback in
Also tightened the rejection condition: a resubmit is only rejected if the original is still in the mempool or has been finalized — a tx that expired without making it into a block can be resubmitted, and the new text acknowledges that. |
Closes 0xMiden/web-sdk#122
Summary
ClientError::ApplyTransactionAfterSubmitFailed { tx_id, submission_height, source }error variant for the case wheresubmit_proven_transactionsucceeds butapply_transactionfailsBackground
The SDK's transaction pipeline is
execute → prove → submit → apply. Between submit succeeding (tx admitted to mempool) and apply completing (local IndexedDB updated), the transaction is live on chain but the client's local state doesn't reflect it.If apply fails in this window (transient IDB error, service worker killed, browser tab closed), callers previously saw a generic
ClientErrorwith no way to distinguish it from pre-submit failures. The natural response is to retry the entire transaction — but the node rejects it because the nullifiers are already consumed. The retry loop fails indefinitely.This was surfaced by the wallet's stress suite: several transactions hit the submit-succeeded-apply-failed gap during page-reload perturbations, causing the TransactionProcessor to retry indefinitely while showing incorrect balances.
Changes
crates/rust-client/src/errors.rs:ApplyTransactionAfterSubmitFailedvariant withtx_id,submission_height, andsourceErrorHintimplementation telling the caller what happened and what to doBlockNumberimport added for thesubmission_heightfieldcrates/rust-client/src/transaction/mod.rs:submit_new_transaction_with_provernow wraps the apply call in a single retryApplyTransactionAfterSubmitFailedwith the submission height from the successful submitHow the wallet uses it
The wallet's TransactionProcessor catches this variant (via
errorCodefrom PR #2060) and marks the transaction as Completed instead of Failed. The user sees "Transaction sent" (correct) rather than "Transaction failed" (incorrect). The next sync reconciles note states viaConsumedExternalautomatically.Test plan
ApplyTransactionAfterSubmitFailedtx_idandsubmission_height