Files
PalladiumWallet/src/Core/Wallet/FeePolicy.cs
T
davide c8ba2294f2 feat(app): suggest fee rates from the server and enforce the relay fee
After connecting, the Send tab offers Fast/Normal/Economy rates from the
server's fee estimates (never below the relay fee, implausible values
ignored). A manual rate below the relay fee is refused before building and
a fee above 1% of the amount is flagged in the preview. See ADR 0039.
2026-11-03 14:33:32 +01:00

88 lines
4.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.Json;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Fee suggestions from the indexing server (untrusted input): the relay fee and estimates for
/// three confirmation targets, in sat/vB. A null value means the server could not provide one
/// or gave an implausible one (ADR 0039).
/// </summary>
public sealed record FeeEstimates(decimal? Relay, decimal? Fast, decimal? Normal, decimal? Economy)
{
public bool HasAny => Fast is not null || Normal is not null || Economy is not null;
}
/// <summary>Conversions and checks for the fee rate, kept pure so they can be tested exhaustively.</summary>
public static class FeePolicy
{
public const int FastTarget = 2;
public const int NormalTarget = 6;
public const int EconomyTarget = 24;
/// <summary>The fee warning threshold: a fee above this share of the amount sent.</summary>
public const int HighFeePercent = 1;
private const decimal SatsPerCoin = 100_000_000m;
private const decimal BytesPerKb = 1000m;
/// <summary>
/// Converts a server value in coin/kB to sat/vB (× 100,000,000 / 1,000). Null for "no
/// estimate" (-1), zero, negative, or above <see cref="CoinAmount.MaxFeeRate"/>, which is
/// treated as an implausible answer rather than followed.
/// </summary>
public static decimal? ToSatPerVbyte(decimal coinPerKb)
{
if (coinPerKb <= 0)
return null;
var rate = Math.Round(coinPerKb * SatsPerCoin / BytesPerKb, 2, MidpointRounding.AwayFromZero);
return rate is > 0 and <= CoinAmount.MaxFeeRate ? rate : null;
}
/// <summary>An estimate never goes below the relay fee: the node would reject it.</summary>
public static decimal? AtLeastRelay(decimal? estimate, decimal? relay) =>
estimate is { } e ? Math.Max(e, relay ?? 0m) : null;
/// <summary>The preselected rate: the normal estimate, else 1 sat/vB, never below the relay fee.</summary>
public static decimal DefaultRate(FeeEstimates? estimates) =>
Math.Max(estimates?.Normal ?? 1m, estimates?.Relay ?? 0m);
/// <summary>True when a manual rate is below the server's minimum relay fee.</summary>
public static bool IsBelowRelay(decimal rate, decimal? relay) => relay is { } r && rate < r;
/// <summary>True when the fee is more than <see cref="HighFeePercent"/>% of the amount sent.</summary>
public static bool IsHighFee(long feeSats, long amountSats) =>
amountSats > 0 && feeSats * 100 > amountSats * HighFeePercent;
public static string Format(decimal rate) => rate.ToString("0.##", CultureInfo.InvariantCulture);
/// <summary>
/// Asks the server for the relay fee and the three estimates. Every call is optional: a
/// server error or an unusable value just leaves that field null.
/// </summary>
public static async Task<FeeEstimates> FetchAsync(ElectrumClient client, CancellationToken ct = default)
{
var relay = await TryAsync(() => client.RelayFeeAsync(ct));
var fast = await TryAsync(() => client.EstimateFeeAsync(FastTarget, ct));
var normal = await TryAsync(() => client.EstimateFeeAsync(NormalTarget, ct));
var economy = await TryAsync(() => client.EstimateFeeAsync(EconomyTarget, ct));
return Build(relay, fast, normal, economy);
}
/// <summary>Builds the estimates from raw server values (coin/kB), applying the sanity rules.</summary>
public static FeeEstimates Build(decimal? relayCoinPerKb, decimal? fast, decimal? normal, decimal? economy)
{
var relay = relayCoinPerKb is { } r ? ToSatPerVbyte(r) : null;
decimal? Convert(decimal? v) => AtLeastRelay(v is { } x ? ToSatPerVbyte(x) : null, relay);
return new FeeEstimates(relay, Convert(fast), Convert(normal), Convert(economy));
}
private static async Task<decimal?> TryAsync(Func<Task<decimal>> call)
{
try { return await call(); }
catch (Exception ex) when (ex is ElectrumServerException or InvalidOperationException
or FormatException or JsonException) { return null; }
}
}