Skip to content

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

Closed

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

Activity

  1. lklimek commented on Mar 26, 2026

    @lklimek
    ContributorAuthor

    Investigation: SDK nonce retry mechanism and address nonces

    Summary

    The SDK has no nonce retry mechanism for any transition type. What exists is:

    1. Network-level request retries in broadcast() via retry() — retries the DAPI gRPC request on transport failures, not on consensus/nonce errors.
    2. Identity nonce cache refresh on failure — on broadcast failure, if the transition has an owner_id(), the identity nonce cache is marked stale so the next operation fetches a fresh nonce from Platform.

    Neither mechanism detects AddressInvalidNonceError (or InvalidIdentityContractNonceError) 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-91

    match &result {
        Ok(_) => trace!("broadcast: completed successfully"),
        Err(e) => {
            warn!(error = ?e, "broadcast: failed after retries");
            if let Some(owner_id) = self.owner_id() {
                sdk.refresh_identity_nonce(&owner_id).await;
            }
        }
    }

    This 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() or get_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

    1. owner_id() returns None for all shielded/address-based transitions (packages/rs-dpp/src/state_transition/mod.rs, lines 783-792):

      StateTransition::IdentityCreateFromAddresses(_) => None,
      StateTransition::IdentityTopUpFromAddresses(_) => None,
      StateTransition::AddressFundsTransfer(_) => None,
      StateTransition::AddressFundingFromAssetLock(_) => None,
      StateTransition::AddressCreditWithdrawal(_) => None,
      StateTransition::Shield(_) => None,
      StateTransition::ShieldedTransfer(_) => None,
      StateTransition::Unshield(_) => None,

      So even the stale-marking refresh never fires for these transition types.

    2. Address nonces are not cached at all. The NonceCache (packages/rs-sdk/src/internal_cache/mod.rs) only tracks identity_nonces and contract_nonces (keyed by Identifier and IdentityContractPair). Address nonces are fetched fresh every time via fetch_inputs_with_nonce() in packages/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.

    3. The nonce error arrives late. For shielded transitions, AddressInvalidNonceError comes back from the wait_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 a StateTransitionBroadcastError with a ConsensusError::StateError(StateError::AddressInvalidNonceError(...)) cause.

    The gap

    There is no code path in the SDK that:

    1. Catches AddressInvalidNonceError (error code 40603) from the wait_for_response() result
    2. Extracts the expected_nonce from the error
    3. Updates the nonce in the state transition's inputs map
    4. Re-signs the transition
    5. Re-broadcasts

    Affected transition types

    All address-based transitions are affected, not just shielded ones:

    • Shield (has inputs())
    • IdentityCreateFromAddresses (has inputs())
    • IdentityTopUpFromAddresses (has inputs())
    • AddressFundsTransfer (has inputs())
    • AddressFundingFromAssetLock (has inputs())
    • AddressCreditWithdrawal (has inputs())

    Suggested implementation

    The retry should live in a new method on StateTransition or as a wrapper around the existing broadcast_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:

    1. Calls broadcast() + wait_for_response() as normal
    2. If the error is StateTransitionBroadcastError with cause ConsensusError::StateError(StateError::AddressInvalidNonceError(e)):
      • Extract e.expected_nonce() for each address
      • Call a mutable setter on the state transition to update inputs nonces
      • Re-sign the transition with the address signer
      • Retry broadcast + wait once
    3. All other errors propagate normally

    Alternatively, 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 1c7490f in ~/.cargo/git/checkouts/platform-7a21f318038a582f/.

  2. self-assigned this
    on Mar 26, 2026
  3. lklimek commented on Mar 27, 2026

    @lklimek
    ContributorAuthor

    To be discussed if we should have an address nonce cache in sdk or not.

  4. lklimek commented on Apr 13, 2026

    @lklimek
    ContributorAuthor

    Address sync mechanism should provide the sdk with current nonce. Caching should NOT be required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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