diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cec0966b9c2..5bad1aa9183 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -145,6 +145,20 @@ pub enum PlatformWalletFFIResultCode { /// observing the transaction reconciles the outcome. The host must NOT /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. ErrorTransactionBroadcastUnconfirmed = 20, + /// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected next + /// value (a lagging DAPI replica stale read; consensus code 40603). Same + /// definitively-failed / notes-released / safe-to-retry contract as + /// [`Self::ErrorShieldedBroadcastFailed`] — the transition did NOT execute + /// and any note reservations were released (a shield reserves none) — but + /// as its OWN code so hosts can recognize this specific, self-healing + /// failure and retry: the retry re-fetches the address nonce, resolving + /// the mismatch without host intervention. The submitted and Platform- + /// expected nonce values travel in the result `message` (the typed + /// `Display`); they are not exposed as structured out-fields (that would + /// require an ABI-breaking change to `PlatformWalletFFIResult`). + ErrorAddressNonceMismatch = 21, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -285,6 +299,15 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // A definitively-failed address-nonce race (reaches the blanket impl + // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing + // provided/expected nonce as structured out-fields is INTENTIONALLY + // out of scope: `PlatformWalletFFIResult` is by-value / ABI-frozen, so + // the values travel in the message string and an FFI retry re-fetches + // the nonce. + PlatformWalletError::AddressNonceMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -664,6 +687,44 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` + /// FFI code through the blanket `From` impl (the path identity + /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening + /// to `ErrorUnknown`. The typed Display rendering — carrying the submitted + /// and expected nonce values — survives across the boundary as the message. + #[test] + fn address_nonce_mismatch_maps_to_dedicated_code() { + let err = PlatformWalletError::AddressNonceMismatch { + address: dpp::address_funds::PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "AddressNonceMismatch should map to ErrorAddressNonceMismatch (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. + assert!( + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" + ); + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 94fbbf1269a..dbdead34c63 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -516,6 +516,15 @@ fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("{operation} failed: {e}"), ), + // Definitively failed on an address-nonce race (a shield spends platform + // address funds; a shield reserves no notes). Its own code carries the + // safe-to-retry contract AND lets the host recognize the self-healing + // nonce mismatch — a plain retry re-fetches the nonce. Without this arm + // it would regress to the generic `ErrorWalletOperation` below. + Err(e @ PlatformWalletError::AddressNonceMismatch { .. }) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + format!("{operation} failed: {e}"), + ), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1438,4 +1447,35 @@ mod tests { PlatformWalletFFIResultCode::Success ); } + + /// A shield (Type 15) definitively rejected on an address-nonce race must + /// map to the dedicated `ErrorAddressNonceMismatch` — NOT regress to the + /// generic `ErrorWalletOperation` — so hosts keep the safe-to-retry signal. + /// The submitted/expected nonce values must survive in the message. + #[test] + fn map_spend_result_maps_address_nonce_mismatch_to_dedicated_code() { + let mismatch: Result<(), PlatformWalletError> = + Err(PlatformWalletError::AddressNonceMismatch { + address: PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }); + let result = map_spend_result(mismatch, "shielded shield"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "shield nonce rejection must not regress to ErrorWalletOperation" + ); + let msg = message_of(&result); + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. + assert!( + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" + ); + } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 473e82e5491..8d71c044aac 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,6 +1,8 @@ use dpp::address_funds::PlatformAddress; +use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::fee::Credits; use dpp::identifier::Identifier; +use dpp::prelude::AddressNonce; use key_wallet::account::StandardAccountType; use key_wallet::Network; @@ -102,6 +104,22 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// Platform rejected an address-funds transition because a spent address's + /// provided nonce did not equal its expected next value (DPP consensus code + /// 40603, `AddressInvalidNonceError`) — an optimistic `fetched + 1` nonce + /// racing a lagging replica read. Carries Platform's `expected_nonce` + /// verbatim so the caller can rebuild and retry without re-fetching. + #[error( + "Address nonce mismatch for {address}: submitted nonce {provided_nonce}, \ + Platform expected {expected_nonce}; retry the operation with the \ + expected nonce" + )] + AddressNonceMismatch { + address: PlatformAddress, + provided_nonce: AddressNonce, + expected_nonce: AddressNonce, + }, + #[error("Address sync failed: {0}")] AddressSync(String), @@ -397,3 +415,211 @@ pub fn as_asset_lock_proof_cl_height_too_low( _ => None, } } + +/// Extract the `AddressInvalidNonceError` (DPP consensus code 40603) when +/// Platform rejected an address-funds transition on a stale nonce, exposing +/// `address()`, `provided_nonce()`, and `expected_nonce()` so the caller can +/// retry with the expected value; `None` otherwise. +/// +/// Matches the three `dash_sdk::Error` shapes that can carry a consensus +/// verdict — `StateTransitionBroadcastError` (wait-stream), `Protocol( +/// ConsensusError)` (CheckTx), and a `NoAvailableAddressesToRetry` envelope it +/// recurses into — staying in lockstep with `broadcast_definitely_failed`. +pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + let consensus_error = match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + // Unwrap the dapi-client's exhausted-retry envelope. + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { + return as_address_invalid_nonce(inner) + } + _ => None, + }; + match consensus_error { + Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(e))) => Some(e), + _ => None, + } +} + +/// Promote a nonce-rejection SDK error to the typed +/// [`PlatformWalletError::AddressNonceMismatch`] so callers can recover +/// `expected_nonce` and retry, instead of receiving the rejection flattened +/// to a string. +/// +/// Returns `None` for any error [`as_address_invalid_nonce`] does not match, +/// leaving the caller free to keep its existing fallback mapping. +pub fn promote_address_nonce_error(error: &dash_sdk::Error) -> Option { + as_address_invalid_nonce(error).map(|e| PlatformWalletError::AddressNonceMismatch { + address: *e.address(), + provided_nonce: e.provided_nonce(), + expected_nonce: e.expected_nonce(), + }) +} + +/// Map an address-funded transition's SDK error to a [`PlatformWalletError`], +/// promoting a nonce rejection to the typed +/// [`PlatformWalletError::AddressNonceMismatch`] and otherwise preserving it +/// under [`PlatformWalletError::Sdk`]. Owned-error `.map_err(...)?` analogue of +/// [`promote_address_nonce_error`] for the transfer / withdrawal call sites. +pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWalletError { + promote_address_nonce_error(&error).unwrap_or(PlatformWalletError::Sdk(error)) +} + +#[cfg(test)] +mod address_nonce_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + + const ADDR_BYTES: [u8; 20] = [7u8; 20]; + + /// An `AddressInvalidNonceError` wrapped as a `ConsensusError`, plus the + /// address it names, for asserting round-trip field fidelity. + fn nonce_consensus_error( + provided: AddressNonce, + expected: AddressNonce, + ) -> (PlatformAddress, dpp::consensus::ConsensusError) { + let address = PlatformAddress::P2pkh(ADDR_BYTES); + let err = AddressInvalidNonceError::new(address, provided, expected); + (address, err.into()) + } + + /// `Protocol(ConsensusError)` — the CheckTx-rejection shape. + fn protocol_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) + } + + /// `StateTransitionBroadcastError` — the wait-stream-rejection shape. + fn broadcast_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 40603, + message: "invalid address nonce".to_string(), + cause: Some(cause), + }) + } + + #[test] + fn extracts_nonce_error_from_protocol_shape() { + let err = protocol_shape(1, 2); + let got = as_address_invalid_nonce(&err).expect("protocol shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 1); + assert_eq!(got.expected_nonce(), 2); + } + + #[test] + fn extracts_nonce_error_from_broadcast_shape() { + let err = broadcast_shape(5, 6); + let got = as_address_invalid_nonce(&err).expect("broadcast shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 5); + assert_eq!(got.expected_nonce(), 6); + } + + #[test] + fn ignores_unrelated_and_causeless_errors() { + // A plainly unrelated SDK error. + assert!(as_address_invalid_nonce(&dash_sdk::Error::Generic("boom".to_string())).is_none()); + // The DAPI wait-timeout shape: a broadcast error with no consensus + // cause must NOT be misread as a nonce rejection. + let causeless = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 0, + message: "timeout".to_string(), + cause: None, + }); + assert!(as_address_invalid_nonce(&causeless).is_none()); + } + + #[test] + fn promotes_both_shapes_to_typed_variant() { + for err in [protocol_shape(1, 2), broadcast_shape(1, 2)] { + match promote_address_nonce_error(&err) { + Some(PlatformWalletError::AddressNonceMismatch { + address, + provided_nonce, + expected_nonce, + }) => { + assert_eq!(address, PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(provided_nonce, 1); + assert_eq!(expected_nonce, 2); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } + } + + #[test] + fn promotion_leaves_unrelated_errors_for_the_fallback() { + assert!( + promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() + ); + } + + #[test] + fn promote_or_sdk_promotes_a_matching_nonce_error() { + // The transfer / withdrawal call sites route their SDK error through + // this helper; a nonce rejection must surface as the typed variant. + for err in [protocol_shape(3, 4), broadcast_shape(3, 4)] { + match promote_address_nonce_error_or_sdk(err) { + PlatformWalletError::AddressNonceMismatch { + address, + provided_nonce, + expected_nonce, + } => { + assert_eq!(address, PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(provided_nonce, 3); + assert_eq!(expected_nonce, 4); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } + } + + #[test] + fn promote_or_sdk_falls_back_to_sdk_for_unrelated_errors() { + // A non-nonce error must be preserved verbatim under `Sdk`, not + // flattened — this is the fallback the transfer / withdrawal sites keep. + match promote_address_nonce_error_or_sdk(dash_sdk::Error::Generic("boom".to_string())) { + PlatformWalletError::Sdk(dash_sdk::Error::Generic(msg)) => assert_eq!(msg, "boom"), + other => panic!("expected Sdk(Generic), got {other:?}"), + } + } + + #[test] + fn promote_or_sdk_promotes_through_the_retry_envelope() { + // A nonce rejection wrapped in the dapi-client's retry envelope must + // still promote (the helper recurses via `as_address_invalid_nonce`). + let wrapped = + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(protocol_shape(11, 12))); + match promote_address_nonce_error_or_sdk(wrapped) { + PlatformWalletError::AddressNonceMismatch { + provided_nonce, + expected_nonce, + .. + } => { + assert_eq!(provided_nonce, 11); + assert_eq!(expected_nonce, 12); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } + + #[test] + fn extracts_nonce_error_wrapped_in_no_available_addresses_to_retry() { + // The dapi-client wraps the last rejection in `NoAvailableAddressesToRetry` + // when every address is exhausted mid-retry; the extractor must recurse + // into it (lockstep with `broadcast_definitely_failed`). + let inner = Box::new(protocol_shape(9, 10)); + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(inner); + let got = as_address_invalid_nonce(&wrapped).expect("must unwrap the retry envelope"); + assert_eq!(got.provided_nonce(), 9); + assert_eq!(got.expected_nonce(), 10); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs index df7c1456d0f..7904b15dc9b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs @@ -108,10 +108,12 @@ impl IdentityWallet { ) .await .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register identity from addresses: {}", - e - )) + crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity from addresses: {}", + e + )) + }) })?; // The SDK return path for `put_with_address_funding_fetching_nonces` diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index da15c81cd8d..35f79e8ac37 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -76,10 +76,12 @@ impl IdentityWallet { .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity from addresses: {}", - e - )) + crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity from addresses: {}", + e + )) + }) })?; // Update the identity's balance in the local manager and diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index a62fa1ca045..89f02603e9a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -7,6 +7,7 @@ use dpp::state_transition::address_funds_transfer_transition::AddressFundsTransf use dpp::version::PlatformVersion; use key_wallet::PlatformP2PKHAddress; +use crate::error::promote_address_nonce_error_or_sdk; use crate::wallet::PlatformAddressWallet; use crate::{PlatformAddressChangeSet, PlatformWalletError}; use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; @@ -227,7 +228,8 @@ impl PlatformAddressWallet { } self.sdk .transfer_address_funds(inputs, outputs, fee_strategy, address_signer, None) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } InputSelection::ExplicitWithNonces(inputs) => { if inputs.is_empty() { @@ -243,7 +245,8 @@ impl PlatformAddressWallet { address_signer, None, ) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } InputSelection::Auto => { // Auto-select supports `[DeductFromInput(0)]` and `[ReduceOutput(0)]`; @@ -264,7 +267,8 @@ impl PlatformAddressWallet { .await?; self.sdk .transfer_address_funds(inputs, outputs, fee_strategy, address_signer, None) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index f9780a7f79e..b3ff1687659 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -10,6 +10,7 @@ use dpp::withdrawal::Pooling; use key_wallet::PlatformP2PKHAddress; use super::InputSelection; +use crate::error::promote_address_nonce_error_or_sdk; use crate::wallet::PlatformAddressWallet; use crate::{PlatformAddressChangeSet, PlatformWalletError}; use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; @@ -133,7 +134,8 @@ impl PlatformAddressWallet { address_signer, None, ) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } InputSelection::ExplicitWithNonces(inputs) => { if inputs.is_empty() { @@ -152,7 +154,8 @@ impl PlatformAddressWallet { address_signer, None, ) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } InputSelection::Auto => { // The AUTO path owns its own fee strategy: it picks the @@ -183,7 +186,8 @@ impl PlatformAddressWallet { address_signer, None, ) - .await? + .await + .map_err(promote_address_nonce_error_or_sdk)? } }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9fe357f431e..1aedcbecfdb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -556,7 +556,8 @@ pub async fn shield, P: OrchardPr format_addresses_with_info(rich.addresses_with_info(), network), )) } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + crate::error::promote_address_nonce_error(e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) } }; @@ -2286,6 +2287,9 @@ pub(super) async fn redrive_pending_spends( /// non-empty consensus `data` — the wait-stream error envelope for a /// transition Platform executed and rejected on its merits. /// +/// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring +/// [`crate::error::as_address_invalid_nonce`]. +/// /// Only these prove the transition was evaluated and REJECTED. Everything /// else — transport errors, timeouts, `AlreadyExists` (which proves the /// opposite: the transition is already in the mempool or on chain), @@ -2295,6 +2299,7 @@ fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { match err { dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => true, dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.is_some(), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => carries_consensus_rejection(inner), _ => false, } } @@ -2335,7 +2340,8 @@ async fn broadcast_shielded_spend( match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { - return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + return Err(crate::error::promote_address_nonce_error(&e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); } Err(e) => { warn!( @@ -2443,7 +2449,8 @@ fn classify_spend_wait_failure( wait_err: &dash_sdk::Error, ) -> PlatformWalletError { if carries_consensus_rejection(wait_err) { - PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string()) + crate::error::promote_address_nonce_error(wait_err) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) } else { warn!( operation, @@ -2810,6 +2817,57 @@ mod classify_spend_wait_failure_tests { &dash_sdk::Error::AlreadyExists("state transition already in mempool".to_string()) )); } + + /// A consensus verdict wrapped in the dapi-client's `NoAvailableAddressesToRetry` + /// retry envelope must still count as a rejection — `carries_consensus_rejection` + /// recurses through the envelope, in lockstep with `as_address_invalid_nonce`. + #[test] + fn wrapped_consensus_rejection_is_a_rejection() { + let wrapped = + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(consensus_metadata_rejection())); + assert!(carries_consensus_rejection(&wrapped)); + } + + /// A transport error wrapped in the retry envelope carries no consensus + /// verdict, so it remains ambiguous — the recursion must not misread it. + #[test] + fn wrapped_transport_error_is_not_a_rejection() { + use dash_sdk::dapi_grpc::tonic::Code; + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( + Code::DeadlineExceeded, + ))); + assert!(!carries_consensus_rejection(&wrapped)); + } + + /// A nonce rejection wrapped in the retry envelope must reach + /// `promote_address_nonce_error` and surface as the typed + /// `AddressNonceMismatch`, not fall through to `ShieldedSpendUnconfirmed`. + #[test] + fn wrapped_nonce_rejection_promotes_to_typed_mismatch() { + use dpp::address_funds::PlatformAddress; + use dpp::consensus::state::address_funds::AddressInvalidNonceError; + use dpp::consensus::state::state_error::StateError; + + let address = PlatformAddress::P2pkh([9u8; 20]); + let cause = ConsensusError::StateError(StateError::AddressInvalidNonceError( + AddressInvalidNonceError::new(address, 7, 8), + )); + let inner = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))); + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(inner)); + + match classify_spend_wait_failure("withdraw", &wrapped) { + PlatformWalletError::AddressNonceMismatch { + address: got, + provided_nonce, + expected_nonce, + } => { + assert_eq!(got, address); + assert_eq!(provided_nonce, 7); + assert_eq!(expected_nonce, 8); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } } #[cfg(test)] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9676ee38143..a38ba25a027 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -53,6 +53,14 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// of double-spending; the reservation TTL or a sync reconciles the /// outcome. Do NOT auto-retry. case errorTransactionBroadcastUnconfirmed = 20 + /// Definitively-failed address-nonce race: Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected value + /// (a lagging DAPI replica read). The transition did NOT execute and any + /// notes were released (a shield reserves none) — safe to retry; the retry + /// re-fetches the nonce and self-heals. The submitted/expected nonce values + /// travel in the message string, not as structured fields. + case errorAddressNonceMismatch = 21 case notFound = 98 case errorUnknown = 99 @@ -100,6 +108,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedNoRecordedAnchor case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_UNCONFIRMED: self = .errorTransactionBroadcastUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: + self = .errorAddressNonceMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -208,6 +218,13 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) + /// Definitively-failed address-nonce race (shield, or identity + /// top-up-from-addresses): Platform rejected the transition because the + /// submitted address nonce raced its expected value. The transition did + /// NOT execute and any notes were released (a shield reserves none) — safe + /// to retry, and the retry re-fetches the address nonce so the mismatch + /// self-heals. The submitted/expected nonce values are in the message. + case addressNonceMismatch(String) case notFound(String) case unknown(String) @@ -225,6 +242,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), + .addressNonceMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -258,6 +276,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) + case .errorAddressNonceMismatch: + self = .addressNonceMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }