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/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..0f3a8f90a10 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/tx_decode.rs @@ -0,0 +1,570 @@ +//! 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/PlatformWallet/TransactionDecoder.swift new file mode 100644 index 00000000000..79413c94acb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift @@ -0,0 +1,124 @@ +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 — + /// 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-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 { + /// 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..