Recommended exchange to be resolved during Invoice Creation (#5976)

* Recommended Exchange Rate Selection during Invoice Creation

* Make Recommended exchanges pluginifiable
This commit is contained in:
Nicolas Dorier
2024-05-13 22:29:42 +09:00
committed by GitHub
parent b0da802abe
commit d96b066658
26 changed files with 255 additions and 166 deletions

File diff suppressed because one or more lines are too long

View File

@@ -203,7 +203,7 @@ namespace BTCPayServer.Rating
return (ExpressionSyntax)CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script)).GetRoot().ChildNodes().First().ChildNodes().First().ChildNodes().First();
}
public static RateRules Combine(RateRules[] rateRules)
public static RateRules Combine(IEnumerable<RateRules> rateRules)
{
var str = string.Join(Environment.NewLine, rateRules.Select(r => r.ToString()));
return Parse(str);

View File

@@ -4284,7 +4284,7 @@ namespace BTCPayServer.Tests
config = await clientBasic.GetStoreRateConfiguration(user.StoreId);
Assert.Equal("X_X = coingecko(X_X);", config.EffectiveScript);
await AssertValidationError(new[] { "EffectiveScript", "PreferredSource" }, () =>
await AssertValidationError(new[] { "EffectiveScript" }, () =>
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, EffectiveScript = "BTC_XYZ = 1;" }));
await AssertValidationError(new[] { "EffectiveScript" }, () =>

View File

@@ -196,7 +196,7 @@ retry:
TestLogs.LogInformation($"Created store {name}");
Driver.WaitForElement(By.Id("Name")).SendKeys(name);
var rateSource = new SelectElement(Driver.FindElement(By.Id("PreferredExchange")));
Assert.Equal("Kraken (Recommended)", rateSource.SelectedOption.Text);
Assert.Equal("Recommendation (Kraken)", rateSource.SelectedOption.Text);
rateSource.SelectByText("CoinGecko");
Driver.WaitForElement(By.Id("Create")).Click();
Driver.FindElement(By.Id("StoreNav-StoreSettings")).Click();

View File

@@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Hosting;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Rating;
@@ -357,12 +358,13 @@ retry:
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
var provider = CreateDefaultRates(ChainName.Mainnet);
var defaultRules = new DefaultRulesCollection(provider.Select(p => p.DefaultRates));
var b = new StoreBlob();
string[] temporarilyBroken = Array.Empty<string>();
foreach (var k in StoreBlob.RecommendedExchanges)
foreach (var k in defaultRules.RecommendedExchanges)
{
b.DefaultCurrency = k.Key;
var rules = b.GetDefaultRateRules(provider.Select(p => p.DefaultRates));
var rules = b.GetDefaultRateRules(defaultRules);
var pairs = new[] { CurrencyPair.Parse($"BTC_{k.Key}") }.ToHashSet();
var result = fetcher.FetchRates(pairs, rules, null, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
@@ -389,11 +391,13 @@ retry:
public async Task CanGetRateCryptoCurrenciesByDefault()
{
using var cts = new CancellationTokenSource(60_000);
var provider = CreateDefaultRates(ChainName.Mainnet);
var provider = CreateDefaultRates(ChainName.Mainnet, exchangeRecommendation: true);
var defaultRules = new DefaultRulesCollection(provider.Select(p => p.DefaultRates));
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
var pairs =
provider
.Where(c => c.CryptoCode is not null)
.Select(c => new CurrencyPair(c.CryptoCode, "USD"))
.ToHashSet();
@@ -408,7 +412,7 @@ retry:
}
}
var rules = new StoreBlob().GetDefaultRateRules(provider.Select(p => p.DefaultRates));
var rules = new StoreBlob().GetDefaultRateRules(defaultRules);
var result = fetcher.FetchRates(pairs, rules, null, cts.Token);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
@@ -418,13 +422,22 @@ retry:
}
}
private IEnumerable<(string CryptoCode, DefaultRates DefaultRates)> CreateDefaultRates(ChainName chainName)
private IEnumerable<(string CryptoCode, DefaultRules DefaultRates)> CreateDefaultRates(ChainName chainName, bool exchangeRecommendation = false)
{
var results = new List<(string CryptoCode, DefaultRates DefaultRates)>();
var results = new List<(string CryptoCode, DefaultRules DefaultRates)>();
var prov = CreateNetworkProvider(chainName);
foreach (var network in prov.GetAll())
{
results.Add((network.CryptoCode, new DefaultRates(network.DefaultRateRules)));
results.Add((network.CryptoCode, new DefaultRules(network.DefaultRateRules)));
}
if (exchangeRecommendation)
{
ServiceCollection services = new ServiceCollection();
BTCPayServerServices.RegisterExchangeRecommendations(services);
foreach (var rule in services.BuildServiceProvider().GetRequiredService<IEnumerable<DefaultRules>>())
{
results.Add((null, rule));
}
}
return results;
}

