#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AngleSharp.Dom; using ExchangeSharp; using Microsoft.Extensions.Caching.Memory; using NBitcoin; using Org.BouncyCastle.Asn1.X509; using YamlDotNet.Core.Tokens; namespace BTCPayServer.Services.Fees; public class MempoolSpaceFeeProvider( IMemoryCache memoryCache, string cacheKey, IHttpClientFactory httpClientFactory, bool testnet) : IFeeProvider { private 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 InterpolateOrBound(result, blockTarget); } internal static FeeRate InterpolateOrBound(BlockFeeRate[] ordered, int target) { (BlockFeeRate lb, BlockFeeRate hb) = (ordered[0], ordered[^1]); target = Math.Clamp(target, lb.Blocks, hb.Blocks); for (int i = 0; i < ordered.Length; i++) { if (ordered[i].Blocks > lb.Blocks && ordered[i].Blocks <= target) lb = ordered[i]; if (ordered[i].Blocks < hb.Blocks && ordered[i].Blocks >= target) hb = ordered[i]; } if (hb.Blocks == lb.Blocks) return hb.FeeRate; var a = (decimal)(target - lb.Blocks) / (decimal)(hb.Blocks - lb.Blocks); return new FeeRate((1 - a) * lb.FeeRate.SatoshiPerByte + a * hb.FeeRate.SatoshiPerByte); } internal async Task GetFeeRatesAsync() { try { return (await memoryCache.GetOrCreateAsync(cacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); return await GetFeeRatesCore(); }))!; } catch (Exception) { memoryCache.Remove(cacheKey); throw; } } internal record BlockFeeRate(int Blocks, FeeRate FeeRate); async Task GetFeeRatesCore() { var client = httpClientFactory.CreateClient(nameof(MempoolSpaceFeeProvider)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var result = await client.GetAsync(ExplorerLink, cts.Token); result.EnsureSuccessStatusCode(); var recommendedFees = await result.Content.ReadAsAsync>(); var r = new List(); 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 }; r.Add(new(target, new FeeRate(value))); } var ordered = r.OrderBy(k => k.Blocks).ToArray(); for (var i = 0; i < ordered.Length; i++) { // Randomize a bit ordered[i] = ordered[i] with { FeeRate = new FeeRate(RandomizeByPercentage(ordered[i].FeeRate.SatoshiPerByte, 10m)) }; if (i > 0) // Make sure feerate always increase ordered[i] = ordered[i] with { FeeRate = FeeRate.Max(ordered[i - 1].FeeRate, ordered[i].FeeRate) }; } return ordered; } internal static decimal RandomizeByPercentage(decimal value, decimal percentage) { if (value is 1) return 1; decimal range = (value * percentage) / 100m; var res = value + (range * 2.0m) * ((decimal)(Random.Shared.NextDouble() - 0.5)); return res switch { < 1m => 1m, > 2000m => 2000m, _ => res }; } }