| | 1 | | using System.Text.Json; |
| | 2 | | using MessagePack; |
| | 3 | | using Microsoft.Extensions.Logging; |
| | 4 | | using Microsoft.Extensions.Options; |
| | 5 | |
|
| | 6 | | namespace NLightning.Application.NLTG.Services; |
| | 7 | |
|
| | 8 | | using Common.Options; |
| | 9 | | using Domain.Bitcoin.Services; |
| | 10 | | using Domain.Money; |
| | 11 | | using Models; |
| | 12 | |
|
| | 13 | | public class FeeService : IFeeService |
| | 14 | | { |
| | 15 | | private const string FEE_CACHE_FILE_NAME = "fee_cache.bin"; |
| 4 | 16 | | private static readonly TimeSpan s_defaultCacheExpiration = TimeSpan.FromMinutes(5); |
| | 17 | |
|
| 20 | 18 | | private DateTime _lastFetchTime = DateTime.MinValue; |
| 20 | 19 | | 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 | |
|
| 20 | 29 | | public FeeService(IOptions<FeeEstimationOptions> feeOptions, HttpClient httpClient, ILogger<FeeService> logger) |
| | 30 | | { |
| 20 | 31 | | _feeEstimationOptions = feeOptions.Value; |
| 20 | 32 | | _httpClient = httpClient; |
| 20 | 33 | | _logger = logger; |
| | 34 | |
|
| 20 | 35 | | _cacheFilePath = ParseFilePath(_feeEstimationOptions); |
| 20 | 36 | | _cacheTimeExpiration = ParseCacheTime(_feeEstimationOptions.CacheExpiration); |
| | 37 | |
|
| | 38 | | // Try to load from the file initially |
| 20 | 39 | | _ = LoadFromFileAsync(CancellationToken.None); |
| 20 | 40 | | } |
| | 41 | |
|
| | 42 | | public async Task StartAsync(CancellationToken cancellationToken) |
| | 43 | | { |
| 4 | 44 | | _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
| | 45 | |
|
| | 46 | | // Start the background task |
| 4 | 47 | | _feeTask = RunPeriodicRefreshAsync(_cts.Token); |
| | 48 | |
|
| | 49 | | // If the cache from the file is not valid, refresh immediately |
| 4 | 50 | | if (!IsCacheValid()) |
| | 51 | | { |
| 0 | 52 | | await RefreshFeeRateAsync(_cts.Token); |
| | 53 | | } |
| 4 | 54 | | } |
| | 55 | |
|
| | 56 | | public async Task StopAsync() |
| | 57 | | { |
| 4 | 58 | | if (_cts is null) |
| | 59 | | { |
| 0 | 60 | | throw new InvalidOperationException("Service is not running"); |
| | 61 | | } |
| | 62 | |
|
| 4 | 63 | | await _cts.CancelAsync(); |
| | 64 | |
|
| 4 | 65 | | if (_feeTask is not null) |
| | 66 | | { |
| | 67 | | try |
| | 68 | | { |
| 4 | 69 | | await _feeTask; |
| 4 | 70 | | } |
| 0 | 71 | | catch (OperationCanceledException) |
| | 72 | | { |
| | 73 | | // Expected during cancellation |
| 0 | 74 | | } |
| | 75 | | } |
| 4 | 76 | | } |
| | 77 | |
|
| | 78 | | public async Task<LightningMoney> GetFeeRatePerKwAsync(CancellationToken cancellationToken = default) |
| | 79 | | { |
| 12 | 80 | | if (IsCacheValid()) |
| | 81 | | { |
| 4 | 82 | | return _cachedFeeRate; |
| | 83 | | } |
| | 84 | |
|
| 8 | 85 | | using var linkedCts = CancellationTokenSource |
| 8 | 86 | | .CreateLinkedTokenSource(cancellationToken, _cts?.Token ?? CancellationToken.None); |
| | 87 | |
|
| 8 | 88 | | await RefreshFeeRateAsync(linkedCts.Token); |
| 8 | 89 | | return _cachedFeeRate; |
| 12 | 90 | | } |
| | 91 | |
|
| | 92 | | public LightningMoney GetCachedFeeRatePerKw() |
| | 93 | | { |
| 0 | 94 | | return _cachedFeeRate; |
| | 95 | | } |
| | 96 | |
|
| | 97 | | public async Task RefreshFeeRateAsync(CancellationToken cancellationToken) |
| | 98 | | { |
| | 99 | | try |
| | 100 | | { |
| 24 | 101 | | var feeRate = await FetchFeeRateFromApiAsync(cancellationToken); |
| 8 | 102 | | _cachedFeeRate.Satoshi = feeRate; |
| 8 | 103 | | _lastFetchTime = DateTime.UtcNow; |
| 8 | 104 | | await SaveToFileAsync(); |
| 8 | 105 | | } |
| 4 | 106 | | catch (OperationCanceledException) |
| | 107 | | { |
| | 108 | | // Ignore cancellation |
| 4 | 109 | | } |
| 12 | 110 | | catch (Exception e) |
| | 111 | | { |
| 12 | 112 | | _logger.LogError(e, "Error fetching fee rate from API"); |
| 12 | 113 | | } |
| 24 | 114 | | } |
| | 115 | |
|
| | 116 | | private async Task<long> FetchFeeRateFromApiAsync(CancellationToken cancellationToken) |
| | 117 | | { |
| | 118 | | HttpResponseMessage response; |
| | 119 | |
|
| | 120 | | try |
| | 121 | | { |
| 24 | 122 | | if (_feeEstimationOptions.Method.Equals("GET", StringComparison.CurrentCultureIgnoreCase)) |
| | 123 | | { |
| 24 | 124 | | response = await _httpClient.GetAsync(_feeEstimationOptions.Url, cancellationToken); |
| | 125 | | } |
| | 126 | | else // POST |
| | 127 | | { |
| 0 | 128 | | var content = new StringContent( |
| 0 | 129 | | _feeEstimationOptions.Body, |
| 0 | 130 | | System.Text.Encoding.UTF8, |
| 0 | 131 | | _feeEstimationOptions.ContentType); |
| | 132 | |
|
| 0 | 133 | | response = await _httpClient.PostAsync(_feeEstimationOptions.Url, content, cancellationToken); |
| | 134 | | } |
| 16 | 135 | | } |
| 8 | 136 | | catch (Exception e) |
| | 137 | | { |
| 8 | 138 | | throw new InvalidOperationException("Error fetching from API", e); |
| | 139 | | } |
| | 140 | |
|
| 16 | 141 | | response.EnsureSuccessStatusCode(); |
| 16 | 142 | | var jsonResponseStream = await response.Content.ReadAsStreamAsync(cancellationToken); |
| | 143 | |
|
| | 144 | | // Parse the JSON response |
| 16 | 145 | | using var document = |
| 16 | 146 | | await JsonDocument.ParseAsync(jsonResponseStream, cancellationToken: cancellationToken); |
| 8 | 147 | | var root = document.RootElement; |
| | 148 | |
|
| | 149 | | // Extract the preferred fee rate from the JSON response |
| 8 | 150 | | if (!root.TryGetProperty(_feeEstimationOptions.PreferredFeeRate, out var feeRateElement)) |
| | 151 | | { |
| 0 | 152 | | throw new InvalidOperationException( |
| 0 | 153 | | $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response."); |
| | 154 | | } |
| | 155 | |
|
| | 156 | | // Parse the fee rate value |
| 8 | 157 | | if (!feeRateElement.TryGetDecimal(out var feeRate)) |
| | 158 | | { |
| 0 | 159 | | throw new InvalidOperationException( |
| 0 | 160 | | $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response."); |
| | 161 | | } |
| | 162 | |
|
| | 163 | | // Apply the multiplier to convert to sat/kw |
| 8 | 164 | | if (decimal.TryParse(_feeEstimationOptions.RateMultiplier, out var multiplier)) |
| | 165 | | { |
| 8 | 166 | | return (long)(feeRate * multiplier); |
| | 167 | | } |
| | 168 | |
|
| 0 | 169 | | throw new InvalidOperationException( |
| 0 | 170 | | $"Could not extract {_feeEstimationOptions.PreferredFeeRate} from API response."); |
| 8 | 171 | | } |
| | 172 | |
|
| | 173 | | private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken) |
| | 174 | | { |
| | 175 | | try |
| | 176 | | { |
| 12 | 177 | | while (!cancellationToken.IsCancellationRequested) |
| | 178 | | { |
| | 179 | | // Refresh if it's not canceled |
| 12 | 180 | | if (!cancellationToken.IsCancellationRequested) |
| | 181 | | { |
| 12 | 182 | | await RefreshFeeRateAsync(cancellationToken); |
| | 183 | |
|
| | 184 | | // Wait for the cache time or until cancellation |
| 12 | 185 | | await Task.Delay(_cacheTimeExpiration, cancellationToken); |
| | 186 | | } |
| | 187 | | } |
| 0 | 188 | | } |
| 4 | 189 | | catch (OperationCanceledException) |
| | 190 | | { |
| 4 | 191 | | _logger.LogInformation("Stopping fee service"); |
| 4 | 192 | | } |
| 0 | 193 | | catch (Exception ex) |
| | 194 | | { |
| 0 | 195 | | _logger.LogError(ex, "Unhandled exception in fee service"); |
| 0 | 196 | | } |
| 4 | 197 | | } |
| | 198 | |
|
| | 199 | | private async Task SaveToFileAsync() |
| | 200 | | { |
| 8 | 201 | | _logger.LogDebug("Saving fee rate to file {filePath}", _cacheFilePath); |
| | 202 | |
|
| | 203 | | try |
| | 204 | | { |
| 8 | 205 | | var cacheData = new FeeRateCacheData |
| 8 | 206 | | { |
| 8 | 207 | | FeeRate = _cachedFeeRate, |
| 8 | 208 | | LastFetchTime = _lastFetchTime |
| 8 | 209 | | }; |
| | 210 | |
|
| 8 | 211 | | await using var fileStream = File.OpenWrite(_cacheFilePath); |
| 4 | 212 | | await MessagePackSerializer.SerializeAsync(fileStream, cacheData, cancellationToken: CancellationToken.None) |
| 4 | 213 | | } |
| 4 | 214 | | catch (Exception e) |
| | 215 | | { |
| 4 | 216 | | _logger.LogError(e, "Error saving fee rate to file"); |
| 4 | 217 | | } |
| 8 | 218 | | } |
| | 219 | |
|
| | 220 | | private async Task LoadFromFileAsync(CancellationToken cancellationToken) |
| | 221 | | { |
| 20 | 222 | | _logger.LogDebug("Loading fee rate from file {filePath}", _cacheFilePath); |
| | 223 | |
|
| | 224 | | try |
| | 225 | | { |
| 20 | 226 | | if (!File.Exists(_cacheFilePath)) |
| | 227 | | { |
| 16 | 228 | | _logger.LogDebug("Fee rate cache file does not exist. Skipping load."); |
| 16 | 229 | | return; |
| | 230 | | } |
| | 231 | |
|
| 4 | 232 | | await using var fileStream = File.OpenRead(_cacheFilePath); |
| 4 | 233 | | var cacheData = |
| 4 | 234 | | await MessagePackSerializer.DeserializeAsync<FeeRateCacheData?>(fileStream, |
| 4 | 235 | | cancellationToken: cancellationToken); |
| | 236 | |
|
| 4 | 237 | | if (cacheData == null) |
| | 238 | | { |
| 0 | 239 | | _logger.LogDebug("Fee rate cache file is empty. Skipping load."); |
| 0 | 240 | | return; |
| | 241 | | } |
| | 242 | |
|
| 4 | 243 | | _cachedFeeRate = cacheData.FeeRate; |
| 4 | 244 | | _lastFetchTime = cacheData.LastFetchTime; |
| 4 | 245 | | } |
| 0 | 246 | | catch (OperationCanceledException) |
| | 247 | | { |
| | 248 | | // Ignore cancellation |
| 0 | 249 | | } |
| 0 | 250 | | catch (Exception e) |
| | 251 | | { |
| 0 | 252 | | _logger.LogError(e, "Error loading fee rate from file"); |
| 0 | 253 | | } |
| 20 | 254 | | } |
| | 255 | |
|
| | 256 | | private bool IsCacheValid() |
| | 257 | | { |
| 16 | 258 | | 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" |
| 20 | 266 | | var valueStr = new string(cacheTime.Where(char.IsDigit).ToArray()); |
| 20 | 267 | | var unit = new string(cacheTime.Where(char.IsLetter).ToArray()).ToLowerInvariant(); |
| | 268 | |
|
| 20 | 269 | | if (!int.TryParse(valueStr, out var value)) |
| 0 | 270 | | return s_defaultCacheExpiration; |
| | 271 | |
|
| 20 | 272 | | return unit switch |
| 20 | 273 | | { |
| 4 | 274 | | "s" or "second" or "seconds" => TimeSpan.FromSeconds(value), |
| 16 | 275 | | "m" or "minute" or "minutes" => TimeSpan.FromMinutes(value), |
| 0 | 276 | | "h" or "hour" or "hours" => TimeSpan.FromHours(value), |
| 0 | 277 | | "d" or "day" or "days" => TimeSpan.FromDays(value), |
| 0 | 278 | | _ => TimeSpan.FromMinutes(5) |
| 20 | 279 | | }; |
| | 280 | | } |
| 0 | 281 | | catch |
| | 282 | | { |
| 0 | 283 | | return s_defaultCacheExpiration; // Default on error |
| | 284 | | } |
| 20 | 285 | | } |
| | 286 | |
|
| | 287 | | private static string ParseFilePath(FeeEstimationOptions feeEstimationOptions) |
| | 288 | | { |
| 20 | 289 | | var filePath = feeEstimationOptions.CacheFile; |
| 20 | 290 | | if (string.IsNullOrWhiteSpace(filePath)) |
| | 291 | | { |
| 0 | 292 | | return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FEE_CACHE_FILE_NAME); |
| | 293 | | } |
| | 294 | |
|
| | 295 | | // Check if the file path is absolute or relative |
| 20 | 296 | | return Path.IsPathRooted(filePath) |
| 20 | 297 | | ? filePath |
| 20 | 298 | | : Path.Combine(Directory.GetCurrentDirectory(), filePath); // If it's relative, combine it with the current |
| | 299 | | } |
| | 300 | | } |