View File

@@ -31,7 +31,7 @@ namespace BTCPayServer.Components.WalletNav
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly UIWalletsController _walletsController;
private readonly CurrencyNameTable _currencies;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly RateFetcher _rateFetcher;
public WalletNav(
@@ -39,14 +39,14 @@ namespace BTCPayServer.Components.WalletNav
PaymentMethodHandlerDictionary handlers,
UIWalletsController walletsController,
CurrencyNameTable currencies,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
RateFetcher rateFetcher)
{
_walletProvider = walletProvider;
_handlers = handlers;
_walletsController = walletsController;
_currencies = currencies;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_rateFetcher = rateFetcher;
}
@@ -76,7 +76,7 @@ namespace BTCPayServer.Components.WalletNav
if (defaultCurrency != network.CryptoCode)
{
var rule = store.GetStoreBlob().GetRateRules(_defaultRates)?.GetRuleFor(new Rating.CurrencyPair(network.CryptoCode, defaultCurrency));
var rule = store.GetStoreBlob().GetRateRules(_defaultRules)?.GetRuleFor(new Rating.CurrencyPair(network.CryptoCode, defaultCurrency));
var bid = rule is null ? null : (await _rateFetcher.FetchRate(rule, new StoreIdRateContext(walletId.StoreId), HttpContext.RequestAborted)).BidAsk?.Bid;
if (bid is decimal b)
{

View File

@@ -31,7 +31,7 @@ namespace BTCPayServer.Controllers
readonly RateFetcher _rateProviderFactory;
readonly CurrencyNameTable _currencyNameTable;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly PaymentMethodHandlerDictionary _handlers;
readonly StoreRepository _storeRepo;
private readonly InvoiceRepository _invoiceRepository;
@@ -43,14 +43,14 @@ namespace BTCPayServer.Controllers
StoreRepository storeRepo,
InvoiceRepository invoiceRepository,
CurrencyNameTable currencyNameTable,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
PaymentMethodHandlerDictionary handlers)
{
_rateProviderFactory = rateProviderFactory ?? throw new ArgumentNullException(nameof(rateProviderFactory));
_storeRepo = storeRepo;
_invoiceRepository = invoiceRepository;
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_handlers = handlers;
}
@@ -124,7 +124,7 @@ namespace BTCPayServer.Controllers
}
}
var rules = store.GetStoreBlob().GetRateRules(_defaultRates);
var rules = store.GetStoreBlob().GetRateRules(_defaultRules);
var pairs = new HashSet<CurrencyPair>();
foreach (var currency in currencyPairs.Split(','))
{

View File

@@ -48,7 +48,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly Dictionary<PaymentMethodId, IPaymentLinkExtension> _paymentLinkExtensions;
private readonly PayoutMethodHandlerDictionary _payoutHandlers;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
public LanguageService LanguageService { get; }
@@ -62,7 +62,7 @@ namespace BTCPayServer.Controllers.Greenfield
Dictionary<PaymentMethodId, IPaymentLinkExtension> paymentLinkExtensions,
PayoutMethodHandlerDictionary payoutHandlers,
PaymentMethodHandlerDictionary handlers,
IEnumerable<DefaultRates> defaultRates)
DefaultRulesCollection defaultRules)
{
_invoiceController = invoiceController;
_invoiceRepository = invoiceRepository;
@@ -76,7 +76,7 @@ namespace BTCPayServer.Controllers.Greenfield
_paymentLinkExtensions = paymentLinkExtensions;
_payoutHandlers = payoutHandlers;
_handlers = handlers;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
LanguageService = languageService;
}
@@ -430,7 +430,7 @@ 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(_defaultRates), new StoreIdRateContext(storeId),
store.GetStoreBlob().GetRateRules(_defaultRules), new StoreIdRateContext(storeId),
cancellationToken
);

View File

