using System.Globalization; using System.Text.Json; using PalladiumWallet.Core.Net; namespace PalladiumWallet.Core.Wallet; /// /// 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). /// 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; } /// Conversions and checks for the fee rate, kept pure so they can be tested exhaustively. public static class FeePolicy { public const int FastTarget = 2; public const int NormalTarget = 6; public const int EconomyTarget = 24; /// The fee warning threshold: a fee above this share of the amount sent. public const int HighFeePercent = 1; private const decimal SatsPerCoin = 100_000_000m; private const decimal BytesPerKb = 1000m; /// /// Converts a server value in coin/kB to sat/vB (× 100,000,000 / 1,000). Null for "no /// estimate" (-1), zero, negative, or above , which is /// treated as an implausible answer rather than followed. /// 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; } /// An estimate never goes below the relay fee: the node would reject it. public static decimal? AtLeastRelay(decimal? estimate, decimal? relay) => estimate is { } e ? Math.Max(e, relay ?? 0m) : null; /// The preselected rate: the normal estimate, else 1 sat/vB, never below the relay fee. public static decimal DefaultRate(FeeEstimates? estimates) => Math.Max(estimates?.Normal ?? 1m, estimates?.Relay ?? 0m); /// True when a manual rate is below the server's minimum relay fee. public static bool IsBelowRelay(decimal rate, decimal? relay) => relay is { } r && rate < r; /// True when the fee is more than % of the amount sent. 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); /// /// 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. /// public static async Task 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); } /// Builds the estimates from raw server values (coin/kB), applying the sanity rules. 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 TryAsync(Func> call) { try { return await call(); } catch (Exception ex) when (ex is ElectrumServerException or InvalidOperationException or FormatException or JsonException) { return null; } } }