From 760d00f559a722d7deab02949cffbc1bdacc9491 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 21:31:58 -0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(kotlin-sdk):=20bind=20fund=5Ffrom=5Fas?= =?UTF-8?q?set=5Flock=5Fexternal=20(Core=20=E2=86=92=20third-party=20Platf?= =?UTF-8?q?orm=20address)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ManagedPlatformWallet.fundFromAssetLockExternal and resumeFundFromAssetLockExternal, a parity port of Swift's external-recipient asset-lock funding. Explicit-amount recipients may be any P2PKH Platform address; the single remainder must belong to platformAccountIndex (enforced in platform-wallet). - JNI: walletFundFromAssetLockExternal / walletResumeFundFromAssetLockExternal over the *_external_signer FFIs. The get-platform / fund / free-changeset / destroy sequence is one helper shared with the owned pair; Rust unit tests pin which FFI each scope calls. - The external fund takes a required onAssetLockBuilt callback, bridged to the FFI's AssetLockBuiltFn (daemon attach, exceptions cleared, panics caught). It reports the new lock's outpoint before the lock is persisted or broadcast: the tracked lock stores no recipients, so the outpoint is the only link between the recipients a host persisted and the lock it resumes. The owned fund still passes no callback. - A synchronous Kotlin pre-flight mirrors Swift's (non-empty, exactly one remainder, at least one explicit amount, P2PKH only, 20-byte hashes). - KDoc: persist recipients, record the outpoint, and resume only through the external resume with the same list; an owned-only resume list refunds the lock to the wallet and a different list pays the new list. - Tests: Kotlin unit tests ported from Swift plus outpoint decoding, and instrumented binding tests for all four funding exports (symbols, argument order, validation branches) without a wallet. Co-Authored-By: Claude Opus 5.5 --- packages/kotlin-sdk/PARITY.md | 2 +- .../wallet/FundFromAssetLockBindingTest.kt | 142 ++++ .../dashsdk/ffi/WalletManagerNative.kt | 54 ++ .../dashsdk/wallet/ManagedPlatformWallet.kt | 277 +++++++- .../wallet/FundFromAssetLockRecipientTest.kt | 140 ++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 630 +++++++++++++----- 6 files changed, 1060 insertions(+), 185 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index fffebbc4a8c..4b343a2a01b 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -40,7 +40,7 @@ Status legend: | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create routes to CreateDocumentScreen and replace / delete / transfer route to DocumentActionsScreen through the transition catalog) | | ~~FriendsView.swift~~ (deleted upstream) | — (retired) | retired — deleted by PR #3841 and superseded by the first-class DashPay tab; `FriendsScreen.kt` and its `Friends` route were removed in milestone K3. See the **Views/DashPay/** section below | -| FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow | +| FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow. Third-party payee: `ManagedPlatformWallet.fundFromAssetLockExternal` / `resumeFundFromAssetLockExternal` bind `platform_address_wallet_[resume_]fund_from_asset_lock_external_signer` (SDK only, no UI — Swift's example app has none either); the external fund takes a required `onAssetLockBuilt` callback reporting the lock's outpoint before broadcast, so hosts can bind persisted recipients to the lock they resume (the owned fund still passes none) | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | | GroupDetailView.swift | ui/contracts/GroupDetailScreen.kt · `GroupDetail` | ported — members resolved against local identities; adds live open-proposals via bridged `Groups.pendingActions` | diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt new file mode 100644 index 00000000000..de5ff008516 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt @@ -0,0 +1,142 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.ffi.AssetLockBuiltBridge +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.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.nio.ByteBuffer + +/** + * Binding-level coverage for the four asset-lock funding JNI exports + * (`walletFundFromAssetLock[External]`, `walletResumeFundFromAssetLock[External]`) + * — the no-wallet discipline of [CoreWalletSetGapLimitBindingTest]: a + * symbol or descriptor mismatch surfaces here as `UnsatisfiedLinkError`, + * and each JNI validation branch is pinned to the message it throws, which + * doubles as a parameter-order pin (the ints and longs share descriptor + * slots, so swapped arguments would land in the wrong branch). No network, + * no wallet, no funds: every call either stops at JNI validation (raw code + * 1) or crosses into the FFI with the dead handle 0 and fails there (code in + * the platform-wallet range). + * + * Which FFI each scope calls (owned vs external) is pinned Rust-side by + * `wallet_manager::tests::should_route_each_*_scope_to_its_own_ffi`. + */ +@RunWith(AndroidJUnit4::class) +class FundFromAssetLockBindingTest { + + private val noopBridge = object : AssetLockBuiltBridge() { + override fun onAssetLockBuilt(outPoint: ByteArray) = Unit + } + + /** One explicit-amount payee and one remainder, P2PKH. */ + private val validBlob: ByteArray = ByteBuffer.allocate(4 + 2 * 30).apply { + putInt(2) + put(0); put(ByteArray(20) { 0x0B }); put(1); putLong(500L) + put(0); put(ByteArray(20) { 0x0A }); put(0); putLong(0L) + }.array() + + /** Claims two rows but carries only one. */ + private val truncatedBlob: ByteArray = validBlob.copyOfRange(0, 4 + 30) + + @Before + fun loadNative() = NativeLoader.ensureLoaded() + + private fun fund( + external: Boolean, + amountDuffs: Long = 100_000L, + accountIndex: Int = 0, + platformAccountIndex: Int = 0, + blob: ByteArray = validBlob, + ): DashSDKException = assertThrows(DashSDKException::class.java) { + if (external) { + WalletManagerNative.walletFundFromAssetLockExternal( + 0L, amountDuffs, accountIndex, platformAccountIndex, blob, 0L, 0L, noopBridge, + ) + } else { + WalletManagerNative.walletFundFromAssetLock( + 0L, amountDuffs, accountIndex, platformAccountIndex, blob, 0L, 0L, + ) + } + } + + private fun resume( + external: Boolean, + txid: ByteArray = ByteArray(32), + vout: Int = 0, + platformAccountIndex: Int = 0, + blob: ByteArray = validBlob, + ): DashSDKException = assertThrows(DashSDKException::class.java) { + if (external) { + WalletManagerNative.walletResumeFundFromAssetLockExternal( + 0L, txid, vout, platformAccountIndex, blob, 0L, 0L, + ) + } else { + WalletManagerNative.walletResumeFundFromAssetLock( + 0L, txid, vout, platformAccountIndex, blob, 0L, 0L, + ) + } + } + + private fun assertJniRejection(e: DashSDKException, branch: String) { + assertEquals("JNI-side rejection carries raw code 1: ${e.message}", 1, e.code) + assertTrue( + "the rejection must name the $branch branch, got: ${e.message}", + e.message.orEmpty().startsWith(branch), + ) + } + + private fun assertReachedFfi(e: DashSDKException) { + assertTrue( + "must fail inside the FFI (code >= ${DashSdkError.PLATFORM_WALLET_CODE_OFFSET}), " + + "got ${e.code}: ${e.message}", + e.code >= DashSdkError.PLATFORM_WALLET_CODE_OFFSET, + ) + } + + @Test + fun shouldRejectBadFundArgumentsBeforeTheFfi() { + for (external in listOf(false, true)) { + assertJniRejection(fund(external, amountDuffs = 0L), "amountDuffs") + assertJniRejection(fund(external, amountDuffs = -1L), "amountDuffs") + assertJniRejection(fund(external, accountIndex = -1), "accountIndex") + assertJniRejection(fund(external, platformAccountIndex = -1), "platformAccountIndex") + assertJniRejection(fund(external, blob = truncatedBlob), "recipients blob") + } + } + + @Test + fun shouldRejectBadResumeArgumentsBeforeTheFfi() { + for (external in listOf(false, true)) { + assertJniRejection(resume(external, vout = -1), "outPointVout") + assertJniRejection(resume(external, platformAccountIndex = -1), "platformAccountIndex") + assertJniRejection(resume(external, txid = ByteArray(31)), "outPointTxid") + assertJniRejection(resume(external, blob = truncatedBlob), "recipients blob") + } + } + + @Test + fun shouldReachTheFfiWithWellFormedArguments() { + for (external in listOf(false, true)) { + assertReachedFfi(fund(external)) + assertReachedFfi(resume(external)) + } + } + + @Test + fun shouldAcceptANullBuiltBridge() { + val e = assertThrows(DashSDKException::class.java) { + WalletManagerNative.walletFundFromAssetLockExternal( + 0L, 100_000L, 0, 0, validBlob, 0L, 0L, null, + ) + } + assertReachedFfi(e) + } +} 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 58351c59fbb..13cba8439fb 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 @@ -396,6 +396,46 @@ internal object WalletManagerNative { coreSignerHandle: Long, ): ByteArray + /** + * Pay a third party's Platform address from a Core L1 asset lock built + * from the wallet balance. Sibling of [walletFundFromAssetLock] with the + * same parameters and blob layouts; binds + * `platform_address_wallet_fund_from_asset_lock_external_signer`, which + * lets explicit-amount rows be any P2PKH address while the single + * remainder row must still belong to [platformAccountIndex]. The + * returned changeset holds only the wallet's own addresses. + * + * @param onAssetLockBuilt called at most once with the new lock's raw + * 36-byte outpoint, before the lock is persisted or broadcast (see + * [AssetLockBuiltBridge]); null for no callback. + */ + external fun walletFundFromAssetLockExternal( + walletHandle: Long, + amountDuffs: Long, + accountIndex: Int, + platformAccountIndex: Int, + recipientsBlob: ByteArray, + signerHandle: Long, + coreSignerHandle: Long, + onAssetLockBuilt: AssetLockBuiltBridge?, + ): ByteArray + + /** + * Resume a stuck external-recipient funding from an already-tracked lock. + * Sibling of [walletResumeFundFromAssetLock] with the relaxed recipient + * rule of [walletFundFromAssetLockExternal]; binds + * `platform_address_wallet_resume_fund_from_asset_lock_external_signer`. + */ + external fun walletResumeFundFromAssetLockExternal( + walletHandle: Long, + outPointTxid: ByteArray, + outPointVout: Int, + platformAccountIndex: Int, + recipientsBlob: ByteArray, + signerHandle: Long, + coreSignerHandle: Long, + ): ByteArray + // ── Wallet-signed Platform-address credit movement (ADDR-02/04) ─── /** @@ -746,3 +786,17 @@ internal object WalletManagerNative { gapLimit: Int, ): ByteArray } + +/** + * Native → Kotlin bridge for the asset-lock-built callback of + * [WalletManagerNative.walletFundFromAssetLockExternal]. Rust invokes + * [onAssetLockBuilt] at most once per call, on the SDK's runtime worker + * thread (the JNI trampoline attaches it as a daemon), after the lock + * transaction is signed and before the lock's row is persisted or the lock + * is broadcast. An exception thrown here is cleared by the trampoline and + * does not stop the funding. + */ +abstract class AssetLockBuiltBridge { + /** @param outPoint 36 bytes: 32-byte txid (wire order) then the 4-byte little-endian vout. */ + abstract fun onAssetLockBuilt(outPoint: ByteArray) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index b0710595759..118c431f592 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -3,9 +3,11 @@ package org.dashfoundation.dashsdk.wallet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.AssetLockBuiltBridge import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative import org.dashfoundation.dashsdk.ffi.WalletManagerNative +import org.dashfoundation.dashsdk.persistence.encodeOutPointHex import org.dashfoundation.dashsdk.tokens.translateManagedIdentityNotFoundToZero import java.util.concurrent.atomic.AtomicLong @@ -723,6 +725,48 @@ class ManagedPlatformWallet internal constructor( (31 * addressType + hash.contentHashCode()) * 31 + (credits?.hashCode() ?: 0) } + /** + * The outpoint of a newly built asset lock, as reported to + * [fundFromAssetLockExternal]'s `onAssetLockBuilt` — port of Swift's + * `AssetLockBuiltOutPoint`. + * + * @property txid 32-byte txid in wire (little-endian) order — the order + * [resumeFundFromAssetLockExternal] takes. + * @property vout output index of the credit output. + * @property outPointHex `:`, the key of the + * lock's persisted `AssetLockEntity` row. + */ + class AssetLockBuiltOutPoint private constructor( + val txid: ByteArray, + val vout: Int, + val outPointHex: String, + ) { + override fun equals(other: Any?): Boolean = + other is AssetLockBuiltOutPoint && txid.contentEquals(other.txid) && vout == other.vout + + override fun hashCode(): Int = 31 * txid.contentHashCode() + vout + + override fun toString(): String = "AssetLockBuiltOutPoint($outPointHex)" + + companion object { + /** + * Decode the 36 bytes the FFI reports (`txid_le[32] || vout_le[4]`). + * + * @throws IllegalArgumentException for any other length. + */ + fun fromRawBytes(raw: ByteArray): AssetLockBuiltOutPoint { + require(raw.size == 36) { "outpoint must be 36 bytes, got ${raw.size}" } + val vout = java.nio.ByteBuffer.wrap(raw, 32, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).int + return AssetLockBuiltOutPoint( + txid = raw.copyOfRange(0, 32), + vout = vout, + outPointHex = encodeOutPointHex(raw), + ) + } + } + } + /** One updated address balance from a funding changeset. */ data class UpdatedBalance(val addressType: Int, val hash: ByteArray, val balance: Long) { override fun equals(other: Any?): Boolean = @@ -811,6 +855,160 @@ class ManagedPlatformWallet internal constructor( decodeChangeset(blob) } + /** + * Pay a THIRD PARTY's Platform address from a Core L1 asset lock built + * from this wallet's balance, keeping the remainder (change) on one of + * this wallet's own addresses — port of + * `ManagedPlatformAddressWallet.fundFromAssetLockExternal`. + * + * Sibling of [fundFromAssetLock]: same orchestration, same parameters, + * same return type. The only difference is the Rust recipient pre-flight: + * explicit-amount recipients may be ANY valid P2PKH Platform address, not + * just members of [platformAccountIndex]. The single remainder recipient + * must still belong to [platformAccountIndex], so change and fee always + * land on an owned address. + * + * Whether a recipient may be a third party is decided by WHICH function + * you call, not by a flag on [FundRecipient]. Keep using + * [fundFromAssetLock] for self-funding: there a mistyped address is + * rejected before anything is broadcast, while here it is a valid and + * IRREVERSIBLE payment to a stranger. + * + * ## Failure and resume — bind the recipients to the lock + * The lock is tracked as `AssetLockFundingType.AssetLockAddressTopUp`, + * exactly like an owned funding. The tracked lock stores NO recipients + * and NO owned/external flag: Rust only knows the outpoint, its status + * and its proof. If the Platform step fails, the lock stays non-Consumed + * and shows as resumable, and resume takes the recipients from the caller + * again. Therefore: + * - Persist the recipient list (address and amount) under an id of your + * own BEFORE calling this. + * - In [onAssetLockBuilt], durably record the reported outpoint against + * that id. It is the only link between the persisted recipients and + * the lock: with more than one funding in flight, matching by amount + * or timing can pick the wrong lock after a restart. + * - Resume a lock recorded this way with exactly its list through + * [resumeFundFromAssetLockExternal]. Any other route is wrong: + * [resumeFundFromAssetLock] with this list fails closed (its owned-only + * pre-flight rejects the third-party output before submit), but with an + * owned-only list — e.g. a generic "resume top-up" screen that picks a + * fresh own address — it succeeds and returns the whole lock to the + * wallet, leaving the payee unpaid; and resuming with a different list + * silently pays the new list. + * + * @param amountDuffs amount to lock, in Core duffs. + * @param fundingAccountIndex standard-family source index — see + * [fundFromAssetLock]. + * @param platformAccountIndex Platform-payment account that owns the + * REMAINDER recipient. Explicit-amount recipients need not belong to it. + * @param recipients exactly one entry with `credits = null` (the + * remainder, an address of this wallet) and at least one entry with an + * explicit amount; P2PKH (`addressType = 0`) only. A remainder-only + * list is rejected: it pays nobody else, so use [fundFromAssetLock]. + * @param signerHandle the platform-address per-input `SignerHandle`. + * @param coreSignerHandle the manager's `MnemonicResolverHandle` for the + * asset-lock's outer state-transition signature. + * @param onAssetLockBuilt called at most once with the new lock's + * outpoint, after the lock transaction is signed and before the lock's + * row is persisted or the lock is broadcast; it is not called when the + * build itself fails. It runs on the SDK's runtime thread, inside this + * call: record the outpoint synchronously (durably, to survive a + * restart) before returning, keep it short, and do not call any wallet + * or SDK function from it. It cannot stop the funding: an exception it + * throws is discarded and the lock is still broadcast and submitted. + * @return updated balances for the recipients that belong to this wallet + * — in practice only the remainder address. The third party's credit + * is proven on Platform but has no local row, so it is not included. + * @throws IllegalArgumentException if the arguments fail the pre-flight + * ([fundFromAssetLockExternalPreflight]); nothing is broadcast then. + */ + suspend fun fundFromAssetLockExternal( + amountDuffs: Long, + fundingAccountIndex: Int, + platformAccountIndex: Int, + recipients: List, + signerHandle: Long, + coreSignerHandle: Long, + onAssetLockBuilt: (AssetLockBuiltOutPoint) -> Unit, + ): List = gate.op { + require(amountDuffs > 0) { "amountDuffs must be positive, got $amountDuffs" } + require(fundingAccountIndex >= 0) { + "fundingAccountIndex must be non-negative, got $fundingAccountIndex" + } + require(platformAccountIndex >= 0) { + "platformAccountIndex must be non-negative, got $platformAccountIndex" + } + fundFromAssetLockExternalPreflight(recipients) + val bridge = object : AssetLockBuiltBridge() { + override fun onAssetLockBuilt(outPoint: ByteArray) = + onAssetLockBuilt(AssetLockBuiltOutPoint.fromRawBytes(outPoint)) + } + val blob = mapNativeErrors { + WalletManagerNative.walletFundFromAssetLockExternal( + walletHandle = handle, + amountDuffs = amountDuffs, + accountIndex = fundingAccountIndex, + platformAccountIndex = platformAccountIndex, + recipientsBlob = encodeRecipients(recipients), + signerHandle = signerHandle, + coreSignerHandle = coreSignerHandle, + onAssetLockBuilt = bridge, + ) + } + decodeChangeset(blob) + } + + /** + * Resume a stuck external-recipient funding from an already-tracked asset + * lock by outpoint — port of + * `ManagedPlatformAddressWallet.resumeFundFromAssetLockExternal`. + * + * Sibling of [resumeFundFromAssetLock] (same resume-by-outpoint shape) + * and of [fundFromAssetLockExternal] (same relaxed recipient rules). + * + * The recipients are a PARAMETER, not something recovered from the + * tracked lock: Rust never learns the destination of an address-funding + * asset lock. Pass exactly the list persisted before the original + * [fundFromAssetLockExternal] call, found through the outpoint its + * `onAssetLockBuilt` reported. The asset lock is a bearer input, + * not a commitment to a destination, so a different list pays the new + * list. + * + * @param outPointTxid 32-byte raw txid (little-endian wire order). + * @param outPointVout funding output index. + * @throws IllegalArgumentException if the arguments fail the pre-flight; + * nothing is submitted then. + */ + suspend fun resumeFundFromAssetLockExternal( + outPointTxid: ByteArray, + outPointVout: Int, + platformAccountIndex: Int, + recipients: List, + signerHandle: Long, + coreSignerHandle: Long, + ): List = gate.op { + require(outPointTxid.size == 32) { + "outPointTxid must be exactly 32 bytes, got ${outPointTxid.size}" + } + require(outPointVout >= 0) { "outPointVout must be non-negative, got $outPointVout" } + require(platformAccountIndex >= 0) { + "platformAccountIndex must be non-negative, got $platformAccountIndex" + } + fundFromAssetLockExternalPreflight(recipients) + val blob = mapNativeErrors { + WalletManagerNative.walletResumeFundFromAssetLockExternal( + walletHandle = handle, + outPointTxid = outPointTxid, + outPointVout = outPointVout, + platformAccountIndex = platformAccountIndex, + recipientsBlob = encodeRecipients(recipients), + signerHandle = signerHandle, + coreSignerHandle = coreSignerHandle, + ) + } + decodeChangeset(blob) + } + // ── Wallet-signed Platform-address credit movement (ADDR-02/04) ─── /** One recipient of a wallet-signed platform-address transfer. */ @@ -1132,24 +1330,6 @@ class ManagedPlatformWallet internal constructor( return out.toByteArray() } - /** - * Encode [recipients] to the funding-recipients blob the FFI reads: - * `u32 rowCount` then per row `u8 addressType, u8[20] hash, - * u8 hasBalance (0/1), u64 balance`. - */ - private fun encodeRecipients(recipients: List): ByteArray { - val out = java.io.ByteArrayOutputStream() - val dos = java.io.DataOutputStream(out) - dos.writeInt(recipients.size) - for (r in recipients) { - dos.writeByte(r.addressType) - dos.write(r.hash) - dos.writeByte(if (r.credits != null) 1 else 0) - dos.writeLong(r.credits ?: 0L) - } - return out.toByteArray() - } - /** * Decode a funding changeset blob (`u32 rowCount` then per row * `u8 addressType, u8[20] hash, u64 balance`) into [UpdatedBalance]s. @@ -1201,6 +1381,67 @@ class ManagedPlatformWallet internal constructor( return out } + internal companion object { + /** + * Encode [recipients] to the funding-recipients blob the FFI reads: + * `u32 rowCount` then per row `u8 addressType, u8[20] hash, + * u8 hasBalance (0/1), u64 balance`. + * + * Pure marshalling: one row per recipient, in the caller's order. + * Rust collects the rows into a map, so order carries no meaning, + * and it derives the fee-paying output itself + * (`remainder_fee_strategy`) — Kotlin never computes a fee strategy. + */ + internal fun encodeRecipients(recipients: List): ByteArray { + val out = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(out) + dos.writeInt(recipients.size) + for (r in recipients) { + dos.writeByte(r.addressType) + dos.write(r.hash) + dos.writeByte(if (r.credits != null) 1 else 0) + dos.writeLong(r.credits ?: 0L) + } + return out.toByteArray() + } + + /** + * Synchronous pre-flight for [fundFromAssetLockExternal] / + * [resumeFundFromAssetLockExternal] — port of Swift's + * `fundFromAssetLockExternalPreflight`. Rust enforces the same rules + * (plus "the remainder must be ours", which only it can check); this + * gives the caller an error before any native work. + * + * Rules: a non-empty list; exactly one remainder (`credits = null`); + * at least one explicit-amount recipient (a remainder-only request + * pays nobody else — use [fundFromAssetLock]); P2PKH + * (`addressType = 0`) only; 20-byte hashes. + * + * @throws IllegalArgumentException when a rule is violated. + */ + internal fun fundFromAssetLockExternalPreflight(recipients: List) { + require(recipients.isNotEmpty()) { "recipients is empty" } + val remainderCount = recipients.count { it.credits == null } + require(remainderCount == 1) { + "Exactly one recipient must have credits = null (the remainder recipient), " + + "found $remainderCount" + } + require(recipients.any { it.credits != null }) { + "fundFromAssetLockExternal requires at least one explicit-amount recipient; " + + "a remainder-only request funds nothing externally — use fundFromAssetLock" + } + for (r in recipients) { + require(r.addressType == 0) { + "FundRecipient.addressType must be 0 (P2PKH) for platform-address " + + "funding (got ${r.addressType})" + } + require(r.hash.size == 20) { + "FundRecipient.hash must be exactly 20 bytes (got ${r.hash.size})" + } + } + } + } + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { override fun run() { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt new file mode 100644 index 00000000000..dcaf24a74a0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt @@ -0,0 +1,140 @@ +package org.dashfoundation.dashsdk.wallet + +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.Companion.encodeRecipients +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.Companion.fundFromAssetLockExternalPreflight +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.AssetLockBuiltOutPoint +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.FundRecipient +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.nio.ByteBuffer + +/** + * Pins the two host-side responsibilities of the asset-lock funding surface — + * marshalling recipients faithfully, and rejecting recipient lists Kotlin can + * check synchronously. Port of Swift's `FundFromAssetLockRecipientTests`. + * + * The fee-paying output (`ReduceOutput(index)`) is deliberately NOT derived + * here: it is positional against the transition's outputs map (a consensus + * ordering rule), so `platform-wallet` derives it (`remainder_fee_strategy`). + */ +class FundFromAssetLockRecipientTest { + + private fun recipient(tag: Int, credits: Long?, type: Int = 0) = + FundRecipient(addressType = type, hash = ByteArray(20) { tag.toByte() }, credits = credits) + + private data class Row(val type: Int, val hash: ByteArray, val hasBalance: Boolean, val balance: Long) + + private fun decode(blob: ByteArray): List { + val buffer = ByteBuffer.wrap(blob) + val rows = List(buffer.int) { + val type = buffer.get().toInt() and 0xFF + val hash = ByteArray(20).also { buffer.get(it) } + val hasBalance = buffer.get().toInt() != 0 + Row(type, hash, hasBalance, buffer.long) + } + assertEquals("trailing bytes after the last row", 0, buffer.remaining()) + return rows + } + + // ── Marshalling ─────────────────────────────────────────────────── + + @Test + fun shouldEncodeRecipientsOneForOneInCallerOrder() { + val alice = recipient(0x0A, credits = null) + val bob = recipient(0xBB, credits = 500) + val carol = recipient(0xCC, credits = 700) + + val rows = decode(encodeRecipients(listOf(bob, carol, alice))) + + assertEquals(3, rows.size) + rows.zip(listOf(bob, carol, alice)).forEach { (row, r) -> assertArrayEquals(r.hash, row.hash) } + assertEquals(listOf(true, true, false), rows.map { it.hasBalance }) + assertEquals(listOf(500L, 700L, 0L), rows.map { it.balance }) + assertEquals(listOf(0, 0, 0), rows.map { it.type }) + } + + @Test + fun shouldEncodeNoFeeStrategyOnlyTheRemainderFlag() { + val blob = encodeRecipients(listOf(recipient(0xCC, credits = 700), recipient(0x0A, credits = null))) + // The blob is exactly the address rows: 4-byte count + 30 bytes per + // row. The remainder is identified by hasBalance = 0, never by an + // index computed on this side of the boundary. + assertEquals(4 + 2 * 30, blob.size) + assertEquals(1, decode(blob).count { !it.hasBalance }) + } + + // ── External pre-flight ─────────────────────────────────────────── + + @Test + fun shouldAcceptThirdPartyPayeeWithOwnRemainder() { + fundFromAssetLockExternalPreflight( + listOf(recipient(0xBB, credits = 500), recipient(0x0A, credits = null)), + ) + } + + @Test + fun shouldRejectRemainderOnlyRequest() { + // Nothing is paid externally — a call-site mistake; fundFromAssetLock + // validates the destination properly. + assertThrows(IllegalArgumentException::class.java) { + fundFromAssetLockExternalPreflight(listOf(recipient(0x0A, credits = null))) + } + } + + @Test + fun shouldRejectP2shAndBadHashLengths() { + assertThrows(IllegalArgumentException::class.java) { + fundFromAssetLockExternalPreflight( + listOf(recipient(0xBB, credits = 500, type = 1), recipient(0x0A, credits = null)), + ) + } + // A wrong-length hash cannot even be constructed, so it never reaches + // the pre-flight or the native blob. + listOf(19, 21, 0).forEach { size -> + assertThrows(IllegalArgumentException::class.java) { + FundRecipient(addressType = 0, hash = ByteArray(size), credits = 500) + } + } + } + + @Test + fun shouldRejectWrongRemainderCount() { + listOf( + emptyList(), + listOf(recipient(0x0A, credits = 500), recipient(0xBB, credits = 700)), + listOf(recipient(0x0A, credits = null), recipient(0xBB, credits = null)), + listOf(recipient(0x0A, credits = null), recipient(0xBB, credits = null), recipient(0xCC, credits = 1)), + ).forEach { recipients -> + assertThrows("accepted $recipients", IllegalArgumentException::class.java) { + fundFromAssetLockExternalPreflight(recipients) + } + } + } + + // ── Asset-lock-built outpoint ───────────────────────────────────── + + @Test + fun shouldDecodeTheReportedOutpointLikeThePersistedRowKey() { + val txidWire = ByteArray(32) { it.toByte() } + val raw = txidWire + byteArrayOf(0x02, 0x01, 0x00, 0x00) // vout 258, little-endian + + val outPoint = AssetLockBuiltOutPoint.fromRawBytes(raw) + + assertArrayEquals(txidWire, outPoint.txid) + assertEquals(258, outPoint.vout) + // Display order is the byte-reversed txid — the AssetLockEntity key. + val displayHex = txidWire.reversedArray().joinToString("") { "%02x".format(it) } + assertEquals("$displayHex:258", outPoint.outPointHex) + } + + @Test + fun shouldRejectAnOutpointThatIsNot36Bytes() { + listOf(0, 35, 37).forEach { size -> + assertThrows(IllegalArgumentException::class.java) { + AssetLockBuiltOutPoint.fromRawBytes(ByteArray(size)) + } + } + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 4dfc8cbadef..10f2da961a6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -44,8 +44,8 @@ use crate::events::{build_event_extension, build_event_vtable, KotlinEventCtx}; use crate::persistence::{ build_extension, build_vtable, restrict_account_writers, KotlinPersistenceCtx, }; -use crate::support::{guard, take_pwffi_error, throw_pwffi_result, throw_sdk_exception}; -use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString, JValue}; +use crate::support::{guard, take_pwffi_error, throw_pwffi_result, throw_sdk_exception, JVM}; +use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::{ jboolean, jbyteArray, jdoubleArray, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE, @@ -60,6 +60,7 @@ use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabili use platform_wallet_ffi::types::IdentifierArray; use std::ffi::{c_void, CStr}; use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; #[cfg(feature = "shielded")] @@ -1952,6 +1953,328 @@ unsafe fn encode_changeset( blob } +/// Which recipient-ownership rule an asset-lock funding call asks +/// `platform-wallet` to enforce. The JNI bodies are otherwise identical, so +/// the choice is a single parameter rather than four copies of the +/// get/fund/free/destroy sequence. +#[derive(Clone, Copy)] +enum FundRecipientScope { + /// Every recipient must belong to `platformAccountIndex` + /// (`platform_address_wallet_*fund_from_asset_lock_signer`). + Owned, + /// Explicit-amount recipients may be any P2PKH address; the single + /// remainder must still belong to `platformAccountIndex` + /// (`platform_address_wallet_*fund_from_asset_lock_external_signer`). + External, +} + +/// Run one asset-lock funding FFI against a transient platform-address +/// wallet handle and return its changeset as a `byte[]` blob. +/// +/// Resolves the handle (`platform_wallet_get_platform`), calls `fund` with it +/// and an empty changeset, encodes + frees the changeset on success, then +/// destroys the handle. The FFI error (if any) is thrown; a destroy error is +/// thrown only when nothing was thrown before it. Returns null when an +/// exception is pending. +fn run_address_funding(env: &mut JNIEnv, wallet_handle: jlong, fund: F) -> jbyteArray +where + F: FnOnce( + Handle, + *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, + ) -> PlatformWalletFFIResult, +{ + let mut addr_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_platform( + wallet_handle as Handle, + &mut addr_handle as *mut Handle, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + + let mut changeset = platform_wallet_ffi::PlatformAddressChangeSetFFI { + updated: ptr::null_mut(), + updated_count: 0, + }; + let fund_result = fund( + addr_handle, + &mut changeset as *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, + ); + + let out = if take_pwffi_error(env, fund_result) { + ptr::null_mut() + } else { + let blob = unsafe { encode_changeset(&changeset) }; + unsafe { + platform_wallet_ffi::platform_address_wallet_free_changeset( + &changeset as *const platform_wallet_ffi::PlatformAddressChangeSetFFI, + ) + }; + env.byte_array_from_slice(&blob) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }; + + let destroy_result = + unsafe { platform_wallet_ffi::platform_address_wallet_destroy(addr_handle) }; + if !env.exception_check().unwrap_or(false) { + let _ = take_pwffi_error(env, destroy_result); + } else { + unsafe { platform_wallet_ffi_result_free(&mut { destroy_result }) }; + } + + out +} + +/// Signature shared by the two fresh-build address-funding FFIs. +type FundFromAssetLockFfi = unsafe extern "C" fn( + Handle, + u64, + u32, + u32, + *const platform_wallet_ffi::FundingAddressEntryFFI, + usize, + *const platform_wallet_ffi::FeeStrategyStepFFI, + usize, + *mut rs_sdk_ffi::SignerHandle, + *mut rs_sdk_ffi::MnemonicResolverHandle, + *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, + platform_wallet_ffi::AssetLockBuiltFn, + *mut c_void, +) -> PlatformWalletFFIResult; + +/// Signature shared by the two resume address-funding FFIs. +type ResumeFundFromAssetLockFfi = unsafe extern "C" fn( + Handle, + *const platform_wallet_ffi::OutPointFFI, + u32, + *const platform_wallet_ffi::FundingAddressEntryFFI, + usize, + *const platform_wallet_ffi::FeeStrategyStepFFI, + usize, + *mut rs_sdk_ffi::SignerHandle, + *mut rs_sdk_ffi::MnemonicResolverHandle, + *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, +) -> PlatformWalletFFIResult; + +/// The fresh-build FFI that enforces `scope`'s recipient rule. +fn fund_ffi_for(scope: FundRecipientScope) -> FundFromAssetLockFfi { + match scope { + FundRecipientScope::Owned => { + platform_wallet_ffi::platform_address_wallet_fund_from_asset_lock_signer + } + FundRecipientScope::External => { + platform_wallet_ffi::platform_address_wallet_fund_from_asset_lock_external_signer + } + } +} + +/// The resume FFI that enforces `scope`'s recipient rule. +fn resume_ffi_for(scope: FundRecipientScope) -> ResumeFundFromAssetLockFfi { + match scope { + FundRecipientScope::Owned => { + platform_wallet_ffi::platform_address_wallet_resume_fund_from_asset_lock_signer + } + FundRecipientScope::External => { + platform_wallet_ffi::platform_address_wallet_resume_fund_from_asset_lock_external_signer + } + } +} + +/// `AssetLockBuiltFn` trampoline into a Kotlin `AssetLockBuiltBridge`. +/// +/// Calls `bridge.onAssetLockBuilt(byte[36])` with the raw outpoint +/// (`txid_le[32] || vout_le[4]`). Rust invokes it at most once, on the +/// funding call's worker thread, after the lock transaction is signed and +/// before its row is persisted or it is broadcast. The thread is attached as +/// a daemon; a Kotlin exception is cleared and a panic caught, because the +/// callback cannot fail the funding (fire-and-forget, like +/// `seed_pool_progress_trampoline`). +/// +/// # Safety +/// `context`, when non-null, must be a live `GlobalRef` pointer for the +/// duration of the funding call; `out_point_36`, when non-null, must point +/// to 36 readable bytes for the duration of this call. +unsafe extern "C" fn asset_lock_built_trampoline(context: *mut c_void, out_point_36: *const u8) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if context.is_null() || out_point_36.is_null() { + return; + } + let Some(vm) = JVM.get() else { return }; + let Ok(mut env) = vm.attach_current_thread_as_daemon() else { + return; + }; + // SAFETY: `context` is a live GlobalRef pinned for the call duration. + let bridge = &*(context as *const GlobalRef); + // SAFETY: the FFI hands 36 valid bytes for the duration of this call. + let bytes = std::slice::from_raw_parts(out_point_36, 36); + let call = env.with_local_frame(4, |env| -> Result<(), jni::errors::Error> { + let arr = env.byte_array_from_slice(bytes)?; + env.call_method( + bridge.as_obj(), + "onAssetLockBuilt", + "([B)V", + &[JValue::Object(&arr)], + )?; + Ok(()) + }); + if call.is_err() { + let _ = env.exception_clear(); + } + })); +} + +/// Shared body of `walletFundFromAssetLock` / `walletFundFromAssetLockExternal`. +/// +/// `on_built` is the optional Kotlin `AssetLockBuiltBridge`; it is held as a +/// `GlobalRef` until the blocking FFI call has returned, so the trampoline +/// can never see a freed reference. +#[allow(clippy::too_many_arguments)] +fn fund_from_asset_lock( + env: &mut JNIEnv, + scope: FundRecipientScope, + wallet_handle: jlong, + amount_duffs: jlong, + account_index: jni::sys::jint, + platform_account_index: jni::sys::jint, + recipients_blob: &JByteArray, + signer_handle: jlong, + core_signer_handle: jlong, + on_built: &JObject, +) -> jbyteArray { + // Reject sign errors at the boundary — negatives would otherwise + // bit-cast to huge unsigned values on the FFI call. + if amount_duffs <= 0 { + throw_sdk_exception(env, 1, "amountDuffs must be positive"); + return ptr::null_mut(); + } + if account_index < 0 { + throw_sdk_exception(env, 1, "accountIndex must be non-negative"); + return ptr::null_mut(); + } + if platform_account_index < 0 { + throw_sdk_exception(env, 1, "platformAccountIndex must be non-negative"); + return ptr::null_mut(); + } + + let Some(entries) = decode_funding_recipients(env, recipients_blob) else { + return ptr::null_mut(); + }; + + // Box the GlobalRef so the trampoline gets a stable `*const GlobalRef`. + let built_global: Option> = if on_built.is_null() { + None + } else { + match env.new_global_ref(on_built) { + Ok(g) => Some(Box::new(g)), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "NewGlobalRef(asset-lock-built bridge) failed"); + return ptr::null_mut(); + } + } + }; + let (built_fn, built_ctx): (platform_wallet_ffi::AssetLockBuiltFn, *mut c_void) = + match built_global.as_ref() { + Some(g) => ( + Some(asset_lock_built_trampoline), + g.as_ref() as *const GlobalRef as *mut c_void, + ), + None => (None, ptr::null_mut()), + }; + + let ffi = fund_ffi_for(scope); + let out = run_address_funding(env, wallet_handle, |addr_handle, changeset| unsafe { + ffi( + addr_handle, + amount_duffs as u64, + account_index as u32, + platform_account_index as u32, + entries.as_ptr(), + entries.len(), + // The fee strategy is derived Rust-side; these are ignored. + ptr::null(), + 0, + signer_handle as *mut rs_sdk_ffi::SignerHandle, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + changeset, + built_fn, + built_ctx, + ) + }); + // The FFI can no longer fire the callback; release the bridge now. + drop(built_global); + out +} + +/// Shared body of `walletResumeFundFromAssetLock` / +/// `walletResumeFundFromAssetLockExternal`. +#[allow(clippy::too_many_arguments)] +fn resume_fund_from_asset_lock( + env: &mut JNIEnv, + scope: FundRecipientScope, + wallet_handle: jlong, + out_point_txid: &JByteArray, + out_point_vout: jni::sys::jint, + platform_account_index: jni::sys::jint, + recipients_blob: &JByteArray, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jbyteArray { + // Reject sign errors at the boundary — negatives would otherwise + // bit-cast to huge u32s on the FFI call. + if out_point_vout < 0 { + throw_sdk_exception(env, 1, "outPointVout must be non-negative"); + return ptr::null_mut(); + } + if platform_account_index < 0 { + throw_sdk_exception(env, 1, "platformAccountIndex must be non-negative"); + return ptr::null_mut(); + } + + let txid = match env.convert_byte_array(out_point_txid) { + Ok(b) if b.len() == 32 => b, + Ok(_) => { + throw_sdk_exception(env, 1, "outPointTxid must be exactly 32 bytes"); + return ptr::null_mut(); + } + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outPointTxid was null/invalid"); + return ptr::null_mut(); + } + }; + let mut txid_arr = [0u8; 32]; + txid_arr.copy_from_slice(&txid); + let out_point = platform_wallet_ffi::OutPointFFI { + txid: txid_arr, + vout: out_point_vout as u32, + }; + + let Some(entries) = decode_funding_recipients(env, recipients_blob) else { + return ptr::null_mut(); + }; + + let ffi = resume_ffi_for(scope); + run_address_funding(env, wallet_handle, |addr_handle, changeset| unsafe { + ffi( + addr_handle, + &out_point as *const platform_wallet_ffi::OutPointFFI, + platform_account_index as u32, + entries.as_ptr(), + entries.len(), + // The fee strategy is derived Rust-side; these are ignored. + ptr::null(), + 0, + signer_handle as *mut rs_sdk_ffi::SignerHandle, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + changeset, + ) + }) +} + /// Fund Platform addresses from a Core L1 asset lock built from the /// wallet's balance, returning the resulting changeset (updated address /// balances) as a `byte[]` blob. @@ -1983,84 +2306,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w core_signer_handle: jlong, ) -> jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - // Reject sign errors at the boundary — negatives would otherwise - // bit-cast to huge unsigned values on the FFI call. - if amount_duffs <= 0 { - throw_sdk_exception(env, 1, "amountDuffs must be positive"); - return ptr::null_mut(); - } - if account_index < 0 { - throw_sdk_exception(env, 1, "accountIndex must be non-negative"); - return ptr::null_mut(); - } - if platform_account_index < 0 { - throw_sdk_exception(env, 1, "platformAccountIndex must be non-negative"); - return ptr::null_mut(); - } - - let Some(entries) = decode_funding_recipients(env, &recipients_blob) else { - return ptr::null_mut(); - }; - - // Resolve the transient platform-address wallet handle. - let mut addr_handle: Handle = 0; - let result = unsafe { - platform_wallet_ffi::platform_wallet_get_platform( - wallet_handle as Handle, - &mut addr_handle as *mut Handle, - ) - }; - if take_pwffi_error(env, result) { - return ptr::null_mut(); - } - - let mut changeset = platform_wallet_ffi::PlatformAddressChangeSetFFI { - updated: ptr::null_mut(), - updated_count: 0, - }; - let fund_result = unsafe { - platform_wallet_ffi::platform_address_wallet_fund_from_asset_lock_signer( - addr_handle, - amount_duffs as u64, - account_index as u32, - platform_account_index as u32, - entries.as_ptr(), - entries.len(), - // The fee strategy is derived Rust-side; these are ignored. - ptr::null(), - 0, - signer_handle as *mut rs_sdk_ffi::SignerHandle, - core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - &mut changeset as *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, - // Kotlin hosts do not take the callback. - None, - std::ptr::null_mut(), - ) - }; - - let out = if take_pwffi_error(env, fund_result) { - ptr::null_mut() - } else { - let blob = unsafe { encode_changeset(&changeset) }; - unsafe { - platform_wallet_ffi::platform_address_wallet_free_changeset( - &changeset as *const platform_wallet_ffi::PlatformAddressChangeSetFFI, - ) - }; - env.byte_array_from_slice(&blob) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) - }; - - let destroy_result = - unsafe { platform_wallet_ffi::platform_address_wallet_destroy(addr_handle) }; - if !env.exception_check().unwrap_or(false) { - let _ = take_pwffi_error(env, destroy_result); - } else { - unsafe { platform_wallet_ffi_result_free(&mut { destroy_result }) }; - } - - out + fund_from_asset_lock( + env, + FundRecipientScope::Owned, + wallet_handle, + amount_duffs, + account_index, + platform_account_index, + &recipients_blob, + signer_handle, + core_signer_handle, + &JObject::null(), + ) }) } @@ -2084,94 +2341,96 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w core_signer_handle: jlong, ) -> jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - // Reject sign errors at the boundary — negatives would otherwise - // bit-cast to huge u32s on the FFI call. - if out_point_vout < 0 { - throw_sdk_exception(env, 1, "outPointVout must be non-negative"); - return ptr::null_mut(); - } - if platform_account_index < 0 { - throw_sdk_exception(env, 1, "platformAccountIndex must be non-negative"); - return ptr::null_mut(); - } - - let txid = match env.convert_byte_array(&out_point_txid) { - Ok(b) if b.len() == 32 => b, - Ok(_) => { - throw_sdk_exception(env, 1, "outPointTxid must be exactly 32 bytes"); - return ptr::null_mut(); - } - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "outPointTxid was null/invalid"); - return ptr::null_mut(); - } - }; - let mut txid_arr = [0u8; 32]; - txid_arr.copy_from_slice(&txid); - let out_point = platform_wallet_ffi::OutPointFFI { - txid: txid_arr, - vout: out_point_vout as u32, - }; - - let Some(entries) = decode_funding_recipients(env, &recipients_blob) else { - return ptr::null_mut(); - }; - - let mut addr_handle: Handle = 0; - let result = unsafe { - platform_wallet_ffi::platform_wallet_get_platform( - wallet_handle as Handle, - &mut addr_handle as *mut Handle, - ) - }; - if take_pwffi_error(env, result) { - return ptr::null_mut(); - } - - let mut changeset = platform_wallet_ffi::PlatformAddressChangeSetFFI { - updated: ptr::null_mut(), - updated_count: 0, - }; - let fund_result = unsafe { - platform_wallet_ffi::platform_address_wallet_resume_fund_from_asset_lock_signer( - addr_handle, - &out_point as *const platform_wallet_ffi::OutPointFFI, - platform_account_index as u32, - entries.as_ptr(), - entries.len(), - // The fee strategy is derived Rust-side; these are ignored. - ptr::null(), - 0, - signer_handle as *mut rs_sdk_ffi::SignerHandle, - core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - &mut changeset as *mut platform_wallet_ffi::PlatformAddressChangeSetFFI, - ) - }; - - let out = if take_pwffi_error(env, fund_result) { - ptr::null_mut() - } else { - let blob = unsafe { encode_changeset(&changeset) }; - unsafe { - platform_wallet_ffi::platform_address_wallet_free_changeset( - &changeset as *const platform_wallet_ffi::PlatformAddressChangeSetFFI, - ) - }; - env.byte_array_from_slice(&blob) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) - }; + resume_fund_from_asset_lock( + env, + FundRecipientScope::Owned, + wallet_handle, + &out_point_txid, + out_point_vout, + platform_account_index, + &recipients_blob, + signer_handle, + core_signer_handle, + ) + }) +} - let destroy_result = - unsafe { platform_wallet_ffi::platform_address_wallet_destroy(addr_handle) }; - if !env.exception_check().unwrap_or(false) { - let _ = take_pwffi_error(env, destroy_result); - } else { - unsafe { platform_wallet_ffi_result_free(&mut { destroy_result }) }; - } +/// Pay a third party's Platform address from a Core L1 asset lock built +/// from the wallet's balance, keeping the remainder on one of the wallet's +/// own addresses. Sibling of [`walletFundFromAssetLock`] with the same +/// parameters and blob layouts; calls +/// `platform_address_wallet_fund_from_asset_lock_external_signer`, whose +/// pre-flight lets explicit-amount recipients be any P2PKH address while the +/// single remainder must still belong to `platformAccountIndex`. +/// +/// The returned changeset holds only the wallet's own addresses (in +/// practice the remainder); the third party's credit has no local row. +/// +/// `onAssetLockBuilt` is a Kotlin `AssetLockBuiltBridge` (or null). Rust calls +/// its `onAssetLockBuilt(byte[36])` at most once, with the new lock's raw +/// outpoint, before the lock is persisted or broadcast (see +/// [`asset_lock_built_trampoline`]); it lets the host bind the recipients it +/// persisted to the lock it must later resume. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletFundFromAssetLockExternal( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + amount_duffs: jlong, + account_index: jni::sys::jint, + platform_account_index: jni::sys::jint, + recipients_blob: JByteArray, + signer_handle: jlong, + core_signer_handle: jlong, + on_asset_lock_built: JObject, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + fund_from_asset_lock( + env, + FundRecipientScope::External, + wallet_handle, + amount_duffs, + account_index, + platform_account_index, + &recipients_blob, + signer_handle, + core_signer_handle, + &on_asset_lock_built, + ) + }) +} - out +/// Resume a stuck external-recipient funding from an already-tracked lock by +/// outpoint. Sibling of [`walletResumeFundFromAssetLock`] (same parameters +/// and layouts) with the relaxed recipient rule of +/// [`walletFundFromAssetLockExternal`]; calls +/// `platform_address_wallet_resume_fund_from_asset_lock_external_signer`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletResumeFundFromAssetLockExternal( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + out_point_txid: JByteArray, + out_point_vout: jni::sys::jint, + platform_account_index: jni::sys::jint, + recipients_blob: JByteArray, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + resume_fund_from_asset_lock( + env, + FundRecipientScope::External, + wallet_handle, + &out_point_txid, + out_point_vout, + platform_account_index, + &recipients_blob, + signer_handle, + core_signer_handle, + ) }) } @@ -4006,3 +4265,42 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_s } }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_route_each_fund_scope_to_its_own_ffi() { + let owned: FundFromAssetLockFfi = + platform_wallet_ffi::platform_address_wallet_fund_from_asset_lock_signer; + let external: FundFromAssetLockFfi = + platform_wallet_ffi::platform_address_wallet_fund_from_asset_lock_external_signer; + assert_ne!(owned as usize, external as usize); + assert_eq!( + fund_ffi_for(FundRecipientScope::Owned) as usize, + owned as usize + ); + assert_eq!( + fund_ffi_for(FundRecipientScope::External) as usize, + external as usize + ); + } + + #[test] + fn should_route_each_resume_scope_to_its_own_ffi() { + let owned: ResumeFundFromAssetLockFfi = + platform_wallet_ffi::platform_address_wallet_resume_fund_from_asset_lock_signer; + let external: ResumeFundFromAssetLockFfi = + platform_wallet_ffi::platform_address_wallet_resume_fund_from_asset_lock_external_signer; + assert_ne!(owned as usize, external as usize); + assert_eq!( + resume_ffi_for(FundRecipientScope::Owned) as usize, + owned as usize + ); + assert_eq!( + resume_ffi_for(FundRecipientScope::External) as usize, + external as usize + ); + } +} From 487b5dc44fdd74112519ca3770668e1d9e5f7c0f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 6 Nov 2026 23:17:35 -0800 Subject: [PATCH 2/4] refactor(kotlin-sdk): simplify the asset-lock-built outpoint and bridge - Drop the external preflight's 20-byte hash check: FundRecipient's constructor already rejects any other length. - AssetLockBuiltOutPoint keeps the 36 raw bytes and derives txid (via outpointTxid), vout and outPointHex from them, so the length is checked once and the hex is built only when read. - AssetLockBuiltBridge becomes a fun interface; callers pass a lambda. Co-Authored-By: Claude Opus 5.5 --- .../wallet/FundFromAssetLockBindingTest.kt | 4 +-- .../dashsdk/ffi/WalletManagerNative.kt | 4 +-- .../dashsdk/wallet/ManagedPlatformWallet.kt | 31 ++++++------------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt index de5ff008516..9fb2cddd893 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt @@ -32,9 +32,7 @@ import java.nio.ByteBuffer @RunWith(AndroidJUnit4::class) class FundFromAssetLockBindingTest { - private val noopBridge = object : AssetLockBuiltBridge() { - override fun onAssetLockBuilt(outPoint: ByteArray) = Unit - } + private val noopBridge = AssetLockBuiltBridge { } /** One explicit-amount payee and one remainder, P2PKH. */ private val validBlob: ByteArray = ByteBuffer.allocate(4 + 2 * 30).apply { 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 13cba8439fb..1d5e724ab70 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 @@ -796,7 +796,7 @@ internal object WalletManagerNative { * is broadcast. An exception thrown here is cleared by the trampoline and * does not stop the funding. */ -abstract class AssetLockBuiltBridge { +fun interface AssetLockBuiltBridge { /** @param outPoint 36 bytes: 32-byte txid (wire order) then the 4-byte little-endian vout. */ - abstract fun onAssetLockBuilt(outPoint: ByteArray) + fun onAssetLockBuilt(outPoint: ByteArray) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 118c431f592..8f726cfa4bd 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -8,6 +8,7 @@ import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.encodeOutPointHex +import org.dashfoundation.dashsdk.persistence.outpointTxid import org.dashfoundation.dashsdk.tokens.translateManagedIdentityNotFoundToZero import java.util.concurrent.atomic.AtomicLong @@ -736,15 +737,15 @@ class ManagedPlatformWallet internal constructor( * @property outPointHex `:`, the key of the * lock's persisted `AssetLockEntity` row. */ - class AssetLockBuiltOutPoint private constructor( - val txid: ByteArray, - val vout: Int, - val outPointHex: String, - ) { + class AssetLockBuiltOutPoint private constructor(private val raw: ByteArray) { + val txid: ByteArray = outpointTxid(raw) + val vout: Int = java.nio.ByteBuffer.wrap(raw, 32, 4).order(java.nio.ByteOrder.LITTLE_ENDIAN).int + val outPointHex: String get() = encodeOutPointHex(raw) + override fun equals(other: Any?): Boolean = - other is AssetLockBuiltOutPoint && txid.contentEquals(other.txid) && vout == other.vout + other is AssetLockBuiltOutPoint && raw.contentEquals(other.raw) - override fun hashCode(): Int = 31 * txid.contentHashCode() + vout + override fun hashCode(): Int = raw.contentHashCode() override fun toString(): String = "AssetLockBuiltOutPoint($outPointHex)" @@ -756,13 +757,7 @@ class ManagedPlatformWallet internal constructor( */ fun fromRawBytes(raw: ByteArray): AssetLockBuiltOutPoint { require(raw.size == 36) { "outpoint must be 36 bytes, got ${raw.size}" } - val vout = java.nio.ByteBuffer.wrap(raw, 32, 4) - .order(java.nio.ByteOrder.LITTLE_ENDIAN).int - return AssetLockBuiltOutPoint( - txid = raw.copyOfRange(0, 32), - vout = vout, - outPointHex = encodeOutPointHex(raw), - ) + return AssetLockBuiltOutPoint(raw.copyOf()) } } } @@ -939,10 +934,7 @@ class ManagedPlatformWallet internal constructor( "platformAccountIndex must be non-negative, got $platformAccountIndex" } fundFromAssetLockExternalPreflight(recipients) - val bridge = object : AssetLockBuiltBridge() { - override fun onAssetLockBuilt(outPoint: ByteArray) = - onAssetLockBuilt(AssetLockBuiltOutPoint.fromRawBytes(outPoint)) - } + val bridge = AssetLockBuiltBridge { onAssetLockBuilt(AssetLockBuiltOutPoint.fromRawBytes(it)) } val blob = mapNativeErrors { WalletManagerNative.walletFundFromAssetLockExternal( walletHandle = handle, @@ -1435,9 +1427,6 @@ class ManagedPlatformWallet internal constructor( "FundRecipient.addressType must be 0 (P2PKH) for platform-address " + "funding (got ${r.addressType})" } - require(r.hash.size == 20) { - "FundRecipient.hash must be exactly 20 bytes (got ${r.hash.size})" - } } } } From cc3850954491749e0cd7e59275f7f3bfd24083ae Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 8 Nov 2026 00:36:39 -0800 Subject: [PATCH 3/4] fix(kotlin-sdk): leave recipient rules to platform-wallet and drive the built-lock trampoline in tests - Remove the Kotlin fundFromAssetLockExternalPreflight. platform-wallet's validate_recipient_addresses already checks shape and ownership before any lock is built, and the two policies had diverged (Kotlin rejected an owned remainder-only list that Rust accepts). Its errors now surface through mapNativeErrors; KDoc and PARITY.md say so. - Add a debug-only JNI driver (cfg(debug_assertions), so absent from the release library) that pins Kotlin bridges as the funding path does and runs the production asset_lock_built_trampoline on a native thread. Instrumented tests check the outpoint arrives intact on a native thread before the call returns, and that a throwing callback is cleared so the next callback on the same worker still runs. Co-Authored-By: Claude Opus 5.5 --- packages/kotlin-sdk/PARITY.md | 2 +- .../ffi/AssetLockBuiltTrampolineDriver.kt | 18 +++++ .../wallet/FundFromAssetLockBindingTest.kt | 42 ++++++++++- .../dashsdk/wallet/ManagedPlatformWallet.kt | 52 +++----------- .../wallet/FundFromAssetLockRecipientTest.kt | 54 +++----------- .../rs-unified-sdk-jni/src/wallet_manager.rs | 72 +++++++++++++++++++ 6 files changed, 150 insertions(+), 90 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/ffi/AssetLockBuiltTrampolineDriver.kt diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 4b343a2a01b..79f343d11a3 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -40,7 +40,7 @@ Status legend: | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create routes to CreateDocumentScreen and replace / delete / transfer route to DocumentActionsScreen through the transition catalog) | | ~~FriendsView.swift~~ (deleted upstream) | — (retired) | retired — deleted by PR #3841 and superseded by the first-class DashPay tab; `FriendsScreen.kt` and its `Friends` route were removed in milestone K3. See the **Views/DashPay/** section below | -| FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow. Third-party payee: `ManagedPlatformWallet.fundFromAssetLockExternal` / `resumeFundFromAssetLockExternal` bind `platform_address_wallet_[resume_]fund_from_asset_lock_external_signer` (SDK only, no UI — Swift's example app has none either); the external fund takes a required `onAssetLockBuilt` callback reporting the lock's outpoint before broadcast, so hosts can bind persisted recipients to the lock they resume (the owned fund still passes none) | +| FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow. Third-party payee: `ManagedPlatformWallet.fundFromAssetLockExternal` / `resumeFundFromAssetLockExternal` bind `platform_address_wallet_[resume_]fund_from_asset_lock_external_signer` (SDK only, no UI — Swift's example app has none either); the external fund takes a required `onAssetLockBuilt` callback reporting the lock's outpoint before broadcast, so hosts can bind persisted recipients to the lock they resume (the owned fund still passes none). Deliberate divergence: Kotlin has no recipient preflight (Swift's `fundFromAssetLockExternalPreflight`); `platform-wallet` owns those rules, so Kotlin accepts what Rust accepts | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | | GroupDetailView.swift | ui/contracts/GroupDetailScreen.kt · `GroupDetail` | ported — members resolved against local identities; adds live open-proposals via bridged `Groups.pendingActions` | diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/ffi/AssetLockBuiltTrampolineDriver.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/ffi/AssetLockBuiltTrampolineDriver.kt new file mode 100644 index 00000000000..4eee2afdba5 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/ffi/AssetLockBuiltTrampolineDriver.kt @@ -0,0 +1,18 @@ +package org.dashfoundation.dashsdk.ffi + +/** + * Test-only binding to the native driver that runs the production + * `asset_lock_built_trampoline` (rs-unified-sdk-jni `wallet_manager.rs`) + * without a wallet. The export is compiled only into debug + * (`build_android.sh --profile dev`) libraries, which is what the + * instrumented tests load. + */ +object AssetLockBuiltTrampolineDriver { + /** + * Pin each of [bridges] the way a funding call does and invoke the + * trampoline for each, in order, on one new native thread with + * [outPoint] (must be 36 bytes) as the payload. Returns after that thread + * has finished. + */ + external fun invokeOnNativeThread(bridges: Array, outPoint: ByteArray) +} diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt index 9fb2cddd893..47683b90c93 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockBindingTest.kt @@ -3,16 +3,21 @@ package org.dashfoundation.dashsdk.wallet import androidx.test.ext.junit.runners.AndroidJUnit4 import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AssetLockBuiltBridge +import org.dashfoundation.dashsdk.ffi.AssetLockBuiltTrampolineDriver import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.WalletManagerNative +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.nio.ByteBuffer +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference /** * Binding-level coverage for the four asset-lock funding JNI exports @@ -27,7 +32,9 @@ import java.nio.ByteBuffer * the platform-wallet range). * * Which FFI each scope calls (owned vs external) is pinned Rust-side by - * `wallet_manager::tests::should_route_each_*_scope_to_its_own_ffi`. + * `wallet_manager::tests::should_route_each_*_scope_to_its_own_ffi`. The + * asset-lock-built trampoline, which a dead handle never reaches, is driven + * directly through the debug-only [AssetLockBuiltTrampolineDriver]. */ @RunWith(AndroidJUnit4::class) class FundFromAssetLockBindingTest { @@ -128,6 +135,39 @@ class FundFromAssetLockBindingTest { } } + @Test + fun shouldDeliverTheOutpointFromANativeThreadBeforeReturning() { + val raw = ByteArray(32) { (it + 1).toByte() } + byteArrayOf(0x02, 0x01, 0x00, 0x00) + val received = CopyOnWriteArrayList() + val callbackThread = AtomicReference() + val recording = AssetLockBuiltBridge { + callbackThread.set(Thread.currentThread()) + received += it + } + + AssetLockBuiltTrampolineDriver.invokeOnNativeThread(arrayOf(recording), raw) + + assertEquals("delivered exactly once, before the call returned", 1, received.size) + assertArrayEquals(raw, received.single()) + assertNotEquals(Thread.currentThread(), callbackThread.get()) + val outPoint = ManagedPlatformWallet.AssetLockBuiltOutPoint.fromRawBytes(received.single()) + assertEquals(258, outPoint.vout) + } + + @Test + fun shouldClearAThrowingCallbackSoTheNextCallbackOnTheWorkerRuns() { + val raw = ByteArray(36) { it.toByte() } + val received = CopyOnWriteArrayList() + val throwing = AssetLockBuiltBridge { throw IllegalStateException("host failed") } + val recording = AssetLockBuiltBridge { received += it } + + // Returns normally: the throw is cleared on the worker, never rethrown here. + AssetLockBuiltTrampolineDriver.invokeOnNativeThread(arrayOf(throwing, recording), raw) + + assertEquals(1, received.size) + assertArrayEquals(raw, received.single()) + } + @Test fun shouldAcceptANullBuiltBridge() { val e = assertThrows(DashSDKException::class.java) { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 8f726cfa4bd..a110c3aca0c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -897,9 +897,10 @@ class ManagedPlatformWallet internal constructor( * @param platformAccountIndex Platform-payment account that owns the * REMAINDER recipient. Explicit-amount recipients need not belong to it. * @param recipients exactly one entry with `credits = null` (the - * remainder, an address of this wallet) and at least one entry with an - * explicit amount; P2PKH (`addressType = 0`) only. A remainder-only - * list is rejected: it pays nobody else, so use [fundFromAssetLock]. + * remainder, an address of this wallet) plus the explicit-amount + * payees; P2PKH (`addressType = 0`) only. `platform-wallet` owns these + * rules and rejects a violating list before any lock is built; its + * error surfaces as a [org.dashfoundation.dashsdk.ffi.DashSDKException]. * @param signerHandle the platform-address per-input `SignerHandle`. * @param coreSignerHandle the manager's `MnemonicResolverHandle` for the * asset-lock's outer state-transition signature. @@ -914,8 +915,8 @@ class ManagedPlatformWallet internal constructor( * @return updated balances for the recipients that belong to this wallet * — in practice only the remainder address. The third party's credit * is proven on Platform but has no local row, so it is not included. - * @throws IllegalArgumentException if the arguments fail the pre-flight - * ([fundFromAssetLockExternalPreflight]); nothing is broadcast then. + * @throws IllegalArgumentException for a non-positive amount or a + * negative index; nothing is built then. */ suspend fun fundFromAssetLockExternal( amountDuffs: Long, @@ -933,7 +934,6 @@ class ManagedPlatformWallet internal constructor( require(platformAccountIndex >= 0) { "platformAccountIndex must be non-negative, got $platformAccountIndex" } - fundFromAssetLockExternalPreflight(recipients) val bridge = AssetLockBuiltBridge { onAssetLockBuilt(AssetLockBuiltOutPoint.fromRawBytes(it)) } val blob = mapNativeErrors { WalletManagerNative.walletFundFromAssetLockExternal( @@ -968,8 +968,10 @@ class ManagedPlatformWallet internal constructor( * * @param outPointTxid 32-byte raw txid (little-endian wire order). * @param outPointVout funding output index. - * @throws IllegalArgumentException if the arguments fail the pre-flight; - * nothing is submitted then. + * @param recipients the persisted list; `platform-wallet` applies the + * same rules as [fundFromAssetLockExternal] before submitting. + * @throws IllegalArgumentException for a txid that is not 32 bytes or a + * negative vout or index; nothing is submitted then. */ suspend fun resumeFundFromAssetLockExternal( outPointTxid: ByteArray, @@ -986,7 +988,6 @@ class ManagedPlatformWallet internal constructor( require(platformAccountIndex >= 0) { "platformAccountIndex must be non-negative, got $platformAccountIndex" } - fundFromAssetLockExternalPreflight(recipients) val blob = mapNativeErrors { WalletManagerNative.walletResumeFundFromAssetLockExternal( walletHandle = handle, @@ -1396,39 +1397,6 @@ class ManagedPlatformWallet internal constructor( } return out.toByteArray() } - - /** - * Synchronous pre-flight for [fundFromAssetLockExternal] / - * [resumeFundFromAssetLockExternal] — port of Swift's - * `fundFromAssetLockExternalPreflight`. Rust enforces the same rules - * (plus "the remainder must be ours", which only it can check); this - * gives the caller an error before any native work. - * - * Rules: a non-empty list; exactly one remainder (`credits = null`); - * at least one explicit-amount recipient (a remainder-only request - * pays nobody else — use [fundFromAssetLock]); P2PKH - * (`addressType = 0`) only; 20-byte hashes. - * - * @throws IllegalArgumentException when a rule is violated. - */ - internal fun fundFromAssetLockExternalPreflight(recipients: List) { - require(recipients.isNotEmpty()) { "recipients is empty" } - val remainderCount = recipients.count { it.credits == null } - require(remainderCount == 1) { - "Exactly one recipient must have credits = null (the remainder recipient), " + - "found $remainderCount" - } - require(recipients.any { it.credits != null }) { - "fundFromAssetLockExternal requires at least one explicit-amount recipient; " + - "a remainder-only request funds nothing externally — use fundFromAssetLock" - } - for (r in recipients) { - require(r.addressType == 0) { - "FundRecipient.addressType must be 0 (P2PKH) for platform-address " + - "funding (got ${r.addressType})" - } - } - } } /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt index dcaf24a74a0..016689c1e35 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/FundFromAssetLockRecipientTest.kt @@ -1,7 +1,6 @@ package org.dashfoundation.dashsdk.wallet import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.Companion.encodeRecipients -import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.Companion.fundFromAssetLockExternalPreflight import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.AssetLockBuiltOutPoint import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.FundRecipient import org.junit.Assert.assertArrayEquals @@ -11,9 +10,9 @@ import org.junit.Test import java.nio.ByteBuffer /** - * Pins the two host-side responsibilities of the asset-lock funding surface — - * marshalling recipients faithfully, and rejecting recipient lists Kotlin can - * check synchronously. Port of Swift's `FundFromAssetLockRecipientTests`. + * Pins the host side of the asset-lock funding surface: marshalling + * recipients faithfully and decoding the reported outpoint. Recipient rules + * are platform-wallet's alone, so none are checked here. * * The fee-paying output (`ReduceOutput(index)`) is deliberately NOT derived * here: it is positional against the transition's outputs map (a consensus @@ -21,8 +20,8 @@ import java.nio.ByteBuffer */ class FundFromAssetLockRecipientTest { - private fun recipient(tag: Int, credits: Long?, type: Int = 0) = - FundRecipient(addressType = type, hash = ByteArray(20) { tag.toByte() }, credits = credits) + private fun recipient(tag: Int, credits: Long?) = + FundRecipient(addressType = 0, hash = ByteArray(20) { tag.toByte() }, credits = credits) private data class Row(val type: Int, val hash: ByteArray, val hasBalance: Boolean, val balance: Long) @@ -65,33 +64,10 @@ class FundFromAssetLockRecipientTest { assertEquals(1, decode(blob).count { !it.hasBalance }) } - // ── External pre-flight ─────────────────────────────────────────── - - @Test - fun shouldAcceptThirdPartyPayeeWithOwnRemainder() { - fundFromAssetLockExternalPreflight( - listOf(recipient(0xBB, credits = 500), recipient(0x0A, credits = null)), - ) - } - - @Test - fun shouldRejectRemainderOnlyRequest() { - // Nothing is paid externally — a call-site mistake; fundFromAssetLock - // validates the destination properly. - assertThrows(IllegalArgumentException::class.java) { - fundFromAssetLockExternalPreflight(listOf(recipient(0x0A, credits = null))) - } - } - @Test - fun shouldRejectP2shAndBadHashLengths() { - assertThrows(IllegalArgumentException::class.java) { - fundFromAssetLockExternalPreflight( - listOf(recipient(0xBB, credits = 500, type = 1), recipient(0x0A, credits = null)), - ) - } - // A wrong-length hash cannot even be constructed, so it never reaches - // the pre-flight or the native blob. + fun shouldNotConstructARecipientWithAWrongLengthHash() { + // So a malformed hash never reaches the native blob. Every recipient + // RULE (remainder count, P2PKH only, ownership) is platform-wallet's. listOf(19, 21, 0).forEach { size -> assertThrows(IllegalArgumentException::class.java) { FundRecipient(addressType = 0, hash = ByteArray(size), credits = 500) @@ -99,20 +75,6 @@ class FundFromAssetLockRecipientTest { } } - @Test - fun shouldRejectWrongRemainderCount() { - listOf( - emptyList(), - listOf(recipient(0x0A, credits = 500), recipient(0xBB, credits = 700)), - listOf(recipient(0x0A, credits = null), recipient(0xBB, credits = null)), - listOf(recipient(0x0A, credits = null), recipient(0xBB, credits = null), recipient(0xCC, credits = 1)), - ).forEach { recipients -> - assertThrows("accepted $recipients", IllegalArgumentException::class.java) { - fundFromAssetLockExternalPreflight(recipients) - } - } - } - // ── Asset-lock-built outpoint ───────────────────────────────────── @Test diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 10f2da961a6..d392f95c5e5 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -2126,6 +2126,78 @@ unsafe extern "C" fn asset_lock_built_trampoline(context: *mut c_void, out_point })); } +/// Test-only driver for [`asset_lock_built_trampoline`], bound by +/// `AssetLockBuiltTrampolineDriver` in the Kotlin androidTest source set. +/// +/// The funding path reaches the trampoline only after a wallet has built a +/// lock, so the wallet-free binding tests could not run it. This export +/// pins each bridge in a `GlobalRef` (as `fund_from_asset_lock` does) and +/// invokes the production trampoline for each, in order, on ONE freshly +/// spawned native thread with `out_point` as the payload, joining before it +/// returns. A throwing bridge followed by a recording one therefore checks +/// the exception is cleared on the same worker. +/// +/// Compiled only with `debug_assertions` (`build_android.sh --profile dev`, +/// which CI's emulator job uses); the release library does not export it. +#[cfg(debug_assertions)] +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_AssetLockBuiltTrampolineDriver_invokeOnNativeThread( + mut env: JNIEnv, + _this: JObject, + bridges: JObjectArray, + out_point: JByteArray, +) { + guard(&mut env, (), |env| { + let bytes = match env.convert_byte_array(&out_point) { + Ok(bytes) if bytes.len() == 36 => bytes, + _ => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outPoint must be exactly 36 bytes"); + return; + } + }; + let pinned = (|| -> jni::errors::Result> { + let len = env.get_array_length(&bridges)?; + (0..len) + .map(|i| { + let bridge = env.get_object_array_element(&bridges, i)?; + env.new_global_ref(bridge) + }) + .collect() + })(); + let globals = match pinned { + Ok(globals) => globals, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "bridges could not be pinned"); + return; + } + }; + // Raw pointers are `!Send`; the addresses cross as `usize`. `globals` + // outlives the join, so each pointer stays live for the worker. + let contexts: Vec = globals + .iter() + .map(|global| global as *const GlobalRef as usize) + .collect(); + let worker = std::thread::spawn(move || { + for context in contexts { + // SAFETY: `context` is a live GlobalRef (pinned by `globals` + // until after the join) and `bytes` holds 36 bytes. + unsafe { asset_lock_built_trampoline(context as *mut c_void, bytes.as_ptr()) }; + } + }); + let joined = worker.join(); + drop(globals); + if joined.is_err() { + throw_sdk_exception( + env, + 99, + "asset-lock-built trampoline driver thread panicked", + ); + } + }) +} + /// Shared body of `walletFundFromAssetLock` / `walletFundFromAssetLockExternal`. /// /// `on_built` is the optional Kotlin `AssetLockBuiltBridge`; it is held as a From 39783c336c83bd2e9317e3e98faeb10849239570 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 8 Nov 2026 08:19:00 -0800 Subject: [PATCH 4/4] docs(kotlin-sdk): name DashSdkError as the recipient-rejection type mapNativeErrors converts the native DashSDKException into the public DashSdkError hierarchy, so that is what callers of fundFromAssetLockExternal catch. Co-Authored-By: Claude Opus 5.5 --- .../org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index a110c3aca0c..5a01e25f73d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -900,7 +900,7 @@ class ManagedPlatformWallet internal constructor( * remainder, an address of this wallet) plus the explicit-amount * payees; P2PKH (`addressType = 0`) only. `platform-wallet` owns these * rules and rejects a violating list before any lock is built; its - * error surfaces as a [org.dashfoundation.dashsdk.ffi.DashSDKException]. + * error surfaces as a [org.dashfoundation.dashsdk.errors.DashSdkError]. * @param signerHandle the platform-address per-input `SignerHandle`. * @param coreSignerHandle the manager's `MnemonicResolverHandle` for the * asset-lock's outer state-transition signature.