From b0bf8d35ef7d67dd5c29e52b26b0270d2d549050 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Feb 2026 19:33:35 +0100 Subject: [PATCH] release 260209 --- .../FeeRateProviders/FeeRateProvider.cs | 301 +++++++++++++++--- .../MempoolSpaceFeeRateProvider.cs | 26 +- WalletWasabi.Daemon/Global.cs | 8 +- .../Rpc/WasabiJsonRpcService.cs | 3 +- .../Helpers/TransactionFeeHelper.cs | 4 +- .../Send/ViewModels/SendFeeViewModel.cs | 8 +- .../Send/ViewModels/SendViewModel.cs | 8 +- .../ViewModels/TransactionPreviewViewModel.cs | 242 +++++++++----- .../IntegrationTests/TorTests.cs | 4 +- .../RegressionTests/ReceiveSpeedupTests.cs | 4 +- .../FeesEstimation/IWalletFeeRateProvider.cs | 4 +- WalletWasabi/Helpers/Constants.cs | 4 +- .../Assets/LegalDocumentsGingerWallet.txt | 21 +- .../Wasabi/WasabiHttpClientFactory.cs | 7 +- 14 files changed, 500 insertions(+), 144 deletions(-) diff --git a/WalletWasabi.Daemon/FeeRateProviders/FeeRateProvider.cs b/WalletWasabi.Daemon/FeeRateProviders/FeeRateProvider.cs index ce40d567d1..d8e79abaed 100644 --- a/WalletWasabi.Daemon/FeeRateProviders/FeeRateProvider.cs +++ b/WalletWasabi.Daemon/FeeRateProviders/FeeRateProvider.cs @@ -1,24 +1,48 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; using NBitcoin; using Nito.AsyncEx; using WalletWasabi.Blockchain.Analysis.FeesEstimation; using WalletWasabi.WebClients.Wasabi; using GingerCommon.Logging; using WabiSabi.Helpers; -using System.Diagnostics.CodeAnalysis; namespace WalletWasabi.Daemon.FeeRateProviders; -public class FeeRateProvider : IWalletFeeRateProvider +/// +/// Provides fee rate estimates with automatic background refresh capability. +/// Inherits from BackgroundService for lifecycle management. +/// Thread-safe implementation with configurable refresh intervals. +/// +public class FeeRateProvider : BackgroundService, IWalletFeeRateProvider { private IFeeRateProvider? _feeRateProvider; private readonly AsyncLock _lock = new(); + private readonly object _cacheLock = new(); private readonly WasabiHttpClientFactory _httpClientFactory; private readonly Network _network; private readonly TaskCompletionSource _initialized = new(); + private readonly SemaphoreSlim _refreshTrigger = new(0, 1); + private readonly object _fastRefreshLock = new(); + + private AllFeeEstimate? _allFeeEstimate; + private DateTimeOffset? _lastRefresh; + + private volatile bool _isFastRefresh; + private volatile bool _disposed; + + /// + /// Normal refresh interval (10 minutes). + /// + private static readonly TimeSpan NormalRefreshInterval = TimeSpan.FromMinutes(10); + + /// + /// Fast refresh interval used during transactions (1 minute). + /// + private static readonly TimeSpan FastRefreshInterval = TimeSpan.FromMinutes(1); public FeeRateProvider(WasabiHttpClientFactory httpClientFactory, FeeRateProviderSource provider, Network network) { @@ -30,15 +54,47 @@ public FeeRateProvider(WasabiHttpClientFactory httpClientFactory, FeeRateProvide public FeeRateProviderSource Provider { get; private set; } /// + /// Gets or sets whether fast refresh mode is enabled. + /// When true, refreshes every minute. When false, refreshes every 10 minutes. + /// Changing this value will interrupt the current wait and apply the new interval. + /// Thread-safe property. + /// + public bool IsFastRefresh + { + get => _isFastRefresh; + set + { + ThrowIfDisposed(); + + lock (_fastRefreshLock) + { + if (_isFastRefresh != value) + { + _isFastRefresh = value; + Logger.LogInfo($"Fee rate refresh mode changed to: {(value ? "Fast (1 min)" : "Normal (10 min)")}"); + + if (value) + { + // Interrupt current wait to apply new interval immediately + TriggerImmediateRefresh(); + } + } + } + } + } + + /// + /// Initializes the fee rate provider based on configuration. /// Full node initialization happens later so we initialize here. + /// Must be called before the service starts. /// - /// + /// Optional RPC fee rate provider for full node mode. public void Initialize(RpcFeeRateProvider? rpcFeeRateProvider) { + ThrowIfDisposed(); + try { - // We always respect the user choice, otherwise throw error. - if (_network == Network.RegTest) { _feeRateProvider = new RegTestFeeRateProvider(); @@ -63,7 +119,7 @@ public void Initialize(RpcFeeRateProvider? rpcFeeRateProvider) { if (Provider != FeeRateProviderSource.MempoolSpace) { - Logging.Logger.LogWarning($"{nameof(FeeRateProvider)} config is missing or errorneus - falling back to '{FeeRateProviderSource.MempoolSpace}'."); + Logging.Logger.LogWarning($"{nameof(FeeRateProvider)} config is missing or erroneous - falling back to '{FeeRateProviderSource.MempoolSpace}'."); Provider = FeeRateProviderSource.MempoolSpace; } _feeRateProvider = new MempoolSpaceFeeRateProvider(_httpClientFactory, _network); @@ -80,77 +136,248 @@ public void Initialize(RpcFeeRateProvider? rpcFeeRateProvider) /// /// Used for tests. /// - public FeeRateProvider(WasabiHttpClientFactory httpClientFactory, Network network) : this(httpClientFactory, FeeRateProviderSource.BlockstreamInfo, network) + public FeeRateProvider(WasabiHttpClientFactory httpClientFactory, Network network) + : this(httpClientFactory, FeeRateProviderSource.BlockstreamInfo, network) { Initialize(null); } + /// + /// Gets the current fee estimate from cache. + /// Returns immediately without blocking - safe to call from UI thread. + /// Thread-safe synchronous access to cached values. + /// + /// Cached fee estimate. + /// If provider is not initialized or cache is empty. + /// If provider is disposed. public AllFeeEstimate GetAllFeeEstimate() { + ThrowIfDisposed(); + if (_feeRateProvider is null) { - throw new InvalidOperationException($"{nameof(FeeRateProvider)} is null."); + throw new InvalidOperationException($"{nameof(FeeRateProvider)} is null. Call Initialize first."); } - using (_lock.Lock()) + // Synchronous cache read with dedicated lock + lock (_cacheLock) { - var task = Task.Run(async () => await GetCacheAsync(CancellationToken.None).ConfigureAwait(false)); - task.Wait(); - return task.Result; + return GetCachedValueOrThrow(); } } - public async Task GetAllFeeEstimateAsync(CancellationToken cancellationToken) + /// + /// Triggers an immediate refresh by interrupting the current wait period. + /// The refresh loop will immediately proceed to the next refresh cycle. + /// Non-blocking - returns immediately. + /// Thread-safe and can be called multiple times safely. + /// + public void TriggerImmediateRefresh() { - if (_feeRateProvider is null) + if (_disposed) { - throw new InvalidOperationException($"{nameof(FeeRateProvider)} is null."); + return; // Silently ignore if disposed } - using (await _lock.LockAsync(cancellationToken).ConfigureAwait(false)) + try + { + // Release semaphore to signal immediate refresh + // If already released (CurrentCount == 1), this will be ignored + if (_refreshTrigger.CurrentCount == 0) + { + _refreshTrigger.Release(); + Logger.LogDebug("Immediate refresh triggered."); + } + } + catch (ObjectDisposedException) + { + // Semaphore disposed during shutdown, ignore + } + catch (SemaphoreFullException) { - return await GetCacheAsync(cancellationToken).ConfigureAwait(false); + // Already signaled, ignore } } - public void TriggerRefresh() + /// + /// Returns the cached fee estimate value or throws if not available. + /// Must be called within a lock. + /// + /// Cached fee estimate. + /// If cache is not initialized. + private AllFeeEstimate GetCachedValueOrThrow() { - Task.Run(() => GetCacheAsync(CancellationToken.None, true)); + if (_allFeeEstimate is null) + { + throw new InvalidOperationException("Fee rate cache is not initialized. Waiting for first refresh cycle."); + } + + return _allFeeEstimate; } - private AllFeeEstimate? _allFeeEstimate; - private DateTimeOffset? _lastFee; + /// + /// Refreshes the fee rate cache from the provider. + /// + /// Cancellation token. + private async Task RefreshCacheAsync(CancellationToken cancellationToken) + { + // Wait for initialization to complete + await _initialized.Task.ConfigureAwait(false); + + if (_feeRateProvider is null) + { + throw new InvalidOperationException($"{nameof(FeeRateProvider)} is null. Cannot refresh."); + } + + // Use async lock for the actual refresh operation + using (await _lock.LockAsync(cancellationToken).ConfigureAwait(false)) + { + var result = await _feeRateProvider.GetFeeRatesAsync(cancellationToken).ConfigureAwait(false); + + // Update cache atomically with simple lock + lock (_cacheLock) + { + _lastRefresh = DateTimeOffset.UtcNow; + _allFeeEstimate = result; + } + + Logger.LogDebug($"Fee rates refreshed successfully at {_lastRefresh:yyyy-MM-dd HH:mm:ss}"); + } + } - private async Task GetCacheAsync(CancellationToken cancellationToken, bool forceRefresh = false) + /// + /// Main background refresh loop - executed by BackgroundService. + /// Continuously refreshes fee rates at configured intervals. + /// Can be interrupted for immediate refresh via TriggerImmediateRefresh(). + /// + /// Cancellation token for stopping the service. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Guard.NotNull(nameof(_feeRateProvider), _feeRateProvider); + Logger.LogInfo("Fee rate refresh loop started."); - if (!NeedsRefresh() && !forceRefresh) + // Wait for initialization before starting + try + { + await _initialized.Task.ConfigureAwait(false); + } + catch (Exception ex) { - return _allFeeEstimate; + Logger.LogError($"Initialization failed: {ex}"); + return; } - await _initialized.Task.ConfigureAwait(false); + // Perform initial refresh immediately + try + { + await RefreshCacheAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Logger.LogInfo("Fee rate refresh loop cancelled during initial refresh."); + return; + } + catch (Exception ex) + { + Logger.LogError($"Initial fee rate refresh failed: {ex}"); + } - var result = await _feeRateProvider.GetFeeRatesAsync(cancellationToken).ConfigureAwait(false); - _lastFee = DateTimeOffset.Now; - _allFeeEstimate = result; - return result; + // Main refresh loop + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Determine current refresh interval based on mode + var interval = _isFastRefresh ? FastRefreshInterval : NormalRefreshInterval; + + // Wait for interval OR immediate refresh trigger OR stopping + var waitTask = _refreshTrigger.WaitAsync(interval, stoppingToken); + + try + { + await waitTask.ConfigureAwait(false); + + // If we got here, immediate refresh was triggered + Logger.LogDebug("Wait period interrupted for immediate refresh."); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // Timeout expired (normal interval), proceed to refresh + } + + // Check if we're stopping before attempting refresh + if (stoppingToken.IsCancellationRequested) + { + break; + } + + // Perform the refresh + await RefreshCacheAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Service is stopping + break; + } + catch (Exception ex) + { + Logger.LogError($"Error during fee rate refresh: {ex}"); + + // Add a delay before retry to prevent tight loop on persistent errors + try + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + Logger.LogInfo("Fee rate refresh loop stopped."); } - [MemberNotNullWhen(false, nameof(_allFeeEstimate))] - private bool NeedsRefresh() + /// + /// Throws ObjectDisposedException if disposed. + /// + private void ThrowIfDisposed() { - if (_lastFee is null || _allFeeEstimate is null) + if (_disposed) { - return true; + throw new ObjectDisposedException(nameof(FeeRateProvider)); } + } - if (DateTimeOffset.UtcNow - _lastFee < TimeSpan.FromMinutes(1)) + /// + /// Disposes resources when the service is stopped. + /// + public override void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + _refreshTrigger?.Dispose(); + } + catch (Exception ex) + { + Logger.LogError($"Error disposing refresh trigger: {ex}"); + } + + try + { + base.Dispose(); + } + catch (Exception ex) { - return false; + Logger.LogError($"Error in base.Dispose: {ex}"); } - return true; + Logger.LogInfo($"{nameof(FeeRateProvider)} disposed."); } } diff --git a/WalletWasabi.Daemon/FeeRateProviders/MempoolSpaceFeeRateProvider.cs b/WalletWasabi.Daemon/FeeRateProviders/MempoolSpaceFeeRateProvider.cs index 53a1f1a9bc..37bccdd779 100644 --- a/WalletWasabi.Daemon/FeeRateProviders/MempoolSpaceFeeRateProvider.cs +++ b/WalletWasabi.Daemon/FeeRateProviders/MempoolSpaceFeeRateProvider.cs @@ -17,6 +17,7 @@ namespace WalletWasabi.Daemon.FeeRateProviders; public class MempoolSpaceFeeRateProvider : IFeeRateProvider { private const string ApiUrl = "https://mempool.space/api/v1/"; + private const string OnionApiUrl = "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/"; private const string TestNetApiUrl = "https://mempool.space/testnet/api/v1/"; // Define the mappings between JSON property names and target blocks @@ -32,17 +33,22 @@ private static readonly (string JsonKey, int TargetBlocks)[] FeeMappings = public MempoolSpaceFeeRateProvider(WasabiHttpClientFactory httpClientFactory, Network network) { - string apiUrl = network switch + if (network == Network.Main) { - _ when network == Network.Main => ApiUrl, - _ when network == Network.TestNet => TestNetApiUrl, - _ => throw new NotSupportedException($"Unsupported network: {network}") - }; - - // Mempool testnet from Tor works unreliable - 503 (Service Unavailable). - HttpClient = httpClientFactory.NewHttpClient( - () => new Uri(apiUrl), - Tor.Socks5.Pool.Circuits.Mode.NewCircuitPerRequest); + // Mempool works only from onion if you use Tor, exit nodes are timed out. + HttpClient = httpClientFactory.IsTorEnabled + ? httpClientFactory.NewHttpClient(() => new Uri(OnionApiUrl), Tor.Socks5.Pool.Circuits.Mode.NewCircuitPerRequest) + : httpClientFactory.NewClearnetHttpClient(() => new Uri(ApiUrl)); + } + else if (network == Network.TestNet) + { + // Mempool testnet from Tor works unreliable - 503 (Service Unavailable). + HttpClient = httpClientFactory.NewClearnetHttpClient(() => new Uri(TestNetApiUrl)); + } + else + { + throw new NotSupportedException($"Unsupported network: {network}"); + } } public async Task GetFeeRatesAsync(CancellationToken cancellationToken) diff --git a/WalletWasabi.Daemon/Global.cs b/WalletWasabi.Daemon/Global.cs index 3ee3942245..c7675171f5 100644 --- a/WalletWasabi.Daemon/Global.cs +++ b/WalletWasabi.Daemon/Global.cs @@ -119,7 +119,8 @@ public Global(string dataDir, string configFilePath, Config config, UiConfig uiC }, friendlyName: "Bitcoin P2P Network"); - FeeRateProvider = new FeeRateProvider(HttpClientFactory, Config.FeeRateEstimationProvider, Network); + HostedServices.Register(() => new FeeRateProvider(HttpClientFactory, Config.FeeRateEstimationProvider, Network), friendlyName: "FeeRateProvider"); + var feeRateProvider = HostedServices.Get(); // Block providers. SpecificNodeBlockProvider = new SpecificNodeBlockProvider(Network, Config.ServiceConfiguration, HttpClientFactory.TorEndpoint); @@ -135,7 +136,7 @@ public Global(string dataDir, string configFilePath, Config config, UiConfig uiC new P2PBlockProvider(P2PNodesManager)); HostedServices.Register(() => new UnconfirmedTransactionChainProvider(HttpClientFactory), friendlyName: "Unconfirmed Transaction Chain Provider"); - WalletFactory walletFactory = new(DataDir, config.Network, BitcoinStore, wasabiSynchronizer, config.ServiceConfiguration, FeeRateProvider, BlockDownloadService, HostedServices.Get()); + WalletFactory walletFactory = new(DataDir, config.Network, BitcoinStore, wasabiSynchronizer, config.ServiceConfiguration, feeRateProvider, BlockDownloadService, HostedServices.Get()); WalletDirectories walletDirectories = new(Config.Network, DataDir); TwoFactorAuthenticationService = new TwoFactorAuthenticationService(walletDirectories, HttpClientFactory.SharedWasabiClient); @@ -197,7 +198,6 @@ public Global(string dataDir, string configFilePath, Config config, UiConfig uiC public TwoFactorAuthenticationService TwoFactorAuthenticationService { get; } private BuySellClient BuysellClient { get; } - public FeeRateProvider FeeRateProvider { get; } private WasabiHttpClientFactory BuildHttpClientFactory(Func backendUriGetter) => new( @@ -249,7 +249,7 @@ public async Task InitializeNoWalletAsync(bool initializeSleepInhibitor, Termina await StartLocalBitcoinNodeAsync(cancel).ConfigureAwait(false); var rpcFeeProvider = BitcoinCoreNode is null ? null : new RpcFeeRateProvider(BitcoinCoreNode.RpcClient); - FeeRateProvider.Initialize(rpcFeeProvider); + HostedServices.Get().Initialize(rpcFeeProvider); await BlockDownloadService.StartAsync(cancel).ConfigureAwait(false); diff --git a/WalletWasabi.Daemon/Rpc/WasabiJsonRpcService.cs b/WalletWasabi.Daemon/Rpc/WasabiJsonRpcService.cs index 99c0f180b9..f232cadbbd 100644 --- a/WalletWasabi.Daemon/Rpc/WasabiJsonRpcService.cs +++ b/WalletWasabi.Daemon/Rpc/WasabiJsonRpcService.cs @@ -13,6 +13,7 @@ using WalletWasabi.Blockchain.TransactionBuilding; using WalletWasabi.Blockchain.TransactionOutputs; using WalletWasabi.Blockchain.Transactions; +using WalletWasabi.Daemon.FeeRateProviders; using WalletWasabi.Extensions; using WalletWasabi.Helpers; using WalletWasabi.Models; @@ -521,7 +522,7 @@ public void StopCoinJoining() [JsonRpcMethod("getfeerates", initializable: false)] public object GetFeeRate() { - if (Global.FeeRateProvider.GetAllFeeEstimate() is { } nonNullFeeRates) + if (Global.HostedServices.Get().GetAllFeeEstimate() is { } nonNullFeeRates) { return nonNullFeeRates.Estimations; } diff --git a/WalletWasabi.Fluent/Helpers/TransactionFeeHelper.cs b/WalletWasabi.Fluent/Helpers/TransactionFeeHelper.cs index 40723ccfaf..eb42d489ac 100644 --- a/WalletWasabi.Fluent/Helpers/TransactionFeeHelper.cs +++ b/WalletWasabi.Fluent/Helpers/TransactionFeeHelper.cs @@ -92,14 +92,14 @@ public static bool TryGetFeeEstimates(IWalletFeeRateProvider feeProvider, Networ return false; } - public static async Task GetFeeEstimatesAsync(IWalletFeeRateProvider feeProvider, Network network, CancellationToken token) + public static AllFeeEstimate GetFeeEstimates(IWalletFeeRateProvider feeProvider, Network network) { if (network == Network.TestNet || network == Network.RegTest) { return TestNetFeeEstimates; } - return await feeProvider.GetAllFeeEstimateAsync(token); + return feeProvider.GetAllFeeEstimate(); } public static TimeSpan CalculateConfirmationTime(double targetBlock) diff --git a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendFeeViewModel.cs b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendFeeViewModel.cs index 7879c01357..f99e97f0ca 100644 --- a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendFeeViewModel.cs +++ b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendFeeViewModel.cs @@ -97,6 +97,11 @@ protected override void OnNavigatedTo(bool isInHistory, CompositeDisposable disp base.OnNavigatedTo(isInHistory, disposables); + if (isInHistory) + { + return; + } + RxApp.MainThreadScheduler.Schedule(async () => { try @@ -126,12 +131,13 @@ private async Task RefreshFeeChartAsync(CancellationToken cancellationToken) try { - feeEstimates = await TransactionFeeHelper.GetFeeEstimatesAsync(_wallet.FeeProvider, _wallet.Network, cancelTokenSource.Token); + feeEstimates = TransactionFeeHelper.GetFeeEstimates(_wallet.FeeProvider, _wallet.Network); } catch (Exception ex) { Logger.LogInfo(ex); await FeeEstimationsAreNotAvailableAsync(); + OnNext(); return; } diff --git a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendViewModel.cs b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendViewModel.cs index cde1bc2d07..b0b9f21d76 100644 --- a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendViewModel.cs +++ b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/SendViewModel.cs @@ -202,7 +202,7 @@ private async Task OnPasteAsync(bool pasteIfInvalid = true) private IPayjoinClient? GetPayjoinClient(string? endPoint) { if (!string.IsNullOrWhiteSpace(endPoint) && - Uri.IsWellFormedUriString(endPoint, UriKind.Absolute)) + Uri.IsWellFormedUriString(endPoint, UriKind.Absolute)) { var payjoinEndPointUri = new Uri(endPoint); if (Services.Config.UseTor != TorMode.Disabled) @@ -361,10 +361,7 @@ protected override void OnNavigatedTo(bool inHistory, CompositeDisposable dispos RxApp.MainThreadScheduler.Schedule(async () => await OnAutoPasteAsync()); - Observable - .Timer(TimeSpan.Zero, TimeSpan.FromSeconds(10), RxApp.TaskpoolScheduler) - .Subscribe(_ => _wallet.FeeProvider.TriggerRefresh()) - .DisposeWith(disposables); + _wallet.FeeProvider.IsFastRefresh = true; base.OnNavigatedTo(inHistory, disposables); } @@ -376,6 +373,7 @@ protected override void OnNavigatedFrom(bool isInHistory) if (!isInHistory && _coinJoinManager is { } coinJoinManager) { coinJoinManager.WalletLeftSendWorkflow(_wallet); + _wallet.FeeProvider.IsFastRefresh = false; } } } diff --git a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/TransactionPreviewViewModel.cs b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/TransactionPreviewViewModel.cs index 277dec0cc8..0a6d8466b5 100644 --- a/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/TransactionPreviewViewModel.cs +++ b/WalletWasabi.Fluent/HomeScreen/Send/ViewModels/TransactionPreviewViewModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reactive.Concurrency; @@ -33,9 +34,12 @@ public partial class TransactionPreviewViewModel : RoutableViewModel private readonly Wallet _wallet; private readonly WalletModel _walletModel; private readonly SendFlowModel _sendFlow; + private readonly object _ctsLock = new(); + private TransactionInfo _info; private TransactionInfo _currentTransactionInfo; - private CancellationTokenSource _cancellationTokenSource; + private CancellationTokenSource? _cancellationTokenSource; + [AutoNotify] private BuildTransactionResult? _transaction; [AutoNotify] private string _nextButtonText; [AutoNotify] private TransactionSummaryViewModel? _displayedTransactionSummary; @@ -52,7 +56,6 @@ public TransactionPreviewViewModel(WalletModel walletModel, SendFlowModel sendFl _info = _sendFlow.TransactionInfo ?? throw new InvalidOperationException($"Missing required TransactionInfo."); _currentTransactionInfo = _info.Clone(); - _cancellationTokenSource = new CancellationTokenSource(); PrivacySuggestions = new PrivacySuggestionsFlyoutViewModel(walletModel, _sendFlow); CurrentTransactionSummary = new TransactionSummaryViewModel(this, walletModel, _info); @@ -73,13 +76,11 @@ public TransactionPreviewViewModel(WalletModel walletModel, SendFlowModel sendFl { SkipCommand = ReactiveCommand.CreateFromTask(OnConfirmAsync); NextCommand = ReactiveCommand.CreateFromTask(OnExportPsbtAsync); - _nextButtonText = Resources.SavePSBTFile; } else { NextCommand = ReactiveCommand.CreateFromTask(OnConfirmAsync); - _nextButtonText = Resources.Confirm; } @@ -99,21 +100,72 @@ public TransactionPreviewViewModel(WalletModel walletModel, SendFlowModel sendFl } public TransactionSummaryViewModel CurrentTransactionSummary { get; } - public TransactionSummaryViewModel PreviewTransactionSummary { get; } - public List TransactionSummaries { get; } - public PrivacySuggestionsFlyoutViewModel PrivacySuggestions { get; } - public bool PreferPsbtWorkflow => _walletModel.Settings.PreferPsbtWorkflow; - public ICommand AdjustFeeCommand { get; } - public ICommand ChangeCoinsCommand { get; } - public ICommand UndoCommand { get; } + /// + /// Gets the current cancellation token in a thread-safe manner. + /// + private CancellationToken GetCancellationToken() + { + lock (_ctsLock) + { + return _cancellationTokenSource?.Token ?? CancellationToken.None; + } + } + + /// + /// Cancels and recreates the CancellationTokenSource in a thread-safe manner. + /// Properly disposes the old CTS. + /// + private void ResetCancellationToken() + { + lock (_ctsLock) + { + try + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } + finally + { + _cancellationTokenSource = new CancellationTokenSource(); + } + } + } + + /// + /// Cancels and disposes the CancellationTokenSource. + /// + private void CancelAndDisposeCancellationToken() + { + lock (_ctsLock) + { + try + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } + finally + { + _cancellationTokenSource = null; + } + } + } + private async Task OnExportPsbtAsync() { if (Transaction is { }) @@ -150,7 +202,6 @@ private void UpdateTransaction(TransactionSummaryViewModel summary, BuildTransac } summary.UpdateTransaction(transaction, _info); - DisplayedTransactionSummary = summary; } @@ -169,7 +220,7 @@ private async Task OnAdjustFeeAsync() private async Task BuildAndUpdateAsync() { - var newTransaction = await BuildTransactionAsync(); + var newTransaction = await BuildTransactionAsync(GetCancellationToken()); if (newTransaction is { }) { @@ -195,7 +246,7 @@ private async Task OnChangeCoinsAsync() } } - private async Task InitialiseTransactionAsync() + private async Task InitialiseTransactionAsync(CancellationToken cancellationToken) { if (_info.FeeRate == FeeRate.Zero) { @@ -218,9 +269,9 @@ private async Task InitialiseTransactionAsync() return _info.FeeRate != FeeRate.Zero && _info.Coins.Any(); } - private async Task BuildTransactionAsync() + private async Task BuildTransactionAsync(CancellationToken cancellationToken) { - if (!await InitialiseTransactionAsync()) + if (!await InitialiseTransactionAsync(cancellationToken)) { return null; } @@ -229,13 +280,17 @@ private async Task InitialiseTransactionAsync() { IsBusy = true; - return await Task.Run(() => TransactionHelpers.BuildTransaction(_wallet, _info, tryToSign: false)); + return await Task.Run(() => TransactionHelpers.BuildTransaction(_wallet, _info, tryToSign: false), cancellationToken); + } + catch (OperationCanceledException) + { + return null; } catch (Exception ex) when (ex is NotEnoughFundsException or TransactionFeeOverpaymentException || (ex is InvalidTxException itx && itx.Errors.OfType().Any())) { if (await TransactionFeeHelper.TrySetMaxFeeRateAsync(_wallet, _info)) { - return await BuildTransactionAsync(); + return await BuildTransactionAsync(cancellationToken); } await ShowErrorAsync( @@ -255,12 +310,12 @@ await ShowErrorAsync( if (newCoins is not null) { _info.Coins = newCoins; - return await BuildTransactionAsync(); + return await BuildTransactionAsync(cancellationToken); } } else if (await TransactionFeeHelper.TrySetMaxFeeRateAsync(_wallet, _info)) { - return await BuildTransactionAsync(); + return await BuildTransactionAsync(cancellationToken); } await ShowErrorAsync( @@ -289,7 +344,7 @@ await ShowErrorAsync( private async Task InitialiseViewModelAsync() { - if (await BuildTransactionAsync() is { } initialTransaction) + if (await BuildTransactionAsync(GetCancellationToken()) is { } initialTransaction) { UpdateTransaction(CurrentTransactionSummary, initialTransaction); } @@ -306,15 +361,28 @@ protected override void OnNavigatedTo(bool isInHistory, CompositeDisposable disp PrivacySuggestions.WhenAnyValue(x => x.PreviewSuggestion) .DoAsync(async x => { - if (x?.Transaction is { } transaction) + try + { + var ct = GetCancellationToken(); + + if (x?.Transaction is { } transaction) + { + UpdateTransaction(PreviewTransactionSummary, transaction); + await PrivacySuggestions.UpdatePreviewWarningsAsync(_info, transaction, ct); + } + else + { + DisplayedTransactionSummary = CurrentTransactionSummary; + PrivacySuggestions.ClearPreviewWarnings(); + } + } + catch (OperationCanceledException) { - UpdateTransaction(PreviewTransactionSummary, transaction); - await PrivacySuggestions.UpdatePreviewWarningsAsync(_info, transaction, _cancellationTokenSource.Token); + // Cancelled, ignore } - else + catch (Exception ex) { - DisplayedTransactionSummary = CurrentTransactionSummary; - PrivacySuggestions.ClearPreviewWarnings(); + Logger.LogError($"Error in PreviewSuggestion handler: {ex}"); } }) .Subscribe() @@ -323,11 +391,18 @@ protected override void OnNavigatedTo(bool isInHistory, CompositeDisposable disp PrivacySuggestions.WhenAnyValue(x => x.SelectedSuggestion) .SubscribeAsync(async suggestion => { - PrivacySuggestions.SelectedSuggestion = null; + try + { + PrivacySuggestions.SelectedSuggestion = null; - if (suggestion is { }) + if (suggestion is { }) + { + await ApplyPrivacySuggestionAsync(suggestion); + } + } + catch (Exception ex) { - await ApplyPrivacySuggestionAsync(suggestion); + Logger.LogError($"Error applying privacy suggestion: {ex}"); } }) .DisposeWith(disposables); @@ -336,21 +411,37 @@ protected override void OnNavigatedTo(bool isInHistory, CompositeDisposable disp .WhereNotNull() .Throttle(TimeSpan.FromMilliseconds(100)) .ObserveOn(RxApp.MainThreadScheduler) - .Do(_ => + .Do(transaction => { - _cancellationTokenSource.Cancel(); - _cancellationTokenSource = new(); + // Reset cancellation token for new transaction processing + ResetCancellationToken(); }) .DoAsync(async transaction => { - await CheckChangePocketAvailableAsync(transaction); - await PrivacySuggestions.BuildPrivacySuggestionsAsync(_info, transaction, _cancellationTokenSource.Token); + try + { + var ct = GetCancellationToken(); + + await CheckChangePocketAvailableAsync(transaction); + await PrivacySuggestions.BuildPrivacySuggestionsAsync(_info, transaction, ct); + } + catch (OperationCanceledException) + { + // Cancelled, ignore + } + catch (Exception ex) + { + Logger.LogError($"Error processing transaction: {ex}"); + } }) .Subscribe() .DisposeWith(disposables); if (!isInHistory) { + // Initialize CancellationTokenSource + ResetCancellationToken(); + RxApp.MainThreadScheduler.Schedule(async () => await InitialiseViewModelAsync()); } } @@ -359,8 +450,7 @@ protected override void OnNavigatedFrom(bool isInHistory) { if (!isInHistory) { - _cancellationTokenSource.Cancel(); - _cancellationTokenSource.Dispose(); + CancelAndDisposeCancellationToken(); } base.OnNavigatedFrom(isInHistory); @@ -370,23 +460,30 @@ protected override void OnNavigatedFrom(bool isInHistory) private async Task OnConfirmAsync() { + var ct = GetCancellationToken(); + try { - var transaction = await Task.Run(() => TransactionHelpers.BuildTransaction(_wallet, _info)); + var transaction = await Task.Run(() => TransactionHelpers.BuildTransaction(_wallet, _info), ct); var transactionAuthorizationInfo = new TransactionAuthorizationInfo(transaction); var authResult = await AuthorizeAsync(transactionAuthorizationInfo); + if (authResult) { IsBusy = true; - var finalTransaction = - await GetFinalTransactionAsync(transactionAuthorizationInfo.Transaction, _info); + var finalTransaction = await GetFinalTransactionAsync(transactionAuthorizationInfo.Transaction, _info, ct); await SendTransactionAsync(finalTransaction); _wallet.UpdateUsedHdPubKeysLabels(transaction.HdPubKeysWithNewLabels); - _cancellationTokenSource.Cancel(); + + CancelAndDisposeCancellationToken(); UiContext.Navigate(CurrentTarget).To().SendSuccess(finalTransaction); } } + catch (OperationCanceledException) + { + Logger.LogInfo("Transaction confirmation was cancelled."); + } catch (Exception ex) { Logger.LogError(ex); @@ -403,7 +500,7 @@ await ShowErrorAsync( private async Task AuthorizeAsync(TransactionAuthorizationInfo transactionAuthorizationInfo) { - if (!_walletModel.IsHardwareWallet && !_walletModel.Auth.HasPassword) // Do not show authentication dialog when password is empty + if (!_walletModel.IsHardwareWallet && !_walletModel.Auth.HasPassword) { return true; } @@ -420,16 +517,20 @@ private async Task SendTransactionAsync(SmartTransaction transaction) await Services.TransactionBroadcaster.SendTransactionAsync(transaction); } - private async Task GetFinalTransactionAsync(SmartTransaction transaction, TransactionInfo transactionInfo) + private async Task GetFinalTransactionAsync(SmartTransaction transaction, TransactionInfo transactionInfo, CancellationToken cancellationToken) { if (transactionInfo.PayJoinClient is { }) { try { var payJoinTransaction = await Task.Run(() => - TransactionHelpers.BuildTransaction(_wallet, transactionInfo, isPayJoin: true)); + TransactionHelpers.BuildTransaction(_wallet, transactionInfo, isPayJoin: true), cancellationToken); return payJoinTransaction.Transaction; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { Logger.LogError(ex); @@ -471,48 +572,45 @@ private async Task ApplyPrivacySuggestionAsync(PrivacySuggestion suggestion) switch (suggestion) { case LabelManagementSuggestion: - { - var newCoins = await UiContext.Navigate().To().PrivacyControl(_wallet, _sendFlow, _info, Transaction?.SpentCoins, false).GetResultAsync(); - if (newCoins is not null) { - _info.Coins = newCoins; - await BuildAndUpdateAsync(); + var newCoins = await UiContext.Navigate().To().PrivacyControl(_wallet, _sendFlow, _info, Transaction?.SpentCoins, false).GetResultAsync(); + if (newCoins is not null) + { + _info.Coins = newCoins; + await BuildAndUpdateAsync(); + } + break; } - break; - } - case ChangeAvoidanceSuggestion { Transaction: { } txn }: _info.ChangelessCoins = txn.SpentCoins; break; case FullPrivacySuggestion fullPrivacySuggestion: - { - if (fullPrivacySuggestion.IsChangeless) - { - _info.ChangelessCoins = fullPrivacySuggestion.Coins; - } - else { - _info.Coins = fullPrivacySuggestion.Coins; + if (fullPrivacySuggestion.IsChangeless) + { + _info.ChangelessCoins = fullPrivacySuggestion.Coins; + } + else + { + _info.Coins = fullPrivacySuggestion.Coins; + } + break; } - break; - } - case BetterPrivacySuggestion betterPrivacySuggestion: - { - if (betterPrivacySuggestion.IsChangeless) { - _info.ChangelessCoins = betterPrivacySuggestion.Coins; + if (betterPrivacySuggestion.IsChangeless) + { + _info.ChangelessCoins = betterPrivacySuggestion.Coins; + } + else + { + _info.Coins = betterPrivacySuggestion.Coins; + } + break; } - else - { - _info.Coins = betterPrivacySuggestion.Coins; - } - - break; - } } if (suggestion.Transaction is { } transaction) diff --git a/WalletWasabi.Tests/IntegrationTests/TorTests.cs b/WalletWasabi.Tests/IntegrationTests/TorTests.cs index 273d62d67f..dd53f12cdc 100644 --- a/WalletWasabi.Tests/IntegrationTests/TorTests.cs +++ b/WalletWasabi.Tests/IntegrationTests/TorTests.cs @@ -136,7 +136,7 @@ public async Task CanDoBasicPostHttpsRequestAsync() Assert.NotNull(headersNode); Assert.Equal("58", GetJsonNode(headersNode, "content-length").GetValue()); - Assert.Equal("gzip, br", GetJsonNode(headersNode, "accept-encoding").GetValue()); + Assert.Equal("gzip", GetJsonNode(headersNode, "accept-encoding").GetValue()); Assert.Equal("text/plain; charset=utf-8", GetJsonNode(headersNode, "content-type").GetValue()); } @@ -238,7 +238,7 @@ public async Task CanDoHttpsAsync() Assert.Equal("https", GetJsonNode(headersNode, "x-forwarded-proto").GetValue()); Assert.Equal("443", GetJsonNode(headersNode, "x-forwarded-port").GetValue()); - Assert.Equal("gzip, br", GetJsonNode(headersNode, "accept-encoding").GetValue()); + Assert.Equal("gzip", GetJsonNode(headersNode, "accept-encoding").GetValue()); } Assert.Equal("https://postman-echo.com/get?foo1=bar1&foo2=bar2", GetJsonNode(responseNode, "url").GetValue()); diff --git a/WalletWasabi.Tests/RegressionTests/ReceiveSpeedupTests.cs b/WalletWasabi.Tests/RegressionTests/ReceiveSpeedupTests.cs index c849ad3323..527d0de482 100644 --- a/WalletWasabi.Tests/RegressionTests/ReceiveSpeedupTests.cs +++ b/WalletWasabi.Tests/RegressionTests/ReceiveSpeedupTests.cs @@ -62,7 +62,7 @@ public async Task ReceiveSpeedupTestsAsync() // 3. Create wasabi synchronizer service. await using WasabiHttpClientFactory httpClientFactory = new(torEndPoint: null, backendUriGetter: () => new Uri(RegTestFixture.BackendEndPoint)); using WasabiSynchronizer synchronizer = new(period: TimeSpan.FromSeconds(3), 10000, bitcoinStore, httpClientFactory); - FeeRateProvider feeProvider = new(httpClientFactory, network); + using FeeRateProvider feeProvider = new(httpClientFactory, network); using UnconfirmedTransactionChainProvider unconfirmedChainProvider = new(httpClientFactory); // 4. Create key manager service. @@ -149,7 +149,7 @@ public async Task ReceiveSpeedupTestsAsync() Assert.Equal(outputToSpend, cpfpInput); // CPFP fee rate should be higher than the best fee rate. - var feeRates = await wallet.FeeProvider.GetAllFeeEstimateAsync(CancellationToken.None); + var feeRates = wallet.FeeProvider.GetAllFeeEstimate(); var feeRate = feeRates.GetFeeRate(2); Assert.NotNull(feeRate); var cpfpFeeRate = cpfp.Transaction.Transaction.GetFeeRate(cpfp.Transaction.WalletInputs.Select(x => x.Coin).ToArray()); diff --git a/WalletWasabi/Blockchain/Analysis/FeesEstimation/IWalletFeeRateProvider.cs b/WalletWasabi/Blockchain/Analysis/FeesEstimation/IWalletFeeRateProvider.cs index ccca12506d..227a3e3355 100644 --- a/WalletWasabi/Blockchain/Analysis/FeesEstimation/IWalletFeeRateProvider.cs +++ b/WalletWasabi/Blockchain/Analysis/FeesEstimation/IWalletFeeRateProvider.cs @@ -7,7 +7,5 @@ public interface IWalletFeeRateProvider { public AllFeeEstimate GetAllFeeEstimate(); - public Task GetAllFeeEstimateAsync(CancellationToken cancellationToken); - - public void TriggerRefresh(); + public bool IsFastRefresh { get; set; } } diff --git a/WalletWasabi/Helpers/Constants.cs b/WalletWasabi/Helpers/Constants.cs index eb8a63c124..def4bce44f 100644 --- a/WalletWasabi/Helpers/Constants.cs +++ b/WalletWasabi/Helpers/Constants.cs @@ -90,11 +90,11 @@ public static class Constants public static readonly Money MaximumNumberOfBitcoinsMoney = Money.Coins(MaximumNumberOfBitcoins); - public static readonly Version ClientVersion = new(2, 0, 23, 0); + public static readonly Version ClientVersion = new(2, 0, 24, 0); public static readonly Version HwiVersion = new("3.1.0"); public static readonly Version BitcoinCoreVersion = new("23.0"); - public static readonly Version GingerLegalDocumentsVersion = new(8, 0); + public static readonly Version GingerLegalDocumentsVersion = new(9, 0); public static readonly FeeRate MinRelayFeeRate = new(1m); public static readonly FeeRate AbsurdlyHighFeeRate = new(10_000m); diff --git a/WalletWasabi/Legal/Assets/LegalDocumentsGingerWallet.txt b/WalletWasabi/Legal/Assets/LegalDocumentsGingerWallet.txt index 7649b1be91..38b6360803 100644 --- a/WalletWasabi/Legal/Assets/LegalDocumentsGingerWallet.txt +++ b/WalletWasabi/Legal/Assets/LegalDocumentsGingerWallet.txt @@ -1,7 +1,24 @@ -Last Updated: Apr 11, 2025 +Last Updated: Dec 15, 2025 -Version Number: 8 +Version Number: 9 + + +========================================= + +DISCLAIMER + +========================================= + + +================================================================ + +PROHIBITION OF ACCESS FROM THE USA, ITS STATES, AND TERRITORIES + +================================================================ + + + Users located in the United States of America, including its states and territories, are hereby prohibited from accessing this platform. InvisibleBit LLC does not provide services to individuals located in these regions and shall not be liable for any consequences resulting from unauthorized access. By accessing this platform, users outside of the United States confirm that they are not a national of or located within the United States or its territories and agree to abide by this restriction. ========================================= diff --git a/WalletWasabi/WebClients/Wasabi/WasabiHttpClientFactory.cs b/WalletWasabi/WebClients/Wasabi/WasabiHttpClientFactory.cs index 96715b8a97..b5ab247b31 100644 --- a/WalletWasabi/WebClients/Wasabi/WasabiHttpClientFactory.cs +++ b/WalletWasabi/WebClients/Wasabi/WasabiHttpClientFactory.cs @@ -94,10 +94,15 @@ public IHttpClient NewHttpClient(Func? baseUriFn, Mode mode, ICircuit? circ } else { - return new ClearnetHttpClient(HttpClient, baseUriFn); + return NewClearnetHttpClient(baseUriFn); } } + public IHttpClient NewClearnetHttpClient(Func? baseUriFn) + { + return new ClearnetHttpClient(HttpClient, baseUriFn); + } + /// Creates new . /// Do not use this function unless is not sufficient for your use case. ///