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 for the case where `submit_proven_transaction` succeeds but the local store update fails after a single retry. 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)).
* [FEATURE][web] Serialize all async `WebClient` JS methods — both the explicit wrappers and every async call that falls through `createClientProxy` to the underlying WASM client (e.g. `getAccount`, `importAccountById`, `getAccountStorage`) — via an internal `_serializeWasmCall` chain. Prevents `"recursive use of an object detected"` panics when an unwrapped read/write races the auto-sync timer or any explicitly-wrapped method. Expose `waitForIdle()` on `MidenClient` so callers can drain in-flight work before mutating non-WASM state ([#2057](https://github.com/0xMiden/miden-client/pull/2057)).
* [FEATURE][web] Split `@miden-sdk/miden-sdk` into eager and lazy entry points. The default entry (`import from "@miden-sdk/miden-sdk"`) now awaits WASM at module top level via a small shim (`js/eager.js`) — consumers don't need `await MidenClient.ready()` / `isReady` before constructing wasm-bindgen types. The lazy entry (`import from "@miden-sdk/miden-sdk/lazy"`) preserves the previous behavior and is required for Capacitor WKWebView hosts (the custom-scheme handler hangs on TLA) and Next.js SSR. Verified empirically against the Miden Wallet's iOS E2E suite on devnet. `@miden-sdk/react` imports from `/lazy` internally and manages readiness via `isReady`.
* [FEATURE][web] Expose `lastAuthError()` on `MidenClient` for typed sign-callback failure recovery — preserves the raw thrown value from the JS signCallback so consumers can distinguish locked/rejected/IO-error failure modes ([#2058](https://github.com/0xMiden/miden-client/pull/2058)).
Expand Down
29 changes: 29 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,19 @@ pub enum ClientError {
#[source]
source: RpcError,
},
#[error(
"transaction {tx_id} was accepted by the node at block {submission_height} but the local \
store update failed after a retry. 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."
)]
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
pending_update: Box<crate::transaction::TransactionStoreUpdate>,
#[source]
source: Box<ClientError>,
},
}

// CONVERSIONS
Expand Down Expand Up @@ -228,6 +244,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 accepted by the node at block \
{submission_height} but the local store update failed (twice). The \
pending 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."
),
docs_url: Some(TROUBLESHOOTING_DOC),
})
},
_ => None,
}
}
Expand Down
27 changes: 26 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,32 @@ where
let submission_height =
self.submit_proven_transaction(proven_transaction, &tx_result).await?;

self.apply_transaction(&tx_result, submission_height).await?;
// The transaction has been accepted by the node; the local store update
// is a separate step that can fail independently. Build the update once
// and retry the write once before surfacing a distinct error that
// carries the pending update for caller-driven recovery.
//
// The update is boxed so it does not inflate the enclosing future
// across await points (triggers clippy::large_futures).
let tx_update =
Box::new(self.get_transaction_store_update(&tx_result, submission_height).await?);

if let Err(first_err) = self.apply_transaction_update((*tx_update).clone()).await {
info!("apply_transaction_update failed once; retrying to cover transient errors");
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),
});
}

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

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.

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

}

Ok(tx_id)
}
Expand Down
Loading