@@ -27,16 +27,16 @@ namespace BTCPayServer.Controllers.GreenField
public class GreenfieldStoreRateConfigurationController : ControllerBase
{
private readonly RateFetcher _rateProviderFactory;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly StoreRepository _storeRepository;
public GreenfieldStoreRateConfigurationController(
RateFetcher rateProviderFactory,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
StoreRepository storeRepository)
{
_rateProviderFactory = rateProviderFactory;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_storeRepository = storeRepository;
}
@@ -49,10 +49,10 @@ namespace BTCPayServer.Controllers.GreenField
return Ok(new StoreRateConfiguration()
{
EffectiveScript = blob.GetRateRules(_defaultRates, out var preferredExchange).ToString(),
EffectiveScript = blob.GetRateRules(_defaultRules, out var preferredExchange).ToString(),
Spread = blob.Spread * 100.0m,
IsCustomScript = blob.RateScripting,
PreferredSource = preferredExchange ? blob.PreferredExchange : null
PreferredSource = preferredExchange ? blob.GetPreferredExchange(_defaultRules) : null
});
}
@@ -118,7 +118,7 @@ namespace BTCPayServer.Controllers.GreenField
return this.CreateValidationError(ModelState);
PopulateBlob(configuration, blob);
var rules = blob.GetRateRules(_defaultRates);
var rules = blob.GetRateRules(_defaultRules);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, new StoreIdRateContext(data.Id), CancellationToken.None);
@@ -156,7 +156,7 @@ namespace BTCPayServer.Controllers.GreenField
{
if (string.IsNullOrEmpty(configuration.EffectiveScript))
{
configuration.EffectiveScript = storeBlob.GetDefaultRateRules(_defaultRates).ToString();
configuration.EffectiveScript = storeBlob.GetDefaultRateRules(_defaultRules).ToString();
}
if (!RateRules.TryParse(configuration.EffectiveScript, out var r))
@@ -182,12 +182,8 @@ $"You can't set the preferredSource if you are using custom scripts");
ModelState.AddModelError(nameof(configuration.EffectiveScript),
$"You can't set the effectiveScript if you aren't using custom scripts");
}
if (string.IsNullOrEmpty(configuration.PreferredSource))
if (!string.IsNullOrEmpty(configuration.PreferredSource))
{
ModelState.AddModelError(nameof(configuration.PreferredSource),
$"The preferredSource is required if you aren't using custom scripts");
}
configuration.PreferredSource = _rateProviderFactory
.RateProviderFactory
.AvailableRateProviders
@@ -202,6 +198,7 @@ $"The preferredSource is required if you aren't using custom scripts");
}
}
}
}
private static void PopulateBlob(StoreRateConfiguration configuration, StoreBlob storeBlob)
{

View File

@@ -24,14 +24,14 @@ namespace BTCPayServer.Controllers.GreenField
public class GreenfieldStoreRatesController : ControllerBase
{
private readonly RateFetcher _rateProviderFactory;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
public GreenfieldStoreRatesController(
RateFetcher rateProviderFactory,
IEnumerable<DefaultRates> defaultRates)
DefaultRulesCollection defaultRules)
{
_rateProviderFactory = rateProviderFactory;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
}
[HttpGet("")]
@@ -62,7 +62,7 @@ namespace BTCPayServer.Controllers.GreenField
}
var rules = blob.GetRateRules(_defaultRates);
var rules = blob.GetRateRules(_defaultRules);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, new StoreIdRateContext(data.Id), CancellationToken.None);

View File

@@ -382,7 +382,7 @@ namespace BTCPayServer.Controllers
var paidCurrency = Math.Round(cryptoPaid * paymentMethod.Rate, cdCurrency.Divisibility);
model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethod.Divisibility);
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodCurrency);
rules = store.GetStoreBlob().GetRateRules(_defaultRates);
rules = store.GetStoreBlob().GetRateRules(_defaultRules);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodCurrency, invoice.Currency), rules, new StoreIdRateContext(store.Id),
cancellationToken);
@@ -491,7 +491,7 @@ namespace BTCPayServer.Controllers
return View("_RefundModal", model);
}
rules = store.GetStoreBlob().GetRateRules(_defaultRates);
rules = store.GetStoreBlob().GetRateRules(_defaultRules);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodCurrency, model.CustomCurrency), rules, new StoreIdRateContext(store.Id),
cancellationToken);

View File

@@ -52,7 +52,7 @@ namespace BTCPayServer.Controllers
readonly BTCPayNetworkProvider _NetworkProvider;
private readonly PayoutMethodHandlerDictionary _payoutHandlers;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _paymentHostedService;
private readonly LanguageService _languageService;
@@ -94,7 +94,7 @@ namespace BTCPayServer.Controllers
AppService appService,
IFileService fileService,
UriResolver uriResolver,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
IAuthorizationService authorizationService,
TransactionLinkProviders transactionLinkProviders,
Dictionary<PaymentMethodId, IPaymentModelExtension> paymentModelExtensions,
@@ -125,7 +125,7 @@ namespace BTCPayServer.Controllers
_viewProvider = viewProvider;
_fileService = fileService;
_uriResolver = uriResolver;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_appService = appService;
}
@@ -297,7 +297,7 @@ namespace BTCPayServer.Controllers
private async Task FetchRates(InvoiceCreationContext context, CancellationToken cancellationToken)
{
var rateRules = context.StoreBlob.GetRateRules(_defaultRates);
var rateRules = context.StoreBlob.GetRateRules(_defaultRules);
await context.FetchingRates(_RateProvider, rateRules, cancellationToken);
}
}

