From 1fe99cb8c969c16f13ee249a5e0f6ee6efde3605 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 3 Sep 2026 16:24:55 -0700 Subject: [PATCH 1/4] feat(kotlin-sdk): bind the ordered wallet bring-up (startWalletSubsystems) over JNI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android could not call platform_wallet_manager_start_wallet_subsystems at all — the C export and the Swift binding existed, but no JNI export and no Kotlin surface — so every Android consumer (dash-wallet, the example app) starts the L1 scan before the DIP-15 receival accounts exist and depends on the after-the-fact rescan, whose in-session rewind loses a race against the filter pipeline's forward-only synced-height advance (see MO-1012, 2026-09-03 instrumented restore). The JNI wrapper returns the outcome as a fixed 57-byte big-endian blob; WalletStartupOutcome.decode is the Kotlin half of that contract and WalletStartupTest pins it, along with the status-helper semantics (discoveryWorthRetrying / identityIsSettled) mirrored from the Swift binding. PlatformWalletManager.startWalletSubsystems follows the drain's per-call key-material contract: resolver and signer built for the call, closed when it returns. Call it once per wallet load, immediately before startSpv. (cherry picked from commit 783b58a2bf5940a3e4be105dc2b67468620c6a6c) --- .../dashsdk/ffi/WalletManagerNative.kt | 45 ++++ .../dashsdk/wallet/PlatformWalletManager.kt | 68 ++++++ .../dashsdk/wallet/WalletStartup.kt | 205 ++++++++++++++++++ .../dashsdk/wallet/WalletStartupTest.kt | 131 +++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 108 +++++++++ 5 files changed, 557 insertions(+) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 090bca5a5a1..58351c59fbb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -700,4 +700,49 @@ internal object WalletManagerNative { accountIndex: Int, coreFeePerByte: Int, ): String? + + // ── Ordered wallet bring-up ─────────────────────────────────────── + + /** + * Ordered wallet bring-up — identity → contacts → contact-account + * drain — run as ONE bounded call so the host can start Core SPV + * knowing the DIP-15 contact addresses exist and will be in the first + * filter set. Wraps `platform_wallet_manager_start_wallet_subsystems`; + * iOS binds the same call (`PlatformWalletManagerStartup.swift`), and + * the ordering / retry / budget policy lives Rust-side in + * `platform_wallet::manager::startup`. + * + * Blocks until the sequence finishes or its budget expires — call from + * a background dispatcher. Throws [DashSDKException] only for a + * malformed request (bad handle, unknown wallet, bad argument); an + * unreachable Platform, a failed sync pass or an unfinished drain are + * all reported through the returned blob's status, because the host + * must be able to start Core SPV regardless. + * + * @param managerHandle the raw manager handle ([nativeManagerHandle]). + * @param walletId the 32-byte wallet id. + * @param mnemonicResolverHandle Keychain mnemonic resolver, or 0 for a + * wallet holding resident keys. Without it the drain is skipped and + * the pending count reported. + * @param identitySignerHandle identity signer for the DIP-15 + * auto-accept pass, or 0 to skip it. + * @param budgetSecs ceiling for the whole sequence; 0 = SDK default + * (20s). Never unbounded — this call gates Core SPV. + * @param gapLimit identity-discovery gap limit; 0 = SDK default. + * @return a fixed 57-byte big-endian outcome blob, decoded by + * `WalletStartupOutcome.decode` (the layouts must match the JNI + * side): offset 0 status (1B), 1 hasIdentityId (1B), 2 identityId + * (32B), 34 discoveryAttempts (u32), 38 dashPaySyncRan (1B), + * 39 seedBindingUnverified (1B), 40 identityScanIncomplete (1B), + * 41 contactAccountsDrained (u32), 45 contactAccountsPending (u32), + * 49 elapsedMs (u64). + */ + external fun startWalletSubsystems( + managerHandle: Long, + walletId: ByteArray, + mnemonicResolverHandle: Long, + identitySignerHandle: Long, + budgetSecs: Long, + gapLimit: Int, + ): ByteArray } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index c4e859f3060..4f5dfa6b11d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2341,6 +2341,74 @@ class PlatformWalletManager( } } + // ── Ordered wallet bring-up ─────────────────────────────────────── + + /** + * Bring one wallet's DashPay state up in dependency order — identity → + * contacts → contact-account drain — then return so the caller can + * start Core SPV. Port of Swift's `startWalletSubsystems` + * (`PlatformWalletManagerStartup.swift`); the ordering, the retry + * policy and the budget all live Rust-side + * (`platform_wallet::manager::startup`) — this is a thin bridge. + * + * A contact's DIP-15 payment addresses are derived from its contact + * account, and an address the wallet is not watching when the + * compact-filter scan passes its funding height produces no + * transaction at all. Call this once per wallet load, immediately + * before [startSpv], so the first filter set already covers them — + * a restored wallet then needs no receival-payment rescan at all. + * + * Budget expiry is reported in the outcome, never thrown: Core sync is + * the wallet's primary function and must not be held hostage to + * Platform being slow. Start SPV regardless of the returned status; + * inspect [WalletStartupOutcome.contactAccountsPending] for + * diagnostics. + * + * Key material follows the drain's per-call contract: the mnemonic + * resolver and identity signer are built for this call and closed when + * it returns — Rust borrows and never retains them. An auth-gated + * signing failure (identity keys are biometric-gated on Android) + * leaves the affected entries queued; the recurring sweep self-heals. + * + * Throws only for a malformed request (bad wallet id, negative + * arguments, unknown wallet, torn-down manager). + * + * @param walletId the 32-byte wallet id. + * @param budgetSecs ceiling for the whole sequence in seconds; 0 = SDK + * default (20s). Never unbounded — this call gates Core SPV. + * @param gapLimit identity-discovery gap limit; 0 = SDK default. + */ + suspend fun startWalletSubsystems( + walletId: ByteArray, + budgetSecs: Long = 0, + gapLimit: Int = 0, + ): WalletStartupOutcome = teardownGate.op { + require(walletId.size == 32) { "walletId must be 32 bytes, got ${walletId.size}" } + require(budgetSecs >= 0) { "budgetSecs must be non-negative, got $budgetSecs" } + require(gapLimit >= 0) { "gapLimit must be non-negative, got $gapLimit" } + withContext(Dispatchers.IO) { + val startupResolver = MnemonicResolverAndPersister(walletStorage) + val startupSigner = + KeystoreSigner(walletStorage, network, biometricGate, database.platformAddressDao()) + try { + val blob = mapNativeErrors { + WalletManagerNative.startWalletSubsystems( + managerHandle, + walletId, + startupResolver.nativeHandle, + startupSigner.nativeHandle, + budgetSecs, + gapLimit, + ) + } + WalletStartupOutcome.decode(blob) + } finally { + runCatching { startupResolver.close() } + runCatching { startupSigner.close() } + } + } + } + // ── Lifecycle ───────────────────────────────────────────────────── val isClosed: Boolean get() = bundleRef.get() == 0L diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt new file mode 100644 index 00000000000..8e24810bda1 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt @@ -0,0 +1,205 @@ +package org.dashfoundation.dashsdk.wallet + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Why a wallet bring-up stopped where it did — Kotlin mirror of the Rust + * `WalletStartupStatus` (and of Swift's `WalletStartupStatus`). Raw values + * are the `WalletStartupStatusFFI` ABI discriminants: append, never + * renumber. + * + * Every case is a normal result. A host starts Core SPV on all of them — + * the partial cases just mean the DIP-15 rescan has work left to do. + */ +enum class WalletStartupStatus(val raw: Int) { + /** + * Identity resolved, contacts synced, no contact-account builds left + * queued. Everything a contact payment needs is in place. + */ + READY(0), + + /** + * Platform answered that this seed owns no identity. Terminal, and not + * a failure — there is nothing to sync and nothing to drain. + */ + NO_IDENTITY(1), + + /** + * The identity scan never reached Platform inside the budget. The + * wallet may well own one; we do not know yet, and asking again may + * answer it. + */ + PARTIAL_NO_IDENTITY(2), + + /** + * Identity resolved and synced, but contact-account builds are still + * queued — the budget ran out, the drain failed on some entries, or no + * contact-crypto provider was available. + */ + PARTIAL_ACCOUNTS_PENDING(3), + + /** + * Discovery failed locally — a wallet or persistence fault, not a + * reachability problem. Another scan will not answer it: the same + * fault is still there. + */ + DISCOVERY_FAILED(4), + + /** + * The contact-crypto provider does not resolve the seed that owns this + * wallet, so the contact-account drain was skipped without deriving + * anything. Not about Platform being slow: the signer handed to the + * call belongs to a different wallet, and deriving anyway would write + * contact receiving addresses from the wrong seed that no later + * correct-seed pass would ever revisit. The queued work is intact; a + * rerun with the right signer completes it. + */ + SEED_BINDING_UNVERIFIED(5), + + /** + * An identity is known and every later step ran, but the gap-limit + * identity scan is on record as having left indices unanswered. The + * identity reported is real; it may not be the only one. The verdict + * stays on record, so the next launch re-scans instead of taking the + * warm shortcut. + */ + IDENTITY_SCAN_INCOMPLETE(6), + ; + + /** + * Whether another discovery scan could change the answer: true for + * [PARTIAL_NO_IDENTITY] (Platform was never reached) and + * [IDENTITY_SCAN_INCOMPLETE] (reached, but not for every index). The + * others are terminal for this launch. + */ + val discoveryWorthRetrying: Boolean + get() = this == PARTIAL_NO_IDENTITY || this == IDENTITY_SCAN_INCOMPLETE + + /** + * Whether the identity question has an answer. Not the inverse of + * [discoveryWorthRetrying]: [DISCOVERY_FAILED] leaves the question + * open AND is not worth retrying, while [IDENTITY_SCAN_INCOMPLETE] + * has an answer that is merely known to be partial. Use this to + * decide what to show, and [discoveryWorthRetrying] to decide whether + * to scan again. + */ + val identityIsSettled: Boolean + get() = this != PARTIAL_NO_IDENTITY && this != DISCOVERY_FAILED + + companion object { + fun fromRaw(raw: Int): WalletStartupStatus = + entries.firstOrNull { it.raw == raw } + ?: throw IllegalArgumentException("unknown WalletStartupStatus discriminant $raw") + } +} + +/** + * What a wallet bring-up did — Kotlin mirror of the Rust + * `WalletStartupOutcome` (and of Swift's `WalletStartupOutcome`). + */ +data class WalletStartupOutcome( + val status: WalletStartupStatus, + /** The wallet's identity, when one is known by the time this returned. */ + val identityId: ByteArray?, + /** + * Discovery scans performed; 0 when a local identity was already known + * and no network scan was needed. + */ + val discoveryAttempts: Int, + /** + * Whether the inline contact-request pass ran TO COMPLETION. False when + * it was skipped, failed, ran out of budget, or came back degraded — a + * pass that could not read some identities' contact documents left + * their account builds unenqueued, so an empty pending count does not + * mean their addresses are ready. + */ + val dashPaySyncRan: Boolean, + /** + * The drain was skipped because the contact-crypto provider does not + * resolve this wallet's seed. Nothing was derived and nothing written; + * the queued work is intact. + */ + val seedBindingUnverified: Boolean, + /** + * The wallet's identity scan is on record as having left indices + * unanswered and this launch did not close the gap. Carried separately + * from [status] because a pending contact queue outranks it there. + */ + val identityScanIncomplete: Boolean, + /** Contact-crypto entries the drain completed. */ + val contactAccountsDrained: Int, + /** + * Contact-account builds still queued on return. Non-zero means the + * DIP-15 rescan will have to backfill those contacts' payments. + */ + val contactAccountsPending: Int, + /** Wall-clock duration of the whole sequence, in milliseconds. */ + val elapsedMs: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WalletStartupOutcome) return false + return status == other.status && + (identityId?.contentEquals(other.identityId) ?: (other.identityId == null)) && + discoveryAttempts == other.discoveryAttempts && + dashPaySyncRan == other.dashPaySyncRan && + seedBindingUnverified == other.seedBindingUnverified && + identityScanIncomplete == other.identityScanIncomplete && + contactAccountsDrained == other.contactAccountsDrained && + contactAccountsPending == other.contactAccountsPending && + elapsedMs == other.elapsedMs + } + + override fun hashCode(): Int { + var result = status.hashCode() + result = 31 * result + (identityId?.contentHashCode() ?: 0) + result = 31 * result + discoveryAttempts + result = 31 * result + dashPaySyncRan.hashCode() + result = 31 * result + seedBindingUnverified.hashCode() + result = 31 * result + identityScanIncomplete.hashCode() + result = 31 * result + contactAccountsDrained + result = 31 * result + contactAccountsPending + result = 31 * result + elapsedMs.hashCode() + return result + } + + companion object { + /** Size of the JNI outcome blob; must match the Rust serializer. */ + const val BLOB_SIZE: Int = 57 + + /** + * Decode the fixed-layout big-endian blob produced by + * `WalletManagerNative.startWalletSubsystems` — the layout table + * lives on both the JNI wrapper and the external declaration, and + * the three must stay in lockstep. + */ + fun decode(blob: ByteArray): WalletStartupOutcome { + require(blob.size == BLOB_SIZE) { + "startup outcome blob must be $BLOB_SIZE bytes, got ${blob.size}" + } + val buf = ByteBuffer.wrap(blob).order(ByteOrder.BIG_ENDIAN) + val status = WalletStartupStatus.fromRaw(buf.get().toInt() and 0xFF) + val hasIdentity = buf.get().toInt() != 0 + val identity = ByteArray(32).also { buf.get(it) } + val discoveryAttempts = buf.int + val dashPaySyncRan = buf.get().toInt() != 0 + val seedBindingUnverified = buf.get().toInt() != 0 + val identityScanIncomplete = buf.get().toInt() != 0 + val drained = buf.int + val pending = buf.int + val elapsedMs = buf.long + return WalletStartupOutcome( + status = status, + identityId = if (hasIdentity) identity else null, + discoveryAttempts = discoveryAttempts, + dashPaySyncRan = dashPaySyncRan, + seedBindingUnverified = seedBindingUnverified, + identityScanIncomplete = identityScanIncomplete, + contactAccountsDrained = drained, + contactAccountsPending = pending, + elapsedMs = elapsedMs, + ) + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt new file mode 100644 index 00000000000..b6d9ec0287e --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt @@ -0,0 +1,131 @@ +package org.dashfoundation.dashsdk.wallet + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the 57-byte outcome blob ABI shared with the JNI serializer in + * `rs-unified-sdk-jni/src/wallet_manager.rs` — the layout table lives on + * both sides and this test is the Kotlin half of the contract. + */ +class WalletStartupTest { + + private fun blob( + status: Int = 0, + hasIdentity: Boolean = true, + identity: ByteArray = ByteArray(32) { it.toByte() }, + discoveryAttempts: Int = 3, + dashPaySyncRan: Boolean = true, + seedBindingUnverified: Boolean = false, + identityScanIncomplete: Boolean = false, + drained: Int = 4, + pending: Int = 0, + elapsedMs: Long = 12_345L, + ): ByteArray { + val buf = ByteBuffer.allocate(WalletStartupOutcome.BLOB_SIZE).order(ByteOrder.BIG_ENDIAN) + buf.put(status.toByte()) + buf.put(if (hasIdentity) 1 else 0) + buf.put(identity) + buf.putInt(discoveryAttempts) + buf.put(if (dashPaySyncRan) 1 else 0) + buf.put(if (seedBindingUnverified) 1 else 0) + buf.put(if (identityScanIncomplete) 1 else 0) + buf.putInt(drained) + buf.putInt(pending) + buf.putLong(elapsedMs) + return buf.array() + } + + @Test + fun decodesEveryFieldOfAReadyOutcome() { + val identity = ByteArray(32) { (it * 3).toByte() } + val outcome = WalletStartupOutcome.decode( + blob( + status = 0, + hasIdentity = true, + identity = identity, + discoveryAttempts = 2, + dashPaySyncRan = true, + seedBindingUnverified = false, + identityScanIncomplete = false, + drained = 5, + pending = 1, + elapsedMs = 9_876L, + ), + ) + assertEquals(WalletStartupStatus.READY, outcome.status) + assertArrayEquals(identity, outcome.identityId) + assertEquals(2, outcome.discoveryAttempts) + assertTrue(outcome.dashPaySyncRan) + assertFalse(outcome.seedBindingUnverified) + assertFalse(outcome.identityScanIncomplete) + assertEquals(5, outcome.contactAccountsDrained) + assertEquals(1, outcome.contactAccountsPending) + assertEquals(9_876L, outcome.elapsedMs) + } + + @Test + fun absentIdentityDecodesToNullEvenWhenBytesAreSet() { + // The JNI side zeroes the array when has_identity_id is false, but + // the decoder must key on the flag, not the bytes. + val outcome = WalletStartupOutcome.decode( + blob(status = 1, hasIdentity = false, identity = ByteArray(32) { 7 }), + ) + assertEquals(WalletStartupStatus.NO_IDENTITY, outcome.status) + assertNull(outcome.identityId) + } + + @Test + fun everyAbiDiscriminantRoundTrips() { + for (status in WalletStartupStatus.entries) { + val outcome = WalletStartupOutcome.decode(blob(status = status.raw)) + assertEquals(status, outcome.status) + } + } + + @Test + fun unknownDiscriminantThrows() { + assertThrows(IllegalArgumentException::class.java) { + WalletStartupOutcome.decode(blob(status = 200)) + } + } + + @Test + fun wrongBlobSizeThrows() { + assertThrows(IllegalArgumentException::class.java) { + WalletStartupOutcome.decode(ByteArray(WalletStartupOutcome.BLOB_SIZE - 1)) + } + assertThrows(IllegalArgumentException::class.java) { + WalletStartupOutcome.decode(ByteArray(WalletStartupOutcome.BLOB_SIZE + 1)) + } + } + + @Test + fun statusHelperSemanticsMatchTheSwiftBinding() { + // discoveryWorthRetrying: only the two "Platform not fully asked" cases. + assertTrue(WalletStartupStatus.PARTIAL_NO_IDENTITY.discoveryWorthRetrying) + assertTrue(WalletStartupStatus.IDENTITY_SCAN_INCOMPLETE.discoveryWorthRetrying) + assertFalse(WalletStartupStatus.READY.discoveryWorthRetrying) + assertFalse(WalletStartupStatus.NO_IDENTITY.discoveryWorthRetrying) + assertFalse(WalletStartupStatus.DISCOVERY_FAILED.discoveryWorthRetrying) + assertFalse(WalletStartupStatus.SEED_BINDING_UNVERIFIED.discoveryWorthRetrying) + assertFalse(WalletStartupStatus.PARTIAL_ACCOUNTS_PENDING.discoveryWorthRetrying) + // identityIsSettled: not the inverse — DISCOVERY_FAILED is unsettled + // AND not worth retrying; IDENTITY_SCAN_INCOMPLETE is settled AND + // worth retrying. + assertFalse(WalletStartupStatus.PARTIAL_NO_IDENTITY.identityIsSettled) + assertFalse(WalletStartupStatus.DISCOVERY_FAILED.identityIsSettled) + assertTrue(WalletStartupStatus.IDENTITY_SCAN_INCOMPLETE.identityIsSettled) + assertTrue(WalletStartupStatus.READY.identityIsSettled) + assertTrue(WalletStartupStatus.NO_IDENTITY.identityIsSettled) + assertTrue(WalletStartupStatus.SEED_BINDING_UNVERIFIED.identityIsSettled) + assertTrue(WalletStartupStatus.PARTIAL_ACCOUNTS_PENDING.identityIsSettled) + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 3a0b2be5ffe..c9880a59b48 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3899,3 +3899,111 @@ fn throw_pwffi(env: &mut JNIEnv, result: &mut PlatformWalletFFIResult) { throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); unsafe { platform_wallet_ffi_result_free(result) }; } + +/// Ordered wallet bring-up: identity → contacts → contact-account drain, run +/// as one bounded call so the host can start Core SPV knowing the DIP-15 +/// contact addresses exist and will be in the first filter set — the JNI +/// bridge over `platform_wallet_manager_start_wallet_subsystems`. The +/// ordering, retry policy and budget all live Rust-side +/// (`platform_wallet::manager::startup`); iOS binds the same call as +/// `PlatformWalletManagerStartup.swift`. +/// +/// `mnemonicResolverHandle` is nullable (0): required for a Keychain-backed +/// external-signable wallet; 0 means the wallet holds resident keys. Without +/// it the drain is skipped and the pending count reported. +/// `identitySignerHandle` is nullable (0): 0 skips the DIP-15 auto-accept +/// pass. `budgetSecs` 0 selects the crate default (20s) — the call always +/// terminates, because it gates Core SPV. `gapLimit` 0 selects the default. +/// +/// Returns the outcome as a fixed 57-byte big-endian blob (decoded by +/// `WalletStartupOutcome.decode` in Kotlin — the layouts must match): +/// +/// | offset | size | field | +/// |---|---|---| +/// | 0 | 1 | status (`WalletStartupStatusFFI` discriminant) | +/// | 1 | 1 | hasIdentityId (0/1) | +/// | 2 | 32 | identityId (valid only when hasIdentityId=1) | +/// | 34 | 4 | discoveryAttempts (u32) | +/// | 38 | 1 | dashpaySyncRan (0/1) | +/// | 39 | 1 | seedBindingUnverified (0/1) | +/// | 40 | 1 | identityScanIncomplete (0/1) | +/// | 41 | 4 | contactAccountsDrained (u32) | +/// | 45 | 4 | contactAccountsPending (u32) | +/// | 49 | 8 | elapsedMs (u64) | +/// +/// Throws only for a malformed request (bad handle, unknown wallet, bad +/// argument) — an unreachable Platform, a failed sync pass or an unfinished +/// drain all come back through the blob's status, because the host must be +/// able to start Core SPV regardless. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_startWalletSubsystems( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + mnemonic_resolver_handle: jlong, + identity_signer_handle: jlong, + budget_secs: jlong, + gap_limit: jint, +) -> jbyteArray { + guard(&mut env, std::ptr::null_mut(), |env| { + let Some(wallet_id) = read_id32(env, &wallet_id) else { + return std::ptr::null_mut(); + }; + if budget_secs < 0 { + throw_sdk_exception(env, 1, "budgetSecs must be non-negative"); + return std::ptr::null_mut(); + } + if gap_limit < 0 { + throw_sdk_exception(env, 1, "gapLimit must be non-negative"); + return std::ptr::null_mut(); + } + + let mut outcome = platform_wallet_ffi::wallet_startup::WalletStartupOutcomeFFI { + status: 0, + has_identity_id: false, + identity_id: [0u8; 32], + discovery_attempts: 0, + dashpay_sync_ran: false, + seed_binding_unverified: false, + identity_scan_incomplete: false, + contact_accounts_drained: 0, + contact_accounts_pending: 0, + elapsed_ms: 0, + }; + let result = unsafe { + platform_wallet_ffi::wallet_startup::platform_wallet_manager_start_wallet_subsystems( + manager_handle as Handle, + wallet_id.as_ptr(), + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + identity_signer_handle as *mut rs_sdk_ffi::SignerHandle, + budget_secs as u64, + gap_limit as u32, + &mut outcome, + ) + }; + if take_pwffi_error(env, result) { + return std::ptr::null_mut(); + } + + let mut blob = [0u8; 57]; + blob[0] = outcome.status; + blob[1] = outcome.has_identity_id as u8; + blob[2..34].copy_from_slice(&outcome.identity_id); + blob[34..38].copy_from_slice(&outcome.discovery_attempts.to_be_bytes()); + blob[38] = outcome.dashpay_sync_ran as u8; + blob[39] = outcome.seed_binding_unverified as u8; + blob[40] = outcome.identity_scan_incomplete as u8; + blob[41..45].copy_from_slice(&outcome.contact_accounts_drained.to_be_bytes()); + blob[45..49].copy_from_slice(&outcome.contact_accounts_pending.to_be_bytes()); + blob[49..57].copy_from_slice(&outcome.elapsed_ms.to_be_bytes()); + match env.byte_array_from_slice(&blob) { + Ok(array) => array.into_raw(), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "startup outcome blob allocation failed"); + std::ptr::null_mut() + } + } + }) +} From 7cfc6e3628065d56f8d81e105edc6c175b5cb1c0 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 12 Sep 2026 08:27:06 -0700 Subject: [PATCH 2/4] fix(kotlin-sdk): guard each startup handle from the moment it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KeystoreSigner`'s constructor can throw (Keystore unlock, DAO access), and the single try/finally began only after BOTH constructors returned — so a throw there leaked the resolver's native handle. Each handle now has its own guard, closed in reverse construction order. Review finding on dashpay/platform#4658. --- .../dashsdk/wallet/PlatformWalletManager.kt | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 4f5dfa6b11d..6f6dca52c7b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2387,24 +2387,36 @@ class PlatformWalletManager( require(budgetSecs >= 0) { "budgetSecs must be non-negative, got $budgetSecs" } require(gapLimit >= 0) { "gapLimit must be non-negative, got $gapLimit" } withContext(Dispatchers.IO) { + // Each handle is guarded from the moment it exists: the signer's + // constructor can throw (Keystore unlock, DAO access), and a single + // try covering both would leak the resolver's native handle when it + // does. Closed in reverse construction order. val startupResolver = MnemonicResolverAndPersister(walletStorage) - val startupSigner = - KeystoreSigner(walletStorage, network, biometricGate, database.platformAddressDao()) try { - val blob = mapNativeErrors { - WalletManagerNative.startWalletSubsystems( - managerHandle, - walletId, - startupResolver.nativeHandle, - startupSigner.nativeHandle, - budgetSecs, - gapLimit, + val startupSigner = + KeystoreSigner( + walletStorage, + network, + biometricGate, + database.platformAddressDao(), ) + try { + val blob = mapNativeErrors { + WalletManagerNative.startWalletSubsystems( + managerHandle, + walletId, + startupResolver.nativeHandle, + startupSigner.nativeHandle, + budgetSecs, + gapLimit, + ) + } + WalletStartupOutcome.decode(blob) + } finally { + runCatching { startupSigner.close() } } - WalletStartupOutcome.decode(blob) } finally { runCatching { startupResolver.close() } - runCatching { startupSigner.close() } } } } From 3a32c3584e43c3aba309924465a32d879eb37468 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 14 Sep 2026 00:12:11 -0700 Subject: [PATCH 3/4] docs(kotlin-sdk): cite the Swift source for the ported startup types Both `WalletStartupStatus` and `WalletStartupOutcome` mirror Swift types; the Kotlin SDK convention is to name the Swift file a ported KDoc came from, as `Network.kt`, `Sdk.kt` and `IdentityUpdates.kt` already do. Review finding on dashpay/platform#4658. Co-Authored-By: Claude Opus 5 --- .../org/dashfoundation/dashsdk/wallet/WalletStartup.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt index 8e24810bda1..43b583ec9ea 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt @@ -5,8 +5,9 @@ import java.nio.ByteOrder /** * Why a wallet bring-up stopped where it did — Kotlin mirror of the Rust - * `WalletStartupStatus` (and of Swift's `WalletStartupStatus`). Raw values - * are the `WalletStartupStatusFFI` ABI discriminants: append, never + * `WalletStartupStatus` and of Swift's `WalletStartupStatus` + * (`SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift`). Raw + * values are the `WalletStartupStatusFFI` ABI discriminants: append, never * renumber. * * Every case is a normal result. A host starts Core SPV on all of them — @@ -96,7 +97,8 @@ enum class WalletStartupStatus(val raw: Int) { /** * What a wallet bring-up did — Kotlin mirror of the Rust - * `WalletStartupOutcome` (and of Swift's `WalletStartupOutcome`). + * `WalletStartupOutcome` and of Swift's `WalletStartupOutcome` + * (`SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift`). */ data class WalletStartupOutcome( val status: WalletStartupStatus, From 565ae31c7f075c7a2b227cb0ef00495eb7da7dc8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 15 Sep 2026 14:38:32 -0700 Subject: [PATCH 4/4] fix(kotlin-sdk): map an unknown startup status to PARTIAL_NO_IDENTITY, as Swift does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status discriminants are an append-only ABI, so a newer native library can report a value this Kotlin build predates. `fromRaw` threw on it, which turned a benign version skew into a failed bring-up. Swift already falls back (`WalletStartupStatus(rawValue:) ?? .partialNoIdentity`); do the same here — PARTIAL_NO_IDENTITY is the conservative reading (start Core SPV, a DIP-15 rescan is still owed). `fromRaw` stays pure so the enum remains unit-testable without Android; the call site in `startWalletSubsystems` checks `isKnownRaw` first and logs a warning naming the unknown value, so the skew is visible rather than silent. The test that asserted the throw now asserts the fallback. Review finding on dashpay/platform#4658. Co-Authored-By: Claude Fable 5.1 --- .../dashsdk/wallet/PlatformWalletManager.kt | 12 ++++++++++++ .../dashsdk/wallet/WalletStartup.kt | 15 +++++++++++++-- .../dashsdk/wallet/WalletStartupTest.kt | 11 +++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 6f6dca52c7b..022ba301aa2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2411,6 +2411,18 @@ class PlatformWalletManager( gapLimit, ) } + val rawStatus = blob.firstOrNull()?.toInt()?.and(0xFF) + if (rawStatus != null && !WalletStartupStatus.isKnownRaw(rawStatus)) { + // Append-only ABI: a newer native library reported a status + // this build predates. decode() maps it to PARTIAL_NO_IDENTITY + // (Swift parity); say so once so the mismatch is visible. + android.util.Log.w( + "PlatformWalletManager", + "startWalletSubsystems: unknown WalletStartupStatus discriminant " + + "$rawStatus from the native library; treating as PARTIAL_NO_IDENTITY " + + "(update the Kotlin SDK to match the native build)", + ) + } WalletStartupOutcome.decode(blob) } finally { runCatching { startupSigner.close() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt index 43b583ec9ea..69ac4d5dbb5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartup.kt @@ -89,9 +89,20 @@ enum class WalletStartupStatus(val raw: Int) { get() = this != PARTIAL_NO_IDENTITY && this != DISCOVERY_FAILED companion object { + /** + * The raw values are an append-only ABI, so a newer native library can + * report a status this Kotlin build predates. Treat it as + * [PARTIAL_NO_IDENTITY] rather than fail the bring-up: that is the + * conservative case (host starts Core SPV, DIP-15 rescan still owed), + * and it is what Swift does (`WalletStartupStatus(rawValue:) ?? + * .partialNoIdentity` in `PlatformWalletManagerStartup.swift`). Callers + * that want to surface the mismatch check [isKnownRaw] first. + */ fun fromRaw(raw: Int): WalletStartupStatus = - entries.firstOrNull { it.raw == raw } - ?: throw IllegalArgumentException("unknown WalletStartupStatus discriminant $raw") + entries.firstOrNull { it.raw == raw } ?: PARTIAL_NO_IDENTITY + + /** True when [raw] is a discriminant this build knows. */ + fun isKnownRaw(raw: Int): Boolean = entries.any { it.raw == raw } } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt index b6d9ec0287e..2431cadd3c5 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletStartupTest.kt @@ -91,10 +91,13 @@ class WalletStartupTest { } @Test - fun unknownDiscriminantThrows() { - assertThrows(IllegalArgumentException::class.java) { - WalletStartupOutcome.decode(blob(status = 200)) - } + fun unknownDiscriminantFallsBackLikeSwift() { + // Append-only ABI: a newer native library may report a status this + // build predates. Swift maps it to .partialNoIdentity; so do we. + val outcome = WalletStartupOutcome.decode(blob(status = 200)) + assertEquals(WalletStartupStatus.PARTIAL_NO_IDENTITY, outcome.status) + assertFalse(WalletStartupStatus.isKnownRaw(200)) + for (status in WalletStartupStatus.entries) assertTrue(WalletStartupStatus.isKnownRaw(status.raw)) } @Test