Repository navigation
feat(sdk): auto-retry on AddressInvalidNonceError during state transition broadcast #3407
Description
Problem
When broadcasting an address-based state transition (e.g., Shield Credits, AddressFundsTransfer), the SDK uses address nonces from the caller. If a nonce is stale (e.g., a previous transition was confirmed but the caller's state wasn't updated), Platform rejects with AddressInvalidNonceError:
ConsensusError(StateError(AddressInvalidNonceError(AddressInvalidNonceError {
address: P2pkh([...]),
provided_nonce: 4,
expected_nonce: 5
})))
The error contains the correct expected nonce, but the SDK surfaces it as a hard failure. For shielded transitions, the Halo 2 proof build takes ~30 seconds — a nonce mismatch that requires rebuilding the entire proof is a significant UX penalty.
Existing mechanism for identity nonces (parity reference)
The SDK already handles stale identity nonces after broadcast failure. In broadcast.rs (lines 83-91):
Err(e) => {
warn!(error = ?e, "broadcast: failed after retries");
if let Some(owner_id) = self.owner_id() {
sdk.refresh_identity_nonce(&owner_id).await;
}
}refresh_identity_nonce() marks the cached nonce as stale (sets last_fetch_timestamp = STALE_TIMESTAMP in NonceCache::refresh()), so the next operation re-fetches from Platform. This doesn't retry the current operation, but it prevents the next one from hitting the same stale nonce.
The gap: address-based transitions skip this path entirely
StateTransition::owner_id() returns None for all address-based transition types:
StateTransition::IdentityCreateFromAddresses(_) => None,
StateTransition::IdentityTopUpFromAddresses(_) => None,
StateTransition::AddressFundsTransfer(_) => None,
StateTransition::AddressFundingFromAssetLock(_) => None,
StateTransition::AddressCreditWithdrawal(_) => None,
StateTransition::Shield(_) => None,
StateTransition::ShieldedTransfer(_) => None,
StateTransition::Unshield(_) => None,
StateTransition::ShieldFromAssetLock(_) => None,
StateTransition::ShieldedWithdrawal(_) => None,So the if let Some(owner_id) = self.owner_id() guard never fires, and no nonce cleanup happens at all for these transitions. Address nonces are also not tracked by NonceCache — they are fetched fresh each time via fetch_inputs_with_nonce() — so there is no cache to mark stale even if the code ran.
Affected transition types
All address-based transitions that use inputs() with address nonces:
| Transition | owner_id() |
inputs() |
|---|---|---|
Shield |
None |
has inputs |
Unshield |
None |
has inputs |
ShieldedTransfer |
None |
has inputs |
ShieldFromAssetLock |
None |
has inputs |
ShieldedWithdrawal |
None |
has inputs |
AddressFundsTransfer |
None |
has inputs |
IdentityCreateFromAddresses |
None |
has inputs |
IdentityTopUpFromAddresses |
None |
has inputs |
AddressFundingFromAssetLock |
None |
has inputs |
AddressCreditWithdrawal |
None |
has inputs |
Proposed fix
Add an address nonce stale-marking path in broadcast.rs, parallel to the existing identity nonce path:
Err(e) => {
warn!(error = ?e, "broadcast: failed after retries");
if let Some(owner_id) = self.owner_id() {
// Existing: mark identity nonce as stale
sdk.refresh_identity_nonce(&owner_id).await;
}
if let Some(inputs) = self.inputs() {
// NEW: mark address nonces as stale for all input addresses
for address in inputs.keys() {
sdk.refresh_address_nonce(address).await;
}
}
}This requires:
- Adding address nonce tracking to
NonceCache(or a parallel cache), so there is a cache entry to mark stale - Adding
Sdk::refresh_address_nonce(&self, address: &PlatformAddress)that marks the address nonce as stale - Ensuring
fetch_inputs_with_nonce()(inaddress_inputs.rs) checks the cache before fetching from Platform
Alternatively, if adding full address nonce caching is out of scope, the error itself could be detected and a re-fetch forced on the next operation. The key requirement is parity: address-based transitions should get the same "mark stale on failure" treatment that identity-based transitions already have.
Related issues and PRs
- SDK nonce cache incorrectly retains stale values after failed state transitions #2737 (closed) — SDK nonce cache incorrectly retaining stale values after failed state transitions. The fix introduced the
refresh()stale-marking mechanism for identity nonces. - Bug: identity_create_from_addresses sends current nonce instead of next nonce (missing +1) #3083 (closed) — WASM SDK sending current nonce instead of next nonce for
identity_create_from_addresses. Fixed in PR fix(wasm-sdk): increment address nonce in identity_create_from_addresses #3084 / fix(wasm-sdk): increment address nonce and add regression tests #3087. - feat(sdk): high-level document APIs lack idempotent retry — timeout causes duplicate state transitions #3090 (open) — High-level document APIs lack idempotent retry on timeout. Broader retry/idempotency issue.
- AddressFundsTransferTransition::calculate_min_required_fee is too low #3040 (open) —
AddressFundsTransferTransition::calculate_min_required_feeis too low. Related address-based transition issue. - chore: review unique_identifiers implementations across all state transitions #3187 (open) — Review
unique_identifiersimplementations across all state transitions (includes address-based types). - PR refactor(sdk): rewrite NonceCache with LRU eviction, drift detection, and structured errors #3111 (closed) — NonceCache rewrite with LRU eviction and structured errors. Established the current cache architecture.
- PR feat(sdk): sync platform address nonces #2979 (closed) — Sync platform address nonces. Added the address nonce fetching infrastructure.
Context
Encountered in Dash Evo Tool during shielded credit operations. Dash Evo Tool currently works around this by showing a user-friendly error asking the user to retry — the retry succeeds because the wallet re-fetches address nonces before the next attempt.
Co-authored by Claudius the Magnificent AI Agent
Activity
lklimek commentedon Mar 26, 2026
Investigation: SDK nonce retry mechanism and address nonces
Summary
The SDK has no nonce retry mechanism for any transition type. What exists is:
broadcast()viaretry()— retries the DAPI gRPC request on transport failures, not on consensus/nonce errors.owner_id(), the identity nonce cache is marked stale so the next operation fetches a fresh nonce from Platform.Neither mechanism detects
AddressInvalidNonceError(orInvalidIdentityContractNonceError) and retries the transition with a corrected nonce.Where the existing "refresh" logic lives
File:
packages/rs-sdk/src/platform/transition/broadcast.rs, lines 83-91This refresh only runs after the broadcast has already failed — it doesn't retry. It marks the identity nonce cache as stale so the next call to
get_identity_nonce()orget_identity_contract_nonce()will re-fetch from Platform. This helps subsequent operations succeed, but doesn't save the current one.Why shielded transitions are doubly affected
owner_id()returnsNonefor all shielded/address-based transitions (packages/rs-dpp/src/state_transition/mod.rs, lines 783-792):So even the stale-marking refresh never fires for these transition types.
Address nonces are not cached at all. The
NonceCache(packages/rs-sdk/src/internal_cache/mod.rs) only tracksidentity_noncesandcontract_nonces(keyed byIdentifierandIdentityContractPair). Address nonces are fetched fresh every time viafetch_inputs_with_nonce()inpackages/rs-sdk/src/platform/transition/address_inputs.rs. This means the stale-time and drift-threshold protections that help identity nonces don't apply to address nonces.The nonce error arrives late. For shielded transitions,
AddressInvalidNonceErrorcomes back from thewait_for_response()step (the Platform processes the transition and returns the consensus error). By that point, the broadcast already succeeded at the transport level. The error surfaces as aStateTransitionBroadcastErrorwith aConsensusError::StateError(StateError::AddressInvalidNonceError(...))cause.The gap
There is no code path in the SDK that:
AddressInvalidNonceError(error code 40603) from thewait_for_response()resultexpected_noncefrom the errorinputsmapAffected transition types
All address-based transitions are affected, not just shielded ones:
Shield(hasinputs())IdentityCreateFromAddresses(hasinputs())IdentityTopUpFromAddresses(hasinputs())AddressFundsTransfer(hasinputs())AddressFundingFromAssetLock(hasinputs())AddressCreditWithdrawal(hasinputs())Suggested implementation
The retry should live in a new method on
StateTransitionor as a wrapper around the existingbroadcast_and_wait()flow. The key challenge is that after updating the nonce, the transition needs to be re-signed (the signature covers the nonce). For address-based transitions, this means calling the address signer again.The cleanest approach would be a
broadcast_and_wait_with_address_nonce_retry()method that:broadcast()+wait_for_response()as normalStateTransitionBroadcastErrorwith causeConsensusError::StateError(StateError::AddressInvalidNonceError(e)):e.expected_nonce()for each addressinputsnoncesAlternatively, each trait (
ShieldFunds,TransferAddressFunds,WithdrawAddressFunds, etc.) could handle the retry at their own level, since they already have access to the signer.Investigation performed by reading the SDK source at git revision
1c7490fin~/.cargo/git/checkouts/platform-7a21f318038a582f/.fix(ui): wallet screen identity, state management, and input validati…
lklimek commentedon Mar 27, 2026
To be discussed if we should have an address nonce cache in sdk or not.
lklimek commentedon Apr 13, 2026
Address sync mechanism should provide the sdk with current nonce. Caching should NOT be required.