From a844c61f0ff105eee65c8f2ee5cc24eb04df0054 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 20:00:40 +0700 Subject: [PATCH] feat(sdk): carry consensus error codes through the wallet FFI A token operation Platform rejected reached the mobile apps as text only: platform-wallet rendered the SDK error into `TokenError(String)`, so the FFI result, the JNI exception and the Swift error had nothing but a message left, and the example apps recognised the once-per-identity already-claimed rejection (consensus code 40722) by matching that message. platform-wallet keeps the `dash_sdk::Error` of a failed token state transition in a new `TokenOperationFailed` variant, with the same Display text, and exposes the consensus error it holds through `PlatformWalletError::consensus_error`. `PlatformWalletFFIResult` gains `consensus_code` and `consensus_kind` (basic, signature, fee, state), which the conversion from `PlatformWalletError` fills in. The result code and the message are unchanged, so existing hosts see what they saw before. Kotlin surfaces them as `DashSDKException.consensusError` and `DashSdkError.consensusError`; Swift as a `consensusRejection` case and `PlatformWalletError.consensusError`. Both example apps now compare the consensus code and no longer read the message. Co-Authored-By: Claude Fable 5.1 --- packages/kotlin-sdk/CLAUDE.md | 5 +- .../tokens/OncePerIdentityClaimStore.kt | 30 +- .../TokenMaterializerOncePerIdentityTest.kt | 85 ++++-- .../dashsdk/errors/DashSdkError.kt | 12 +- .../dashsdk/errors/PlatformConsensusError.kt | 52 ++++ .../dashsdk/ffi/DashSDKException.kt | 28 +- .../dashsdk/errors/DashSdkErrorTest.kt | 49 ++++ packages/rs-platform-wallet-ffi/src/error.rs | 260 +++++++++++++++++- .../src/shielded_send.rs | 2 +- packages/rs-platform-wallet/src/error.rs | 156 ++++++++++- .../wallet/identity/network/tokens/burn.rs | 11 +- .../wallet/identity/network/tokens/claim.rs | 11 +- .../network/tokens/destroy_frozen_funds.rs | 11 +- .../wallet/identity/network/tokens/freeze.rs | 11 +- .../wallet/identity/network/tokens/mint.rs | 11 +- .../wallet/identity/network/tokens/pause.rs | 11 +- .../identity/network/tokens/purchase.rs | 11 +- .../wallet/identity/network/tokens/resume.rs | 11 +- .../identity/network/tokens/set_price.rs | 11 +- .../identity/network/tokens/transfer.rs | 11 +- .../identity/network/tokens/unfreeze.rs | 11 +- .../identity/network/tokens/update_config.rs | 11 +- packages/rs-unified-sdk-jni/src/support.rs | 77 +++++- .../rs-unified-sdk-jni/src/wallet_manager.rs | 11 +- .../PlatformWalletManager.swift | 10 +- .../PlatformWallet/PlatformWalletResult.swift | 139 +++++++++- .../Services/OncePerIdentityClaimStore.swift | 69 +---- .../TokenClaimResolverTests.swift | 103 ++++--- .../ErrorHandlingTests.swift | 93 ++++++- 29 files changed, 1002 insertions(+), 311 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt diff --git a/packages/kotlin-sdk/CLAUDE.md b/packages/kotlin-sdk/CLAUDE.md index eda950c3b3d..68c2ff5a77b 100644 --- a/packages/kotlin-sdk/CLAUDE.md +++ b/packages/kotlin-sdk/CLAUDE.md @@ -31,7 +31,10 @@ are data encrypted under Keystore-wrapped AES keys). - `packages/rs-unified-sdk-jni` — Rust cdylib exposing JNI symbols. It calls the `extern "C"` entry points of the FFI crates **as rlib dependencies**, so `DashSDKResult` never crosses JNI by value. Errors throw - `org.dashfoundation.dashsdk.ffi.DashSDKException(code, message)`; panics + `org.dashfoundation.dashsdk.ffi.DashSDKException(code, message)`, which + also carries the consensus code and kind when a platform-wallet result + reports a consensus rejection (`DashSdkError.consensusError`; branch on + that, never on the message); panics are caught at every export (`support::guard`) — the JNI library must never abort the app process (workspace profiles `*-android` keep `panic = "unwind"`). diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt index b834cbf6e55..e68ccd8033b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.example.util.toHex /** @@ -40,26 +41,17 @@ class OncePerIdentityClaimStore( const val ALREADY_CLAIMED_ERROR_CODE = 40722 /** - * The code as a number of its own. Error texts carry amounts and - * millisecond timestamps, and a claim time such as 1758140722000 - * contains the digits, so a plain substring match would take an - * unrelated failure for a spent claim and hide the kind for good. + * True when [error] is the already-claimed rejection: the SDK error, + * or one in its cause chain, carries the consensus code the native + * layer read off Platform's verdict. The message is never consulted. + * Claim errors quote amounts and millisecond timestamps, so text that + * happens to contain the digits says nothing, and a false positive is + * permanent because the record is never cleared. */ - private val ALREADY_CLAIMED_CODE_PATTERN = - Regex("(?() + .any { it.consensusError?.code == ALREADY_CLAIMED_ERROR_CODE } private fun preferenceKey(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) = booleanPreferencesKey( diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt index 684336ac439..a392d936780 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt @@ -1,5 +1,7 @@ package org.dashfoundation.example.services.tokens +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity import org.dashfoundation.dashsdk.persistence.entities.TokenEntity import org.junit.Assert.assertEquals @@ -124,45 +126,70 @@ class TokenMaterializerOncePerIdentityTest { } @Test - fun `already-claimed rejection is recognised by code and by message`() { + fun `already-claimed rejection is recognised by its consensus code`() { + val rejection = claimFailure(consensusCode = 40722, consensusKind = STATE_KIND) + assertTrue(OncePerIdentityClaimStore.isAlreadyClaimed(rejection)) + // Wrapped by a caller on the way up, the SDK error is still found. assertTrue( - OncePerIdentityClaimStore.isAlreadyClaimed( - IllegalStateException("consensus error 40722: claim rejected"), - ), - ) - assertTrue( - OncePerIdentityClaimStore.isAlreadyClaimed( - RuntimeException( - "claim failed", - IllegalStateException( - "Token claim error: identity 'a' already claimed the " + - "once-per-identity distribution of token 'b' at 100", - ), - ), - ), + OncePerIdentityClaimStore.isAlreadyClaimed(RuntimeException("claim failed", rejection)), ) - assertTrue( - OncePerIdentityClaimStore.isAlreadyClaimed(IllegalStateException("code=40722")), - ) - assertFalse( - OncePerIdentityClaimStore.isAlreadyClaimed( - IllegalStateException("Token mint past max supply"), - ), - ) - // The digits inside a longer number are not the code: a timestamp or an - // amount in an unrelated failure must not read as a spent claim. + } + + @Test + fun `another consensus rejection is not the already-claimed one`() { + // `TokenNotForDirectSale`, the state error one code below. assertFalse( OncePerIdentityClaimStore.isAlreadyClaimed( - IllegalStateException("Token mint past max supply: 1758140722000"), + claimFailure(consensusCode = 40721, consensusKind = STATE_KIND), ), ) - assertFalse( - OncePerIdentityClaimStore.isAlreadyClaimed(IllegalStateException("amount 4072299")), - ) + } + + @Test + fun `error text is never read as the already-claimed rejection`() { + // A failure that was not a consensus rejection, whatever its message + // says: the code as a number of its own, the sentence rs-dpp renders, + // and the digits inside a timestamp. + listOf( + "consensus error 40722: claim rejected", + "Token claim error: identity 'a' already claimed the " + + "once-per-identity distribution of token 'b' at 100", + "Token mint past max supply: 1758140722000", + ).forEach { message -> + assertFalse( + message, + OncePerIdentityClaimStore.isAlreadyClaimed( + DashSdkError.fromNative(DashSDKException(UNKNOWN_WALLET_ERROR, message)), + ), + ) + assertFalse( + message, + OncePerIdentityClaimStore.isAlreadyClaimed(IllegalStateException(message)), + ) + } } @Test fun `V0-wrapped block is unwrapped`() { assertEquals("7", TokenOncePerIdentityDistribution.parse("""{"V0":{"amount":7}}""")?.amount) } + + /** What `wallet.tokens.claim` throws for a claim Platform rejected. */ + private fun claimFailure(consensusCode: Int, consensusKind: Int): DashSdkError = + DashSdkError.fromNative( + DashSDKException( + UNKNOWN_WALLET_ERROR, + "Token operation failed: Token claim failed: state transition broadcast error", + consensusCode, + consensusKind, + ), + ) + + private companion object { + /** `PlatformWalletFFIResultCode::ErrorUnknown`, as the JNI bridge throws it. */ + const val UNKNOWN_WALLET_ERROR = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 99 + + /** `PlatformWalletFFIConsensusErrorKind::State`. */ + const val STATE_KIND = 4 + } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 6d0ba538740..420ad1a6bbb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -38,6 +38,14 @@ sealed class DashSdkError( */ open val userMessage: String get() = message.orEmpty() + /** + * The consensus error Platform rejected the operation with, when the + * native layer reported one; `null` for every other failure. Branch on + * its code instead of matching [message]. + */ + val consensusError: PlatformConsensusError? + get() = (cause as? DashSDKException)?.consensusError + class InvalidParameter(message: String, cause: Throwable? = null) : DashSdkError(message, cause) @@ -211,8 +219,8 @@ sealed class DashSdkError( * Distinct from [CoreInsufficientFunds] (22), which is the atomic * Core-send selector rather than the asset-lock builder. The shortfall * figures travel in [message] as `available {n} duffs, required {n} - * duffs` — the native result is ABI-frozen to code + message, so there - * are no structured fields to read. + * duffs`: the native result has no fields for them, so there is + * nothing structured to read. * * Raised by * [shieldedFundFromCoinJoinDrain][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromCoinJoinDrain] diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt new file mode 100644 index 00000000000..52cb67eb152 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt @@ -0,0 +1,52 @@ +package org.dashfoundation.dashsdk.errors + +/** + * The consensus error Platform rejected a state transition with, as the + * native layer reported it: the numeric [code] rs-dpp assigns the error + * (`packages/rs-dpp/src/errors/consensus/codes.rs`) and the [kind] it belongs + * to. Branch on these rather than on the error message, whose wording is not + * a contract. + * + * Read it from [DashSdkError.consensusError]. It is present for the wallet + * operations whose native error still holds the SDK's consensus verdict (the + * token state transitions among them) and `null` for every failure that was + * not a consensus rejection. + */ +data class PlatformConsensusError( + val code: Int, + val kind: ConsensusErrorKind, +) + +/** + * Which of rs-dpp's consensus error families an error belongs to. The native + * layer reports it next to the code, so it is never derived from the number + * on this side. Values mirror `PlatformWalletFFIConsensusErrorKind` in + * `rs-platform-wallet-ffi/src/error.rs`. + */ +enum class ConsensusErrorKind { + /** Structure or version validation failed; the transition was not executed. */ + BASIC, + + /** The transition's signature or signing key was rejected. */ + SIGNATURE, + + /** The fee could not be covered. */ + FEE, + + /** The transition was well-formed but Platform state rejected it. */ + STATE, + + /** A family this SDK build does not know. The [PlatformConsensusError.code] still holds. */ + UNKNOWN, + ; + + internal companion object { + fun fromNative(value: Int): ConsensusErrorKind = when (value) { + 1 -> BASIC + 2 -> SIGNATURE + 3 -> FEE + 4 -> STATE + else -> UNKNOWN + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt index 4aeb732db9b..fe8ab7c976d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt @@ -1,20 +1,40 @@ package org.dashfoundation.dashsdk.ffi +import org.dashfoundation.dashsdk.errors.ConsensusErrorKind +import org.dashfoundation.dashsdk.errors.PlatformConsensusError + /** * Exception thrown by the native layer (`rs-unified-sdk-jni`) when an FFI call - * returns an error. Constructed from Rust via JNI — the `(Int, String)` - * constructor signature must stay in sync with - * `rs-unified-sdk-jni/src/support.rs::throw_sdk_exception`. + * returns an error. Constructed from Rust via JNI: the `(Int, String)` and + * `(Int, String, Int, Int)` constructor signatures must stay in sync with + * `rs-unified-sdk-jni/src/support.rs::throw_sdk_exception` and + * `throw_sdk_consensus_exception`. * * [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. * + * [consensusError] is the consensus rejection behind the failure when the + * native result carried one, `null` otherwise. + * * Internal: the public API maps this into the * [org.dashfoundation.dashsdk.errors.DashSdkError] hierarchy. */ class DashSDKException( val code: Int, message: String, -) : Exception(message) + val consensusError: PlatformConsensusError?, +) : Exception(message) { + constructor(code: Int, message: String) : this(code, message, null) + + /** + * JNI entry for a failure that is a consensus rejection. [consensusKind] + * is the `PlatformWalletFFIConsensusErrorKind` discriminant. + */ + constructor(code: Int, message: String, consensusCode: Int, consensusKind: Int) : this( + code, + message, + PlatformConsensusError(consensusCode, ConsensusErrorKind.fromNative(consensusKind)), + ) +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 6c139315be5..1254c322813 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -3,6 +3,7 @@ package org.dashfoundation.dashsdk.errors import org.dashfoundation.dashsdk.ffi.DashSDKException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -472,4 +473,52 @@ class DashSdkErrorTest { assertFalse(error is DashSdkError.NotFound) assertEquals("wallet not found", (error as DashSdkError).message) } + + @Test + fun shouldSurfaceTheConsensusErrorOfARejectedTokenOperation() { + // What the JNI bridge throws for a claim Platform rejected as already + // claimed: ErrorUnknown (99), consensus code 40722, kind State (4). + val native = DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 99, + "Token operation failed: Token claim failed: state transition broadcast error", + 40722, + 4, + ) + val mapped = DashSdkError.fromNative(native) + + // The consensus error rides along; the type and message are what they + // were without it. + assertTrue(mapped is DashSdkError.PlatformWallet.Generic) + assertEquals(native.message, mapped.message) + assertEquals( + PlatformConsensusError(40722, ConsensusErrorKind.STATE), + mapped.consensusError, + ) + } + + @Test + fun shouldMapEveryNativeConsensusErrorKind() { + val kinds = mapOf( + 1 to ConsensusErrorKind.BASIC, + 2 to ConsensusErrorKind.SIGNATURE, + 3 to ConsensusErrorKind.FEE, + 4 to ConsensusErrorKind.STATE, + // A family this build does not know keeps its code. + 9 to ConsensusErrorKind.UNKNOWN, + ) + kinds.forEach { (native, expected) -> + val error = DashSdkError.fromNative(DashSDKException(1099, "rejected", 10000, native)) + assertEquals(PlatformConsensusError(10000, expected), error.consensusError) + } + } + + @Test + fun shouldHaveNoConsensusErrorWithoutAConsensusRejection() { + val plain = DashSdkError.fromNative( + DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 99, "code 40722 in text only"), + ) + assertNull(plain.consensusError) + // An SDK error raised on the Kotlin side has no native cause at all. + assertNull(DashSdkError.InvalidParameter("bad argument").consensusError) + } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index e8ab690b819..020bccc138c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1,3 +1,5 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::ConsensusError; use dpp::platform_value::string_encoding::Encoding; use platform_wallet::changeset::PersistenceErrorKind; use platform_wallet::PlatformWalletError; @@ -221,8 +223,8 @@ pub enum PlatformWalletFFIResultCode { /// Asset-lock coin selection came up short over the *permitted* funding /// set (dashpay/platform#4073). Carries the structured /// `available`/`required` duff amounts in the message string — the - /// by-value `PlatformWalletFFIResult` is ABI-frozen (code + message only), - /// so the figures ride the typed `Display` rendering or not at all. + /// by-value `PlatformWalletFFIResult` has no per-error value fields, so + /// the figures ride the typed `Display` rendering or not at all. /// /// Distinct from [`Self::ErrorCoreInsufficientFunds`] (22), which is the /// atomic Core-send selector rather than the asset-lock builder. What the @@ -298,8 +300,8 @@ pub enum PlatformWalletFFIResultCode { // // 38/39/40 carry a STABLE JSON detail object in the result `message` // instead of the typed `Display` rendering — see each variant's doc for - // the exact object. `PlatformWalletFFIResult` is ABI-frozen (code + - // message only), so structured values ride the message or not at all. + // the exact object. `PlatformWalletFFIResult` has no per-error value + // fields, so structured values ride the message or not at all. /// Maps `SignedPaymentError::StaleReservationToken` from the deferred /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound @@ -615,12 +617,72 @@ pub enum PlatformWalletFFIResultCode { ErrorUnknown = 99, } +/// Which family a consensus rejection belongs to, paired with the numeric +/// `consensus_code` on [`PlatformWalletFFIResult`]. +/// +/// rs-dpp groups consensus errors by the ten-thousands digit of their code +/// (`packages/rs-dpp/src/errors/consensus/codes.rs`): basic 1xxxx, signature +/// 2xxxx, fee 3xxxx, state 4xxxx. The kind is handed over as its own value so +/// a host branches on it instead of re-deriving the grouping from the number. +/// +/// [`Self::None`] is the only value a result carries when `consensus_code` is +/// 0, and it covers both "the failure was not a consensus rejection" and the +/// `ConsensusError::DefaultError` placeholder, which names no rejection a host +/// could act on. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformWalletFFIConsensusErrorKind { + None = 0, + Basic = 1, + Signature = 2, + Fee = 3, + State = 4, +} + +impl PlatformWalletFFIConsensusErrorKind { + /// The kind of `error`, or [`Self::None`] for a variant that carries no + /// actionable rejection. The wildcard arm also absorbs + /// `ConsensusError::TestConsensusError`, which exists only in dpp's own + /// `cfg(test)` builds. + fn of(error: &ConsensusError) -> Self { + match error { + ConsensusError::BasicError(_) => Self::Basic, + ConsensusError::SignatureError(_) => Self::Signature, + ConsensusError::FeeError(_) => Self::Fee, + ConsensusError::StateError(_) => Self::State, + _ => Self::None, + } + } +} + /// Must be freed with ['platform_wallet_ffi_result_free'] +/// +/// `code` and `message` are the stable pair every host has always read. +/// `consensus_code` / `consensus_kind` describe Platform's own rejection when +/// the failure was one: the rs-dpp consensus code (10000 and up) and its +/// family, or `0` / [`PlatformWalletFFIConsensusErrorKind::None`] when the +/// failure came from anywhere else. They let a host branch on a rejection it +/// would otherwise have to recognize by its rendered text. +/// +/// Both language surfaces are generated from this same source tree (Swift +/// through the cbindgen header inside the xcframework, Kotlin through an rlib +/// dependency of `rs-unified-sdk-jni`), so appending a field is a +/// source-compatible change rather than an ABI break. What has not changed is +/// that the struct carries no room for a *specific* error's values: those +/// still ride the `message`, as the codes that document a JSON detail object +/// below describe. #[repr(C)] #[derive(Debug)] pub struct PlatformWalletFFIResult { pub code: PlatformWalletFFIResultCode, pub message: *mut c_char, + /// The rs-dpp consensus error code Platform rejected the operation with, + /// or 0 when the failure was not a consensus rejection. Real codes start + /// at 10000, so 0 is unambiguous. + pub consensus_code: u32, + /// The family `consensus_code` belongs to, or + /// [`PlatformWalletFFIConsensusErrorKind::None`] when there is no code. + pub consensus_kind: PlatformWalletFFIConsensusErrorKind, } impl Drop for PlatformWalletFFIResult { @@ -639,6 +701,8 @@ impl PlatformWalletFFIResult { Self { code: PlatformWalletFFIResultCode::Success, message: std::ptr::null_mut(), + consensus_code: 0, + consensus_kind: PlatformWalletFFIConsensusErrorKind::None, } } @@ -648,9 +712,28 @@ impl PlatformWalletFFIResult { Self { code, message: c_msg.into_raw(), + consensus_code: 0, + consensus_kind: PlatformWalletFFIConsensusErrorKind::None, } } + /// Stamp Platform's own rejection onto an already-built result. + /// + /// Leaves `code` and `message` alone: the numeric result code is the host + /// ABI and must keep meaning what it did, so the consensus verdict is + /// added beside it rather than in place of it. A `ConsensusError` with no + /// actionable family (see [`PlatformWalletFFIConsensusErrorKind::of`]) + /// leaves the result untouched, so `consensus_code == 0` always means + /// "no rejection to branch on". + fn with_consensus_error(mut self, error: &ConsensusError) -> Self { + let kind = PlatformWalletFFIConsensusErrorKind::of(error); + if kind != PlatformWalletFFIConsensusErrorKind::None { + self.consensus_code = error.code(); + self.consensus_kind = kind; + } + self + } + /// A `Success`-coded result that still carries an advisory `message`. /// /// Used by non-error outcomes that want to convey a human-readable @@ -666,6 +749,8 @@ impl PlatformWalletFFIResult { Self { code: PlatformWalletFFIResultCode::Success, message: c_msg.into_raw(), + consensus_code: 0, + consensus_kind: PlatformWalletFFIConsensusErrorKind::None, } } } @@ -709,7 +794,7 @@ impl From> for PlatformWalletFFIResult { /// The value-carrying DPNS-marketplace rejections, rendered as /// `(code, JSON detail)` instead of `(code, Display)`. /// -/// `PlatformWalletFFIResult` is ABI-frozen at `{ code, message }`, so a +/// `PlatformWalletFFIResult` has no per-error value fields, so a /// host that needs the *values* — not prose naming them — can only get /// them through the message. These three therefore put a stable JSON /// object there; the exact shape is documented on each @@ -868,9 +953,9 @@ impl From for PlatformWalletFFIResult { // 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. + // out of scope: `PlatformWalletFFIResult` is by-value and has no + // per-error value fields, so the values travel in the message + // string and an FFI retry re-fetches the nonce. PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } @@ -1025,7 +1110,17 @@ impl From for PlatformWalletFFIResult { // mid-string and is deliberately not matched. _ => PlatformWalletFFIResultCode::ErrorUnknown, }; - PlatformWalletFFIResult::err(code, error.to_string()) + let result = PlatformWalletFFIResult::err(code, error.to_string()); + // Platform's own verdict, for the variants that still hold the SDK + // error it arrived in (`Sdk`, `TokenOperationFailed`). It rides + // alongside the code chosen above rather than replacing it, so a host + // that branches on a dedicated code keeps seeing exactly what it saw + // before and one that wants the rejection itself no longer has to + // recognize it by its rendered text. + match error.consensus_error() { + Some(consensus_error) => result.with_consensus_error(consensus_error), + None => result, + } } } @@ -1231,6 +1326,10 @@ impl From for PlatformWalletFFIResult { #[cfg(test)] mod tests { use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::basic::{BasicError, UnsupportedVersionError}; + use dpp::consensus::state::token::TokenOncePerIdentityDistributionAlreadyClaimedError; + use dpp::prelude::Identifier; use key_wallet::account::StandardAccountType; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; @@ -2318,6 +2417,149 @@ mod tests { } } + /// The once-per-identity claim rejection, the state error this whole + /// consensus channel exists for: Drive charges for the second claim and + /// the host must recognize it to stop offering the kind. + fn already_claimed() -> ConsensusError { + ConsensusError::from(TokenOncePerIdentityDistributionAlreadyClaimedError::new( + Identifier::from([1u8; 32]), + Identifier::from([2u8; 32]), + 1_758_140_722_000, + )) + } + + /// The wait-stream shape a state rejection arrives in: accepted by + /// CheckTx, rejected at block execution. + fn broadcast_rejection(cause: ConsensusError) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: cause.code(), + message: cause.to_string(), + cause: Some(cause), + }) + } + + /// A rejected token operation keeps the catch-all result code it has + /// always had, and the consensus verdict travels beside it. Both halves + /// matter: the code is what existing hosts branch on, and the verdict is + /// what a host needs to stop matching rendered text. + #[test] + fn should_carry_the_consensus_code_of_a_rejected_token_operation() { + let error = PlatformWalletError::token_operation_failed( + "claim", + broadcast_rejection(already_claimed()), + ); + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "the host-visible result code must not move for a token failure" + ); + assert_eq!(result.consensus_code, 40722); + assert_eq!( + result.consensus_kind, + PlatformWalletFFIConsensusErrorKind::State + ); + assert_eq!( + message_of(&result), + rendered, + "the Display rendering hosts already log must survive verbatim" + ); + } + + /// The kind comes from the rejection's own family, not from the digits of + /// its code: a basic rejection must not arrive labelled as a state one. + #[test] + fn should_report_the_family_of_a_basic_rejection() { + let basic = ConsensusError::BasicError(BasicError::UnsupportedVersionError( + UnsupportedVersionError::new(9, 1, 8), + )); + let expected_code = basic.code(); + let result: PlatformWalletFFIResult = + PlatformWalletError::token_operation_failed("mint", broadcast_rejection(basic)).into(); + + assert_eq!( + result.consensus_kind, + PlatformWalletFFIConsensusErrorKind::Basic + ); + assert_eq!(result.consensus_code, expected_code); + } + + /// Everything that is not a consensus rejection reports 0 / None, so a + /// host can read `consensus_code == 0` as "nothing to branch on" without + /// a second check. + #[test] + fn should_report_no_consensus_error_for_non_rejections() { + let ok = PlatformWalletFFIResult::ok(); + assert_eq!(ok.consensus_code, 0); + assert_eq!(ok.consensus_kind, PlatformWalletFFIConsensusErrorKind::None); + + let err = PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::ErrorUnknown, "boom"); + assert_eq!(err.consensus_code, 0); + assert_eq!( + err.consensus_kind, + PlatformWalletFFIConsensusErrorKind::None + ); + + let transport: PlatformWalletFFIResult = PlatformWalletError::token_operation_failed( + "claim", + dash_sdk::Error::Generic("boom".to_string()), + ) + .into(); + assert_eq!(transport.consensus_code, 0); + assert_eq!( + transport.consensus_kind, + PlatformWalletFFIConsensusErrorKind::None + ); + + // A rejection that was rendered into a string before it got here is + // gone, however much its text looks like one. + let stringified: PlatformWalletFFIResult = + PlatformWalletError::TokenError(already_claimed().to_string()).into(); + assert_eq!(stringified.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!(stringified.consensus_code, 0); + assert_eq!( + stringified.consensus_kind, + PlatformWalletFFIConsensusErrorKind::None + ); + } + + /// A signer that cannot reach its key is not a Platform rejection, and it + /// keeps the dedicated code hosts route to key repair. + #[test] + fn should_keep_the_signing_key_unavailable_code_for_a_token_operation() { + let result: PlatformWalletFFIResult = PlatformWalletError::token_operation_failed( + "claim", + dash_sdk::Error::Protocol(dpp::ProtocolError::Generic(format!( + "{}no private key stored for 02abcd", + rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX + ))), + ) + .into(); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + assert_eq!(result.consensus_code, 0); + assert_eq!( + result.consensus_kind, + PlatformWalletFFIConsensusErrorKind::None + ); + } + + /// The kind values are ABI, mirrored by hand in the Swift and Kotlin + /// hosts, so pin them rather than trusting declaration order. + #[test] + fn should_pin_the_consensus_kind_discriminants() { + assert_eq!(PlatformWalletFFIConsensusErrorKind::None as i32, 0); + assert_eq!(PlatformWalletFFIConsensusErrorKind::Basic as i32, 1); + assert_eq!(PlatformWalletFFIConsensusErrorKind::Signature as i32, 2); + assert_eq!(PlatformWalletFFIConsensusErrorKind::Fee as i32, 3); + assert_eq!(PlatformWalletFFIConsensusErrorKind::State as i32, 4); + } + /// Read a result's message back as an owned `String`. Every /// marketplace assertion below inspects the message, and the raw /// `CStr::from_ptr` dance is noise at each site. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index e5c086a1cca..f0570b466d4 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -792,7 +792,7 @@ fn catch_funding_panic( /// `From for PlatformWalletFFIResult` impl in /// [`crate::error`], so `e.into()` also carries each typed `Display` /// rendering verbatim — the structured figures ride the message string or -/// not at all (`PlatformWalletFFIResult` is ABI-frozen at code + message). +/// not at all (`PlatformWalletFFIResult` has no per-error value fields). /// /// - `AssetLockAlreadyConsumed` -> `ErrorAssetLockAlreadyConsumed` (24). The /// wallet retains nonterminal consumption-unknown state; the host must not diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 421f8bb533c..d19d2aa401d 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,5 +1,6 @@ use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; +use dpp::consensus::ConsensusError; use dpp::fee::Credits; use dpp::identifier::Identifier; use dpp::prelude::{AddressNonce, CoreBlockHeight}; @@ -806,6 +807,21 @@ pub enum PlatformWalletError { #[error("Token operation failed: {0}")] TokenError(String), + /// A token state transition failed inside the SDK. Unlike + /// [`Self::TokenError`] it keeps the `dash_sdk::Error` instead of + /// rendering it, so a consensus rejection stays reachable through + /// [`Self::consensus_error`] and the FFI boundary can hand hosts its + /// numeric code rather than leaving them to match the message. The + /// `Display` text is what `TokenError` rendered for the same failure. + /// Build it with [`Self::token_operation_failed`]. + #[error("Token operation failed: Token {operation} failed: {source}")] + TokenOperationFailed { + /// The operation as it reads in the message: `claim`, `mint`, … + operation: &'static str, + #[source] + source: dash_sdk::Error, + }, + #[error("Timed out waiting for finality proof for outpoint {0}")] /// IS-lock did not propagate within `wait_for_proof`'s deadline. /// Carries the outpoint (not just the txid) so the caller can @@ -1002,6 +1018,35 @@ impl PlatformWalletError { pub fn from_restore_failure(source: PlatformWalletError) -> Self { Self::PersisterRestore(Box::new(source)) } + + /// A token state transition failed in the SDK. + /// + /// A structured key-unavailable signer failure is kept verbatim under + /// [`Self::Sdk`] so the FFI boundary can still restore code 31 (see + /// [`preserve_signer_key_unavailable_or`]); every other failure becomes + /// [`Self::TokenOperationFailed`], which keeps `source` rather than + /// formatting it away. + pub fn token_operation_failed(operation: &'static str, source: dash_sdk::Error) -> Self { + preserve_signer_key_unavailable_or(source, |source| Self::TokenOperationFailed { + operation, + source, + }) + } + + /// The consensus error Platform rejected the operation with, when this + /// error still carries the `dash_sdk::Error` that holds one. + /// + /// `None` for the variants that stringified their cause, and for the + /// typed promotions (`AddressNonceMismatch`, `DocumentNotForSale`, …), + /// which keep the values they need and have FFI codes of their own. + pub fn consensus_error(&self) -> Option<&ConsensusError> { + match self { + Self::Sdk(source) | Self::TokenOperationFailed { source, .. } => { + consensus_error_of(source) + } + _ => None, + } + } } /// Check whether an SDK error indicates that an InstantSend lock proof was @@ -1012,7 +1057,6 @@ impl PlatformWalletError { /// (typically because the quorum that signed it has rotated out). pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool { use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; matches!( consensus_error_of(error), @@ -1028,7 +1072,6 @@ pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool { /// named a height the transaction was not mined at. pub fn is_asset_lock_proof_transaction_height_invalid(error: &dash_sdk::Error) -> bool { use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; matches!( consensus_error_of(error), @@ -1094,7 +1137,6 @@ pub fn as_asset_lock_proof_cl_height_too_low( error: &dash_sdk::Error, ) -> Option<&dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError> { use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; let consensus_error = match error { dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { @@ -1122,7 +1164,6 @@ pub fn as_asset_lock_proof_cl_height_too_low( /// 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) => { @@ -1172,7 +1213,7 @@ pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWal /// matchers below; the same coverage caveat as /// [`as_asset_lock_proof_cl_height_too_low`] applies (re-audit when /// `dash_sdk::Error` gains consensus-carrying variants). -fn consensus_error_of(error: &dash_sdk::Error) -> Option<&dpp::consensus::ConsensusError> { +fn consensus_error_of(error: &dash_sdk::Error) -> Option<&ConsensusError> { match error { dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { broadcast_err.cause.as_ref() @@ -1197,7 +1238,6 @@ pub fn is_asset_lock_already_consumed( out_point: &dashcore::OutPoint, ) -> bool { use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; matches!( consensus_error_of(error), @@ -1223,7 +1263,6 @@ pub fn is_asset_lock_already_consumed( /// in charge. pub fn promote_document_trade_error(error: &dash_sdk::Error) -> Option { use dpp::consensus::state::state_error::StateError; - use dpp::consensus::ConsensusError; match consensus_error_of(error)? { ConsensusError::StateError(StateError::DocumentNotForSaleError(e)) => { @@ -1319,6 +1358,109 @@ pub fn preserve_signer_key_unavailable_or( } } +#[cfg(test)] +mod token_operation_failed_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::codes::ErrorWithCode; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::state::token::TokenOncePerIdentityDistributionAlreadyClaimedError; + + fn already_claimed() -> ConsensusError { + ConsensusError::from(TokenOncePerIdentityDistributionAlreadyClaimedError::new( + Identifier::from([1u8; 32]), + Identifier::from([2u8; 32]), + 1_758_140_722_000, + )) + } + + /// The wait-stream shape a state rejection arrives in: the claim was + /// accepted by CheckTx and rejected at block execution. + fn broadcast_rejection(cause: ConsensusError) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: cause.code(), + message: cause.to_string(), + cause: Some(cause), + }) + } + + #[test] + fn should_keep_the_consensus_error_of_a_rejected_token_operation() { + let error = PlatformWalletError::token_operation_failed( + "claim", + broadcast_rejection(already_claimed()), + ); + + let consensus_error = error.consensus_error().expect("consensus error kept"); + assert_eq!(consensus_error.code(), 40722); + assert!(matches!( + consensus_error, + ConsensusError::StateError( + StateError::TokenOncePerIdentityDistributionAlreadyClaimedError(_) + ) + )); + } + + /// The CheckTx shape, and the dapi-client's exhausted-retry envelope + /// around it. + #[test] + fn should_find_the_consensus_error_in_every_sdk_error_shape() { + let check_tx = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + already_claimed(), + ))); + let enveloped = + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(dash_sdk::Error::Protocol( + dpp::ProtocolError::ConsensusError(Box::new(already_claimed())), + ))); + + for source in [check_tx, enveloped] { + let error = PlatformWalletError::token_operation_failed("claim", source); + assert_eq!(error.consensus_error().map(|e| e.code()), Some(40722)); + } + // An SDK error propagated with `?` carries it too. + let propagated = PlatformWalletError::from(broadcast_rejection(already_claimed())); + assert_eq!(propagated.consensus_error().map(|e| e.code()), Some(40722)); + } + + /// Hosts and logs already see this text for a failed token operation, so + /// keeping the SDK error must not reword it. + #[test] + fn should_render_the_text_token_error_rendered() { + let source = || dash_sdk::Error::Generic("boom".to_string()); + let kept = PlatformWalletError::token_operation_failed("claim", source()); + let stringified = + PlatformWalletError::TokenError(format!("Token claim failed: {}", source())); + + assert_eq!(kept.to_string(), stringified.to_string()); + } + + #[test] + fn should_have_no_consensus_error_without_a_consensus_rejection() { + let transport = PlatformWalletError::token_operation_failed( + "claim", + dash_sdk::Error::Generic("boom".to_string()), + ); + assert!(transport.consensus_error().is_none()); + + // A rejection that was rendered into a string is gone for good, however + // much its text looks like one. + let stringified = PlatformWalletError::TokenError(already_claimed().to_string()); + assert!(stringified.consensus_error().is_none()); + } + + /// The key-unavailable signer failure keeps its own route to code 31. + #[test] + fn should_leave_a_key_unavailable_signer_failure_under_sdk() { + let error = PlatformWalletError::token_operation_failed( + "claim", + dash_sdk::Error::Protocol(dpp::ProtocolError::Generic(format!( + "{SIGNER_KEY_UNAVAILABLE_PREFIX}no private key stored for 02abcd" + ))), + ); + assert!(matches!(error, PlatformWalletError::Sdk(_))); + } +} + #[cfg(test)] mod signer_key_unavailable_tests { use super::*; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs index 80968d1d274..ad92058d0a3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs @@ -77,13 +77,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token burn failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("burn", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs index 06a1d2b94f9..e4fac83faf6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs @@ -73,13 +73,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token claim failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("claim", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs index e1730f3a0cd..67dca9b15d1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs @@ -87,13 +87,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token destroy frozen funds failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("destroy frozen funds", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs index 85ffe4cff90..51861bfb9c4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs @@ -80,13 +80,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token freeze failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("freeze", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs index b4298a97f34..a7fec47add6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs @@ -133,13 +133,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token mint failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("mint", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs index 4772e8c2bcd..0a2bc4ab48b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs @@ -79,13 +79,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token pause failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("pause", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs index 4344f27a144..5e0f11a1f22 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs @@ -78,13 +78,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token purchase failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("purchase", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs index e787f0cea47..d7ac7d90bb2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs @@ -78,13 +78,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token resume failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("resume", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs index 7a6c2473c4d..842a8f89f81 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs @@ -103,13 +103,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token set price failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("set price", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs index d516eb79c12..c1ad8e58a4a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs @@ -82,13 +82,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token transfer failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("transfer", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs index 52a86c38834..f43ac1ab532 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs @@ -82,13 +82,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token unfreeze failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("unfreeze", e)) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs index 61a6c1b6403..eadb9b0cb72 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs @@ -86,13 +86,8 @@ impl IdentityWallet { None, ) .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the FFI - // boundary can still restore code 31; only genuine operation - // failures get stringified into `TokenError`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::TokenError(format!("Token config update failed: {}", e)) - }) - }) + // Keeps the SDK error, so a consensus rejection reaches the FFI + // boundary with its code instead of as rendered text. + .map_err(|e| PlatformWalletError::token_operation_failed("config update", e)) } } diff --git a/packages/rs-unified-sdk-jni/src/support.rs b/packages/rs-unified-sdk-jni/src/support.rs index 00ded475ff9..4bdca664c06 100644 --- a/packages/rs-unified-sdk-jni/src/support.rs +++ b/packages/rs-unified-sdk-jni/src/support.rs @@ -1,10 +1,12 @@ //! Shared JNI plumbing: JVM caching, panic guards, exception throwing. use dash_network::ffi::FFINetwork; -use jni::objects::JThrowable; +use jni::objects::{JThrowable, JValue}; +use jni::sys::jint; use jni::{JNIEnv, JavaVM}; use platform_wallet_ffi::error::{ - platform_wallet_ffi_result_free, PlatformWalletFFIResult, PlatformWalletFFIResultCode, + platform_wallet_ffi_result_free, PlatformWalletFFIConsensusErrorKind, PlatformWalletFFIResult, + PlatformWalletFFIResultCode, }; use std::ffi::CStr; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -59,6 +61,18 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - if result.code == PlatformWalletFFIResultCode::Success { return false; } + throw_pwffi_result(env, &result); + // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. + unsafe { platform_wallet_ffi_result_free(&mut result) }; + true +} + +/// Throw the `DashSDKException` for a non-`Success` `result`, leaving its +/// message for the caller to free. The exception code is the result code +/// shifted by [`PWFFI_CODE_OFFSET`]; a result that carries a consensus +/// rejection (`consensus_code != 0`) hands its code and kind to the exception +/// as well, so Kotlin can branch on them instead of on the message. +pub fn throw_pwffi_result(env: &mut JNIEnv, result: &PlatformWalletFFIResult) { let message = if result.message.is_null() { format!("platform-wallet error (code {})", result.code as i32) } else { @@ -67,10 +81,18 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - .to_string_lossy() .into_owned() }; - throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); - // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. - unsafe { platform_wallet_ffi_result_free(&mut result) }; - true + let code = result.code as i32 + PWFFI_CODE_OFFSET; + if result.consensus_code == 0 { + throw_sdk_exception(env, code, &message); + } else { + throw_sdk_consensus_exception( + env, + code, + &message, + result.consensus_code, + result.consensus_kind, + ); + } } /// The process-wide JVM, cached in [`crate::JNI_OnLoad`]. Callback @@ -84,6 +106,41 @@ pub const SDK_EXCEPTION_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DashSDKExc /// `RuntimeException` if the class or constructor lookup fails (e.g. the /// library is loaded outside the Kotlin SDK). pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { + throw_sdk_exception_with(env, code, message, "(ILjava/lang/String;)V", &[]); +} + +/// Throw `DashSDKException(code, message, consensusCode, consensusKind)` for +/// a failure that is a consensus rejection. `consensus_kind` crosses as its +/// discriminant, which Kotlin's `ConsensusErrorKind.fromNative` decodes. Same +/// `RuntimeException` fallback as [`throw_sdk_exception`]. +pub fn throw_sdk_consensus_exception( + env: &mut JNIEnv, + code: i32, + message: &str, + consensus_code: u32, + consensus_kind: PlatformWalletFFIConsensusErrorKind, +) { + // Consensus codes top out in the 40000s, far inside a `jint`; one that + // somehow is not goes out as `jint::MAX` rather than wrapping negative. + let consensus_code = jint::try_from(consensus_code).unwrap_or(jint::MAX); + throw_sdk_exception_with( + env, + code, + message, + "(ILjava/lang/String;II)V", + &[consensus_code, consensus_kind as jint], + ); +} + +/// Construct `DashSDKException` through the constructor `signature` names, +/// passing `code`, `message` and then `extra`, and throw it. +fn throw_sdk_exception_with( + env: &mut JNIEnv, + code: i32, + message: &str, + signature: &str, + extra: &[jint], +) { // If an exception is already pending we must not call further JNI // functions that would themselves throw. if env.exception_check().unwrap_or(false) { @@ -91,11 +148,9 @@ pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { } let thrown = (|| -> jni::errors::Result<()> { let jmsg = env.new_string(message)?; - let obj = env.new_object( - SDK_EXCEPTION_CLASS, - "(ILjava/lang/String;)V", - &[code.into(), (&jmsg).into()], - )?; + let mut args: Vec = vec![code.into(), (&jmsg).into()]; + args.extend(extra.iter().map(|value| JValue::from(*value))); + let obj = env.new_object(SDK_EXCEPTION_CLASS, signature, &args)?; env.throw(JThrowable::from(obj)) })(); if thrown.is_err() { diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c9880a59b48..10396a29939 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -42,7 +42,7 @@ use crate::events::{build_event_extension, build_event_vtable, KotlinEventCtx}; use crate::persistence::{build_extension, build_vtable, KotlinPersistenceCtx}; -use crate::support::{guard, take_pwffi_error, throw_sdk_exception, PWFFI_CODE_OFFSET}; +use crate::support::{guard, take_pwffi_error, throw_pwffi_result, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::{ jboolean, jbyteArray, jdoubleArray, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, @@ -3889,14 +3889,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w /// Success-coded message first (so they can't hand the result to /// `take_pwffi_error`, which treats Success as "no error, nothing to free"). fn throw_pwffi(env: &mut JNIEnv, result: &mut PlatformWalletFFIResult) { - let message = if result.message.is_null() { - format!("platform-wallet error (code {})", result.code as i32) - } else { - unsafe { CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned() - }; - throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); + throw_pwffi_result(env, result); unsafe { platform_wallet_ffi_result_free(result) }; } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 77908039238..23729b1aa92 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -967,7 +967,7 @@ public class PlatformWalletManager: ObservableObject { "ffi_code": .integer(Int64(result.code.rawValue)), "step": .publicText(name), ], - error: PlatformWalletError(code: result.code, message: result.message) + error: PlatformWalletError(result: result) ) } return .init(name: name, ffiCode: result.code.rawValue, milliseconds: ms) @@ -1462,10 +1462,10 @@ public class PlatformWalletManager: ObservableObject { "off_main_thread": .boolean(offMain), "source": .publicText("mnemonic"), ], - error: PlatformWalletError(code: result.code, message: result.message), + error: PlatformWalletError(result: result), redacting: [params.mnemonic] ) - return .failure(PlatformWalletError(code: result.code, message: result.message)) + return .failure(PlatformWalletError(result: result)) } SDKLogger.event( "wallet_create_completed", @@ -1745,10 +1745,10 @@ public class PlatformWalletManager: ObservableObject { "off_main_thread": .boolean(offMain), "phase": .publicText("bulk_load"), ], - error: PlatformWalletError(code: bulk.code, message: bulk.message) + error: PlatformWalletError(result: bulk) ) return OffMainLoadOutcome( - bulkResult: .failure(PlatformWalletError(code: bulk.code, message: bulk.message)), + bulkResult: .failure(PlatformWalletError(result: bulk)), lookups: []) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index a539c5d060a..2827c6921b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -106,8 +106,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// Do not present "try a smaller amount" to the user on the drain path. /// /// The structured `available` / `required` duff amounts travel in the - /// message string — `PlatformWalletFFIResult` is ABI-frozen at code + - /// message, so there are no out-params for them. + /// message string: `PlatformWalletFFIResult` carries no per-error value + /// fields, so there are no out-params for them. /// /// Distinct from `errorCoreInsufficientFunds` (22), which is the atomic /// Core-send selector rather than the asset-lock builder. What the figures @@ -391,6 +391,65 @@ public enum PlatformWalletResultCode: Int32, Sendable { } } +// MARK: - Consensus rejection + +/// Platform's own verdict on a state transition it refused. +/// +/// Drive answers a rejected transition with an rs-dpp consensus error: its +/// numeric `code` names the exact rule that refused it (40722 is a second +/// once-per-identity token claim), and its `kind` says which family of rule +/// that was. Rust reads both off the consensus error and puts them on the FFI +/// result, so a host branches on the code rather than recognising the +/// rejection in its rendered text. +public struct PlatformConsensusError: Equatable, Sendable { + /// The family a consensus rejection belongs to. + /// + /// rs-dpp groups its codes by family (basic 1xxxx, signature 2xxxx, fee + /// 3xxxx, state 4xxxx) and hands the grouping over as a value of its own, + /// so this side never derives it from the digits of `code`. Mirror of + /// `PlatformWalletFFIConsensusErrorKind`. + public enum Kind: Equatable, Sendable { + case basic + case signature + case fee + case state + + /// `nil` for the FFI's `None` value, which pairs with + /// `consensus_code == 0`, and for any family a newer Rust side adds + /// before this mirror learns it. + init?(ffi: PlatformWalletFFIConsensusErrorKind) { + switch ffi { + case PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_BASIC: self = .basic + case PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_SIGNATURE: self = .signature + case PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_FEE: self = .fee + case PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_STATE: self = .state + default: return nil + } + } + } + + /// The rs-dpp consensus error code + /// (`packages/rs-dpp/src/errors/consensus/codes.rs`). Always 10000 or + /// above: 0 is the FFI's "no rejection" sentinel and never reaches here. + public let code: UInt32 + + /// Which family of consensus rule refused the transition. + public let kind: Kind + + public init(code: UInt32, kind: Kind) { + self.code = code + self.kind = kind + } + + /// The rejection an FFI result carries, or `nil` when it carries none. + init?(ffi: PlatformWalletFFIResult) { + guard ffi.consensus_code != 0, let kind = Kind(ffi: ffi.consensus_kind) else { + return nil + } + self.init(code: ffi.consensus_code, kind: kind) + } +} + // MARK: - Class wrapper /// Reference-counted wrapper around a `PlatformWalletFFIResult` @@ -423,6 +482,12 @@ final class PlatformWalletResult { inner.message.map { String(cString: $0) } } + /// Platform's verdict when the failure was a consensus rejection, read + /// from the result's own fields rather than from `message`. + var consensusError: PlatformConsensusError? { + PlatformConsensusError(ffi: inner) + } + var isSuccess: Bool { code == .success } @@ -652,8 +717,30 @@ public enum PlatformWalletError: LocalizedError { /// retryable — unlike `reservationWalletMismatch`, no other generation holds /// this payment either. case notFound(String) + /// Platform refused the state transition, and the wallet layer has no + /// dedicated case for that particular refusal. Carries the rs-dpp + /// consensus code and family, so a host can branch on the exact rule that + /// refused it (`error.consensusError?.code == 40722` is a second + /// once-per-identity token claim), plus the rendered message `.unknown` + /// would have carried. + /// + /// Only the catch-all FFI code reaches here. A rejection the wallet layer + /// promotes to a code of its own (a changed listing price, an + /// address-nonce race) keeps its typed case, which already carries the + /// values that case exists for. + case consensusRejection(PlatformConsensusError, String) case unknown(String) + /// Platform's verdict when this error is a consensus rejection the wallet + /// layer left untyped, so a caller can write + /// `error.consensusError?.code == 40722` without pattern matching. `nil` + /// for every other case, including the typed promotions of specific + /// rejections. + public var consensusError: PlatformConsensusError? { + guard case .consensusRejection(let consensus, _) = self else { return nil } + return consensus + } + /// What to show a person. For most cases this is still the diagnostic /// detail Rust attached to the originating `PlatformWalletFFIResult` (or /// the context string a Swift-side guard chose when constructing the @@ -688,6 +775,7 @@ public enum PlatformWalletError: LocalizedError { .notForSale(let m), .assetLockInputConflict(let m), .assetLockInputContested(let m), + .consensusRejection(_, let m), .notFound(let m), .unknown(let m): return m // The persister messages are a nested Rust error chain naming the @@ -738,13 +826,26 @@ public enum PlatformWalletError: LocalizedError { } init(result: PlatformWalletResult) { - self.init(code: result.code, message: result.message) + self.init( + code: result.code, + message: result.message, + consensus: result.consensusError + ) } /// Internal seam for exercising the stable error-code/detail contract /// without manufacturing a Rust-owned `PlatformWalletFFIResult` string. /// Production callers continue to enter through `init(result:)`. - init(code: PlatformWalletResultCode, message: String?) { + /// + /// `consensus` is Platform's verdict when the result carried one, and is + /// consulted on the catch-all code alone: every other code is a + /// classification the wallet layer chose deliberately and must keep + /// reaching its own case. + init( + code: PlatformWalletResultCode, + message: String?, + consensus: PlatformConsensusError? = nil + ) { let detail = message ?? "" switch code { case .success: @@ -866,7 +967,16 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedRecoveryKeysRequired: self = .shieldedRecoveryKeysRequired(detail) case .notFound: self = .notFound(detail) - case .errorUnknown: self = .unknown(detail) + // The catch-all, which is where a consensus rejection with no + // dedicated code lands. When Rust stamped its verdict on the result, + // keep it: a host that has to tell one rejection from another gets + // the code instead of the rendered sentence. + case .errorUnknown: + if let consensus { + self = .consensusRejection(consensus, detail) + } else { + self = .unknown(detail) + } } } } @@ -876,7 +986,7 @@ public enum PlatformWalletError: LocalizedError { /// Decoders for the stable JSON detail objects that FFI result codes /// 38/39/40 put in the result `message`. /// -/// `PlatformWalletFFIResult` is ABI-frozen at `{ code, message }`, so the +/// `PlatformWalletFFIResult` carries no per-error value fields, so the /// wallet layer's typed values (both prices, both credit amounts, the /// contest end time) can only cross as a documented JSON object. Each /// decoder returns `nil` on anything that isn't that object, and the @@ -921,6 +1031,23 @@ private enum TradeErrorDetail { // MARK: - Convenience extensions extension PlatformWalletFFIResult { + /// A result built from the `{ code, message }` pair alone, carrying no + /// consensus verdict: the same defaults Rust's own `ok()` / `err()` + /// constructors fill in. + /// + /// Swift-side call sites that fabricate a result (the manager's + /// null-pointer guard, the native call doubles in the tests) describe a + /// failure this side invented, which by definition is not a Platform + /// rejection, so they say only what they mean. + init(code: PlatformWalletFFIResultCode, message: UnsafeMutablePointer?) { + self.init( + code: code, + message: message, + consensus_code: 0, + consensus_kind: PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_NONE + ) + } + @inline(__always) func check() throws { try PlatformWalletResult(self).throwIfError() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift index 3a4d722903c..29a2ab88705 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift @@ -72,69 +72,28 @@ final class OncePerIdentityClaimStore: OncePerIdentityClaimRecording, @unchecked /// Recognises Drive's "this identity already claimed" rejection in the error /// a claim submission throws. /// -/// What reaches Swift today is text: the FFI hands back a -/// `PlatformWalletError` carrying a rendered message, and rs-drive-abci puts -/// the consensus error's `Display` text in it, which rs-dpp spells -/// "identity '' already claimed the once-per-identity distribution of -/// token '' at ". That phrase is the primary signal, with the type -/// name as a second one in case a path renders the variant rather than its -/// message. +/// A claim rejected by Platform arrives as a `PlatformWalletError` carrying +/// Platform's own verdict: `consensusError` holds the rs-dpp consensus code +/// and family that Rust read off the rejection and passed across the FFI +/// boundary. The already-claimed rejection is state error 40722, and rs-dpp +/// codes are unique across the families, so recognising it is an equality +/// check on that one number. /// -/// The consensus code is matched as well, for parity with Android. No error -/// text carries it today, because nothing on the way here renders the code -/// next to the message, but a plumbing change that starts surfacing it (a -/// structured FFI detail, a broadcast error that prints its `code`) then -/// works without a follow-up here. +/// Nothing here reads the message. Claim errors quote token amounts and +/// millisecond timestamps, so a claim time such as 1758140722000 contains the +/// digits of the code, and the prose around them is rs-dpp's to reword. /// /// A miss is not fatal in either direction: missing the rejection only means -/// the kind stays offered until the next attempt, and a false positive is -/// guarded against by the signals being specific, the code included. +/// the kind stays offered until the next attempt, and a wrong hit would take +/// a different rejection arriving under this code. enum OncePerIdentityClaimRejection { /// Consensus code of `TokenOncePerIdentityDistributionAlreadyClaimedError` /// (rs-dpp `errors/consensus/codes.rs`), the state error Drive returns /// for a second claim by the same identity. - static let alreadyClaimedConsensusCode = "40722" - - private static let messageSignals = [ - "already claimed the once-per-identity distribution", - "tokenonceperidentitydistributionalreadyclaimederror" - ] + static let alreadyClaimedConsensusCode: UInt32 = 40722 static func isAlreadyClaimed(_ error: Error) -> Bool { - let message = error.localizedDescription.lowercased() - if messageSignals.contains(where: { message.contains($0) }) { - return true - } - return containsStandaloneNumber(alreadyClaimedConsensusCode, in: message) - } - - /// Whether `number` occurs in `text` with no digit on either side, the - /// equivalent of the regular expression `(? Bool { - var searchStart = text.startIndex - while let range = text.range(of: number, range: searchStart.. text.startIndex - && isASCIIDigit(text[text.index(before: range.lowerBound)]) - let digitAfter = range.upperBound < text.endIndex - && isASCIIDigit(text[range.upperBound]) - if !digitBefore && !digitAfter { - return true - } - // Overlapping occurrences matter as little here as anywhere, but - // stepping one character keeps a later standalone match findable - // after a rejected one. - searchStart = text.index(after: range.lowerBound) - } - return false - } - - private static func isASCIIDigit(_ character: Character) -> Bool { - character.isASCII && character.isNumber + guard let walletError = error as? PlatformWalletError else { return false } + return walletError.consensusError?.code == alreadyClaimedConsensusCode } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift index 35363a0615f..5093a52858e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift @@ -485,82 +485,73 @@ final class TokenClaimResolverTests: XCTestCase { XCTAssertTrue(reopened.hasClaimed(token: token, identity: identity)) } - /// Drive's rejection is recognised from the message, which is all the - /// FFI hands back for a consensus error today. - func testAlreadyClaimedRejectionIsRecognisedFromTheMessage() { - let rejection = PlatformWalletError.unknown( + /// Drive's rejection is recognised from the consensus code the FFI hands + /// over beside the message. + func testAlreadyClaimedRejectionIsRecognisedFromTheConsensusCode() { + let rejection = PlatformWalletError.consensusRejection( + PlatformConsensusError(code: 40722, kind: .state), """ - Token claim failed: state transition broadcast error: Token claim error: identity \ - '5r5MYEznyc9UtKQZpmM1DUisVDwtPhUpaBxNpeTBQEHi' already claimed the once-per-identity \ - distribution of token '6vk7Xk3dLFdBfNkAvj6vSpk6NNAoBEMRkGbdBHSqzmPu' at 1750000000000 + Token operation failed: Token claim failed: state transition broadcast error: \ + identity '5r5MYEznyc9UtKQZpmM1DUisVDwtPhUpaBxNpeTBQEHi' already claimed the \ + once-per-identity distribution of token \ + '6vk7Xk3dLFdBfNkAvj6vSpk6NNAoBEMRkGbdBHSqzmPu' at 1750000000000 """ ) XCTAssertTrue(OncePerIdentityClaimRejection.isAlreadyClaimed(rejection)) + XCTAssertEqual(OncePerIdentityClaimRejection.alreadyClaimedConsensusCode, 40722) + } - XCTAssertTrue( - OncePerIdentityClaimRejection.isAlreadyClaimed( - PlatformWalletError.unknown( - "TokenOncePerIdentityDistributionAlreadyClaimedError { token_id: .. }" - ) + /// Any other rejection is a different code, including the neighbours in + /// the token block: recording a claim that never happened would hide the + /// kind from that identity for good, since the record is never cleared. + func testOtherConsensusCodesAreNotTheAlreadyClaimedRejection() { + let others: [UInt32] = [40721, 40723, 40108, 10000] + for code in others { + XCTAssertFalse( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.consensusRejection( + PlatformConsensusError(code: code, kind: .state), + "Token operation failed: Token claim failed" + ) + ), + "consensus code \(code) is not the already-claimed rejection" ) - ) + } + + // A failure that never reached Platform carries no verdict at all. XCTAssertFalse( OncePerIdentityClaimRejection.isAlreadyClaimed( - PlatformWalletError.unknown( - "Token claim failed: Token claim error: no current rewards" - ) + PlatformWalletError.unknown("Token operation failed: Token claim failed: timeout") ) ) + struct SomeOtherError: Error {} + XCTAssertFalse(OncePerIdentityClaimRejection.isAlreadyClaimed(SomeOtherError())) } - /// The consensus code counts as a signal too, for parity with Android, - /// but only where it stands as a number of its own. - func testAlreadyClaimedRejectionIsRecognisedFromTheConsensusCode() { - let recognised = [ + /// Text is not a signal. rs-dpp owns the wording, claim errors quote + /// millisecond timestamps whose digits contain the code, and an error + /// that only describes the rejection did not come from Platform refusing + /// this claim. + func testRenderedTextAloneIsNotTheAlreadyClaimedRejection() { + let messagesThatOnlyDescribeIt = [ + """ + Token operation failed: Token claim failed: identity \ + '5r5MYEznyc9UtKQZpmM1DUisVDwtPhUpaBxNpeTBQEHi' already claimed the once-per-identity \ + distribution of token '6vk7Xk3dLFdBfNkAvj6vSpk6NNAoBEMRkGbdBHSqzmPu' at 1750000000000 + """, + "TokenOncePerIdentityDistributionAlreadyClaimedError { token_id: .. }", "Token claim failed: consensus error 40722: claim rejected", - "Token claim failed: code=40722", - "Token claim failed: broadcast rejected (code 40722)" + "Token claim failed: 40722 at 1758140722000", + "Token mint past max supply: 1758140722000" ] - for message in recognised { - XCTAssertTrue( - OncePerIdentityClaimRejection.isAlreadyClaimed( - PlatformWalletError.unknown(message) - ), - "should recognise the standalone code in: \(message)" - ) - } - } - - /// The digits of the code inside a longer number are not the code. Claim - /// errors quote millisecond timestamps and token amounts, and reading one - /// of those as the rejection would record a claim that never happened and - /// hide the kind from that identity permanently. - func testDigitsOfTheConsensusCodeInsideALongerNumberAreNotTheCode() { - let notRecognised = [ - "Token mint past max supply: 1758140722000", - "Token claim failed: amount 4072299", - "Token claim failed: identity balance 407220 is too low", - "Token claim failed: at 40722000" - ] - for message in notRecognised { + for message in messagesThatOnlyDescribeIt { XCTAssertFalse( OncePerIdentityClaimRejection.isAlreadyClaimed( PlatformWalletError.unknown(message) ), - "should not read a longer number as the code in: \(message)" + "text must not stand in for the consensus code: \(message)" ) } - - // The same message with the code standing on its own still counts, - // so the guard above is about digit boundaries and not about the - // words around them. - XCTAssertTrue( - OncePerIdentityClaimRejection.isAlreadyClaimed( - PlatformWalletError.unknown( - "Token claim failed: 40722 at 1758140722000" - ) - ) - ) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index b4cf8f404f5..d9cba56b996 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -58,7 +58,7 @@ final class ErrorHandlingTests: XCTestCase { ) // The structured available/required duffs ride the message string — - // PlatformWalletFFIResult is ABI-frozen at code + message — so the + // PlatformWalletFFIResult carries no per-error value fields, so the // typed error must carry them through unaltered. let rendered = "asset lock coin selection is short: available 18000000 duffs, " + "required 100000000 duffs" @@ -194,6 +194,97 @@ final class ErrorHandlingTests: XCTestCase { } } + // MARK: - Consensus rejections + + /// The four consensus families decode from their generated C constants, + /// and the `None` value is absence rather than a family: it is what + /// pairs with `consensus_code == 0`. + func testShouldMapEveryConsensusErrorKindFromFFI() { + let mappings: [(PlatformWalletFFIConsensusErrorKind, PlatformConsensusError.Kind)] = [ + (PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_BASIC, .basic), + (PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_SIGNATURE, .signature), + (PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_FEE, .fee), + (PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_STATE, .state), + ] + for (ffi, expected) in mappings { + XCTAssertEqual(PlatformConsensusError.Kind(ffi: ffi), expected) + } + XCTAssertNil( + PlatformConsensusError.Kind(ffi: PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_NONE) + ) + } + + /// A rejection Rust left on the catch-all code reaches the host as the + /// typed consensus case, carrying Platform's own code and family plus the + /// message the `.unknown` case would have carried. 40722 is the second + /// once-per-identity token claim, which the example app branches on. + func testShouldSurfaceAConsensusRejectionOnTheCatchAllCode() { + let rendered = "Token operation failed: Token claim failed: identity already claimed" + let error = PlatformWalletError( + code: .errorUnknown, + message: rendered, + consensus: PlatformConsensusError(code: 40722, kind: .state) + ) + + guard case .consensusRejection(let consensus, let message) = error else { + return XCTFail("expected typed consensusRejection error, got \(error)") + } + XCTAssertEqual(consensus.code, 40722) + XCTAssertEqual(consensus.kind, .state) + XCTAssertEqual(message, rendered) + XCTAssertEqual(error.errorDescription, rendered) + // The accessor is what callers branch on, so it must agree. + XCTAssertEqual(error.consensusError, consensus) + } + + /// The catch-all without a verdict stays `.unknown`, and a dedicated code + /// keeps its own case even when a verdict rides along: the wallet layer + /// chose that classification and it carries what the case exists for. + func testShouldNotTurnEveryFailureIntoAConsensusRejection() { + let plain = PlatformWalletError(code: .errorUnknown, message: "timed out") + guard case .unknown = plain else { + return XCTFail("a catch-all with no verdict must stay unknown, got \(plain)") + } + XCTAssertNil(plain.consensusError) + + let dedicated = PlatformWalletError( + code: .errorAddressNonceMismatch, + message: "submitted nonce 1, Platform expected 2", + consensus: PlatformConsensusError(code: 40603, kind: .state) + ) + guard case .addressNonceMismatch = dedicated else { + return XCTFail("a dedicated code must keep its case, got \(dedicated)") + } + XCTAssertNil(dedicated.consensusError) + } + + /// The result wrapper reads the verdict off the C struct's own fields, so + /// a result with no rejection reports none rather than a zero code. + func testShouldReadTheConsensusVerdictFromTheFFIResult() { + let rejected = PlatformWalletResult( + PlatformWalletFFIResult( + code: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN, + message: nil, + consensus_code: 40722, + consensus_kind: PLATFORM_WALLET_FFI_CONSENSUS_ERROR_KIND_STATE + ) + ) + XCTAssertEqual( + rejected.consensusError, + PlatformConsensusError(code: 40722, kind: .state) + ) + + // The two-field convenience init is the "this side invented the + // failure" shape, and must not fabricate a rejection. + let invented = PlatformWalletResult( + PlatformWalletFFIResult( + code: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_NULL_POINTER, + message: nil + ) + ) + XCTAssertNil(invented.consensusError) + } + func testPlatformWalletNotFoundFFIResultMapping() { // Code 98 (the blanket Option→result miss) stays typed inside the // wallet-error family — the mapping Kotlin now converges on