Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/kotlin-sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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("(?<![0-9])$ALREADY_CLAIMED_ERROR_CODE(?![0-9])")

/**
* True when [error] is the already-claimed rejection. The native
* layer surfaces consensus errors as text, so match the code and the
* message rs-dpp renders for it.
*/
fun isAlreadyClaimed(error: Throwable): Boolean {
val message = generateSequence(error) { it.cause }
.mapNotNull { it.message }
.joinToString(" ")
return ALREADY_CLAIMED_CODE_PATTERN.containsMatchIn(message) ||
message.contains("already claimed the once-per-identity distribution")
}
fun isAlreadyClaimed(error: Throwable): Boolean =
generateSequence(error) { it.cause }
.filterIsInstance<DashSdkError>()
.any { it.consensusError?.code == ALREADY_CLAIMED_ERROR_CODE }

private fun preferenceKey(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) =
booleanPreferencesKey(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}
Loading
Loading