View File

@@ -14,6 +14,7 @@ using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using static BTCPayServer.Lightning.Eclair.Models.ChannelResponse;
namespace BTCPayServer.Controllers;
@@ -22,17 +23,9 @@ public partial class UIStoresController
[HttpGet("{storeId}/rates")]
public IActionResult Rates()
{
var exchanges = GetSupportedExchanges().ToList();
var storeBlob = CurrentStore.GetStoreBlob();
var vm = new RatesViewModel();
vm.SetExchangeRates(exchanges, storeBlob.PreferredExchange ?? storeBlob.GetRecommendedExchange());
vm.Spread = (double)(storeBlob.Spread * 100m);
vm.StoreId = CurrentStore.Id;
vm.Script = storeBlob.GetRateRules(_defaultRates).ToString();
vm.DefaultScript = storeBlob.GetDefaultRateRules(_defaultRates).ToString();
vm.AvailableExchanges = exchanges;
vm.DefaultCurrencyPairs = storeBlob.GetDefaultCurrencyPairString();
vm.ShowScripting = storeBlob.RateScripting;
FillFromStore(vm, storeBlob);
return View(vm);
}
@@ -48,9 +41,6 @@ public partial class UIStoresController
{
return RedirectToAction(nameof(ShowRateRules), new { scripting = false, storeId = model.StoreId });
}
var exchanges = GetSupportedExchanges().ToList();
model.SetExchangeRates(exchanges, model.PreferredExchange ?? HttpContext.GetStoreData().GetStoreBlob().GetRecommendedExchange());
model.StoreId = storeId ?? model.StoreId;
CurrencyPair[]? currencyPairs = null;
try
@@ -70,22 +60,10 @@ public partial class UIStoresController
}
if (model.PreferredExchange != null)
model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(model.PreferredExchange))
model.PreferredExchange = null;
var blob = CurrentStore.GetStoreBlob();
model.DefaultScript = blob.GetDefaultRateRules(_defaultRates).ToString();
model.AvailableExchanges = exchanges;
blob.PreferredExchange = model.PreferredExchange;
blob.Spread = (decimal)model.Spread / 100.0m;
blob.DefaultCurrencyPairs = currencyPairs;
if (!model.ShowScripting)
{
if (!exchanges.Any(provider => provider.Id.Equals(model.PreferredExchange, StringComparison.InvariantCultureIgnoreCase)))
{
ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})");
return View(model);
}
}
RateRules? rules;
if (model.ShowScripting)
{
@@ -94,17 +72,20 @@ public partial class UIStoresController
errors ??= [];
var errorString = string.Join(", ", errors.ToArray());
ModelState.AddModelError(nameof(model.Script), $"Parsing error ({errorString})");
FillFromStore(model, blob);
return View(model);
}
else
{
blob.RateScript = rules.ToString();
ModelState.Remove(nameof(model.Script));
model.Script = blob.RateScript;
}
}
rules = blob.GetRateRules(_defaultRates);
blob.PreferredExchange = model.PreferredExchange;
blob.Spread = (decimal)model.Spread / 100.0m;
blob.DefaultCurrencyPairs = currencyPairs;
FillFromStore(model, blob);
rules = blob.GetRateRules(_defaultRules);
if (command == "Test")
{
if (string.IsNullOrWhiteSpace(model.ScriptTest))
@@ -142,6 +123,12 @@ public partial class UIStoresController
return View(model);
}
if (model.PreferredExchange is not null && !model.AvailableExchanges.Any(a => a.Id == model.PreferredExchange))
{
ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange");
return View(model);
}
// command == Save
if (CurrentStore.SetStoreBlob(blob))
{
@@ -175,16 +162,29 @@ public partial class UIStoresController
{
var blob = CurrentStore.GetStoreBlob();
blob.RateScripting = scripting;
blob.RateScript = blob.GetDefaultRateRules(_defaultRates).ToString();
blob.RateScript = blob.GetDefaultRateRules(_defaultRules).ToString();
CurrentStore.SetStoreBlob(blob);
await _storeRepo.UpdateStore(CurrentStore);
TempData[WellKnownTempData.SuccessMessage] = "Rate rules scripting " + (scripting ? "activated" : "deactivated");
return RedirectToAction(nameof(Rates), new { storeId = CurrentStore.Id });
}
private IEnumerable<RateSourceInfo> GetSupportedExchanges()
private void FillFromStore(RatesViewModel vm, StoreBlob storeBlob)
{
return _rateFactory.RateProviderFactory.AvailableRateProviders
var sources = _rateFactory.RateProviderFactory.AvailableRateProviders
.OrderBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase);
vm.AvailableExchanges = sources;
var exchange = storeBlob.GetPreferredExchange(_defaultRules);
var chosenSource = sources.First(r => r.Id == exchange);
vm.Exchanges = UIUserStoresController.GetExchangesSelectList(_rateFactory, _defaultRules, storeBlob);
vm.PreferredExchange = vm.Exchanges.SelectedValue as string;
vm.PreferredResolvedExchange = chosenSource.Id;
vm.RateSource = chosenSource.Url;
vm.Spread = (double)(storeBlob.Spread * 100m);
vm.StoreId = CurrentStore.Id;
vm.Script = storeBlob.GetRateRules(_defaultRules).ToString();
vm.DefaultScript = storeBlob.GetDefaultRateRules(_defaultRules).ToString();
vm.DefaultCurrencyPairs = storeBlob.GetDefaultCurrencyPairString();
vm.ShowScripting = storeBlob.RateScripting;
}
}

