diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index a97ef49d7c..d023b5a511 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -38,6 +38,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 + # target/llvm-cov-target is deliberately NOT pruned here: it holds the # coverage-instrumented build (~10GB) that makes the test step's compile # incremental (~2.5 min saved per run). The size/free-disk guard below diff --git a/Cargo.lock b/Cargo.lock index c0bc429072..a20399021b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "bincode", "dashcore-private", @@ -2474,7 +2474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2535,7 +2535,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2904,7 +2904,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" [[package]] name = "glob" @@ -3839,7 +3839,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "aes", "async-trait", @@ -4124,7 +4124,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4140,7 +4140,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=08bf729de819f52973002f754342fedac14a06db#08bf729de819f52973002f754342fedac14a06db" +source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" dependencies = [ "async-trait", "bincode", @@ -4651,7 +4651,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5750,7 +5750,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6557,7 +6557,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6570,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6629,7 +6629,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7489,7 +7489,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8938,7 +8938,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 41ec72c3f0..41182275c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "08bf729de819f52973002f754342fedac14a06db" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index ee1f064b18..e8816d3956 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -173,6 +173,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 diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index fd0d964ea5..9b37485330 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -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, MAX_STANDARD_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}; @@ -508,6 +510,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 > MAX_STANDARD_OP_RETURN_BYTES { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "OP_RETURN payload too large: {data_len} bytes (max {MAX_STANDARD_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] @@ -538,6 +591,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] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift index 7d1047d866..b7eead9a97 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -68,6 +68,26 @@ public final class FinalizedCoreTransaction { } func takeForAbandon() throws -> Handle { try takeForBroadcast() } + + /// Consensus-serialized signed transaction bytes (copied out) without + /// consuming the ownership token. + public func serializedData() throws -> Data { + guard nativeHandle != 0 else { + throw PlatformWalletError.unknown("FinalizedCoreTransaction already consumed") + } + + var bytesPtr: UnsafeMutablePointer? = nil + var bytesLen: UInt = 0 + try core_wallet_signed_transaction_v2_bytes(nativeHandle, &bytesPtr, &bytesLen).check() + + guard let bytesPtr, bytesLen > 0 else { + throw PlatformWalletError.unknown( + "FFI returned success but finalized transaction bytes were empty" + ) + } + defer { platform_wallet_bytes_free(bytesPtr, bytesLen) } + return Data(bytes: bytesPtr, count: Int(bytesLen)) + } } /// A built, signed Core transaction whose funding UTXOs are reserved, awaiting @@ -266,6 +286,20 @@ public final class CoreTransactionBuilder { return self } + /// Add a zero-value OP_RETURN output carrying `data` for a MAYACHAIN-style + /// deposit. See https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions + @discardableResult + public func addOpReturn(_ data: Data) throws -> CoreTransactionBuilder { + try data.withUnsafeBytes { buf in + try core_wallet_tx_builder_add_op_return( + handle, + buf.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(data.count) + ).check() + } + return self + } + @discardableResult public func setChangeAddress(_ address: String) throws -> CoreTransactionBuilder { let c = strdup(address) @@ -274,6 +308,22 @@ public final class CoreTransactionBuilder { return self } + /// Preserve outputs in insertion order for a MAYACHAIN-style deposit. + /// See https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions + @discardableResult + public func preserveOutputOrder() throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_preserve_output_order(handle).check() + return self + } + + /// Route change to the first selected input address (VIN0) for a MAYACHAIN-style deposit. + /// See https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions + @discardableResult + public func changeToFirstInput() throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_change_to_first_input(handle).check() + return self + } + @discardableResult public func setFeeRate(satPerKb: UInt64) throws -> CoreTransactionBuilder { try core_wallet_tx_builder_set_fee_rate(handle, satPerKb).check() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/MayaDepositVerificationIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/MayaDepositVerificationIntegrationTests.swift new file mode 100644 index 0000000000..42e6723ba4 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/MayaDepositVerificationIntegrationTests.swift @@ -0,0 +1,685 @@ +import Foundation +import XCTest +@testable import SwiftDashSDK + +@MainActor +final class MayaDepositVerificationIntegrationTests: IntegrationTestCase { + private enum Constants { + static let bip44TypeTag: UInt8 = 0 + static let bip44StandardTag: UInt8 = 0 + static let bip44AccountIndex: UInt32 = 0 + static let feeRateSatPerKb: UInt64 = 1_000 + static let minimumDepositDuffs: UInt64 = 10_000 + static let maxMemoBytes = 80 + static let longMemo = + "=:ARB.GLD:0x51a1449b3B6D635EddeC781cD47a99221712De97:344233230e4/1/0:_/def:15/0" + static let shortMemo = + "=:r:thor166n4w5039meulfa3p6ydg60ve6ueac7tlt0jws:669458827/1/0:_/def:15/0" + } + + private struct DepositObservation { + let name: String + let txHex: String + let serializedSize: Int + let inputCount: Int + let outputCount: Int + let vaultAddress: String + let depositAmount: UInt64 + let actualVaultAmount: UInt64 + let memo: String + let decodedMemo: String + let memoBytes: Int + let outputOneIsOpReturn: Bool + let inputZeroAddress: String? + let outputTwoMatchesInputZeroScript: Bool + let feeDuffs: UInt64 + let feeRateDuffsPerByte: Double + } + + private struct LegacyFeeObservation { + let name: String + let actualFeeDuffs: UInt64 + let legacyExpectedFeeDuffs: UInt64 + let serializedSize: Int + let inputCount: Int + let outputCount: Int + let payloadLength: Int + } + + private struct ParsedTransactionLayout { + let inputCount: Int + let outputCount: Int + let payloadLength: Int + } + + /// Opt-in gate. `run_tests.sh` (which CI runs for `swift-sdk-build`) executes this bundle, + /// and these tests sit behind several 90-second `waitForSpendable` windows on top of a full + /// SPV bootstrap. A bootstrap stall would hang the job rather than fail it, so they run only + /// when explicitly requested until the bootstrap path is reliable. + private static let isEnabled = + ProcessInfo.processInfo.environment["MAYA_DEPOSIT_VERIFICATION"] == "1" + + private func skipUnlessEnabled() throws { + try XCTSkipUnless( + Self.isEnabled, + "Set MAYA_DEPOSIT_VERIFICATION=1 to run the Maya deposit verification suite " + + "(requires a local dashmate devnet and a completed SPV bootstrap)." + ) + } + + func testPrompt04StaticProofAndLegacyFeeParity() async throws { + try skipUnlessEnabled() + try env.walletManager.startSpv(config: env.spvConfig) + + let shortDeposit = try await buildDepositObservation( + name: "short-memo-single-input", + fundingDashAmounts: [0.5], + depositAmountDuffs: 200_000, + memo: Constants.shortMemo + ) + let longDeposit = try await buildDepositObservation( + name: "long-memo-multi-input", + fundingDashAmounts: [0.2, 0.2], + depositAmountDuffs: 35_000_000, + memo: Constants.longMemo + ) + + assertDepositObservation(shortDeposit) + assertDepositObservation(longDeposit) + + let ordinary = try await legacyFeeObservationForOrdinarySend() + let multiRecipient = try await legacyFeeObservationForMultiRecipientSend() + let selectedInput = try await legacyFeeObservationForSelectedInputShape() + let sweep = try await legacyFeeObservationForDrainShape() + let addressFunding = try await legacyFeeObservationForAssetLockShape( + name: "asset-lock-address-top-up", + fundingType: .assetLockAddressTopUp + ) + let identityFunding = try await legacyFeeObservationForAssetLockShape( + name: "asset-lock-identity-registration", + fundingType: .identityRegistration + ) + + let feeObservations = [ + ordinary, + multiRecipient, + selectedInput, + sweep, + addressFunding, + identityFunding, + ] + + for observation in feeObservations { + XCTAssertEqual( + observation.actualFeeDuffs, + observation.legacyExpectedFeeDuffs, + "\(observation.name) fee moved: actual \(observation.actualFeeDuffs) vs legacy \(observation.legacyExpectedFeeDuffs)" + ) + } + + print(renderDepositObservation(shortDeposit)) + print(renderDepositObservation(longDeposit)) + for observation in feeObservations { + print(renderLegacyFeeObservation(observation)) + } + } + + /// The 80-byte ceiling is the reason the whole memo path can refuse a swap, so prove the + /// boundary rather than the fixture: 80 bytes must be accepted, 81 rejected. + /// + /// Also pins the FFI guarantee this PR introduced — `core_wallet_tx_builder_add_op_return` + /// validates the payload *before* `take_builder()`, so a rejected memo must leave the + /// builder's already-configured outputs and options intact rather than replacing it with a + /// `mem::take` default. The rejected builder is therefore reused here and finalized, and the + /// resulting transaction must still carry the vault output and the ordering flags set before + /// the failed call. + func testOpReturnCeilingBoundaryAndRejectionPreservesBuilder() async throws { + try skipUnlessEnabled() + try env.walletManager.startSpv(config: env.spvConfig) + + let wallet = try await env.makeTestWallet(name: "maya-op-return-boundary") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let depositAmount: UInt64 = 200_000 + let vaultAddress = try await env.coreRPC.getNewAddress() + let atCeiling = Data(repeating: 0x4d, count: Constants.maxMemoBytes) + let overCeiling = Data(repeating: 0x4d, count: Constants.maxMemoBytes + 1) + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addOutput(address: vaultAddress, amountDuffs: depositAmount) + try builder.preserveOutputOrder() + try builder.changeToFirstInput() + + XCTAssertThrowsError(try builder.addOpReturn(overCeiling)) { error in + XCTAssertTrue( + "\(error)".lowercased().contains("op_return"), + "expected an OP_RETURN size error, got \(error)" + ) + } + + // Same builder instance: if the rejection had consumed it, this would build a + // transaction missing the vault output and the ordering flags. + try builder.addOpReturn(atCeiling) + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + + let decoded = try TransactionDecoder.decode(try tx.serializedData(), network: .regtest) + XCTAssertEqual(decoded.outputs.count, 3, "vault + memo + change survived the rejection") + XCTAssertEqual(decoded.outputs[0].address, vaultAddress, "VOUT0 lost after the rejected memo") + XCTAssertEqual(decoded.outputs[0].valueDuffs, depositAmount) + XCTAssertEqual(decoded.outputs[1].valueDuffs, 0) + XCTAssertEqual( + opReturnPayload(from: decoded.outputs[1].scriptPubkey), atCeiling, + "an exactly-80-byte payload must be accepted and carried verbatim" + ) + } + + private func buildDepositObservation( + name: String, + fundingDashAmounts: [Double], + depositAmountDuffs: UInt64, + memo: String + ) async throws -> DepositObservation { + let wallet = try await env.makeTestWallet(name: "maya-\(name)") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + for amount in fundingDashAmounts { + let address = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: address, dash: amount) + } + + let expectedSpendable = fundingDashAmounts.reduce(UInt64(0)) { partial, amount in + partial + UInt64((amount * 100_000_000).rounded()) + } + try await wallet.waitForSpendable(exactly: expectedSpendable, timeout: 90) + + let utxosBeforeBuild = try bip44Utxos(for: platformWallet) + let vaultAddress = try await env.coreRPC.getNewAddress() + let memoData = Data(memo.utf8) + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addOutput(address: vaultAddress, amountDuffs: depositAmountDuffs) + try builder.addOpReturn(memoData) + try builder.preserveOutputOrder() + try builder.changeToFirstInput() + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + let txData = try tx.serializedData() + + let decoded = try TransactionDecoder.decode(txData, network: .regtest) + let memoOutput = decoded.outputs[1] + let decodedMemoData = try XCTUnwrap(opReturnPayload(from: memoOutput.scriptPubkey)) + let decodedMemo = try XCTUnwrap(String(data: decodedMemoData, encoding: .utf8)) + + // Both fixtures fund far more than the deposit plus fee, so change is always well above + // dust and the transaction must be exactly vault + memo + change. Assert the shape here, + // before any subscripting: a wrong shape must surface as a readable failure rather than + // trapping on an out-of-range index and taking the whole test process down. + XCTAssertEqual( + decoded.outputs.count, 3, + "\(name) must contain vault, memo and change outputs" + ) + XCTAssertFalse(decoded.inputs.isEmpty, "\(name) has no inputs") + guard decoded.outputs.count == 3, let firstInput = decoded.inputs.first else { + throw XCTSkip("\(name) produced an unexpected transaction shape; assertions above hold the detail") + } + + let inputZeroMatch = try findMatchedUTXO(for: firstInput, in: utxosBeforeBuild) + let outputTwoMatchesInputZeroScript = + decoded.outputs[2].scriptPubkey == inputZeroMatch.scriptPubkey + + return DepositObservation( + name: name, + txHex: hex(txData), + serializedSize: txData.count, + inputCount: decoded.inputs.count, + outputCount: decoded.outputs.count, + vaultAddress: vaultAddress, + depositAmount: depositAmountDuffs, + actualVaultAmount: decoded.outputs[0].valueDuffs, + memo: memo, + decodedMemo: decodedMemo, + memoBytes: memoData.count, + outputOneIsOpReturn: memoOutput.scriptPubkey.first == 0x6a, + inputZeroAddress: decoded.inputs.first?.address, + outputTwoMatchesInputZeroScript: outputTwoMatchesInputZeroScript, + feeDuffs: tx.fee, + feeRateDuffsPerByte: Double(tx.fee) / Double(txData.count) + ) + } + + private func assertDepositObservation(_ observation: DepositObservation) { + // Exactly three, not a range: both fixtures leave millions of duffs after the vault + // payment and fee, so change is mandatory. Accepting two outputs would let a regression + // that suppresses change pass the very test that exists to prove change goes to VIN0. + XCTAssertEqual(observation.outputCount, 3, "\(observation.name) must be vault + memo + change") + XCTAssertEqual(observation.actualVaultAmount, observation.depositAmount, "\(observation.name) VOUT0 amount mismatch") + XCTAssertTrue(observation.outputOneIsOpReturn, "\(observation.name) VOUT1 is not OP_RETURN") + XCTAssertEqual(observation.decodedMemo, observation.memo, "\(observation.name) memo payload mismatch") + XCTAssertGreaterThanOrEqual(observation.depositAmount, Constants.minimumDepositDuffs, "\(observation.name) deposit fell below Maya dust floor") + XCTAssertLessThanOrEqual(observation.memoBytes, Constants.maxMemoBytes, "\(observation.name) memo exceeded 80 bytes") + XCTAssertGreaterThanOrEqual(observation.feeDuffs, UInt64(observation.serializedSize), "\(observation.name) fee fell below 1 duff/byte") + XCTAssertTrue(observation.outputTwoMatchesInputZeroScript, "\(observation.name) VOUT2 does not return change to VIN0 scriptPubKey") + } + + private func legacyFeeObservationForOrdinarySend() async throws -> LegacyFeeObservation { + let wallet = try await env.makeTestWallet(name: "legacy-ordinary") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let utxosBeforeBuild = try bip44Utxos(for: platformWallet) + let recipient = try await env.coreRPC.getNewAddress() + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addOutput(address: recipient, amountDuffs: 1_000_000) + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + let txData = try tx.serializedData() + + return try makeLegacyFeeObservation( + name: "ordinary-single-recipient-send", + txData: txData, + actualFee: tx.fee, + utxosBeforeBuild: utxosBeforeBuild + ) + } + + private func legacyFeeObservationForMultiRecipientSend() async throws -> LegacyFeeObservation { + let wallet = try await env.makeTestWallet(name: "legacy-multi-recipient") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let utxosBeforeBuild = try bip44Utxos(for: platformWallet) + let recipientA = try await env.coreRPC.getNewAddress() + let recipientB = try await env.coreRPC.getNewAddress() + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addOutput(address: recipientA, amountDuffs: 1_000_000) + try builder.addOutput(address: recipientB, amountDuffs: 2_000_000) + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + let txData = try tx.serializedData() + + return try makeLegacyFeeObservation( + name: "multi-recipient-bip70-shape", + txData: txData, + actualFee: tx.fee, + utxosBeforeBuild: utxosBeforeBuild + ) + } + + private func legacyFeeObservationForSelectedInputShape() async throws -> LegacyFeeObservation { + let wallet = try await env.makeTestWallet(name: "legacy-selected-input") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let selectedUtxos = try bip44Utxos(for: platformWallet) + let recipient = try await env.coreRPC.getNewAddress() + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addInputs( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex, + utxos: selectedUtxos + ) + try builder.addOutput(address: recipient, amountDuffs: 1_000_000) + try builder.setChangeAddress(fundingAddress) + try builder.setFeeRate(satPerKb: Constants.feeRateSatPerKb) + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + let txData = try tx.serializedData() + + return try makeLegacyFeeObservation( + name: "selected-input-send-shape", + txData: txData, + actualFee: tx.fee, + utxosBeforeBuild: selectedUtxos + ) + } + + private func legacyFeeObservationForDrainShape() async throws -> LegacyFeeObservation { + let wallet = try await env.makeTestWallet(name: "legacy-drain") + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let utxosBeforeBuild = try bip44Utxos(for: platformWallet) + let recipient = try await env.coreRPC.getNewAddress() + + let builder = try CoreTransactionBuilder(network: .regtest) + try builder.addInputs( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex, + utxos: utxosBeforeBuild + ) + try builder.setSelectionStrategy(.all) + try builder.setFeeRate(satPerKb: Constants.feeRateSatPerKb) + try builder.addOutput(address: recipient, amountDuffs: 0) + let tx = try builder.finalizeAtomic( + wallet: platformWallet, + accountType: .bip44, + accountIndex: Constants.bip44AccountIndex + ) + let txData = try tx.serializedData() + + return try makeLegacyFeeObservation( + name: "drain-shape-coinjoin-sweep-equivalent", + txData: txData, + actualFee: tx.fee, + utxosBeforeBuild: utxosBeforeBuild + ) + } + + private func legacyFeeObservationForAssetLockShape( + name: String, + fundingType: ManagedAssetLockManager.FundingType + ) async throws -> LegacyFeeObservation { + let wallet = try await env.makeTestWallet(name: name) + let coreWallet = wallet.getCoreWallet() + let platformWallet = wallet.getPlatformWallet() + + let fundingAddress = try coreWallet.nextReceiveAddress() + _ = try await fundByMining(address: fundingAddress, dash: 0.5) + try await wallet.waitForSpendable(exactly: 50_000_000, timeout: 90) + + let utxosBeforeBuild = try bip44Utxos(for: platformWallet) + let manager = try platformWallet.assetLockManager() + let resolver = MnemonicResolver() + let built = try manager.buildTransaction( + amountDuffs: 10_000_000, + accountIndex: Constants.bip44AccountIndex, + fundingType: fundingType, + identityIndex: 0, + resolver: resolver + ) + + let decoded = try TransactionDecoder.decode(built.transaction, network: .regtest) + let actualFee = try sumSelectedInputs(decoded.inputs, from: utxosBeforeBuild) + - decoded.outputs.reduce(UInt64(0)) { $0 + $1.valueDuffs } + + return try makeLegacyFeeObservation( + name: name, + txData: built.transaction, + actualFee: actualFee, + utxosBeforeBuild: utxosBeforeBuild + ) + } + + private func makeLegacyFeeObservation( + name: String, + txData: Data, + actualFee: UInt64, + utxosBeforeBuild: [PlatformWalletManager.AccountUtxo] + ) throws -> LegacyFeeObservation { + let decoded = try TransactionDecoder.decode(txData, network: .regtest) + let layout = try parseTransactionLayout(txData) + let selectedInputsValue = try sumSelectedInputs(decoded.inputs, from: utxosBeforeBuild) + let paidOutputs = decoded.outputs.reduce(UInt64(0)) { $0 + $1.valueDuffs } + XCTAssertEqual( + actualFee, + selectedInputsValue - paidOutputs, + "\(name) reported fee does not match selected-input minus output value" + ) + + let outputsLegacyBytes = layout.outputCount * 34 + let legacyExpected = 8 + + varIntSize(layout.inputCount) + + layout.inputCount * 148 + + varIntSize(layout.outputCount) + + outputsLegacyBytes + + (layout.payloadLength > 0 ? varIntSize(layout.payloadLength) + layout.payloadLength : 0) + + return LegacyFeeObservation( + name: name, + actualFeeDuffs: actualFee, + legacyExpectedFeeDuffs: UInt64(legacyExpected), + serializedSize: txData.count, + inputCount: layout.inputCount, + outputCount: layout.outputCount, + payloadLength: layout.payloadLength + ) + } + + @discardableResult + private func fundByMining(address: String, dash: Double) async throws -> String { + let txid = try await env.coreRPC.sendToAddress(amount: dash, address: address) + _ = try await env.mine(1) + return txid + } + + private func bip44Utxos(for wallet: ManagedPlatformWallet) throws -> [PlatformWalletManager.AccountUtxo] { + let walletId = wallet.walletId + guard let balance = env.walletManager.accountBalances(for: walletId).first(where: { + $0.typeTag == Constants.bip44TypeTag + && $0.standardTag == Constants.bip44StandardTag + && $0.index == Constants.bip44AccountIndex + }) else { + throw XCTSkip("BIP44 account balance was not materialized") + } + return env.walletManager.accountUtxos(for: walletId, balance: balance).filter { !$0.isLocked } + } + + private func findMatchedUTXO( + for input: DecodedTransaction.Input, + in utxos: [PlatformWalletManager.AccountUtxo] + ) throws -> PlatformWalletManager.AccountUtxo { + guard let match = utxos.first(where: { + $0.outpointTxid == input.prevTxid && $0.outpointVout == input.prevVout + }) else { + throw NSError(domain: "MayaVerification", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Could not match input \(hex(input.prevTxid)):\(input.prevVout) to a pre-build UTXO" + ]) + } + return match + } + + private func sumSelectedInputs( + _ inputs: [DecodedTransaction.Input], + from utxos: [PlatformWalletManager.AccountUtxo] + ) throws -> UInt64 { + var total: UInt64 = 0 + for input in inputs { + total += try findMatchedUTXO(for: input, in: utxos).valueDuffs + } + return total + } + + private func parseTransactionLayout(_ txData: Data) throws -> ParsedTransactionLayout { + var offset = 0 + + guard txData.count >= 4 else { + throw NSError(domain: "MayaVerification", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Serialized transaction too short" + ]) + } + offset += 4 + + let inputCount = try Int(readVarInt(from: txData, offset: &offset)) + for _ in 0.. UInt64 { + guard offset < data.count else { + throw NSError(domain: "MayaVerification", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected end of transaction while reading varint" + ]) + } + + let prefix = data[offset] + offset += 1 + + switch prefix { + case 0x00...0xfc: + return UInt64(prefix) + case 0xfd: + guard offset + 2 <= data.count else { throw truncatedVarIntError() } + let value = UInt64(data[offset]) | (UInt64(data[offset + 1]) << 8) + offset += 2 + return value + case 0xfe: + guard offset + 4 <= data.count else { throw truncatedVarIntError() } + let value = UInt64(data[offset]) + | (UInt64(data[offset + 1]) << 8) + | (UInt64(data[offset + 2]) << 16) + | (UInt64(data[offset + 3]) << 24) + offset += 4 + return value + default: + guard offset + 8 <= data.count else { throw truncatedVarIntError() } + var value: UInt64 = 0 + for shift in 0..<8 { + value |= UInt64(data[offset + shift]) << (8 * UInt64(shift)) + } + offset += 8 + return value + } + } + + private func truncatedVarIntError() -> NSError { + NSError(domain: "MayaVerification", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected end of transaction while reading extended varint" + ]) + } + + private func opReturnPayload(from script: Data) -> Data? { + guard script.count >= 2, script[0] == 0x6a else { return nil } + let pushOpcode = script[1] + + switch pushOpcode { + case 0x01...0x4b: + let length = Int(pushOpcode) + guard script.count == 2 + length else { return nil } + return script.subdata(in: 2..<(2 + length)) + case 0x4c: + guard script.count >= 3 else { return nil } + let length = Int(script[2]) + guard script.count == 3 + length else { return nil } + return script.subdata(in: 3..<(3 + length)) + case 0x4d: + guard script.count >= 4 else { return nil } + let length = Int(script[2]) | (Int(script[3]) << 8) + guard script.count == 4 + length else { return nil } + return script.subdata(in: 4..<(4 + length)) + default: + return nil + } + } + + private func varIntSize(_ value: Int) -> Int { + switch value { + case 0...0xfc: + return 1 + case 0xfd...0xffff: + return 3 + case 0x1_0000...0xffff_ffff: + return 5 + default: + return 9 + } + } + + private func hex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + private func renderDepositObservation(_ observation: DepositObservation) -> String { + [ + "PROMPT04_DEPOSIT \(observation.name)", + " tx_hex=\(observation.txHex)", + " serialized_size=\(observation.serializedSize)", + " input_count=\(observation.inputCount)", + " output_count=\(observation.outputCount)", + " vault_address=\(observation.vaultAddress)", + " deposit_amount=\(observation.depositAmount)", + " actual_vout0_amount=\(observation.actualVaultAmount)", + " memo_bytes=\(observation.memoBytes)", + " input0_address=\(observation.inputZeroAddress ?? "nil")", + " output2_matches_input0_script=\(observation.outputTwoMatchesInputZeroScript)", + " fee_duffs=\(observation.feeDuffs)", + String(format: " fee_rate_duffs_per_byte=%.6f", observation.feeRateDuffsPerByte), + ].joined(separator: "\n") + } + + private func renderLegacyFeeObservation(_ observation: LegacyFeeObservation) -> String { + [ + "PROMPT04_FEE \(observation.name)", + " actual_fee_duffs=\(observation.actualFeeDuffs)", + " legacy_expected_fee_duffs=\(observation.legacyExpectedFeeDuffs)", + " serialized_size=\(observation.serializedSize)", + " input_count=\(observation.inputCount)", + " output_count=\(observation.outputCount)", + " payload_length=\(observation.payloadLength)", + ].joined(separator: "\n") + } +}