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
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
53 changes: 53 additions & 0 deletions packages/rs-unified-sdk-jni/src/wallet_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +648 to +675

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Add an instrumented binding test for the new JNI export

No repository test invokes coreWalletSetGapLimit, so the Kotlin external declaration, generated JNI symbol, parameter descriptor, and the new validation branches can regress without detection. cargo check -p rs-unified-sdk-jni verifies only the Rust side and cannot detect a Kotlin/JNI naming or signature mismatch that would produce UnsatisfiedLinkError on Android. The existing Android instrumented suite already uses invalid handles to pin JNI bindings without requiring a funded wallet; add equivalent coverage that loads the native library, verifies that account type 3, a negative account index, and a non-positive gap limit are rejected as invalid parameters, then calls a concrete account type with handle 0 and verifies that execution reaches the underlying FFI's invalid-handle path.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 25000de — CoreWalletSetGapLimitBindingTest (androidTest, mirrors the existing CoreTxBuilderOpReturnBindingTest no-wallet pattern): five cases pin the validation branches (AllSpendable and unknown account types, negative accountIndex, gapLimit 0/-1) to raw code 1 with branch-naming message assertions that double as int-parameter-order pins, and each concrete account type with a dead handle must surface an FFI-translated code >= PLATFORM_WALLET_CODE_OFFSET — proving execution crossed the JNI symbol into core_wallet_set_gap_limit rather than dying in the trampoline. Compiles green (:sdk:compileDebugAndroidTestKotlin); rides the next instrumented device run since the only prebuilt local .so predates the export.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Add an instrumented binding test for the new JNI export no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

})
}

// ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─────
//
// The base refactor replaced the one-shot `core_wallet_send_to_addresses`
Expand Down
Loading