< Summary - Combined Code Coverage

Information
Class: NLightning.Infrastructure.Bitcoin.Services.FeeService
Assembly: NLightning.Infrastructure.Bitcoin
File(s): /home/runner/work/nlightning/nlightning/src/NLightning.Infrastructure.Bitcoin/Services/FeeService.cs
Tag: 36_15743069263
Line coverage
75%
Covered lines: 82
Uncovered lines: 27
Coverable lines: 109
Total lines: 301
Line coverage: 75.2%
Branch coverage
37%
Covered branches: 29
Total branches: 78
Branch coverage: 37.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
StartAsync()50%2.03280%
StopAsync()75%4.59466.67%
GetFeeRatePerKwAsync()75%44100%
GetCachedFeeRatePerKw()100%210%
RefreshFeeRateAsync()100%11100%
FetchFeeRateFromApiAsync()50%12.85857.69%
RunPeriodicRefreshAsync()75%4.59466.67%
SaveToFileAsync()100%11100%
LoadFromFileAsync()100%11100%
IsCacheValid()100%22100%
ParseCacheTime(...)22%2105060%
ParseFilePath(...)50%4.05485.71%

File(s)

/home/runner/work/nlightning/nlightning/src/NLightning.Infrastructure.Bitcoin/Services/FeeService.cs

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4
 5namespace NLightning.Infrastructure.Bitcoin.Services;
 6
 7using Domain.Bitcoin.Interfaces;
 8using Domain.Money;
 9using Options;
 10
 11public class FeeService : IFeeService
 12{
 13    private const string FeeCacheFileName = "fee_cache.bin";
 414    private static readonly TimeSpan s_defaultCacheExpiration = TimeSpan.FromMinutes(5);
 15
 2016    private DateTime _lastFetchTime = DateTime.MinValue;
 2017    private readonly LightningMoney _cachedFeeRate = LightningMoney.Zero;
 18    private Task? _feeTask;
 19    private CancellationTokenSource? _cts;
 20
 21    private readonly HttpClient _httpClient;
 22    private readonly ILogger<FeeService> _logger;
 23    private readonly TimeSpan _cacheTimeExpiration;
 24    private readonly string _cacheFilePath;
 25    private readonly FeeEstimationOptions _feeEstimationOptions;
 26
 2027    public FeeService(IOptions<FeeEstimationOptions> feeOptions, HttpClient httpClient, ILogger<FeeService> logger)
 28    {
 2029        _feeEstimationOptions = feeOptions.Value;
 2030        _httpClient = httpClient;
 2031        _logger = logger;
 32
 2033        _cacheFilePath = ParseFilePath(_feeEstimationOptions);
 2034        _cacheTimeExpiration = ParseCacheTime(_feeEstimationOptions.CacheExpiration);
 35
 36        // Try to load from the file initially
 2037        _ = LoadFromFileAsync();
 2038    }
 39
 40    public async Task StartAsync(CancellationToken cancellationToken)
 41    {
 442        _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 43
 44        // Start the background task
 445        _feeTask = RunPeriodicRefreshAsync(_cts.Token);
 46
 47        // If the cache from the file is not valid, refresh immediately
 448        if (!IsCacheValid())
 49        {
 050            await RefreshFeeRateAsync(_cts.Token);
 51        }
 452    }
 53
 54    public async Task StopAsync()
 55    {
 456        if (_cts is null)
 57        {
 058            throw new InvalidOperationException("Service is not running");
 59        }
 60
 461        await _cts.CancelAsync();
 62
 463        if (_feeTask is not null)
 64        {
 65            try
 66            {
 467                await _feeTask;
 468            }
 069            catch (OperationCanceledException)
 70            {
 71                // Expected during cancellation
 072            }
 73        }
 474    }
 75
 76    public async Task<LightningMoney> GetFeeRatePerKwAsync(CancellationToken cancellationToken = default)
 77    {
 1278        if (IsCacheValid())
 79        {
 480            return _cachedFeeRate;
 81        }
 82
 883        using var linkedCts = CancellationTokenSource
 884           .CreateLinkedTokenSource(cancellationToken, _cts?.Token ?? CancellationToken.None);
 85
 886        await RefreshFeeRateAsync(linkedCts.Token);
 887        return _cachedFeeRate;
 1288    }
 89
 90    public LightningMoney GetCachedFeeRatePerKw()
 91    {
 092        return _cachedFeeRate;
 93    }
 94
 95    public async Task RefreshFeeRateAsync(CancellationToken cancellationToken)
 96    {
 97        try
 98        {
 2499            var feeRate = await FetchFeeRateFromApiAsync(cancellationToken);
 8100            _cachedFeeRate.Satoshi = feeRate;
 8101            _lastFetchTime = DateTime.UtcNow;
 8102            await SaveToFileAsync();
 8103        }
 4104        catch (OperationCanceledException)
 105        {
 106            // Ignore cancellation
 4107        }
 12108        catch (Exception e)
 109        {
 12110            _logger.LogError(e, "Error fetching fee rate from API");
 12111        }
 24112    }
 113
 114    private async Task<long> FetchFeeRateFromApiAsync(CancellationToken cancellationToken)
 115    {
 116        HttpResponseMessage response;
 117
 118        try
 119        {
 24120            if (_feeEstimationOptions.Method.Equals("GET", StringComparison.CurrentCultureIgnoreCase))
 121            {
 24122                response = await _httpClient.GetAsync(_feeEstimationOptions.Url, cancellationToken);
 123            }
 124            else // POST
 125            {
 0126                var content = new StringContent(
 0127                    _feeEstimationOptions.Body,
 0128                    System.Text.Encoding.UTF8,
 0129                    _feeEstimationOptions.ContentType);
 130
 0131                response = await _httpClient.PostAsync(_feeEstimationOptions.Url, content, cancellationToken);
 132            }
 16133        }
 8134        catch (Exception e)
 135        {
 8136            throw new InvalidOperationException("Error fetching from API", e);
 137        }
 138
 16139        response.EnsureSuccessStatusCode();
 16140        var jsonResponseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
 141
 142        // Parse the JSON response
 16143        using var document =
 16144            await JsonDocument.ParseAsync(jsonResponseStream, cancellationToken: cancellationToken);
 8145        var root = document.RootElement;
 146
 147        // Extract the preferred fee rate from the JSON response
 8148        if (!root.TryGetProperty(_feeEstimationOptions.PreferredFeeRate, out var feeRateElement))
 149        {
 0150            throw new InvalidOperationException(
 0151                $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 152        }
 153
 154        // Parse the fee rate value
 8155        if (!feeRateElement.TryGetDecimal(out var feeRate))
 156        {
 0157            throw new InvalidOperationException(
 0158                $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 159        }
 160
 161        // Apply the multiplier to convert to sat/kw
 8162        if (decimal.TryParse(_feeEstimationOptions.RateMultiplier, out var multiplier))
 163        {
 8164            return (long)(feeRate * multiplier);
 165        }
 166
 0167        throw new InvalidOperationException(
 0168            $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 8169    }
 170
 171    private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
 172    {
 173        try
 174        {
 12175            while (!cancellationToken.IsCancellationRequested)
 176            {
 177                // Refresh if it's not canceled
 12178                if (!cancellationToken.IsCancellationRequested)
 179                {
 12180                    await RefreshFeeRateAsync(cancellationToken);
 181
 182                    // Wait for the cache time or until cancellation
 12183                    await Task.Delay(_cacheTimeExpiration, cancellationToken);
 184                }
 185            }
 0186        }
 4187        catch (OperationCanceledException)
 188        {
 4189            _logger.LogInformation("Stopping fee service");
 4190        }
 0191        catch (Exception ex)
 192        {
 0193            _logger.LogError(ex, "Unhandled exception in fee service");
 0194        }
 4195    }
 196
 197    private Task SaveToFileAsync()
 198    {
 8199        _logger.LogDebug("Saving fee rate to file {filePath}", _cacheFilePath);
 200
 8201        return Task.CompletedTask;
 202        // try
 203        // {
 204        //     var cacheData = new FeeRateCacheData
 205        //     {
 206        //         FeeRate = _cachedFeeRate,
 207        //         LastFetchTime = _lastFetchTime
 208        //     };
 209        //
 210        //     await using var fileStream = File.OpenWrite(_cacheFilePath);
 211        //     await MessagePackSerializer.SerializeAsync(fileStream, cacheData, cancellationToken: CancellationToken.No
 212        // }
 213        // catch (Exception e)
 214        // {
 215        //     _logger.LogError(e, "Error saving fee rate to file");
 216        // }
 217    }
 218
 219    private Task LoadFromFileAsync()
 220    {
 20221        _logger.LogDebug("Loading fee rate from file {filePath}", _cacheFilePath);
 222
 20223        return Task.CompletedTask;
 224        // try
 225        // {
 226        //     if (!File.Exists(_cacheFilePath))
 227        //     {
 228        //         _logger.LogDebug("Fee rate cache file does not exist. Skipping load.");
 229        //         return;
 230        //     }
 231        //
 232        //     await using var fileStream = File.OpenRead(_cacheFilePath);
 233        //     var cacheData =
 234        //         await MessagePackSerializer.DeserializeAsync<FeeRateCacheData?>(fileStream,
 235        //             cancellationToken: cancellationToken);
 236        //
 237        //     if (cacheData == null)
 238        //     {
 239        //         _logger.LogDebug("Fee rate cache file is empty. Skipping load.");
 240        //         return;
 241        //     }
 242        //
 243        //     _cachedFeeRate = cacheData.FeeRate;
 244        //     _lastFetchTime = cacheData.LastFetchTime;
 245        // }
 246        // catch (OperationCanceledException)
 247        // {
 248        //     // Ignore cancellation
 249        // }
 250        // catch (Exception e)
 251        // {
 252        //     _logger.LogError(e, "Error loading fee rate from file");
 253        // }
 254    }
 255
 256    private bool IsCacheValid()
 257    {
 16258        return !_cachedFeeRate.IsZero && DateTime.UtcNow.Subtract(_lastFetchTime).CompareTo(_cacheTimeExpiration) <= 0;
 259    }
 260
 261    private static TimeSpan ParseCacheTime(string cacheTime)
 262    {
 263        try
 264        {
 265            // Parse formats like "5m", "1hour", "30s"
 20266            var valueStr = new string(cacheTime.Where(char.IsDigit).ToArray());
 20267            var unit = new string(cacheTime.Where(char.IsLetter).ToArray()).ToLowerInvariant();
 268
 20269            if (!int.TryParse(valueStr, out var value))
 0270                return s_defaultCacheExpiration;
 271
 20272            return unit switch
 20273            {
 4274                "s" or "second" or "seconds" => TimeSpan.FromSeconds(value),
 16275                "m" or "minute" or "minutes" => TimeSpan.FromMinutes(value),
 0276                "h" or "hour" or "hours" => TimeSpan.FromHours(value),
 0277                "d" or "day" or "days" => TimeSpan.FromDays(value),
 0278                _ => TimeSpan.FromMinutes(5)
 20279            };
 280        }
 0281        catch
 282        {
 0283            return s_defaultCacheExpiration; // Default on error
 284        }
 20285    }
 286
 287    private static string ParseFilePath(FeeEstimationOptions feeEstimationOptions)
 288    {
 20289        var filePath = feeEstimationOptions.CacheFile;
 20290        if (string.IsNullOrWhiteSpace(filePath))
 291        {
 0292            return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FeeCacheFileName);
 293        }
 294
 295        // Check if the file path is absolute or relative
 20296        return Path.IsPathRooted(filePath)
 20297                   ? filePath
 20298                   : Path.Combine(Directory.GetCurrentDirectory(),
 20299                                  filePath); // If it's relative, combine it with the current directory
 300    }
 301}