Skip to content
Merged
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 thread
bfoss765 marked this conversation as resolved.
})
}

// ── 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