View File

@@ -55,7 +55,7 @@ public partial class UIStoresController : Controller
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions,
IHtmlHelper html,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
EmailSenderFactory emailSenderFactory,
WalletFileParsers onChainWalletParsers,
UriResolver uriResolver,
@@ -85,7 +85,7 @@ public partial class UIStoresController : Controller
_settingsRepository = settingsRepository;
_eventAggregator = eventAggregator;
_html = html;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
_webhookNotificationManager = webhookNotificationManager;
_lightningNetworkOptions = lightningNetworkOptions.Value;
@@ -104,7 +104,7 @@ public partial class UIStoresController : Controller
private readonly ExplorerClientProvider _explorerProvider;
private readonly LanguageService _langService;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly PoliciesSettings _policiesSettings;
private readonly IAuthorizationService _authorizationService;
private readonly AppService _appService;

View File

@@ -23,17 +23,20 @@ namespace BTCPayServer.Controllers
private readonly StoreRepository _repo;
private readonly SettingsRepository _settingsRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly DefaultRulesCollection _defaultRules;
private readonly RateFetcher _rateFactory;
public string CreatedStoreId { get; set; }
public UIUserStoresController(
UserManager<ApplicationUser> userManager,
DefaultRulesCollection defaultRules,
StoreRepository storeRepository,
RateFetcher rateFactory,
SettingsRepository settingsRepository)
{
_repo = storeRepository;
_userManager = userManager;
_defaultRules = defaultRules;
_rateFactory = rateFactory;
_settingsRepository = settingsRepository;
}
@@ -81,7 +84,7 @@ namespace BTCPayServer.Controllers
{
var stores = await _repo.GetStoresByUserId(GetUserId());
vm.IsFirstStore = !stores.Any();
vm.Exchanges = GetExchangesSelectList(vm.PreferredExchange);
vm.Exchanges = GetExchangesSelectList(null);
return View(vm);
}
@@ -124,14 +127,19 @@ namespace BTCPayServer.Controllers
private string GetUserId() => _userManager.GetUserId(User);
private SelectList GetExchangesSelectList(string selected)
private SelectList GetExchangesSelectList(StoreBlob storeBlob) => GetExchangesSelectList(_rateFactory, _defaultRules, storeBlob);
internal static SelectList GetExchangesSelectList(RateFetcher rateFetcher, DefaultRulesCollection defaultRules, StoreBlob storeBlob)
{
var exchanges = _rateFactory.RateProviderFactory
if (storeBlob is null)
storeBlob = new StoreBlob();
var defaultExchange = defaultRules.GetRecommendedExchange(storeBlob.DefaultCurrency);
var exchanges = rateFetcher.RateProviderFactory
.AvailableRateProviders
.OrderBy(s => s.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
exchanges.Insert(0, new (null, "Recommended", ""));
var chosen = exchanges.FirstOrDefault(f => f.Id == selected) ?? exchanges.First();
var exchange = exchanges.First(e => e.Id == defaultExchange);
exchanges.Insert(0, new(null, $"Recommendation ({exchange.DisplayName})", ""));
var chosen = exchanges.FirstOrDefault(f => f.Id == storeBlob.PreferredExchange) ?? exchanges.First();
return new SelectList(exchanges, nameof(chosen.Id), nameof(chosen.DisplayName), chosen.Id);
}
}

View File

