diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 9792c014ab..26fa825fa5 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -5,8 +5,23 @@ use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use platform_wallet::PlatformWalletError; use std::os::raw::c_char; +fn classify_broadcast_result( + result: Result, + local_txid: dashcore::Txid, +) -> (Option, PlatformWalletFFIResult) { + match result { + Ok(_) => (Some(local_txid), PlatformWalletFFIResult::ok()), + Err(error @ PlatformWalletError::TransactionBroadcast(_)) + | Err(error @ PlatformWalletError::TransactionBroadcastUnconfirmed(_)) => { + (Some(local_txid), error.into()) + } + Err(error) => (None, error.into()), + } +} + /// Consume and broadcast an atomically finalized V2 transaction. /// /// Success and `MaybeSent` both permanently consume the handle. A definitive @@ -39,15 +54,19 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( "transaction was finalized by a different wallet".to_string(), ); } + let local_txid = finalized.transaction.transaction().txid(); let result = runtime().block_on( finalized .wallet .broadcast_finalized_transaction(&finalized.transaction), ); - let txid = unwrap_result_or_return!(result); + let (txid, ffi_result) = classify_broadcast_result(result, local_txid); + let Some(txid) = txid else { + return ffi_result; + }; let c_str = unwrap_result_or_return!(std::ffi::CString::new(txid.to_string())); *out_txid = c_str.into_raw(); - PlatformWalletFFIResult::ok() + ffi_result } /// Consume a finalized transaction without broadcasting and release its input @@ -128,6 +147,9 @@ pub unsafe extern "C" fn core_wallet_signed_transaction_v2_fee( /// # Safety /// `handle` must be a valid core-wallet handle; `tx` must be a valid, /// non-null pointer to an `FFICoreTransaction`; `out_txid` must be writable. +/// On accepted, rejected, and unknown network outcomes `out_txid` receives a +/// Rust-owned C string that the caller frees with +/// `platform_wallet_string_free`. Operational errors leave it null. #[no_mangle] pub unsafe extern "C" fn core_wallet_broadcast_transaction( handle: Handle, @@ -136,11 +158,13 @@ pub unsafe extern "C" fn core_wallet_broadcast_transaction( account_index: u32, out_txid: *mut *mut c_char, ) -> PlatformWalletFFIResult { - check_ptr!(tx); check_ptr!(out_txid); + *out_txid = std::ptr::null_mut(); + check_ptr!(tx); let tx: dashcore::Transaction = unwrap_result_or_return!(dashcore::consensus::deserialize((*tx).bytes())); + let local_txid = tx.txid(); let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { runtime().block_on(async { @@ -161,11 +185,66 @@ pub unsafe extern "C" fn core_wallet_broadcast_transaction( let result = unwrap_option_or_return!(option); - let txid = unwrap_result_or_return!(result); + let (txid, ffi_result) = classify_broadcast_result(result, local_txid); + let Some(txid) = txid else { + return ffi_result; + }; let c_str = unwrap_result_or_return!(std::ffi::CString::new(txid.to_string())); *out_txid = c_str.into_raw(); - PlatformWalletFFIResult::ok() + ffi_result +} + +#[cfg(test)] +mod outcome_tests { + use dashcore::hashes::Hash; + + use super::*; + + fn txid(byte: u8) -> dashcore::Txid { + dashcore::Txid::from_byte_array([byte; 32]) + } + + #[test] + fn network_outcomes_all_carry_a_txid() { + let accepted = classify_broadcast_result(Ok(txid(1)), txid(9)); + assert_eq!(accepted.0, Some(txid(9))); + assert_eq!(accepted.1.code, PlatformWalletFFIResultCode::Success); + + let rejected = classify_broadcast_result( + Err(PlatformWalletError::TransactionBroadcast( + "rejected".to_string(), + )), + txid(2), + ); + assert_eq!(rejected.0, Some(txid(2))); + assert_eq!( + rejected.1.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected + ); + + let unknown = classify_broadcast_result( + Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + "timeout".to_string(), + )), + txid(3), + ); + assert_eq!(unknown.0, Some(txid(3))); + assert_eq!( + unknown.1.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + ); + } + + #[test] + fn operational_error_does_not_carry_a_txid() { + let outcome = classify_broadcast_result( + Err(PlatformWalletError::TransactionBuild("invalid".to_string())), + txid(4), + ); + assert_eq!(outcome.0, None); + assert_eq!(outcome.1.code, PlatformWalletFFIResultCode::ErrorUnknown); + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 7d01177b5b..44532de863 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -168,6 +168,10 @@ pub enum PlatformWalletFFIResultCode { /// Existing-lock recovery attempted to use a lock for the wrong funding /// family or bound identity index. ErrorAssetLockFundingMismatch = 25, + /// Maps `PlatformWalletError::TransactionBroadcast`. Core definitively + /// rejected the transaction, so its UTXO reservation was released and the + /// host may safely retry after addressing the rejection reason. + ErrorTransactionBroadcastRejected = 26, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -308,6 +312,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + PlatformWalletError::TransactionBroadcast(..) => { + PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected + } // A definitively-failed address-nonce race (reaches the blanket impl // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing // provided/expected nonce as structured out-fields is INTENTIONALLY @@ -762,6 +769,24 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + #[test] + fn transaction_broadcast_rejected_maps_to_dedicated_code() { + let rejected = PlatformWalletError::TransactionBroadcast( + "mandatory-script-verify-flag-failed".to_string(), + ); + let rendered = rejected.to_string(); + let result: PlatformWalletFFIResult = rejected.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected, + "TransactionBroadcast should map to its dedicated rejection code" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(msg, rendered, "Display payload must survive verbatim"); + } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` /// FFI code through the blanket `From` impl (the path identity /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening diff --git a/packages/rs-platform-wallet/src/broadcaster.rs b/packages/rs-platform-wallet/src/broadcaster.rs index af9f625e4c..256d03ac87 100644 --- a/packages/rs-platform-wallet/src/broadcaster.rs +++ b/packages/rs-platform-wallet/src/broadcaster.rs @@ -1,11 +1,9 @@ //! Transaction broadcasting abstraction. //! -//! Two implementations are provided: -//! -//! - [`DapiBroadcaster`] — broadcasts via Platform's DAPI gRPC (default for -//! standalone wallets without SPV). -//! - [`SpvBroadcaster`] — broadcasts via SPV P2P peers (used when the wallet -//! is managed by [`PlatformWalletManager`] with SPV enabled). +//! Core acceptance is authoritative: a socket write to an SPV peer is not +//! enough to report success. Production SPV wallets first submit through +//! DAPI's Core `sendrawtransaction` bridge and only relay/inject the +//! transaction through SPV after Core has accepted it. use std::sync::Arc; @@ -15,30 +13,23 @@ use dashcore::{Transaction, Txid}; use crate::error::PlatformWalletError; use crate::spv::SpvRuntime; -/// A failed broadcast, classified by whether the transaction could have -/// reached the network. +/// A failed broadcast, classified by whether Core definitively rejected the +/// transaction or its acceptance remains unknown. /// /// The classification decides whether the transaction's reserved inputs are /// safe to release for an immediate retry (see -/// `wallet::reservations::broadcast_releasing_on_rejection`). Only the -/// broadcaster implementation has the transport knowledge to make this call, -/// so the distinction is part of the error type rather than a convention on -/// message strings. +/// `wallet::reservations::broadcast_releasing_on_rejection`). #[derive(Debug, thiserror::Error)] pub enum BroadcastError { - /// The transaction was definitively **not** handed to the network: the - /// failure happened before any peer or endpoint received the bytes. - /// Reserved inputs are safe to release and the send may be retried - /// immediately. - #[error("transaction broadcast rejected before reaching the network: {reason}")] + /// Core definitively did not accept the transaction. Reserved inputs are + /// safe to release and the send may be retried. + #[error("transaction broadcast rejected: {reason}")] Rejected { reason: String }, - /// The outcome is unknown — the transaction **may** already be on the - /// network (transport timeout after delivery, partial peer send, or an - /// internal multi-node retry whose earlier attempt may have succeeded). - /// Reserved inputs must be kept; the reservation-TTL backstop or a later - /// sync reconciles the outcome. - #[error("transaction broadcast outcome unknown — it may already be on the network: {reason}")] + /// The outcome is unknown — Core may already have accepted the + /// transaction even though the response was lost. Reserved inputs must be + /// kept until a later sync or the reservation-TTL backstop reconciles it. + #[error("transaction broadcast outcome unknown — Core may already have accepted it: {reason}")] MaybeSent { reason: String }, } @@ -57,88 +48,465 @@ impl From for PlatformWalletError { /// Broadcasts a signed transaction to the Dash network. /// -/// Implementations may use DAPI (gRPC), SPV (P2P peers), or Core RPC. +/// `Ok(txid)` means an authoritative Core endpoint accepted the transaction +/// (or reported that it already knows it). A successful P2P socket write alone +/// must never satisfy this contract. #[async_trait] pub trait TransactionBroadcaster: Send + Sync { - /// Contract: implementations must classify every failure as - /// [`BroadcastError::Rejected`] **only** when the transaction provably - /// never reached the network (no bytes handed to any peer or endpoint). - /// Anything uncertain — timeouts after delivery, partial sends, retried - /// submissions — must be [`BroadcastError::MaybeSent`], so callers keep - /// the transaction's input reservation instead of releasing it into a - /// potential double-spend. + /// Contract: [`BroadcastError::Rejected`] is allowed only when Core + /// definitively did not accept the transaction. Any timeout, transport + /// ambiguity, or unverifiable response must be [`BroadcastError::MaybeSent`]. async fn broadcast(&self, transaction: &Transaction) -> Result; } -/// Broadcasts transactions via Platform's DAPI gRPC endpoint. -/// -/// Used by default when no SPV runtime is available. -pub struct DapiBroadcaster { +#[derive(Debug)] +enum DapiSubmission { + Accepted, + AlreadyKnown, + Rejected { reason: String }, + Uncertain { reason: String }, +} + +fn classify_dapi_response(txid: Txid, returned_txid: String) -> DapiSubmission { + if returned_txid == txid.to_string() { + DapiSubmission::Accepted + } else { + DapiSubmission::Uncertain { + reason: format!( + "DAPI returned transaction id '{returned_txid}' for submitted transaction {txid}" + ), + } + } +} + +fn classify_dapi_error( + code: Option, + reason: String, +) -> DapiSubmission { + use dash_sdk::dapi_grpc::tonic::Code; + + match code { + Some(Code::AlreadyExists) => DapiSubmission::AlreadyKnown, + Some(Code::InvalidArgument | Code::FailedPrecondition) => { + DapiSubmission::Rejected { reason } + } + _ => DapiSubmission::Uncertain { reason }, + } +} + +#[async_trait] +trait DapiCoreClient: Send + Sync { + async fn submit(&self, transaction: &Transaction) -> DapiSubmission; + + async fn transaction_known(&self, txid: Txid) -> Result; +} + +struct SdkDapiCoreClient { sdk: Arc, } -impl DapiBroadcaster { - pub fn new(sdk: Arc) -> Self { +fn broadcast_request_settings() -> dash_sdk::dapi_client::RequestSettings { + dash_sdk::dapi_client::RequestSettings { + retries: Some(0), + ..dash_sdk::dapi_client::RequestSettings::default() + } +} + +impl SdkDapiCoreClient { + fn new(sdk: Arc) -> Self { Self { sdk } } } +fn grpc_code( + error: &dash_sdk::dapi_client::DapiClientError, +) -> Option { + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + + match error { + DapiClientError::Transport(TransportError::Grpc(status)) => Some(status.code()), + DapiClientError::NoAvailableAddressesToRetry(error) => match error.as_ref() { + TransportError::Grpc(status) => Some(status.code()), + }, + _ => None, + } +} + #[async_trait] -impl TransactionBroadcaster for DapiBroadcaster { - async fn broadcast(&self, transaction: &Transaction) -> Result { - use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; +impl DapiCoreClient for SdkDapiCoreClient { + async fn submit(&self, transaction: &Transaction) -> DapiSubmission { + use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner}; use dash_sdk::dapi_grpc::core::v0::BroadcastTransactionRequest; use dashcore::consensus; - let tx_bytes = consensus::serialize(transaction); - + let txid = transaction.txid(); let request = BroadcastTransactionRequest { - transaction: tx_bytes, + transaction: consensus::serialize(transaction), allow_high_fees: false, bypass_limits: false, }; - // Every DAPI failure is classified `MaybeSent`: `sdk.execute` retries - // across nodes internally (RequestSettings::default()), so the error - // surfaced here is only the *last* attempt's — an earlier attempt may - // have delivered the transaction even though the response was lost - // (the classic shape being a node that accepts the tx while its gRPC - // response times out, followed by a retry that fails differently). - // Distinguishing a genuinely pre-send rejection would require - // disabling the internal retries and inspecting transport errors; - // until then the conservative classification keeps reserved inputs - // safe from double-spends at the cost of holding them for the - // reservation TTL. - let _response = self - .sdk - .execute(request, RequestSettings::default()) - .await - .into_inner() - .map_err(|e| BroadcastError::MaybeSent { - reason: format!("DAPI broadcast failed: {}", e), - })?; - - Ok(transaction.txid()) - } -} - -/// Broadcasts transactions via SPV P2P peers. -/// -/// Used when the wallet is managed by [`PlatformWalletManager`] with SPV. + // A broadcast is deliberately single-attempt. If the SDK retried after + // a lost success response, a later node could report a conflict and we + // could incorrectly release inputs for a transaction already accepted + // by the first node. + let settings = broadcast_request_settings(); + + match self.sdk.execute(request, settings).await.into_inner() { + Ok(response) => classify_dapi_response(txid, response.transaction_id), + Err(error) => { + classify_dapi_error(grpc_code(&error), format!("DAPI broadcast failed: {error}")) + } + } + } + + async fn transaction_known(&self, txid: Txid) -> Result { + match self.sdk.get_transaction(&txid.to_string()).await { + Ok(Some(fetched)) if fetched.transaction.txid() == txid => Ok(true), + Ok(Some(fetched)) => Err(format!( + "DAPI getTransaction returned {} while reconciling {}", + fetched.transaction.txid(), + txid + )), + Ok(None) => Ok(false), + Err(error) => Err(format!("DAPI getTransaction failed: {error}")), + } + } +} + +/// Broadcasts transactions through Platform's DAPI Core bridge. +pub struct DapiBroadcaster { + client: Arc, +} + +impl DapiBroadcaster { + pub fn new(sdk: Arc) -> Self { + Self { + client: Arc::new(SdkDapiCoreClient::new(sdk)), + } + } + + #[cfg(test)] + fn from_client(client: Arc) -> Self { + Self { client } + } + + async fn reconcile_uncertain( + &self, + txid: Txid, + submit_reason: String, + ) -> Result { + match self.client.transaction_known(txid).await { + Ok(true) => Ok(txid), + Ok(false) => Err(BroadcastError::MaybeSent { + reason: format!("{submit_reason}; follow-up getTransaction did not find {txid}"), + }), + Err(lookup_reason) => Err(BroadcastError::MaybeSent { + reason: format!("{submit_reason}; {lookup_reason}"), + }), + } + } +} + +#[async_trait] +impl TransactionBroadcaster for DapiBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + let txid = transaction.txid(); + match self.client.submit(transaction).await { + DapiSubmission::Accepted | DapiSubmission::AlreadyKnown => Ok(txid), + DapiSubmission::Rejected { reason } => Err(BroadcastError::Rejected { reason }), + DapiSubmission::Uncertain { reason } => self.reconcile_uncertain(txid, reason).await, + } + } +} + +#[async_trait] +trait AcceptedTransactionRelay: Send + Sync { + async fn relay(&self, transaction: &Transaction) -> Result<(), BroadcastError>; +} + +#[async_trait] +impl AcceptedTransactionRelay for SpvRuntime { + async fn relay(&self, transaction: &Transaction) -> Result<(), BroadcastError> { + self.broadcast_transaction(transaction).await + } +} + +/// Uses DAPI/Core as the acceptance authority, then relays the accepted +/// transaction through SPV so its local mempool pipeline sees it immediately. pub struct SpvBroadcaster { - spv: Arc, + authority: DapiBroadcaster, + relay: Arc, } impl SpvBroadcaster { - pub fn new(spv: Arc) -> Self { - Self { spv } + pub fn new(spv: Arc, sdk: Arc) -> Self { + Self { + authority: DapiBroadcaster::new(sdk), + relay: spv, + } + } + + #[cfg(test)] + fn from_components( + authority: DapiBroadcaster, + relay: Arc, + ) -> Self { + Self { authority, relay } } } #[async_trait] impl TransactionBroadcaster for SpvBroadcaster { async fn broadcast(&self, transaction: &Transaction) -> Result { - self.spv.broadcast_transaction(transaction).await?; - Ok(transaction.txid()) + let txid = self.authority.broadcast(transaction).await?; + + // Core acceptance is final for this API. SPV propagation/local apply is + // best-effort after that point; a failure is healed by normal SPV sync. + if let Err(error) = self.relay.relay(transaction).await { + tracing::warn!( + txid = %txid, + error = %error, + "Core accepted transaction but SPV relay/local apply failed" + ); + } + + Ok(txid) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + use dashcore::hashes::Hash; + + use super::*; + + struct FakeDapiClient { + submission: Mutex>, + known: Result, + lookup_calls: AtomicUsize, + } + + impl FakeDapiClient { + fn new(submission: DapiSubmission, known: Result) -> Self { + Self { + submission: Mutex::new(Some(submission)), + known, + lookup_calls: AtomicUsize::new(0), + } + } + } + + #[async_trait] + impl DapiCoreClient for FakeDapiClient { + async fn submit(&self, _transaction: &Transaction) -> DapiSubmission { + self.submission + .lock() + .expect("submission mutex") + .take() + .expect("one submission") + } + + async fn transaction_known(&self, _txid: Txid) -> Result { + self.lookup_calls.fetch_add(1, Ordering::SeqCst); + self.known.clone() + } + } + + struct RelaySpy { + calls: AtomicUsize, + result: Mutex>>, + } + + impl RelaySpy { + fn succeeding() -> Self { + Self { + calls: AtomicUsize::new(0), + result: Mutex::new(Some(Ok(()))), + } + } + + fn failing() -> Self { + Self { + calls: AtomicUsize::new(0), + result: Mutex::new(Some(Err(BroadcastError::MaybeSent { + reason: "relay failed".to_string(), + }))), + } + } + } + + #[async_trait] + impl AcceptedTransactionRelay for RelaySpy { + async fn relay(&self, _transaction: &Transaction) -> Result<(), BroadcastError> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.result + .lock() + .expect("relay mutex") + .take() + .expect("one relay") + } + } + + fn transaction() -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: Vec::new(), + output: Vec::new(), + special_transaction_payload: None, + } + } + + fn dapi_broadcaster(client: Arc) -> DapiBroadcaster { + DapiBroadcaster::from_client(client) + } + + #[tokio::test] + async fn accepted_and_already_known_are_successful_without_lookup() { + for submission in [DapiSubmission::Accepted, DapiSubmission::AlreadyKnown] { + let client = Arc::new(FakeDapiClient::new(submission, Ok(false))); + let result = dapi_broadcaster(client.clone()) + .broadcast(&transaction()) + .await; + + assert!(result.is_ok()); + assert_eq!(client.lookup_calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn explicit_rejection_is_not_reconciled() { + let client = Arc::new(FakeDapiClient::new( + DapiSubmission::Rejected { + reason: "policy rejection".to_string(), + }, + Ok(true), + )); + let result = dapi_broadcaster(client.clone()) + .broadcast(&transaction()) + .await; + + assert!(matches!(result, Err(BroadcastError::Rejected { .. }))); + assert_eq!(client.lookup_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn uncertain_submission_is_accepted_only_when_lookup_finds_it() { + for (known, accepted) in [(Ok(true), true), (Ok(false), false)] { + let client = Arc::new(FakeDapiClient::new( + DapiSubmission::Uncertain { + reason: "timeout".to_string(), + }, + known, + )); + let result = dapi_broadcaster(client.clone()) + .broadcast(&transaction()) + .await; + + assert_eq!(result.is_ok(), accepted); + if !accepted { + assert!(matches!(result, Err(BroadcastError::MaybeSent { .. }))); + } + assert_eq!(client.lookup_calls.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn lookup_failure_keeps_uncertain_submission_unknown() { + let client = Arc::new(FakeDapiClient::new( + DapiSubmission::Uncertain { + reason: "timeout".to_string(), + }, + Err("lookup unavailable".to_string()), + )); + let result = dapi_broadcaster(client).broadcast(&transaction()).await; + + assert!(matches!(result, Err(BroadcastError::MaybeSent { .. }))); + } + + #[tokio::test] + async fn spv_relay_runs_only_after_core_acceptance() { + let accepted_client = Arc::new(FakeDapiClient::new(DapiSubmission::Accepted, Ok(false))); + let accepted_relay = Arc::new(RelaySpy::succeeding()); + let broadcaster = SpvBroadcaster::from_components( + dapi_broadcaster(accepted_client), + accepted_relay.clone(), + ); + assert!(broadcaster.broadcast(&transaction()).await.is_ok()); + assert_eq!(accepted_relay.calls.load(Ordering::SeqCst), 1); + + let rejected_client = Arc::new(FakeDapiClient::new( + DapiSubmission::Rejected { + reason: "rejected".to_string(), + }, + Ok(false), + )); + let rejected_relay = Arc::new(RelaySpy::succeeding()); + let broadcaster = SpvBroadcaster::from_components( + dapi_broadcaster(rejected_client), + rejected_relay.clone(), + ); + assert!(matches!( + broadcaster.broadcast(&transaction()).await, + Err(BroadcastError::Rejected { .. }) + )); + assert_eq!(rejected_relay.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn relay_failure_does_not_downgrade_core_acceptance() { + let client = Arc::new(FakeDapiClient::new(DapiSubmission::Accepted, Ok(false))); + let relay = Arc::new(RelaySpy::failing()); + let broadcaster = SpvBroadcaster::from_components(dapi_broadcaster(client), relay.clone()); + + assert!(broadcaster.broadcast(&transaction()).await.is_ok()); + assert_eq!(relay.calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn tonic_submission_codes_map_to_authoritative_states() { + use dash_sdk::dapi_grpc::tonic::Code; + + assert!(matches!( + classify_dapi_error(Some(Code::AlreadyExists), "known".to_string()), + DapiSubmission::AlreadyKnown + )); + for code in [Code::InvalidArgument, Code::FailedPrecondition] { + assert!(matches!( + classify_dapi_error(Some(code), "rejected".to_string()), + DapiSubmission::Rejected { .. } + )); + } + for code in [Code::DeadlineExceeded, Code::Unavailable] { + assert!(matches!( + classify_dapi_error(Some(code), "transport".to_string()), + DapiSubmission::Uncertain { .. } + )); + } + } + + #[test] + fn dapi_submission_disables_sdk_retries() { + assert_eq!(broadcast_request_settings().retries, Some(0)); + } + + #[test] + fn empty_or_mismatched_response_txid_requires_reconciliation() { + let expected = transaction().txid(); + for returned in [String::new(), Txid::all_zeros().to_string()] { + assert!(matches!( + classify_dapi_response(expected, returned), + DapiSubmission::Uncertain { .. } + )); + } + assert!(matches!( + classify_dapi_response(expected, expected.to_string()), + DapiSubmission::Accepted + )); } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c746fb802b..88e7bcc058 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -149,9 +149,10 @@ impl PlatformWalletManager

