From 7cb62fe493b8a523abb1f8cb4e159f967ff807c3 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 2 Jul 2026 15:22:37 +0200 Subject: [PATCH 1/5] feat(platform-wallet): add pure transaction decoder + thin FFI/Swift wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host apps that consume the platform-wallet FFI need to match arbitrary persisted transactions against external addresses and exact amounts (e.g. the CrowdNode on-chain API: "did a tx pay amount X to address Y?"). The wallet layer persists every transaction's raw bytes but only materializes TXO rows for the wallet's own outputs, so matching an external payment's per-output (address, amount) was unevaluable from either the FFI or the Swift SDK. Layered so the logic lives in the pure crate and the FFI stays a marshalling shell (mirroring how mnemonic_words.rs forwards to key_wallet::Mnemonic): - rs-platform-wallet/src/transaction_decode.rs (pure, no FFI, no unsafe): decode_transaction(bytes, network) -> DecodedTransaction — consensus-decode (rejecting trailing bytes), per-output (Address::from_script, value, script), per-input (OutPoint + best-effort P2PKH sender address from the scriptSig). 6 pure-Rust unit tests. - rs-platform-wallet-ffi/src/tx_decode.rs: platform_wallet_decode_transaction / ..._free — thin C-ABI marshalling over the pure fn + #[repr(C)] structs. 3 boundary tests (C round-trip, null/empty params, null-free safety). - swift-sdk TransactionDecoder.swift: typed DecodedTransaction wrapper with PlatformWalletError mapping, defer-freed FFI memory, txidDisplayHex helper. 7 unit tests. Purely additive — no breaking changes. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../rs-platform-wallet-ffi/src/tx_decode.rs | 288 ++++++++++++++++++ packages/rs-platform-wallet/src/lib.rs | 2 + .../src/transaction_decode.rs | 229 ++++++++++++++ .../PlatformWallet/TransactionDecoder.swift | 113 +++++++ .../TransactionDecoderTests.swift | 80 +++++ 6 files changed, 714 insertions(+) create mode 100644 packages/rs-platform-wallet-ffi/src/tx_decode.rs create mode 100644 packages/rs-platform-wallet/src/transaction_decode.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 74897c6df09..f6d5ba6f29b 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -67,6 +67,7 @@ pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; pub mod tokens; +pub mod tx_decode; pub mod types; pub mod utils; pub mod wallet; @@ -130,6 +131,7 @@ pub use sign_with_mnemonic_resolver::*; pub use spv::*; pub use token_persistence::*; pub use tokens::*; +pub use tx_decode::*; pub use types::*; pub use utils::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/tx_decode.rs b/packages/rs-platform-wallet-ffi/src/tx_decode.rs new file mode 100644 index 00000000000..8117f70fcb7 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/tx_decode.rs @@ -0,0 +1,288 @@ +//! C-ABI marshalling for [`platform_wallet::decode_transaction`] — **thin FFI +//! surface only**. All decoding logic (consensus decode, address derivation, +//! the P2PKH sender-address heuristic) lives in the pure `platform-wallet` +//! crate and is unit-tested there; these entry points only cross the C +//! boundary: marshal bytes in, marshal the decoded structure out, and pair +//! every allocation with a free. Mirrors the delegation shape of +//! `mnemonic_words.rs` (which forwards to `key_wallet::Mnemonic`). +//! +//! `txid` / `prev_txid` are in consensus (internal) byte order — the same +//! convention as `OutPointFFI` — reverse for explorer-style display. + +use std::ffi::CString; +use std::os::raw::c_char; + +use dashcore::hashes::Hash; +use dashcore::Address; +use platform_wallet::decode_transaction; + +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::types::{FFINetwork, Network}; +use crate::{check_ptr, unwrap_result_or_return}; + +/// One decoded transaction input. +#[repr(C)] +pub struct DecodedTxInputFFI { + /// Previous output's txid in consensus (internal) byte order. + pub prev_txid: [u8; 32], + /// Previous output's index. + pub prev_vout: u32, + /// Sender address recovered from a P2PKH scriptSig, or null when the + /// input is coinbase / non-P2PKH / unparseable. Owned by the parent + /// structure — freed by `platform_wallet_decoded_transaction_free`. + pub address: *mut c_char, +} + +/// One decoded transaction output. +#[repr(C)] +pub struct DecodedTxOutputFFI { + /// Destination address for standard scripts (P2PKH / P2SH), or null for + /// non-standard scripts. Owned by the parent structure. + pub address: *mut c_char, + /// Output value in duffs. + pub value_duffs: u64, + /// Raw scriptPubKey bytes (owned by the parent structure). + pub script_pubkey: *mut u8, + pub script_pubkey_len: usize, +} + +/// A consensus-decoded transaction. Allocated by +/// `platform_wallet_decode_transaction`; release with +/// `platform_wallet_decoded_transaction_free`. +#[repr(C)] +pub struct DecodedTransactionFFI { + /// Transaction id in consensus (internal) byte order. + pub txid: [u8; 32], + pub inputs: *mut DecodedTxInputFFI, + pub inputs_count: usize, + pub outputs: *mut DecodedTxOutputFFI, + pub outputs_count: usize, +} + +/// Boxed-slice allocator matching the crate's array-return convention. +fn vec_to_ptr(v: Vec) -> *mut T { + if v.is_empty() { + std::ptr::null_mut() + } else { + Box::into_raw(v.into_boxed_slice()) as *mut T + } +} + +/// Render an optional address to an owned C string, or null. +fn addr_to_cstr(address: Option
) -> *mut c_char { + address + .and_then(|a| CString::new(a.to_string()).ok()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()) +} + +/// Consensus-decode `tx_bytes` into a `DecodedTransactionFFI`. +/// +/// # Safety +/// - `tx_bytes` must point to `tx_bytes_len` readable bytes. +/// - `out_decoded` must be a valid pointer; on success it receives an owned +/// `*mut DecodedTransactionFFI` that must be released with +/// `platform_wallet_decoded_transaction_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_decode_transaction( + tx_bytes: *const u8, + tx_bytes_len: usize, + network: FFINetwork, + out_decoded: *mut *mut DecodedTransactionFFI, +) -> PlatformWalletFFIResult { + check_ptr!(tx_bytes); + check_ptr!(out_decoded); + if tx_bytes_len == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "tx_bytes_len is zero", + ); + } + + let network: Network = network.into(); + let slice = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); + let decoded = unwrap_result_or_return!(decode_transaction(slice, network)); + + let inputs: Vec = decoded + .inputs + .into_iter() + .map(|input| DecodedTxInputFFI { + prev_txid: input.previous_output.txid.to_byte_array(), + prev_vout: input.previous_output.vout, + address: addr_to_cstr(input.address), + }) + .collect(); + + let outputs: Vec = decoded + .outputs + .into_iter() + .map(|output| { + let script = output.script_pubkey.as_bytes().to_vec(); + let script_len = script.len(); + DecodedTxOutputFFI { + address: addr_to_cstr(output.address), + value_duffs: output.value_duffs, + script_pubkey: vec_to_ptr(script), + script_pubkey_len: script_len, + } + }) + .collect(); + + let ffi = DecodedTransactionFFI { + txid: decoded.txid.to_byte_array(), + inputs_count: inputs.len(), + inputs: vec_to_ptr(inputs), + outputs_count: outputs.len(), + outputs: vec_to_ptr(outputs), + }; + + *out_decoded = Box::into_raw(Box::new(ffi)); + PlatformWalletFFIResult::ok() +} + +/// Release a `DecodedTransactionFFI` returned by +/// `platform_wallet_decode_transaction`. Safe to call with null. +/// +/// # Safety +/// `decoded` must be null or a pointer previously returned by +/// `platform_wallet_decode_transaction` that has not been freed yet. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_decoded_transaction_free( + decoded: *mut DecodedTransactionFFI, +) { + if decoded.is_null() { + return; + } + let decoded = Box::from_raw(decoded); + + if !decoded.inputs.is_null() { + let inputs = + Vec::from_raw_parts(decoded.inputs, decoded.inputs_count, decoded.inputs_count); + for input in &inputs { + if !input.address.is_null() { + drop(CString::from_raw(input.address)); + } + } + drop(inputs); + } + + if !decoded.outputs.is_null() { + let outputs = Vec::from_raw_parts( + decoded.outputs, + decoded.outputs_count, + decoded.outputs_count, + ); + for output in &outputs { + if !output.address.is_null() { + drop(CString::from_raw(output.address)); + } + if !output.script_pubkey.is_null() { + drop(Vec::from_raw_parts( + output.script_pubkey, + output.script_pubkey_len, + output.script_pubkey_len, + )); + } + } + drop(outputs); + } +} + +#[cfg(test)] +mod tests { + //! Boundary-only tests: the decode *logic* is covered in + //! `platform_wallet::transaction_decode`. Here we only assert the C + //! marshalling round-trips and that memory handling is sound. + use super::*; + use dashcore::consensus::serialize; + use dashcore::secp256k1::{Secp256k1, SecretKey}; + use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use std::ffi::CStr; + + fn synthetic_tx_bytes() -> Vec { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x42u8; 32]).unwrap(); + let pubkey = dashcore::PublicKey::new(sk.public_key(&secp)); + let addr = Address::p2pkh(&pubkey, Network::Testnet); + let script_sig = dashcore::blockdata::script::Builder::new() + .push_slice(&[0x30u8; 71]) + .push_key(&pubkey) + .into_script(); + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0x11u8; 32]), + vout: 3, + }, + script_sig, + sequence: 0xffffffff, + witness: Witness::default(), + }], + output: vec![TxOut { + value: 151_072, + script_pubkey: addr.script_pubkey(), + }], + special_transaction_payload: None, + }; + serialize(&tx) + } + + #[test] + fn marshals_decoded_transaction_across_the_boundary() { + let bytes = synthetic_tx_bytes(); + unsafe { + let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); + let res = platform_wallet_decode_transaction( + bytes.as_ptr(), + bytes.len(), + FFINetwork::Testnet, + &mut out, + ); + assert_eq!(res.code, PlatformWalletFFIResultCode::Success); + assert!(!out.is_null()); + + let inputs = std::slice::from_raw_parts((*out).inputs, (*out).inputs_count); + let outputs = std::slice::from_raw_parts((*out).outputs, (*out).outputs_count); + assert_eq!((*out).inputs_count, 1); + assert_eq!((*out).outputs_count, 1); + assert_eq!(inputs[0].prev_vout, 3); + assert!(!inputs[0].address.is_null()); + assert_eq!(outputs[0].value_duffs, 151_072); + let addr = CStr::from_ptr(outputs[0].address).to_str().unwrap(); + assert!(addr.starts_with('y')); + assert!(outputs[0].script_pubkey_len > 0); + + platform_wallet_decoded_transaction_free(out); + } + } + + #[test] + fn rejects_null_and_empty_input() { + unsafe { + let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); + let res = platform_wallet_decode_transaction( + std::ptr::null(), + 4, + FFINetwork::Testnet, + &mut out, + ); + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let bytes = [0u8; 4]; + let res = platform_wallet_decode_transaction( + bytes.as_ptr(), + 0, + FFINetwork::Testnet, + &mut out, + ); + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + } + + #[test] + fn free_null_is_safe() { + unsafe { platform_wallet_decoded_transaction_free(std::ptr::null_mut()) } + } +} diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 289a71378fd..e9b6c35989c 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -19,6 +19,7 @@ pub mod error; pub mod events; pub mod manager; pub mod spv; +pub mod transaction_decode; pub mod wallet; pub use error::PlatformWalletError; @@ -46,6 +47,7 @@ pub use manager::platform_address_sync::{ }; pub use manager::PlatformWalletManager; pub use spv::SpvRuntime; +pub use transaction_decode::{decode_transaction, DecodedInput, DecodedOutput, DecodedTransaction}; pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; diff --git a/packages/rs-platform-wallet/src/transaction_decode.rs b/packages/rs-platform-wallet/src/transaction_decode.rs new file mode 100644 index 00000000000..cbd20c36a5d --- /dev/null +++ b/packages/rs-platform-wallet/src/transaction_decode.rs @@ -0,0 +1,229 @@ +//! Pure transaction decoding — consensus-decode raw Dash transaction bytes +//! into structured inputs/outputs with derived addresses. **No wallet state, +//! no FFI, no `unsafe`.** +//! +//! Wallet persistence stores every transaction's raw bytes but only +//! materializes TXO rows for the wallet's *own* outputs, so callers that need +//! to match arbitrary transactions against external addresses (e.g. the +//! CrowdNode on-chain API: "did a tx pay amount X to address Y?") need to +//! reconstruct per-output `(address, amount)` from the stored bytes. This is +//! that primitive. The C-ABI wrapper lives in `platform-wallet-ffi`; this +//! module owns the logic and its unit tests. +//! +//! - Output `address` comes from [`Address::from_script`]; `None` for +//! non-standard scripts (OP_RETURN, bare multisig, …). +//! - Input `address` is recovered only for P2PKH spends — the last data push +//! of the scriptSig parsed as a public key — mirroring what wallet UIs +//! conventionally show as "sent from". `None` otherwise. + +use dashcore::blockdata::script::Instruction; +use dashcore::consensus::deserialize; +use dashcore::{Address, Network, OutPoint, PublicKey, ScriptBuf, Transaction, Txid}; + +/// A consensus-decoded transaction with addresses rendered for a network. +#[derive(Debug, Clone)] +pub struct DecodedTransaction { + pub txid: Txid, + pub inputs: Vec, + pub outputs: Vec, +} + +/// One decoded input: the previous outpoint and a best-effort sender address. +#[derive(Debug, Clone)] +pub struct DecodedInput { + pub previous_output: OutPoint, + /// Best-effort P2PKH sender address recovered from the scriptSig; `None` + /// for coinbase / non-P2PKH / unparseable inputs. + pub address: Option
, +} + +/// One decoded output: destination address, value, and raw script. +#[derive(Debug, Clone)] +pub struct DecodedOutput { + /// Destination address for standard scripts (P2PKH / P2SH); `None` for + /// non-standard scripts. + pub address: Option
, + pub value_duffs: u64, + pub script_pubkey: ScriptBuf, +} + +/// Consensus-decode `tx_bytes` and resolve per-output/-input addresses for +/// `network`. Rejects trailing bytes after a valid transaction (uses +/// [`deserialize`], not a streaming decode), so a valid prefix followed by +/// garbage is an error rather than a silent trim. +pub fn decode_transaction( + tx_bytes: &[u8], + network: Network, +) -> Result { + let tx: Transaction = deserialize(tx_bytes)?; + + let inputs = tx + .input + .iter() + .map(|txin| { + let address = if txin.previous_output.is_null() { + None // coinbase + } else { + input_address_from_script_sig(&txin.script_sig, network) + }; + DecodedInput { + previous_output: txin.previous_output, + address, + } + }) + .collect(); + + let outputs = tx + .output + .iter() + .map(|txout| DecodedOutput { + address: Address::from_script(&txout.script_pubkey, network).ok(), + value_duffs: txout.value, + script_pubkey: txout.script_pubkey.clone(), + }) + .collect(); + + Ok(DecodedTransaction { + txid: tx.txid(), + inputs, + outputs, + }) +} + +/// Best-effort P2PKH sender address from a scriptSig: take the last data push, +/// parse it as a public key, hash to a P2PKH address. Returns `None` for +/// coinbase, empty, non-push, or non-P2PKH script sigs. +fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option
{ + let mut last_push: Option> = None; + for instruction in script_sig.instructions() { + match instruction { + Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes().to_vec()), + Ok(_) => {} + Err(_) => return None, + } + } + let candidate = last_push?; + if candidate.len() != 33 && candidate.len() != 65 { + return None; + } + let pubkey = PublicKey::from_slice(&candidate).ok()?; + Some(Address::p2pkh(&pubkey, network)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::consensus::serialize; + use dashcore::hashes::Hash; + use dashcore::secp256k1::{Secp256k1, SecretKey}; + use dashcore::{TxIn, TxOut, Witness}; + + fn test_pubkey() -> PublicKey { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("valid secret key"); + PublicKey::new(sk.public_key(&secp)) + } + + /// One P2PKH input (spending 11..11:3), one P2PKH output of 151072 duffs, + /// one OP_RETURN output. The 151072 amount mirrors a CrowdNode signUp + /// signal so the fixture doubles as documentation. + fn synthetic_tx(network: Network) -> (Transaction, Address) { + let pubkey = test_pubkey(); + let addr = Address::p2pkh(&pubkey, network); + let script_sig = dashcore::blockdata::script::Builder::new() + .push_slice(&[0x30u8; 71]) + .push_key(&pubkey) + .into_script(); + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0x11u8; 32]), + vout: 3, + }, + script_sig, + sequence: 0xffffffff, + witness: Witness::default(), + }], + output: vec![ + TxOut { + value: 151_072, + script_pubkey: addr.script_pubkey(), + }, + TxOut { + value: 0, + script_pubkey: ScriptBuf::new_op_return(&[0xAAu8; 4]), + }, + ], + special_transaction_payload: None, + }; + (tx, addr) + } + + #[test] + fn decodes_outputs_with_addresses_and_values() { + let (tx, addr) = synthetic_tx(Network::Testnet); + let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); + + assert_eq!(decoded.txid, tx.txid()); + assert_eq!(decoded.outputs.len(), 2); + + assert_eq!(decoded.outputs[0].address.as_ref(), Some(&addr)); + assert_eq!(decoded.outputs[0].value_duffs, 151_072); + assert_eq!(decoded.outputs[0].script_pubkey, addr.script_pubkey()); + assert!( + addr.to_string().starts_with('y'), + "testnet P2PKH starts with 'y'" + ); + + // OP_RETURN: no address, script still present. + assert!(decoded.outputs[1].address.is_none()); + assert!(!decoded.outputs[1].script_pubkey.as_bytes().is_empty()); + } + + #[test] + fn recovers_p2pkh_input_address_from_script_sig() { + let (tx, addr) = synthetic_tx(Network::Testnet); + let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); + + assert_eq!(decoded.inputs.len(), 1); + assert_eq!( + decoded.inputs[0].previous_output.txid, + Txid::from_byte_array([0x11u8; 32]) + ); + assert_eq!(decoded.inputs[0].previous_output.vout, 3); + assert_eq!(decoded.inputs[0].address.as_ref(), Some(&addr)); + } + + #[test] + fn network_changes_rendered_addresses() { + let (tx, testnet_addr) = synthetic_tx(Network::Testnet); + let decoded = decode_transaction(&serialize(&tx), Network::Mainnet).unwrap(); + let rendered = decoded.outputs[0].address.as_ref().unwrap().to_string(); + assert_ne!(rendered, testnet_addr.to_string()); + assert!(rendered.starts_with('X'), "mainnet P2PKH starts with 'X'"); + } + + #[test] + fn coinbase_input_has_no_address() { + let (mut tx, _) = synthetic_tx(Network::Testnet); + tx.input[0].previous_output = OutPoint::null(); + tx.input[0].script_sig = ScriptBuf::new(); + let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); + assert!(decoded.inputs[0].address.is_none()); + } + + #[test] + fn trailing_garbage_is_an_error() { + let (tx, _) = synthetic_tx(Network::Testnet); + let mut bytes = serialize(&tx); + bytes.extend_from_slice(&[0xDE, 0xAD]); + assert!(decode_transaction(&bytes, Network::Testnet).is_err()); + } + + #[test] + fn garbage_bytes_are_an_error() { + assert!(decode_transaction(&[0xFFu8; 16], Network::Testnet).is_err()); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift new file mode 100644 index 00000000000..c3c6848ccad --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift @@ -0,0 +1,113 @@ +import Foundation +import DashSDKFFI + +/// A consensus-decoded Dash transaction: per-output `(address, value, script)` +/// plus per-input previous outpoints with a best-effort P2PKH sender address. +/// +/// Wallet persistence stores every transaction's raw bytes but only +/// materializes TXO rows for the wallet's own outputs, so matching arbitrary +/// transactions against external addresses (e.g. the CrowdNode on-chain API: +/// "did a tx pay amount X to address Y?") requires decoding the stored bytes. +/// `TransactionDecoder.decode` is that opener. +public struct DecodedTransaction: Sendable, Equatable { + public struct Input: Sendable, Equatable { + /// Previous output's txid in consensus (internal) byte order. + public let prevTxid: Data + /// Previous output's index. + public let prevVout: UInt32 + /// Sender address recovered from a P2PKH scriptSig; nil for + /// coinbase / non-P2PKH / unparseable script sigs. + public let address: String? + } + + public struct Output: Sendable, Equatable { + /// Destination address for standard scripts (P2PKH / P2SH); nil for + /// non-standard scripts (OP_RETURN, bare multisig, …). + public let address: String? + /// Output value in duffs. + public let valueDuffs: UInt64 + /// Raw scriptPubKey bytes. + public let scriptPubkey: Data + } + + /// Transaction id in consensus (internal) byte order — reverse for + /// explorer-style display (see `txidDisplayHex`). + public let txid: Data + public let inputs: [Input] + public let outputs: [Output] + + /// Explorer-style (reversed, hex) rendering of `txid`. + public var txidDisplayHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } +} + +/// Pure utility — decodes raw transaction bytes via +/// `platform_wallet_decode_transaction`. No wallet handle or state involved. +public enum TransactionDecoder { + /// Consensus-decode raw transaction bytes. + /// - Parameters: + /// - txData: Serialized transaction bytes (e.g. from persisted rows). + /// - network: Network used to render addresses (base58 version bytes). + /// - Returns: The decoded transaction. + /// - Throws: `PlatformWalletError.deserialization` for malformed bytes + /// (including trailing garbage), `.invalidParameter` for empty input. + public static func decode(_ txData: Data, network: Network) throws -> DecodedTransaction { + guard !txData.isEmpty else { + throw PlatformWalletError.invalidParameter("txData is empty") + } + + var outDecoded: UnsafeMutablePointer? = nil + let res = txData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_decode_transaction( + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(txData.count), + network.ffiValue, + &outDecoded + ) + } + try res.check() + guard let decoded = outDecoded else { + throw PlatformWalletError.nullPointer("decode returned success but no result") + } + defer { platform_wallet_decoded_transaction_free(decoded) } + + var entry = decoded.pointee + let txid = withUnsafeBytes(of: &entry.txid) { Data($0) } + + var inputs: [DecodedTransaction.Input] = [] + if let ptr = entry.inputs, entry.inputs_count > 0 { + inputs = (0.. 0 { + outputs = (0.. 0 { + script = Data(bytes: sptr, count: Int(output.script_pubkey_len)) + } else { + script = Data() + } + return DecodedTransaction.Output( + address: address, + valueDuffs: output.value_duffs, + scriptPubkey: script + ) + } + } + + return DecodedTransaction(txid: txid, inputs: inputs, outputs: outputs) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift new file mode 100644 index 00000000000..c5da7971eb0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import SwiftDashSDK + +/// Pure-function tests for `TransactionDecoder` — no wallet handle or network +/// access. The fixture is the same synthetic transaction exercised by the +/// Rust-side unit tests in `rs-platform-wallet-ffi/src/tx_decode.rs`: +/// one P2PKH input (spending 1111…11:3, scriptSig = push(sig) push(pubkey)), +/// one P2PKH output of 151 072 duffs, and one OP_RETURN output. +final class TransactionDecoderTests: XCTestCase { + private static let fixtureHex = + "01000000011111111111111111111111111111111111111111111111111111111111111111" + + "030000006a4730303030303030303030303030303030303030303030303030303030303030" + + "3030303030303030303030303030303030303030303030303030303030303030303030" + + "30303030210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0" + + "ab1cffffffff02204e0200000000001976a91414db4138d56a2ecfb10881a9be394d9f32" + + "1985b288ac0000000000000000066a04aaaaaaaa00000000" + + private static let fixtureAddress = "yNDj28QBMm5sY6bLjFcNdWRNef24KLQNuQ" + private static let fixtureTxidDisplay = + "bf7479216e5ba76f60bf11654c881824c6f9cdbb64eebe332cf835a3391cb5d5" + + private var fixtureData: Data { + var data = Data() + let hex = Self.fixtureHex + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index.. Date: Mon, 6 Jul 2026 08:39:51 +0200 Subject: [PATCH 2/5] refactor(platform-wallet): map dashcore::Transaction directly to FFI structs Review response for #3981: - drop the intermediate DecodedTransaction/DecodedInput/DecodedOutput types and the transaction_decode module in rs-platform-wallet; the FFI entry point now consensus-decodes into dashcore::Transaction and marshals it straight into the (unchanged) C structures - tighten the P2PKH sender heuristic: exactly two data pushes (DER-shaped signature, then 33/65-byte pubkey) and nothing else, so P2SH redeem scripts no longer masquerade as sender addresses - null *out_decoded on entry so failure paths can't leak stale pointers to C callers - consume the decoded tx (into_iter + ScriptBuf::into_bytes) instead of cloning every scriptPubkey; hold borrows in the heuristic instead of reallocating per push - document input.address as unauthenticated (display/matching only) on both the FFI struct and the Swift mirror; add prevTxidDisplayHex - move all tests to the FFI crate, through the public entry point; use dashcore test_utils fixtures where they fit (Address::dummy, Transaction::dummy, dummy_coinbase) and pin the new heuristic rejections (non-P2PKH scriptSig, redeem-script collision, non-DER first push) plus null-on-failure FFI surface (struct layouts, symbols) is byte-identical to the previous commit, so the generated header and Swift wrapper API are unaffected. --- packages/rs-platform-wallet-ffi/Cargo.toml | 3 + .../rs-platform-wallet-ffi/src/tx_decode.rs | 374 +++++++++++++++--- packages/rs-platform-wallet/src/lib.rs | 2 - .../src/transaction_decode.rs | 229 ----------- .../PlatformWallet/TransactionDecoder.swift | 17 +- 5 files changed, 343 insertions(+), 282 deletions(-) delete mode 100644 packages/rs-platform-wallet/src/transaction_decode.rs diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 8a2bd4ef2b4..980bcef98b6 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -59,6 +59,9 @@ zeroize = { version = "1", features = ["derive"] } [dev-dependencies] tempfile = "3.8" dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } +# test-utils unlocks the Address::dummy / Transaction::dummy fixtures +# used by the tx_decode tests. +dashcore = { workspace = true, features = ["test-utils"] } [build-dependencies] cbindgen = "0.27" diff --git a/packages/rs-platform-wallet-ffi/src/tx_decode.rs b/packages/rs-platform-wallet-ffi/src/tx_decode.rs index 8117f70fcb7..c94b8704cef 100644 --- a/packages/rs-platform-wallet-ffi/src/tx_decode.rs +++ b/packages/rs-platform-wallet-ffi/src/tx_decode.rs @@ -1,20 +1,28 @@ -//! C-ABI marshalling for [`platform_wallet::decode_transaction`] — **thin FFI -//! surface only**. All decoding logic (consensus decode, address derivation, -//! the P2PKH sender-address heuristic) lives in the pure `platform-wallet` -//! crate and is unit-tested there; these entry points only cross the C -//! boundary: marshal bytes in, marshal the decoded structure out, and pair -//! every allocation with a free. Mirrors the delegation shape of -//! `mnemonic_words.rs` (which forwards to `key_wallet::Mnemonic`). +//! C-ABI transaction decoding — consensus-decode raw Dash transaction bytes +//! into per-input/per-output structures with derived addresses, in one call. //! -//! `txid` / `prev_txid` are in consensus (internal) byte order — the same -//! convention as `OutPointFFI` — reverse for explorer-style display. +//! Wallet persistence stores every transaction's raw bytes but only +//! materializes TXO rows for the wallet's *own* outputs, so callers that need +//! to match arbitrary transactions against external addresses (e.g. the +//! CrowdNode on-chain API: "did a tx pay amount X to address Y?") need +//! per-output `(address, amount)` reconstructed from the stored bytes. This +//! entry point decodes straight into [`dashcore::Transaction`] and marshals +//! that into the C structures below — no intermediate representation. +//! +//! - Output `address` comes from [`Address::from_script`]; null for +//! non-standard scripts (OP_RETURN, bare multisig, …). +//! - Input `address` is recovered only for P2PKH-shaped scriptSigs and is +//! **unauthenticated** — see [`DecodedTxInputFFI::address`]. +//! - `txid` / `prev_txid` are in consensus (internal) byte order — the same +//! convention as `OutPointFFI` — reverse for explorer-style display. use std::ffi::CString; use std::os::raw::c_char; +use dashcore::blockdata::script::Instruction; +use dashcore::consensus::deserialize; use dashcore::hashes::Hash; -use dashcore::Address; -use platform_wallet::decode_transaction; +use dashcore::{Address, PublicKey, ScriptBuf, Transaction}; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::types::{FFINetwork, Network}; @@ -27,9 +35,15 @@ pub struct DecodedTxInputFFI { pub prev_txid: [u8; 32], /// Previous output's index. pub prev_vout: u32, - /// Sender address recovered from a P2PKH scriptSig, or null when the - /// input is coinbase / non-P2PKH / unparseable. Owned by the parent - /// structure — freed by `platform_wallet_decoded_transaction_free`. + /// Sender address recovered from a P2PKH-shaped scriptSig (exactly two + /// pushes: DER signature, then public key), or null when the input is + /// coinbase / non-P2PKH / unparseable. **Unauthenticated**: the value is + /// derived from a script push the spender fully controls, with no + /// signature verification against the spent UTXO — use it for display or + /// as a matching hint only, never for authentication or authorization. + /// The attacker-resistant matching primitive is the per-output + /// `(address, value_duffs)` pair. Owned by the parent structure — freed + /// by `platform_wallet_decoded_transaction_free`. pub address: *mut c_char, } @@ -76,13 +90,48 @@ fn addr_to_cstr(address: Option
) -> *mut c_char { .unwrap_or(std::ptr::null_mut()) } -/// Consensus-decode `tx_bytes` into a `DecodedTransactionFFI`. +/// Best-effort P2PKH sender address from a scriptSig. Requires the exact +/// P2PKH spend shape — two data pushes and nothing else: a DER-shaped ECDSA +/// signature (`0x30` tag, ≤ 73 bytes including the sighash byte) followed by +/// a 33/65-byte public key — so a P2SH redeem script or any other +/// last-push-happens-to-parse collision returns `None` rather than an +/// unrelated address. Nothing here verifies the signature against the spent +/// UTXO, so even a `Some` result is only the *claimed* sender — see +/// [`DecodedTxInputFFI::address`]. +fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option
{ + let mut sig: Option<&[u8]> = None; + let mut pubkey_bytes: Option<&[u8]> = None; + for instruction in script_sig.instructions() { + match instruction { + Ok(Instruction::PushBytes(bytes)) => match (sig, pubkey_bytes) { + (None, None) => sig = Some(bytes.as_bytes()), + (Some(_), None) => pubkey_bytes = Some(bytes.as_bytes()), + _ => return None, // more than two pushes + }, + _ => return None, // non-push opcode or unparseable script + } + } + let (sig, pubkey_bytes) = (sig?, pubkey_bytes?); + if sig.is_empty() || sig[0] != 0x30 || sig.len() > 73 { + return None; + } + if pubkey_bytes.len() != 33 && pubkey_bytes.len() != 65 { + return None; + } + let pubkey = PublicKey::from_slice(pubkey_bytes).ok()?; + Some(Address::p2pkh(&pubkey, network)) +} + +/// Consensus-decode `tx_bytes` into a `DecodedTransactionFFI`. Rejects +/// trailing bytes after a valid transaction (uses [`deserialize`], not a +/// streaming decode), so a valid prefix followed by garbage is an error +/// rather than a silent trim. /// /// # Safety /// - `tx_bytes` must point to `tx_bytes_len` readable bytes. -/// - `out_decoded` must be a valid pointer; on success it receives an owned -/// `*mut DecodedTransactionFFI` that must be released with -/// `platform_wallet_decoded_transaction_free`. +/// - `out_decoded` must be a valid pointer; it is nulled on entry and, on +/// success, receives an owned `*mut DecodedTransactionFFI` that must be +/// released with `platform_wallet_decoded_transaction_free`. #[no_mangle] pub unsafe extern "C" fn platform_wallet_decode_transaction( tx_bytes: *const u8, @@ -92,6 +141,7 @@ pub unsafe extern "C" fn platform_wallet_decode_transaction( ) -> PlatformWalletFFIResult { check_ptr!(tx_bytes); check_ptr!(out_decoded); + *out_decoded = std::ptr::null_mut(); if tx_bytes_len == 0 { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, @@ -101,27 +151,36 @@ pub unsafe extern "C" fn platform_wallet_decode_transaction( let network: Network = network.into(); let slice = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); - let decoded = unwrap_result_or_return!(decode_transaction(slice, network)); + let tx: Transaction = unwrap_result_or_return!(deserialize(slice)); + let txid = tx.txid(); // before the input/output vectors are consumed - let inputs: Vec = decoded - .inputs + let inputs: Vec = tx + .input .into_iter() - .map(|input| DecodedTxInputFFI { - prev_txid: input.previous_output.txid.to_byte_array(), - prev_vout: input.previous_output.vout, - address: addr_to_cstr(input.address), + .map(|txin| { + let address = if txin.previous_output.is_null() { + None // coinbase + } else { + input_address_from_script_sig(&txin.script_sig, network) + }; + DecodedTxInputFFI { + prev_txid: txin.previous_output.txid.to_byte_array(), + prev_vout: txin.previous_output.vout, + address: addr_to_cstr(address), + } }) .collect(); - let outputs: Vec = decoded - .outputs + let outputs: Vec = tx + .output .into_iter() - .map(|output| { - let script = output.script_pubkey.as_bytes().to_vec(); + .map(|txout| { + let address = Address::from_script(&txout.script_pubkey, network).ok(); + let script = txout.script_pubkey.into_bytes(); let script_len = script.len(); DecodedTxOutputFFI { - address: addr_to_cstr(output.address), - value_duffs: output.value_duffs, + address: addr_to_cstr(address), + value_duffs: txout.value, script_pubkey: vec_to_ptr(script), script_pubkey_len: script_len, } @@ -129,7 +188,7 @@ pub unsafe extern "C" fn platform_wallet_decode_transaction( .collect(); let ffi = DecodedTransactionFFI { - txid: decoded.txid.to_byte_array(), + txid: txid.to_byte_array(), inputs_count: inputs.len(), inputs: vec_to_ptr(inputs), outputs_count: outputs.len(), @@ -190,22 +249,96 @@ pub unsafe extern "C" fn platform_wallet_decoded_transaction_free( #[cfg(test)] mod tests { - //! Boundary-only tests: the decode *logic* is covered in - //! `platform_wallet::transaction_decode`. Here we only assert the C - //! marshalling round-trips and that memory handling is sound. + //! Everything funnels through the public FFI entry point: fixture → + //! serialize → `platform_wallet_decode_transaction` → plain-Rust copy → + //! assert. `decode_ok` owns the pointer walking and frees exactly once. + //! Fixtures use `dashcore::test_utils` (`Address::dummy`, + //! `Transaction::dummy`, `Transaction::dummy_coinbase`) where they fit; + //! test_utils has no builder for a P2PKH-shaped scriptSig + //! (signature + pubkey pushes), so that fixture stays hand-built. use super::*; use dashcore::consensus::serialize; use dashcore::secp256k1::{Secp256k1, SecretKey}; - use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use dashcore::{OutPoint, TxIn, TxOut, Txid, Witness}; use std::ffi::CStr; - fn synthetic_tx_bytes() -> Vec { + /// FFI result copied into plain Rust so assertions read naturally. + struct Decoded { + txid: [u8; 32], + /// `(prev_txid, prev_vout, sender address)` + inputs: Vec<([u8; 32], u32, Option)>, + /// `(address, value_duffs, script_pubkey)` + outputs: Vec<(Option, u64, Vec)>, + } + + /// Copy an optional C string into owned Rust. + unsafe fn cstr_opt(ptr: *mut c_char) -> Option { + if ptr.is_null() { + None + } else { + Some(CStr::from_ptr(ptr).to_str().unwrap().to_owned()) + } + } + + /// Decode `tx` through the FFI boundary, copy the result out, and free + /// the FFI allocation exactly once. + fn decode_ok(tx: &Transaction, network: FFINetwork) -> Decoded { + let bytes = serialize(tx); + unsafe { + let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); + let res = + platform_wallet_decode_transaction(bytes.as_ptr(), bytes.len(), network, &mut out); + assert_eq!(res.code, PlatformWalletFFIResultCode::Success); + assert!(!out.is_null()); + + let inputs = if (*out).inputs.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts((*out).inputs, (*out).inputs_count) + .iter() + .map(|i| (i.prev_txid, i.prev_vout, cstr_opt(i.address))) + .collect() + }; + let outputs = if (*out).outputs.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts((*out).outputs, (*out).outputs_count) + .iter() + .map(|o| { + let script = if o.script_pubkey.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts(o.script_pubkey, o.script_pubkey_len) + .to_vec() + }; + (cstr_opt(o.address), o.value_duffs, script) + }) + .collect() + }; + let copied = Decoded { + txid: (*out).txid, + inputs, + outputs, + }; + platform_wallet_decoded_transaction_free(out); + copied + } + } + + fn test_pubkey() -> PublicKey { let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[0x42u8; 32]).unwrap(); - let pubkey = dashcore::PublicKey::new(sk.public_key(&secp)); - let addr = Address::p2pkh(&pubkey, Network::Testnet); + let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("valid secret key"); + PublicKey::new(sk.public_key(&secp)) + } + + /// One P2PKH-shaped input (spending 11..11:3), one P2PKH output of + /// 151072 duffs, one OP_RETURN output. The 151072 amount mirrors a + /// CrowdNode signUp signal so the fixture doubles as documentation. + fn p2pkh_spend_tx(network: Network) -> (Transaction, Address) { + let pubkey = test_pubkey(); + let addr = Address::p2pkh(&pubkey, network); let script_sig = dashcore::blockdata::script::Builder::new() - .push_slice(&[0x30u8; 71]) + .push_slice([0x30u8; 71]) // DER-shaped: 0x30 tag, ≤ 73 bytes .push_key(&pubkey) .into_script(); let tx = Transaction { @@ -220,18 +353,163 @@ mod tests { sequence: 0xffffffff, witness: Witness::default(), }], - output: vec![TxOut { - value: 151_072, - script_pubkey: addr.script_pubkey(), - }], + output: vec![ + TxOut { + value: 151_072, + script_pubkey: addr.script_pubkey(), + }, + TxOut { + value: 0, + script_pubkey: ScriptBuf::new_op_return(&[0xAAu8; 4]), + }, + ], special_transaction_payload: None, }; - serialize(&tx) + (tx, addr) + } + + /// A one-input `Transaction::dummy` with its scriptSig replaced. + fn tx_with_script_sig(script_sig: ScriptBuf) -> Transaction { + let addr = Address::dummy(Network::Testnet, 1); + let mut tx = Transaction::dummy(&addr, 1..2, &[9_000]); + tx.input[0].script_sig = script_sig; + tx + } + + #[test] + fn decodes_outputs_with_addresses_and_values() { + let addr = Address::dummy(Network::Testnet, 7); + let tx = Transaction::dummy(&addr, 1..2, &[151_072, 20_002]); + let decoded = decode_ok(&tx, FFINetwork::Testnet); + + assert_eq!(decoded.txid, tx.txid().to_byte_array()); + assert_eq!(decoded.outputs.len(), 2); + assert_eq!(decoded.outputs[0].0, Some(addr.to_string())); + assert_eq!(decoded.outputs[0].1, 151_072); + assert_eq!(decoded.outputs[0].2, addr.script_pubkey().into_bytes()); + assert_eq!(decoded.outputs[1].0, Some(addr.to_string())); + assert_eq!(decoded.outputs[1].1, 20_002); + assert!( + addr.to_string().starts_with('y'), + "testnet P2PKH starts with 'y'" + ); + } + + #[test] + fn op_return_output_has_no_address() { + let (tx, _) = p2pkh_spend_tx(Network::Testnet); + let decoded = decode_ok(&tx, FFINetwork::Testnet); + assert!(decoded.outputs[1].0.is_none()); + assert!( + !decoded.outputs[1].2.is_empty(), + "script bytes still present" + ); + } + + #[test] + fn recovers_p2pkh_input_address_from_script_sig() { + let (tx, addr) = p2pkh_spend_tx(Network::Testnet); + let decoded = decode_ok(&tx, FFINetwork::Testnet); + + assert_eq!(decoded.inputs.len(), 1); + assert_eq!(decoded.inputs[0].0, [0x11u8; 32]); + assert_eq!(decoded.inputs[0].1, 3); + assert_eq!(decoded.inputs[0].2, Some(addr.to_string())); + } + + #[test] + fn network_changes_rendered_addresses() { + let addr = Address::dummy(Network::Testnet, 7); + let tx = Transaction::dummy(&addr, 1..2, &[151_072]); + let decoded = decode_ok(&tx, FFINetwork::Mainnet); + let rendered = decoded.outputs[0].0.clone().unwrap(); + assert_ne!(rendered, addr.to_string()); + assert!(rendered.starts_with('X'), "mainnet P2PKH starts with 'X'"); + } + + #[test] + fn coinbase_input_has_no_address() { + let addr = Address::dummy(Network::Testnet, 3); + let tx = Transaction::dummy_coinbase(&addr, 50_000); + let decoded = decode_ok(&tx, FFINetwork::Testnet); + assert!(decoded.inputs[0].2.is_none()); + } + + #[test] + fn non_p2pkh_script_sig_yields_no_input_address() { + // Transaction::dummy fills script_sig with a *lock* script + // (OP_DUP OP_HASH160 <20 B> OP_EQUALVERIFY OP_CHECKSIG): opcodes + // present, last push 20 bytes — must not produce an address. + let addr = Address::dummy(Network::Testnet, 9); + let tx = Transaction::dummy(&addr, 1..2, &[1_000]); + let decoded = decode_ok(&tx, FFINetwork::Testnet); + assert!(decoded.inputs[0].2.is_none()); + } + + #[test] + fn redeem_script_collision_yields_no_input_address() { + // Three pushes ending in a valid 33-byte pubkey — the P2SH + // redeem-script shape the exactly-two-pushes rule exists to reject. + let script_sig = dashcore::blockdata::script::Builder::new() + .push_slice([0x30u8; 71]) + .push_slice([0x01u8; 20]) + .push_key(&test_pubkey()) + .into_script(); + let decoded = decode_ok(&tx_with_script_sig(script_sig), FFINetwork::Testnet); + assert!(decoded.inputs[0].2.is_none()); + } + + #[test] + fn non_signature_first_push_yields_no_input_address() { + // Two pushes, but the first is not DER-shaped (no 0x30 tag). + let script_sig = dashcore::blockdata::script::Builder::new() + .push_slice([0xAAu8; 10]) + .push_key(&test_pubkey()) + .into_script(); + let decoded = decode_ok(&tx_with_script_sig(script_sig), FFINetwork::Testnet); + assert!(decoded.inputs[0].2.is_none()); + } + + #[test] + fn trailing_garbage_is_a_deserialization_error() { + let (tx, _) = p2pkh_spend_tx(Network::Testnet); + let mut bytes = serialize(&tx); + bytes.extend_from_slice(&[0xDE, 0xAD]); + unsafe { + // Pre-poison the out param to pin that failures null it. + let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); + let res = platform_wallet_decode_transaction( + bytes.as_ptr(), + bytes.len(), + FFINetwork::Testnet, + &mut out, + ); + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorDeserialization); + assert!(out.is_null(), "out param is nulled on failure"); + } + } + + #[test] + fn garbage_bytes_are_a_deserialization_error() { + let bytes = [0xFFu8; 16]; + unsafe { + let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); + let res = platform_wallet_decode_transaction( + bytes.as_ptr(), + bytes.len(), + FFINetwork::Testnet, + &mut out, + ); + assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorDeserialization); + assert!(out.is_null(), "out param is nulled on failure"); + } } #[test] fn marshals_decoded_transaction_across_the_boundary() { - let bytes = synthetic_tx_bytes(); + // Raw pointer walk (no helper) — pins the C-side memory layout. + let (tx, _) = p2pkh_spend_tx(Network::Testnet); + let bytes = serialize(&tx); unsafe { let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); let res = platform_wallet_decode_transaction( @@ -246,7 +524,7 @@ mod tests { let inputs = std::slice::from_raw_parts((*out).inputs, (*out).inputs_count); let outputs = std::slice::from_raw_parts((*out).outputs, (*out).outputs_count); assert_eq!((*out).inputs_count, 1); - assert_eq!((*out).outputs_count, 1); + assert_eq!((*out).outputs_count, 2); assert_eq!(inputs[0].prev_vout, 3); assert!(!inputs[0].address.is_null()); assert_eq!(outputs[0].value_duffs, 151_072); diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e9b6c35989c..289a71378fd 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -19,7 +19,6 @@ pub mod error; pub mod events; pub mod manager; pub mod spv; -pub mod transaction_decode; pub mod wallet; pub use error::PlatformWalletError; @@ -47,7 +46,6 @@ pub use manager::platform_address_sync::{ }; pub use manager::PlatformWalletManager; pub use spv::SpvRuntime; -pub use transaction_decode::{decode_transaction, DecodedInput, DecodedOutput, DecodedTransaction}; pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; diff --git a/packages/rs-platform-wallet/src/transaction_decode.rs b/packages/rs-platform-wallet/src/transaction_decode.rs deleted file mode 100644 index cbd20c36a5d..00000000000 --- a/packages/rs-platform-wallet/src/transaction_decode.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Pure transaction decoding — consensus-decode raw Dash transaction bytes -//! into structured inputs/outputs with derived addresses. **No wallet state, -//! no FFI, no `unsafe`.** -//! -//! Wallet persistence stores every transaction's raw bytes but only -//! materializes TXO rows for the wallet's *own* outputs, so callers that need -//! to match arbitrary transactions against external addresses (e.g. the -//! CrowdNode on-chain API: "did a tx pay amount X to address Y?") need to -//! reconstruct per-output `(address, amount)` from the stored bytes. This is -//! that primitive. The C-ABI wrapper lives in `platform-wallet-ffi`; this -//! module owns the logic and its unit tests. -//! -//! - Output `address` comes from [`Address::from_script`]; `None` for -//! non-standard scripts (OP_RETURN, bare multisig, …). -//! - Input `address` is recovered only for P2PKH spends — the last data push -//! of the scriptSig parsed as a public key — mirroring what wallet UIs -//! conventionally show as "sent from". `None` otherwise. - -use dashcore::blockdata::script::Instruction; -use dashcore::consensus::deserialize; -use dashcore::{Address, Network, OutPoint, PublicKey, ScriptBuf, Transaction, Txid}; - -/// A consensus-decoded transaction with addresses rendered for a network. -#[derive(Debug, Clone)] -pub struct DecodedTransaction { - pub txid: Txid, - pub inputs: Vec, - pub outputs: Vec, -} - -/// One decoded input: the previous outpoint and a best-effort sender address. -#[derive(Debug, Clone)] -pub struct DecodedInput { - pub previous_output: OutPoint, - /// Best-effort P2PKH sender address recovered from the scriptSig; `None` - /// for coinbase / non-P2PKH / unparseable inputs. - pub address: Option
, -} - -/// One decoded output: destination address, value, and raw script. -#[derive(Debug, Clone)] -pub struct DecodedOutput { - /// Destination address for standard scripts (P2PKH / P2SH); `None` for - /// non-standard scripts. - pub address: Option
, - pub value_duffs: u64, - pub script_pubkey: ScriptBuf, -} - -/// Consensus-decode `tx_bytes` and resolve per-output/-input addresses for -/// `network`. Rejects trailing bytes after a valid transaction (uses -/// [`deserialize`], not a streaming decode), so a valid prefix followed by -/// garbage is an error rather than a silent trim. -pub fn decode_transaction( - tx_bytes: &[u8], - network: Network, -) -> Result { - let tx: Transaction = deserialize(tx_bytes)?; - - let inputs = tx - .input - .iter() - .map(|txin| { - let address = if txin.previous_output.is_null() { - None // coinbase - } else { - input_address_from_script_sig(&txin.script_sig, network) - }; - DecodedInput { - previous_output: txin.previous_output, - address, - } - }) - .collect(); - - let outputs = tx - .output - .iter() - .map(|txout| DecodedOutput { - address: Address::from_script(&txout.script_pubkey, network).ok(), - value_duffs: txout.value, - script_pubkey: txout.script_pubkey.clone(), - }) - .collect(); - - Ok(DecodedTransaction { - txid: tx.txid(), - inputs, - outputs, - }) -} - -/// Best-effort P2PKH sender address from a scriptSig: take the last data push, -/// parse it as a public key, hash to a P2PKH address. Returns `None` for -/// coinbase, empty, non-push, or non-P2PKH script sigs. -fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option
{ - let mut last_push: Option> = None; - for instruction in script_sig.instructions() { - match instruction { - Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes().to_vec()), - Ok(_) => {} - Err(_) => return None, - } - } - let candidate = last_push?; - if candidate.len() != 33 && candidate.len() != 65 { - return None; - } - let pubkey = PublicKey::from_slice(&candidate).ok()?; - Some(Address::p2pkh(&pubkey, network)) -} - -#[cfg(test)] -mod tests { - use super::*; - use dashcore::consensus::serialize; - use dashcore::hashes::Hash; - use dashcore::secp256k1::{Secp256k1, SecretKey}; - use dashcore::{TxIn, TxOut, Witness}; - - fn test_pubkey() -> PublicKey { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("valid secret key"); - PublicKey::new(sk.public_key(&secp)) - } - - /// One P2PKH input (spending 11..11:3), one P2PKH output of 151072 duffs, - /// one OP_RETURN output. The 151072 amount mirrors a CrowdNode signUp - /// signal so the fixture doubles as documentation. - fn synthetic_tx(network: Network) -> (Transaction, Address) { - let pubkey = test_pubkey(); - let addr = Address::p2pkh(&pubkey, network); - let script_sig = dashcore::blockdata::script::Builder::new() - .push_slice(&[0x30u8; 71]) - .push_key(&pubkey) - .into_script(); - let tx = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint { - txid: Txid::from_byte_array([0x11u8; 32]), - vout: 3, - }, - script_sig, - sequence: 0xffffffff, - witness: Witness::default(), - }], - output: vec![ - TxOut { - value: 151_072, - script_pubkey: addr.script_pubkey(), - }, - TxOut { - value: 0, - script_pubkey: ScriptBuf::new_op_return(&[0xAAu8; 4]), - }, - ], - special_transaction_payload: None, - }; - (tx, addr) - } - - #[test] - fn decodes_outputs_with_addresses_and_values() { - let (tx, addr) = synthetic_tx(Network::Testnet); - let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); - - assert_eq!(decoded.txid, tx.txid()); - assert_eq!(decoded.outputs.len(), 2); - - assert_eq!(decoded.outputs[0].address.as_ref(), Some(&addr)); - assert_eq!(decoded.outputs[0].value_duffs, 151_072); - assert_eq!(decoded.outputs[0].script_pubkey, addr.script_pubkey()); - assert!( - addr.to_string().starts_with('y'), - "testnet P2PKH starts with 'y'" - ); - - // OP_RETURN: no address, script still present. - assert!(decoded.outputs[1].address.is_none()); - assert!(!decoded.outputs[1].script_pubkey.as_bytes().is_empty()); - } - - #[test] - fn recovers_p2pkh_input_address_from_script_sig() { - let (tx, addr) = synthetic_tx(Network::Testnet); - let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); - - assert_eq!(decoded.inputs.len(), 1); - assert_eq!( - decoded.inputs[0].previous_output.txid, - Txid::from_byte_array([0x11u8; 32]) - ); - assert_eq!(decoded.inputs[0].previous_output.vout, 3); - assert_eq!(decoded.inputs[0].address.as_ref(), Some(&addr)); - } - - #[test] - fn network_changes_rendered_addresses() { - let (tx, testnet_addr) = synthetic_tx(Network::Testnet); - let decoded = decode_transaction(&serialize(&tx), Network::Mainnet).unwrap(); - let rendered = decoded.outputs[0].address.as_ref().unwrap().to_string(); - assert_ne!(rendered, testnet_addr.to_string()); - assert!(rendered.starts_with('X'), "mainnet P2PKH starts with 'X'"); - } - - #[test] - fn coinbase_input_has_no_address() { - let (mut tx, _) = synthetic_tx(Network::Testnet); - tx.input[0].previous_output = OutPoint::null(); - tx.input[0].script_sig = ScriptBuf::new(); - let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap(); - assert!(decoded.inputs[0].address.is_none()); - } - - #[test] - fn trailing_garbage_is_an_error() { - let (tx, _) = synthetic_tx(Network::Testnet); - let mut bytes = serialize(&tx); - bytes.extend_from_slice(&[0xDE, 0xAD]); - assert!(decode_transaction(&bytes, Network::Testnet).is_err()); - } - - #[test] - fn garbage_bytes_are_an_error() { - assert!(decode_transaction(&[0xFFu8; 16], Network::Testnet).is_err()); - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift index c3c6848ccad..79413c94acb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift @@ -11,13 +11,24 @@ import DashSDKFFI /// `TransactionDecoder.decode` is that opener. public struct DecodedTransaction: Sendable, Equatable { public struct Input: Sendable, Equatable { - /// Previous output's txid in consensus (internal) byte order. + /// Previous output's txid in consensus (internal) byte order — + /// reverse for explorer-style display (see `prevTxidDisplayHex`). public let prevTxid: Data /// Previous output's index. public let prevVout: UInt32 - /// Sender address recovered from a P2PKH scriptSig; nil for - /// coinbase / non-P2PKH / unparseable script sigs. + /// Sender address recovered from a P2PKH-shaped scriptSig; nil for + /// coinbase / non-P2PKH / unparseable script sigs. Unauthenticated — + /// derived from a script push the spender fully controls, with no + /// signature verification against the spent UTXO. Use it for display + /// or as a matching hint only, never for authentication or + /// authorization; the attacker-resistant matching primitive is the + /// per-output `(address, valueDuffs)` pair. public let address: String? + + /// Explorer-style (reversed, hex) rendering of `prevTxid`. + public var prevTxidDisplayHex: String { + prevTxid.reversed().map { String(format: "%02x", $0) }.joined() + } } public struct Output: Sendable, Equatable { From ffa8c9434ea320a965f9971fbdecc8ccc5b838af Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 6 Jul 2026 10:51:28 +0200 Subject: [PATCH 3/5] fix(platform-wallet): null out_decoded before validating tx_bytes The safety docs promise out_decoded is nulled on entry, but check_ptr!(tx_bytes) returned first, so a NULL tx_bytes left a stale pointer visible in the out-slot on the public C ABI. Reorder the checks and pre-poison the out param in rejects_null_and_empty_input to pin both failure paths. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/tx_decode.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/tx_decode.rs b/packages/rs-platform-wallet-ffi/src/tx_decode.rs index c94b8704cef..0f3a8f90a10 100644 --- a/packages/rs-platform-wallet-ffi/src/tx_decode.rs +++ b/packages/rs-platform-wallet-ffi/src/tx_decode.rs @@ -139,9 +139,9 @@ pub unsafe extern "C" fn platform_wallet_decode_transaction( network: FFINetwork, out_decoded: *mut *mut DecodedTransactionFFI, ) -> PlatformWalletFFIResult { - check_ptr!(tx_bytes); check_ptr!(out_decoded); *out_decoded = std::ptr::null_mut(); + check_ptr!(tx_bytes); if tx_bytes_len == 0 { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, @@ -539,7 +539,8 @@ mod tests { #[test] fn rejects_null_and_empty_input() { unsafe { - let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); + // Pre-poison the out param to pin that failures null it. + let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); let res = platform_wallet_decode_transaction( std::ptr::null(), 4, @@ -547,8 +548,10 @@ mod tests { &mut out, ); assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(out.is_null(), "out param is nulled on failure"); let bytes = [0u8; 4]; + out = std::ptr::NonNull::dangling().as_ptr(); let res = platform_wallet_decode_transaction( bytes.as_ptr(), 0, @@ -556,6 +559,7 @@ mod tests { &mut out, ); assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert!(out.is_null(), "out param is nulled on failure"); } } From 1c1809268004dde202ba8b88654300a616e0099d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 12 Jul 2026 06:27:09 +0700 Subject: [PATCH 4/5] test(swift-sdk): fix mis-transcribed tx-decode fixture hex The hand-transcribed fixture dropped one 0x30 byte from the 71-byte signature push, so consensus decode ran past the end of the buffer and every decode test failed with deserialization("IO error"). The hex is now regenerated byte-for-byte from the Rust-side p2pkh_spend_tx fixture in rs-platform-wallet-ffi/src/tx_decode.rs; the expected address and txid were already correct. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKTests/TransactionDecoderTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift index c5da7971eb0..d53ff2547d8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift @@ -10,10 +10,10 @@ final class TransactionDecoderTests: XCTestCase { private static let fixtureHex = "01000000011111111111111111111111111111111111111111111111111111111111111111" + "030000006a4730303030303030303030303030303030303030303030303030303030303030" - + "3030303030303030303030303030303030303030303030303030303030303030303030" - + "30303030210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0" - + "ab1cffffffff02204e0200000000001976a91414db4138d56a2ecfb10881a9be394d9f32" - + "1985b288ac0000000000000000066a04aaaaaaaa00000000" + + "30303030303030303030303030303030303030303030303030303030303030303030303030" + + "303030210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + + "ffffffff02204e0200000000001976a91414db4138d56a2ecfb10881a9be394d9f321985b2" + + "88ac0000000000000000066a04aaaaaaaa00000000" private static let fixtureAddress = "yNDj28QBMm5sY6bLjFcNdWRNef24KLQNuQ" private static let fixtureTxidDisplay = From edc0be7c32cadf9edc5fa8f42c30c2ca2ff4bef5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 12 Jul 2026 15:20:37 +0700 Subject: [PATCH 5/5] refactor(swift-sdk): consume upstream transaction_decode from key-wallet-ffi The transaction-decode FFI added by this PR was upstreamed into rust-dashcore's key-wallet-ffi (dashpay/rust-dashcore#870), where it belongs: it is pure Core-chain functionality with no platform-wallet involvement. Bump the rust-dashcore pin to that merge commit (one commit, additive only), delete the platform-side copy from rs-platform-wallet-ffi, and re-point the thin Swift wrapper at key-wallet-ffi's transaction_decode / decoded_transaction_free using the FFIError + bool house convention. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 24 +- Cargo.toml | 16 +- packages/rs-platform-wallet-ffi/Cargo.toml | 3 - packages/rs-platform-wallet-ffi/src/lib.rs | 2 - .../rs-platform-wallet-ffi/src/tx_decode.rs | 570 ------------------ .../TransactionDecoder.swift | 36 +- .../TransactionDecoderTests.swift | 2 +- 7 files changed, 41 insertions(+), 612 deletions(-) delete mode 100644 packages/rs-platform-wallet-ffi/src/tx_decode.rs rename packages/swift-sdk/Sources/SwiftDashSDK/{PlatformWallet => KeyWallet}/TransactionDecoder.swift (84%) diff --git a/Cargo.lock b/Cargo.lock index 98d435d7aeb..7c5524b3f00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3#1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index b7db6e44951..efb87c098d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "1ee1c94840f14c63fa34e9d19f6eaaa1d29847c3" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 88d896e40e8..370e71754ea 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -63,9 +63,6 @@ zeroize = { version = "1", features = ["derive"] } [dev-dependencies] tempfile = "3.8" dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } -# test-utils unlocks the Address::dummy / Transaction::dummy fixtures -# used by the tx_decode tests. -dashcore = { workspace = true, features = ["test-utils"] } [build-dependencies] cbindgen = "0.27" diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index d2f14f8670e..0d4adcf512e 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -72,7 +72,6 @@ pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; pub mod tokens; -pub mod tx_decode; pub mod types; pub mod utils; pub mod wallet; @@ -140,7 +139,6 @@ pub use sign_with_mnemonic_resolver::*; pub use spv::*; pub use token_persistence::*; pub use tokens::*; -pub use tx_decode::*; pub use types::*; pub use utils::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/tx_decode.rs b/packages/rs-platform-wallet-ffi/src/tx_decode.rs deleted file mode 100644 index 0f3a8f90a10..00000000000 --- a/packages/rs-platform-wallet-ffi/src/tx_decode.rs +++ /dev/null @@ -1,570 +0,0 @@ -//! C-ABI transaction decoding — consensus-decode raw Dash transaction bytes -//! into per-input/per-output structures with derived addresses, in one call. -//! -//! Wallet persistence stores every transaction's raw bytes but only -//! materializes TXO rows for the wallet's *own* outputs, so callers that need -//! to match arbitrary transactions against external addresses (e.g. the -//! CrowdNode on-chain API: "did a tx pay amount X to address Y?") need -//! per-output `(address, amount)` reconstructed from the stored bytes. This -//! entry point decodes straight into [`dashcore::Transaction`] and marshals -//! that into the C structures below — no intermediate representation. -//! -//! - Output `address` comes from [`Address::from_script`]; null for -//! non-standard scripts (OP_RETURN, bare multisig, …). -//! - Input `address` is recovered only for P2PKH-shaped scriptSigs and is -//! **unauthenticated** — see [`DecodedTxInputFFI::address`]. -//! - `txid` / `prev_txid` are in consensus (internal) byte order — the same -//! convention as `OutPointFFI` — reverse for explorer-style display. - -use std::ffi::CString; -use std::os::raw::c_char; - -use dashcore::blockdata::script::Instruction; -use dashcore::consensus::deserialize; -use dashcore::hashes::Hash; -use dashcore::{Address, PublicKey, ScriptBuf, Transaction}; - -use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; -use crate::types::{FFINetwork, Network}; -use crate::{check_ptr, unwrap_result_or_return}; - -/// One decoded transaction input. -#[repr(C)] -pub struct DecodedTxInputFFI { - /// Previous output's txid in consensus (internal) byte order. - pub prev_txid: [u8; 32], - /// Previous output's index. - pub prev_vout: u32, - /// Sender address recovered from a P2PKH-shaped scriptSig (exactly two - /// pushes: DER signature, then public key), or null when the input is - /// coinbase / non-P2PKH / unparseable. **Unauthenticated**: the value is - /// derived from a script push the spender fully controls, with no - /// signature verification against the spent UTXO — use it for display or - /// as a matching hint only, never for authentication or authorization. - /// The attacker-resistant matching primitive is the per-output - /// `(address, value_duffs)` pair. Owned by the parent structure — freed - /// by `platform_wallet_decoded_transaction_free`. - pub address: *mut c_char, -} - -/// One decoded transaction output. -#[repr(C)] -pub struct DecodedTxOutputFFI { - /// Destination address for standard scripts (P2PKH / P2SH), or null for - /// non-standard scripts. Owned by the parent structure. - pub address: *mut c_char, - /// Output value in duffs. - pub value_duffs: u64, - /// Raw scriptPubKey bytes (owned by the parent structure). - pub script_pubkey: *mut u8, - pub script_pubkey_len: usize, -} - -/// A consensus-decoded transaction. Allocated by -/// `platform_wallet_decode_transaction`; release with -/// `platform_wallet_decoded_transaction_free`. -#[repr(C)] -pub struct DecodedTransactionFFI { - /// Transaction id in consensus (internal) byte order. - pub txid: [u8; 32], - pub inputs: *mut DecodedTxInputFFI, - pub inputs_count: usize, - pub outputs: *mut DecodedTxOutputFFI, - pub outputs_count: usize, -} - -/// Boxed-slice allocator matching the crate's array-return convention. -fn vec_to_ptr(v: Vec) -> *mut T { - if v.is_empty() { - std::ptr::null_mut() - } else { - Box::into_raw(v.into_boxed_slice()) as *mut T - } -} - -/// Render an optional address to an owned C string, or null. -fn addr_to_cstr(address: Option
) -> *mut c_char { - address - .and_then(|a| CString::new(a.to_string()).ok()) - .map(CString::into_raw) - .unwrap_or(std::ptr::null_mut()) -} - -/// Best-effort P2PKH sender address from a scriptSig. Requires the exact -/// P2PKH spend shape — two data pushes and nothing else: a DER-shaped ECDSA -/// signature (`0x30` tag, ≤ 73 bytes including the sighash byte) followed by -/// a 33/65-byte public key — so a P2SH redeem script or any other -/// last-push-happens-to-parse collision returns `None` rather than an -/// unrelated address. Nothing here verifies the signature against the spent -/// UTXO, so even a `Some` result is only the *claimed* sender — see -/// [`DecodedTxInputFFI::address`]. -fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option
{ - let mut sig: Option<&[u8]> = None; - let mut pubkey_bytes: Option<&[u8]> = None; - for instruction in script_sig.instructions() { - match instruction { - Ok(Instruction::PushBytes(bytes)) => match (sig, pubkey_bytes) { - (None, None) => sig = Some(bytes.as_bytes()), - (Some(_), None) => pubkey_bytes = Some(bytes.as_bytes()), - _ => return None, // more than two pushes - }, - _ => return None, // non-push opcode or unparseable script - } - } - let (sig, pubkey_bytes) = (sig?, pubkey_bytes?); - if sig.is_empty() || sig[0] != 0x30 || sig.len() > 73 { - return None; - } - if pubkey_bytes.len() != 33 && pubkey_bytes.len() != 65 { - return None; - } - let pubkey = PublicKey::from_slice(pubkey_bytes).ok()?; - Some(Address::p2pkh(&pubkey, network)) -} - -/// Consensus-decode `tx_bytes` into a `DecodedTransactionFFI`. Rejects -/// trailing bytes after a valid transaction (uses [`deserialize`], not a -/// streaming decode), so a valid prefix followed by garbage is an error -/// rather than a silent trim. -/// -/// # Safety -/// - `tx_bytes` must point to `tx_bytes_len` readable bytes. -/// - `out_decoded` must be a valid pointer; it is nulled on entry and, on -/// success, receives an owned `*mut DecodedTransactionFFI` that must be -/// released with `platform_wallet_decoded_transaction_free`. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_decode_transaction( - tx_bytes: *const u8, - tx_bytes_len: usize, - network: FFINetwork, - out_decoded: *mut *mut DecodedTransactionFFI, -) -> PlatformWalletFFIResult { - check_ptr!(out_decoded); - *out_decoded = std::ptr::null_mut(); - check_ptr!(tx_bytes); - if tx_bytes_len == 0 { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "tx_bytes_len is zero", - ); - } - - let network: Network = network.into(); - let slice = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); - let tx: Transaction = unwrap_result_or_return!(deserialize(slice)); - let txid = tx.txid(); // before the input/output vectors are consumed - - let inputs: Vec = tx - .input - .into_iter() - .map(|txin| { - let address = if txin.previous_output.is_null() { - None // coinbase - } else { - input_address_from_script_sig(&txin.script_sig, network) - }; - DecodedTxInputFFI { - prev_txid: txin.previous_output.txid.to_byte_array(), - prev_vout: txin.previous_output.vout, - address: addr_to_cstr(address), - } - }) - .collect(); - - let outputs: Vec = tx - .output - .into_iter() - .map(|txout| { - let address = Address::from_script(&txout.script_pubkey, network).ok(); - let script = txout.script_pubkey.into_bytes(); - let script_len = script.len(); - DecodedTxOutputFFI { - address: addr_to_cstr(address), - value_duffs: txout.value, - script_pubkey: vec_to_ptr(script), - script_pubkey_len: script_len, - } - }) - .collect(); - - let ffi = DecodedTransactionFFI { - txid: txid.to_byte_array(), - inputs_count: inputs.len(), - inputs: vec_to_ptr(inputs), - outputs_count: outputs.len(), - outputs: vec_to_ptr(outputs), - }; - - *out_decoded = Box::into_raw(Box::new(ffi)); - PlatformWalletFFIResult::ok() -} - -/// Release a `DecodedTransactionFFI` returned by -/// `platform_wallet_decode_transaction`. Safe to call with null. -/// -/// # Safety -/// `decoded` must be null or a pointer previously returned by -/// `platform_wallet_decode_transaction` that has not been freed yet. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_decoded_transaction_free( - decoded: *mut DecodedTransactionFFI, -) { - if decoded.is_null() { - return; - } - let decoded = Box::from_raw(decoded); - - if !decoded.inputs.is_null() { - let inputs = - Vec::from_raw_parts(decoded.inputs, decoded.inputs_count, decoded.inputs_count); - for input in &inputs { - if !input.address.is_null() { - drop(CString::from_raw(input.address)); - } - } - drop(inputs); - } - - if !decoded.outputs.is_null() { - let outputs = Vec::from_raw_parts( - decoded.outputs, - decoded.outputs_count, - decoded.outputs_count, - ); - for output in &outputs { - if !output.address.is_null() { - drop(CString::from_raw(output.address)); - } - if !output.script_pubkey.is_null() { - drop(Vec::from_raw_parts( - output.script_pubkey, - output.script_pubkey_len, - output.script_pubkey_len, - )); - } - } - drop(outputs); - } -} - -#[cfg(test)] -mod tests { - //! Everything funnels through the public FFI entry point: fixture → - //! serialize → `platform_wallet_decode_transaction` → plain-Rust copy → - //! assert. `decode_ok` owns the pointer walking and frees exactly once. - //! Fixtures use `dashcore::test_utils` (`Address::dummy`, - //! `Transaction::dummy`, `Transaction::dummy_coinbase`) where they fit; - //! test_utils has no builder for a P2PKH-shaped scriptSig - //! (signature + pubkey pushes), so that fixture stays hand-built. - use super::*; - use dashcore::consensus::serialize; - use dashcore::secp256k1::{Secp256k1, SecretKey}; - use dashcore::{OutPoint, TxIn, TxOut, Txid, Witness}; - use std::ffi::CStr; - - /// FFI result copied into plain Rust so assertions read naturally. - struct Decoded { - txid: [u8; 32], - /// `(prev_txid, prev_vout, sender address)` - inputs: Vec<([u8; 32], u32, Option)>, - /// `(address, value_duffs, script_pubkey)` - outputs: Vec<(Option, u64, Vec)>, - } - - /// Copy an optional C string into owned Rust. - unsafe fn cstr_opt(ptr: *mut c_char) -> Option { - if ptr.is_null() { - None - } else { - Some(CStr::from_ptr(ptr).to_str().unwrap().to_owned()) - } - } - - /// Decode `tx` through the FFI boundary, copy the result out, and free - /// the FFI allocation exactly once. - fn decode_ok(tx: &Transaction, network: FFINetwork) -> Decoded { - let bytes = serialize(tx); - unsafe { - let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); - let res = - platform_wallet_decode_transaction(bytes.as_ptr(), bytes.len(), network, &mut out); - assert_eq!(res.code, PlatformWalletFFIResultCode::Success); - assert!(!out.is_null()); - - let inputs = if (*out).inputs.is_null() { - Vec::new() - } else { - std::slice::from_raw_parts((*out).inputs, (*out).inputs_count) - .iter() - .map(|i| (i.prev_txid, i.prev_vout, cstr_opt(i.address))) - .collect() - }; - let outputs = if (*out).outputs.is_null() { - Vec::new() - } else { - std::slice::from_raw_parts((*out).outputs, (*out).outputs_count) - .iter() - .map(|o| { - let script = if o.script_pubkey.is_null() { - Vec::new() - } else { - std::slice::from_raw_parts(o.script_pubkey, o.script_pubkey_len) - .to_vec() - }; - (cstr_opt(o.address), o.value_duffs, script) - }) - .collect() - }; - let copied = Decoded { - txid: (*out).txid, - inputs, - outputs, - }; - platform_wallet_decoded_transaction_free(out); - copied - } - } - - fn test_pubkey() -> PublicKey { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("valid secret key"); - PublicKey::new(sk.public_key(&secp)) - } - - /// One P2PKH-shaped input (spending 11..11:3), one P2PKH output of - /// 151072 duffs, one OP_RETURN output. The 151072 amount mirrors a - /// CrowdNode signUp signal so the fixture doubles as documentation. - fn p2pkh_spend_tx(network: Network) -> (Transaction, Address) { - let pubkey = test_pubkey(); - let addr = Address::p2pkh(&pubkey, network); - let script_sig = dashcore::blockdata::script::Builder::new() - .push_slice([0x30u8; 71]) // DER-shaped: 0x30 tag, ≤ 73 bytes - .push_key(&pubkey) - .into_script(); - let tx = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint { - txid: Txid::from_byte_array([0x11u8; 32]), - vout: 3, - }, - script_sig, - sequence: 0xffffffff, - witness: Witness::default(), - }], - output: vec![ - TxOut { - value: 151_072, - script_pubkey: addr.script_pubkey(), - }, - TxOut { - value: 0, - script_pubkey: ScriptBuf::new_op_return(&[0xAAu8; 4]), - }, - ], - special_transaction_payload: None, - }; - (tx, addr) - } - - /// A one-input `Transaction::dummy` with its scriptSig replaced. - fn tx_with_script_sig(script_sig: ScriptBuf) -> Transaction { - let addr = Address::dummy(Network::Testnet, 1); - let mut tx = Transaction::dummy(&addr, 1..2, &[9_000]); - tx.input[0].script_sig = script_sig; - tx - } - - #[test] - fn decodes_outputs_with_addresses_and_values() { - let addr = Address::dummy(Network::Testnet, 7); - let tx = Transaction::dummy(&addr, 1..2, &[151_072, 20_002]); - let decoded = decode_ok(&tx, FFINetwork::Testnet); - - assert_eq!(decoded.txid, tx.txid().to_byte_array()); - assert_eq!(decoded.outputs.len(), 2); - assert_eq!(decoded.outputs[0].0, Some(addr.to_string())); - assert_eq!(decoded.outputs[0].1, 151_072); - assert_eq!(decoded.outputs[0].2, addr.script_pubkey().into_bytes()); - assert_eq!(decoded.outputs[1].0, Some(addr.to_string())); - assert_eq!(decoded.outputs[1].1, 20_002); - assert!( - addr.to_string().starts_with('y'), - "testnet P2PKH starts with 'y'" - ); - } - - #[test] - fn op_return_output_has_no_address() { - let (tx, _) = p2pkh_spend_tx(Network::Testnet); - let decoded = decode_ok(&tx, FFINetwork::Testnet); - assert!(decoded.outputs[1].0.is_none()); - assert!( - !decoded.outputs[1].2.is_empty(), - "script bytes still present" - ); - } - - #[test] - fn recovers_p2pkh_input_address_from_script_sig() { - let (tx, addr) = p2pkh_spend_tx(Network::Testnet); - let decoded = decode_ok(&tx, FFINetwork::Testnet); - - assert_eq!(decoded.inputs.len(), 1); - assert_eq!(decoded.inputs[0].0, [0x11u8; 32]); - assert_eq!(decoded.inputs[0].1, 3); - assert_eq!(decoded.inputs[0].2, Some(addr.to_string())); - } - - #[test] - fn network_changes_rendered_addresses() { - let addr = Address::dummy(Network::Testnet, 7); - let tx = Transaction::dummy(&addr, 1..2, &[151_072]); - let decoded = decode_ok(&tx, FFINetwork::Mainnet); - let rendered = decoded.outputs[0].0.clone().unwrap(); - assert_ne!(rendered, addr.to_string()); - assert!(rendered.starts_with('X'), "mainnet P2PKH starts with 'X'"); - } - - #[test] - fn coinbase_input_has_no_address() { - let addr = Address::dummy(Network::Testnet, 3); - let tx = Transaction::dummy_coinbase(&addr, 50_000); - let decoded = decode_ok(&tx, FFINetwork::Testnet); - assert!(decoded.inputs[0].2.is_none()); - } - - #[test] - fn non_p2pkh_script_sig_yields_no_input_address() { - // Transaction::dummy fills script_sig with a *lock* script - // (OP_DUP OP_HASH160 <20 B> OP_EQUALVERIFY OP_CHECKSIG): opcodes - // present, last push 20 bytes — must not produce an address. - let addr = Address::dummy(Network::Testnet, 9); - let tx = Transaction::dummy(&addr, 1..2, &[1_000]); - let decoded = decode_ok(&tx, FFINetwork::Testnet); - assert!(decoded.inputs[0].2.is_none()); - } - - #[test] - fn redeem_script_collision_yields_no_input_address() { - // Three pushes ending in a valid 33-byte pubkey — the P2SH - // redeem-script shape the exactly-two-pushes rule exists to reject. - let script_sig = dashcore::blockdata::script::Builder::new() - .push_slice([0x30u8; 71]) - .push_slice([0x01u8; 20]) - .push_key(&test_pubkey()) - .into_script(); - let decoded = decode_ok(&tx_with_script_sig(script_sig), FFINetwork::Testnet); - assert!(decoded.inputs[0].2.is_none()); - } - - #[test] - fn non_signature_first_push_yields_no_input_address() { - // Two pushes, but the first is not DER-shaped (no 0x30 tag). - let script_sig = dashcore::blockdata::script::Builder::new() - .push_slice([0xAAu8; 10]) - .push_key(&test_pubkey()) - .into_script(); - let decoded = decode_ok(&tx_with_script_sig(script_sig), FFINetwork::Testnet); - assert!(decoded.inputs[0].2.is_none()); - } - - #[test] - fn trailing_garbage_is_a_deserialization_error() { - let (tx, _) = p2pkh_spend_tx(Network::Testnet); - let mut bytes = serialize(&tx); - bytes.extend_from_slice(&[0xDE, 0xAD]); - unsafe { - // Pre-poison the out param to pin that failures null it. - let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); - let res = platform_wallet_decode_transaction( - bytes.as_ptr(), - bytes.len(), - FFINetwork::Testnet, - &mut out, - ); - assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorDeserialization); - assert!(out.is_null(), "out param is nulled on failure"); - } - } - - #[test] - fn garbage_bytes_are_a_deserialization_error() { - let bytes = [0xFFu8; 16]; - unsafe { - let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); - let res = platform_wallet_decode_transaction( - bytes.as_ptr(), - bytes.len(), - FFINetwork::Testnet, - &mut out, - ); - assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorDeserialization); - assert!(out.is_null(), "out param is nulled on failure"); - } - } - - #[test] - fn marshals_decoded_transaction_across_the_boundary() { - // Raw pointer walk (no helper) — pins the C-side memory layout. - let (tx, _) = p2pkh_spend_tx(Network::Testnet); - let bytes = serialize(&tx); - unsafe { - let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); - let res = platform_wallet_decode_transaction( - bytes.as_ptr(), - bytes.len(), - FFINetwork::Testnet, - &mut out, - ); - assert_eq!(res.code, PlatformWalletFFIResultCode::Success); - assert!(!out.is_null()); - - let inputs = std::slice::from_raw_parts((*out).inputs, (*out).inputs_count); - let outputs = std::slice::from_raw_parts((*out).outputs, (*out).outputs_count); - assert_eq!((*out).inputs_count, 1); - assert_eq!((*out).outputs_count, 2); - assert_eq!(inputs[0].prev_vout, 3); - assert!(!inputs[0].address.is_null()); - assert_eq!(outputs[0].value_duffs, 151_072); - let addr = CStr::from_ptr(outputs[0].address).to_str().unwrap(); - assert!(addr.starts_with('y')); - assert!(outputs[0].script_pubkey_len > 0); - - platform_wallet_decoded_transaction_free(out); - } - } - - #[test] - fn rejects_null_and_empty_input() { - unsafe { - // Pre-poison the out param to pin that failures null it. - let mut out: *mut DecodedTransactionFFI = std::ptr::NonNull::dangling().as_ptr(); - let res = platform_wallet_decode_transaction( - std::ptr::null(), - 4, - FFINetwork::Testnet, - &mut out, - ); - assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorNullPointer); - assert!(out.is_null(), "out param is nulled on failure"); - - let bytes = [0u8; 4]; - out = std::ptr::NonNull::dangling().as_ptr(); - let res = platform_wallet_decode_transaction( - bytes.as_ptr(), - 0, - FFINetwork::Testnet, - &mut out, - ); - assert_eq!(res.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); - assert!(out.is_null(), "out param is nulled on failure"); - } - } - - #[test] - fn free_null_is_safe() { - unsafe { platform_wallet_decoded_transaction_free(std::ptr::null_mut()) } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift similarity index 84% rename from packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift rename to packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 79413c94acb..380deeeeff7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -53,35 +53,39 @@ public struct DecodedTransaction: Sendable, Equatable { } } -/// Pure utility — decodes raw transaction bytes via -/// `platform_wallet_decode_transaction`. No wallet handle or state involved. +/// Pure utility — decodes raw transaction bytes via `transaction_decode` +/// (key-wallet-ffi). No wallet handle or state involved. public enum TransactionDecoder { /// Consensus-decode raw transaction bytes. /// - Parameters: /// - txData: Serialized transaction bytes (e.g. from persisted rows). /// - network: Network used to render addresses (base58 version bytes). /// - Returns: The decoded transaction. - /// - Throws: `PlatformWalletError.deserialization` for malformed bytes - /// (including trailing garbage), `.invalidParameter` for empty input. + /// - Throws: `KeyWalletError.invalidInput` for malformed or empty bytes + /// (including trailing garbage). public static func decode(_ txData: Data, network: Network) throws -> DecodedTransaction { - guard !txData.isEmpty else { - throw PlatformWalletError.invalidParameter("txData is empty") - } - + var error = FFIError() var outDecoded: UnsafeMutablePointer? = nil - let res = txData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - platform_wallet_decode_transaction( + let success = txData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in + transaction_decode( raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - UInt(txData.count), + txData.count, network.ffiValue, - &outDecoded + &outDecoded, + &error ) } - try res.check() - guard let decoded = outDecoded else { - throw PlatformWalletError.nullPointer("decode returned success but no result") + + defer { + if error.message != nil { + error_message_free(error.message) + } + } + + guard success, let decoded = outDecoded else { + throw KeyWalletError(ffiError: error) } - defer { platform_wallet_decoded_transaction_free(decoded) } + defer { decoded_transaction_free(decoded) } var entry = decoded.pointee let txid = withUnsafeBytes(of: &entry.txid) { Data($0) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift index d53ff2547d8..a6154d0f5e1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift @@ -3,7 +3,7 @@ import XCTest /// Pure-function tests for `TransactionDecoder` — no wallet handle or network /// access. The fixture is the same synthetic transaction exercised by the -/// Rust-side unit tests in `rs-platform-wallet-ffi/src/tx_decode.rs`: +/// Rust-side unit tests in `key-wallet-ffi/src/tx_decode.rs` (rust-dashcore): /// one P2PKH input (spending 1111…11:3, scriptSig = push(sig) push(pubkey)), /// one P2PKH output of 151 072 duffs, and one OP_RETURN output. final class TransactionDecoderTests: XCTestCase {