@@ -73,7 +73,7 @@ namespace BTCPayServer.Controllers
private readonly PayjoinClient _payjoinClient;
private readonly LabelService _labelService;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly Dictionary<PaymentMethodId, IPaymentModelExtension> _paymentModelExtensions;
private readonly TransactionLinkProviders _transactionLinkProviders;
private readonly PullPaymentHostedService _pullPaymentHostedService;
@@ -100,14 +100,14 @@ namespace BTCPayServer.Controllers
IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService,
LabelService labelService,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
PaymentMethodHandlerDictionary handlers,
Dictionary<PaymentMethodId, IPaymentModelExtension> paymentModelExtensions,
TransactionLinkProviders transactionLinkProviders)
{
_currencyTable = currencyTable;
_labelService = labelService;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_handlers = handlers;
_paymentModelExtensions = paymentModelExtensions;
_transactionLinkProviders = transactionLinkProviders;
@@ -459,7 +459,7 @@ namespace BTCPayServer.Controllers
if (network == null || network.ReadonlyWallet)
return NotFound();
var storeData = store.GetStoreBlob();
var rateRules = store.GetStoreBlob().GetRateRules(_defaultRates);
var rateRules = store.GetStoreBlob().GetRateRules(_defaultRules);
rateRules.Spread = 0.0m;
var currencyPair = new Rating.CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency);
double.TryParse(defaultAmount, out var amount);

View File

@@ -102,7 +102,19 @@ namespace BTCPayServer.Data
public decimal Spread { get; set; } = 0.0m;
/// <summary>
/// This may be null. Use <see cref="GetPreferredExchange(DefaultRulesCollection)"/> instead if you want to return a valid exchange
/// </summary>
public string PreferredExchange { get; set; }
/// <summary>
/// Use the preferred exchange of the store, or the recommended exchange from the default currency
/// </summary>
/// <param name="defaultRules"></param>
/// <returns></returns>
public string GetPreferredExchange(DefaultRulesCollection defaultRules)
{
return string.IsNullOrEmpty(PreferredExchange) ? defaultRules.GetRecommendedExchange(DefaultCurrency) : PreferredExchange;
}
public List<PaymentMethodCriteria> PaymentMethodCriteria { get; set; }
public string HtmlTitle { get; set; }
@@ -135,18 +147,18 @@ namespace BTCPayServer.Data
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public double PaymentTolerance { get; set; }
public BTCPayServer.Rating.RateRules GetRateRules(IEnumerable<DefaultRates> defaultRates)
public BTCPayServer.Rating.RateRules GetRateRules(DefaultRulesCollection defaultRules)
{
return GetRateRules(defaultRates, out _);
return GetRateRules(defaultRules, out _);
}
public BTCPayServer.Rating.RateRules GetRateRules(IEnumerable<DefaultRates> defaultRates, out bool preferredSource)
public BTCPayServer.Rating.RateRules GetRateRules(DefaultRulesCollection defaultRules, out bool preferredSource)
{
if (!RateScripting ||
string.IsNullOrEmpty(RateScript) ||
!BTCPayServer.Rating.RateRules.TryParse(RateScript, out var rules))
{
preferredSource = true;
return GetDefaultRateRules(defaultRates);
return GetDefaultRateRules(defaultRules);
}
else
{
@@ -156,34 +168,13 @@ namespace BTCPayServer.Data
}
}
public RateRules GetDefaultRateRules(IEnumerable<DefaultRates> defaultRates)
public RateRules GetDefaultRateRules(DefaultRulesCollection defaultRules)
{
var preferredExchange = string.IsNullOrEmpty(PreferredExchange) ? GetRecommendedExchange() : PreferredExchange;
var preferredExchangeRule = RateRules.Parse($"X_X = {preferredExchange}(X_X);");
var rules = RateRules.Combine(defaultRates.Select(r => r.Rules).Concat([preferredExchangeRule]).ToArray());
var rules = defaultRules.WithPreferredExchange(PreferredExchange);
rules.Spread = Spread;
return rules;
}
public static JObject RecommendedExchanges = new()
{
{ "EUR", "kraken" },
{ "USD", "kraken" },
{ "GBP", "kraken" },
{ "CHF", "kraken" },
{ "GTQ", "bitpay" },
{ "COP", "yadio" },
{ "ARS", "yadio" },
{ "JPY", "bitbank" },
{ "TRY", "btcturk" },
{ "UGX", "yadio"},
{ "RSD", "bitpay"},
{ "NGN", "bitnob"}
};
public string GetRecommendedExchange() =>
RecommendedExchanges.Property(DefaultCurrency)?.Value.ToString() ?? "coingecko";
[Obsolete("Use GetExcludedPaymentMethods instead")]
public string[] ExcludedPaymentMethods { get; set; }

View File