{ } inserted_in_manager.push(wallet_id); - let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( - &self.spv_manager, - ))); + let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new( + Arc::clone(&self.spv_manager), + Arc::clone(&self.sdk), + )); let platform_wallet = PlatformWallet::new( Arc::clone(&self.sdk), wallet_id, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 947121519b..dce340de3f 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -437,9 +437,10 @@ impl PlatformWalletManager

{ } // Build the PlatformWallet handle. - let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( - &self.spv_manager, - ))); + let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new( + Arc::clone(&self.spv_manager), + Arc::clone(&self.sdk), + )); let persister_dyn: Arc = Arc::clone(&self.persister) as _; let platform_wallet = PlatformWallet::new( diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 13d5d9cf5a..a628ca99df 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -36,28 +36,14 @@ pub struct SpvRuntime { task: Mutex>>, peer_tracker: Arc, } -/// Classify a dash-spv broadcast failure per the -/// [`TransactionBroadcaster::broadcast`] contract. +/// Classify a best-effort SPV relay/local-apply failure. /// -/// dash-spv's `broadcast_transaction` raises -/// `NetworkError::NotConnected` from its zero-connected-peers check -/// *before* handing the transaction to any peer, so it is the only -/// error safe to classify [`BroadcastError::Rejected`]; anything else -/// may follow a partial peer send and must stay -/// [`BroadcastError::MaybeSent`]. Pinned by the tests below so a -/// dash-spv semantic change is caught at this crate's boundary. -/// -/// [`TransactionBroadcaster::broadcast`]: crate::broadcaster::TransactionBroadcaster::broadcast +/// This method runs only after DAPI/Core has accepted the transaction, so an +/// SPV failure can never mean the transaction itself was rejected. The caller +/// logs this diagnostic and preserves the authoritative accepted result. fn classify_spv_broadcast_error(error: dash_spv::error::SpvError) -> BroadcastError { - use dash_spv::error::{NetworkError, SpvError}; - - match error { - SpvError::Network(NetworkError::NotConnected) => BroadcastError::Rejected { - reason: "SPV broadcast failed: no connected peers".to_string(), - }, - other => BroadcastError::MaybeSent { - reason: format!("SPV broadcast failed: {}", other), - }, + BroadcastError::MaybeSent { + reason: format!("SPV relay/local apply failed: {error}"), } } @@ -126,22 +112,15 @@ impl SpvRuntime { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) } - /// Broadcast a transaction to all connected SPV peers. - /// - /// Failures are classified per the [`TransactionBroadcaster::broadcast`] - /// contract: an unstarted client and dash-spv's zero-peer - /// `NetworkError::NotConnected` both fire before any bytes leave the - /// process, so they are [`BroadcastError::Rejected`]; any later failure - /// may follow a partial peer send and is [`BroadcastError::MaybeSent`]. - /// - /// [`TransactionBroadcaster::broadcast`]: crate::broadcaster::TransactionBroadcaster::broadcast + /// Relay a Core-accepted transaction to connected SPV peers and inject it + /// into dash-spv's local message pipeline. pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, ) -> Result<(), BroadcastError> { let client_guard = self.client.read().await; - let client = client_guard.as_ref().ok_or(BroadcastError::Rejected { - reason: "SPV client not started".to_string(), + let client = client_guard.as_ref().ok_or(BroadcastError::MaybeSent { + reason: "SPV relay/local apply skipped: client not started".to_string(), })?; client @@ -449,11 +428,10 @@ mod tests { } } - /// An unstarted SPV client fails before any bytes leave the process, - /// so the failure must classify `Rejected` (safe to release the - /// transaction's input reservation). + /// SPV runs only after Core acceptance, so an unavailable local relay can + /// never downgrade the transaction to rejected. #[tokio::test] - async fn broadcast_on_unstarted_client_is_rejected() { + async fn broadcast_on_unstarted_client_is_relay_failure() { let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( Network::Testnet, ))); @@ -461,23 +439,19 @@ mod tests { let result = runtime.broadcast_transaction(&dummy_tx()).await; assert!( - matches!(result, Err(BroadcastError::Rejected { .. })), - "unstarted client must classify Rejected, got {result:?}" + matches!(result, Err(BroadcastError::MaybeSent { .. })), + "unstarted client must stay a relay diagnostic, got {result:?}" ); } - /// dash-spv raises `NetworkError::NotConnected` from its - /// zero-connected-peers check before handing the transaction to any - /// peer, so it is the one client error safe to classify `Rejected`. - /// If dash-spv ever starts raising `NotConnected` after a partial - /// send, this pin must be revisited — releasing on a post-send - /// failure reopens the double-spend-on-retry window. + /// Even a zero-peer relay failure happens after authoritative Core + /// acceptance and therefore remains a best-effort relay diagnostic. #[test] - fn not_connected_classifies_rejected() { + fn not_connected_classifies_relay_failure() { let result = classify_spv_broadcast_error(SpvError::Network(NetworkError::NotConnected)); assert!( - matches!(result, BroadcastError::Rejected { .. }), - "NotConnected must classify Rejected, got {result:?}" + matches!(result, BroadcastError::MaybeSent { .. }), + "NotConnected must not downgrade Core acceptance, got {result:?}" ); } diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 8fd146dc77..9fa24250d3 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -270,8 +270,11 @@ pub async fn funded_spv_core_wallet( Arc::clone(&manager), Arc::new(crate::events::PlatformEventManager::new(Vec::new())), )); - let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new( + spv, + Arc::clone(&sdk), + )); ( crate::CoreWallet::new(sdk, manager, wallet_id, broadcaster, balance), signer, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 536f669a86..5ae16cf488 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -276,11 +276,12 @@ impl AssetLockManager { // broadcast once (that's what `Broadcast` means), so it may // still be in a mempool or already mined — in which case the // network reports "already known" / "already in block - // chain". The broadcaster can't distinguish that from a real - // rejection (DAPI classifies every failure as `MaybeSent`), - // so we log and proceed to `wait_for_proof` regardless - // rather than failing the resume on a tx that is actually - // fine. If the tx really was mined, `wait_for_proof` + // chain". DAPI reconciles ambiguous responses with + // `getTransaction`, but a resumed lock still treats a + // re-broadcast error as best-effort: the original submission + // may already be mined or known to a different node. We log + // and proceed to `wait_for_proof` regardless. If the tx really + // was mined, `wait_for_proof` // resolves immediately from the SPV/persisted record. if let Err(e) = self.broadcaster.broadcast(&tx).await { tracing::debug!( diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index b141af762a..171f6e6944 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -1676,7 +1676,7 @@ mod tests { let persister = WalletPersister::new(WALLET, recorder); let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); - let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv, Arc::clone(&sdk))); let asset_locks = Arc::new(AssetLockManager::new( Arc::clone(&sdk), Arc::clone(&wallet_manager), diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index 38db2fd492..7398d80470 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -1932,7 +1932,7 @@ mod auto_select_tests { let persister = WalletPersister::new([0u8; 32], Arc::new(NoPlatformPersistence)); let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); - let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv, Arc::clone(&sdk))); let asset_locks = Arc::new(AssetLockManager::new( Arc::clone(&sdk), Arc::clone(&wallet_manager), diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 1d9c7b26e6..15b6a07a43 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -773,7 +773,7 @@ mod tests { let persister = WalletPersister::new([0u8; 32], Arc::new(NoPlatformPersistence)); let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); - let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv, Arc::clone(&sdk))); let asset_locks = Arc::new(AssetLockManager::new( Arc::clone(&sdk), Arc::clone(&wallet_manager), diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index e1fef220d4..2fccd129b1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -1565,7 +1565,7 @@ mod plan_withdrawal_seam_tests { let persister = WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); - let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv, Arc::clone(&sdk))); let asset_locks = Arc::new(AssetLockManager::new( Arc::clone(&sdk), Arc::clone(&wallet_manager), diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 096a7c2af1..10b1cbebdf 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -4,8 +4,8 @@ //! funding account's `ReservationSet` and leaves the reservation held on //! success, expecting the transaction to be broadcast. When the broadcast //! *fails* the reservation must be reconciled here: released for an immediate -//! retry when the transaction provably never reached the network, kept (for -//! the reservation-TTL backstop or a later sync) when it may have. +//! retry when Core definitively rejected the transaction, kept (for the +//! reservation-TTL backstop or a later sync) when acceptance is unknown. //! //! Every build-then-broadcast path must go through //! [`broadcast_releasing_on_rejection`] so the cleanup exists once instead of @@ -25,8 +25,8 @@ use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// Broadcast `tx` and reconcile the funding account's UTXO reservation on /// failure. /// -/// On [`BroadcastError::Rejected`] — the transaction provably never reached -/// the network — the inputs reserved by the preceding `build_signed` are +/// On [`BroadcastError::Rejected`] — Core definitively did not accept the +/// transaction — the inputs reserved by the preceding `build_signed` are /// released so an immediate retry can reselect them instead of failing with /// spurious insufficient funds until the reservation-TTL backstop. On /// [`BroadcastError::MaybeSent`] the reservation is intentionally kept: diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 91d2367700..84ed938beb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -123,13 +123,13 @@ where Arc::new(crate::events::PlatformEventManager::new(Vec::new())), )); PlatformWallet::new( - sdk, + Arc::clone(&sdk), wallet_id, wallet_manager, balance, Arc::new(tokio::sync::Notify::new()), persister as Arc, - Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv, sdk)), ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 2684533c3b..553424f6a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -1,6 +1,44 @@ import Foundation import DashSDKFFI +/// Authoritative network outcome for a signed Core transaction. +/// +/// `accepted` means Dash Core accepted the transaction into its mempool or +/// already knows it; it does not mean the transaction is mined or +/// InstantSend-locked. `unknown` must not be retried automatically because the +/// Core response may have been lost after acceptance. +public enum CoreTransactionBroadcastOutcome: Equatable, Sendable { + case accepted(txid: String) + case rejected(txid: String, reason: String) + case unknown(txid: String, reason: String) + + public var txid: String { + switch self { + case .accepted(let txid), .rejected(let txid, _), .unknown(let txid, _): + return txid + } + } + + init( + resultCode: PlatformWalletResultCode, + txid: String, + reason: String + ) throws { + switch resultCode { + case .success: + self = .accepted(txid: txid) + case .errorTransactionBroadcastRejected: + self = .rejected(txid: txid, reason: reason) + case .errorTransactionBroadcastUnconfirmed: + self = .unknown(txid: txid, reason: reason) + default: + throw PlatformWalletError.unknown( + "Cannot create Core broadcast outcome from \(resultCode)" + ) + } + } +} + /// Core wallet for UTXO management, address derivation, and transaction broadcasting. /// /// Obtained via `ManagedPlatformWallet.coreWallet()`. @@ -104,42 +142,119 @@ public class ManagedCoreWallet { /// definitive broadcast rejection releases the UTXO reservation /// `buildSigned` took, letting an immediate retry reselect those inputs. /// - /// Returns the transaction ID as a hex string. - @available(*, deprecated, message: "Use the atomic FinalizedCoreTransaction send path") - public func broadcastTransaction(_ tx: CoreTransaction) throws -> String { + /// Returns the authoritative accepted/rejected/unknown network outcome. + /// Throws only for local or FFI failures that prevented an outcome from + /// being determined. + public func broadcastTransactionWithOutcome( + _ tx: CoreTransaction + ) throws -> CoreTransactionBroadcastOutcome { var txidPtr: UnsafeMutablePointer? = nil - try withUnsafePointer(to: tx.ffi) { txPtr in - try core_wallet_broadcast_transaction( + let ffiResult = withUnsafePointer(to: tx.ffi) { txPtr in + core_wallet_broadcast_transaction( handle, txPtr, tx.accountType.ffi, tx.accountIndex, &txidPtr - ).check() + ) } + let result = PlatformWalletResult(ffiResult) - guard let ptr = txidPtr else { - throw PlatformWalletError.nullPointer( - "core_wallet_broadcast_transaction returned a NULL txid pointer" + defer { + if let txidPtr { + platform_wallet_string_free(txidPtr) + } + } + + switch result.code { + case .success, .errorTransactionBroadcastRejected, + .errorTransactionBroadcastUnconfirmed: + guard let txidPtr else { + throw PlatformWalletError.nullPointer( + "core_wallet_broadcast_transaction returned a NULL txid pointer for \(result.code)" + ) + } + let txid = String(cString: txidPtr) + let reason = result.message ?? "" + + return try CoreTransactionBroadcastOutcome( + resultCode: result.code, + txid: txid, + reason: reason + ) + + default: + try result.throwIfError() + throw PlatformWalletError.unknown( + "core_wallet_broadcast_transaction returned an unexpected success state" ) } - defer { core_wallet_free_address(ptr) } // same free for C strings + } - return String(cString: ptr) + /// Compatibility wrapper preserving the former throwing API. + /// + /// New code should inspect `broadcastTransactionWithOutcome` so an unknown + /// outcome cannot be mistaken for a definitive rejection. + @available(*, deprecated, message: "Use broadcastTransactionWithOutcome(_:) and handle accepted/rejected/unknown") + public func broadcastTransaction(_ tx: CoreTransaction) throws -> String { + switch try broadcastTransactionWithOutcome(tx) { + case .accepted(let txid): + return txid + case .rejected(_, let reason): + throw PlatformWalletError.transactionBroadcastRejected(reason) + case .unknown(_, let reason): + throw PlatformWalletError.transactionBroadcastUnconfirmed(reason) + } } - /// Consume and broadcast an atomically finalized transaction. - public func broadcastTransaction(_ tx: FinalizedCoreTransaction) throws -> String { + /// Consume and broadcast an atomically finalized transaction, returning + /// the authoritative accepted/rejected/unknown network outcome. + public func broadcastTransactionWithOutcome( + _ tx: FinalizedCoreTransaction + ) throws -> CoreTransactionBroadcastOutcome { let transactionHandle = try tx.takeForBroadcast() var txidPtr: UnsafeMutablePointer? = nil - try core_wallet_broadcast_signed_transaction_v2( + let result = PlatformWalletResult(core_wallet_broadcast_signed_transaction_v2( handle, transactionHandle, &txidPtr - ).check() - guard let ptr = txidPtr else { - throw PlatformWalletError.nullPointer( - "core_wallet_broadcast_signed_transaction_v2 returned NULL" + )) + + defer { + if let txidPtr { + core_wallet_free_address(txidPtr) + } + } + + switch result.code { + case .success, .errorTransactionBroadcastRejected, + .errorTransactionBroadcastUnconfirmed: + guard let txidPtr else { + throw PlatformWalletError.nullPointer( + "core_wallet_broadcast_signed_transaction_v2 returned a NULL txid pointer for \(result.code)" + ) + } + return try CoreTransactionBroadcastOutcome( + resultCode: result.code, + txid: String(cString: txidPtr), + reason: result.message ?? "" + ) + + default: + try result.throwIfError() + throw PlatformWalletError.unknown( + "core_wallet_broadcast_signed_transaction_v2 returned an unexpected success state" ) } - defer { core_wallet_free_address(ptr) } - return String(cString: ptr) + } + + /// Compatibility wrapper preserving the former throwing API. + @available(*, deprecated, message: "Use broadcastTransactionWithOutcome(_:) and handle accepted/rejected/unknown") + public func broadcastTransaction(_ tx: FinalizedCoreTransaction) throws -> String { + switch try broadcastTransactionWithOutcome(tx) { + case .accepted(let txid): + return txid + case .rejected(_, let reason): + throw PlatformWalletError.transactionBroadcastRejected(reason) + case .unknown(_, let reason): + throw PlatformWalletError.transactionBroadcastUnconfirmed(reason) + } } /// Consume without sending and release its reservation immediately. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index e741d96244..3834ca71b4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -66,6 +66,9 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorAssetLockNotTracked = 23 case errorAssetLockAlreadyConsumed = 24 case errorAssetLockFundingMismatch = 25 + /// Core definitively rejected the transaction. Its reserved inputs were + /// released and a corrected transaction may be submitted again. + case errorTransactionBroadcastRejected = 26 case notFound = 98 case errorUnknown = 99 @@ -123,6 +126,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockAlreadyConsumed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_FUNDING_MISMATCH: self = .errorAssetLockFundingMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_REJECTED: + self = .errorTransactionBroadcastRejected case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -235,6 +240,9 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) + /// Core definitively rejected the transaction and its input reservation + /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. + case transactionBroadcastRejected(String) /// Definitively-failed address-nonce race (shield, or identity /// top-up-from-addresses): Platform rejected the transition because the /// submitted address nonce raced its expected value. The transition did @@ -262,6 +270,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), + .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), .notFound(let m), .unknown(let m): return m @@ -300,6 +309,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) + case .errorTransactionBroadcastRejected: + self = .transactionBroadcastRejected(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) case .notFound: self = .notFound(detail) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index a87f8d07a3..872e7bcb94 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -524,11 +524,11 @@ class SendViewModel: ObservableObject { accountType: .bip44, accountIndex: senderAccountIndex ) - // Broadcast lives on the core wallet; grab it locally. - let _ = try platformWallet.coreWallet().broadcastTransaction(signedTx) - successMessage = recipients.count > 1 - ? "Payment sent to \(recipients.count) recipients" - : "Payment sent" + // Core acceptance, rather than a successful peer socket write, + // is the boundary for showing payment success. + let outcome = try platformWallet.coreWallet() + .broadcastTransactionWithOutcome(signedTx) + applyCoreBroadcastOutcome(outcome, recipientCount: recipients.count) case .platformToPlatform: guard let addressWallet = platformAddressWallet else { @@ -831,4 +831,27 @@ class SendViewModel: ObservableObject { self.error = error.localizedDescription } } + + /// Apply the authoritative Core broadcast outcome to the send UI. Kept as + /// a small pure state transition so rejected/unknown can be regression + /// tested without constructing a live wallet or transaction. + func applyCoreBroadcastOutcome( + _ outcome: CoreTransactionBroadcastOutcome, + recipientCount: Int + ) { + switch outcome { + case .accepted: + error = nil + successMessage = recipientCount > 1 + ? "Payment sent to \(recipientCount) recipients" + : "Payment sent" + case .rejected(_, let reason): + successMessage = nil + error = "Payment rejected by Dash Core: \(reason)" + case .unknown(let txid, let reason): + successMessage = nil + error = "Payment status could not be verified (\(txid)). " + + "It may already have been accepted; do not retry. \(reason)" + } + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift index f5687d708e..0cace94131 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift @@ -186,4 +186,40 @@ final class SendViewModelCoreRecipientsTests: XCTestCase { XCTAssertEqual(vm.coreSendTotalDuffs, 100_000) XCTAssertTrue(vm.canSend) } + + // MARK: - Authoritative Core broadcast outcome + + func test_acceptedBroadcastOutcome_showsSuccess() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + + vm.applyCoreBroadcastOutcome(.accepted(txid: "abc"), recipientCount: 1) + + XCTAssertEqual(vm.successMessage, "Payment sent") + XCTAssertNil(vm.error) + } + + func test_rejectedBroadcastOutcome_doesNotShowSuccess() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + + vm.applyCoreBroadcastOutcome( + .rejected(txid: "abc", reason: "mempool policy"), + recipientCount: 1 + ) + + XCTAssertNil(vm.successMessage) + XCTAssertEqual(vm.error, "Payment rejected by Dash Core: mempool policy") + } + + func test_unknownBroadcastOutcome_warnsAgainstRetry() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + + vm.applyCoreBroadcastOutcome( + .unknown(txid: "abc", reason: "request timed out"), + recipientCount: 1 + ) + + XCTAssertNil(vm.successMessage) + XCTAssertTrue(vm.error?.contains("do not retry") == true) + XCTAssertTrue(vm.error?.contains("abc") == true) + } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 6c0c30afc3..1170520130 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -405,4 +405,43 @@ final class ErrorHandlingTests: XCTestCase { let failureResult: Result = .failure(TestError()) XCTAssertEqual(failureResult.errorMessage, "Test failure") } + + // MARK: - Core broadcast outcome mapping + + func testCoreBroadcastOutcomeMapping() throws { + XCTAssertEqual( + try CoreTransactionBroadcastOutcome( + resultCode: .success, + txid: "accepted-id", + reason: "" + ), + .accepted(txid: "accepted-id") + ) + XCTAssertEqual( + try CoreTransactionBroadcastOutcome( + resultCode: .errorTransactionBroadcastRejected, + txid: "rejected-id", + reason: "policy" + ), + .rejected(txid: "rejected-id", reason: "policy") + ) + XCTAssertEqual( + try CoreTransactionBroadcastOutcome( + resultCode: .errorTransactionBroadcastUnconfirmed, + txid: "unknown-id", + reason: "timeout" + ), + .unknown(txid: "unknown-id", reason: "timeout") + ) + } + + func testCoreBroadcastOutcomeRejectsOperationalResultCode() { + XCTAssertThrowsError( + try CoreTransactionBroadcastOutcome( + resultCode: .errorInvalidHandle, + txid: "unused", + reason: "invalid handle" + ) + ) + } }