Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ sealed class DashSdkError(
class AssetLockTooManyInputs(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedTreeInconsistent` (native code 67). The local shielded
* commitment tree holds more leaves than a fresh sync proves. Not
* retryable: resyncing cannot repair it. Call
* [rebuildShieldedTree][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.rebuildShieldedTree],
* which keeps notes and spent status. Also delivered per wallet on
* [org.dashfoundation.dashsdk.wallet.WalletSyncEvent.ShieldedResult.error].
*/
class ShieldedTreeInconsistent(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend
* could not be built against a Platform-recorded anchor because the
Expand Down Expand Up @@ -672,6 +683,13 @@ sealed class DashSdkError(
*/
const val PLATFORM_WALLET_NOT_FOUND_CODE = 98

/**
* Map a de-offset `PlatformWalletFFIResultCode` delivered outside an
* exception (a sync-completed callback) into the public hierarchy.
*/
fun fromPlatformWalletCode(code: Int, message: String): DashSdkError =
fromPlatformWalletNative(code, message, null)

/** Map a native error code + message into the public hierarchy. */
fun fromNative(e: DashSDKException): DashSdkError {
val message = e.message ?: "Unknown SDK error"
Expand Down Expand Up @@ -768,6 +786,7 @@ sealed class DashSdkError(
27 -> PlatformWallet.ShutdownIncomplete(message, cause) // ErrorShutdownIncomplete
29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds
60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs
67 -> PlatformWallet.ShieldedTreeInconsistent(message, cause) // ErrorShieldedTreeInconsistent
// The deferred-token trio sits at the contiguous block 34-36 because
// 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete
// (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ abstract class NativeWalletEventBridge {

/**
* `on_shielded_sync_completed_fn`, once per wallet result — descriptor
* `([BZZZIJIJLjava/lang/String;)V`.
* `([BZZZIJIJLjava/lang/String;I)V`.
*
* @param walletId 32-byte wallet id.
* @param success sync succeeded; numeric fields are meaningful.
Expand All @@ -111,6 +111,8 @@ abstract class NativeWalletEventBridge {
* @param newlySpent notes newly detected as spent this pass.
* @param balance current unspent shielded balance after the pass.
* @param errorMessage failure message, or null on success / skipped.
* @param errorCode de-offset `PlatformWalletFFIResultCode` classifying the
* failure; 0 on success / skipped.
*/
open fun onShieldedSyncCompleted(
walletId: ByteArray,
Expand All @@ -122,6 +124,7 @@ abstract class NativeWalletEventBridge {
newlySpent: Int,
balance: Long,
errorMessage: String?,
errorCode: Int,
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,14 @@ internal object WalletManagerNative {
*/
external fun shieldedClear(managerHandle: Long)

/**
* Repair an inconsistent shielded commitment tree: empty it and rewind
* scan cursors, keeping notes, spent status and registrations. Background
* shielded sync restarts by itself if it was running. Throws on failure.
* Only present when the native library is built with shielded.
*/
external fun shieldedRebuildTree(managerHandle: Long)

/**
* Start the Core SPV client — flattened form of
* `platform_wallet_manager_spv_start` (11 discrete params, no config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ class PlatformWalletManager(
newlySpent: Int,
balance: Long,
errorMessage: String?,
errorCode: Int,
) {
_syncEvents.tryEmit(
WalletSyncEvent.ShieldedResult(
Expand All @@ -378,6 +379,7 @@ class PlatformWalletManager(
newlySpent = newlySpent,
balance = balance,
errorMessage = errorMessage,
errorCode = errorCode,
),
)
}
Expand Down Expand Up @@ -1545,6 +1547,20 @@ class PlatformWalletManager(
mapNativeErrors { WalletManagerNative.shieldedClear(managerHandle) }
}

/**
* Repair an inconsistent shielded commitment tree — port of Swift's
* `PlatformWalletManager.rebuildShieldedTree()`. Call it when a sync
* reports [DashSdkError.PlatformWallet.ShieldedTreeInconsistent]. Empties
* the tree and rewinds scan cursors so the next pass rescans the whole
* pool; notes, spent status and registrations are kept, so the host keeps
* its Room rows. Background shielded sync restarts by itself if it was
* running. Throws on failure. Only meaningful on a shielded build
* ([Sdk.hasShielded]); the native entry point is absent otherwise.
*/
suspend fun rebuildShieldedTree() = withContext(Dispatchers.IO) {
mapNativeErrors { WalletManagerNative.shieldedRebuildTree(managerHandle) }
}

// ── Shielded funding submits ──────────────────────────────────────
//
// These are manager-handle FFI calls (they resolve the wallet + its
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.dashfoundation.dashsdk.wallet

import org.dashfoundation.dashsdk.errors.DashSdkError

/**
* Typed sync events fanned out by [PlatformWalletManager] from the native
* `EventHandlerCallbacks` vtable (bridged in `rs-unified-sdk-jni/src/events.rs`).
Expand Down Expand Up @@ -111,7 +113,24 @@ sealed interface WalletSyncEvent {
val newlySpent: Int,
val balance: Long,
val errorMessage: String?,
/** De-offset `PlatformWalletFFIResultCode`; 0 on success / skipped. */
val errorCode: Int = 0,
) : WalletSyncEvent {
/**
* The failure as a typed error, or null on success / skipped.
* [DashSdkError.PlatformWallet.ShieldedTreeInconsistent] means only
* [PlatformWalletManager.rebuildShieldedTree] repairs it.
*/
val error: DashSdkError?
get() = if (success || skipped) {
null
} else {
DashSdkError.fromPlatformWalletCode(
errorCode,
errorMessage ?: "shielded sync failed",
)
}

override fun equals(other: Any?): Boolean =
other is ShieldedResult &&
walletId.contentEquals(other.walletId) &&
Expand All @@ -122,7 +141,8 @@ sealed interface WalletSyncEvent {
totalScanned == other.totalScanned &&
newlySpent == other.newlySpent &&
balance == other.balance &&
errorMessage == other.errorMessage
errorMessage == other.errorMessage &&
errorCode == other.errorCode

override fun hashCode(): Int {
var result = walletId.contentHashCode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ class DashSdkErrorTest {
assertFalse(mapped.isRetryable)
}

@Test
fun shouldMapAnInconsistentShieldedTreeToItsTypedError() {
val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 67, "3 leaves, 2 proven")
val mapped = DashSdkError.fromNative(native)
assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedTreeInconsistent)
assertEquals(native.message, mapped.message)
assertEquals(native, mapped.cause)
assertFalse(mapped.isRetryable)
}

@Test
fun mapsNativeCodesToHierarchy() {
// Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge
import org.dashfoundation.dashsdk.errors.DashSdkError
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 @@ -88,11 +91,12 @@ class WalletEventFanOutTest {
newlySpent: Int,
balance: Long,
errorMessage: String?,
errorCode: Int,
) {
sink.tryEmit(
WalletSyncEvent.ShieldedResult(
walletId, success, skipped, cooldownSkip, newNotes,
totalScanned, newlySpent, balance, errorMessage,
totalScanned, newlySpent, balance, errorMessage, errorCode,
),
)
}
Expand Down Expand Up @@ -130,7 +134,7 @@ class WalletEventFanOutTest {
bridge.onPlatformAddressSyncPassCompleted(1_000, 1)
bridge.onDpnsMarketplaceSyncCompleted(walletId, true, 7, 8, 9, 10, null)
bridge.onDpnsMarketplaceSyncPassCompleted(1_500, 1)
bridge.onShieldedSyncCompleted(walletId, true, false, false, 7, 8, 9, 10, null)
bridge.onShieldedSyncCompleted(walletId, true, false, false, 7, 8, 9, 10, null, 0)
bridge.onShieldedSyncPassCompleted(2_000, 1)
bridge.onShieldedSyncProgress(11, 12)
bridge.onShieldedTreeProgress(13, 14)
Expand Down Expand Up @@ -161,11 +165,32 @@ class WalletEventFanOutTest {
assertEquals(7, sr.newNotes)
assertEquals(8L, sr.totalScanned)
assertEquals(10L, sr.balance)
assertNull(sr.error)

assertTrue(received[7] is WalletSyncEvent.ShieldedPassCompleted)
val sp = received[8] as WalletSyncEvent.ShieldedProgress
assertEquals(11L, sp.cumulativeScanned)
val tp = received[9] as WalletSyncEvent.ShieldedTreeProgress
assertEquals(13L, tp.leavesCommitted)
}

@Test
fun shouldTypeAnInconsistentShieldedTreeFromItsResultCode() {
val walletId = ByteArray(32) { 6 }
val result = WalletSyncEvent.ShieldedResult(
walletId, false, false, false, 0, 0, 0, 0,
"tree has 3 leaves, chain reports 2", 67,
)

val error = result.error
assertTrue(error is DashSdkError.PlatformWallet.ShieldedTreeInconsistent)
assertEquals("tree has 3 leaves, chain reports 2", error!!.message)
assertFalse("a resync cannot repair the tree; rebuild it", error.isRetryable)

val generic = WalletSyncEvent.ShieldedResult(
walletId, false, false, false, 0, 0, 0, 0, "transport failed", 6,
).error
assertFalse(generic is DashSdkError.PlatformWallet.ShieldedTreeInconsistent)
assertEquals("transport failed", generic!!.message)
}
}
5 changes: 4 additions & 1 deletion packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ These are shipped ABI. Do not renumber.
| 98 | `NotFound` | Sentinel — `Option` returned as an error |
| 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors |

**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42
**Next allocatable integer: 68** — 27–67 are all claimed (67 proposed by
#5565 for an inconsistent shielded commitment tree; 61 is held by open #5395,
62 by open #5487 and 63–66 by open #5536, which record their own rows; 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); 49–54 proposed by active #4586 (the persister
Expand Down Expand Up @@ -177,6 +179,7 @@ Fork-era numbers remain in the collision history, which is immutable record.
| 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding |
| 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) |
| 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it |
| 67 | `ErrorShieldedTreeInconsistent` | #5565 | Proposed — the local shielded commitment tree holds more leaves than a sufficiently fresh sync proves; resyncing cannot repair it, and `platform_wallet_manager_shielded_rebuild_tree` does, keeping notes and spent status. Not retryable. Returned by `platform_wallet_manager_shielded_sync_wallet` and carried per wallet in `ShieldedSyncWalletResultFFI.error_code` on the sync-completed callback. Skips 61–66, held by open #5395, #5487 and #5536. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `shieldedTreeInconsistent` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.ShieldedTreeInconsistent` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin |

**Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here
as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged
Expand Down
30 changes: 30 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,14 @@ pub enum PlatformWalletFFIResultCode {
/// host must not silently substitute a partial drain.
ErrorAssetLockTooManyInputs = 60,

/// Maps `PlatformWalletError::ShieldedTreeInconsistent`: the local shielded
/// commitment tree holds more leaves than a sufficiently fresh sync proves.
/// Resyncing cannot repair it; call
/// `platform_wallet_manager_shielded_rebuild_tree`, which keeps notes and
/// spent status. Also carried per wallet by the shielded sync-completed
/// callback. 61-66 are claimed by open PRs; see `ERROR_CODE_REGISTRY.md`.
ErrorShieldedTreeInconsistent = 67,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand Down Expand Up @@ -923,6 +931,9 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::ShieldedNoRecordedAnchor(..) => {
PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor
}
PlatformWalletError::ShieldedTreeInconsistent { .. } => {
PlatformWalletFFIResultCode::ErrorShieldedTreeInconsistent
}
PlatformWalletError::PlatformShieldCapacityExceeded { .. } => {
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance
}
Expand Down Expand Up @@ -2186,6 +2197,25 @@ mod tests {
);
}

/// Hosts offer the tree rebuild by code alone, so the inconsistent-tree
/// verdict must keep its own pinned code rather than flatten.
#[test]
fn should_map_shielded_tree_inconsistent_to_pinned_code_67() {
assert_eq!(
PlatformWalletFFIResultCode::ErrorShieldedTreeInconsistent as i32,
67
);
let result: PlatformWalletFFIResult = PlatformWalletError::ShieldedTreeInconsistent {
local_leaf_count: 3,
chain_leaf_count: 2,
}
.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorShieldedTreeInconsistent
);
}

#[test]
fn shielded_insufficient_balance_code_is_pinned_at_41() {
assert_eq!(
Expand Down
Loading
Loading