Skip to content
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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()
}
}
Comment thread
ZocoLini marked this conversation as resolved.

/// 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: `KeyWalletError.invalidInput` for malformed or empty bytes
/// (including trailing garbage).
public static func decode(_ txData: Data, network: Network) throws -> DecodedTransaction {
var error = FFIError()
var outDecoded: UnsafeMutablePointer<DecodedTransactionFFI>? = nil
let success = txData.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
transaction_decode(
raw.baseAddress?.assumingMemoryBound(to: UInt8.self),
txData.count,
network.ffiValue,
&outDecoded,
&error
)
}

defer {
if error.message != nil {
error_message_free(error.message)
}
}

guard success, let decoded = outDecoded else {
throw KeyWalletError(ffiError: error)
}
defer { 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..<Int(entry.inputs_count)).map { i in
var input = ptr[i]
let prevTxid = withUnsafeBytes(of: &input.prev_txid) { Data($0) }
let address = input.address.map { String(cString: $0) }
return DecodedTransaction.Input(
prevTxid: prevTxid,
prevVout: input.prev_vout,
address: address
)
}
}

var outputs: [DecodedTransaction.Output] = []
if let ptr = entry.outputs, entry.outputs_count > 0 {
outputs = (0..<Int(entry.outputs_count)).map { i in
let output = ptr[i]
let address = output.address.map { String(cString: $0) }
let script: Data
if let sptr = output.script_pubkey, output.script_pubkey_len > 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)
}
}
Original file line number Diff line number Diff line change
@@ -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 `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 {
private static let fixtureHex =
"01000000011111111111111111111111111111111111111111111111111111111111111111"
+ "030000006a4730303030303030303030303030303030303030303030303030303030303030"
+ "30303030303030303030303030303030303030303030303030303030303030303030303030"
+ "303030210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
+ "ffffffff02204e0200000000001976a91414db4138d56a2ecfb10881a9be394d9f321985b2"
+ "88ac0000000000000000066a04aaaaaaaa00000000"

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..<next], radix: 16)!)
index = next
}
return data
}

func testDecodesOutputsWithAddressesAndValues() throws {
let decoded = try TransactionDecoder.decode(fixtureData, network: .testnet)

XCTAssertEqual(decoded.outputs.count, 2)

// P2PKH output: exact signal amount, testnet address.
XCTAssertEqual(decoded.outputs[0].address, Self.fixtureAddress)
XCTAssertEqual(decoded.outputs[0].valueDuffs, 151_072)
XCTAssertFalse(decoded.outputs[0].scriptPubkey.isEmpty)

// OP_RETURN output: no address, script still exposed.
XCTAssertNil(decoded.outputs[1].address)
XCTAssertFalse(decoded.outputs[1].scriptPubkey.isEmpty)
}

func testRecoversP2PKHInputAddressAndOutpoint() throws {
let decoded = try TransactionDecoder.decode(fixtureData, network: .testnet)

XCTAssertEqual(decoded.inputs.count, 1)
XCTAssertEqual(decoded.inputs[0].prevTxid, Data(repeating: 0x11, count: 32))
XCTAssertEqual(decoded.inputs[0].prevVout, 3)
XCTAssertEqual(decoded.inputs[0].address, Self.fixtureAddress)
}

func testTxidDisplayHexMatchesExplorerOrder() throws {
let decoded = try TransactionDecoder.decode(fixtureData, network: .testnet)
XCTAssertEqual(decoded.txidDisplayHex, Self.fixtureTxidDisplay)
XCTAssertEqual(decoded.txid, Data(decoded.txid.reversed().reversed()))
}

func testNetworkChangesRenderedAddresses() throws {
let mainnet = try TransactionDecoder.decode(fixtureData, network: .mainnet)
XCTAssertNotEqual(mainnet.outputs[0].address, Self.fixtureAddress)
XCTAssertTrue(mainnet.outputs[0].address?.hasPrefix("X") ?? false)
}

func testMalformedBytesThrowDeserialization() {
var bytes = fixtureData
bytes.append(contentsOf: [0xDE, 0xAD]) // trailing garbage
XCTAssertThrowsError(try TransactionDecoder.decode(bytes, network: .testnet))
XCTAssertThrowsError(try TransactionDecoder.decode(Data([0xFF, 0xFF, 0xFF]), network: .testnet))
}

func testEmptyInputThrowsInvalidParameter() {
XCTAssertThrowsError(try TransactionDecoder.decode(Data(), network: .testnet))
}
}
Loading