diff --git a/monero-tests/tests/sweep.rs b/monero-tests/tests/sweep.rs index cbb7ff672f..00be6dc2a6 100644 --- a/monero-tests/tests/sweep.rs +++ b/monero-tests/tests/sweep.rs @@ -57,6 +57,7 @@ async fn sweep_moves_largest_output_to_destination() -> anyhow::Result<()> { source_view, funding_txid, vec![(dest_address, 1.0)], + None, ) .await?; daemon.publish_transaction(&signed).await?; @@ -121,6 +122,7 @@ async fn sweep_splits_output_across_multiple_destinations() -> anyhow::Result<() source_view, funding_txid, vec![(dest_a_address, ratio_a), (dest_b_address, ratio_b)], + None, ) .await?; daemon.publish_transaction(&signed).await?; diff --git a/monero-wallet-ng/src/retry.rs b/monero-wallet-ng/src/retry.rs index 01764e83fa..b09a9eb119 100644 --- a/monero-wallet-ng/src/retry.rs +++ b/monero-wallet-ng/src/retry.rs @@ -1,6 +1,6 @@ //! Retry utilities with exponential backoff. -use std::{fmt::Debug, time::Duration}; +use std::{fmt::Debug, future::Future, time::Duration}; use backoff::backoff::Backoff as _; @@ -44,3 +44,40 @@ impl Default for Backoff { Self::new() } } + +/// Run an operation, optionally retrying transient failures with exponential backoff. +/// +/// When `inner_retry` is `Some`, every error returned by `operation` is treated +/// as transient and retried according to the exponential-backoff policy until it +/// gives up, at which point the final error is returned. +/// When `None`, `operation` is attempted exactly once and any error is returned immediately. +pub async fn with_retry( + inner_retry: Option, + description: &'static str, + mut operation: F, +) -> Result +where + E: Debug, + F: FnMut() -> Fut, + Fut: Future>, +{ + let Some(backoff) = inner_retry else { + return operation().await; + }; + + backoff::future::retry_notify( + backoff, + || { + let attempt = operation(); + async move { attempt.await.map_err(backoff::Error::transient) } + }, + |err, retry_after: Duration| { + tracing::warn!( + error = ?err, + retry_after_secs = retry_after.as_secs(), + "{description} failed; retrying" + ); + }, + ) + .await +} diff --git a/monero-wallet-ng/src/sweep.rs b/monero-wallet-ng/src/sweep.rs index 29ab441caf..2ef7823d6b 100644 --- a/monero-wallet-ng/src/sweep.rs +++ b/monero-wallet-ng/src/sweep.rs @@ -18,6 +18,7 @@ use monero_oxide_wallet::send::{Change, SendError, SignableTransaction}; use monero_oxide_wallet::transaction::{NotPruned, Transaction}; use monero_oxide_wallet::{OutputWithDecoys, Scanner, ViewPair, ViewPairError}; +use crate::retry::with_retry; use crate::rpc::{ProvidesTransactionStatus, TransactionStatus, TransactionStatusError}; use crate::util::public_key; use crate::{MAX_FEE_PER_WEIGHT, RING_LEN}; @@ -94,6 +95,7 @@ pub async fn construct_sweep_tx_to_single

( private_view_key: Zeroizing, tx_id: [u8; 32], destination: MoneroAddress, + inner_retry: Option, ) -> Result, SweepError> where P: ProvidesScannableBlocks @@ -110,6 +112,7 @@ where private_view_key, tx_id, vec![(destination, 1.0)], + inner_retry, ) .await } @@ -119,12 +122,16 @@ where /// Looks up which block contains `tx_id` via the provider, scans that block /// for outputs belonging to `tx_id`, selects the largest output, /// and constructs a transaction that sweeps it across `destinations` split by ratio. +/// +/// As this internally does multiple network requests sequentially, +/// those are retried according to the `inner_retry` policy. pub async fn construct_sweep_tx_to

