Support pluginable rate providers (#5777)

* Support pluginable rate providers

This PR allows plugins to provide custom rate providers, that can be contextual to a store. For example, if you use the upcoming fiat offramp plugin, or the Blink plugin, you'll probably want to configure the fetch the rates from them since they are determining the actual fiat rrate to you. However, they require API keys. This PR enables these scenarios, even much more advanced ones, but for example:
* Install fiat offramp plugin
* Configure it
* You can now use the fiat offramp rate provider (no additional config steps beyond selecting the rate source from the select, or maybe the plugin would automatically set it for you once configured)

* Apply suggestions from code review

* Simplify

* Do not use BackgroundFetcherRateProvider for contextual rate prov

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2024-04-30 11:31:15 +02:00 committed by GitHub
parent 4821f77304
commit 6049fa23a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 152 additions and 50 deletions

View file

@ -1,9 +1,26 @@
#nullable enable
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Services.Rates;
namespace BTCPayServer.Rating
{
public static class Extensions
{
public static Task<PairRate[]> GetRatesAsyncWithMaybeContext(this IRateProvider rateProvider, IRateContext? context, CancellationToken cancellationToken)
{
if (rateProvider is IContextualRateProvider contextualRateProvider && context is { })
{
return contextualRateProvider.GetRatesAsync(context, cancellationToken);
}
else
{
return rateProvider.GetRatesAsync(cancellationToken);
}
}
public static decimal RoundToSignificant(this decimal value, int divisibility)
{
return RoundToSignificant(value, ref divisibility);

View file

@ -53,7 +53,7 @@ namespace BTCPayServer.Services.Rates
/// <summary>
/// This class is a decorator which handle caching and pre-emptive query to the underlying rate provider
/// </summary>
public class BackgroundFetcherRateProvider : IRateProvider
public class BackgroundFetcherRateProvider : IContextualRateProvider
{
public class LatestFetch
{
@ -63,6 +63,9 @@ namespace BTCPayServer.Services.Rates
public DateTimeOffset Updated;
public DateTimeOffset Expiration;
public Exception Exception;
public IRateContext Context { get; internal set; }
internal PairRate[] GetResult()
{
if (Expiration <= DateTimeOffset.UtcNow)
@ -185,7 +188,7 @@ namespace BTCPayServer.Services.Rates
{
try
{
await Fetch(cancellationToken);
await Fetch(_Latest?.Context, cancellationToken);
}
catch { } // Exception is inside _Latest
return _Latest;
@ -194,7 +197,11 @@ namespace BTCPayServer.Services.Rates
}
LatestFetch _Latest;
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
public Task<PairRate[]> GetRatesAsync(IRateContext context, CancellationToken cancellationToken)
{
return GetRatesAsyncCore(context, cancellationToken);
}
async Task<PairRate[]> GetRatesAsyncCore(IRateContext context, CancellationToken cancellationToken)
{
LastRequested = DateTimeOffset.UtcNow;
var latest = _Latest;
@ -202,7 +209,11 @@ namespace BTCPayServer.Services.Rates
{
latest = null;
}
return (latest ?? (await Fetch(cancellationToken))).GetResult();
return (latest ?? (await Fetch(context, cancellationToken))).GetResult();
}
public Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
return GetRatesAsyncCore(null, cancellationToken);
}
/// <summary>
@ -224,15 +235,16 @@ namespace BTCPayServer.Services.Rates
public RateSourceInfo RateSourceInfo => _Inner.RateSourceInfo;
private async Task<LatestFetch> Fetch(CancellationToken cancellationToken)
private async Task<LatestFetch> Fetch(IRateContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var previous = _Latest;
var fetch = new LatestFetch();
try
{
var rates = await _Inner.GetRatesAsync(cancellationToken);
var rates = await _Inner.GetRatesAsyncWithMaybeContext(context, cancellationToken);
fetch.Latest = rates;
fetch.Context = context;
fetch.Updated = DateTimeOffset.UtcNow;
fetch.Expiration = fetch.Updated + ValidatyTime;
fetch.NextRefresh = fetch.Updated + RefreshRate;

View file

@ -1,3 +1,5 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
@ -7,6 +9,33 @@ namespace BTCPayServer.Services.Rates
public interface IRateProvider
{
RateSourceInfo RateSourceInfo { get; }
/// <summary>
/// Returns rates of the provider
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException">If using this provider isn't supported (For example if a <see cref="IContextualRateProvider"/> requires a context)</exception>
Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken);
}
public interface IRateContext { }
public interface IHasStoreIdRateContext : IRateContext
{
string StoreId { get; }
}
public record StoreIdRateContext(string StoreId) : IHasStoreIdRateContext;
/// <summary>
/// A rate provider which know additional context about the rate query.
/// </summary>
public interface IContextualRateProvider : IRateProvider
{
/// <summary>
/// Returns rates of the provider when a context is available
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException">If using this provider isn't getting an expected context</exception>
Task<PairRate[]> GetRatesAsync(IRateContext context, CancellationToken cancellationToken);
}
}

View file

@ -22,7 +22,7 @@ namespace BTCPayServer.Services.Rates
public BidAsk BidAsk { get; set; }
public TimeSpan Latency { get; internal set; }
}
#nullable enable
public class RateFetcher
{
private readonly RateProviderFactory _rateProviderFactory;
@ -34,17 +34,18 @@ namespace BTCPayServer.Services.Rates
public RateProviderFactory RateProviderFactory => _rateProviderFactory;
public async Task<RateResult> FetchRate(CurrencyPair pair, RateRules rules, CancellationToken cancellationToken)
public async Task<RateResult> FetchRate(CurrencyPair pair, RateRules rules, IRateContext? context, CancellationToken cancellationToken)
{
return await FetchRates(new HashSet<CurrencyPair>(new[] { pair }), rules, cancellationToken).First().Value;
return await FetchRates(new HashSet<CurrencyPair>(new[] { pair }), rules, context, cancellationToken).First().Value;
}
public Dictionary<CurrencyPair, Task<RateResult>> FetchRates(HashSet<CurrencyPair> pairs, RateRules rules, CancellationToken cancellationToken)
public Dictionary<CurrencyPair, Task<RateResult>> FetchRates(HashSet<CurrencyPair> pairs, RateRules rules, IRateContext? context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(rules);
var fetchingRates = new Dictionary<CurrencyPair, Task<RateResult>>();
var fetchingExchanges = new Dictionary<string, Task<QueryRateResult>>();
var consolidatedRates = new ExchangeRates();
foreach (var i in pairs.Select(p => (Pair: p, RateRule: rules.GetRuleFor(p))))
{
@ -53,7 +54,7 @@ namespace BTCPayServer.Services.Rates
{
if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching))
{
fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, cancellationToken);
fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, context, cancellationToken);
fetchingExchanges.Add(requiredExchange.Exchange, fetching);
}
dependentQueries.Add(fetching);
@ -63,7 +64,7 @@ namespace BTCPayServer.Services.Rates
return fetchingRates;
}
public Task<RateResult> FetchRate(RateRule rateRule, CancellationToken cancellationToken)
public Task<RateResult> FetchRate(RateRule rateRule, IRateContext? context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(rateRule);
var fetchingExchanges = new Dictionary<string, Task<QueryRateResult>>();
@ -72,7 +73,7 @@ namespace BTCPayServer.Services.Rates
{
if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching))
{
fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, cancellationToken);
fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, context, cancellationToken);
fetchingExchanges.Add(requiredExchange.Exchange, fetching);
}
dependentQueries.Add(fetching);

View file

@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@ -15,22 +16,21 @@ namespace BTCPayServer.Services.Rates
{
public class RateProviderFactory
{
class WrapperRateProvider : IRateProvider
class WrapperRateProvider
{
public RateSourceInfo RateSourceInfo => _inner.RateSourceInfo;
private readonly IRateProvider _inner;
public Exception Exception { get; private set; }
public Exception? Exception { get; private set; }
public TimeSpan Latency { get; set; }
public WrapperRateProvider(IRateProvider inner)
{
_inner = inner;
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
public async Task<PairRate[]> GetRatesAsync(IRateContext? context, CancellationToken cancellationToken)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
try
{
return await _inner.GetRatesAsync(cancellationToken);
return await _inner.GetRatesAsyncWithMaybeContext(context, cancellationToken);
}
catch (Exception ex)
{
@ -45,12 +45,19 @@ namespace BTCPayServer.Services.Rates
}
public class QueryRateResult
{
public QueryRateResult(string exchangeName, TimeSpan latency, PairRate[] pairRates)
{
Exchange = exchangeName;
Latency = latency;
PairRates = pairRates;
}
public TimeSpan Latency { get; set; }
public PairRate[] PairRates { get; set; }
public ExchangeException Exception { get; internal set; }
public ExchangeException? Exception { get; internal set; }
public string Exchange { get; internal set; }
}
public RateProviderFactory(IHttpClientFactory httpClientFactory, IEnumerable<IRateProvider> rateProviders)
public RateProviderFactory(IHttpClientFactory httpClientFactory,IEnumerable<IRateProvider> rateProviders)
{
_httpClientFactory = httpClientFactory;
foreach (var prov in rateProviders)
@ -65,10 +72,18 @@ namespace BTCPayServer.Services.Rates
{
foreach (var provider in Providers.ToArray())
{
var prov = new BackgroundFetcherRateProvider(Providers[provider.Key]);
prov.RefreshRate = TimeSpan.FromMinutes(1.0);
prov.ValidatyTime = TimeSpan.FromMinutes(5.0);
Providers[provider.Key] = prov;
var prov = Providers[provider.Key];
if (prov is IContextualRateProvider)
{
Providers[provider.Key] = prov;
}
else
{
var prov2 = new BackgroundFetcherRateProvider(prov);
prov2.RefreshRate = TimeSpan.FromMinutes(1.0);
prov2.ValidatyTime = TimeSpan.FromMinutes(5.0);
Providers[provider.Key] = prov2;
}
var rsi = provider.Value.RateSourceInfo;
AvailableRateProviders.Add(new(rsi.Id, rsi.DisplayName, rsi.Url));
}
@ -93,18 +108,15 @@ namespace BTCPayServer.Services.Rates
public List<RateSourceInfo> AvailableRateProviders { get; } = new List<RateSourceInfo>();
public async Task<QueryRateResult> QueryRates(string exchangeName, CancellationToken cancellationToken)
public async Task<QueryRateResult> QueryRates(string exchangeName, IRateContext? context = null, CancellationToken cancellationToken = default)
{
Providers.TryGetValue(exchangeName, out var directProvider);
directProvider = directProvider ?? NullRateProvider.Instance;
directProvider ??= NullRateProvider.Instance;
var wrapper = new WrapperRateProvider(directProvider);
var value = await wrapper.GetRatesAsync(cancellationToken);
return new QueryRateResult()
var value = await wrapper.GetRatesAsync(context, cancellationToken);
return new QueryRateResult(exchangeName, wrapper.Latency, value)
{
Exchange = exchangeName,
Latency = wrapper.Latency,
PairRates = value,
Exception = wrapper.Exception != null ? new ExchangeException() { Exception = wrapper.Exception, ExchangeName = exchangeName } : null
};
}

View file

@ -1110,6 +1110,19 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.True(parsers.TryParseWalletFile(electrumText, mainnet, out electrum, out _));
}
[Fact]
public async Task CanPassContextToRateProviders()
{
var factory = CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
Assert.True(RateRules.TryParse("X_X=spy(X_X)", out var rule));
var result = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rule, null, default);
Assert.Single(result.Errors);
result = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rule, new StoreIdRateContext("hello"), default);
Assert.Empty(result.Errors);
Assert.Equal(SpyContextualRateProvider.ExpectedBidAsk, result.BidAsk);
}
[Fact]
public async Task CheckRatesProvider()
{
@ -1123,15 +1136,15 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
var fetch = new BackgroundFetcherRateProvider(spy);
fetch.DoNotAutoFetchIfExpired = true;
factory.Providers.Add("bitpay", fetch);
var fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, default);
var fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, null, default);
spy.AssertHit();
fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, default);
fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, null, default);
spy.AssertNotHit();
await fetch.UpdateIfNecessary(default);
spy.AssertNotHit();
fetch.RefreshRate = TimeSpan.FromSeconds(1.0);
Thread.Sleep(1020);
fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, default);
fetchedRate = await fetcher.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules, null, default);
spy.AssertNotHit();
fetch.ValidatyTime = TimeSpan.FromSeconds(1.0);
await fetch.UpdateIfNecessary(default);
@ -1141,11 +1154,27 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
await Assert.ThrowsAsync<InvalidOperationException>(() => fetch.GetRatesAsync(default));
}
class SpyContextualRateProvider : IContextualRateProvider
{
public static BidAsk ExpectedBidAsk = new BidAsk(1.12345m);
public RateSourceInfo RateSourceInfo => new RateSourceInfo("spy", "hello world", "abc...");
public Task<PairRate[]> GetRatesAsync(IRateContext context, CancellationToken cancellationToken)
{
Assert.IsAssignableFrom<IHasStoreIdRateContext>(context);
return Task.FromResult(new [] { new PairRate(new CurrencyPair("BTC", "USD"), ExpectedBidAsk) });
}
public Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
public static RateProviderFactory CreateBTCPayRateFactory()
{
ServiceCollection services = new ServiceCollection();
services.AddHttpClient();
BTCPayServerServices.RegisterRateSources(services);
services.AddRateProvider<SpyContextualRateProvider>();
var o = services.BuildServiceProvider();
return new RateProviderFactory(TestUtils.CreateHttpFactory(), o.GetService<IEnumerable<IRateProvider>>());
}

View file

@ -346,7 +346,7 @@ retry:
Assert.True(RateRules.TryParse("X_X=kraken(X_BTC) * kraken(BTC_X)", out var rule));
foreach (var pair in new[] { "DOGE_USD", "DOGE_CAD", "DASH_CAD", "DASH_USD", "DASH_EUR" })
{
var result = fetcher.FetchRate(CurrencyPair.Parse(pair), rule, default).GetAwaiter().GetResult();
var result = fetcher.FetchRate(CurrencyPair.Parse(pair), rule, null, default).GetAwaiter().GetResult();
Assert.NotNull(result.BidAsk);
Assert.Empty(result.Errors);
}
@ -365,7 +365,7 @@ retry:
b.DefaultCurrency = k.Key;
var rules = b.GetDefaultRateRules(provider);
var pairs = new[] { CurrencyPair.Parse($"BTC_{k.Key}") }.ToHashSet();
var result = fetcher.FetchRates(pairs, rules, default);
var result = fetcher.FetchRates(pairs, rules, null, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}");
@ -410,7 +410,7 @@ retry:
}
var rules = new StoreBlob().GetDefaultRateRules(provider);
var result = fetcher.FetchRates(pairs, rules, cts.Token);
var result = fetcher.FetchRates(pairs, rules, null, cts.Token);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
var rateResult = await value;

