diff --git a/BTCPayServer.Tests/ThirdPartyTests.cs b/BTCPayServer.Tests/ThirdPartyTests.cs index 2b0c6350f..feea2bc73 100644 --- a/BTCPayServer.Tests/ThirdPartyTests.cs +++ b/BTCPayServer.Tests/ThirdPartyTests.cs @@ -11,11 +11,14 @@ using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Rating; +using BTCPayServer.Services.Fees; using BTCPayServer.Services.Rates; using BTCPayServer.Storage.Models; using BTCPayServer.Storage.Services.Providers.AzureBlobStorage.Configuration; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileSystemGlobbing; using NBitcoin; using NBitpayClient; @@ -73,6 +76,25 @@ namespace BTCPayServer.Tests await UnitTest1.CanUploadRemoveFiles(controller); } + [Fact] + public async Task CanQueryMempoolFeeProvider() + { + IServiceCollection collection = new ServiceCollection(); + collection.AddMemoryCache(); + collection.AddHttpClient(); + var prov = collection.BuildServiceProvider(); + foreach (var isTestnet in new[] { true, false }) + { + var mempoolSpaceFeeProvider = new MempoolSpaceFeeProvider( + prov.GetService(), + "test" + isTestnet, + prov.GetService(), + isTestnet); + var rates = await mempoolSpaceFeeProvider.GetFeeRatesAsync(); + Assert.NotEmpty(rates); + await mempoolSpaceFeeProvider.GetFeeRateAsync(20); + } + } [Fact] public async Task CanQueryDirectProviders() { diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 917d4416e..880bbd2a4 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -1266,7 +1266,7 @@ namespace BTCPayServer.Controllers { metadataObj = JObject.Parse(model.Metadata); } - catch (Exception e) + catch (Exception) { ModelState.AddModelError(nameof(model.Metadata), "Metadata was not valid JSON"); } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index fc5153986..5c5084d6b 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -366,10 +366,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.AddSingleton(provider => provider.GetService()); services.TryAddSingleton(CurrencyNameTable.Instance); - services.TryAddSingleton(o => new NBXplorerFeeProviderFactory(o.GetRequiredService()) - { - Fallback = new FeeRate(100L, 1) - }); + services.TryAddSingleton(); services.Configure((o) => { diff --git a/BTCPayServer/Services/Cheater.cs b/BTCPayServer/Services/Cheater.cs index f97968e3a..bfb4ecf11 100644 --- a/BTCPayServer/Services/Cheater.cs +++ b/BTCPayServer/Services/Cheater.cs @@ -36,7 +36,6 @@ namespace BTCPayServer.Services async Task IHostedService.StartAsync(CancellationToken cancellationToken) { - _ = CashCow?.ScanRPCCapabilitiesAsync(cancellationToken); #if ALTCOINS var liquid = _prov.GetNetwork("LBTC"); if (liquid is not null) @@ -59,7 +58,9 @@ namespace BTCPayServer.Services } } } - +#else + if (CashCow is { } c) + await c.ScanRPCCapabilitiesAsync(cancellationToken); #endif } diff --git a/BTCPayServer/Services/Fees/FallbackFeeProvider.cs b/BTCPayServer/Services/Fees/FallbackFeeProvider.cs new file mode 100644 index 000000000..ccf78c28a --- /dev/null +++ b/BTCPayServer/Services/Fees/FallbackFeeProvider.cs @@ -0,0 +1,28 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NBitcoin; + +namespace BTCPayServer.Services.Fees +{ + public class FallbackFeeProvider(IFeeProvider[] Providers) : IFeeProvider + { + public async Task GetFeeRateAsync(int blockTarget = 20) + { + for (int i = 0; i < Providers.Length; i++) + { + try + { + return await Providers[i].GetFeeRateAsync(blockTarget); + } + catch when (i < Providers.Length - 1) + { + } + } + throw new NotSupportedException("No provider available"); + } + } +} diff --git a/BTCPayServer/Services/Fees/FeeProviderFactory.cs b/BTCPayServer/Services/Fees/FeeProviderFactory.cs new file mode 100644 index 000000000..887d0ccdc --- /dev/null +++ b/BTCPayServer/Services/Fees/FeeProviderFactory.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Caching.Memory; +using NBitcoin; + +namespace BTCPayServer.Services.Fees; + +public class FeeProviderFactory : IFeeProviderFactory +{ + public FeeProviderFactory( + BTCPayServerEnvironment Environment, + ExplorerClientProvider ExplorerClients, + IHttpClientFactory HttpClientFactory, + IMemoryCache MemoryCache) + { + _FeeProviders = new (); + + // TODO: Pluginify this + foreach ((var network, var client) in ExplorerClients.GetAll()) + { + List providers = new List(); + if (network.IsBTC && Environment.NetworkType != ChainName.Regtest) + { + providers.Add(new MempoolSpaceFeeProvider( + MemoryCache, + $"MempoolSpaceFeeProvider-{network.CryptoCode}", + HttpClientFactory, + network is BTCPayNetwork n && + n.NBitcoinNetwork.ChainName == ChainName.Testnet)); + } + providers.Add(new NBXplorerFeeProvider(client)); + providers.Add(new StaticFeeProvider(new FeeRate(100L, 1))); + var fallback = new FallbackFeeProvider(providers.ToArray()); + _FeeProviders.Add(network, fallback); + } + } + private readonly Dictionary _FeeProviders; + public IFeeProvider CreateFeeProvider(BTCPayNetworkBase network) + { + return _FeeProviders.TryGetValue(network, out var prov) ? prov : throw new NotSupportedException($"No fee provider for this network ({network.CryptoCode})"); + } +} diff --git a/BTCPayServer/Services/Fees/MempoolSpaceFeeProvider.cs b/BTCPayServer/Services/Fees/MempoolSpaceFeeProvider.cs new file mode 100644 index 000000000..703978b7b --- /dev/null +++ b/BTCPayServer/Services/Fees/MempoolSpaceFeeProvider.cs @@ -0,0 +1,64 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp.Dom; +using Microsoft.Extensions.Caching.Memory; +using NBitcoin; + +namespace BTCPayServer.Services.Fees; + +public class MempoolSpaceFeeProvider( + IMemoryCache MemoryCache, + string CacheKey, + IHttpClientFactory HttpClientFactory, + bool Testnet) : IFeeProvider +{ + private readonly string ExplorerLink = Testnet switch + { + true => "https://mempool.space/testnet/api/v1/fees/recommended", + false => "https://mempool.space/api/v1/fees/recommended" + }; + + public async Task GetFeeRateAsync(int blockTarget = 20) + { + var result = await GetFeeRatesAsync(); + + return result.TryGetValue(blockTarget, out var feeRate) + ? feeRate + : + //try get the closest one + result[result.Keys.MinBy(key => Math.Abs(key - blockTarget))]; + } + + public Task> GetFeeRatesAsync() + { + return MemoryCache.GetOrCreateAsync(CacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var client = HttpClientFactory.CreateClient(nameof(MempoolSpaceFeeProvider)); + using var result = await client.GetAsync(ExplorerLink); + result.EnsureSuccessStatusCode(); + var recommendedFees = await result.Content.ReadAsAsync>(); + var feesByBlockTarget = new Dictionary(); + foreach ((var feeId, decimal value) in recommendedFees) + { + var target = feeId switch + { + "fastestFee" => 1, + "halfHourFee" => 3, + "hourFee" => 6, + "economyFee" when recommendedFees.TryGetValue("minimumFee", out var minFee) && minFee == value => 144, + "economyFee" => 72, + "minimumFee" => 144, + _ => -1 + }; + feesByBlockTarget.TryAdd(target, new FeeRate(value)); + } + return feesByBlockTarget; + })!; + } +} diff --git a/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs b/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs index b69897dfc..6ed2f9edb 100644 --- a/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs +++ b/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs @@ -1,4 +1,4 @@ -using System; +#nullable enable using System.Threading.Tasks; using NBitcoin; using NBXplorer; @@ -6,43 +6,11 @@ using NBXplorer.Models; namespace BTCPayServer.Services.Fees { - public class NBXplorerFeeProviderFactory : IFeeProviderFactory + public class NBXplorerFeeProvider(ExplorerClient ExplorerClient) : IFeeProvider { - public NBXplorerFeeProviderFactory(ExplorerClientProvider explorerClients) - { - ArgumentNullException.ThrowIfNull(explorerClients); - _ExplorerClients = explorerClients; - } - - private readonly ExplorerClientProvider _ExplorerClients; - - public FeeRate Fallback { get; set; } - public IFeeProvider CreateFeeProvider(BTCPayNetworkBase network) - { - return new NBXplorerFeeProvider(this, _ExplorerClients.GetExplorerClient(network)); - } - } - public class NBXplorerFeeProvider : IFeeProvider - { - public NBXplorerFeeProvider(NBXplorerFeeProviderFactory parent, ExplorerClient explorerClient) - { - ArgumentNullException.ThrowIfNull(explorerClient); - _Factory = parent; - _ExplorerClient = explorerClient; - } - - readonly NBXplorerFeeProviderFactory _Factory; - readonly ExplorerClient _ExplorerClient; public async Task GetFeeRateAsync(int blockTarget = 20) { - try - { - return (await _ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate; - } - catch (NBXplorerException ex) when (ex.Error.HttpCode == 400 && ex.Error.Code == "fee-estimation-unavailable") - { - return _Factory.Fallback; - } + return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate; } } } diff --git a/BTCPayServer/Services/Fees/StaticFeeProvider.cs b/BTCPayServer/Services/Fees/StaticFeeProvider.cs new file mode 100644 index 000000000..1e82c9641 --- /dev/null +++ b/BTCPayServer/Services/Fees/StaticFeeProvider.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using NBitcoin; + +namespace BTCPayServer.Services.Fees; + +public class StaticFeeProvider : IFeeProvider +{ + private readonly FeeRate _feeRate; + + public StaticFeeProvider(FeeRate feeRate) + { + _feeRate = feeRate; + } + + public Task GetFeeRateAsync(int blockTarget = 20) + { + return Task.FromResult(_feeRate); + } +} \ No newline at end of file diff --git a/Build/Common.csproj b/Build/Common.csproj index 8cf952962..4393327f9 100644 --- a/Build/Common.csproj +++ b/Build/Common.csproj @@ -2,8 +2,8 @@ net8.0 $(TargetFrameworkOverride) - NU1701,CA1816,CA1308,CA1810,CA2208,CA1303,CA2000,CA2016,CA1835,CA2249,CA9998,CA1704;CS8981 - 10.0 + NU1701,CA1816,CA1308,CA1810,CA2208,CA1303,CA2000,CA2016,CA1835,CA2249,CA9998,CA1704;CS8981 + 12.0 True 6.0