From d0356041fafdfa6a46a8bc6c975c96a5953539ff Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:43:34 -0400 Subject: [PATCH 1/5] feat(kotlin-sdk): expose core_wallet_set_gap_limit to Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust seam existed end-to-end (AddressPool::set_gap_limit -> ManagedCoreFundsAccount -> CoreWallet::set_gap_limit -> the core_wallet_set_gap_limit C export, in-tree since #3970) but stopped at the C boundary: WalletManagerNative had no trampoline, so no Kotlin host could widen an address window. Adds the JNI export (account-type mapping via the existing core_account_type; from-height guard mirrors the sibling exports), the external fun, and a ManagedCoreWallet.setGapLimit wrapper under mapNativeErrors. Motivation: a migrated wallet whose OTHER same-seed client (dashj) kept deriving past the SDK's watched window goes silently blind to the change output and every descendant — the wallet reports synced with the wrong balance. Widening the gap limit (Rust caps at 1000) and re-scanning recovers the history; the Android app's one-shot migration heal is the first consumer. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 17 ++++++++ .../dashsdk/wallet/ManagedCoreWallet.kt | 21 +++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 43 +++++++++++++++++++ 3 files changed, 81 insertions(+) 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 40bdef869c3..d0c4e9f2f53 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 @@ -128,6 +128,23 @@ internal object WalletManagerNative { /** Balance as `long[4]` = {confirmed, unconfirmed, immature, locked}. */ external fun walletGetBalance(walletHandle: Long): LongArray + /** + * Widen an account's address-pool gap limit, generating the addresses + * the wider limit now requires (capped Rust-side at MAX_GAP_LIMIT = + * 1000). The compact-filter scan watches `last used index + gap`, so + * this is the host's lever for wallets whose usage frontier advanced + * OUTSIDE the SDK's view (another client on the same seed spending + * past the default window). `accountType`: 0 BIP44, 1 BIP32, + * 2 CoinJoin — AllSpendable (3) is rejected, gap limits are + * per-account. + */ + external fun coreWalletSetGapLimit( + walletHandle: Long, + accountType: Int, + accountIndex: Int, + gapLimit: Int, + ) + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt index dbed938ea3e..37aca33c542 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -27,6 +27,27 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { check(it != 0L) { "ManagedCoreWallet has been closed" } } + /** + * Widen [accountType]/[accountIndex]'s address-pool gap limit, + * deriving the addresses the wider limit requires (Rust caps at + * 1000). Use when the seed's usage frontier moved outside the SDK's + * watched window — e.g. a migrated wallet whose other client (dashj) + * kept spending — then re-scan so the newly watched scripts match + * their history. + */ + fun setGapLimit( + accountType: CoreTransactionBuilder.AccountType, + accountIndex: Int, + gapLimit: Int, + ): Unit = mapNativeErrors { + WalletManagerNative.coreWalletSetGapLimit( + handle, + accountType.ffiValue, + accountIndex, + gapLimit, + ) + } + /** Consume and broadcast a finalized transaction. */ fun broadcastTransaction(tx: FinalizedCoreTransaction): String = WalletManagerNative.coreWalletBroadcastSignedTransaction( diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1cc5801db3e..c2f10e11541 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -623,6 +623,49 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } +/// `core_wallet_set_gap_limit` — widen an account's address-pool gap +/// limit, generating the addresses the wider limit now requires (capped +/// Rust-side at `MAX_GAP_LIMIT`). The window a compact-filter scan watches +/// is `last used index + gap`, so this is the host's lever for wallets +/// whose usage frontier moved OUTSIDE the SDK's view (another client on +/// the same seed — dashj during the migration — spending past the default +/// window; observed in the field as change addresses the SDK refused to +/// recognise). `account_type`: 0 BIP44, 1 BIP32, 2 CoinJoin +/// (3 AllSpendable is rejected — gap limits are per-account). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletSetGapLimit( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + account_type: jni::sys::jint, + account_index: jni::sys::jint, + gap_limit: jni::sys::jint, +) { + guard(&mut env, (), |env| { + let Some(account_type) = core_account_type(account_type) else { + throw_sdk_exception(env, 1, "accountType out of range (expected 0..=2)"); + return; + }; + if account_index < 0 { + throw_sdk_exception(env, 1, "accountIndex must be non-negative"); + return; + } + if gap_limit <= 0 { + throw_sdk_exception(env, 1, "gapLimit must be positive"); + return; + } + let result = unsafe { + platform_wallet_ffi::core_wallet_set_gap_limit( + wallet_handle as Handle, + account_type, + account_index as u32, + gap_limit as u32, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ───── // // The base refactor replaced the one-shot `core_wallet_send_to_addresses` From 69cacb6bbc361e9c0f93ef83af0c18cab3db30a0 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:50:07 -0400 Subject: [PATCH 2/5] fix(jni): reject the AllSpendable aggregate in coreWalletSetGapLimit A gap limit belongs to one account's address pools; the aggregate (3) has none, so refuse it at the boundary with a clear message instead of forwarding it to a per-account FFI. Co-Authored-By: Claude Fable 5 --- .../rs-unified-sdk-jni/src/wallet_manager.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c2f10e11541..0fb4ac022a6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -642,9 +642,19 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c gap_limit: jni::sys::jint, ) { guard(&mut env, (), |env| { - let Some(account_type) = core_account_type(account_type) else { - throw_sdk_exception(env, 1, "accountType out of range (expected 0..=2)"); - return; + // A gap limit belongs to ONE account's address pools; the + // AllSpendable aggregate (3) has no pool of its own, so reject it + // here rather than letting the per-account FFI fail opaquely. + let account_type = match core_account_type(account_type) { + Some(platform_wallet_ffi::CoreAccountTypeFFI::AllSpendable) | None => { + throw_sdk_exception( + env, + 1, + "accountType must be a concrete account (0=BIP44, 1=BIP32, 2=CoinJoin)", + ); + return; + } + Some(concrete) => concrete, }; if account_index < 0 { throw_sdk_exception(env, 1, "accountIndex must be non-negative"); From 25000dea588f8a3f2ea38fd92cc684793df4d270 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 09:03:34 -0400 Subject: [PATCH 3/5] test(kotlin-sdk): instrumented binding coverage for coreWalletSetGapLimit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No test invoked the new JNI export, so the Kotlin external declaration, generated symbol, parameter descriptor, and the trampoline's validation branches could regress silently (an UnsatisfiedLinkError only in production). Mirror the CoreTxBuilderOpReturnBindingTest no-wallet discipline: pin the AllSpendable (3) and unknown account-type rejections, the negative accountIndex rejection, and the non-positive gapLimit rejections to the JNI-side DashSDKException (raw code 1, branch- naming messages — which also pins the int parameter order), then prove every concrete account type (0/1/2) passes validation and crosses into core_wallet_set_gap_limit by asserting handle 0 surfaces the FFI's translated error in the platform-wallet code range instead. Validated with :sdk:compileDebugAndroidTestKotlin (BUILD SUCCESSFUL); an on-device run needs a libdash_sdk_jni.so built from this branch — the machine's only prebuilt binary predates the export. Co-Authored-By: Claude Opus 4.8 --- .../CoreWalletSetGapLimitBindingTest.kt | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreWalletSetGapLimitBindingTest.kt diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreWalletSetGapLimitBindingTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreWalletSetGapLimitBindingTest.kt new file mode 100644 index 00000000000..455d2df71d7 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreWalletSetGapLimitBindingTest.kt @@ -0,0 +1,121 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.NativeLoader +import org.dashfoundation.dashsdk.ffi.WalletManagerNative +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Binding-level coverage for the `coreWalletSetGapLimit` JNI export — the + * same no-wallet discipline as [CoreTxBuilderOpReturnBindingTest]: prove the + * Kotlin external declaration, the generated JNI symbol, and the parameter + * descriptor stay in lockstep (a naming/signature mismatch surfaces here as + * `UnsatisfiedLinkError`, not in production), and pin each trampoline + * validation branch to the exception it throws. No network, no wallet, no + * funds. + * + * Two rejection layers are asserted apart: + * - the JNI trampoline's own parameter validation throws + * [DashSDKException] with RAW code 1 (the rs-sdk-ffi InvalidParameter + * code) and a branch-naming message, BEFORE any FFI call; + * - a well-formed call with a dead handle crosses into + * `core_wallet_set_gap_limit`, whose storage miss comes back translated + * into the platform-wallet code range + * (>= [DashSdkError.PLATFORM_WALLET_CODE_OFFSET]) — proof the JNI + * validations passed and execution reached the underlying FFI's + * invalid-handle path. + * + * The branch-naming message assertions double as parameter-order pins: the + * three ints share one JNI descriptor slot type, so a swapped argument + * order in either declaration would misroute a probe into the wrong + * validation branch and fail the message check. + */ +@RunWith(AndroidJUnit4::class) +class CoreWalletSetGapLimitBindingTest { + + private fun callExpectingThrow( + handle: Long, + accountType: Int, + accountIndex: Int, + gapLimit: Int, + ): DashSDKException { + NativeLoader.ensureLoaded() + return assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(handle, accountType, accountIndex, gapLimit) + } + } + + @Test + fun allSpendableAggregateIsRejectedBeforeTheFfi() { + // 3 = AllSpendable: it pools several accounts and has no address + // pool of its own, so the trampoline rejects it up front rather + // than letting the per-account FFI fail opaquely. + val e = callExpectingThrow(0L, accountType = 3, accountIndex = 0, gapLimit = 100) + assertEquals("JNI-side parameter rejection carries raw code 1", 1, e.code) + assertTrue( + "the rejection must name the accountType branch, got: ${e.message}", + e.message.orEmpty().contains("accountType"), + ) + } + + @Test + fun unknownAccountTypeIsRejectedBeforeTheFfi() { + // Outside the mapped range entirely — the mapping's `None` arm + // shares the AllSpendable rejection. + val e = callExpectingThrow(0L, accountType = 42, accountIndex = 0, gapLimit = 100) + assertEquals(1, e.code) + assertTrue( + "the rejection must name the accountType branch, got: ${e.message}", + e.message.orEmpty().contains("accountType"), + ) + } + + @Test + fun negativeAccountIndexIsRejectedBeforeTheFfi() { + val e = callExpectingThrow(0L, accountType = 0, accountIndex = -1, gapLimit = 100) + assertEquals(1, e.code) + assertTrue( + "the rejection must name the accountIndex branch, got: ${e.message}", + e.message.orEmpty().contains("accountIndex"), + ) + } + + @Test + fun nonPositiveGapLimitIsRejectedBeforeTheFfi() { + // 0 would freeze the address frontier and a negative jint would + // otherwise bit-cast to a huge u32 — both stop at the boundary. + for (gap in intArrayOf(0, -1)) { + val e = callExpectingThrow(0L, accountType = 0, accountIndex = 0, gapLimit = gap) + assertEquals("gapLimit $gap", 1, e.code) + assertTrue( + "the rejection must name the gapLimit branch for $gap, got: ${e.message}", + e.message.orEmpty().contains("gapLimit"), + ) + } + } + + @Test + fun concreteAccountTypesReachTheFfiInvalidHandlePath() { + // 0 BIP44, 1 BIP32, 2 CoinJoin — every concrete arm of the + // trampoline's account-type mapping must pass validation and cross + // into `core_wallet_set_gap_limit`, where handle 0 can never be a + // live core wallet. The FFI's miss comes back translated into the + // platform-wallet code range — NOT the trampoline's raw code 1 — + // which proves the call left the JNI layer and the concrete arms + // are wired through. + for (accountType in intArrayOf(0, 1, 2)) { + val e = callExpectingThrow(0L, accountType, accountIndex = 0, gapLimit = 100) + assertTrue( + "type $accountType must fail inside the FFI (translated code >= " + + "${DashSdkError.PLATFORM_WALLET_CODE_OFFSET}), got ${e.code}: ${e.message}", + e.code >= DashSdkError.PLATFORM_WALLET_CODE_OFFSET, + ) + } + } +} From 6637b6841e963fa518ea3b0cf9a40f1fece9f9a3 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Mon, 17 Aug 2026 07:45:41 -0400 Subject: [PATCH 4/5] test(kotlin-sdk): pin the coreWalletSetGapLimit JNI binding on device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/platform#4377 review finding 3c43490f1527: no repository test invoked the new export, so the Kotlin external declaration, the generated JNI symbol, its parameter descriptor and the validation branches could all regress silently. `cargo check -p rs-unified-sdk-jni` proves only that the Rust compiles; it cannot catch a name or descriptor mismatch between the Kotlin `external fun` and the generated symbol, which surfaces as an UnsatisfiedLinkError on device rather than at build time. Loading the library and calling through the real declaration is the only thing that pins that contract, hence instrumented rather than unit. Follows the existing invalid-handle pattern (syncFaultDetectedSymbolBindsAndRejects InvalidHandle), so it needs no funded wallet: every case is rejected before the handle is dereferenced. Covers all three validation branches — AllSpendable (3), a negative account index, and a zero/negative gap limit — plus otherwise-valid arguments against a null handle, which proves the earlier rejections came from validation rather than from the handle. A binding failure raises UnsatisfiedLinkError, which is NOT a DashSDKException, so the assertions fail loudly instead of passing on the wrong exception. --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index d9bfe5ee81b..454154ba382 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -102,6 +102,48 @@ class FfiSmokeTest { } } + /** + * `coreWalletSetGapLimit` binds, and rejects every invalid argument. + * + * `cargo check -p rs-unified-sdk-jni` proves only the Rust side compiles; + * it cannot catch a name or descriptor mismatch between the Kotlin + * `external fun` and the generated JNI symbol, which surfaces as an + * `UnsatisfiedLinkError` on device rather than at build time. Loading the + * library and calling through the real declaration is the only thing that + * pins that contract — hence an instrumented test rather than a unit one. + * + * Needs no funded wallet: every case here is rejected before the handle is + * dereferenced, so an invalid handle is a legitimate argument. A binding + * failure surfaces as `UnsatisfiedLinkError`, which is NOT a + * `DashSDKException`, so the assertion below fails loudly rather than + * passing on the wrong exception. + */ + @Test + fun setGapLimitSymbolBindsAndRejectsInvalidArguments() { + NativeLoader.ensureLoaded() + + // AllSpendable (3) is not a single account — a gap limit is per-account. + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(0L, 3, 0, 20) + } + // Negative account index. + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(0L, 0, -1, 20) + } + // Non-positive gap limit: zero and negative. + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, 0) + } + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, -5) + } + // Otherwise-valid arguments against a null handle still fail — proving + // the earlier rejections came from validation and not from the handle. + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, 20) + } + } + /** * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve * each bridge method by (name, descriptor) only when its slot first From ceb42b680531a5d070d947d8fff7a3384f08651e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:09:59 -0400 Subject: [PATCH 5/5] test(kotlin-sdk): drop the duplicate gap-limit smoke test `CoreWalletSetGapLimitBindingTest` already pins the JNI binding on device and covers a strict superset of this method: AllSpendable, unknown account types, negative indexes, non-positive limits, all three concrete account mappings, parameter ordering via branch-specific messages, the raw JNI validation codes, and the translated FFI errors. Both classes run under the same unfiltered `:sdk:connectedDebugAndroidTest`, so the copy only bought redundant device time and a second place to update whenever the binding contract moves. Co-Authored-By: Claude Opus 4.8 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index 454154ba382..d9bfe5ee81b 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -102,48 +102,6 @@ class FfiSmokeTest { } } - /** - * `coreWalletSetGapLimit` binds, and rejects every invalid argument. - * - * `cargo check -p rs-unified-sdk-jni` proves only the Rust side compiles; - * it cannot catch a name or descriptor mismatch between the Kotlin - * `external fun` and the generated JNI symbol, which surfaces as an - * `UnsatisfiedLinkError` on device rather than at build time. Loading the - * library and calling through the real declaration is the only thing that - * pins that contract — hence an instrumented test rather than a unit one. - * - * Needs no funded wallet: every case here is rejected before the handle is - * dereferenced, so an invalid handle is a legitimate argument. A binding - * failure surfaces as `UnsatisfiedLinkError`, which is NOT a - * `DashSDKException`, so the assertion below fails loudly rather than - * passing on the wrong exception. - */ - @Test - fun setGapLimitSymbolBindsAndRejectsInvalidArguments() { - NativeLoader.ensureLoaded() - - // AllSpendable (3) is not a single account — a gap limit is per-account. - assertThrows(DashSDKException::class.java) { - WalletManagerNative.coreWalletSetGapLimit(0L, 3, 0, 20) - } - // Negative account index. - assertThrows(DashSDKException::class.java) { - WalletManagerNative.coreWalletSetGapLimit(0L, 0, -1, 20) - } - // Non-positive gap limit: zero and negative. - assertThrows(DashSDKException::class.java) { - WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, 0) - } - assertThrows(DashSDKException::class.java) { - WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, -5) - } - // Otherwise-valid arguments against a null handle still fail — proving - // the earlier rejections came from validation and not from the handle. - assertThrows(DashSDKException::class.java) { - WalletManagerNative.coreWalletSetGapLimit(0L, 0, 0, 20) - } - } - /** * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve * each bridge method by (name, descriptor) only when its slot first