View file

@ -73,7 +73,7 @@ namespace BTCPayServer.Components.WalletNav
if (defaultCurrency != network.CryptoCode)
{
var rule = store.GetStoreBlob().GetRateRules(_networkProvider)?.GetRuleFor(new Rating.CurrencyPair(network.CryptoCode, defaultCurrency));
var bid = rule is null ? null : (await _rateFetcher.FetchRate(rule, HttpContext.RequestAborted)).BidAsk?.Bid;
var bid = rule is null ? null : (await _rateFetcher.FetchRate(rule, new StoreIdRateContext(walletId.StoreId), HttpContext.RequestAborted)).BidAsk?.Bid;
if (bid is decimal b)
{
var currencyData = _currencies.GetCurrencyData(defaultCurrency, true);

View file

@ -137,7 +137,7 @@ namespace BTCPayServer.Controllers
pairs.Add(pair);
}
var fetching = _rateProviderFactory.FetchRates(pairs, rules, cancellationToken);
var fetching = _rateProviderFactory.FetchRates(pairs, rules, new StoreIdRateContext(storeId), cancellationToken);
await Task.WhenAll(fetching.Select(f => f.Value).ToArray());
return Json(pairs
.Select(r => (Pair: r, Value: fetching[r].GetAwaiter().GetResult().BidAsk?.Bid))

View file

@ -414,8 +414,9 @@ namespace BTCPayServer.Controllers.Greenfield
var paidCurrency = Math.Round(cryptoPaid * paymentPrompt.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate(
new CurrencyPair(paymentPrompt.Currency, invoice.Currency),
store.GetStoreBlob().GetRateRules(_networkProvider),
cancellationToken
store.GetStoreBlob().GetRateRules(_networkProvider), new StoreIdRateContext(storeId),
cancellationToken
);
var paidAmount = cryptoPaid.RoundToSignificant(paymentPrompt.Divisibility);
var createPullPayment = new CreatePullPayment

View file

@ -120,7 +120,7 @@ namespace BTCPayServer.Controllers.GreenField
var rules = blob.GetRateRules(_btcPayNetworkProvider);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, CancellationToken.None);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, new StoreIdRateContext(data.Id), CancellationToken.None);
await Task.WhenAll(rateTasks.Values);
var result = new List<StoreRateResult>();
foreach (var rateTask in rateTasks)

View file

@ -64,7 +64,7 @@ namespace BTCPayServer.Controllers.GreenField
var rules = blob.GetRateRules(_btcPayNetworkProvider);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, CancellationToken.None);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, new StoreIdRateContext(data.Id), CancellationToken.None);
await Task.WhenAll(rateTasks.Values);
var result = new List<StoreRateResult>();
foreach (var rateTask in rateTasks)

