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, + ) + } + } +} 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..0fb4ac022a6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -623,6 +623,59 @@ 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| { + // 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"); + 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`