diff --git a/CHANGELOG.md b/CHANGELOG.md index e09ac8f104..ab272fadea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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 if the original is still in the mempool or has been finalized in a block, since the account and network state have already been mutated by the 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)). diff --git a/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index 9529d150cb..3fe06fc04b 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; @@ -165,6 +166,20 @@ pub enum ClientError { #[source] source: RpcError, }, + #[error( + "transaction {} was accepted into the node's mempool 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 if the \ + original is still in the mempool or has been finalized in a block, because the \ + account (and network) state has already been mutated by the accepted copy.", + pending_update.executed_transaction().id(), + pending_update.submission_height() + )] + ApplyTransactionAfterSubmitFailed { + pending_update: Box, + #[source] + source: Box, + }, } // CONVERSIONS @@ -228,6 +243,22 @@ impl From<&ClientError> for Option { or provide the seed when importing.".to_string(), docs_url: Some(TROUBLESHOOTING_DOC), }), + ClientError::ApplyTransactionAfterSubmitFailed { pending_update, .. } => { + let tx_id = pending_update.executed_transaction().id(); + let submission_height = pending_update.submission_height(); + Some(ErrorHint { + message: format!( + "Transaction {tx_id} was accepted into the node's mempool at block \ + {submission_height} but the local store update failed. 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: if the original is still in the mempool or has been \ + finalized in a block, the account (and network) state has already been \ + mutated by the accepted copy, so the node will reject the retry." + ), + docs_url: Some(TROUBLESHOOTING_DOC), + }) + }, _ => None, } } diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index 060225c263..874ac818ef 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -226,7 +226,27 @@ 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. On failure, return a + // distinct error carrying the pending update so the caller can decide + // how to recover (re-apply later via `apply_transaction_update`, + // persist for the next session, etc.). + // + // 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(apply_err) = self.apply_transaction_update((*tx_update).clone()).await { + info!( + "apply_transaction_update failed for submitted tx {tx_id}; returning \ + ApplyTransactionAfterSubmitFailed with the pending update attached: {apply_err}" + ); + return Err(ClientError::ApplyTransactionAfterSubmitFailed { + pending_update: tx_update, + source: Box::new(apply_err), + }); + } Ok(tx_id) }