( provider: P, private_spend_key: Zeroizing, private_view_key: Zeroizing, tx_id: [u8; 32], destinations: Vec<(MoneroAddress, f64)>, + inner_retry: Option, ) -> Result, SweepError> where P: ProvidesScannableBlocks @@ -140,7 +147,11 @@ where } // Locate the block that contains the transaction - let block_height = match provider.transaction_status(tx_id).await? { + let status = with_retry(inner_retry.clone(), "Sweep transaction-status lookup", || { + async { provider.transaction_status(tx_id).await } + }) + .await?; + let block_height = match status { TransactionStatus::InBlock { block_height } => block_height as usize, TransactionStatus::InPool => return Err(SweepError::TransactionInMempool { tx_id }), TransactionStatus::Unknown => return Err(SweepError::TransactionNotFound { tx_id }), @@ -156,9 +167,14 @@ where // Find the largest output belonging to the transaction let largest_output = { - let blocks = provider - .contiguous_scannable_blocks(block_height..=block_height) - .await?; + let blocks = with_retry(inner_retry.clone(), "Sweep block fetch", || { + async { + provider + .contiguous_scannable_blocks(block_height..=block_height) + .await + } + }) + .await?; let block = blocks .into_iter() .next() @@ -173,20 +189,29 @@ where }; // Generate decoys for the input - let block_number = provider.latest_block_number().await?; - let input = OutputWithDecoys::new( - &mut OsRng, - &provider, - RING_LEN, - block_number, - largest_output, - ) + let block_number = with_retry(inner_retry.clone(), "Sweep latest-block-number lookup", || { + async { provider.latest_block_number().await } + }) + .await?; + let input = with_retry(inner_retry.clone(), "Sweep decoy selection", || { + async { + OutputWithDecoys::new( + &mut OsRng, + &provider, + RING_LEN, + block_number, + largest_output.clone(), + ) + .await + } + }) .await?; // Get the fee rate - let fee_rate = provider - .fee_rate(FeePriority::Normal, MAX_FEE_PER_WEIGHT) - .await?; + let fee_rate = with_retry(inner_retry, "Sweep fee-rate lookup", || { + async { provider.fee_rate(FeePriority::Normal, MAX_FEE_PER_WEIGHT).await } + }) + .await?; // Generate a random outgoing view key let mut outgoing_view_key = Zeroizing::new([0u8; 32]); diff --git a/monero-wallet/src/wallets.rs b/monero-wallet/src/wallets.rs index 782a8449d2..46306b85e2 100644 --- a/monero-wallet/src/wallets.rs +++ b/monero-wallet/src/wallets.rs @@ -321,6 +321,7 @@ impl Wallets { spend_key: monero_oxide_ext::PrivateKey, view_key: PrivateViewKey, destinations: Vec<(monero_address::MoneroAddress, f64)>, + inner_retry: Option, ) -> Result> { let rpc_client = self.rpc_client().await; let tx_id = tx_hash_to_bytes(lock_tx_hash)?; @@ -334,6 +335,7 @@ impl Wallets { view_scalar, tx_id, destinations, + inner_retry, ) .await .context("Failed to construct sweep transaction to destinations") @@ -346,6 +348,7 @@ impl Wallets { spend_key: monero_oxide_ext::PrivateKey, view_key: PrivateViewKey, destination: monero_address::MoneroAddress, + inner_retry: Option, ) -> Result> { let rpc_client = self.rpc_client().await; let tx_id = tx_hash_to_bytes(lock_tx_hash)?; @@ -359,6 +362,7 @@ impl Wallets { view_scalar, tx_id, destination, + inner_retry, ) .await .context("Failed to construct sweep transaction to destination") diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 9cd586ce9d..a5120940fa 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -1022,7 +1022,13 @@ impl XmrRefundable for State3 { tracing::debug!(%swap_id, %main_address, "Sweeping lock output to redeem address"); let tx = monero_wallet - .construct_sweep_to_single(&transfer_proof.tx_hash(), spend_key, view_key, main_address) + .construct_sweep_to_single( + &transfer_proof.tx_hash(), + spend_key, + view_key, + main_address, + None, + ) .await .context("Failed to construct Monero refund transaction")?; diff --git a/swap/src/protocol/bob/common.rs b/swap/src/protocol/bob/common.rs index 2f6208b1f7..7f19c85c2d 100644 --- a/swap/src/protocol/bob/common.rs +++ b/swap/src/protocol/bob/common.rs @@ -52,12 +52,17 @@ impl XmrRedeemable for State5 { "Sweeping lock output across receive pool" ); + let inner_retry = backoff::ExponentialBackoffBuilder::new() + .with_max_elapsed_time(Some(std::time::Duration::from_secs(45))) + .build(); + let tx = monero_wallet .construct_sweep_to( &self.lock_transfer_proof.tx_hash(), spend_key, view_key, destinations, + Some(inner_retry), ) .await .context("Failed to construct Monero redeem transaction")?;