Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ff25eb6
fix(platform-wallet): typed persister errors with bounded transient r…
lklimek Sep 2, 2026
9500955
docs(platform-wallet): correct the failed-load test's coverage claim
lklimek Sep 2, 2026
b88fdf4
fix(platform-wallet): keep the manager usable after a failed load
lklimek Sep 3, 2026
8f42f61
fix(platform-wallet): remove in-crate store retry; caller decides
lklimek Sep 3, 2026
e50f647
merge(platform-wallet): keep the manager usable after a failed load
lklimek Sep 3, 2026
245e283
merge(platform-wallet): remove in-crate store retry; caller decides
lklimek Sep 3, 2026
45397aa
feat(platform-wallet-ffi): carry the persister retry classification a…
lklimek Sep 3, 2026
c0fc42a
merge(platform-wallet-ffi): carry the persister retry classification …
lklimek Sep 3, 2026
9bdf7c6
test(platform-wallet): route scan-verdict log capture through a globa…
lklimek Sep 3, 2026
7d5bc9a
merge(platform-wallet): fix parallel-test tracing recorder interference
lklimek Sep 3, 2026
20f90f9
refactor(platform-wallet)!: name the persister operation at construct…
lklimek Sep 3, 2026
c4932f7
merge(platform-wallet): named error constructors and transient-as-mis…
lklimek Sep 3, 2026
acd1d2c
test(platform-wallet): assert the poll-loop failure report fires once…
lklimek Sep 3, 2026
1f5b3d2
merge(platform-wallet): assert the poll-loop failure report fires onc…
lklimek Sep 3, 2026
80e8f24
Merge origin/v4.2-dev into feat/platform-wallet-typed-persister-errors
lklimek Sep 3, 2026
0940cf4
docs(platform-wallet): condense the typed-persister-error commentary
lklimek Sep 3, 2026
33f60df
fix(platform-wallet): keep the persister error chain out of the UI
lklimek Sep 4, 2026
e7de2d2
merge(platform-wallet): keep the persister error chain out of the UI
lklimek Sep 4, 2026
a05492c
fix(platform-wallet): type the restore failure, keep the drain, finis…
lklimek Sep 4, 2026
4718d16
merge(platform-wallet): type the restore failure, keep the drain, fin…
lklimek Sep 4, 2026
08767e7
fix(platform-wallet): gate the re-issue promise at the trait, not at …
lklimek Sep 4, 2026
8dd5ec3
merge(platform-wallet): gate the re-issue promise at the trait, not a…
lklimek Sep 4, 2026
7bed9b4
docs(platform-wallet): tell hosts a failed rollback withholds the retry
lklimek Sep 4, 2026
0573a48
fix(kotlin-sdk): fail the load instead of restoring nothing
lklimek Sep 4, 2026
5e5dd0a
merge(kotlin-sdk): fail the load instead of restoring nothing
lklimek Sep 4, 2026
f822bd7
fix(platform-wallet): read before the registration write, claim befor…
lklimek Sep 4, 2026
24f353b
merge(platform-wallet): read before the registration write, claim bef…
lklimek Sep 4, 2026
0dae674
Merge branch 'v4.2-dev' into feat/platform-wallet-typed-persister-errors
lklimek Sep 7, 2026
628e257
Merge branch 'v4.2-dev' into feat/platform-wallet-typed-persister-errors
lklimek Sep 7, 2026
50d6d91
fix(platform-wallet): read persisted state before the wallet is visib…
lklimek Sep 7, 2026
eadbd82
Merge remote-tracking branch 'origin/v4.2-dev' into feat/platform-wal…
lklimek Sep 8, 2026
6cd2a2b
docs(platform-wallet): clarify persistence retry and release contracts
lklimek Sep 8, 2026
730af57
docs(platform-wallet): address remaining review wording
lklimek Sep 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ package org.dashfoundation.dashsdk.errors
import org.dashfoundation.dashsdk.ffi.DashSDKException
import org.json.JSONObject

// Display text for the persister failures, whose native message is a nested
// Rust error chain (operation, backend classification, the store's own
// phrasing) that no user can act on. One string per outcome a person can
// distinguish; a failed read and a failed write must not describe each other.
private const val PERSISTER_BUSY_USER_MESSAGE =
"The wallet database is busy. Try again in a moment."
private const val PERSISTER_UNREADABLE_USER_MESSAGE =
"The wallet data could not be read and may need to be restored."
private const val PERSISTER_UNSAVED_USER_MESSAGE =
"The wallet data could not be saved and may need to be restored."