@@ -53,8 +53,6 @@ namespace BTCPayServer.Data
{
ArgumentNullException.ThrowIfNull(storeData);
var result = storeData.StoreBlob == null ? new StoreBlob() : new Serializer(null).ToObject<StoreBlob>(storeData.StoreBlob);
if (result.PreferredExchange == null)
result.PreferredExchange = result.GetRecommendedExchange();
if (result.PaymentMethodCriteria is null)
result.PaymentMethodCriteria = new List<PaymentMethodCriteria>();
result.PaymentMethodCriteria.RemoveAll(criteria => criteria?.PaymentMethod is null);

View File

@@ -1,11 +0,0 @@
using System;
using System.Linq;
using BTCPayServer.Rating;
namespace BTCPayServer;
public record DefaultRates(RateRules Rules)
{
public DefaultRates(string[] Rules) : this(RateRules.Combine(Rules.Select(r => RateRules.Parse(r)).ToArray()))
{
}
}

View File

@@ -0,0 +1,72 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Data;
using BTCPayServer.Rating;
using Newtonsoft.Json.Linq;
namespace BTCPayServer;
public record DefaultRules(RateRules Rules)
{
public record Recommendation : DefaultRules
{
public Recommendation(string currency, string exchange) : base($"X_{currency.ToUpperInvariant()} = {exchange.ToLowerInvariant()}(X_{currency.ToUpperInvariant()});")
{
Currency = currency.ToUpperInvariant();
Exchange = exchange.ToLowerInvariant();
}
public string Currency { get; }
public string Exchange { get; }
}
public const int HardcodedRecommendedExchangeOrder = 10;
public DefaultRules(string Rules) : this(RateRules.Parse(Rules))
{
}
public DefaultRules(string[] Rules) : this(RateRules.Combine(Rules.Select(r => RateRules.Parse(r)).ToArray()))
{
}
/// <summary>
/// Rules are applied in order, the lower the order, the higher the priority. Default is 0.
/// </summary>
public int Order { get; set; }
}
public class DefaultRulesCollection
{
public DefaultRulesCollection(IEnumerable<DefaultRules> defaultRules)
{
defaultRules = defaultRules.OrderBy(o => o.Order).ToList();
Consolidated = RateRules.Combine(defaultRules.Select(r => r.Rules));
ConsolidatedWithoutRecommendation = RateRules.Combine(defaultRules.Where(r => r is not DefaultRules.Recommendation).Select(r => r.Rules));
var rules = Consolidated.ToString();
foreach (var recommendation in defaultRules.OfType<DefaultRules.Recommendation>())
{
RecommendedExchanges.TryAdd(recommendation.Currency, recommendation.Exchange);
}
}
public RateRules Consolidated { get; private set; }
public RateRules ConsolidatedWithoutRecommendation { get; private set; }
public RateRules WithPreferredExchange(string? preferredExchange)
{
if (string.IsNullOrEmpty(preferredExchange))
{
return Consolidated;
}
else
{
var catchAll = RateRules.Parse($"X_X = {preferredExchange}(X_X);");
return RateRules.Combine([catchAll, ConsolidatedWithoutRecommendation]);
}
}
public Dictionary<string, string> RecommendedExchanges { get; } = new Dictionary<string, string>();
public string GetRecommendedExchange(string currency) =>
RecommendedExchanges.TryGetValue(currency, out var ex) ? ex : "coingecko";
public override string ToString() => Consolidated.ToString();
}

View File

@@ -279,7 +279,7 @@ namespace BTCPayServer.HostedServices
EventAggregator eventAggregator,
BTCPayNetworkProvider networkProvider,
PayoutMethodHandlerDictionary handlers,
IEnumerable<DefaultRates> defaultRates,
DefaultRulesCollection defaultRules,
NotificationSender notificationSender,
RateFetcher rateFetcher,
ILogger<PullPaymentHostedService> logger,
@@ -292,7 +292,7 @@ namespace BTCPayServer.HostedServices
_eventAggregator = eventAggregator;
_networkProvider = networkProvider;
_handlers = handlers;
_defaultRates = defaultRates;
_defaultRules = defaultRules;
_notificationSender = notificationSender;
_rateFetcher = rateFetcher;
_logger = logger;
@@ -306,7 +306,7 @@ namespace BTCPayServer.HostedServices
private readonly EventAggregator _eventAggregator;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly PayoutMethodHandlerDictionary _handlers;
private readonly IEnumerable<DefaultRates> _defaultRates;
private readonly DefaultRulesCollection _defaultRules;
private readonly NotificationSender _notificationSender;
private readonly RateFetcher _rateFetcher;
private readonly ILogger<PullPaymentHostedService> _logger;
@@ -392,7 +392,7 @@ namespace BTCPayServer.HostedServices
if (explicitRateRule is null)
{
var storeBlob = payout.StoreData.GetStoreBlob();
var rules = storeBlob.GetRateRules(_defaultRates);
var rules = storeBlob.GetRateRules(_defaultRules);
rules.Spread = 0.0m;
rule = rules.GetRuleFor(currencyPair);
}

View File

@@ -403,6 +403,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<NotificationManager>();
services.AddScoped<NotificationSender>();
RegisterExchangeRecommendations(services);
services.AddSingleton<DefaultRulesCollection>();
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, InvoiceEventSaverService>();
services.AddSingleton<IHostedService, BitpayIPNSender>();
@@ -504,6 +506,30 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
return services;
}
public static void RegisterExchangeRecommendations(IServiceCollection services)
{
foreach (var rule in new Dictionary<string, string>()
{
{ "EUR", "kraken" },
{ "USD", "kraken" },
{ "GBP", "kraken" },
{ "CHF", "kraken" },
{ "GTQ", "bitpay" },
{ "COP", "yadio" },
{ "ARS", "yadio" },
{ "JPY", "bitbank" },
{ "TRY", "btcturk" },
{ "UGX", "yadio"},
{ "RSD", "bitpay"},
{ "NGN", "bitnob"}
})
{
var r = new DefaultRules.Recommendation(rule.Key, rule.Value);
r.Order = DefaultRules.HardcodedRecommendedExchangeOrder;
services.AddSingleton<DefaultRules>(r);
}
}
public static void AddOnchainWalletParsers(IServiceCollection services)
{
services.AddSingleton<WalletFileParsers>();
@@ -563,13 +589,13 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
}
public static IServiceCollection AddBTCPayNetwork(this IServiceCollection services, BTCPayNetworkBase network)
{
services.AddSingleton(new DefaultRates(network.DefaultRateRules));
services.AddSingleton(new DefaultRules(network.DefaultRateRules));
services.AddSingleton<BTCPayNetworkBase>(network);
return services;
}
public static IServiceCollection AddBTCPayNetwork(this IServiceCollection services, BTCPayNetwork network)
{
services.AddSingleton(new DefaultRates(network.DefaultRateRules));
services.AddSingleton(new DefaultRules(network.DefaultRateRules));
// BTC
{
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);

View File

@@ -17,15 +17,6 @@ namespace BTCPayServer.Models.StoreViewModels
public bool Error { get; set; }
}
public void SetExchangeRates(IEnumerable<RateSourceInfo> supportedList, string preferredExchange)
{
supportedList = supportedList.ToArray();
var chosen = supportedList.FirstOrDefault(f => f.Id == preferredExchange) ?? supportedList.FirstOrDefault();
Exchanges = new SelectList(supportedList, nameof(chosen.Id), nameof(chosen.DisplayName), chosen);
PreferredExchange = chosen?.Id;
RateSource = chosen?.Url;
}
public List<TestResultViewModel> TestRateRules { get; set; }
public SelectList Exchanges { get; set; }
@@ -47,6 +38,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Preferred Price Source")]
public string PreferredExchange { get; set; }
public string PreferredResolvedExchange { get; set; }
public string RateSource { get; set; }
}

