diff --git a/.editorconfig b/.editorconfig index 0e4027a0f..7d842c897 100644 --- a/.editorconfig +++ b/.editorconfig @@ -252,7 +252,7 @@ indent_style = space indent_size = 2 end_of_line = lf -[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] +[{*.har,*.jsb2,*.jsb3,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] indent_style = space indent_size = 2 diff --git a/BTCPayServer.Abstractions/Models/ConfirmModel.cs b/BTCPayServer.Abstractions/Models/ConfirmModel.cs index 5235d562b..2a6cf81b6 100644 --- a/BTCPayServer.Abstractions/Models/ConfirmModel.cs +++ b/BTCPayServer.Abstractions/Models/ConfirmModel.cs @@ -5,7 +5,6 @@ namespace BTCPayServer.Abstractions.Models public class ConfirmModel { private const string ButtonClassDefault = "btn-danger"; - public ConfirmModel() { } public ConfirmModel(string title, string desc, string action = null, string buttonClass = ButtonClassDefault, string actionName = null, string controllerName = null) @@ -23,6 +22,7 @@ namespace BTCPayServer.Abstractions.Models } } + public bool GenerateForm { get; set; } = true; public string Title { get; set; } public string Description { get; set; } public bool DescriptionHtml { get; set; } diff --git a/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs b/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs index 0848bfb18..55b5d19c5 100644 --- a/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs +++ b/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs @@ -8,19 +8,29 @@ namespace BTCPayServer.Client; public partial class BTCPayServerClient { - public virtual async Task GetStoreRateConfiguration(string storeId, CancellationToken token = default) + public virtual async Task GetStoreRateConfiguration(string storeId, bool? fallback = null, CancellationToken token = default) { - return await SendHttpRequest($"api/v1/stores/{storeId}/rates/configuration", null, HttpMethod.Get, token); + var path = GetRateConfigPath(storeId, fallback); + return await SendHttpRequest(path, null, HttpMethod.Get, token); } + private string GetRateConfigPath(string storeId, bool? fallback) + => fallback switch + { + null => $"api/v1/stores/{storeId}/rates/configuration", + true => $"api/v1/stores/{storeId}/rates/configuration/fallback", + false => $"api/v1/stores/{storeId}/rates/configuration/primary", + }; + public virtual async Task> GetRateSources(CancellationToken token = default) { return await SendHttpRequest>("misc/rate-sources", null, HttpMethod.Get, token); } - public virtual async Task UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, CancellationToken token = default) + public virtual async Task UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, bool? fallback = null, CancellationToken token = default) { - return await SendHttpRequest($"api/v1/stores/{storeId}/rates/configuration", request, HttpMethod.Put, token); + var path = GetRateConfigPath(storeId, fallback); + return await SendHttpRequest(path, request, HttpMethod.Put, token); } public virtual async Task> PreviewUpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, string[] currencyPair = null, CancellationToken token = default) diff --git a/BTCPayServer.Data/Migrations/20250508000000_fallbackrates.cs b/BTCPayServer.Data/Migrations/20250508000000_fallbackrates.cs new file mode 100644 index 000000000..fd3329cfc --- /dev/null +++ b/BTCPayServer.Data/Migrations/20250508000000_fallbackrates.cs @@ -0,0 +1,40 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250508000000_fallbackrates")] + public partial class fallbackrates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "Stores" + SET "StoreBlob" = "StoreBlob" + || jsonb_build_object( + 'primaryRateSettings', + jsonb_build_object( + 'rateScript', "StoreBlob"->'rateScript', + 'rateScripting', COALESCE("StoreBlob"->'rateScripting', 'false'::JSONB), + 'preferredExchange', "StoreBlob"->'preferredExchange' + ) + ) + - 'rateScript' + - 'rateScripting' + - 'preferredExchange' + WHERE "StoreBlob" IS NOT NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/BTCPayServer.Rating/RateRules.cs b/BTCPayServer.Rating/RateRules.cs index 0d9ac828d..da0331cfc 100644 --- a/BTCPayServer.Rating/RateRules.cs +++ b/BTCPayServer.Rating/RateRules.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -25,6 +26,14 @@ namespace BTCPayServer.Rating RateUnavailable, InvalidExchangeName, } + + public record RateRulesCollection(RateRules Primary, RateRules? Fallback) + { + public RateRuleCollection GetRuleFor(CurrencyPair currencyPair) + => new(Primary.GetRuleFor(currencyPair), Fallback?.GetRuleFor(currencyPair)); + } + public record RateRuleCollection(RateRule Primary, RateRule? Fallback); + public class RateRules { class NormalizeCurrencyPairsRewritter : CSharpSyntaxRewriter diff --git a/BTCPayServer.Rating/Services/RateFetcher.cs b/BTCPayServer.Rating/Services/RateFetcher.cs index 257b3c9ec..85365162e 100644 --- a/BTCPayServer.Rating/Services/RateFetcher.cs +++ b/BTCPayServer.Rating/Services/RateFetcher.cs @@ -34,50 +34,101 @@ namespace BTCPayServer.Services.Rates public RateProviderFactory RateProviderFactory => _rateProviderFactory; - public async Task FetchRate(CurrencyPair pair, RateRules rules, IRateContext? context, CancellationToken cancellationToken) + public Task FetchRate(CurrencyPair pair, RateRules rules, IRateContext? context, CancellationToken cancellationToken) + => FetchRate(pair, new RateRulesCollection(rules, null), context, cancellationToken); + public async Task FetchRate(CurrencyPair pair, RateRulesCollection rules, IRateContext? context, CancellationToken cancellationToken) { return await FetchRates(new HashSet(new[] { pair }), rules, context, cancellationToken).First().Value; } public Dictionary> FetchRates(HashSet pairs, RateRules rules, IRateContext? context, CancellationToken cancellationToken) + => FetchRates(pairs, new RateRulesCollection(rules, null), context, cancellationToken); + + record FetchContext( + Dictionary Query, + Dictionary> FetchingRates, + Dictionary> FetchingExchanges, + IRateContext? RateContext, + CancellationToken CancellationToken) { - ArgumentNullException.ThrowIfNull(rules); + public FetchContext(IRateContext? context, CancellationToken cancellationToken) + :this(new(), new(), new(), context, cancellationToken) + { - var fetchingRates = new Dictionary>(); - var fetchingExchanges = new Dictionary>(); + } + } - foreach (var i in pairs.Select(p => (Pair: p, RateRule: rules.GetRuleFor(p)))) + public Dictionary> FetchRates(HashSet pairs, RateRulesCollection rules, IRateContext? context, + CancellationToken cancellationToken) + { + var ctx = new FetchContext(context, cancellationToken); + void SetQuery(RateRules rateRules) + { + ctx.Query.Clear(); + foreach (var p in pairs.Select(p => (Pair: p, RateRule: rateRules.GetRuleFor(p)))) + ctx.Query.Add(p.Pair, p.RateRule); + } + SetQuery(rules.Primary); + FetchRates(ctx); + if (rules.Fallback is not null) + { + SetQuery(rules.Fallback); + FetchRates(ctx); + } + return ctx.FetchingRates; + } + private void FetchRates(FetchContext ctx) + { + foreach (var i in ctx.Query) { var dependentQueries = new List>(); - foreach (var requiredExchange in i.RateRule.ExchangeRates) + foreach (var requiredExchange in i.Value.ExchangeRates) { - if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching)) + if (!ctx.FetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching)) { - fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, context, cancellationToken); - fetchingExchanges.Add(requiredExchange.Exchange, fetching); + fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, ctx.RateContext, ctx.CancellationToken); + ctx.FetchingExchanges.Add(requiredExchange.Exchange, fetching); } dependentQueries.Add(fetching); } - fetchingRates.Add(i.Pair, GetRuleValue(dependentQueries, i.RateRule)); + + if (ctx.FetchingRates.TryGetValue(i.Key, out var primaryFetch)) + { + ctx.FetchingRates[i.Key] = FallbackGetRuleValue(dependentQueries, i.Value, primaryFetch); + } + else + ctx.FetchingRates.Add(i.Key, GetRuleValue(dependentQueries, i.Value)); } - return fetchingRates; + } + + private async Task FallbackGetRuleValue(List> dependentQueries, RateRule fallbackRateRule, Task primaryFetch) + { + var primaryResult = await primaryFetch; + if (primaryResult.BidAsk != null) + return primaryResult; + return await GetRuleValue(dependentQueries, fallbackRateRule); } public Task FetchRate(RateRule rateRule, IRateContext? context, CancellationToken cancellationToken) + => FetchRate(new RateRuleCollection(rateRule, null), context, cancellationToken); + + public Task FetchRate(RateRuleCollection rateRule, IRateContext? context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(rateRule); - var fetchingExchanges = new Dictionary>(); - var dependentQueries = new List>(); - foreach (var requiredExchange in rateRule.ExchangeRates) + var ctx = new FetchContext(context, cancellationToken); + void SetQuery(RateRule r) { - if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching)) - { - fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, context, cancellationToken); - fetchingExchanges.Add(requiredExchange.Exchange, fetching); - } - dependentQueries.Add(fetching); + ctx.Query.Clear(); + ctx.Query.Add(new("AAA","AAA"), r); } - return GetRuleValue(dependentQueries, rateRule); + SetQuery(rateRule.Primary); + FetchRates(ctx); + if (rateRule.Fallback is not null) + { + SetQuery(rateRule.Fallback); + FetchRates(ctx); + } + return ctx.FetchingRates.First().Value; } private async Task GetRuleValue(List> dependentQueries, RateRule rateRule) diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs index ffda1a10c..1d005b3ff 100644 --- a/BTCPayServer.Tests/PlaywrightTester.cs +++ b/BTCPayServer.Tests/PlaywrightTester.cs @@ -493,5 +493,10 @@ namespace BTCPayServer.Tests } public Task GoToInvoice(string invoiceId) => GoToUrl($"/invoices/{invoiceId}/"); + + public async Task ConfirmModal() + { + await Page.ClickAsync(".modal.fade.show .modal-confirm"); + } } } diff --git a/BTCPayServer.Tests/ThirdPartyTests.cs b/BTCPayServer.Tests/ThirdPartyTests.cs index 0114a96cb..442207dd1 100644 --- a/BTCPayServer.Tests/ThirdPartyTests.cs +++ b/BTCPayServer.Tests/ThirdPartyTests.cs @@ -98,8 +98,8 @@ namespace BTCPayServer.Tests await mempoolSpaceFeeProvider.GetFeeRateAsync(); mempoolSpaceFeeProvider.CachedOnly = false; Assert.NotEmpty(rates); - - + + var recommendedFees = await Task.WhenAll(new[] { @@ -126,7 +126,7 @@ namespace BTCPayServer.Tests //ENSURE THESE ARE LOGICAL Assert.True(recommendedFees[0].FeeRate >= recommendedFees[1].FeeRate, $"{recommendedFees[0].Target}:{recommendedFees[0].FeeRate} >= {recommendedFees[1].Target}:{recommendedFees[1].FeeRate}"); Assert.True(recommendedFees[1].FeeRate >= recommendedFees[2].FeeRate, $"{recommendedFees[1].Target}:{recommendedFees[1].FeeRate} >= {recommendedFees[2].Target}:{recommendedFees[2].FeeRate}"); - Assert.True(recommendedFees[2].FeeRate >= recommendedFees[3].FeeRate, $"{recommendedFees[2].Target}:{recommendedFees[2].FeeRate} >= {recommendedFees[3].Target}:{recommendedFees[3].FeeRate}"); + Assert.True(recommendedFees[2].FeeRate >= recommendedFees[3].FeeRate, $"{recommendedFees[2].Target}:{recommendedFees[2].FeeRate} >= {recommendedFees[3].Target}:{recommendedFees[3].FeeRate}"); } } [Fact] @@ -214,7 +214,7 @@ namespace BTCPayServer.Tests e => e.CurrencyPair == new CurrencyPair("BTC", "NOK") && e.BidAsk.Bid > 1.0m); // 1 BTC will always be more than 1 NOK } - else if (name == "barebitcoin") + else if (name == "barebitcoin") { Assert.Contains(exchangeRates.ByExchange[name], e => e.CurrencyPair == new CurrencyPair("BTC", "NOK") && @@ -378,7 +378,7 @@ retry: foreach (var k in defaultRules.RecommendedExchanges) { b.DefaultCurrency = k.Key; - var rules = b.GetDefaultRateRules(defaultRules); + var rules = b.GetOrCreateRateSettings(false).GetDefaultRateRules(defaultRules, b.Spread); var pairs = new[] { CurrencyPair.Parse($"BTC_{k.Key}") }.ToHashSet(); var result = fetcher.FetchRates(pairs, rules, null, default); foreach ((CurrencyPair key, Task value) in result) @@ -386,7 +386,7 @@ retry: TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}"); var rateResult = await value; var hasRate = rateResult.BidAsk != null; - + if (temporarilyBroken.Contains(k.Key)) { if (!hasRate) @@ -426,7 +426,7 @@ retry: } } - var rules = new StoreBlob().GetDefaultRateRules(defaultRules); + var rules = new StoreBlob().GetOrCreateRateSettings(false).GetDefaultRateRules(defaultRules, 0.0m); var result = fetcher.FetchRates(pairs, rules, null, cts.Token); foreach ((CurrencyPair key, Task value) in result) { @@ -537,7 +537,7 @@ retry: version = Regex.Match(actual, "Original file: /npm/decimal\\.js@([0-9]+.[0-9]+.[0-9]+)/decimal\\.js").Groups[1].Value; expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/decimal.js@{version}/decimal.min.js")).Content.ReadAsStringAsync()).Trim(); EqualJsContent(expected, actual); - + actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bbqr", "bbqr.iife.js").Trim(); expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bbqr@1.0.0/dist/bbqr.iife.js")).Content.ReadAsStringAsync()).Trim(); EqualJsContent(expected, actual); @@ -623,7 +623,7 @@ retry: { var storeController = user.GetController(); var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model; - vm.PreferredExchange = exchange; + vm.PrimarySource.PreferredExchange = exchange; await storeController.Rates(vm); var invoice2 = await user.BitPay.CreateInvoiceAsync( new Invoice() diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index e7643c97d..7f7557caa 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -77,6 +77,7 @@ using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel; using Microsoft.Extensions.Caching.Memory; using PosViewType = BTCPayServer.Client.Models.PosViewType; using BTCPayServer.PaymentRequest; +using BTCPayServer.Views.Stores; namespace BTCPayServer.Tests { @@ -1380,68 +1381,184 @@ namespace BTCPayServer.Tests } [Fact(Timeout = LongRunningTestTimeout)] - [Trait("Integration", "Integration")] + [Trait("Playwright", "Playwright")] public async Task CanModifyRates() { - using var tester = CreateServerTester(); + await using var tester = CreatePlaywrightTester(); await tester.StartAsync(); - var user = tester.NewAccount(); - user.GrantAccess(); - user.RegisterDerivationScheme("BTC"); + await tester.RegisterNewUser(true); + await tester.CreateNewStore(); + await tester.GoToStore(); + await tester.GoToStore(StoreNavPages.Rates); - var store = user.GetController(); - var rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); - Assert.False(rateVm.ShowScripting); - Assert.Equal(CoinGeckoRateProvider.CoinGeckoName, rateVm.PreferredExchange); - Assert.Equal(0.0, rateVm.Spread); - Assert.Null(rateVm.TestRateRules); + async Task Test(string pairs) + { + await tester.Page.FillAsync("#ScriptTest", pairs); + await tester.Page.ClickAsync("button[value='Test']"); + } - rateVm.PreferredExchange = "bitflyer"; - Assert.IsType(await store.Rates(rateVm, "Save")); - rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); - Assert.Equal("bitflyer", rateVm.PreferredExchange); + foreach (var fallback in new[] { false, true }) + { + tester.TestLogs.LogInformation($"Testing rates (fallback={fallback})"); + var source = fallback ? "FallbackSource" : "PrimarySource"; + var toggleScriptSelector = $"#{source}_ShowScripting_submit"; - rateVm.ScriptTest = "BTC_JPY,BTC_CAD"; - rateVm.Spread = 10; - store = user.GetController(); - rateVm = Assert.IsType(Assert.IsType(await store.Rates(rateVm, "Test")) - .Model); - Assert.NotNull(rateVm.TestRateRules); - Assert.Equal(2, rateVm.TestRateRules.Count); - Assert.False(rateVm.TestRateRules[0].Error); - Assert.StartsWith("(bitflyer(BTC_JPY)) * (0.9, 1.1) =", rateVm.TestRateRules[0].Rule, - StringComparison.OrdinalIgnoreCase); - Assert.True(rateVm.TestRateRules[1].Error); - Assert.IsType(await store.Rates(rateVm, "Save")); + var l = tester.Page.Locator(toggleScriptSelector); + await l.WaitForAsync(); + Assert.DoesNotContain("btcpay-toggle--active", await l.GetAttributeAsync("class")); + if (fallback) + { + Assert.Equal("", await tester.Page.Locator($"#{source}_PreferredExchange").InputValueAsync()); + } + else + { + Assert.Equal(CoinGeckoRateProvider.CoinGeckoName, await tester.Page.Locator($"#{source}_PreferredExchange").InputValueAsync()); + } - Assert.IsType(store.ShowRateRulesPost(true).Result); - Assert.IsType(await store.Rates(rateVm, "Save")); - store = user.GetController(); - rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); - Assert.Equal(rateVm.StoreId, user.StoreId); - Assert.Equal(rateVm.DefaultScript, rateVm.Script); - Assert.True(rateVm.ShowScripting); - rateVm.ScriptTest = "BTC_JPY"; - rateVm = Assert.IsType(Assert.IsType(await store.Rates(rateVm, "Test")) - .Model); - Assert.True(rateVm.ShowScripting); - Assert.Contains("(bitflyer(BTC_JPY)) * (0.9, 1.1) = ", rateVm.TestRateRules[0].Rule, - StringComparison.OrdinalIgnoreCase); + Assert.Equal("0", await tester.Page.InputValueAsync("#Spread")); + await tester.Page.SelectOptionAsync($"#{source}_PreferredExchange", "bitflyer"); + await tester.ClickPagePrimary(); + await tester.FindAlertMessage(); - rateVm.ScriptTest = "BTC_USD,BTC_CAD,DOGE_USD,DOGE_CAD"; - rateVm.Script = "DOGE_X = bitpay(DOGE_BTC) * BTC_X;\n" + - "X_CAD = ndax(X_CAD);\n" + - "X_X = coingecko(X_X);"; - rateVm.Spread = 50; - rateVm = Assert.IsType(Assert.IsType(await store.Rates(rateVm, "Test")) - .Model); - Assert.True(rateVm.TestRateRules.All(t => !t.Error)); - Assert.IsType(await store.Rates(rateVm, "Save")); - store = user.GetController(); - rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); - Assert.Equal(50, rateVm.Spread); - Assert.True(rateVm.ShowScripting); - Assert.Contains("DOGE_X", rateVm.Script, StringComparison.OrdinalIgnoreCase); + await tester.Page.FillAsync("#Spread", "10"); + await Test("BTC_JPY,BTC_CAD"); + var rules = await tester.Page.Locator(".testresult .testresult_rule").AllAsync(); + if (fallback) + { + // If fallback is set, we should see the results of the fallback too + Assert.Contains("(coingecko(BTC_CAD)) * (0.9, 1.1) = 4050.0", await rules[0].InnerTextAsync()); + Assert.Contains("(ERR_RATE_UNAVAILABLE(bitflyer, BTC_CAD)) * (0.9, 1.1)", await rules[1].InnerTextAsync()); + Assert.Contains("(ERR_RATE_UNAVAILABLE(coingecko, BTC_JPY)) * (0.9, 1.1)", await rules[2].InnerTextAsync()); + Assert.Contains("(bitflyer(BTC_JPY)) * (0.9, 1.1) = 630000.0", await rules[3].InnerTextAsync()); + } + else + { + Assert.Contains("(ERR_RATE_UNAVAILABLE(bitflyer, BTC_CAD))", await rules[0].InnerTextAsync()); + Assert.Contains("(bitflyer(BTC_JPY)) * (0.9, 1.1) =", await rules[1].InnerTextAsync()); + } + + await tester.ClickPagePrimary(); + await tester.FindAlertMessage(); + + await tester.Page.ClickAsync(toggleScriptSelector); + await tester.FindAlertMessage(partialText: "Rate rules scripting activated"); + + l = tester.Page.Locator(toggleScriptSelector); + await l.WaitForAsync(); + Assert.Contains("btcpay-toggle--active", await l.GetAttributeAsync("class")); + + var script = await tester.Page.InputValueAsync($"#{source}_Script"); + var defaultScript = await tester.Page.GetAttributeAsync($"#{source}_DefaultScript", "data-defaultScript"); + Assert.Equal(script, defaultScript); + + await Test("BTC_JPY"); + rules = await tester.Page.Locator(".testresult .testresult_rule").AllAsync(); + if (fallback) + { + Assert.Contains("(ERR_RATE_UNAVAILABLE(coingecko, BTC_JPY)) * (0.9, 1.1)", await rules[0].InnerTextAsync()); + Assert.Contains("(bitflyer(BTC_JPY)) * (0.9, 1.1) = 630000.0", await rules[1].InnerTextAsync()); + } + else + { + Assert.Contains("(bitflyer(BTC_JPY)) * (0.9, 1.1) =", await rules[0].InnerTextAsync()); + } + + await tester.Page.FillAsync("#Spread", "50"); + await tester.Page.FillAsync($"#{source}_Script",""" + DOGE_X = bitpay(DOGE_BTC) * BTC_X; + X_CAD = ndax(X_CAD); + X_X = coingecko(X_X); + """); + await Test("BTC_USD,BTC_CAD,DOGE_USD,DOGE_CAD"); + rules = await tester.Page.Locator(".testresult").AllAsync(); + if (fallback) + { + Assert.Equal(8, rules.Count); + } + else + { + Assert.Equal(4, rules.Count); + foreach (var rule in rules) + { + await rule.Locator(".testresult_success").WaitForAsync(); + Assert.Contains("(0.5, 1.5)", await rule.InnerTextAsync()); + } + } + + await tester.ClickPagePrimary(); + Assert.Equal("50", await tester.Page.InputValueAsync("#Spread")); + var beforeReset = await tester.Page.InputValueAsync($"#{source}_Script"); + Assert.Contains("X_CAD = ndax(X_CAD);", beforeReset); + await tester.Page.ClickAsync($"#{source}_DefaultScript"); + var afterReset = await tester.Page.InputValueAsync($"#{source}_Script"); + Assert.NotEqual(beforeReset, afterReset); + + await tester.Page.ClickAsync(toggleScriptSelector); + await tester.ConfirmModal(); + await tester.FindAlertMessage(partialText: "Rate rules scripting deactivated"); + + l = tester.Page.Locator(toggleScriptSelector); + await l.WaitForAsync(); + Assert.DoesNotContain("btcpay-toggle--active", await l.GetAttributeAsync("class")); + + if (fallback) + { + await tester.Page.ClickAsync("#HasFallback"); + await tester.ClickPagePrimary(); + await tester.Page.FillAsync("#Spread", "0"); + await tester.ClickPagePrimary(); + } + else + { + await tester.Page.ClickAsync("#HasFallback"); + await tester.ClickPagePrimary(); + await tester.FindAlertMessage(); + await tester.Page.SelectOptionAsync($"#{source}_PreferredExchange", CoinGeckoRateProvider.CoinGeckoName); + await tester.Page.FillAsync("#Spread", "0"); + await tester.ClickPagePrimary(); + } + } + + await tester.Page.ClickAsync("#HasFallback"); + await tester.ClickPagePrimary(); + await tester.FindAlertMessage(); + await tester.Page.SelectOptionAsync($"#PrimarySource_PreferredExchange", CoinGeckoRateProvider.CoinGeckoName); + await tester.Page.SelectOptionAsync($"#FallbackSource_PreferredExchange", "bitflyer"); + + await tester.Page.FillAsync("#DefaultCurrencyPairs", "BTC_JPY,BTC_CAD"); + await tester.ClickPagePrimary(); + + using var req = await tester.Server.PayTester.HttpClient.GetAsync($"/api/rates?storeId={tester.StoreId}"); + var rates = JArray.Parse(await req.Content.ReadAsStringAsync()); + foreach (var expected in new[] + { + (name: "BTC_JPY", rate: 700000m), + (name: "BTC_CAD", rate: 4500m) + }) + { + // JPY is handled by the fallback, CAD by the primary + var r = rates.First(r => r["currencyPair"].ToString() == expected.name); + Assert.Equal(expected.rate, r["rate"]!.Value()); + } + + await tester.GenerateWallet(); + var invoiceId = await tester.CreateInvoice(currency: "JPY", amount: 700000m); + var client = await tester.AsTestAccount().CreateClient(); + var paymentMethods = await client.GetInvoicePaymentMethods(tester.StoreId, invoiceId); + Assert.Equal(1.0m, paymentMethods[0].Amount); + + // The fallback doesn't support JPY anymore + var a = await client.GetStoreRateConfiguration(tester.StoreId, fallback: false); + var b = await client.GetStoreRateConfiguration(tester.StoreId, fallback: true); + Assert.Equal("coingecko", a.PreferredSource); + Assert.Equal("bitflyer", b.PreferredSource); + await client.UpdateStoreRateConfiguration(tester.StoreId, new() + { + PreferredSource = "coingecko" + }, true); + b = await client.GetStoreRateConfiguration(tester.StoreId, fallback: true); + Assert.Equal("coingecko", b.PreferredSource); + await tester.CreateInvoice(currency: "JPY", amount: 700000m, expectedSeverity: StatusMessageModel.StatusSeverity.Error); } @@ -2824,14 +2941,14 @@ namespace BTCPayServer.Tests using var ctx = db.CreateContext(); var store = (await ctx.Stores.AsNoTracking().ToListAsync())[0]; var b = store.GetStoreBlob(); - b.PreferredExchange = "coinaverage"; + b.GetOrCreateRateSettings(false).PreferredExchange = "coinaverage"; store.SetStoreBlob(b); await ctx.SaveChangesAsync(); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\"='20230123062447_migrateoldratesource'"); await ctx.Database.MigrateAsync(); store = (await ctx.Stores.AsNoTracking().ToListAsync())[0]; b = store.GetStoreBlob(); - Assert.Equal("coingecko", b.PreferredExchange); + Assert.Equal("coingecko", b.GetOrCreateRateSettings(false).PreferredExchange); } [Fact(Timeout = LongRunningTestTimeout)] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs index ece330e2b..84f8f59f5 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs @@ -40,18 +40,31 @@ namespace BTCPayServer.Controllers.GreenField } [HttpGet("")] - [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("primary")] + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public IActionResult GetStoreRateConfiguration() + => GetStoreRateConfigurationCore(false); + + [HttpGet("fallback")] + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public IActionResult GetStoreFallbackRateConfiguration() + => GetStoreRateConfigurationCore(true); + + [NonAction] + private IActionResult GetStoreRateConfigurationCore(bool? fallback) { var data = HttpContext.GetStoreData(); - var blob = data.GetStoreBlob(); + var storeBlob = data.GetStoreBlob(); + var blob = storeBlob.GetRateSettings(fallback ?? false); + if (blob is null) + return this.CreateAPIError(404, "fallback-disabled", "The fallback rates are disabled"); return Ok(new StoreRateConfiguration() { - EffectiveScript = blob.GetRateRules(_defaultRules, out var preferredExchange).ToString(), - Spread = blob.Spread * 100.0m, + EffectiveScript = blob.GetRateRules(_defaultRules, storeBlob.Spread, out var preferredExchange).ToString(), + Spread = storeBlob.Spread * 100.0m, IsCustomScript = blob.RateScripting, - PreferredSource = preferredExchange ? blob.GetPreferredExchange(_defaultRules) : null + PreferredSource = preferredExchange ? blob.GetPreferredExchange(_defaultRules, storeBlob.DefaultCurrency) : null }); } @@ -63,25 +76,38 @@ namespace BTCPayServer.Controllers.GreenField new RateSource() { Id = provider.Id, Name = provider.DisplayName })); } - [HttpPut("")] + [HttpPut("fallback")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task UpdateStoreRateConfiguration( + public Task UpdateStoreRateFallbackConfiguration( StoreRateConfiguration configuration) + => UpdateStoreRateConfigurationCore(configuration, true); + + [HttpPut("")] + [HttpPut("primary")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public Task UpdateStoreRateConfiguration( + StoreRateConfiguration configuration) + => UpdateStoreRateConfigurationCore(configuration, false); + + [NonAction] + private async Task UpdateStoreRateConfigurationCore(StoreRateConfiguration configuration, bool fallback) { var storeData = HttpContext.GetStoreData(); - var blob = storeData.GetStoreBlob(); - ValidateAndSanitizeConfiguration(configuration, blob); + var storeBlob = storeData.GetStoreBlob(); + var blob = storeBlob.GetRateSettings(fallback); + if (blob is null) + return this.CreateAPIError(404, "fallback-disabled", "The fallback rates are disabled"); + ValidateAndSanitizeConfiguration(configuration, storeBlob, blob); if (!ModelState.IsValid) return this.CreateValidationError(ModelState); - PopulateBlob(configuration, blob); + PopulateBlob(configuration, storeBlob, blob); - storeData.SetStoreBlob(blob); + storeData.SetStoreBlob(storeBlob); await _storeRepository.UpdateStore(storeData); - - - return GetStoreRateConfiguration(); + HttpContext.SetStoreData(storeData); + return GetStoreRateConfigurationCore(fallback); } [HttpPost("preview")] @@ -90,7 +116,9 @@ namespace BTCPayServer.Controllers.GreenField StoreRateConfiguration configuration, [FromQuery] string[]? currencyPair) { var data = HttpContext.GetStoreData(); - var blob = data.GetStoreBlob(); + var storeBlob = data.GetStoreBlob(); + // Fallback or not, the preview will be the same + var blob = storeBlob.GetOrCreateRateSettings(true); var parsedCurrencyPairs = new HashSet(); if (currencyPair?.Any() is true) @@ -109,15 +137,15 @@ namespace BTCPayServer.Controllers.GreenField } else { - parsedCurrencyPairs = blob.DefaultCurrencyPairs.ToHashSet(); + parsedCurrencyPairs = storeBlob.DefaultCurrencyPairs?.ToHashSet() ?? new(); } - ValidateAndSanitizeConfiguration(configuration, blob); + ValidateAndSanitizeConfiguration(configuration, storeBlob, blob); if (!ModelState.IsValid) return this.CreateValidationError(ModelState); - PopulateBlob(configuration, blob); + PopulateBlob(configuration, storeBlob, blob); - var rules = blob.GetRateRules(_defaultRules); + var rules = blob.GetRateRules(_defaultRules, storeBlob.Spread); var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, new StoreIdRateContext(data.Id), CancellationToken.None); @@ -131,14 +159,14 @@ namespace BTCPayServer.Controllers.GreenField { CurrencyPair = rateTask.Key.ToString(), Errors = rateTaskResult.Errors.Select(errors => errors.ToString()).ToList(), - Rate = rateTaskResult.Errors.Any() ? (decimal?)null : rateTaskResult.BidAsk.Bid + Rate = rateTaskResult.Errors.Any() ? null : rateTaskResult.BidAsk.Bid }); } return Ok(result); } - private void ValidateAndSanitizeConfiguration(StoreRateConfiguration? configuration, StoreBlob storeBlob) + private void ValidateAndSanitizeConfiguration(StoreRateConfiguration? configuration, StoreBlob storeBlob, StoreBlob.RateSettings rateSettings) { if (configuration is null) { @@ -155,7 +183,7 @@ namespace BTCPayServer.Controllers.GreenField { if (string.IsNullOrEmpty(configuration.EffectiveScript)) { - configuration.EffectiveScript = storeBlob.GetDefaultRateRules(_defaultRules).ToString(); + configuration.EffectiveScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString(); } if (!RateRules.TryParse(configuration.EffectiveScript, out var r)) @@ -199,12 +227,12 @@ $"You can't set the preferredSource if you are using custom scripts"); } } - private static void PopulateBlob(StoreRateConfiguration configuration, StoreBlob storeBlob) + private static void PopulateBlob(StoreRateConfiguration configuration, StoreBlob storeBlob, StoreBlob.RateSettings rateSettings) { - storeBlob.PreferredExchange = configuration.PreferredSource; + rateSettings.PreferredExchange = configuration.PreferredSource; storeBlob.Spread = configuration.Spread / 100.0m; - storeBlob.RateScripting = configuration.IsCustomScript; - storeBlob.RateScript = configuration.EffectiveScript; + rateSettings.RateScripting = configuration.IsCustomScript; + rateSettings.RateScript = configuration.EffectiveScript; } } } diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 6ca2baf6e..6e4a3f8a9 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -712,7 +712,7 @@ namespace BTCPayServer.Controllers.Greenfield return GetFromActionResult( await GetController().ShowOnChainWalletOverview(storeId, cryptoCode)); } - + public override async Task GetOnChainWalletHistogram(string storeId, string cryptoCode, HistogramType? type = null, CancellationToken token = default) { return GetFromActionResult( @@ -1199,9 +1199,15 @@ namespace BTCPayServer.Controllers.Greenfield return Task.FromResult(GetFromActionResult(GetController().GetRateSources())); } - public override Task GetStoreRateConfiguration(string storeId, CancellationToken token = default) + public override Task GetStoreRateConfiguration(string storeId, bool? fallback = null, CancellationToken token = default) { - return Task.FromResult(GetFromActionResult(GetController().GetStoreRateConfiguration())); + var ctrl = GetController(); + var res = fallback switch + { + null or true => ctrl.GetStoreFallbackRateConfiguration(), + false => ctrl.GetStoreRateConfiguration() + }; + return Task.FromResult(GetFromActionResult(res)); } public override async Task> GetStoreRates(string storeId, @@ -1220,9 +1226,15 @@ namespace BTCPayServer.Controllers.Greenfield currencyPair)); } - public override async Task UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, CancellationToken token = default) + public override async Task UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, bool? fallback = null, CancellationToken token = default) { - return GetFromActionResult(await GetController().UpdateStoreRateConfiguration(request)); + var ctrl = GetController(); + var res = fallback switch + { + null or true => await ctrl.UpdateStoreRateConfiguration(request), + false => await ctrl.UpdateStoreRateFallbackConfiguration(request) + }; + return GetFromActionResult(res); } public override async Task MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default) diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index c24c75ff1..f9f9bedaf 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -340,7 +340,7 @@ namespace BTCPayServer.Controllers var store = GetCurrentStore(); var pmi = PayoutMethodId.Parse(model.SelectedPayoutMethod); var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true); - RateRules rules; + RateRulesCollection rules; RateResult rateResult; CreatePullPaymentRequest createPullPayment; diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index 80d39df45..f4f9a3d4d 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -269,13 +269,11 @@ namespace BTCPayServer.Controllers "No wallet has been linked to your BTCPay Store. See the following link for more information on how to connect your store and wallet. (https://docs.btcpayserver.org/WalletSetup/)"); else { - var list = logs.ToList(); - var errors = list.Where(l => l.Severity == InvoiceEventData.EventSeverity.Error).Select(l => l.Log); message.AppendLine("Error retrieving a matching payment method or rate."); - foreach (var error in errors) - message.AppendLine(error); + foreach (var error in logs.ToList()) + message.AppendLine(error.ToString()); } - + throw new BitpayHttpException(400, message.ToString()); } entity.SetPaymentPrompts(new PaymentPromptDictionary(contexts.Select(c => c.Prompt))); diff --git a/BTCPayServer/Controllers/UIStoresController.Rates.cs b/BTCPayServer/Controllers/UIStoresController.Rates.cs index ab2372b89..b5b72bd5a 100644 --- a/BTCPayServer/Controllers/UIStoresController.Rates.cs +++ b/BTCPayServer/Controllers/UIStoresController.Rates.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; using BTCPayServer.Data; @@ -24,7 +25,7 @@ public partial class UIStoresController { var storeBlob = CurrentStore.GetStoreBlob(); var vm = new RatesViewModel(); - FillFromStore(vm, storeBlob); + SetViewModel(vm, storeBlob); return View(vm); } @@ -32,61 +33,63 @@ public partial class UIStoresController [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task Rates(RatesViewModel model, string? command = null, string? storeId = null, CancellationToken cancellationToken = default) { - if (command == "scripting-on") - { - return RedirectToAction(nameof(ShowRateRules), new { scripting = true, storeId = model.StoreId }); - } - if (command == "scripting-off") - { - return RedirectToAction(nameof(ShowRateRules), new { scripting = false, storeId = model.StoreId }); - } model.StoreId = storeId ?? model.StoreId; - CurrencyPair[]? currencyPairs = null; + + var storeBlob = CurrentStore.GetStoreBlob(); try { - currencyPairs = model.DefaultCurrencyPairs? + var currencyPairs = model.DefaultCurrencyPairs? .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) .Select(p => CurrencyPair.Parse(p)) .ToArray(); + storeBlob.DefaultCurrencyPairs = currencyPairs; } catch { ModelState.AddModelError(nameof(model.DefaultCurrencyPairs), StringLocalizer["Invalid currency pairs (should be for example: {0})", "BTC_USD,BTC_CAD,BTC_JPY"]); } + storeBlob.Spread = (decimal)model.Spread / 100.0m; + + var primarySettings = storeBlob.PrimaryRateSettings ??= new(); + FillToStore(primarySettings, model.PrimarySource); + if (model.HasFallback) + { + storeBlob.FallbackRateSettings = new(); + FillToStore(storeBlob.FallbackRateSettings, model.FallbackSource); + } + else + { + storeBlob.FallbackRateSettings = null; + } + if (!ModelState.IsValid) { + SetViewModel(model, storeBlob); return View(model); } - if (model.PreferredExchange != null) - model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(model.PreferredExchange)) - model.PreferredExchange = null; - var blob = CurrentStore.GetStoreBlob(); - RateRules? rules; - if (model.ShowScripting) + if (command is "scripting-toggle-fallback" or "scripting-toggle-primary") { - if (!RateRules.TryParse(model.Script, out rules, out var errors)) + var isFallback = command is "scripting-toggle-fallback"; + var rateSettings = storeBlob.GetOrCreateRateSettings(isFallback); + rateSettings.RateScripting = !rateSettings.RateScripting; + rateSettings.RateScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString(); + CurrentStore.SetStoreBlob(storeBlob); + await _storeRepo.UpdateStore(CurrentStore); + if (rateSettings.RateScripting) { - errors ??= []; - var errorString = string.Join(", ", errors.ToArray()); - ModelState.AddModelError(nameof(model.Script), StringLocalizer["Parsing error: {0}", errorString]); - FillFromStore(model, blob); - return View(model); + TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Rate rules scripting activated"].Value; } else { - blob.RateScript = rules.ToString(); - ModelState.Remove(nameof(model.Script)); + TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Rate rules scripting deactivated"].Value; } + + return RedirectToAction(nameof(Rates), null, new { storeId = CurrentStore.Id }); } - blob.PreferredExchange = model.PreferredExchange; - blob.Spread = (decimal)model.Spread / 100.0m; - blob.DefaultCurrencyPairs = currencyPairs; - FillFromStore(model, blob); - rules = blob.GetRateRules(_defaultRules); - if (command == "Test") + else if (command == "Test") { + SetViewModel(model, storeBlob); if (string.IsNullOrWhiteSpace(model.ScriptTest)) { ModelState.AddModelError(nameof(model.ScriptTest), StringLocalizer["Fill out currency pair to test for (like {0})", "BTC_USD,BTC_CAD"]); @@ -104,32 +107,34 @@ public partial class UIStoresController } pairs.Add(currencyPair); } - - var fetchs = _rateFactory.FetchRates(pairs.ToHashSet(), rules, new StoreIdRateContext(model.StoreId), cancellationToken); var testResults = new List(); - foreach (var fetch in fetchs) + foreach (var isFallback in new[]{ false, true }) { - var testResult = await (fetch.Value); - testResults.Add(new RatesViewModel.TestResultViewModel + var blob = storeBlob.GetRateSettings(isFallback); + if (blob is null) + continue; + var rules = blob.GetRateRules(_defaultRules, storeBlob.Spread); + var fetchs = _rateFactory.FetchRates(pairs.ToHashSet(), rules, new StoreIdRateContext(model.StoreId), cancellationToken); + foreach (var fetch in fetchs) { - CurrencyPair = fetch.Key.ToString(), - Error = testResult.Errors.Count != 0, - Rule = testResult.Errors.Count == 0 ? testResult.Rule + " = " + testResult.BidAsk.Bid.ToString(CultureInfo.InvariantCulture) - : testResult.EvaluatedRule - }); + var testResult = await (fetch.Value); + testResults.Add(new RatesViewModel.TestResultViewModel + { + CurrencyPair = isFallback ? $"{fetch.Key} (fallback)" : fetch.Key.ToString(), + Error = testResult.Errors.Count != 0, + Rule = testResult.Errors.Count == 0 + ? testResult.Rule + " = " + testResult.BidAsk.Bid.ToString(CultureInfo.InvariantCulture) + : testResult.EvaluatedRule + }); + } } - model.TestRateRules = testResults; - return View(model); - } - - if (model.PreferredExchange is not null && !model.AvailableExchanges.Any(a => a.Id == model.PreferredExchange)) - { - ModelState.AddModelError(nameof(model.PreferredExchange), StringLocalizer["Unsupported exchange"]); + model.TestRateRules = testResults.OrderBy(o => o.CurrencyPair).ToList(); + model.Hash = "#TestResult"; return View(model); } // command == Save - if (CurrentStore.SetStoreBlob(blob)) + if (CurrentStore.SetStoreBlob(storeBlob)) { await _storeRepo.UpdateStore(CurrentStore); TempData[WellKnownTempData.SuccessMessage] = "Rate settings updated"; @@ -140,50 +145,97 @@ public partial class UIStoresController }); } - [HttpGet("{storeId}/rates/confirm")] - [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] - public IActionResult ShowRateRules(bool scripting) + private void FillToStore(StoreBlob.RateSettings blob, RatesViewModel.Source model) { - return View("Confirm", new ConfirmModel + if (model.PreferredExchange != null) + model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(model.PreferredExchange)) + model.PreferredExchange = null; + + blob.RateScripting = model.ShowScripting; + if (model.ShowScripting) { - Action = StringLocalizer["Continue"], - Title = StringLocalizer["Rate rule scripting"], - Description = scripting - ? StringLocalizer["This action will modify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)"] - : StringLocalizer["This action will delete your rate script. Are you sure to turn off rate rules scripting?"], - ButtonClass = scripting ? "btn-primary" : "btn-danger" - }); + RateRules? rules; + if (!RateRules.TryParse(model.Script, out rules, out var errors)) + { + errors ??= []; + var errorString = string.Join(", ", errors.ToArray()); + ModelState.AddModelError(nameof(model.Script), StringLocalizer["Parsing error: {0}", errorString]); + return; + } + + blob.RateScript = rules.ToString(); + ModelState.Remove(nameof(model.Script)); + } + else + { + blob.RateScript = null; + } + blob.PreferredExchange = model.PreferredExchange; + if (model.PreferredExchange is not null && GetAvailableExchanges().All(a => a.Id != model.PreferredExchange)) + { + ModelState.AddModelError(nameof(model.PreferredExchange), StringLocalizer["Unsupported exchange"]); + return; + } } - [HttpPost("{storeId}/rates/confirm")] - [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] - public async Task ShowRateRulesPost(bool scripting) + private void SetViewModel(RatesViewModel.Source vm, StoreBlob.RateSettings? rateSettings, StoreBlob storeBlob) { - var blob = CurrentStore.GetStoreBlob(); - blob.RateScripting = scripting; - 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 void FillFromStore(RatesViewModel vm, StoreBlob storeBlob) - { - var sources = _rateFactory.RateProviderFactory.AvailableRateProviders - .OrderBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase); - vm.AvailableExchanges = sources; - var exchange = storeBlob.GetPreferredExchange(_defaultRules); + if (rateSettings is null) + return; + var sources = GetAvailableExchanges(); + var exchange = rateSettings.GetPreferredExchange(_defaultRules, storeBlob.DefaultCurrency); var chosenSource = sources.First(r => r.Id == exchange); - vm.Exchanges = _userStoresController.GetExchangesSelectList(storeBlob); + vm.Exchanges = _userStoresController.GetExchangesSelectList(storeBlob.DefaultCurrency, rateSettings); vm.PreferredExchange = vm.Exchanges.SelectedValue as string; vm.PreferredResolvedExchange = chosenSource.Id; vm.RateSource = chosenSource.Url; + vm.Script = rateSettings.GetRateRules(_defaultRules, storeBlob.Spread).ToString(); + vm.DefaultScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString(); + vm.ShowScripting = rateSettings.RateScripting; + + vm.ScriptingConfirm = new() + { + Title = StringLocalizer["Rate rule scripting"], + Action = StringLocalizer["Continue"], + GenerateForm = false + }; + if (vm.ShowScripting) + { + vm.ScriptingConfirm.Description = StringLocalizer["This action will delete your rate script. Are you sure to turn off rate rules scripting?"]; + vm.ScriptingConfirm.ButtonClass = "btn-danger"; + } + else + { + vm.ScriptingConfirm.Description = StringLocalizer["This action will modify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)"]; + vm.ScriptingConfirm.ButtonClass = "btn-primary"; + } + } + + private List GetAvailableExchanges() + { + return _rateFactory.RateProviderFactory.AvailableRateProviders + .OrderBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private void SetViewModel(RatesViewModel vm, StoreBlob storeBlob) + { + vm.AvailableExchanges = GetAvailableExchanges(); + vm.PrimarySource = new(); + vm.FallbackSource = new() { IsFallback = true }; + SetViewModel(vm.PrimarySource, storeBlob.GetRateSettings(false), storeBlob); + if (storeBlob.GetRateSettings(true) is { } r) + { + vm.HasFallback = true; + SetViewModel(vm.FallbackSource, r, storeBlob); + } + else + { + SetViewModel(vm.FallbackSource, new(), storeBlob); + } + 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; } } diff --git a/BTCPayServer/Controllers/UIUserStoresController.cs b/BTCPayServer/Controllers/UIUserStoresController.cs index 659d5d593..73bae2e2d 100644 --- a/BTCPayServer/Controllers/UIUserStoresController.cs +++ b/BTCPayServer/Controllers/UIUserStoresController.cs @@ -71,11 +71,12 @@ namespace BTCPayServer.Controllers public async Task CreateStore(bool skipWizard) { var stores = await _repo.GetStoresByUserId(GetUserId()); + var defaultCurrency = (await _settingsRepository.GetSettingAsync())?.DefaultCurrency ?? StoreBlob.StandardDefaultCurrency; var vm = new CreateStoreViewModel { IsFirstStore = !(stores.Any() || skipWizard), - DefaultCurrency = (await _settingsRepository.GetSettingAsync())?.DefaultCurrency ?? StoreBlob.StandardDefaultCurrency, - Exchanges = GetExchangesSelectList(null) + DefaultCurrency = defaultCurrency, + Exchanges = GetExchangesSelectList(defaultCurrency, null) }; return View(vm); @@ -89,14 +90,15 @@ namespace BTCPayServer.Controllers { var stores = await _repo.GetStoresByUserId(GetUserId()); vm.IsFirstStore = !stores.Any(); - vm.Exchanges = GetExchangesSelectList(null); + var defaultCurrency = (await _settingsRepository.GetSettingAsync())?.DefaultCurrency ?? StoreBlob.StandardDefaultCurrency; + vm.Exchanges = GetExchangesSelectList(defaultCurrency, null); return View(vm); } var store = new StoreData { StoreName = vm.Name }; var blob = store.GetStoreBlob(); blob.DefaultCurrency = vm.DefaultCurrency; - blob.PreferredExchange = vm.PreferredExchange; + blob.GetOrCreateRateSettings(false).PreferredExchange = vm.PreferredExchange; store.SetStoreBlob(blob); await _repo.CreateStore(GetUserId(), store); CreatedStoreId = store.Id; @@ -132,18 +134,18 @@ namespace BTCPayServer.Controllers private string GetUserId() => _userManager.GetUserId(User); - internal SelectList GetExchangesSelectList(StoreBlob storeBlob) + internal SelectList GetExchangesSelectList(string defaultCurrency, StoreBlob.RateSettings rateSettings) { - if (storeBlob is null) - storeBlob = new StoreBlob(); - var defaultExchange = _defaultRules.GetRecommendedExchange(storeBlob.DefaultCurrency); + if (rateSettings is null) + rateSettings = new (); + var defaultExchange = _defaultRules.GetRecommendedExchange(defaultCurrency); var exchanges = _rateFactory.RateProviderFactory .AvailableRateProviders .OrderBy(s => s.Id, StringComparer.OrdinalIgnoreCase) .ToList(); var exchange = exchanges.First(e => e.Id == defaultExchange); exchanges.Insert(0, new(null, StringLocalizer["Recommendation ({0})", exchange.DisplayName], "")); - var chosen = exchanges.FirstOrDefault(f => f.Id == storeBlob.PreferredExchange) ?? exchanges.First(); + var chosen = exchanges.FirstOrDefault(f => f.Id == rateSettings.PreferredExchange) ?? exchanges.First(); return new SelectList(exchanges, nameof(chosen.Id), nameof(chosen.DisplayName), chosen.Id); } } diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 2529ff027..95411e248 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -876,7 +876,7 @@ namespace BTCPayServer.Controllers throw new Exception("Store not found"); var storeData = store.GetStoreBlob(); var rateRules = storeData.GetRateRules(_defaultRules); - rateRules.Spread = 0.0m; + storeData.Spread = 0.0m; var currencyPair = new CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency); using CancellationTokenSource cts = new(); diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 5dd59bb55..1792860a5 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -58,7 +58,7 @@ namespace BTCPayServer.Data _DefaultCurrency = _DefaultCurrency.Trim().ToUpperInvariant(); } } - + public string StoreSupportUrl { get; set; } CurrencyPair[] _DefaultCurrencyPairs; @@ -101,32 +101,93 @@ namespace BTCPayServer.Data public TimeSpan DisplayExpirationTimer { get; set; } public decimal Spread { get; set; } = 0.0m; - - /// - /// This may be null. Use instead if you want to return a valid exchange - /// - public string PreferredExchange { get; set; } - /// - /// Use the preferred exchange of the store, or the recommended exchange from the default currency - /// - /// - /// - public string GetPreferredExchange(DefaultRulesCollection defaultRules) + public class RateSettings { - return string.IsNullOrEmpty(PreferredExchange) ? defaultRules.GetRecommendedExchange(DefaultCurrency) : PreferredExchange; + /// + /// This may be null. Use instead if you want to return a valid exchange + /// + public string PreferredExchange { get; set; } + public bool RateScripting { get; set; } + public string RateScript { get; set; } + + /// + /// Use the preferred exchange of the store, or the recommended exchange from the default currency + /// + /// + /// + public string GetPreferredExchange(DefaultRulesCollection defaultRules, string defaultCurrency) + { + return string.IsNullOrEmpty(PreferredExchange) ? defaultRules.GetRecommendedExchange(defaultCurrency) : PreferredExchange; + } + public BTCPayServer.Rating.RateRules GetRateRules(DefaultRulesCollection defaultRules, decimal spread) + { + return GetRateRules(defaultRules, spread, out _); + } + public BTCPayServer.Rating.RateRules GetRateRules(DefaultRulesCollection defaultRules, decimal spread, out bool preferredSource) + { + if (!RateScripting || + string.IsNullOrEmpty(RateScript) || + !BTCPayServer.Rating.RateRules.TryParse(RateScript, out var rules)) + { + preferredSource = true; + return GetDefaultRateRules(defaultRules, spread); + } + else + { + preferredSource = false; + rules.Spread = spread; + return rules; + } + } + + public RateRules GetDefaultRateRules(DefaultRulesCollection defaultRules, decimal spread) + { + var rules = defaultRules.WithPreferredExchange(PreferredExchange); + rules.Spread = spread; + return rules; + } } + #nullable enable + public void SetRateSettings(RateSettings? rateSettings, bool fallback) + { + if (fallback) + FallbackRateSettings = rateSettings; + else + PrimaryRateSettings = rateSettings; + } + + public RateSettings GetOrCreateRateSettings(bool fallback) + { + var settings = GetRateSettings(fallback); + if (settings is null) + { + settings = new RateSettings(); + SetRateSettings(settings, fallback); + } + return settings; + } + public RateSettings? GetRateSettings(bool fallback) + { + if (fallback) + return FallbackRateSettings; + PrimaryRateSettings ??= new(); + return PrimaryRateSettings; + } + public RateSettings? PrimaryRateSettings { get; set; } + public RateSettings? FallbackRateSettings { get; set; } +#nullable restore + + public List PaymentMethodCriteria { get; set; } public string HtmlTitle { get; set; } public bool AutoDetectLanguage { get; set; } - public bool RateScripting { get; set; } #nullable enable [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } #nullable restore - public string RateScript { get; set; } public bool AnyoneCanInvoice { get; set; } @@ -147,34 +208,6 @@ namespace BTCPayServer.Data [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] public double PaymentTolerance { get; set; } - public BTCPayServer.Rating.RateRules GetRateRules(DefaultRulesCollection defaultRules) - { - return GetRateRules(defaultRules, out _); - } - 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(defaultRules); - } - else - { - preferredSource = false; - rules.Spread = Spread; - return rules; - } - } - - public RateRules GetDefaultRateRules(DefaultRulesCollection defaultRules) - { - var rules = defaultRules.WithPreferredExchange(PreferredExchange); - rules.Spread = Spread; - return rules; - } - [Obsolete("Use GetExcludedPaymentMethods instead")] public string[] ExcludedPaymentMethods { get; set; } @@ -192,7 +225,7 @@ namespace BTCPayServer.Data public List EmailRules { get; set; } public string BrandColor { get; set; } public bool ApplyBrandColorToBackend { get; set; } - + [JsonConverter(typeof(UnresolvedUriJsonConverter))] public UnresolvedUri LogoUrl { get; set; } [JsonConverter(typeof(UnresolvedUriJsonConverter))] @@ -209,7 +242,7 @@ namespace BTCPayServer.Data [DefaultValue(true)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] public bool CelebratePayment { get; set; } = true; - + [DefaultValue(false)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] public bool PlaySoundOnPayment { get; set; } @@ -245,6 +278,13 @@ namespace BTCPayServer.Data ExcludedPaymentMethods = methods.ToArray(); #pragma warning restore CS0618 // Type or member is obsolete } + + public RateRulesCollection GetRateRules(DefaultRulesCollection defaultRules) + { + return new( + (PrimaryRateSettings ?? new()).GetRateRules(defaultRules, Spread), + FallbackRateSettings?.GetRateRules(defaultRules, Spread)); + } } public class PaymentMethodCriteria { diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index c7ea1f98e..904deeef1 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -443,19 +443,19 @@ namespace BTCPayServer.HostedServices var cryptoCode = _handlers.TryGetNetwork(payoutPaymentMethod)?.NBXplorerNetwork.CryptoCode; var currencyPair = new Rating.CurrencyPair(cryptoCode, payout.PullPaymentData?.Currency ?? cryptoCode); - Rating.RateRule rule = null; + Rating.RateRuleCollection rule = null; try { if (explicitRateRule is null) { var storeBlob = payout.StoreData.GetStoreBlob(); var rules = storeBlob.GetRateRules(_defaultRules); - rules.Spread = 0.0m; + storeBlob.Spread = 0.0m; rule = rules.GetRuleFor(currencyPair); } else { - rule = Rating.RateRule.CreateFromExpression(explicitRateRule, currencyPair); + rule = new RateRuleCollection(Rating.RateRule.CreateFromExpression(explicitRateRule, currencyPair), null); } } catch (Exception) @@ -956,13 +956,13 @@ namespace BTCPayServer.HostedServices public record Error(string Message) : ClaimedAmountResult; public record Success(decimal? Amount) : ClaimedAmountResult; } - - + + public static ClaimedAmountResult GetClaimedAmount(IClaimDestination destination, decimal? amount, string payoutCurrency, string ppCurrency) { var amountsComparable = false; var destinationAmount = destination.Amount; - if (destinationAmount is not null && + if (destinationAmount is not null && payoutCurrency == "BTC" && ppCurrency == "SATS") { diff --git a/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs index 160b0fc2a..1e9275547 100644 --- a/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs @@ -1,15 +1,28 @@ -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; +using BTCPayServer.Abstractions.Models; using BTCPayServer.Rating; -using BTCPayServer.Services.Rates; using Microsoft.AspNetCore.Mvc.Rendering; namespace BTCPayServer.Models.StoreViewModels { public class RatesViewModel { + public class Source + { + public bool ShowScripting { get; set; } + [Display(Name = "Rate Rules")] + [MaxLength(2000)] + public string Script { get; set; } + public string DefaultScript { get; set; } + [Display(Name = "Preferred Price Source")] + public string PreferredExchange { get; set; } + public SelectList Exchanges { get; set; } + public string RateSource { get; set; } + public string PreferredResolvedExchange { get; set; } + public bool IsFallback { get; set; } + public ConfirmModel ScriptingConfirm { get; set; } + } public class TestResultViewModel { public string CurrencyPair { get; set; } @@ -18,28 +31,20 @@ namespace BTCPayServer.Models.StoreViewModels } public List TestRateRules { get; set; } + public string Hash { get; set; } - public SelectList Exchanges { get; set; } + public Source PrimarySource { get; set; } + public Source FallbackSource { get; set; } + [Display(Name = "Enable fallback rates")] + public bool HasFallback {get; set; } - public bool ShowScripting { get; set; } - - [Display(Name = "Rate Rules")] - [MaxLength(2000)] - public string Script { get; set; } - public string DefaultScript { get; set; } public string ScriptTest { get; set; } public string DefaultCurrencyPairs { get; set; } public string StoreId { get; set; } - public IEnumerable AvailableExchanges { get; set; } [Display(Name = "Add Exchange Rate Spread")] [Range(0.0, 100.0)] public double Spread { get; set; } - - [Display(Name = "Preferred Price Source")] - public string PreferredExchange { get; set; } - public string PreferredResolvedExchange { get; set; } - - public string RateSource { get; set; } + public IEnumerable AvailableExchanges { get; set; } } } diff --git a/BTCPayServer/Payments/IPaymentMethodHandler.cs b/BTCPayServer/Payments/IPaymentMethodHandler.cs index 62b11d160..f07b3ab9e 100644 --- a/BTCPayServer/Payments/IPaymentMethodHandler.cs +++ b/BTCPayServer/Payments/IPaymentMethodHandler.cs @@ -168,11 +168,11 @@ namespace BTCPayServer.Payments return Task.WhenAll(PaymentMethodContexts.Select(c => c.Value.ActivatingPaymentPrompt())); } - public async Task FetchingRates(RateFetcher rateFetcher, RateRules rateRules, CancellationToken cancellationToken) + public async Task FetchingRates(RateFetcher rateFetcher, RateRulesCollection rateRules, CancellationToken cancellationToken) { var currencyPairsToFetch = GetCurrenciesToFetch(); var fetchingRates = rateFetcher.FetchRates(currencyPairsToFetch, rateRules, new StoreIdRateContext(InvoiceEntity.StoreId), cancellationToken); - HashSet failedRates = new HashSet(); + var failedRates = new HashSet(); foreach (var fetching in fetchingRates) { try @@ -187,7 +187,15 @@ namespace BTCPayServer.Payments Logs.Write($"Rate for {fetching.Key}: {rateResult.Rule} = {rateResult.EvaluatedRule} = {bidLog}", InvoiceEventData.EventSeverity.Info); if (rateResult is RateResult { BidAsk: { } bidAsk }) { - InvoiceEntity.AddRate(fetching.Key, bidAsk.Bid); + if (bidAsk.Bid == 0.0m) + { + failedRates.Add(fetching.Key); + Logs.Write($"The calculated rate should not be 0.", InvoiceEventData.EventSeverity.Error); + } + else + { + InvoiceEntity.AddRate(fetching.Key, bidAsk.Bid); + } } else { @@ -301,7 +309,7 @@ namespace BTCPayServer.Payments var currency = Prompt.Currency; if (currency is not null) RequiredRates.Add(currency); - if (currency is not null + if (currency is not null && Status is PaymentMethodContext.ContextStatus.WaitingForCreation or PaymentMethodContext.ContextStatus.WaitingForActivation) { foreach (var paymentMethodCriteria in StoreBlob.PaymentMethodCriteria diff --git a/BTCPayServer/Payments/Lightning/LightningExtensions.cs b/BTCPayServer/Payments/Lightning/LightningExtensions.cs index a142a4184..bb3a62a4d 100644 --- a/BTCPayServer/Payments/Lightning/LightningExtensions.cs +++ b/BTCPayServer/Payments/Lightning/LightningExtensions.cs @@ -1,3 +1,4 @@ +#nullable enable using BTCPayServer.Configuration; using BTCPayServer.Lightning; using BTCPayServer.Services; @@ -25,7 +26,6 @@ namespace BTCPayServer.Payments.Lightning return connectionString; } } - public static uint256? GetPaymentHash(this LightningInvoice lightningInvoice, Network btcpayNetwork) { return lightningInvoice.PaymentHash != null ? diff --git a/BTCPayServer/Security/CookieAuthorizationHandler.cs b/BTCPayServer/Security/CookieAuthorizationHandler.cs index 3ed69b96a..07710584e 100644 --- a/BTCPayServer/Security/CookieAuthorizationHandler.cs +++ b/BTCPayServer/Security/CookieAuthorizationHandler.cs @@ -43,7 +43,7 @@ namespace BTCPayServer.Security //TODO: In the future, we will add these store permissions to actual aspnet roles, and remove this class. private static readonly PermissionSet ServerAdminRolePermissions = new PermissionSet(new[] {Permission.Create(Policies.CanViewStoreSettings, null)}); - + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement) { if (context.User.Identity.AuthenticationType != AuthenticationSchemes.Cookie) @@ -146,7 +146,7 @@ namespace BTCPayServer.Security if (isAdmin && storeId is not null) { success = ServerAdminRolePermissions.HasPermission(policy, storeId); - + } if (!success && store?.HasPermission(userId, policy) is true) @@ -174,7 +174,6 @@ namespace BTCPayServer.Security if (storeId is not null && store is null) { store = await _storeRepository.FindStore(storeId); - } if (store != null) { diff --git a/BTCPayServer/Views/Shared/ConfirmModal.cshtml b/BTCPayServer/Views/Shared/ConfirmModal.cshtml index fc5a8aa3c..648aeb2e4 100644 --- a/BTCPayServer/Views/Shared/ConfirmModal.cshtml +++ b/BTCPayServer/Views/Shared/ConfirmModal.cshtml @@ -1,4 +1,5 @@ @model BTCPayServer.Abstractions.Models.ConfirmModel + @inject LinkGenerator linkGenerator @{ string actionUrl = null; @@ -11,14 +12,14 @@