Skip to content

feat(sdk): auto-retry on AddressInvalidNonceError during state transition broadcast #3407

@lklimek

Description

@lklimek

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:

  1. Adding address nonce tracking to NonceCache (or a parallel cache), so there is a cache entry to mark stale
  2. Adding Sdk::refresh_address_nonce(&self, address: &PlatformAddress) that marks the address nonce as stale
  3. Ensuring fetch_inputs_with_nonce() (in address_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

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions