Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/kotlin-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 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` |
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AssetLockBuiltBridge>, outPoint: ByteArray)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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
* (`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`. The
* asset-lock-built trampoline, which a dead handle never reaches, is driven
* directly through the debug-only [AssetLockBuiltTrampolineDriver].
*/
@RunWith(AndroidJUnit4::class)
class FundFromAssetLockBindingTest {

private val noopBridge = AssetLockBuiltBridge { }

/** 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 shouldDeliverTheOutpointFromANativeThreadBeforeReturning() {
val raw = ByteArray(32) { (it + 1).toByte() } + byteArrayOf(0x02, 0x01, 0x00, 0x00)
val received = CopyOnWriteArrayList<ByteArray>()
val callbackThread = AtomicReference<Thread>()
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<ByteArray>()
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) {
WalletManagerNative.walletFundFromAssetLockExternal(
0L, 100_000L, 0, 0, validBlob, 0L, 0L, null,
)
}
assertReachedFfi(e)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───

/**
Expand Down Expand Up @@ -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.
*/
fun interface AssetLockBuiltBridge {
/** @param outPoint 36 bytes: 32-byte txid (wire order) then the 4-byte little-endian vout. */
fun onAssetLockBuilt(outPoint: ByteArray)
}
Loading
Loading