Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
37 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
b576260
Merge branch 'v4.2-dev' into feat/platform-wallet-typed-persister-errors
lklimek Sep 9, 2026
be98ae8
refactor(platform-wallet): delegate persister load retries to callers
lklimek Sep 9, 2026
930692a
style(drive-abci): format document deletion regression test
lklimek Sep 9, 2026
7ae7848
test(platform-wallet): exercise scan verdict failures through discovery
lklimek Sep 9, 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 @@ -478,6 +478,77 @@ 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`.
*/
class PersisterLoadTransient(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
}

/**
* `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.
*/
class PersisterLoadFatal(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `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.
*/
class PersisterStoreTransient(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
}

/**
* `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.
*/
class PersisterStoreFatal(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `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.
*/
class PersisterStoreConstraint(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `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].
*/
class PersisterRestore(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* Any other `PlatformWalletFFIResultCode` without a dedicated type.
* Carries the platform-wallet [nativeCode] (already de-offset) and
Expand Down Expand Up @@ -657,6 +728,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,6 +32,18 @@ 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 —
* `PLATFORM_WALLET_PERSIST_RC_TRANSIENT` (-2) for a retryable failure
* after which nothing was applied, or
* `PLATFORM_WALLET_PERSIST_RC_CONSTRAINT` (-3) 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,9 @@ class PlatformWalletManager(
* per restorable id to obtain a [ManagedPlatformWallet] handle.
*
* Idempotent: with no persisted state, leaves [wallets] untouched.
*
* On failure the manager is unchanged and still usable — fix the store
* and call again, or destroy the manager and rebuild it.
*/
suspend fun loadPersistedWallets(): List<ManagedPlatformWallet> = withContext(Dispatchers.IO) {
mapNativeErrors { WalletManagerNative.loadFromPersistor(managerHandle) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,43 @@ class DashSdkErrorTest {
)
}

// TODO: not compiled or run locally — no Kotlin/Gradle toolchain in the
// authoring environment. CI is the first execution of this test and of
// the `DashSdkError.PlatformWallet.Persister*` types it covers.
@Test
fun persisterCodes49Through54MapTypedWithCorrectRetryability() {
// The whole point of the persister block: a host must be able to tell
// a busy store from a corrupt one WITHOUT parsing the message. Before
// these codes all three wallet variants flattened to ErrorUnknown and
// the classification died at the boundary.
val cases = listOf(
Triple(49, DashSdkError.PlatformWallet.PersisterLoadTransient::class.java, true),
Triple(50, DashSdkError.PlatformWallet.PersisterLoadFatal::class.java, false),
Triple(51, DashSdkError.PlatformWallet.PersisterStoreTransient::class.java, true),
Triple(52, DashSdkError.PlatformWallet.PersisterStoreFatal::class.java, false),
Triple(53, DashSdkError.PlatformWallet.PersisterStoreConstraint::class.java, false),
Triple(54, DashSdkError.PlatformWallet.PersisterRestore::class.java, false),
)

for ((code, type, retryable) in cases) {
val message = "persistence backend error from code $code"
val mapped = DashSdkError.fromNative(
DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + code, message),
)

assertTrue(
"code $code must not fall through to Generic",
type.isInstance(mapped),
)
assertEquals(message, mapped.message)
assertEquals(
"code $code retryability is part of its contract",
retryable,
mapped.isRetryable,
)
}
}

@Test
fun assetLockInputConflictCode47MapsTyped() {
// TERMINAL and RESERVED: no native path emits it today (that needs a
Expand Down
24 changes: 20 additions & 4 deletions packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,11 @@ These are shipped ABI. Do not renumber.
| 98 | `NotFound` | Sentinel — `Option` returned as an error |
| 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors |

**Next allocatable integer: 49** — 27–48 are all claimed (27, 29, 31, 34–42
**Next allocatable integer: 55** — 27–54 are all claimed (27, 29, 31, 34–42
and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and
48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their
rows below); 28, 30,
rows below); 49–54 proposed by active #4586 (the persister
operation × kind block); 28, 30,
32 and 33 reserved). **28, 30,
32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the
reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners
Expand Down Expand Up @@ -157,6 +158,12 @@ Fork-era numbers remain in the collision history, which is immutable record.
| 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable |
| 43 | `ErrorShieldedInviteAlreadyClaimed` | #4313 | In review — **ACTIVE; the former "on hold — holds no number" row is obsolete.** The branch revived and renumbered to the frontier exactly as that row prescribed. Lineage: fork-era #4204's 32 → 37 move, then 37 **taken by merged #4348** (`ErrorDocumentNotForSale = 37`, ABI since 2026-08-09), then 37 → 43 on revival. `ErrorShieldedInviteAlreadyClaimed = 43` at head `0302b188ab`. **Rule 5 is satisfied at that head**: Swift carries all three edits — the raw case, the `init(ffi:)` arm, and the typed `PlatformWalletError.shieldedInviteAlreadyClaimed` case with its arm in `init(code:message:)` (which `init(result:)` delegates to) — plus `errorDescription`; Kotlin has the typed terminal `PlatformWallet.ShieldedInviteAlreadyClaimed`, the `43 ->` arm in `fromPlatformWalletNative`, and a `DashSdkErrorTest` pin on 43. Swift's 43 mirror predates `0302b188ab` on the branch; the raw-value test pin for 43 is Kotlin's (Swift's `ErrorHandlingTests` pins 44 and 45 only) |
| 44 | `ErrorShieldedScanBudgetExhausted` | #4313 | In review — claimed from the frontier; carries the #4306 scan-budget semantics (retryable — progress is checkpointed). **Rule 5 is satisfied as of `0302b188ab`, and was not before it.** At that commit's parent Kotlin already mirrored 44 (typed `ShieldedScanBudgetExhausted`, the `fromPlatformWalletNative` arm, a `DashSdkErrorTest` pin) while Swift carried none of rule 5's three edits, so 44 fell to `init(ffi:)`'s `default:` and lost its identity as `.errorUnknown` — one host typed, the other blind, the same failure shape as merged row 29's. `0302b188ab` adds the raw case, the `init(ffi:)` arm, the typed case with its `init(code:message:)` arm and `errorDescription`, and an `ErrorHandlingTests` pin of raw value 44 |
| 49 | `ErrorPersisterLoadTransient` | #4586 | Proposed — claimed from the frontier (48 at the time of the claim). Reading persisted state failed on a store that classified the failure retryable; nothing was mutated. First of a six-code `operation × kind` block: the wallet's `PersisterLoad` / `PersisterStore` / `PersisterRestore` variants each carry a typed `PersistenceError`, and before this block all three flattened to `ErrorUnknown` (99), so the retry classification died at the C boundary while the Rust API had carried it faithfully |
| 50 | `ErrorPersisterLoadFatal` | #4586 | Proposed — permanent read failure. `Fatal`, `Constraint` and `LockPoisoned` all fold here: a read cannot violate a constraint, and none of the three is retryable, so splitting them would spend codes hosts would handle identically |
| 51 | `ErrorPersisterStoreTransient` | #4586 | Proposed — the retryable write failure, and the code a wallet registration against a locked database produces (refs #4365). Emitted ONLY when the round was rolled back whole (host-attested `ATOMIC_CHANGESETS` plus both round brackets wired), because a caller acting on it re-sends the entire changeset and changeset vectors merge by appending |
| 52 | `ErrorPersisterStoreFatal` | #4586 | Proposed — permanent write failure, plus `LockPoisoned` (which carries no kind of its own) |
| 53 | `ErrorPersisterStoreConstraint` | #4586 | Proposed — integrity/foreign-key violation, kept apart from 52 so a host can route "your data is wrong" (caller or schema-mapping bug) differently from "the storage engine is unhappy" (operator/infrastructure). Not retryable either way |
| 54 | `ErrorPersisterRestore` | #4586 | Proposed — rehydrating persisted platform-address state into a freshly registered wallet failed. One code, not three: the variant wraps a `PlatformWalletError` rather than a `PersistenceError`, so there is no kind to split on |
| 45 | `ErrorShieldedLifecycleBusy` | #4313 | In review — claimed from the frontier. A shielded lifecycle operation refused because teardown/clear holds the wallet (retryable — nothing consumed); the FFI remove path passes the refusal through as 45 instead of flattening it to `ErrorWalletOperation` (6). Same rule-5 history as 44: Kotlin mirrored 45 at the parent commit already; Swift's three edits and an `ErrorHandlingTests` pin of raw value 45 landed in `0302b188ab`. **Rule 5 is satisfied at that head** |

**Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here
Expand Down Expand Up @@ -242,6 +249,15 @@ that was always required was made — onto the wrong integers.
| 42 | `ErrorPersisterTransient` | #3968 | Contradicts **merged ABI** — 42 is #4451's `ErrorMasternodeWithdrawalUnconfirmed` (merged 2026-08-22). Not a paper conflict: since the 2026-08-25 base merges, #3968's **own tree** carries both variants — a hard E0081 in `error.rs` (`= 42` at both variants) and a duplicate raw value 42 in Swift's `PlatformWalletResultCode` — so the branch does not compile as-is |
| 43 | `ErrorPersisterFatal` | #3968 | Collides with **active #4313**, whose recorded claim is `ErrorShieldedInviteAlreadyClaimed = 43` (see its proposed row). The silent shape: nothing conflicts textually and neither tree carries both variants, so only this file shows it |

**These two claims are now also redundant, not just misnumbered.** #4586's
49–54 block covers the same ground with finer granularity — it splits the
retry classification by *operation* as well as by kind, so
`ErrorPersisterTransient` / `ErrorPersisterFatal` have no meaning left that
49–52 do not already carry. If #3968 still needs codes it should adopt the
existing block rather than take two more integers from the frontier; a
second, coarser pair of persister codes would leave hosts with two ways to
learn the same thing and no rule for which one arrives.

PR `#3954`'s `ErrorShutdownIncomplete = 27` used to sit in this table. It is
gone because that claim **won**: #3954 was closed and superseded by **#4268**,
which merged 27 into `v4.2-dev` on 2026-08-02. See the collision history below.
Expand All @@ -259,8 +275,8 @@ been challenged on day one. Both persister codes must now take fresh integers
**from the frontier note above, which is the single canonical source; no
number is copied here because any copy goes stale the moment another PR
merges** (as the original "46+" copy in this paragraph did when #4465 shipped
46 — the frontier note reads 48 as of 2026-08-26, so a pair claimed today
takes 48 and 49, recording the claim there and here in the same PR). 26 and
46, and as a later "48 and 49" copy did when #4586 claimed the 49–54
persister block — read the frontier note, do not copy it). 26 and
27 need nothing: they are the merged base's own values, correctly inherited,
and rule 3 keeps them where they are.

Expand Down
Loading
Loading