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/3] 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/3] 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/3] 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, + ) + } + } +}