diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index d07107d4c76..08ac2ee640e 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -19,7 +19,11 @@ "core_wallet_signed_payment_broadcast": "packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs", "core_wallet_signed_payment_finalize": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", "core_wallet_signed_payment_release": "packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs", + "core_wallet_signed_transaction_v2_bytes": "packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs", + "core_wallet_tx_builder_add_op_return": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", + "core_wallet_tx_builder_change_to_first_input": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", "core_wallet_tx_builder_finalize": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", + "core_wallet_tx_builder_preserve_output_order": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", "dash_sdk_sign_async_completion": "packages/rs-sdk-ffi/src/signer.rs", "dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", @@ -255,6 +259,74 @@ } ] }, + { + "id": "core.maya_op_return_deposit", + "title": "MAYACHAIN-shaped deposit build: OP_RETURN memo, preserved output order, change to VIN0, pre-broadcast bytes", + "area": "correctness", + "shared_apis": [ + "core_wallet_tx_builder_add_op_return", + "core_wallet_tx_builder_preserve_output_order", + "core_wallet_tx_builder_change_to_first_input", + "core_wallet_signed_transaction_v2_bytes" + ], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + } + }, + "verification": [ + { + "host": "swift", + "kind": "integration", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/MayaDepositVerificationIntegrationTests.swift", + "id": "testPrompt04StaticProofAndLegacyFeeParity", + "command": "RUN_INTEGRATION_TESTS=1 swift test --package-path packages/swift-sdk --filter MayaDepositVerificationIntegrationTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "integration", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/MayaDepositVerificationIntegrationTests.swift", + "id": "testOpReturnCeilingBoundaryAndRejectionPreservesBuilder", + "command": "RUN_INTEGRATION_TESTS=1 swift test --package-path packages/swift-sdk --filter MayaDepositVerificationIntegrationTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreTxBuilderOpReturnBindingTest.kt", + "id": "mayaShapeOptionsBindAndAccept", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/BuildSignedPaymentMayaOptionsTest.kt", + "id": "mayaOptionsThreadThroughThePublicBuildSignedPayment", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/BuildSignedPaymentMayaOptionsTest.kt", + "id": "oversizeMemoFailsBeforeSelectionAndWalletSurvives", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + } + ] + }, { "id": "tokens.full_u64_domain", "title": "Token amounts and costs preserve the full u64 domain", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 7f7b2a0b8bb..a63ff74953d 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **24** +Capabilities tracked: **25** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 14 | 8 | 1 | 1 | -| Swift | Example app | 4 | 12 | 1 | 7 | -| Kotlin | SDK | 12 | 12 | 0 | 0 | -| Kotlin | Example app | 5 | 12 | 0 | 7 | +| Swift | SDK | 15 | 8 | 1 | 1 | +| Swift | Example app | 4 | 12 | 1 | 8 | +| Kotlin | SDK | 13 | 12 | 0 | 0 | +| Kotlin | Example app | 5 | 12 | 0 | 8 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 7 | 17 | -| Kotlin | 4 | 6 | 14 | +| Swift | 0 | 7 | 18 | +| Kotlin | 4 | 6 | 15 | ## Capability status @@ -27,6 +27,7 @@ Capabilities tracked: **24** | `persistence.platform_address_identity` | partial / not-applicable / required | supported / not-applicable / tested | | `core.atomic_send` | supported / supported / not_applicable | supported / supported / not_applicable | | `core.deferred_signed_payment` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | +| `core.maya_op_return_deposit` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | | `tokens.full_u64_domain` | partial / partial / required | supported / supported / tested | | `shielded.seedless_restart` | supported / partial / required | partial / partial / required | | `dashpay.invitations` | supported / partial / required | supported / partial / required | diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/BuildSignedPaymentMayaOptionsTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/BuildSignedPaymentMayaOptionsTest.kt new file mode 100644 index 00000000000..4673dd354b9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/BuildSignedPaymentMayaOptionsTest.kt @@ -0,0 +1,149 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.config.SdkConfig +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.persistence.DashDatabase +import org.dashfoundation.dashsdk.security.WalletStorage +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Consumer-surface coverage for the MAYACHAIN builder controls: drives ONLY + * the public [ManagedPlatformWallet.buildSignedPayment] overload (the API an + * app consuming the published `dash-sdk-android` artifact can reach), not the + * internal [CoreTransactionBuilder] / `WalletManagerNative` surface that + * [CoreTxBuilderOpReturnBindingTest] pins. + * + * Runs offline against an UNFUNDED wallet, so the deepest reachable outcome + * is key-wallet's atomic selection failing with + * [DashSdkError.PlatformWallet.CoreInsufficientFunds] — which is exactly the + * point: reaching that error through the option-carrying call proves the + * memo/order/change options were accepted and threaded into the build (an + * option failure surfaces earlier as a different error), while an oversize + * memo must fail BEFORE selection with something other than + * insufficient-funds and leave the wallet usable. The full funded + * deposit-shape assertion (vault VOUT0 / memo VOUT1 / change VOUT2) stays + * with the gated Swift `MayaDepositVerificationIntegrationTests` and the + * wallet-side testnet verification. + */ +@RunWith(AndroidJUnit4::class) +class BuildSignedPaymentMayaOptionsTest { + + // BIP39 English test vector (all-zero entropy) — same as + // WalletManagerRoundTripTest; nothing is funded or broadcast. + private val testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon about" + + // Syntactically valid testnet P2PKH standing in for a Maya vault; the + // builder validates encoding/network only. + private val vaultAddress = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA" + + private val mayaMemo = + "=:ETH.ETH:0x1c7b17362c84287bd1184447e6dfeaf920c31bbe".toByteArray(Charsets.UTF_8) + + private lateinit var db: DashDatabase + private lateinit var walletStorage: WalletStorage + private lateinit var sdk: Sdk + + @Before + fun setUp() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = DashDatabase.createInMemory(context) + walletStorage = WalletStorage(context) + // Testnet, no overrides → offline client build (no connection made). + sdk = Sdk.create(SdkConfig(network = Network.TESTNET)) + } + + @After + fun tearDown() { + runCatching { db.close() } + runCatching { sdk.close() } + } + + private fun withUnfundedWallet( + block: suspend (ManagedPlatformWallet, Long) -> Unit, + ) = runBlocking { + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + val created = manager.createWallet( + mnemonic = testMnemonic, + name = "maya-options", + createDefaultAccounts = true, + ) + val wallet = manager.wallet(forWalletId = created.walletId) + assertNotNull("created wallet is addressable", wallet) + block(wallet!!, manager.mnemonicResolverHandle) + } + } + + @Test + fun mayaOptionsThreadThroughThePublicBuildSignedPayment() = withUnfundedWallet { wallet, signer -> + // The canonical Maya sequence through the public API. On an unfunded + // wallet the first possible failure point past option application is + // atomic selection — so insufficient-funds here means the memo and + // both shape flags were accepted and threaded into the build. + val error = runCatching { + wallet.buildSignedPayment( + recipients = listOf(vaultAddress to 100_000L), + network = Network.TESTNET, + coreSignerHandle = signer, + opReturnData = mayaMemo, + preserveOutputOrder = true, + changeToFirstInput = true, + ) + }.exceptionOrNull() + + assertTrue( + "unfunded Maya-shaped build must fail at selection, got: $error", + error is DashSdkError.PlatformWallet.CoreInsufficientFunds, + ) + } + + @Test + fun oversizeMemoFailsBeforeSelectionAndWalletSurvives() = withUnfundedWallet { wallet, signer -> + val error = runCatching { + wallet.buildSignedPayment( + recipients = listOf(vaultAddress to 100_000L), + network = Network.TESTNET, + coreSignerHandle = signer, + opReturnData = ByteArray(81), + preserveOutputOrder = true, + changeToFirstInput = true, + ) + }.exceptionOrNull() + + assertNotNull("81-byte memo must be rejected", error) + assertFalse( + "oversize memo must fail before selection, got: $error", + error is DashSdkError.PlatformWallet.CoreInsufficientFunds, + ) + + // The rejection happened before anything was reserved; the same + // wallet must still drive a well-formed build to the selection stage. + val retry = runCatching { + wallet.buildSignedPayment( + recipients = listOf(vaultAddress to 100_000L), + network = Network.TESTNET, + coreSignerHandle = signer, + opReturnData = mayaMemo, + preserveOutputOrder = true, + changeToFirstInput = true, + ) + }.exceptionOrNull() + + assertTrue( + "wallet survives the rejected memo, got: $retry", + retry is DashSdkError.PlatformWallet.CoreInsufficientFunds, + ) + } +} diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreTxBuilderOpReturnBindingTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreTxBuilderOpReturnBindingTest.kt new file mode 100644 index 00000000000..9d32c4e04d6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/CoreTxBuilderOpReturnBindingTest.kt @@ -0,0 +1,91 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.NativeLoader +import org.dashfoundation.dashsdk.ffi.WalletManagerNative +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Binding-level coverage for the MAYACHAIN-deposit builder controls + * (`add_op_return`, `preserve_output_order`, `change_to_first_input`, + * `signed_transaction_v2_bytes`) — the Android counterpart of the gated + * Swift `MayaDepositVerificationIntegrationTests`, minus everything that + * needs a funded wallet. Proves the four new JNI symbols resolve, happy-path + * calls succeed against a live builder, and the FFI's error paths surface as + * [DashSDKException] instead of aborting. + * + * No network, no wallet, no funds: a builder handle alone accepts outputs + * and options; only funding/finalizing needs a wallet. The full + * deposit-shape assertion (vault VOUT0 / memo VOUT1 / change VOUT2 on a + * really-funded transaction) stays with the Swift integration suite and the + * wallet-side testnet verification. + */ +@RunWith(AndroidJUnit4::class) +class CoreTxBuilderOpReturnBindingTest { + + // Any syntactically valid testnet P2PKH address works — the builder + // validates encoding/network only; nothing is funded or sent. Same + // address the FFI's own persistence tests use. + private val testnetAddress = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA" + + private fun withBuilder(block: (Long) -> Unit) { + NativeLoader.ensureLoaded() + val builder = WalletManagerNative.coreTxBuilderNew(network = 1) + assertNotEquals("builder handle must be live", 0L, builder) + try { + block(builder) + } finally { + WalletManagerNative.coreTxBuilderDestroy(builder) + } + } + + @Test + fun mayaShapeOptionsBindAndAccept() { + withBuilder { builder -> + // The canonical Maya deposit sequence, sans funding: vault output, + // memo, insertion-order + VIN0-change options. + WalletManagerNative.coreTxBuilderAddOutput(builder, vaultAddressForTest(), 100_000) + WalletManagerNative.coreTxBuilderAddOpReturn( + builder, + "=:ETH.ETH:0x1c7b17362c84287bd1184447e6dfeaf920c31bbe".toByteArray(Charsets.UTF_8), + ) + WalletManagerNative.coreTxBuilderPreserveOutputOrder(builder) + WalletManagerNative.coreTxBuilderChangeToFirstInput(builder) + } + } + + @Test + fun opReturnAcceptsExactly80Bytes() { + withBuilder { builder -> + WalletManagerNative.coreTxBuilderAddOpReturn(builder, ByteArray(80)) + } + } + + @Test + fun opReturnRejects81BytesAndBuilderSurvives() { + withBuilder { builder -> + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreTxBuilderAddOpReturn(builder, ByteArray(81)) + } + // The FFI rejects the payload BEFORE consuming builder state, so + // the same handle must still accept further configuration. + WalletManagerNative.coreTxBuilderAddOutput(builder, vaultAddressForTest(), 100_000) + } + } + + @Test + fun signedTransactionBytesSymbolBindsAndRejectsNullHandle() { + NativeLoader.ensureLoaded() + // Handle 0 can never be a finalized transaction; the call must throw + // (not crash), which also proves the JNI symbol resolves. + assertThrows(DashSDKException::class.java) { + WalletManagerNative.coreSignedTransactionV2Bytes(0L) + } + } + + private fun vaultAddressForTest(): String = testnetAddress +} 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 dde343ebdb9..d6de4e201cb 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 @@ -153,6 +153,14 @@ internal object WalletManagerNative { */ external fun coreTxBuilderAddOutput(builder: Long, address: String, amount: Long) + /** + * `core_wallet_tx_builder_add_op_return` — append a zero-value OP_RETURN + * output carrying [data] (a MAYACHAIN-style deposit memo). Rejected + * Rust-side over the 80-byte standardness limit — BEFORE the builder's + * state is consumed, so a refused memo leaves prior outputs intact. + */ + external fun coreTxBuilderAddOpReturn(builder: Long, data: ByteArray) + /** * `core_wallet_tx_builder_set_change_address` — override the change * address (network-checked). Optional; the Core→Core send relies on @@ -160,6 +168,21 @@ internal object WalletManagerNative { */ external fun coreTxBuilderSetChangeAddress(builder: Long, address: String) + /** + * `core_wallet_tx_builder_preserve_output_order` — keep outputs in + * insertion order instead of BIP-69 sorting them at build time + * (MAYACHAIN deposits require vault = VOUT0, memo = VOUT1). + */ + external fun coreTxBuilderPreserveOutputOrder(builder: Long) + + /** + * `core_wallet_tx_builder_change_to_first_input` — route change to the + * address of the first selected input (VIN0). MAYACHAIN identifies the + * depositor by VIN0 and pays refunds there. Overrides the change address + * [coreTxBuilderSetFunding] assigned. + */ + external fun coreTxBuilderChangeToFirstInput(builder: Long) + /** `core_wallet_tx_builder_set_fee_rate` — fee rate in duffs/kB (> 0). */ external fun coreTxBuilderSetFeeRate(builder: Long, satPerKb: Long) @@ -290,6 +313,14 @@ internal object WalletManagerNative { /** Read the finalized transaction's fee before consumption. */ external fun coreSignedTransactionV2Fee(transaction: Long): Long + /** + * `core_wallet_signed_transaction_v2_bytes` — the consensus-serialized + * signed transaction bytes, read WITHOUT consuming the ownership token. + * Lets the caller assert the deposit shape (e.g. MAYACHAIN's + * vault/OP_RETURN/change ordering) before deciding to broadcast. + */ + external fun coreSignedTransactionV2Bytes(transaction: Long): ByteArray + /** `core_wallet_destroy` — release a core handle from [platformWalletGetCore]. Safe on 0. */ external fun coreWalletDestroy(coreHandle: Long) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt index a5c8c5fd7b6..6aace10f3b2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt @@ -18,9 +18,14 @@ import java.util.concurrent.atomic.AtomicLong * backstop; the class is NOT thread-safe (the FFI builder must be used from * one thread at a time). * - * ## Not a public API — drive only through [ManagedPlatformWallet.sendToAddresses] + * ## Not a public API — drive only through [ManagedPlatformWallet] * - * The old [setFunding] + [buildSigned] sequence remains only as a deprecated ABI + * SDK consumers reach this builder through + * [ManagedPlatformWallet.sendToAddresses] (immediate broadcast) and + * [ManagedPlatformWallet.buildSignedPayment] (deferred BIP70/BIP270 flows and + * MAYACHAIN-style deposits — the option parameters there thread [addOpReturn], + * [preserveOutputOrder] and [changeToFirstInput] into the build). The old + * [setFunding] + [buildSigned] sequence remains only as a deprecated ABI * compatibility path and is not used by SDK convenience sends. * * @param network the wallet network — output and change addresses are @@ -61,11 +66,43 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea WalletManagerNative.coreTxBuilderAddOutput(handle, address, amountDuffs) } + /** + * Add a zero-value OP_RETURN output carrying [data] for a MAYACHAIN-style + * deposit memo (mirror of Swift's `addOpReturn` in + * packages/swift-sdk/.../CoreWallet/CoreTransactionBuilder.swift). Payloads over the + * 80-byte standardness limit are rejected Rust-side without disturbing + * outputs already added. + * See https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions + */ + internal fun addOpReturn(data: ByteArray): CoreTransactionBuilder = apply { + WalletManagerNative.coreTxBuilderAddOpReturn(handle, data) + } + /** Override the change address (network-checked Rust-side). */ internal fun setChangeAddress(address: String): CoreTransactionBuilder = apply { WalletManagerNative.coreTxBuilderSetChangeAddress(handle, address) } + /** + * Preserve outputs in insertion order (skip BIP-69 sorting) for a + * MAYACHAIN-style deposit — vault must stay VOUT0, memo VOUT1 (mirror of + * Swift's `preserveOutputOrder` in + * packages/swift-sdk/.../CoreWallet/CoreTransactionBuilder.swift). + */ + internal fun preserveOutputOrder(): CoreTransactionBuilder = apply { + WalletManagerNative.coreTxBuilderPreserveOutputOrder(handle) + } + + /** + * Route change to the first selected input's address (VIN0) for a + * MAYACHAIN-style deposit — MAYAChain identifies the depositor by VIN0 + * and pays refunds there (mirror of Swift's `changeToFirstInput` in + * packages/swift-sdk/.../CoreWallet/CoreTransactionBuilder.swift). + */ + internal fun changeToFirstInput(): CoreTransactionBuilder = apply { + WalletManagerNative.coreTxBuilderChangeToFirstInput(handle) + } + /** Set the fee rate in duffs/kB (> 0). */ internal fun setFeeRate(satPerKb: Long): CoreTransactionBuilder = apply { WalletManagerNative.coreTxBuilderSetFeeRate(handle, satPerKb) @@ -236,6 +273,19 @@ class FinalizedCoreTransaction internal constructor(handle: Long, val fee: Long) internal fun takeForAbandon(): Long = takeForBroadcast() + /** + * Consensus-serialized signed transaction bytes (copied out) WITHOUT + * consuming the ownership token — mirror of Swift's `serializedData()` in + * packages/swift-sdk/.../CoreWallet/CoreTransactionBuilder.swift. + * Lets the caller assert the deposit shape (e.g. MAYACHAIN's + * vault/OP_RETURN/change output order) before deciding to broadcast. + */ + fun serializedData(): ByteArray { + val handle = handleRef.get() + check(handle != 0L) { "FinalizedCoreTransaction has already been consumed" } + return WalletManagerNative.coreSignedTransactionV2Bytes(handle) + } + override fun close() = cleanable.clean() private class Cleanup(private val handleRef: AtomicLong) : Runnable { 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 40a35e06531..48564d25184 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 @@ -307,9 +307,35 @@ class ManagedPlatformWallet internal constructor( * this call and [broadcastSigned] drops the reservation on restart (the * UTXOs become spendable again) — the same property dashj has. * + * ## MAYACHAIN-style deposits + * + * The optional builder controls ([opReturnData], [preserveOutputOrder], + * [changeToFirstInput]) exist for MAYACHAIN/THORChain-style swap deposits + * (see https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions), + * where Swift consumers drive the public `CoreTransactionBuilder` directly + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift: + * `addOpReturn` / `preserveOutputOrder` / `changeToFirstInput`). On + * Android the builder is not public, so this is the supported path: pass + * the vault as the single recipient, the swap memo as [opReturnData], and + * enable both flags — the built transaction then has the vault at VOUT0, + * the memo at VOUT1, and change (paid back to the first input's address, + * which MAYAChain uses to identify the depositor and refund) at VOUT2. + * Assert that shape from [SignedCoreTransaction.rawTxBytes] before + * deciding: [broadcastSigned] to submit, or [releaseReservation] / + * [SignedCoreTransaction.close] to abandon without ever broadcasting. + * * @param network the wallet network — see [sendToAddresses]. * @param coreSignerHandle the manager's `MnemonicResolverHandle` — see * [sendToAddresses]. No private key crosses the boundary. + * @param opReturnData if non-null, append a zero-value OP_RETURN output + * carrying these bytes after the recipient outputs. Size limits are + * enforced Rust-side (the 80-byte standardness ceiling); an oversize + * payload fails the build without reserving anything. + * @param preserveOutputOrder keep outputs in insertion order (skip the + * default BIP-69 sort) — required when a protocol assigns meaning to + * output indices, as MAYAChain does. + * @param changeToFirstInput route change back to the first selected + * input's address (VIN0) instead of a fresh change address. */ suspend fun buildSignedPayment( recipients: List>, @@ -317,6 +343,9 @@ class ManagedPlatformWallet internal constructor( coreSignerHandle: Long, accountType: AccountType = AccountType.BIP44, accountIndex: Int = 0, + opReturnData: ByteArray? = null, + preserveOutputOrder: Boolean = false, + changeToFirstInput: Boolean = false, ): SignedCoreTransaction = gate.opWithCleanupOnCancellation( // Native finalization mints the token and transfers reservation ownership // to it before the blocking JNI call returns, so the token already exists @@ -350,6 +379,16 @@ class ManagedPlatformWallet internal constructor( for ((address, amount) in recipients) { builder.addOutput(address, amount) } + // Canonical MAYACHAIN sequence: memo after the recipient + // outputs, then the shape flags — with preserveOutputOrder the + // built transaction keeps vault=VOUT0 / memo=VOUT1 / change last. + opReturnData?.let { builder.addOpReturn(it) } + if (preserveOutputOrder) { + builder.preserveOutputOrder() + } + if (changeToFirstInput) { + builder.changeToFirstInput() + } builder.finalizeSignedPayment( this@ManagedPlatformWallet, builderAccountType, diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index ac8a39297a2..798d4f11834 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -701,6 +701,40 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_tx_builder_add_op_return` — append a zero-value OP_RETURN +/// output carrying `data` (a MAYACHAIN-style deposit memo). The FFI rejects +/// a payload over the 80-byte standardness limit BEFORE consuming the +/// builder's state, so a refused memo leaves outputs/options intact. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreTxBuilderAddOpReturn( + mut env: JNIEnv, + _class: JClass, + builder: jlong, + data: JByteArray, +) { + guard(&mut env, (), |env| { + if builder == 0 { + throw_sdk_exception(env, 1, "builder handle is 0"); + return; + } + let bytes = match env.convert_byte_array(&data) { + Ok(b) => b, + Err(_) => { + throw_sdk_exception(env, 1, "data must be a byte[]"); + return; + } + }; + let result = unsafe { + platform_wallet_ffi::core_wallet_tx_builder_add_op_return( + builder as *mut platform_wallet_ffi::FFITransactionBuilder, + bytes.as_ptr(), + bytes.len(), + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + /// `core_wallet_tx_builder_set_change_address` — override the change /// address (network-checked Rust-side). Optional: `set_funding` also sets a /// change address, so the `.coreToCore` send path does not call this. @@ -729,6 +763,53 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_tx_builder_preserve_output_order` — keep outputs in +/// insertion order instead of BIP-69 sorting them at build time. Required +/// for MAYACHAIN-style deposits (vault must stay VOUT0, memo VOUT1). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreTxBuilderPreserveOutputOrder( + mut env: JNIEnv, + _class: JClass, + builder: jlong, +) { + guard(&mut env, (), |env| { + if builder == 0 { + throw_sdk_exception(env, 1, "builder handle is 0"); + return; + } + let result = unsafe { + platform_wallet_ffi::core_wallet_tx_builder_preserve_output_order( + builder as *mut platform_wallet_ffi::FFITransactionBuilder, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// `core_wallet_tx_builder_change_to_first_input` — route change to the +/// address of the first selected input (VIN0). Required for MAYACHAIN-style +/// deposits: MAYAChain identifies the depositor by VIN0 and pays refunds +/// there. Overrides any change address `set_funding` assigned. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreTxBuilderChangeToFirstInput( + mut env: JNIEnv, + _class: JClass, + builder: jlong, +) { + guard(&mut env, (), |env| { + if builder == 0 { + throw_sdk_exception(env, 1, "builder handle is 0"); + return; + } + let result = unsafe { + platform_wallet_ffi::core_wallet_tx_builder_change_to_first_input( + builder as *mut platform_wallet_ffi::FFITransactionBuilder, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + /// `core_wallet_tx_builder_set_fee_rate` — set the fee rate in duffs/kB. /// Rejects a non-positive value at the boundary (a negative jlong would /// otherwise bit-cast to a huge u64). @@ -1381,6 +1462,55 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_signed_transaction_v2_bytes` — the consensus-serialized +/// signed transaction bytes of a finalized-transaction handle from +/// [coreTxBuilderFinalize], WITHOUT consuming the ownership token (mirror of +/// Swift's `FinalizedCoreTransaction.serializedData()`). Lets the caller +/// assert the deposit shape (e.g. MAYACHAIN's vault/OP_RETURN/change output +/// order) before deciding to broadcast. The FFI-owned buffer is copied into +/// the returned `byte[]` and freed here. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreSignedTransactionV2Bytes( + mut env: JNIEnv, + _class: JClass, + transaction_handle: jlong, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if transaction_handle == 0 { + throw_sdk_exception(env, 1, "transaction handle is 0"); + return ptr::null_mut(); + } + let mut bytes_ptr: *mut u8 = ptr::null_mut(); + let mut bytes_len: usize = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_signed_transaction_v2_bytes( + transaction_handle as Handle, + &mut bytes_ptr, + &mut bytes_len, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if bytes_ptr.is_null() || bytes_len == 0 { + // A signed transaction is never 0 bytes — same check Swift makes. + throw_sdk_exception( + env, + 1, + "FFI returned success but finalized transaction bytes were empty", + ); + return ptr::null_mut(); + } + // Copy into a JVM array, then free the FFI-owned buffer on every path. + let array = { + let slice = unsafe { std::slice::from_raw_parts(bytes_ptr, bytes_len) }; + env.byte_array_from_slice(slice) + }; + unsafe { platform_wallet_ffi::platform_wallet_bytes_free(bytes_ptr, bytes_len) }; + array.map(|a| a.into_raw()).unwrap_or(ptr::null_mut()) + }) +} + /// `core_wallet_destroy` — release a transient core-wallet handle from /// [platformWalletGetCore]. Safe on 0. #[no_mangle]