Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -322,16 +329,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 @@ -659,7 +665,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 @@ -689,6 +695,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 = "Identity top-up 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
27 changes: 27 additions & 0 deletions packages/rs-dapi-client/src/dapi_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,32 @@ impl DapiRequestExecutor for DapiClient {
request: R,
settings: RequestSettings,
) -> ExecutionResult<R::Response, DapiClientError>
where
R: TransportRequest + Mockable,
R::Response: Mockable,
TransportError: Mockable,
{
self.execute_with_transport_error_observer(request, settings, &|_| {})
.await
}
}

impl DapiClient {
/// Execute with the ordinary retry policy, observing each failed dispatched
/// attempt before its error can be replaced by a later attempt's result.
///
/// The observer also sees client-side attempt deadlines. It is not called
/// for local request refusal, address exhaustion or client construction
/// errors, where the request has not been dispatched. It cannot change the
/// returned result, retry budget, address selection or banning policy.
// Keep the established execute() result and error layout unchanged.
#[allow(clippy::result_large_err)]
pub async fn execute_with_transport_error_observer<R>(
&self,
request: R,
settings: RequestSettings,
observer: &(dyn Fn(&TransportError) + Send + Sync),
) -> ExecutionResult<R::Response, DapiClientError>
where
R: TransportRequest + Mockable,
R::Response: Mockable,
Expand Down Expand Up @@ -1275,6 +1301,7 @@ impl DapiRequestExecutor for DapiClient {
})
}
Err(transport_error) => {
observer(&transport_error);
tracing::debug!(error = ?transport_error, "received error: {transport_error}");
Err(ExecutionError {
inner: DapiClientError::Transport(transport_error),
Expand Down
41 changes: 41 additions & 0 deletions packages/rs-dapi-client/src/transport/tonic_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,47 @@ mod tests {
use super::*;
use crate::transport::{ProxyEndpoint, Socks5Auth, Socks5Proxy};
use crate::RequestSettings;
use dapi_grpc::platform::v0::BroadcastStateTransitionRequest;
use dapi_grpc::tonic::{Code, ConnectError};
use hyper_util::rt::TokioIo;
use std::io;
use tokio::io::DuplexStream;
use tower::service_fn;

#[tokio::test]
async fn should_preserve_typed_connector_failure_in_real_grpc_status() {
for kind in [
io::ErrorKind::ConnectionRefused,
io::ErrorKind::NotFound,
io::ErrorKind::InvalidData,
] {
// Exercise tonic's actual connector -> channel -> generated unary
// request path. The failing connector never opens a socket.
let channel = Channel::builder("http://unreachable.invalid".parse().unwrap())
.connect_with_connector_lazy(service_fn(move |_| async move {
Err::<TokioIo<DuplexStream>, _>(io::Error::new(kind, "establishment failed"))
}));
let mut client = PlatformGrpcClient::new(channel);
let status = client
.broadcast_state_transition(BroadcastStateTransitionRequest::default())
.await
.unwrap_err();
assert_eq!(status.code(), Code::Unavailable);
let mut source = status.source();
let mut found = false;
while let Some(error) = source {
if error.is::<ConnectError>() {
found = true;
break;
}
source = error.source();
}
assert!(
found,
"the SDK can distinguish establishment from a lost ACK: {status:?}"
);
}
}

#[test]
fn should_strip_only_ipv6_brackets() {
Expand Down
1 change: 1 addition & 0 deletions packages/rs-platform-wallet-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ bs58 = "0.5"
zeroize = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3.27.0"
dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] }
platform-wallet = { path = "../rs-platform-wallet", features = ["test-utils"] }

Expand Down
8 changes: 5 additions & 3 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,17 @@ pub enum PlatformWalletFFIResultCode {
/// boundary) and try again. Distinct from `ErrorShieldedSpendUnconfirmed`,
/// where a spend WAS broadcast and must NOT be retried.
ErrorShieldedNoRecordedAnchor = 19,
/// Maps `PlatformWalletError::TransactionBroadcastUnconfirmed`. A core
/// Maps `PlatformWalletError::TransactionBroadcastUnconfirmed`. A Core
/// transaction broadcast (send-to-addresses, DashPay payment, or
/// asset-lock funding) failed with an AMBIGUOUS outcome — the transaction
/// may already be on the network (transport timeout after delivery,
/// partial peer send, or an internal multi-node retry whose earlier
/// attempt may have delivered). The wallet intentionally KEEPS the spent
/// inputs' UTXO reservation, so an immediate retry fails at input
/// selection instead of double-spending; the reservation TTL or a sync
/// observing the transaction reconciles the outcome. The host must NOT
/// selection instead of double-spending; observing the spend reconciles
/// the outcome. Also covers address-funded identity top-ups whose result
/// could not be verified after possible acceptance, without Core UTXO
/// reservations. The host must reconcile the original operation and NOT
/// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`].
ErrorTransactionBroadcastUnconfirmed = 20,
/// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an
Expand Down
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet-ffi/src/identity_top_up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ use crate::identity_registration::IdentityFundingInputFFI;
use crate::runtime::block_on_worker;
use crate::{unwrap_option_or_return, unwrap_result_or_return};

#[cfg(test)]
mod submission_result_tests;

/// Top up an existing identity's credit balance from one or more
/// Platform addresses, using an external `SignerHandle` for the
/// per-address funding signatures.
Expand Down
Loading
Loading