/**
* Public error hierarchy of the Kotlin SDK — the Android analog of the
* Swift SDK's `UserFacingError`/`SDKError` split, keyed off the native
Expand All @@ -19,6 +30,14 @@ sealed class DashSdkError(
/** Whether retrying the same operation can plausibly succeed. */
open val isRetryable: Boolean get() = false

/**
* Text fit to show a person. Defaults to [message] — most native
* messages read as a sentence — but types whose message is a nested
* error chain override it, so a UI can display this unconditionally
* while logs keep [message].
*/
open val userMessage: String get() = message.orEmpty()

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

Expand Down Expand Up @@ -478,6 +497,93 @@ sealed class DashSdkError(
cause,
)

/**
* `ErrorPersisterLoadTransient` (native code 49). Reading persisted
* wallet state failed on a store that reported the failure as
* retryable (`SQLITE_BUSY` and friends). Nothing was mutated — a
* load is a read — so this is retryable. The Android analog of
* Swift's `PlatformWalletError.persisterLoadTransient`. [message] is
* the diagnostic chain; display [userMessage].
*/
class PersisterLoadTransient(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
override val userMessage: String get() = PERSISTER_BUSY_USER_MESSAGE
}

/**
* `ErrorPersisterLoadFatal` (native code 50). Reading persisted
* wallet state failed permanently — a corrupt or unreadable store,
* or a decode that will fail identically next time. Do NOT retry;
* the store needs repair or re-provisioning. Constraint-class read
* failures fold in here: a read cannot violate one, and neither is
* retryable. [message] is the diagnostic chain; display
* [userMessage].
*/
class PersisterLoadFatal(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val userMessage: String get() = PERSISTER_UNREADABLE_USER_MESSAGE
}

/**
* `ErrorPersisterStoreTransient` (native code 51). Writing wallet
* state failed on a busy or momentarily unavailable store.
*
* **Nothing was committed.** The native side only emits this when
* the persister guarantees the failed changeset round was rolled
* back whole, so re-issuing the operation cannot double-apply part
* of it — which is why this, uniquely among the store failures, is
* retryable. A wallet registration against a locked database
* produces it (dashpay/platform#4365); the retry decision is the
* host's, not the wallet's. [message] is the diagnostic chain;
* display [userMessage].
*/
class PersisterStoreTransient(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
override val userMessage: String get() = PERSISTER_BUSY_USER_MESSAGE
}

/**
* `ErrorPersisterStoreFatal` (native code 52). Writing wallet state
* failed permanently — a full disk, a corrupt schema, an I/O error
* outside the retryable class. Do NOT retry; the wallet rolled its
* in-memory state back, so the operation may be re-attempted once
* the underlying fault is fixed. [message] is the diagnostic chain;
* display [userMessage].
*/
class PersisterStoreFatal(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val userMessage: String get() = PERSISTER_UNSAVED_USER_MESSAGE
}

/**
* `ErrorPersisterStoreConstraint` (native code 53). A write violated
* a constraint / foreign key / integrity rule. Deliberately distinct
* from [PersisterStoreFatal]: this is "the data is wrong" (a caller
* or schema-mapping bug) rather than "the storage engine is unhappy"
* (an operator problem), and the two route to different people. Do
* NOT retry unchanged. [message] is the diagnostic chain; display
* [userMessage].
*/
class PersisterStoreConstraint(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val userMessage: String get() = PERSISTER_UNSAVED_USER_MESSAGE
}

/**
* `ErrorPersisterRestore` (native code 54). Rehydrating persisted
* platform-address state into a freshly registered wallet failed.
* One code rather than three: it wraps a wallet error, not a store
* error, so it carries no retry classification. The wrapped error's
* rendering is in [message], which is diagnostic — display
* [userMessage].
*/
class PersisterRestore(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val userMessage: String get() = PERSISTER_UNREADABLE_USER_MESSAGE
}

