< Summary - Combined Code Coverage

Information
Class: NLightning.Application.NLTG.Services.FeeService
Assembly: NLightning.Application.NLTG
File(s): /home/runner/work/nlightning/nlightning/src/NLightning.Application.NLTG/Services/FeeService.cs
Tag: 30_15166811759
Line coverage
75%
Covered lines: 103
Uncovered lines: 34
Coverable lines: 137
Total lines: 300
Line coverage: 75.1%
Branch coverage
41%
Covered branches: 36
Total branches: 86
Branch coverage: 41.8%
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%22100%
LoadFromFileAsync()83.33%7.54665%
IsCacheValid()100%22100%
ParseCacheTime(...)22%2105060%
ParseFilePath(...)50%4.07483.33%

File(s)

/home/runner/work/nlightning/nlightning/src/NLightning.Application.NLTG/Services/FeeService.cs

#LineLine coverage
 1using System.Text.Json;
 2using MessagePack;
 3using Microsoft.Extensions.Logging;
 4using Microsoft.Extensions.Options;
 5
 6namespace NLightning.Application.NLTG.Services;
 7
 8using Common.Options;
 9using Domain.Bitcoin.Services;
 10using Domain.Money;
 11using Models;
 12
 13public class FeeService : IFeeService
 14{
 15    private const string FEE_CACHE_FILE_NAME = "fee_cache.bin";
 416    private static readonly TimeSpan s_defaultCacheExpiration = TimeSpan.FromMinutes(5);
 17
 2018    private DateTime _lastFetchTime = DateTime.MinValue;
 2019    private LightningMoney _cachedFeeRate = LightningMoney.Zero;
 20    private Task? _feeTask;
 21    private CancellationTokenSource? _cts;
 22
 23    private readonly HttpClient _httpClient;
 24    private readonly ILogger<FeeService> _logger;
 25    private readonly TimeSpan _cacheTimeExpiration;
 26    private readonly string _cacheFilePath;
 27    private readonly FeeEstimationOptions _feeEstimationOptions;
 28
 2029    public FeeService(IOptions<FeeEstimationOptions> feeOptions, HttpClient httpClient, ILogger<FeeService> logger)
 30    {
 2031        _feeEstimationOptions = feeOptions.Value;
 2032        _httpClient = httpClient;
 2033        _logger = logger;
 34
 2035        _cacheFilePath = ParseFilePath(_feeEstimationOptions);
 2036        _cacheTimeExpiration = ParseCacheTime(_feeEstimationOptions.CacheExpiration);
 37
 38        // Try to load from the file initially
 2039        _ = LoadFromFileAsync(CancellationToken.None);
 2040    }
 41
 42    public async Task StartAsync(CancellationToken cancellationToken)
 43    {
 444        _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 45
 46        // Start the background task
 447        _feeTask = RunPeriodicRefreshAsync(_cts.Token);
 48
 49        // If the cache from the file is not valid, refresh immediately
 450        if (!IsCacheValid())
 51        {
 052            await RefreshFeeRateAsync(_cts.Token);
 53        }
 454    }
 55
 56    public async Task StopAsync()
 57    {
 458        if (_cts is null)
 59        {
 060            throw new InvalidOperationException("Service is not running");
 61        }
 62
 463        await _cts.CancelAsync();
 64
 465        if (_feeTask is not null)
 66        {
 67            try
 68            {
 469                await _feeTask;
 470            }
 071            catch (OperationCanceledException)
 72            {
 73                // Expected during cancellation
 074            }
 75        }
 476    }
 77
 78    public async Task<LightningMoney> GetFeeRatePerKwAsync(CancellationToken cancellationToken = default)
 79    {
 1280        if (IsCacheValid())
 81        {
 482            return _cachedFeeRate;
 83        }
 84
 885        using var linkedCts = CancellationTokenSource
 886            .CreateLinkedTokenSource(cancellationToken, _cts?.Token ?? CancellationToken.None);
 87
 888        await RefreshFeeRateAsync(linkedCts.Token);
 889        return _cachedFeeRate;
 1290    }
 91
 92    public LightningMoney GetCachedFeeRatePerKw()
 93    {
 094        return _cachedFeeRate;
 95    }
 96
 97    public async Task RefreshFeeRateAsync(CancellationToken cancellationToken)
 98    {
 99        try
 100        {
 24101            var feeRate = await FetchFeeRateFromApiAsync(cancellationToken);
 8102            _cachedFeeRate.Satoshi = feeRate;
 8103            _lastFetchTime = DateTime.UtcNow;
 8104            await SaveToFileAsync();
 8105        }
 4106        catch (OperationCanceledException)
 107        {
 108            // Ignore cancellation
 4109        }
 12110        catch (Exception e)
 111        {
 12112            _logger.LogError(e, "Error fetching fee rate from API");
 12113        }
 24114    }
 115
 116    private async Task<long> FetchFeeRateFromApiAsync(CancellationToken cancellationToken)
 117    {
 118        HttpResponseMessage response;
 119
 120        try
 121        {
 24122            if (_feeEstimationOptions.Method.Equals("GET", StringComparison.CurrentCultureIgnoreCase))
 123            {
 24124                response = await _httpClient.GetAsync(_feeEstimationOptions.Url, cancellationToken);
 125            }
 126            else // POST
 127            {
 0128                var content = new StringContent(
 0129                    _feeEstimationOptions.Body,
 0130                    System.Text.Encoding.UTF8,
 0131                    _feeEstimationOptions.ContentType);
 132
 0133                response = await _httpClient.PostAsync(_feeEstimationOptions.Url, content, cancellationToken);
 134            }
 16135        }
 8136        catch (Exception e)
 137        {
 8138            throw new InvalidOperationException("Error fetching from API", e);
 139        }
 140
 16141        response.EnsureSuccessStatusCode();
 16142        var jsonResponseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
 143
 144        // Parse the JSON response
 16145        using var document =
 16146            await JsonDocument.ParseAsync(jsonResponseStream, cancellationToken: cancellationToken);
 8147        var root = document.RootElement;
 148
 149        // Extract the preferred fee rate from the JSON response
 8150        if (!root.TryGetProperty(_feeEstimationOptions.PreferredFeeRate, out var feeRateElement))
 151        {
 0152            throw new InvalidOperationException(
 0153                $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 154        }
 155
 156        // Parse the fee rate value
 8157        if (!feeRateElement.TryGetDecimal(out var feeRate))
 158        {
 0159            throw new InvalidOperationException(
 0160                $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 161        }
 162
 163        // Apply the multiplier to convert to sat/kw
 8164        if (decimal.TryParse(_feeEstimationOptions.RateMultiplier, out var multiplier))
 165        {
 8166            return (long)(feeRate * multiplier);
 167        }
 168
 0169        throw new InvalidOperationException(
 0170            $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response.");
 8171    }
 172
 173    private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
 174    {
 175        try
 176        {
 12177            while (!cancellationToken.IsCancellationRequested)
 178            {
 179                // Refresh if it's not canceled
 12180                if (!cancellationToken.IsCancellationRequested)
 181                {
 12182                    await RefreshFeeRateAsync(cancellationToken);
 183
 184                    // Wait for the cache time or until cancellation
 12185                    await Task.Delay(_cacheTimeExpiration, cancellationToken);
 186                }
 187            }
 0188        }
 4189        catch (OperationCanceledException)
 190        {
 4191            _logger.LogInformation("Stopping fee service");
 4192        }
 0193        catch (Exception ex)
 194        {
 0195            _logger.LogError(ex, "Unhandled exception in fee service");
 0196        }
 4197    }
 198
 199    private async Task SaveToFileAsync()
 200    {
 8201        _logger.LogDebug("Saving fee rate to file {filePath}", _cacheFilePath);
 202
 203        try
 204        {
 8205            var cacheData = new FeeRateCacheData
 8206            {
 8207                FeeRate = _cachedFeeRate,
 8208                LastFetchTime = _lastFetchTime
 8209            };
 210
 8211            await using var fileStream = File.OpenWrite(_cacheFilePath);
 4212            await MessagePackSerializer.SerializeAsync(fileStream, cacheData, cancellationToken: CancellationToken.None)
 4213        }
 4214        catch (Exception e)
 215        {
 4216            _logger.LogError(e, "Error saving fee rate to file");
 4217        }
 8218    }
 219
 220    private async Task LoadFromFileAsync(CancellationToken cancellationToken)
 221    {
 20222        _logger.LogDebug("Loading fee rate from file {filePath}", _cacheFilePath);
 223
 224        try
 225        {
 20226            if (!File.Exists(_cacheFilePath))
 227            {
 16228                _logger.LogDebug("Fee rate cache file does not exist. Skipping load.");
 16229                return;
 230            }
 231
 4232            await using var fileStream = File.OpenRead(_cacheFilePath);
 4233            var cacheData =
 4234                await MessagePackSerializer.DeserializeAsync<FeeRateCacheData?>(fileStream,
 4235                    cancellationToken: cancellationToken);
 236
 4237            if (cacheData == null)
 238            {
 0239                _logger.LogDebug("Fee rate cache file is empty. Skipping load.");
 0240                return;
 241            }
 242
 4243            _cachedFeeRate = cacheData.FeeRate;
 4244            _lastFetchTime = cacheData.LastFetchTime;
 4245        }
 0246        catch (OperationCanceledException)
 247        {
 248            // Ignore cancellation
 0249        }
 0250        catch (Exception e)
 251        {
 0252            _logger.LogError(e, "Error loading fee rate from file");
 0253        }
 20254    }
 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, FEE_CACHE_FILE_NAME);
 293        }
 294
 295        // Check if the file path is absolute or relative
 20296        return Path.IsPathRooted(filePath)
 20297            ? filePath
 20298            : Path.Combine(Directory.GetCurrentDirectory(), filePath); // If it's relative, combine it with the current 
 299    }
 300}