Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ sealed class DashSdkError(
class DriveInternalError(message: String, cause: Throwable? = null) :
DashSdkError(message, cause)

/** The transition may have executed; reconcile it before submitting again. */
class StateTransitionResultUnconfirmed(message: String, cause: Throwable? = null) :
DashSdkError(message, cause) {
override val userMessage: String get() =
"Submission result is unconfirmed. Check history and reconcile the original operation before submitting again."
}

class InternalError(message: String, cause: Throwable? = null) :
DashSdkError(message, cause)

Expand Down Expand Up @@ -325,16 +332,15 @@ sealed class DashSdkError(
)

/**
* `ErrorTransactionBroadcastUnconfirmed` (native code 20). A core
* transaction broadcast had an AMBIGUOUS outcome — it may already be
* on the network. The wallet keeps the spent inputs reserved so a
* retry can't double-spend; the reservation TTL or a later sync
* reconciles the outcome. Do **NOT** auto-retry.
* `ErrorTransactionBroadcastUnconfirmed` (native code 20). A Core
* transaction or Platform state transition may already have executed,
* but its result is unconfirmed. Reconcile the original operation
* before submitting again. Do **NOT** auto-retry.
*/
class TransactionBroadcastUnconfirmed(message: String, cause: Throwable? = null) :
PlatformWallet(
"$message (do NOT retry: the transaction may already be on the network; " +
"the wallet keeps its inputs reserved until a sync reconciles the outcome)",
"$message (do NOT retry: the submission may already have executed; " +
"reconcile the original operation before submitting again)",
cause,
)

Expand Down Expand Up @@ -662,7 +668,7 @@ sealed class DashSdkError(
/**
* Offset the JNI bridge adds to a `PlatformWalletFFIResultCode`
* before throwing it as a `DashSDKException`, keeping it clear of the
* rs-sdk-ffi `DashSDKErrorCode` range (1–10). Must stay in lockstep
* rs-sdk-ffi `DashSDKErrorCode` range (1–11, 99). Must stay in lockstep
* with `support::PWFFI_CODE_OFFSET` in `rs-unified-sdk-jni`.
*/
const val PLATFORM_WALLET_CODE_OFFSET = 1000
Expand Down Expand Up @@ -692,6 +698,7 @@ sealed class DashSdkError(
8 -> Timeout(message, e)
9 -> NotImplemented(message, e)
10 -> DriveInternalError(message, e)
11 -> StateTransitionResultUnconfirmed(message, e)
else -> InternalError(message, e)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import org.dashfoundation.dashsdk.errors.PlatformConsensusError
* [code] values mirror `DashSDKErrorCode` in `rs-sdk-ffi/src/error.rs`:
* 0=Success, 1=InvalidParameter, 2=InvalidState, 3=NetworkError,
* 4=SerializationError, 5=ProtocolError, 6=CryptoError, 7=NotFound,
* 8=Timeout, 9=NotImplemented, 10=DriveInternalError, 99=InternalError.
* 8=Timeout, 9=NotImplemented, 10=DriveInternalError,
* 11=StateTransitionResultUnconfirmed, 99=InternalError.
*
* [consensusError] is the consensus rejection behind the failure when the
* native result or `DashSDKError` carried one, `null` otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class DashSdkErrorTest {
8 to DashSdkError.Timeout::class,
9 to DashSdkError.NotImplemented::class,
10 to DashSdkError.DriveInternalError::class,
11 to DashSdkError.StateTransitionResultUnconfirmed::class,
99 to DashSdkError.InternalError::class,
)
cases.forEach { (code, expected) ->
Expand All @@ -42,6 +43,19 @@ class DashSdkErrorTest {
}
}

@Test
fun shouldPreserveUnconfirmedSubmissionWithoutRetrying() {
val detail = "State transition may have executed: wait timeout after nonce rejection"
val native = DashSDKException(11, detail)
val mapped = DashSdkError.fromNative(native)
assertTrue(mapped is DashSdkError.StateTransitionResultUnconfirmed)
assertEquals(detail, mapped.message)
assertEquals(native, mapped.cause)
assertNull(mapped.consensusError)
assertFalse(mapped.isRetryable)
assertTrue(mapped.userMessage.contains("before submitting again"))
}

@Test
fun shouldPreserveShieldedIdentityDebitPendingWithoutClaimingSubmission() {
val message = "Identity has an unresolved shielded debit; " +
Expand Down Expand Up @@ -181,6 +195,8 @@ class DashSdkErrorTest {
)
// The message must warn against retrying (distinct from the anchor case).
assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry"))
assertTrue(broadcastUnconfirmed.message!!.contains("reconcile the original operation"))
assertFalse(broadcastUnconfirmed.message!!.contains("inputs reserved"))

// Definitive broadcast rejection (26) must reach callers as its own type,
// NOT as Generic: it is the definitive counterpart to the ambiguous
Expand Down
1 change: 1 addition & 0 deletions packages/rs-dapi-client/src/address_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,7 @@ mod tests {
let failed = list.get_live_address().unwrap();

let result: ExecutionResult<i32, TransportError> = Err(ExecutionError {
possibly_accepted: false,
inner: TransportError::Grpc(Status::unavailable("node down")),
retries: 0,
address: Some(failed.clone()),
Expand Down
16 changes: 15 additions & 1 deletion packages/rs-dapi-client/src/dapi_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ mod tests {
.insert("ratelimit-reset", MetadataValue::try_from("45").unwrap());

let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(TransportError::Grpc(status)),
retries: 0,
address: Some(addr.clone()),
Expand Down Expand Up @@ -487,6 +488,7 @@ mod tests {
address_list.add(addr.clone());

let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(TransportError::Grpc(
dapi_grpc::tonic::Status::resource_exhausted("429"),
)),
Expand Down Expand Up @@ -577,6 +579,7 @@ mod tests {
let transport_err =
TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(transport_err),
retries: 0,
address: Some(addr.clone()),
Expand Down Expand Up @@ -605,6 +608,7 @@ mod tests {
let transport_err =
TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(transport_err),
retries: 0,
address: Some(addr.clone()),
Expand All @@ -622,6 +626,7 @@ mod tests {
address_list.add(addr.clone());

let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::NoAvailableAddresses,
retries: 0,
address: Some(addr.clone()),
Expand All @@ -638,6 +643,7 @@ mod tests {
let transport_err =
TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(transport_err),
retries: 0,
address: None,
Expand Down Expand Up @@ -675,6 +681,7 @@ mod tests {
let transport_err =
TransportError::Grpc(dapi_grpc::tonic::Status::unavailable("temporary"));
let result: ExecutionResult<i32, DapiClientError> = Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(transport_err),
retries: 0,
address: Some(addr),
Expand Down Expand Up @@ -1110,6 +1117,7 @@ impl DapiRequestExecutor for DapiClient {
// same way; see `TransportRequest::names_nothing`.
if let Some(refusal) = request.names_nothing() {
return Err(ExecutionError {
possibly_accepted: false,
inner: DapiClientError::Transport(TransportError::Grpc(Status::invalid_argument(
refusal,
))),
Expand Down Expand Up @@ -1139,6 +1147,7 @@ impl DapiRequestExecutor for DapiClient {
let retry_delay = Duration::from_millis(10);

let mut retries: usize = 0;
let mut possibly_accepted = false;
// Track the last transport error for when all addresses get exhausted
let mut last_transport_error: Option<TransportError> = None;

Expand All @@ -1159,6 +1168,7 @@ impl DapiRequestExecutor for DapiClient {
};

return Err(ExecutionError {
possibly_accepted,
inner: error,
retries,
address: None,
Expand Down Expand Up @@ -1204,6 +1214,7 @@ impl DapiRequestExecutor for DapiClient {
let cloned_error = transport_error.clone();

let execution_error = ExecutionError {
possibly_accepted,
inner: DapiClientError::Transport(transport_error),
retries,
address: Some(address.clone()),
Expand Down Expand Up @@ -1265,6 +1276,8 @@ impl DapiRequestExecutor for DapiClient {
attempt.await
};

let classification = result.as_ref().err().map(|error| request.classify_error(error));
if let Some(classification) = classification { possibly_accepted |= classification.possibly_accepted; }
let execution_result = match result {
Ok(response) => {
tracing::trace!(response = ?response, "received {} response", response_name);
Expand All @@ -1277,6 +1290,7 @@ impl DapiRequestExecutor for DapiClient {
Err(transport_error) => {
tracing::debug!(error = ?transport_error, "received error: {transport_error}");
Err(ExecutionError {
possibly_accepted,
inner: DapiClientError::Transport(transport_error),
retries,
address: Some(address.clone()),
Expand All @@ -1293,7 +1307,7 @@ impl DapiRequestExecutor for DapiClient {
match execution_result {
Ok(response) => return Ok(response),
Err(error) => {
if error.can_retry() && retries < max_retries {
if classification.is_some_and(|outcome| outcome.retry) && retries < max_retries {
// Store last transport error
if let DapiClientError::Transport(ref te) = error.inner {
last_transport_error = Some(te.clone());
Expand Down
41 changes: 40 additions & 1 deletion packages/rs-dapi-client/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub trait InnerInto<T> {
#[derive(Debug, Clone, thiserror::Error, Eq)]
#[error("{inner}")]
pub struct ExecutionError<E> {
/// Some attempt may have delivered this request; not evidence of execution.
pub possibly_accepted: bool,
/// The cause of error
pub inner: E,
/// How many times the request was retried
Expand All @@ -45,9 +47,19 @@ pub struct ExecutionError<E> {
pub address: Option<Address>,
}

impl<E> ExecutionError<E> {
/// Whether any attempt may have delivered the request.
pub fn is_possibly_accepted(&self) -> bool {
self.possibly_accepted
}
}

impl<E: PartialEq> PartialEq for ExecutionError<E> {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner && self.retries == other.retries && self.address == other.address
self.inner == other.inner
&& self.retries == other.retries
&& self.address == other.address
&& self.possibly_accepted == other.possibly_accepted
}
}

Expand All @@ -58,6 +70,7 @@ where
/// Convert inner error type without losing retries and address
fn inner_into(self) -> ExecutionError<T> {
ExecutionError {
possibly_accepted: self.possibly_accepted,
inner: self.inner.into(),
retries: self.retries,
address: self.address,
Expand Down Expand Up @@ -225,6 +238,7 @@ where
address: result.address.clone(),
}),
Err(e) => ExecutionResult::Err(ExecutionError {
possibly_accepted: false,
inner: e.into(),
retries: result.retries,
address: Some(result.address.clone()),
Expand All @@ -251,6 +265,7 @@ mod tests {

fn mock_error() -> ExecutionError<String> {
ExecutionError {
possibly_accepted: false,
inner: "test error".to_string(),
retries: 2,
address: Some(mock_address()),
Expand All @@ -264,13 +279,15 @@ mod tests {
assert_eq!(err1, err2);

let err3 = ExecutionError {
possibly_accepted: false,
inner: "different".to_string(),
retries: 2,
address: Some(mock_address()),
};
assert_ne!(err1, err3);

let err4 = ExecutionError {
possibly_accepted: false,
inner: "test error".to_string(),
retries: 5,
address: Some(mock_address()),
Expand Down Expand Up @@ -388,6 +405,7 @@ mod tests {

let inner = DapiClientError::NoAvailableAddresses;
let error = ExecutionError {
possibly_accepted: false,
inner,
retries: 0,
address: None,
Expand All @@ -405,3 +423,24 @@ mod tests {
assert_eq!(response.retries, 0);
}
}

#[cfg(test)]
mod submission_history_tests {
use super::*;
#[test]
fn should_preserve_delivery_metadata_when_converting_inner_errors() {
let error = ExecutionError {
possibly_accepted: true,
inner: "lost",
retries: 2,
address: None,
};
let converted: ExecutionError<String> = error.inner_into();
assert!(converted.is_possibly_accepted());
assert_eq!(converted.inner, "lost");
assert_eq!(converted.retries, 2);
let mut other = converted.clone();
other.possibly_accepted = false;
assert_ne!(other, converted);
}
}
Loading
Loading