/**
* Any other `PlatformWalletFFIResultCode` without a dedicated type.
* Carries the platform-wallet [nativeCode] (already de-offset) and
Expand Down Expand Up @@ -657,6 +763,17 @@ sealed class DashSdkError(
// the deferred-token trio sits at 34-36 above. See
// PlatformWalletFFIResultCode for the authoritative map.)
31 -> PlatformWallet.SigningKeyUnavailable(message, cause)
// Persister failures, operation x retry classification. These are
// exactly the "retry-semantics-bearing" codes this mapping exists
// for: only the two transients are retryable, and a constraint is
// kept apart from a fatal so hosts can route "your data is wrong"
// differently from "the storage engine is unhappy".
49 -> PlatformWallet.PersisterLoadTransient(message, cause)
50 -> PlatformWallet.PersisterLoadFatal(message, cause)
51 -> PlatformWallet.PersisterStoreTransient(message, cause)
52 -> PlatformWallet.PersisterStoreFatal(message, cause)
53 -> PlatformWallet.PersisterStoreConstraint(message, cause)
54 -> PlatformWallet.PersisterRestore(message, cause)
else ->
// @Deprecated fallback — see the code-6 arm; code 31 is the
// real discriminator.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,27 @@ package org.dashfoundation.dashsdk.ffi
* [onWalletChangesetAccountBegin] / [onWalletChangesetAccountEnd].
* - Persist slots return `Int` (0 = ok, non-zero flips the round's
* success flag so [onChangesetEnd] delivers the rollback).
* - A plain non-zero return means "failed, do not retry". A handler that
* can classify its own failure may instead return one of the two
* sentinels `platform-wallet-ffi` defines — [PERSIST_RC_TRANSIENT] for
* a retryable failure after which nothing was applied, or
* [PERSIST_RC_CONSTRAINT] for an integrity violation. The native side
* forwards the classification to its caller (surfacing as
* `DashSdkError.PlatformWallet.PersisterStoreTransient` and friends) and
* never retries on the handler's behalf. Returning the transient
* sentinel from a ROUND callback additionally asserts that a failed
* round is rolled back whole — see `PersistenceCallbacks` in
* `rs-platform-wallet-ffi/src/persistence.rs` for the exact contract.
* - Load slots return flattened representations (`Array<...>` / typed
* holder objects) that the trampoline re-packs into Rust-owned FFI
* structs; Kotlin never allocates native memory.
* structs; Kotlin never allocates native memory. **Only the
* `Int`-returning persist slots can carry a sentinel.** A load has no
* `Int` to put one in, so every load failure — a thrown exception
Comment thread
lklimek marked this conversation as resolved.
* included — reaches Rust as a fatal, unclassified error, and no load
* on this binding can report itself as transient or constraint-class.
* A subclass must therefore let a failed load THROW: returning an empty
* array reports a successful restore of nothing, which Rust reads as a
* fresh device, turning a store fault into apparent data loss.
*
* ## Threading
*
Expand All @@ -50,6 +68,30 @@ package org.dashfoundation.dashsdk.ffi
*/
abstract class NativePersistenceBridge {

companion object {
// Both values are the ABI defined by
// `packages/rs-platform-wallet-ffi/src/persistence.rs` and must
// change only together with it.

/**
* A retryable failure after which nothing was applied. Returning it
* from a callback inside a changeset round also asserts that the
* failed round was rolled back whole.
*
* The round-end callback is the exception: failing it when the round
* had already failed means the rollback itself did not complete, so
* what reached the store is unknown. Rust classifies that as fatal and
* withholds the retry regardless of this value — re-issuing a
* changeset the store could neither apply nor undo risks merging it
* twice. This sentinel is honoured at round end only on a clean
* round, where the commit failed but the rollback succeeded.
*/
const val PERSIST_RC_TRANSIENT: Int = -2

/** A constraint / integrity violation — the data is wrong, not the store. */
const val PERSIST_RC_CONSTRAINT: Int = -3
}

/**
* Versioned semantic capability declaration consumed when JNI builds the
* native callback vtable. Defaults are deliberately zero: a no-op subclass
Expand Down Expand Up @@ -626,6 +668,10 @@ abstract class NativePersistenceBridge {
): Int = 0

// ── Load callbacks ────────────────────────────────────────────────
//
// These return objects rather than `Int`, so [PERSIST_RC_TRANSIENT] and
// [PERSIST_RC_CONSTRAINT] cannot be expressed here: a failing load
// reaches Rust as a fatal, unclassified error however it fails.

/**
* `on_load_wallet_list_fn`. Returns the persisted wallet list as an
Expand Down
Loading
Loading