diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index eeb5777d227..cec0966b9c2 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -134,6 +134,17 @@ pub enum PlatformWalletFFIResultCode { /// boundary) and try again. Distinct from `ErrorShieldedSpendUnconfirmed`, /// where a spend WAS broadcast and must NOT be retried. ErrorShieldedNoRecordedAnchor = 19, + /// Maps `PlatformWalletError::TransactionBroadcastUnconfirmed`. A core + /// transaction broadcast (send-to-addresses, DashPay payment, or + /// asset-lock funding) failed with an AMBIGUOUS outcome — 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 delivered). The wallet intentionally KEEPS the spent + /// inputs' UTXO reservation, so an immediate retry fails at input + /// selection instead of double-spending; the reservation TTL or a sync + /// observing the transaction reconciles the outcome. The host must NOT + /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. + ErrorTransactionBroadcastUnconfirmed = 20, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -268,6 +279,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::ShieldedNoRecordedAnchor(..) => { PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor } + // The core-transaction sibling of the shielded pair above: the + // do-not-retry signal must survive the boundary as a typed code + // so hosts can distinguish it from a definitive rejection. + PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -626,6 +643,27 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// The ambiguous core-broadcast outcome keeps its typed code across the + /// boundary — flattening it to `ErrorUnknown` would erase the + /// do-not-retry signal the variant exists to carry. + #[test] + fn transaction_broadcast_unconfirmed_maps_to_dedicated_code() { + let unconfirmed = PlatformWalletError::TransactionBroadcastUnconfirmed( + "gRPC deadline exceeded after delivery".to_string(), + ); + let rendered = unconfirmed.to_string(); + let result: PlatformWalletFFIResult = unconfirmed.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed, + "TransactionBroadcastUnconfirmed should map to its dedicated code (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(msg, rendered, "Display payload must survive verbatim"); + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 33f0539c2ad..522b9c7a4e4 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -84,6 +84,10 @@ required-features = ["shielded"] # `tests/shielded_decrypt_bench.rs` to assemble per-chunk wire # fixtures and decode the `ShieldedEncryptedNote` wire type. drive-proof-verifier = { path = "../rs-drive-proof-verifier" } +# `test-utils` exposes key-wallet's `TestWalletContext` + `Address::dummy` +# (via `dashcore/test-utils`) for building funded, signable test wallets. +# Dev-only, so the production build stays on the leaner default features. +key-wallet = { workspace = true, features = ["test-utils"] } rand = "0.8" # Drives the parallel decrypt benchmark in `shielded_decrypt_bench.rs`. rayon = "1.10" diff --git a/packages/rs-platform-wallet/src/broadcaster.rs b/packages/rs-platform-wallet/src/broadcaster.rs index eba802e0ee9..af9f625e4c8 100644 --- a/packages/rs-platform-wallet/src/broadcaster.rs +++ b/packages/rs-platform-wallet/src/broadcaster.rs @@ -15,12 +15,59 @@ 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. +/// +/// 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. +#[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}")] + 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}")] + MaybeSent { reason: String }, +} + +impl From for PlatformWalletError { + fn from(error: BroadcastError) -> Self { + match error { + BroadcastError::Rejected { reason } => { + PlatformWalletError::TransactionBroadcast(reason) + } + BroadcastError::MaybeSent { reason } => { + PlatformWalletError::TransactionBroadcastUnconfirmed(reason) + } + } + } +} + /// Broadcasts a signed transaction to the Dash network. /// /// Implementations may use DAPI (gRPC), SPV (P2P peers), or Core RPC. #[async_trait] pub trait TransactionBroadcaster: Send + Sync { - async fn broadcast(&self, transaction: &Transaction) -> Result; + /// 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. + async fn broadcast(&self, transaction: &Transaction) -> Result; } /// Broadcasts transactions via Platform's DAPI gRPC endpoint. @@ -38,7 +85,7 @@ impl DapiBroadcaster { #[async_trait] impl TransactionBroadcaster for DapiBroadcaster { - async fn broadcast(&self, transaction: &Transaction) -> Result { + async fn broadcast(&self, transaction: &Transaction) -> Result { use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; use dash_sdk::dapi_grpc::core::v0::BroadcastTransactionRequest; use dashcore::consensus; @@ -51,13 +98,24 @@ impl TransactionBroadcaster for DapiBroadcaster { 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| { - PlatformWalletError::TransactionBroadcast(format!("DAPI broadcast failed: {}", e)) + .map_err(|e| BroadcastError::MaybeSent { + reason: format!("DAPI broadcast failed: {}", e), })?; Ok(transaction.txid()) @@ -79,7 +137,7 @@ impl SpvBroadcaster { #[async_trait] impl TransactionBroadcaster for SpvBroadcaster { - async fn broadcast(&self, transaction: &Transaction) -> Result { + async fn broadcast(&self, transaction: &Transaction) -> Result { self.spv.broadcast_transaction(transaction).await?; Ok(transaction.txid()) } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5948f88ea57..30c6ead44f4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -60,6 +60,22 @@ pub enum PlatformWalletError { #[error("Transaction broadcast failed: {0}")] TransactionBroadcast(String), + /// A core transaction broadcast failed with an **ambiguous** outcome — the + /// transaction may already have reached the network (transport timeout + /// after delivery, partial peer send, or an internal multi-node retry + /// whose earlier attempt may have succeeded). The spent inputs' + /// reservation is intentionally kept, so an immediate retry fails at + /// input selection instead of double-spending; the reservation-TTL + /// backstop (or a sync observing the transaction) reconciles the outcome. + /// + /// The shielded sibling is [`Self::ShieldedSpendUnconfirmed`]. + #[error( + "Transaction broadcast outcome unknown — it may already be on the \ + network; its inputs stay reserved until a sync or the reservation \ + TTL reconciles the outcome: {0}" + )] + TransactionBroadcastUnconfirmed(String), + #[error("Transaction building failed: {0}")] TransactionBuild(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 289a71378fd..04d3ef3a2d2 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -19,6 +19,8 @@ pub mod error; pub mod events; pub mod manager; pub mod spv; +#[cfg(test)] +pub(crate) mod test_support; pub mod wallet; pub use error::PlatformWalletError; diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index d2c06742546..5f63ac27e19 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -15,6 +15,7 @@ use dash_spv::{ClientConfig, DashSpvClient, EventHandler, Hash}; use key_wallet_manager::WalletManager; +use crate::broadcaster::BroadcastError; use crate::error::PlatformWalletError; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -32,6 +33,31 @@ pub struct SpvRuntime { client: RwLock>, task: Mutex>>, } +/// Classify a dash-spv broadcast failure per the +/// [`TransactionBroadcaster::broadcast`] contract. +/// +/// 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 +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), + }, + } +} + // TODO: We want it better impl SpvRuntime { /// Create a new SPV runtime. @@ -92,19 +118,27 @@ impl SpvRuntime { } /// 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 pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, - ) -> Result<(), PlatformWalletError> { + ) -> Result<(), BroadcastError> { let client_guard = self.client.read().await; - let client = client_guard.as_ref().ok_or(PlatformWalletError::SpvError( - "SPV Client not started".to_string(), - ))?; + let client = client_guard.as_ref().ok_or(BroadcastError::Rejected { + reason: "SPV client not started".to_string(), + })?; client .broadcast_transaction(tx) .await - .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + .map_err(classify_spv_broadcast_error)?; Ok(()) } @@ -285,3 +319,81 @@ impl std::fmt::Debug for SpvRuntime { .finish() } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dash_spv::error::{NetworkError, SpvError}; + use dashcore::Network; + use key_wallet_manager::WalletManager; + use tokio::sync::RwLock; + + use super::{classify_spv_broadcast_error, SpvRuntime}; + use crate::broadcaster::BroadcastError; + use crate::events::PlatformEventManager; + use crate::wallet::platform_wallet::PlatformWalletInfo; + + /// A minimal valid transaction — the unstarted-client arm never + /// inspects it. + fn dummy_tx() -> dashcore::Transaction { + dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// 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). + #[tokio::test] + async fn broadcast_on_unstarted_client_is_rejected() { + let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( + Network::Testnet, + ))); + let runtime = SpvRuntime::new(wallet_manager, Arc::new(PlatformEventManager::new(vec![]))); + + let result = runtime.broadcast_transaction(&dummy_tx()).await; + assert!( + matches!(result, Err(BroadcastError::Rejected { .. })), + "unstarted client must classify Rejected, 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. + #[test] + fn not_connected_classifies_rejected() { + let result = classify_spv_broadcast_error(SpvError::Network(NetworkError::NotConnected)); + assert!( + matches!(result, BroadcastError::Rejected { .. }), + "NotConnected must classify Rejected, got {result:?}" + ); + } + + /// Every other dash-spv error may follow a partial peer send and must + /// stay `MaybeSent`, keeping the reservation for the TTL backstop. + #[test] + fn any_other_spv_error_classifies_maybe_sent() { + for error in [ + SpvError::Network(NetworkError::Timeout), + SpvError::Network(NetworkError::PeerDisconnected), + SpvError::Network(NetworkError::ConnectionFailed("reset by peer".to_string())), + SpvError::Config("bad config".to_string()), + ] { + let rendered = error.to_string(); + let result = classify_spv_broadcast_error(error); + assert!( + matches!(result, BroadcastError::MaybeSent { .. }), + "{rendered} must classify MaybeSent, got {result:?}" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs new file mode 100644 index 00000000000..4b7f525cd24 --- /dev/null +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -0,0 +1,183 @@ +//! Shared unit-test scaffolding: mock broadcasters, a seed-backed signer, +//! and a funded in-memory wallet manager. +//! +//! Used by the broadcast-failure regression tests in `wallet::core::broadcast` +//! and `wallet::asset_lock::build`. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1}; +use dashcore::{Network, Transaction, Txid}; +use key_wallet::account::account_type::StandardAccountType; +use key_wallet::signer::{Signer, SignerMethod}; +use key_wallet::test_utils::TestWalletContext; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::{DerivationPath, Wallet}; +use key_wallet_manager::WalletManager; +use tokio::sync::RwLock; + +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use crate::wallet::core::WalletBalance; +use crate::wallet::identity::IdentityManager; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; + +/// Broadcaster whose first call fails with a definitive pre-send rejection +/// and which succeeds afterwards, to model a transient broadcast error +/// followed by a user retry. +pub(crate) struct RejectFirstBroadcaster { + failed_once: AtomicBool, +} + +impl RejectFirstBroadcaster { + pub(crate) fn new() -> Self { + Self { + failed_once: AtomicBool::new(false), + } + } +} + +#[async_trait] +impl TransactionBroadcaster for RejectFirstBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + if self.failed_once.swap(true, Ordering::SeqCst) { + Ok(transaction.txid()) + } else { + Err(BroadcastError::Rejected { + reason: "simulated pre-send rejection".to_string(), + }) + } + } +} + +/// Broadcaster that always fails with a definitive pre-send rejection. +pub(crate) struct AlwaysRejectedBroadcaster; + +#[async_trait] +impl TransactionBroadcaster for AlwaysRejectedBroadcaster { + async fn broadcast(&self, _transaction: &Transaction) -> Result { + Err(BroadcastError::Rejected { + reason: "simulated pre-send rejection".to_string(), + }) + } +} + +/// Broadcaster that always fails with an *ambiguous* result — the network +/// may already have accepted the transaction — so its inputs must NOT be +/// released on failure. +pub(crate) struct AlwaysMaybeSentBroadcaster; + +#[async_trait] +impl TransactionBroadcaster for AlwaysMaybeSentBroadcaster { + async fn broadcast(&self, _transaction: &Transaction) -> Result { + Err(BroadcastError::MaybeSent { + reason: "simulated ambiguous broadcast".to_string(), + }) + } +} + +/// Soft signer that derives keys straight from a test wallet's seed. Stands +/// in for the FFI keychain-backed signer used in production. +pub(crate) struct WalletSigner { + wallet: Wallet, +} + +#[async_trait] +impl Signer for WalletSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + let secp = Secp256k1::new(); + let key = self + .wallet + .derive_private_key(path) + .map_err(|e| e.to_string())?; + let message = Message::from_digest(sighash); + Ok(( + secp.sign_ecdsa(&message, &key), + PublicKey::from_secret_key(&secp, &key), + )) + } + + async fn public_key(&self, path: &DerivationPath) -> Result { + let secp = Secp256k1::new(); + let key = self + .wallet + .derive_private_key(path) + .map_err(|e| e.to_string())?; + Ok(PublicKey::from_secret_key(&secp, &key)) + } +} + +/// Builds a testnet wallet manager whose `account_type`/index-0 account +/// holds a single spendable UTXO (10_000_000 duffs) — the whole balance +/// rides on that one input, so a leaked reservation strands it. Returns +/// the manager, the wallet id, the shared balance handle, and a soft +/// signer over the wallet's seed. +pub(crate) async fn funded_wallet_manager( + account_type: StandardAccountType, +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, +) { + let mut ctx = TestWalletContext::new_random(); + + // `new_random()` already derives a BIP44 receive address; only the + // BIP32 arm needs a hand-rolled derivation. + let receive_address = match account_type { + StandardAccountType::BIP44Account => ctx.receive_address.clone(), + StandardAccountType::BIP32Account => { + let xpub = ctx + .wallet + .accounts + .standard_bip32_accounts + .get(&0) + .expect("bip32 account") + .account_xpub; + ctx.managed_wallet + .first_bip32_managed_account_mut() + .expect("bip32 managed account") + .next_receive_address(Some(&xpub), true) + .expect("bip32 receive address") + } + }; + + let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); + let result = ctx + .check_transaction(&funding_tx, TransactionContext::Mempool) + .await; + assert!( + result.is_relevant, + "funding tx should be relevant to {account_type:?}" + ); + assert!(result.is_new_transaction); + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + + let balance = Arc::new(WalletBalance::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + balance: Arc::clone(&balance), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + + (Arc::new(RwLock::new(wm)), wallet_id, balance, signer) +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 72cb5eae582..887aa08dc53 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -345,8 +345,40 @@ impl AssetLockManager { "Asset lock tracked as Built and queued for persistence; broadcasting." ); - // 3. Broadcast. - self.broadcaster.broadcast(&tx).await?; + // 3. Broadcast. On a definitive pre-send rejection, untrack the + // `Built` row BEFORE releasing the funding reservation (the + // asset-lock builder funds from the BIP44 account at + // `account_index`): while the reservation is held the inputs + // cannot be re-selected by a new build, and once the row is gone + // `resume_asset_lock` can no longer re-drive the rejected + // transaction — so at no point is the row resumable while its + // inputs are re-spendable. A `MaybeSent` failure keeps both the + // reservation and the resumable row. + if let Err(e) = self.broadcaster.broadcast(&tx).await { + if matches!(e, crate::broadcaster::BroadcastError::Rejected { .. }) { + let cs_untrack = self.untrack_asset_lock(&out_point).await; + // Release only when the Built row was actually removed. If + // the untrack guard fired instead — a concurrent + // `resume_asset_lock` advanced the row past `Built`, positive + // evidence the transaction reached the network after all — + // the inputs must stay reserved exactly like a `MaybeSent` + // outcome, or the still-tracked row would be resumable while + // its inputs are re-spendable. + let removed_built_row = cs_untrack.removed.contains(&out_point); + self.queue_asset_lock_changeset(cs_untrack); + if removed_built_row { + crate::wallet::reservations::release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + key_wallet::account::account_type::StandardAccountType::BIP44Account, + account_index, + &tx, + ) + .await; + } + } + return Err(e.into()); + } // 4. Transition to Broadcast and queue the changeset. let cs_broadcast = self @@ -380,3 +412,328 @@ impl AssetLockManager { Ok((proof, path, out_point)) } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use dashcore::OutPoint; + use key_wallet::account::account_type::StandardAccountType; + use tokio::sync::Notify; + + use async_trait::async_trait; + use dashcore::{Transaction, Txid}; + use key_wallet_manager::WalletManager; + use tokio::sync::RwLock; + + use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::test_support::{ + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + use crate::wallet::platform_wallet::PlatformWalletInfo; + use crate::wallet::platform_wallet::WalletId; + use crate::{AssetLockFundingType, PlatformWalletError}; + + /// Persistence stub that records every stored changeset so tests can + /// assert what the asset-lock flow queued. + #[derive(Default)] + struct CapturingPersistence { + stored: Mutex>, + } + + impl CapturingPersistence { + /// Outpoints queued for persisted-row deletion across all stored + /// changesets. + fn removed_outpoints(&self) -> Vec { + self.stored + .lock() + .expect("capturing persistence mutex") + .iter() + .filter_map(|cs| cs.asset_locks.as_ref()) + .flat_map(|al| al.removed.iter().copied()) + .collect() + } + } + + impl PlatformWalletPersistence for CapturingPersistence { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stored + .lock() + .expect("capturing persistence mutex") + .push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// Builds an `AssetLockManager` over the shared BIP44-funded fixture. + async fn funded_asset_lock_manager( + broadcaster: Arc, + ) -> ( + Arc>, + WalletSigner, + Arc, + ) { + let (wallet_manager, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let persistence = Arc::new(CapturingPersistence::default()); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(AssetLockManager::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster, + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + )); + + (manager, signer, persistence) + } + + /// A definitively rejected asset-lock broadcast must untrack the `Built` + /// row (in-memory and via the changeset's `removed` set) and release the + /// funding reservation, so nothing can resume the dead transaction and a + /// fresh funding attempt can reselect the inputs immediately. + #[tokio::test] + async fn rejected_asset_lock_broadcast_untracks_row_and_releases_reservation() { + let (manager, signer, persistence) = + funded_asset_lock_manager(Arc::new(AlwaysRejectedBroadcaster)).await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), + "rejected broadcast should surface as TransactionBroadcast, got {result:?}" + ); + + // The Built row is gone in memory… + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + assert!( + info.tracked_asset_locks.is_empty(), + "rejected lock must be untracked, got {:?}", + info.tracked_asset_locks + ); + } + // …and its persisted row was queued for deletion. + assert_eq!( + persistence.removed_outpoints().len(), + 1, + "exactly the rejected lock's outpoint should be queued as removed" + ); + + // The funding reservation was released: a fresh build over the same + // single-UTXO wallet can reselect the inputs immediately. + let rebuild = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + rebuild.is_ok(), + "rebuild after a rejected broadcast should reselect the released \ + inputs, got {rebuild:?}" + ); + } + + /// An *ambiguous* asset-lock broadcast failure must keep both the funding + /// reservation and the resumable `Built` row: the transaction may already + /// be propagating, so a retry must not double-spend and a resume must + /// stay possible. + #[tokio::test] + async fn ambiguous_asset_lock_broadcast_keeps_reservation_and_built_row() { + let (manager, signer, persistence) = + funded_asset_lock_manager(Arc::new(AlwaysMaybeSentBroadcaster)).await; + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!( + result, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) + ), + "ambiguous broadcast should surface as TransactionBroadcastUnconfirmed, got {result:?}" + ); + + // The Built row survives for a later resume… + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + assert_eq!(info.tracked_asset_locks.len(), 1); + let lock = info.tracked_asset_locks.values().next().expect("built row"); + assert_eq!(lock.status, AssetLockStatus::Built); + } + // …no persisted-row deletion was queued… + assert!( + persistence.removed_outpoints().is_empty(), + "ambiguous failure must not queue a row deletion" + ); + + // …and the reservation is kept: a fresh build cannot reselect the + // single reserved UTXO and fails at input selection. + let rebuild = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(rebuild, Err(PlatformWalletError::AssetLockTransaction(_))), + "rebuild must fail at input selection while the reservation is \ + kept, got {rebuild:?}" + ); + } + + /// Broadcaster that simulates the racing interleave the release gate + /// exists for: "during" the broadcast a concurrent `resume_asset_lock` + /// advances the tracked row to `Broadcast`, then the original call still + /// comes back `Rejected`. The advanced row is positive evidence the + /// transaction reached the network, so the cleanup must keep it AND keep + /// the funding reservation. + struct RejectAfterConcurrentResumeBroadcaster { + wallet_manager: Arc>>, + wallet_id: WalletId, + } + + #[async_trait] + impl TransactionBroadcaster for RejectAfterConcurrentResumeBroadcaster { + async fn broadcast(&self, _transaction: &Transaction) -> Result { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet present"); + let lock = info + .tracked_asset_locks + .values_mut() + .next() + .expect("Built row tracked before broadcast"); + lock.status = AssetLockStatus::Broadcast; + drop(wm); + Err(BroadcastError::Rejected { + reason: "simulated rejection racing a concurrent resume".to_string(), + }) + } + } + + /// If a concurrent resume advanced the row past `Built` in the rejection + /// window, the cleanup must keep the row (guard) AND keep the funding + /// reservation (release gate) — otherwise the still-tracked transaction + /// would be resumable while its inputs are re-spendable. + #[tokio::test] + async fn rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation() { + let (wallet_manager, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let broadcaster = Arc::new(RejectAfterConcurrentResumeBroadcaster { + wallet_manager: Arc::clone(&wallet_manager), + wallet_id, + }); + let persistence = Arc::new(CapturingPersistence::default()); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + broadcaster, + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + )); + + let result = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), + "rejection should still surface, got {result:?}" + ); + + // The concurrently-advanced row survives the cleanup… + { + let wm = wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + assert_eq!(info.tracked_asset_locks.len(), 1); + let lock = info.tracked_asset_locks.values().next().expect("row kept"); + assert_eq!(lock.status, AssetLockStatus::Broadcast); + } + // …no persisted-row deletion was queued… + assert!( + persistence.removed_outpoints().is_empty(), + "advanced row must not be queued for deletion" + ); + + // …and the reservation was NOT released: a fresh build cannot + // reselect the single reserved UTXO. + let rebuild = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(rebuild, Err(PlatformWalletError::AssetLockTransaction(_))), + "rebuild must fail at input selection while the reservation is \ + kept for the advanced row, got {rebuild:?}" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index a3611e32ccb..1fef4fe1700 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -25,6 +25,44 @@ impl AssetLockManager { cs } + /// Remove a tracked asset lock whose funding transaction was + /// definitively rejected at broadcast (never reached the network). + /// + /// Unlike [`consume_asset_lock`](Self::consume_asset_lock), the + /// persisted row is deleted too (via the changeset's `removed` set): + /// a rejected lock's funding transaction never existed on the + /// network, so the row has no historical value — and because the + /// rejection released the funding UTXO reservation, leaving the + /// `Built` row resumable would let a later `resume_asset_lock` + /// re-broadcast a transaction whose inputs may have been re-spent. + /// + /// Idempotent: returns an empty changeset if the outpoint is not + /// tracked. Guarded on the row still being + /// [`Built`](AssetLockStatus::Built): if a concurrent flow advanced it + /// (e.g. a `resume_asset_lock` that re-broadcast in the window between + /// the rejected broadcast and this cleanup), the progress is kept + /// rather than clobbered. The caller queues the changeset (call sites + /// live in `asset_lock/build.rs`, inside the module). + pub(crate) async fn untrack_asset_lock(&self, out_point: &OutPoint) -> AssetLockChangeSet { + let mut wm = self.wallet_manager.write().await; + let mut cs = AssetLockChangeSet::default(); + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + match info.tracked_asset_locks.get(out_point) { + Some(entry) if entry.status == AssetLockStatus::Built => { + info.tracked_asset_locks.remove(out_point); + cs.removed.insert(*out_point); + } + Some(entry) => tracing::warn!( + outpoint = %out_point, + status = ?entry.status, + "untrack_asset_lock: lock advanced past Built concurrently — leaving it tracked" + ), + None => {} + } + } + cs + } + /// Mark a tracked asset lock as /// [`Consumed`](AssetLockStatus::Consumed) after a successful /// identity registration or top-up. diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 4609d1fb6d2..a15ce6db8a3 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -4,6 +4,7 @@ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::signer::Signer; use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::reservations::broadcast_releasing_on_rejection; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { @@ -21,7 +22,10 @@ impl CoreWallet { &self, transaction: &Transaction, ) -> Result { - self.broadcaster.broadcast(transaction).await + self.broadcaster + .broadcast(transaction) + .await + .map_err(Into::into) } /// Build, sign, and broadcast a payment to the given addresses. @@ -142,7 +146,121 @@ impl CoreWallet { tx }; - self.broadcast_transaction(&tx).await?; + broadcast_releasing_on_rejection( + self.broadcaster.as_ref(), + &self.wallet_manager, + &self.wallet_id, + account_type, + account_index, + &tx, + ) + .await?; Ok(tx) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dashcore::{Address as DashAddress, Network}; + use key_wallet::account::account_type::StandardAccountType; + + use crate::broadcaster::TransactionBroadcaster; + use crate::test_support::{ + funded_wallet_manager, AlwaysMaybeSentBroadcaster, RejectFirstBroadcaster, WalletSigner, + }; + use crate::wallet::core::CoreWallet; + use crate::PlatformWalletError; + + /// Builds a testnet `CoreWallet` over the shared funded fixture and a + /// 1_000_000-duff payment to a dummy recipient. + async fn funded_core_wallet( + account_type: StandardAccountType, + broadcaster: Arc, + ) -> (CoreWallet, WalletSigner, Vec<(DashAddress, u64)>) { + let (wallet_manager, wallet_id, balance, signer) = + funded_wallet_manager(account_type).await; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new(sdk, wallet_manager, wallet_id, broadcaster, balance); + + let recipient = DashAddress::dummy(Network::Testnet, 42); + let outputs = vec![(recipient, 1_000_000u64)]; + + (core, signer, outputs) + } + + /// A pre-send broadcast failure must release the UTXO reservation taken while + /// building the transaction, so an immediate retry can reselect those inputs + /// instead of failing with spurious insufficient funds until the TTL backstop. + /// Covers both funds-account arms of the release path. + #[tokio::test] + async fn send_to_addresses_releases_reservation_on_broadcast_failure() { + for account_type in [ + StandardAccountType::BIP44Account, + StandardAccountType::BIP32Account, + ] { + let broadcaster = Arc::new(RejectFirstBroadcaster::new()); + let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; + + // First attempt: build + sign succeed, broadcast fails. + let first = core + .send_to_addresses(account_type, 0, outputs.clone(), &signer) + .await; + assert!( + matches!(first, Err(PlatformWalletError::TransactionBroadcast(_))), + "first send should surface the broadcast failure for {account_type:?}, got {first:?}" + ); + + // Immediate retry: only succeeds if the failed broadcast released the + // reservation. With the leak, coin selection sees no spendable UTXO and + // this fails with a build error instead. + let second = core + .send_to_addresses(account_type, 0, outputs, &signer) + .await; + assert!( + second.is_ok(), + "retry after a failed broadcast should succeed once the reservation \ + is released for {account_type:?}, got {second:?}" + ); + } + } + + /// An *ambiguous* broadcast failure — the network may already have accepted + /// the transaction — must NOT release the reservation: retrying would risk a + /// double-spend. The reservation is kept, so an immediate retry fails at the + /// build stage (no spendable UTXO) rather than reaching broadcast again. + #[tokio::test] + async fn send_to_addresses_keeps_reservation_on_ambiguous_broadcast_failure() { + for account_type in [ + StandardAccountType::BIP44Account, + StandardAccountType::BIP32Account, + ] { + let broadcaster = Arc::new(AlwaysMaybeSentBroadcaster); + let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; + + let first = core + .send_to_addresses(account_type, 0, outputs.clone(), &signer) + .await; + assert!( + matches!( + first, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) + ), + "first send should surface the ambiguous failure for {account_type:?}, got {first:?}" + ); + + // Reservation kept: the retry cannot reselect the reserved input and + // fails while building, never reaching the broadcaster again. + let second = core + .send_to_addresses(account_type, 0, outputs, &signer) + .await; + assert!( + matches!(second, Err(PlatformWalletError::TransactionBuild(_))), + "retry after an ambiguous failure must fail at build with the reservation \ + kept for {account_type:?}, got {second:?}" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index c135e04e6fc..ac19102794f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -205,12 +205,17 @@ impl IdentityWallet { (payment_address, tx) }; - // --- 3. Broadcast the transaction. --- - let txid = self - .broadcaster - .broadcast(&tx) - .await - .map_err(|e| PlatformWalletError::TransactionBroadcast(e.to_string()))?; + // --- 3. Broadcast the transaction, releasing the build's UTXO + // reservation if the broadcast is definitively rejected pre-send. --- + let txid = crate::wallet::reservations::broadcast_releasing_on_rejection( + self.broadcaster.as_ref(), + &self.wallet_manager, + &self.wallet_id, + key_wallet::account::account_type::StandardAccountType::BIP44Account, + 0, + &tx, + ) + .await?; tracing::info!( from_identity = %from_identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index a6d10726fc1..edf707d8c83 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -6,6 +6,7 @@ pub mod persister; pub mod platform_addresses; pub mod platform_wallet; mod platform_wallet_traits; +pub(crate) mod reservations; #[cfg(feature = "shielded")] pub mod shielded; pub mod tokens; diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs new file mode 100644 index 00000000000..096a7c2af12 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -0,0 +1,107 @@ +//! Broadcast-side UTXO reservation cleanup. +//! +//! `TransactionBuilder::build_signed` reserves the selected UTXOs in the +//! 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. +//! +//! Every build-then-broadcast path must go through +//! [`broadcast_releasing_on_rejection`] so the cleanup exists once instead of +//! per call site — except paths with rejection-specific cleanup of their own +//! that must run *before* the release (the asset-lock flow untracks its +//! `Built` row first); those call the broadcaster directly and then +//! [`release_reservation_after_rejected_broadcast`]. + +use dashcore::{Transaction, Txid}; +use key_wallet::account::account_type::StandardAccountType; +use key_wallet_manager::WalletManager; +use tokio::sync::RwLock; + +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +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 +/// 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: +/// releasing inputs of a transaction that may already be propagating invites +/// a double-spend on retry. +/// +/// `account_type`/`account_index` identify the funding account whose +/// `ReservationSet` holds the inputs — the same account handed to +/// `set_funding` when the transaction was built. +/// +/// Returns the still-typed [`BroadcastError`]; `?` converts it into +/// [`PlatformWalletError`](crate::PlatformWalletError) at the call sites. +pub(crate) async fn broadcast_releasing_on_rejection( + broadcaster: &B, + wallet_manager: &RwLock>, + wallet_id: &WalletId, + account_type: StandardAccountType, + account_index: u32, + tx: &Transaction, +) -> Result { + match broadcaster.broadcast(tx).await { + Ok(txid) => Ok(txid), + Err(e) => { + if matches!(e, BroadcastError::Rejected { .. }) { + release_reservation_after_rejected_broadcast( + wallet_manager, + wallet_id, + account_type, + account_index, + tx, + ) + .await; + } + Err(e) + } + } +} + +/// Release the funding account's UTXO reservation for `tx` after its +/// broadcast came back [`BroadcastError::Rejected`]. +/// +/// Callers that pair the release with other rejection cleanup must order +/// that cleanup **before** this call when it removes state a concurrent +/// flow could act on — while the reservation is still held the inputs +/// cannot be re-selected by a new build, so the pre-release window is +/// safe. +pub(crate) async fn release_reservation_after_rejected_broadcast( + wallet_manager: &RwLock>, + wallet_id: &WalletId, + account_type: StandardAccountType, + account_index: u32, + tx: &Transaction, +) { + // `release_reservation` takes `&self` and the manager map is + // untouched, so a read lock suffices — this cleanup does not + // serialize concurrent sends. + let wm = wallet_manager.read().await; + let account = wm + .get_wallet_and_info(wallet_id) + .and_then(|(_, info)| match account_type { + StandardAccountType::BIP44Account => info + .core_wallet + .bip44_managed_account_at_index(account_index), + StandardAccountType::BIP32Account => info + .core_wallet + .bip32_managed_account_at_index(account_index), + }); + match account { + Some(account) => account.release_reservation(tx), + None => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + account_index, + "could not release UTXO reservation after rejected broadcast: \ + wallet or funds account not found" + ), + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 45b570e091f..9676ee38143 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -46,6 +46,13 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// shielded sync reach a confirmed state and try again. Distinct from /// `errorShieldedSpendUnconfirmed`, which must NOT be retried. case errorShieldedNoRecordedAnchor = 19 + /// A core transaction broadcast (send, DashPay payment, or asset-lock + /// funding) failed with an ambiguous outcome — the transaction may + /// already be on the network. The wallet keeps the spent inputs' UTXO + /// reservation, so an immediate retry fails at input selection instead + /// of double-spending; the reservation TTL or a sync reconciles the + /// outcome. Do NOT auto-retry. + case errorTransactionBroadcastUnconfirmed = 20 case notFound = 98 case errorUnknown = 99 @@ -91,6 +98,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedSpendUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_NO_RECORDED_ANCHOR: self = .errorShieldedNoRecordedAnchor + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_UNCONFIRMED: + self = .errorTransactionBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -193,6 +202,12 @@ public enum PlatformWalletError: LocalizedError { /// sync reaches a confirmed state. Distinct from `shieldedSpendUnconfirmed`, /// which must NOT be retried. case shieldedNoRecordedAnchor(String) + /// A core transaction broadcast was submitted but its outcome is + /// unknown — the transaction may already be on the network. The wallet + /// keeps the spent inputs reserved so a retry cannot double-spend; the + /// reservation TTL or a later sync reconciles the outcome. Do NOT + /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. + case transactionBroadcastUnconfirmed(String) case notFound(String) case unknown(String) @@ -209,6 +224,7 @@ public enum PlatformWalletError: LocalizedError { .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), + .transactionBroadcastUnconfirmed(let m), .notFound(let m), .unknown(let m): return m } @@ -240,6 +256,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) case .errorShieldedSpendUnconfirmed: self = .shieldedSpendUnconfirmed(detail) case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) + case .errorTransactionBroadcastUnconfirmed: + self = .transactionBroadcastUnconfirmed(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }