Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 7 additions & 0 deletions .github/workflows/tests-rs-workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ jobs:
with:
clean: false

- name: Fail on local rust-dashcore patch override
run: |
if grep -q '^\[patch\."https://github.com/dashpay/rust-dashcore"\]' Cargo.toml; then
echo "::error::Remove the local rust-dashcore [patch] override before merging"
exit 1
fi

- name: Prune macOS runner disk before tests
run: |
for path in ../target-backup-before-*-clean-* target/llvm-cov-target; do
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,36 @@ 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
* [coreTxBuilderSetFunding], which also sets a change address.
*/
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)

Expand Down Expand Up @@ -239,6 +262,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,40 @@ 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`). 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`).
*/
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`).
*/
internal fun changeToFirstInput(): CoreTransactionBuilder = apply {
WalletManagerNative.coreTxBuilderChangeToFirstInput(handle)
Comment on lines +77 to +103

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Expose the MAYACHAIN controls through a public atomic API

All three new builder controls are internal, as are the builder constructor, addOutput, and finalizeAtomic; the underlying WalletManagerNative object is internal as well. The only public driver, ManagedPlatformWallet.sendToAddresses, accepts ordinary positive-value address outputs, applies none of these controls, immediately broadcasts, and never returns a FinalizedCoreTransaction. Consequently, an application consuming the published dash-sdk-android artifact cannot add the OP_RETURN memo, preserve the required output order, route change to VIN0, or obtain a finalized transaction to inspect with serializedData(). The instrumented test only exercises the internal native surface from within the SDK module, so it does not verify consumer accessibility. Add a public atomic prepare/send API that applies these options and returns a FinalizedCoreTransaction for inspection while keeping the deprecated split setFunding/buildSigned path inaccessible.

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Retracting this as a blocker after verifying the stacked integration context. This PR intentionally adds the JNI/Kotlin binding layer on top of #4286; the supported public call site is the option-carrying ManagedPlatformWallet.buildSignedPayment, whose reservation-token overload belongs to the separate #4185/#4247 deferred-payment stack. The downstream #1535 branch has already exercised that combined stack end to end on mainnet. Requiring a second public atomic API here would duplicate or preempt that stack rather than fix a defect in these bindings. The public entry-point follow-up remains a documented dependency before an integration AAR can ship.

}

/** Set the fee rate in duffs/kB (> 0). */
internal fun setFeeRate(satPerKb: Long): CoreTransactionBuilder = apply {
WalletManagerNative.coreTxBuilderSetFeeRate(handle, satPerKb)
Expand Down Expand Up @@ -185,6 +214,18 @@ 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()`.
* 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 {
Expand Down
22 changes: 22 additions & 0 deletions packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ pub unsafe extern "C" fn core_wallet_signed_transaction_v2_fee(
PlatformWalletFFIResult::ok()
}

#[no_mangle]
pub unsafe extern "C" fn core_wallet_signed_transaction_v2_bytes(
transaction_handle: Handle,
out_bytes: *mut *mut u8,
out_len: *mut usize,
) -> PlatformWalletFFIResult {
check_ptr!(out_bytes);
check_ptr!(out_len);
*out_bytes = std::ptr::null_mut();
*out_len = 0;

let bytes = unwrap_option_or_return!(CORE_SIGNED_TRANSACTION_V2_STORAGE
.with_item(transaction_handle, |tx| dashcore::consensus::serialize(
tx.transaction.transaction()
)));
let len = bytes.len();
let boxed = bytes.into_boxed_slice();
*out_bytes = Box::into_raw(boxed) as *mut u8;
*out_len = len;
PlatformWalletFFIResult::ok()
}

/// Broadcast a transaction built by `core_wallet_tx_builder_build_signed`.
///
/// `account_type`/`account_index` identify the funding account handed to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
use key_wallet::managed_account::ManagedCoreFundsAccount;
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
use key_wallet::wallet::managed_wallet_info::fee::FeeRate;
use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder;
use key_wallet::wallet::managed_wallet_info::transaction_builder::{
TransactionBuilder, DEFAULT_MAX_OP_RETURN_BYTES,
};
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle};
Expand Down Expand Up @@ -270,6 +272,57 @@ pub unsafe extern "C" fn core_wallet_tx_builder_add_output(
PlatformWalletFFIResult::ok()
}

/// Add a zero-value OP_RETURN output carrying `data`.
///
/// # Safety
/// `builder` must be a valid, non-destroyed pointer; `data` must reference a
/// readable buffer of `data_len` bytes when `data_len > 0`.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_tx_builder_add_op_return(
builder: *mut FFITransactionBuilder,
data: *const u8,
data_len: usize,
) -> PlatformWalletFFIResult {
check_ptr!(builder);
if data_len > 0 {
check_ptr!(data);
}

let bytes = if data_len == 0 {
&[]
} else {
std::slice::from_raw_parts(data, data_len)
};

// `add_op_return` takes the builder by value, so a rejected payload drops it and leaves
// `take_builder`'s `mem::take` default behind — silently discarding outputs and options
// the caller already configured. Reject an over-long payload *before* taking the builder
// so the slot keeps its real state. `add_op_return` re-checks; this is the same policy
// constant, not a second opinion.
if data_len > DEFAULT_MAX_OP_RETURN_BYTES {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!(
"OP_RETURN payload too large: {data_len} bytes (max {DEFAULT_MAX_OP_RETURN_BYTES})"
),
);
}

let b = (*builder).take_builder();
let b = match b.add_op_return(bytes) {
Ok(b) => b,
Err(err) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
err.to_string(),
);
}
};
(*builder).store_builder(b);

PlatformWalletFFIResult::ok()
}

/// # Safety
/// `builder` must be a valid, non-destroyed pointer; `address` a valid NUL-terminated C string.
#[no_mangle]
Expand Down Expand Up @@ -300,6 +353,40 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_change_address(
PlatformWalletFFIResult::ok()
}

/// Preserve outputs in the order they were added instead of applying BIP-69 sorting.
///
/// # Safety
/// `builder` must be a valid, non-destroyed pointer.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_tx_builder_preserve_output_order(
builder: *mut FFITransactionBuilder,
) -> PlatformWalletFFIResult {
check_ptr!(builder);

let b = (*builder).take_builder();
let b = b.preserve_output_order();
(*builder).store_builder(b);

PlatformWalletFFIResult::ok()
}

/// Route change to the address of the first selected input (VIN0).
///
/// # Safety
/// `builder` must be a valid, non-destroyed pointer.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_tx_builder_change_to_first_input(
builder: *mut FFITransactionBuilder,
) -> PlatformWalletFFIResult {
check_ptr!(builder);

let b = (*builder).take_builder();
let b = b.change_to_first_input();
(*builder).store_builder(b);

PlatformWalletFFIResult::ok()
}

/// # Safety
/// `builder` must be a valid, non-destroyed pointer.
#[no_mangle]
Expand Down
Loading
Loading