From 83e59d4533de7c868d4d015df6d4034a023b9a61 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:48:04 +0000 Subject: [PATCH 1/2] feat: add send_transaction method to UniFFI bridge (#42) Add `send_transaction(raw_tx_hex: String) -> Result` to the `SpvClient` UniFFI wrapper. The method: - Decodes the hex-encoded raw transaction bytes - Deserialises the bytes into a `dashcore::Transaction` - Broadcasts to all connected peers via `DashSpvClient::broadcast_transaction` - Returns a `SendResult` record containing the txid and status on success - Returns `SpvClientError::Transaction` for invalid hex or unparseable bytes - Returns `SpvClientError::Network` when no peers are connected New types: - `SendResult` UniFFI record with `txid` and `status` fields - `SpvClientError::Transaction` variant for transaction-decode errors Tests added for all error paths (invalid hex, invalid bytes, no peers) and record construction/equality. Co-authored-by: Kevin Rombach --- dash-spv/src/bridge/mod.rs | 176 +++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/dash-spv/src/bridge/mod.rs b/dash-spv/src/bridge/mod.rs index a52f1ca10..1aba5ecf7 100644 --- a/dash-spv/src/bridge/mod.rs +++ b/dash-spv/src/bridge/mod.rs @@ -379,6 +379,10 @@ pub enum SpvClientError { Sync { message: String, }, + #[error("Transaction error: {message}")] + Transaction { + message: String, + }, #[error("General error: {message}")] General { message: String, @@ -407,6 +411,17 @@ impl From for SpvClientError { } } +// ============ Send result type ============ + +/// UniFFI-compatible result record for a broadcasted transaction. +#[derive(uniffi::Record, Clone, Debug, PartialEq)] +pub struct SendResult { + /// Transaction ID (txid) of the broadcasted transaction, as a hex string. + pub txid: String, + /// Broadcast status: `"broadcasted"` on success. + pub status: String, +} + // ============ Wallet record types ============ /// UniFFI-compatible wallet balance record. @@ -769,6 +784,53 @@ impl SpvClient { } } +// ============ Send transaction ============ + +#[uniffi::export] +impl SpvClient { + /// Broadcast a raw transaction to the Dash network. + /// + /// Decodes `raw_tx_hex` (a hex-encoded serialised Dash transaction), broadcasts + /// it to all connected peers via `DashSpvClient::broadcast_transaction`, and + /// returns a [`SendResult`] containing the transaction ID on success. + /// + /// # Errors + /// + /// Returns [`SpvClientError::Transaction`] when: + /// * `raw_tx_hex` is not valid hexadecimal. + /// * The decoded bytes cannot be deserialised as a `dashcore::Transaction`. + /// + /// Returns [`SpvClientError::Network`] when: + /// * No peers are connected. + /// * All peers reject or fail to receive the message. + pub async fn send_transaction( + &self, + raw_tx_hex: String, + ) -> Result { + use dashcore::consensus::Decodable; + use hex::FromHex; + + let bytes = Vec::::from_hex(&raw_tx_hex).map_err(|e| SpvClientError::Transaction { + message: format!("Invalid hex: {e}"), + })?; + + let tx = dashcore::Transaction::consensus_decode(&mut bytes.as_slice()).map_err(|e| { + SpvClientError::Transaction { + message: format!("Failed to deserialise transaction: {e}"), + } + })?; + + let txid = tx.txid().to_string(); + + self.inner.broadcast_transaction(&tx).await.map_err(SpvClientError::from)?; + + Ok(SendResult { + txid, + status: "broadcasted".to_string(), + }) + } +} + // ============ Transaction history methods ============ #[uniffi::export] @@ -1682,4 +1744,118 @@ mod tests { "get_transaction should return None (stub)" ); } + + // ---- SendResult record tests ---- + + #[test] + fn test_send_result_fields() { + let result = SendResult { + txid: "abcd1234efgh5678".to_string(), + status: "broadcasted".to_string(), + }; + assert_eq!(result.txid, "abcd1234efgh5678"); + assert_eq!(result.status, "broadcasted"); + } + + #[test] + fn test_send_result_clone_and_eq() { + let result = SendResult { + txid: "txid001".to_string(), + status: "broadcasted".to_string(), + }; + let cloned = result.clone(); + assert_eq!(result, cloned); + } + + // ---- send_transaction error-path tests ---- + + /// `send_transaction` with invalid hex returns `SpvClientError::Transaction`. + #[tokio::test] + async fn test_send_transaction_invalid_hex() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest() + .without_filters() + .without_masternodes() + .with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + let err = client + .send_transaction("not-valid-hex!!".to_string()) + .await + .expect_err("should fail on invalid hex"); + + assert!( + matches!(err, SpvClientError::Transaction { .. }), + "expected Transaction error, got: {err:?}" + ); + } + + /// `send_transaction` with valid hex that is not a valid transaction returns + /// `SpvClientError::Transaction`. + #[tokio::test] + async fn test_send_transaction_invalid_tx_bytes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest() + .without_filters() + .without_masternodes() + .with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + // Valid hex but random bytes — not a parseable transaction. + let err = client + .send_transaction("deadbeefcafe".to_string()) + .await + .expect_err("should fail on non-transaction bytes"); + + assert!( + matches!(err, SpvClientError::Transaction { .. }), + "expected Transaction error, got: {err:?}" + ); + } + + /// `send_transaction` with a well-formed transaction but no connected peers + /// returns `SpvClientError::Network`. + #[tokio::test] + async fn test_send_transaction_no_peers() { + use dashcore::consensus::Encodable; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest() + .without_filters() + .without_masternodes() + .with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + + // Build a minimal coinbase-style transaction (version=1, 1 input, 1 output). + let tx = dashcore::Transaction { + version: 1, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint::null(), + script_sig: dashcore::ScriptBuf::new(), + sequence: 0xFFFF_FFFF, + witness: dashcore::Witness::default(), + }], + output: vec![dashcore::TxOut { + value: 50_000_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + + let mut raw = Vec::new(); + tx.consensus_encode(&mut raw).expect("encode must succeed"); + let raw_hex = hex::encode(&raw); + + let err = client + .send_transaction(raw_hex) + .await + .expect_err("should fail when no peers are connected"); + + assert!( + matches!(err, SpvClientError::Network { .. }), + "expected Network error when no peers connected, got: {err:?}" + ); + } } From 0dc9083ba54d77f7ba6f89ef3212a709ae46f2d5 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Sat, 14 Mar 2026 06:55:06 +0700 Subject: [PATCH 2/2] style: fix formatting --- dash-spv/src/bridge/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dash-spv/src/bridge/mod.rs b/dash-spv/src/bridge/mod.rs index 1aba5ecf7..da3bf8218 100644 --- a/dash-spv/src/bridge/mod.rs +++ b/dash-spv/src/bridge/mod.rs @@ -803,10 +803,7 @@ impl SpvClient { /// Returns [`SpvClientError::Network`] when: /// * No peers are connected. /// * All peers reject or fail to receive the message. - pub async fn send_transaction( - &self, - raw_tx_hex: String, - ) -> Result { + pub async fn send_transaction(&self, raw_tx_hex: String) -> Result { use dashcore::consensus::Decodable; use hex::FromHex;