-
Notifications
You must be signed in to change notification settings - Fork 50
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