View file

@ -391,7 +391,7 @@ namespace BTCPayServer.Controllers
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodCurrency);
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodCurrency, invoice.Currency), rules,
new CurrencyPair(paymentMethodCurrency, invoice.Currency), rules, new StoreIdRateContext(store.Id),
cancellationToken);
//TODO: What if fetching rate failed?
if (rateResult.BidAsk is null)
@ -500,7 +500,7 @@ namespace BTCPayServer.Controllers
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodCurrency, model.CustomCurrency), rules,
new CurrencyPair(paymentMethodCurrency, model.CustomCurrency), rules, new StoreIdRateContext(store.Id),
cancellationToken);
//TODO: What if fetching rate failed?

View file

@ -11,6 +11,7 @@ using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -124,7 +125,7 @@ public partial class UIStoresController
pairs.Add(currencyPair);
}
var fetchs = _rateFactory.FetchRates(pairs.ToHashSet(), rules, cancellationToken);
var fetchs = _rateFactory.FetchRates(pairs.ToHashSet(), rules, new StoreIdRateContext(model.StoreId), cancellationToken);
var testResults = new List<RatesViewModel.TestResultViewModel>();
foreach (var fetch in fetchs)
{

View file

@ -531,7 +531,7 @@ namespace BTCPayServer.Controllers
try
{
cts.CancelAfter(TimeSpan.FromSeconds(5));
var result = await RateFetcher.FetchRate(currencyPair, rateRules, cts.Token)
var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token)
.WithCancellation(cts.Token);
if (result.BidAsk != null)
{

View file

@ -412,7 +412,7 @@ namespace BTCPayServer.HostedServices
throw new FormatException("Invalid RateRule");
}
return _rateFetcher.FetchRate(rule, cancellationToken);
return _rateFetcher.FetchRate(rule, new StoreIdRateContext(payout.StoreDataId), cancellationToken);
}
public Task<PayoutApproval.ApprovalResult> Approve(PayoutApproval approval)

View file

@ -75,7 +75,7 @@ namespace BTCPayServer.HostedServices
await Task.WhenAll(usedProviders
.Select(p => p.Fetcher.UpdateIfNecessary(timeout.Token).ContinueWith(t =>
{
if (t.Result.Exception != null)
if (t.Result.Exception != null && t.Result.Exception is not NotSupportedException)
{
Logs.PayServer.LogWarning($"Error while contacting exchange {p.ExchangeName}: {t.Result.Exception.Message}");
}

View file

@ -163,7 +163,7 @@ namespace BTCPayServer.Payments
public async Task FetchingRates(RateFetcher rateFetcher, RateRules rateRules, CancellationToken cancellationToken)
{
var currencyPairsToFetch = GetCurrenciesToFetch();
var fetchingRates = rateFetcher.FetchRates(currencyPairsToFetch, rateRules, cancellationToken);
var fetchingRates = rateFetcher.FetchRates(currencyPairsToFetch, rateRules, new StoreIdRateContext(InvoiceEntity.StoreId), cancellationToken);
HashSet<CurrencyPair> failedRates = new HashSet<CurrencyPair>();
foreach (var fetching in fetchingRates)
{