mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-25 15:10:00 +01:00
* Refactor fee provider The fee provider ended up glued with a hardcoded factory. This PR: * removes this glue and uses the DI to register fee provider for a network. (allows plugins to add their own fee providers, for any network * Add a 10 second timeout to mempoolspace fee fetching as they are slow at times * use linear interpolation for mempool space fee estimation * fix upper bound * Add tests, rollback pluginify FeeProvider --------- Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
119 lines
4.1 KiB
C#
119 lines
4.1 KiB
C#
#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<FeeRate> 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<BlockFeeRate[]> 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<BlockFeeRate[]> 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<Dictionary<string, decimal>>();
|
|
var r = new List<BlockFeeRate>();
|
|
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
|
|
};
|
|
}
|
|
}
|