Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

* [FEATURE] Distinct `ApplyTransactionAfterSubmitFailed` error variant with one apply retry, so callers know a submitted tx landed on-chain and should not be re-submitted ([#2059](https://github.com/0xMiden/miden-client/pull/2059)).
* [FEATURE][web] Added `"custom"` operation to `preview()` so users can dry-run any pre-built `TransactionRequest`, not just send/mint/consume/swap ([#2052](https://github.com/0xMiden/miden-client/pull/2052)).

## 0.14.3 (2026-04-16)
Expand Down
28 changes: 28 additions & 0 deletions crates/rust-client/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use alloc::boxed::Box;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;

use miden_protocol::Word;
use miden_protocol::account::AccountId;
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::merkle::MerkleError;
pub use miden_protocol::errors::{AccountError, AccountIdError, AssetError, NetworkIdError};
use miden_protocol::errors::{
Expand All @@ -13,6 +15,7 @@ use miden_protocol::errors::{
TransactionScriptError,
};
use miden_protocol::note::{NoteId, NoteTag};
use miden_protocol::transaction::TransactionId;
use miden_standards::account::interface::AccountInterfaceError;
// RE-EXPORTS
// ================================================================================================
Expand Down Expand Up @@ -165,6 +168,18 @@ pub enum ClientError {
#[source]
source: RpcError,
},
#[error(
"transaction {tx_id} was submitted to the network at block {submission_height} but \
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 \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

same transaction."
)]
Comment on lines +169 to +177

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ApplyTransactionAfterSubmitFailed {
tx_id: TransactionId,
submission_height: BlockNumber,
Comment thread
WiktorStarczewski marked this conversation as resolved.
Outdated
#[source]
source: Box<ClientError>,
},
}

// CONVERSIONS
Expand Down Expand Up @@ -228,6 +243,19 @@ impl From<&ClientError> for Option<ErrorHint> {
or provide the seed when importing.".to_string(),
docs_url: Some(TROUBLESHOOTING_DOC),
}),
ClientError::ApplyTransactionAfterSubmitFailed { tx_id, submission_height, .. } => {
Some(ErrorHint {
message: format!(
"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."
),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: this is not fully correct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8c30fab — the hint message no longer promises sync reconciliation or "on-chain permanence." It now:

  1. Says the tx was accepted by the node (mempool admission, not finality).
  2. Points the caller at the attached pending_update field and tells them to re-apply via Client::apply_transaction_update rather than trust sync.
  3. 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.

docs_url: Some(TROUBLESHOOTING_DOC),
})
},
_ => None,
}
}
Expand Down
29 changes: 28 additions & 1 deletion crates/rust-client/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,34 @@ where
let submission_height =
self.submit_proven_transaction(proven_transaction, &tx_result).await?;

self.apply_transaction(&tx_result, submission_height).await?;
// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is IDB IndexedDB here? If so, what kind of quotas are there? Mostly out of curiosity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// reset on the store worker) clear immediately.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

if let Err(first_err) = self.apply_transaction(&tx_result, submission_height).await {
info!("apply_transaction failed once; retrying to cover transient errors");
if let Err(second_err) =
self.apply_transaction(&tx_result, submission_height).await
{
info!(
"apply_transaction failed twice for submitted tx {tx_id}; surfacing \
ApplyTransactionAfterSubmitFailed so the caller can rely on the next \
sync to reconcile local state. First error: {first_err}"
);
return Err(ClientError::ApplyTransactionAfterSubmitFailed {
tx_id,
submission_height,
source: Box::new(second_err),
});
}
}

Ok(tx_id)
}
Expand Down
Loading