View File

@@ -187,7 +187,7 @@
<h4 class="mt-5">Customization</h4>
<div class="form-group mb-3">
<label asp-for="DefaultCurrency" class="form-label"></label>
<input asp-for="DefaultCurrency" placeholder="Default Store Currency" class="form-control" currency-selection />
<input asp-for="DefaultCurrency" placeholder="@StoreBlob.StandardDefaultCurrency" class="form-control" currency-selection />
</div>
<div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label>

View File

@@ -138,7 +138,7 @@ X_X = kraken(X_X);</code></pre>
<select asp-for="PreferredExchange" asp-items="Model.Exchanges" class="form-select"></select>
<span asp-validation-for="PreferredExchange" class="text-danger"></span>
<div id="PreferredExchangeHelpBlock" class="form-text">
Current Rates source is <a href="@Model.RateSource" target="_blank" rel="noreferrer noopener">@Model.PreferredExchange</a>.
Current Rates source is <a href="@Model.RateSource" target="_blank" rel="noreferrer noopener">@Model.PreferredResolvedExchange</a>.
</div>
</div>
}

View File

@@ -1,4 +1,5 @@
@model BTCPayServer.Models.StoreViewModels.CreateStoreViewModel
@model BTCPayServer.Models.StoreViewModels.CreateStoreViewModel
@inject DefaultRulesCollection DefaultRules
@{
Layout = Model.IsFirstStore ? "_LayoutWizard" : "_Layout";
ViewData.SetActivePage(StoreNavPages.Create, Model.IsFirstStore ? "Create your first store" : "Create a new store");
@@ -7,12 +8,12 @@
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
<script>
const exchanges = @Safe.Json(StoreBlob.RecommendedExchanges);
const exchanges = @Safe.Json(DefaultRules.RecommendedExchanges);
const recommended = document.querySelector("#PreferredExchange option[value='']")
const updateRecommended = currency => {
const source = exchanges[currency] || 'coingecko'
const name = source.charAt(0).toUpperCase() + source.slice(1)
recommended.innerText = `${name} (Recommended)`
recommended.innerText = `Recommendation (${name})`
}
updateRecommended(@Safe.Json(Model.DefaultCurrency))
delegate('change', '#DefaultCurrency', e => updateRecommended(e.target.value))