Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions monero-tests/tests/sweep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down
39 changes: 38 additions & 1 deletion monero-wallet-ng/src/retry.rs
Original file line number Diff line number Diff line change
@@ -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 _;

Expand Down Expand Up @@ -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<T, E, F, Fut>(
inner_retry: Option<backoff::ExponentialBackoff>,
description: &'static str,
mut operation: F,
) -> Result<T, E>
where
E: Debug,
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
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
}
55 changes: 40 additions & 15 deletions monero-wallet-ng/src/sweep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -94,6 +95,7 @@ pub async fn construct_sweep_tx_to_single<P>(
private_view_key: Zeroizing<Scalar>,
tx_id: [u8; 32],
destination: MoneroAddress,
inner_retry: Option<backoff::ExponentialBackoff>,
) -> Result<Transaction<NotPruned>, SweepError>
where
P: ProvidesScannableBlocks
Expand All @@ -110,6 +112,7 @@ where
private_view_key,
tx_id,
vec![(destination, 1.0)],
inner_retry,
)
.await
}
Expand All @@ -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<P>(
provider: P,
private_spend_key: Zeroizing<Scalar>,
private_view_key: Zeroizing<Scalar>,
tx_id: [u8; 32],
destinations: Vec<(MoneroAddress, f64)>,
inner_retry: Option<backoff::ExponentialBackoff>,
) -> Result<Transaction<NotPruned>, SweepError>
where
P: ProvidesScannableBlocks
Expand All @@ -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?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry budget starts too early

Medium Severity

When construct_sweep_tx_to uses inner_retry with max_elapsed_time, that limit is measured from when the caller built the backoff, not from the sweep’s RPC steps. Work before the first with_retry (for example rpc_client().await in construct_sweep_to) and local scanning between steps still advance the same elapsed clock, so daemon retries can stop early even though the 45s budget was meant for those requests.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 950658c. Configure here.

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 }),
Expand All @@ -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()
Expand All @@ -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]);
Expand Down
4 changes: 4 additions & 0 deletions monero-wallet/src/wallets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ impl Wallets {
spend_key: monero_oxide_ext::PrivateKey,
view_key: PrivateViewKey,
destinations: Vec<(monero_address::MoneroAddress, f64)>,
inner_retry: Option<backoff::ExponentialBackoff>,
) -> Result<Transaction<NotPruned>> {
let rpc_client = self.rpc_client().await;
let tx_id = tx_hash_to_bytes(lock_tx_hash)?;
Expand All @@ -334,6 +335,7 @@ impl Wallets {
view_scalar,
tx_id,
destinations,
inner_retry,
)
.await
.context("Failed to construct sweep transaction to destinations")
Expand All @@ -346,6 +348,7 @@ impl Wallets {
spend_key: monero_oxide_ext::PrivateKey,
view_key: PrivateViewKey,
destination: monero_address::MoneroAddress,
inner_retry: Option<backoff::ExponentialBackoff>,
) -> Result<Transaction<NotPruned>> {
let rpc_client = self.rpc_client().await;
let tx_id = tx_hash_to_bytes(lock_tx_hash)?;
Expand All @@ -359,6 +362,7 @@ impl Wallets {
view_scalar,
tx_id,
destination,
inner_retry,
)
.await
.context("Failed to construct sweep transaction to destination")
Expand Down
8 changes: 7 additions & 1 deletion swap/src/protocol/alice/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;

Expand Down
5 changes: 5 additions & 0 deletions swap/src/protocol/bob/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
Loading