Make rate calculation scriptable

This commit is contained in:
nicolas.dorier
2018-05-04 01:46:52 +09:00
parent f460837f96
commit 6dc4bfaefe
22 changed files with 472 additions and 121 deletions

View File

@@ -79,7 +79,7 @@ namespace BTCPayServer.Tests
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId); var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork); ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork);
DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]"); DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]");
var vm = (StoreViewModel)((ViewResult)store.UpdateStore(StoreId)).Model; var vm = (StoreViewModel)((ViewResult)store.UpdateStore()).Model;
vm.SpeedPolicy = SpeedPolicy.MediumSpeed; vm.SpeedPolicy = SpeedPolicy.MediumSpeed;
await store.UpdateStore(vm); await store.UpdateStore(vm);

View File

@@ -297,7 +297,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount(); var user = tester.NewAccount();
user.GrantAccess(); user.GrantAccess();
var storeController = user.GetController<StoresController>(); var storeController = user.GetController<StoresController>();
Assert.IsType<ViewResult>(storeController.UpdateStore(user.StoreId)); Assert.IsType<ViewResult>(storeController.UpdateStore());
Assert.IsType<ViewResult>(storeController.AddLightningNode(user.StoreId, "BTC")); Assert.IsType<ViewResult>(storeController.AddLightningNode(user.StoreId, "BTC"));
var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel() var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel()
@@ -312,7 +312,7 @@ namespace BTCPayServer.Tests
Url = tester.MerchantCharge.Client.Uri.AbsoluteUri Url = tester.MerchantCharge.Client.Uri.AbsoluteUri
}, "save", "BTC").GetAwaiter().GetResult()); }, "save", "BTC").GetAwaiter().GetResult());
var storeVm = Assert.IsType<Models.StoreViewModels.StoreViewModel>(Assert.IsType<ViewResult>(storeController.UpdateStore(user.StoreId)).Model); var storeVm = Assert.IsType<Models.StoreViewModels.StoreViewModel>(Assert.IsType<ViewResult>(storeController.UpdateStore()).Model);
Assert.Single(storeVm.LightningNodes.Where(l => !string.IsNullOrEmpty(l.Address))); Assert.Single(storeVm.LightningNodes.Where(l => !string.IsNullOrEmpty(l.Address)));
} }
} }
@@ -678,9 +678,9 @@ namespace BTCPayServer.Tests
private static decimal CreateInvoice(ServerTester tester, TestAccount user, string exchange) private static decimal CreateInvoice(ServerTester tester, TestAccount user, string exchange)
{ {
var storeController = user.GetController<StoresController>(); var storeController = user.GetController<StoresController>();
var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model;
vm.PreferredExchange = exchange; vm.PreferredExchange = exchange;
storeController.UpdateStore(vm).Wait(); storeController.Rates(vm).Wait();
var invoice2 = user.BitPay.CreateInvoice(new Invoice() var invoice2 = user.BitPay.CreateInvoice(new Invoice()
{ {
Price = 5000.0, Price = 5000.0,
@@ -717,10 +717,10 @@ namespace BTCPayServer.Tests
var storeController = user.GetController<StoresController>(); var storeController = user.GetController<StoresController>();
var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model;
Assert.Equal(1.0, vm.RateMultiplier); Assert.Equal(1.0, vm.RateMultiplier);
vm.RateMultiplier = 0.5; vm.RateMultiplier = 0.5;
storeController.UpdateStore(vm).Wait(); storeController.Rates(vm).Wait();
var invoice2 = user.BitPay.CreateInvoice(new Invoice() var invoice2 = user.BitPay.CreateInvoice(new Invoice()
@@ -796,6 +796,66 @@ namespace BTCPayServer.Tests
} }
} }
[Fact]
public void CanModifyRates()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var store = user.GetController<StoresController>();
var rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates()).Model);
Assert.False(rateVm.ShowScripting);
Assert.Equal("coinaverage", rateVm.PreferredExchange);
Assert.Equal(1.0, rateVm.RateMultiplier);
Assert.Null(rateVm.TestRateRules);
rateVm.PreferredExchange = "bitflyer";
Assert.IsType<RedirectToActionResult>(store.Rates(rateVm, "Save").Result);
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates()).Model);
Assert.Equal("bitflyer", rateVm.PreferredExchange);
rateVm.ScriptTest = "BTC_JPY,BTC_CAD";
rateVm.RateMultiplier = 1.1;
store = user.GetController<StoresController>();
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates(rateVm, "Test").Result).Model);
Assert.NotNull(rateVm.TestRateRules);
Assert.Equal(2, rateVm.TestRateRules.Count);
Assert.False(rateVm.TestRateRules[0].Error);
Assert.StartsWith("(bitflyer(BTC_JPY)) * 1.10 =", rateVm.TestRateRules[0].Rule, StringComparison.OrdinalIgnoreCase);
Assert.True(rateVm.TestRateRules[1].Error);
Assert.IsType<RedirectToActionResult>(store.Rates(rateVm, "Save").Result);
Assert.IsType<RedirectToActionResult>(store.ShowRateRulesPost(true).Result);
Assert.IsType<RedirectToActionResult>(store.Rates(rateVm, "Save").Result);
store = user.GetController<StoresController>();
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates()).Model);
Assert.Equal(rateVm.DefaultScript, rateVm.Script);
Assert.True(rateVm.ShowScripting);
rateVm.ScriptTest = "BTC_JPY";
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates(rateVm, "Test").Result).Model);
Assert.True(rateVm.ShowScripting);
Assert.Contains("(bitflyer(BTC_JPY)) * 1.10 = ", rateVm.TestRateRules[0].Rule, StringComparison.OrdinalIgnoreCase);
rateVm.ScriptTest = "BTC_USD,BTC_CAD,DOGE_USD,DOGE_CAD";
rateVm.Script = "DOGE_X = bittrex(DOGE_BTC) * BTC_X;\n" +
"X_CAD = quadrigacx(X_CAD);\n" +
"X_X = gdax(X_X);";
rateVm.RateMultiplier = 0.5;
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates(rateVm, "Test").Result).Model);
Assert.True(rateVm.TestRateRules.All(t => !t.Error));
Assert.IsType<RedirectToActionResult>(store.Rates(rateVm, "Save").Result);
store = user.GetController<StoresController>();
rateVm = Assert.IsType<RatesViewModel>(Assert.IsType<ViewResult>(store.Rates()).Model);
Assert.Equal(0.5, rateVm.RateMultiplier);
Assert.True(rateVm.ShowScripting);
Assert.Contains("DOGE_X", rateVm.Script, StringComparison.OrdinalIgnoreCase);
}
}
[Fact] [Fact]
public void CanPayWithTwoCurrencies() public void CanPayWithTwoCurrencies()
{ {
@@ -1255,7 +1315,7 @@ namespace BTCPayServer.Tests
public void CheckRatesProvider() public void CheckRatesProvider()
{ {
var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); var provider = new BTCPayNetworkProvider(NetworkType.Mainnet);
var coinAverage = new CoinAverageRateProvider(provider); var coinAverage = new CoinAverageRateProvider();
var rates = coinAverage.GetRatesAsync().GetAwaiter().GetResult(); var rates = coinAverage.GetRatesAsync().GetAwaiter().GetResult();
Assert.NotNull(rates.GetRate("coinaverage", new CurrencyPair("BTC", "JPY"))); Assert.NotNull(rates.GetRate("coinaverage", new CurrencyPair("BTC", "JPY")));
var ratesBitpay = new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))).GetRatesAsync().GetAwaiter().GetResult(); var ratesBitpay = new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))).GetRatesAsync().GetAwaiter().GetResult();

View File

@@ -460,9 +460,9 @@ namespace BTCPayServer.Controllers
StatusMessage = $"Invoice {result.Data.Id} just created!"; StatusMessage = $"Invoice {result.Data.Id} just created!";
return RedirectToAction(nameof(ListInvoices)); return RedirectToAction(nameof(ListInvoices));
} }
catch (BitpayHttpException) catch (BitpayHttpException ex)
{ {
ModelState.TryAddModelError(nameof(model.Currency), "Unsupported currency"); ModelState.TryAddModelError(nameof(model.Currency), $"Error: {ex.Message}");
return View(model); return View(model);
} }
} }

View File

@@ -4,6 +4,7 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
@@ -20,6 +21,7 @@ using NBitcoin.DataEncoders;
using NBXplorer.DerivationStrategy; using NBXplorer.DerivationStrategy;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
@@ -33,6 +35,7 @@ namespace BTCPayServer.Controllers
[AutoValidateAntiforgeryToken] [AutoValidateAntiforgeryToken]
public partial class StoresController : Controller public partial class StoresController : Controller
{ {
BTCPayRateProviderFactory _RateFactory;
public string CreatedStoreId { get; set; } public string CreatedStoreId { get; set; }
public StoresController( public StoresController(
NBXplorerDashboard dashboard, NBXplorerDashboard dashboard,
@@ -46,12 +49,14 @@ namespace BTCPayServer.Controllers
AccessTokenController tokenController, AccessTokenController tokenController,
BTCPayWalletProvider walletProvider, BTCPayWalletProvider walletProvider,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
BTCPayRateProviderFactory rateFactory,
ExplorerClientProvider explorerProvider, ExplorerClientProvider explorerProvider,
IFeeProviderFactory feeRateProvider, IFeeProviderFactory feeRateProvider,
LanguageService langService, LanguageService langService,
IHostingEnvironment env, IHostingEnvironment env,
CoinAverageSettings coinAverage) CoinAverageSettings coinAverage)
{ {
_RateFactory = rateFactory;
_Dashboard = dashboard; _Dashboard = dashboard;
_Repo = repo; _Repo = repo;
_TokenRepository = tokenRepo; _TokenRepository = tokenRepo;
@@ -191,6 +196,143 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(StoreUsers), new { storeId = storeId, userId = userId }); return RedirectToAction(nameof(StoreUsers), new { storeId = storeId, userId = userId });
} }
[HttpGet]
[Route("{storeId}/rates")]
public IActionResult Rates()
{
var storeBlob = StoreData.GetStoreBlob();
var vm = new RatesViewModel();
vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName);
vm.RateMultiplier = (double)storeBlob.GetRateMultiplier();
vm.Script = storeBlob.GetRateRules(_NetworkProvider).ToString();
vm.DefaultScript = storeBlob.GetDefaultRateRules(_NetworkProvider).ToString();
vm.AvailableExchanges = GetSupportedExchanges();
vm.ShowScripting = storeBlob.RateScripting;
return View(vm);
}
[HttpPost]
[Route("{storeId}/rates")]
public async Task<IActionResult> Rates(RatesViewModel model, string command = null)
{
model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange);
if (!ModelState.IsValid)
{
return View(model);
}
if (model.PreferredExchange != null)
model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant();
var blob = StoreData.GetStoreBlob();
model.DefaultScript = blob.GetDefaultRateRules(_NetworkProvider).ToString();
model.AvailableExchanges = GetSupportedExchanges();
blob.PreferredExchange = model.PreferredExchange;
blob.SetRateMultiplier(model.RateMultiplier);
if (!model.ShowScripting)
{
if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})");
return View(model);
}
}
RateRules rules = null;
if (model.ShowScripting)
{
if (!RateRules.TryParse(model.Script, out rules, out var errors))
{
errors = errors ?? new List<RateRulesErrors>();
var errorString = String.Join(", ", errors.ToArray());
ModelState.AddModelError(nameof(model.Script), $"Parsing error ({errorString})");
return View(model);
}
else
{
blob.RateScript = rules.ToString();
}
}
rules = blob.GetRateRules(_NetworkProvider);
if (command == "Test")
{
if (string.IsNullOrWhiteSpace(model.ScriptTest))
{
ModelState.AddModelError(nameof(model.ScriptTest), "Fill out currency pair to test for (like BTC_USD,BTC_CAD)");
return View(model);
}
var splitted = model.ScriptTest.Split(',', StringSplitOptions.RemoveEmptyEntries);
var pairs = new List<CurrencyPair>();
foreach (var pair in splitted)
{
if (!CurrencyPair.TryParse(pair, out var currencyPair))
{
ModelState.AddModelError(nameof(model.ScriptTest), $"Invalid currency pair '{pair}' (it should be formatted like BTC_USD,BTC_CAD)");
return View(model);
}
pairs.Add(currencyPair);
}
var fetchs = _RateFactory.FetchRates(pairs.ToHashSet(), rules);
var testResults = new List<RatesViewModel.TestResultViewModel>();
foreach (var fetch in fetchs)
{
var testResult = await (fetch.Value);
testResults.Add(new RatesViewModel.TestResultViewModel()
{
CurrencyPair = fetch.Key.ToString(),
Error = testResult.Errors.Count != 0,
Rule = testResult.Errors.Count == 0 ? testResult.Rule + " = " + testResult.Value.Value.ToString(CultureInfo.InvariantCulture)
: testResult.EvaluatedRule
});
}
model.TestRateRules = testResults;
return View(model);
}
else // command == Save
{
if (StoreData.SetStoreBlob(blob))
{
await _Repo.UpdateStore(StoreData);
StatusMessage = "Rate settings updated";
}
return RedirectToAction(nameof(Rates), new
{
storeId = StoreData.Id
});
}
}
[HttpGet]
[Route("{storeId}/rates/confirm")]
public IActionResult ShowRateRules(bool scripting)
{
return View("Confirm", new ConfirmModel()
{
Action = nameof(ShowRateRulesPost),
Title = "Rate rule scripting",
Description = scripting ?
"This action will mofify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)"
: "This action will delete your rate script. Are you sure to turn off rate rules scripting?",
ButtonClass = "btn-primary"
});
}
[HttpPost]
[Route("{storeId}/rates/confirm")]
public async Task<IActionResult> ShowRateRulesPost(bool scripting)
{
var blob = StoreData.GetStoreBlob();
blob.RateScripting = scripting;
blob.RateScript = blob.GetDefaultRateRules(_NetworkProvider).ToString();
StoreData.SetStoreBlob(blob);
await _Repo.UpdateStore(StoreData);
StatusMessage = "Rate rules scripting activated";
return RedirectToAction(nameof(Rates), new { storeId = StoreData.Id });
}
[HttpGet] [HttpGet]
[Route("{storeId}/checkout")] [Route("{storeId}/checkout")]
public IActionResult CheckoutExperience() public IActionResult CheckoutExperience()
@@ -268,7 +410,7 @@ namespace BTCPayServer.Controllers
[HttpGet] [HttpGet]
[Route("{storeId}")] [Route("{storeId}")]
public IActionResult UpdateStore(string storeId) public IActionResult UpdateStore()
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null)
@@ -276,7 +418,6 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
var vm = new StoreViewModel(); var vm = new StoreViewModel();
vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName);
vm.Id = store.Id; vm.Id = store.Id;
vm.StoreName = store.StoreName; vm.StoreName = store.StoreName;
vm.StoreWebsite = store.StoreWebsite; vm.StoreWebsite = store.StoreWebsite;
@@ -285,7 +426,6 @@ namespace BTCPayServer.Controllers
AddPaymentMethods(store, vm); AddPaymentMethods(store, vm);
vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.MonitoringExpiration = storeBlob.MonitoringExpiration;
vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
vm.RateMultiplier = (double)storeBlob.GetRateMultiplier();
vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate;
return View(vm); return View(vm);
} }
@@ -328,13 +468,6 @@ namespace BTCPayServer.Controllers
[Route("{storeId}")] [Route("{storeId}")]
public async Task<IActionResult> UpdateStore(StoreViewModel model) public async Task<IActionResult> UpdateStore(StoreViewModel model)
{ {
model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange);
if (!ModelState.IsValid)
{
return View(model);
}
if (model.PreferredExchange != null)
model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant();
AddPaymentMethods(StoreData, model); AddPaymentMethods(StoreData, model);
bool needUpdate = false; bool needUpdate = false;
@@ -360,26 +493,11 @@ namespace BTCPayServer.Controllers
blob.InvoiceExpiration = model.InvoiceExpiration; blob.InvoiceExpiration = model.InvoiceExpiration;
blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty;
bool newExchange = blob.PreferredExchange != model.PreferredExchange;
blob.PreferredExchange = model.PreferredExchange;
blob.SetRateMultiplier(model.RateMultiplier);
if (StoreData.SetStoreBlob(blob)) if (StoreData.SetStoreBlob(blob))
{ {
needUpdate = true; needUpdate = true;
} }
if (newExchange)
{
if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})");
return View(model);
}
}
if (needUpdate) if (needUpdate)
{ {
await _Repo.UpdateStore(StoreData); await _Repo.UpdateStore(StoreData);

View File

@@ -200,13 +200,5 @@ namespace BTCPayServer
var res = JsonConvert.SerializeObject(o, Formatting.None, jsonSettings); var res = JsonConvert.SerializeObject(o, Formatting.None, jsonSettings);
return res; return res;
} }
public static HtmlString ToJSVariableModel(this object o, string variableName)
{
var encodedJson = JavaScriptEncoder.Default.Encode(o.ToJson());
return new HtmlString($"var {variableName} = JSON.parse('" + encodedJson + "');");
}
} }
} }

View File

@@ -19,5 +19,6 @@ namespace BTCPayServer.Models
{ {
get; set; get; set;
} }
public string ButtonClass { get; set; } = "btn-danger";
} }
} }

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.StoreViewModels
{
public class RatesViewModel
{
public class TestResultViewModel
{
public string CurrencyPair { get; set; }
public string Rule { get; set; }
public bool Error { get; set; }
}
class Format
{
public string Name { get; set; }
public string Value { get; set; }
}
public void SetExchangeRates(CoinAverageExchange[] supportedList, string preferredExchange)
{
var defaultStore = preferredExchange ?? CoinAverageRateProvider.CoinAverageName;
var choices = supportedList.Select(o => new Format() { Name = o.Display, Value = o.Name }).ToArray();
var chosen = choices.FirstOrDefault(f => f.Value == defaultStore) ?? choices.FirstOrDefault();
Exchanges = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
PreferredExchange = chosen.Value;
}
public List<TestResultViewModel> TestRateRules { get; set; }
public SelectList Exchanges { 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 CoinAverageExchange[] AvailableExchanges { get; set; }
[Display(Name = "Multiply the original rate by ...")]
[Range(0.01, 10.0)]
public double RateMultiplier
{
get;
set;
}
[Display(Name = "Preferred price source (eg. bitfinex, bitstamp...)")]
public string PreferredExchange { get; set; }
public string RateSource
{
get
{
return PreferredExchange == CoinAverageRateProvider.CoinAverageName ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}";
}
}
}
}

View File

@@ -13,11 +13,6 @@ namespace BTCPayServer.Models.StoreViewModels
{ {
public class StoreViewModel public class StoreViewModel
{ {
class Format
{
public string Name { get; set; }
public string Value { get; set; }
}
public class DerivationScheme public class DerivationScheme
{ {
public string Crypto { get; set; } public string Crypto { get; set; }
@@ -50,36 +45,6 @@ namespace BTCPayServer.Models.StoreViewModels
public List<StoreViewModel.DerivationScheme> DerivationSchemes { get; set; } = new List<StoreViewModel.DerivationScheme>(); public List<StoreViewModel.DerivationScheme> DerivationSchemes { get; set; } = new List<StoreViewModel.DerivationScheme>();
public void SetExchangeRates(CoinAverageExchange[] supportedList, string preferredExchange)
{
var defaultStore = preferredExchange ?? CoinAverageRateProvider.CoinAverageName;
var choices = supportedList.Select(o => new Format() { Name = o.Display, Value = o.Name }).ToArray();
var chosen = choices.FirstOrDefault(f => f.Value == defaultStore) ?? choices.FirstOrDefault();
Exchanges = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
PreferredExchange = chosen.Value;
}
public SelectList Exchanges { get; set; }
[Display(Name = "Preferred price source (eg. bitfinex, bitstamp...)")]
public string PreferredExchange { get; set; }
public string RateSource
{
get
{
return PreferredExchange == CoinAverageRateProvider.CoinAverageName ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}";
}
}
[Display(Name = "Multiply the original rate by ...")]
[Range(0.01, 10.0)]
public double RateMultiplier
{
get;
set;
}
[Display(Name = "Invoice expires if the full amount has not been paid after ... minutes")] [Display(Name = "Invoice expires if the full amount has not been paid after ... minutes")]
[Range(1, 60 * 24 * 24)] [Range(1, 60 * 24 * 24)]
public int InvoiceExpiration public int InvoiceExpiration

View File

@@ -30,6 +30,7 @@ namespace BTCPayServer.Rating
if (str == null) if (str == null)
throw new ArgumentNullException(nameof(str)); throw new ArgumentNullException(nameof(str));
value = null; value = null;
str = str.Trim();
var splitted = str.Split('_'); var splitted = str.Split('_');
if (splitted.Length != 2) if (splitted.Length != 2)
return false; return false;

View File

@@ -20,6 +20,7 @@ namespace BTCPayServer.Rating
DivideByZero, DivideByZero,
PreprocessError, PreprocessError,
RateUnavailable, RateUnavailable,
InvalidExchangeName,
} }
public class RateRules public class RateRules
{ {
@@ -28,13 +29,6 @@ namespace BTCPayServer.Rating
public List<RateRulesErrors> Errors = new List<RateRulesErrors>(); public List<RateRulesErrors> Errors = new List<RateRulesErrors>();
bool IsInvocation; bool IsInvocation;
public override SyntaxNode VisitArgumentList(ArgumentListSyntax node)
{
IsInvocation = false;
var result = base.VisitArgumentList(node);
IsInvocation = true;
return result;
}
public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node)
{ {
if (IsInvocation) if (IsInvocation)
@@ -42,17 +36,22 @@ namespace BTCPayServer.Rating
Errors.Add(RateRulesErrors.NestedInvocation); Errors.Add(RateRulesErrors.NestedInvocation);
return base.VisitInvocationExpression(node); return base.VisitInvocationExpression(node);
} }
IsInvocation = true; if (node.Expression is IdentifierNameSyntax id)
var result = base.VisitInvocationExpression(node); {
IsInvocation = false; IsInvocation = true;
return result; var arglist = (ArgumentListSyntax)this.Visit(node.ArgumentList);
IsInvocation = false;
return SyntaxFactory.InvocationExpression(SyntaxFactory.IdentifierName(id.Identifier.ValueText.ToLowerInvariant()), arglist)
.WithTriviaFrom(id);
}
else
{
Errors.Add(RateRulesErrors.InvalidExchangeName);
return node;
}
} }
public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node)
{ {
if (IsInvocation)
{
return SyntaxFactory.IdentifierName(node.Identifier.ValueText.ToLowerInvariant());
}
if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currencyPair)) if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currencyPair))
{ {
return SyntaxFactory.IdentifierName(currencyPair.ToString()) return SyntaxFactory.IdentifierName(currencyPair.ToString())
@@ -70,7 +69,7 @@ namespace BTCPayServer.Rating
public Dictionary<CurrencyPair, (ExpressionSyntax Expression, SyntaxNode Trivia)> ExpressionsByPair = new Dictionary<CurrencyPair, (ExpressionSyntax Expression, SyntaxNode Trivia)>(); public Dictionary<CurrencyPair, (ExpressionSyntax Expression, SyntaxNode Trivia)> ExpressionsByPair = new Dictionary<CurrencyPair, (ExpressionSyntax Expression, SyntaxNode Trivia)>();
public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) public override void VisitAssignmentExpression(AssignmentExpressionSyntax node)
{ {
if (node.Kind() == SyntaxKind.SimpleAssignmentExpression if (node.Kind() == SyntaxKind.SimpleAssignmentExpression
&& node.Left is IdentifierNameSyntax id && node.Left is IdentifierNameSyntax id
&& node.Right is ExpressionSyntax expression) && node.Right is ExpressionSyntax expression)
{ {
@@ -109,14 +108,22 @@ namespace BTCPayServer.Rating
this.root = ruleList.GetSyntaxNode(); this.root = ruleList.GetSyntaxNode();
} }
public static bool TryParse(string str, out RateRules rules) public static bool TryParse(string str, out RateRules rules)
{
return TryParse(str, out rules, out var unused);
}
public static bool TryParse(string str, out RateRules rules, out List<RateRulesErrors> errors)
{ {
rules = null; rules = null;
errors = null;
var expression = CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script)); var expression = CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script));
var rewriter = new NormalizeCurrencyPairsRewritter(); var rewriter = new NormalizeCurrencyPairsRewritter();
// Rename BTC_usd to BTC_USD and verify structure // Rename BTC_usd to BTC_USD and verify structure
var root = rewriter.Visit(expression.GetRoot()); var root = rewriter.Visit(expression.GetRoot());
if (rewriter.Errors.Count > 0) if (rewriter.Errors.Count > 0)
{
errors = rewriter.Errors;
return false; return false;
}
rules = new RateRules(root); rules = new RateRules(root);
return true; return true;
} }
@@ -154,7 +161,7 @@ namespace BTCPayServer.Rating
return CreateExpression($"ERR_NO_RULE_MATCH({p})"); return CreateExpression($"ERR_NO_RULE_MATCH({p})");
var best = candidates var best = candidates
.OrderBy(c => c.Prioriy) .OrderBy(c => c.Prioriy)
.ThenBy(c => c.Expression.Span) .ThenBy(c => c.Expression.Span.Start)
.First(); .First();
return best.Expression; return best.Expression;

View File

@@ -158,7 +158,7 @@ namespace BTCPayServer.Services.Rates
providers.Add(directProvider); providers.Add(directProvider);
if (_CoinAverageSettings.AvailableExchanges.ContainsKey(exchangeName)) if (_CoinAverageSettings.AvailableExchanges.ContainsKey(exchangeName))
{ {
providers.Add(new CoinAverageRateProvider(btcpayNetworkProvider) providers.Add(new CoinAverageRateProvider()
{ {
Exchange = exchangeName, Exchange = exchangeName,
Authenticator = _CoinAverageSettings Authenticator = _CoinAverageSettings

View File

@@ -55,13 +55,7 @@ namespace BTCPayServer.Services.Rates
BTCPayNetworkProvider _NetworkProvider; BTCPayNetworkProvider _NetworkProvider;
public CoinAverageRateProvider() public CoinAverageRateProvider()
{ {
_NetworkProvider = new BTCPayNetworkProvider(NBitcoin.NetworkType.Mainnet);
}
public CoinAverageRateProvider(BTCPayNetworkProvider networkProvider)
{
if (networkProvider == null)
throw new ArgumentNullException(nameof(networkProvider));
_NetworkProvider = networkProvider;
} }
static HttpClient _Client = new HttpClient(); static HttpClient _Client = new HttpClient();

View File

@@ -30,6 +30,14 @@ namespace BTCPayServer.Services.Rates
} }
public string Name { get; set; } public string Name { get; set; }
public string Display { get; set; } public string Display { get; set; }
public string Url
{
get
{
return Name == CoinAverageRateProvider.CoinAverageName ? $"https://apiv2.bitcoinaverage.com/indices/global/ticker/short"
: $"https://apiv2.bitcoinaverage.com/exchanges/{Name}";
}
}
} }
public class CoinAverageExchanges : Dictionary<string, CoinAverageExchange> public class CoinAverageExchanges : Dictionary<string, CoinAverageExchange>
{ {

View File

@@ -16,7 +16,7 @@
<bundle name="wwwroot/bundles/checkout-bundle.min.css" /> <bundle name="wwwroot/bundles/checkout-bundle.min.css" />
<script type="text/javascript"> <script type="text/javascript">
@Model.ToJSVariableModel("srvModel") var srvModel = @Html.Raw(Json.Serialize(Model));
</script> </script>
<bundle name="wwwroot/bundles/checkout-bundle.min.js" /> <bundle name="wwwroot/bundles/checkout-bundle.min.js" />

View File

@@ -19,7 +19,7 @@
<p>Create, search or pay an invoice. (<a href="#help" data-toggle="collapse">Help</a>)</p> <p>Create, search or pay an invoice. (<a href="#help" data-toggle="collapse">Help</a>)</p>
<div id="help" class="collapse text-left"> <div id="help" class="collapse text-left">
<p> <p>
You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.</br> You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.<br />
You can also apply filters to your search by searching for `filtername:value`, here is a list of supported filters You can also apply filters to your search by searching for `filtername:value`, here is a list of supported filters
</p> </p>
<ul> <ul>

View File

@@ -15,7 +15,7 @@
<div class="row"> <div class="row">
<div class="col-lg-12 text-center"> <div class="col-lg-12 text-center">
<form method="post"> <form method="post">
<button type="submit" class="btn btn-secondary btn-danger" title="Continue">@Model.Action</button> <button type="submit" class="btn btn-secondary @Model.ButtonClass" title="Continue">@Model.Action</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -125,7 +125,7 @@
@section Scripts { @section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial") @await Html.PartialAsync("_ValidationScriptsPartial")
<script type="text/javascript"> <script type="text/javascript">
@Model.ServerUrl.ToJSVariableModel("srvModel"); var srvModel = @Html.Raw(Json.Serialize(Model.ServerUrl));
</script> </script>
<script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script> <script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script>
<script src="~/js/StoreAddDerivationScheme.js" type="text/javascript" defer="defer"></script> <script src="~/js/StoreAddDerivationScheme.js" type="text/javascript" defer="defer"></script>

View File

@@ -0,0 +1,149 @@
@model RatesViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Rates";
ViewData.AddActivePage(BTCPayServer.Views.Stores.StoreNavPages.Rates);
}
<h4>@ViewData["Title"]</h4>
@Html.Partial("_StatusMessage", TempData["TempDataProperty-StatusMessage"])
<div class="row">
<div class="col-md-8">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<form method="post">
@if(Model.ShowScripting)
{
<div class="form-group">
<h5>Scripting</h5>
<span>Rate script allows you to express precisely how you want to calculate rates for currency pairs.</span>
<p class="text-muted">
<b>Supported exchanges are</b>:
@for(int i = 0; i < Model.AvailableExchanges.Length; i++)
{
<a href="@Model.AvailableExchanges[i].Url">@Model.AvailableExchanges[i].Name</a><span>@(i == Model.AvailableExchanges.Length - 1 ? "" : ",")</span>
}
</p>
<p><a href="#help" data-toggle="collapse"><b>Click here for more information</b></a></p>
</div>
}
@if(Model.TestRateRules != null)
{
<div class="form-group">
<h5>Test results:</h5>
<table class="table table-sm table-responsive-md">
<tbody>
@foreach(var result in Model.TestRateRules)
{
<tr>
@if(result.Error)
{
<th class="small"><span class="fa fa-times" style="color:red;"></span> @result.CurrencyPair</th>
}
else
{
<th class="small"><span class="fa fa-check" style="color:green;"></span> @result.CurrencyPair</th>
}
<td>@result.Rule</td>
</tr>
}
</tbody>
</table>
</div>
}
@if(Model.ShowScripting)
{
<div id="help" class="collapse text-left">
<p>
The script language is composed of several rules composed of a currency pair and a mathematic expression.
The example below will use <code>gdax</code> for both <code>LTC_USD</code> and <code>BTC_USD</code> pairs.
</p>
<pre>
<code>
LTC_USD = gdax(LTC_USD);
BTC_USD = gdax(BTC_USD);
</code>
</pre>
<p>However, explicitely setting specific pairs like this can be a bit difficult. Instead, you can define a rule <code>X_X</code> which will match any currency pair. The following example will use <code>gdax</code> for getting the rate of any currency pair.</p>
<pre>
<code>
X_X = gdax(X_X);
</code>
</pre>
<p>However, <code>gdax</code> does not support the <code>BTC_CAD</code> pair. For this reason you can add a rule mapping all <code>X_CAD</code> to <code>quadrigacx</code>, a Canadian exchange.</p>
<pre>
<code>
X_CAD = quadrigacx(X_CAD);
X_X = gdax(X_X);
</code>
</pre>
<p>A given currency pair match the most specific rule. If two rules are matching and are as specific, the first rule will be chosen.</p>
<p>
But now, what if you want to support <code>DOGE</code>? The problem with <code>DOGE</code> is that most exchange do not have any pair for it. But <code>bittrex</code> has a <code>DOGE_BTC</code> pair. <br />
Luckily, the rule engine allow you to reference rules:
</p>
<pre>
<code>
DOGE_X = bittrex(DOGE_BTC) * BTC_X
X_CAD = quadrigacx(X_CAD);
X_X = gdax(X_X);
</code>
</pre>
<p>With <code>DOGE_USD</code> will be expanded to <code>bittrex(DOGE_BTC) * gdax(BTC_USD)</code>. And <code>DOGE_CAD</code> will be expanded to <code>bittrex(DOGE_BTC) * quadrigacx(BTC_CAD)</code></p>
</div>
<div class="form-group">
<label asp-for="Script"></label>
<textarea asp-for="Script" rows="20" cols="80" class="form-control"></textarea>
<span asp-validation-for="Script" class="text-danger"></span>
<a href="#" onclick="$('#Script').val(defaultScript); return false;">Set to default settings</a>
</div>
<div class="form-group">
<a asp-action="ShowRateRules" asp-route-scripting="false">Turn off advanced rate rule scripting</a>
</div>
}
else
{
<div class="form-group">
<label asp-for="PreferredExchange"></label>
<select asp-for="PreferredExchange" asp-items="Model.Exchanges" class="form-control"></select>
<span asp-validation-for="PreferredExchange" class="text-danger"></span>
<p id="PreferredExchangeHelpBlock" class="form-text text-muted">
Current price source is <a href="@Model.RateSource" target="_blank">@Model.PreferredExchange</a>.
</p>
</div>
<div class="form-group">
<a asp-action="ShowRateRules" asp-route-scripting="true">Turn on advanced rate rule scripting</a>
</div>
}
<div class="form-group">
<label asp-for="RateMultiplier"></label>
<input asp-for="RateMultiplier" class="form-control" />
<span asp-validation-for="RateMultiplier" class="text-danger"></span>
</div>
<div class="form-group">
<h5>Testing</h5>
<span>Enter currency pairs which you want to test against your rule (eg. <code>DOGE_USD,DOGE_CAD,BTC_CAD,BTC_USD</code>)</span>
<div class="input-group">
<input placeholder="BTC_USD, BTC_CAD" asp-for="ScriptTest" class="form-control" />
<span class="input-group-btn">
<button name="command" value="Test" type="submit" class="btn btn-primary" title="Test">
<span class="fa fa-vial"></span> Test
</button>
</span>
</div>
<span asp-validation-for="ScriptTest" class="text-danger"></span>
</div>
<button name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
<input type="hidden" asp-for="ShowScripting" />
</form>
</div>
</div>
@section Scripts {
<script type="text/javascript">var defaultScript = @Html.Raw(Json.Serialize(Model.DefaultScript));</script>
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@@ -11,6 +11,7 @@ namespace BTCPayServer.Views.Stores
{ {
public static string ActivePageKey => "ActivePage"; public static string ActivePageKey => "ActivePage";
public static string Index => "Index"; public static string Index => "Index";
public static string Rates => "Rates";
public static string Checkout => "Checkout experience"; public static string Checkout => "Checkout experience";
public static string Tokens => "Tokens"; public static string Tokens => "Tokens";
@@ -20,6 +21,7 @@ namespace BTCPayServer.Views.Stores
public static string CheckoutNavClass(ViewContext viewContext) => PageNavClass(viewContext, Checkout); public static string CheckoutNavClass(ViewContext viewContext) => PageNavClass(viewContext, Checkout);
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
public static string RatesNavClass(ViewContext viewContext) => PageNavClass(viewContext, Rates);
public static string PageNavClass(ViewContext viewContext, string page) public static string PageNavClass(ViewContext viewContext, string page)
{ {

View File

@@ -34,19 +34,6 @@
<label asp-for="NetworkFee"></label> <label asp-for="NetworkFee"></label>
<input asp-for="NetworkFee" type="checkbox" class="form-check" /> <input asp-for="NetworkFee" type="checkbox" class="form-check" />
</div> </div>
<div class="form-group">
<label asp-for="PreferredExchange"></label>
<select asp-for="PreferredExchange" asp-items="Model.Exchanges" class="form-control"></select>
<span asp-validation-for="PreferredExchange" class="text-danger"></span>
<p id="PreferredExchangeHelpBlock" class="form-text text-muted">
Current price source is <a href="@Model.RateSource" target="_blank">@Model.PreferredExchange</a>.
</p>
</div>
<div class="form-group">
<label asp-for="RateMultiplier"></label>
<input asp-for="RateMultiplier" class="form-control" />
<span asp-validation-for="RateMultiplier" class="text-danger"></span>
</div>
<div class="form-group"> <div class="form-group">
<label asp-for="InvoiceExpiration"></label> <label asp-for="InvoiceExpiration"></label>
<input asp-for="InvoiceExpiration" class="form-control" /> <input asp-for="InvoiceExpiration" class="form-control" />

View File

@@ -65,7 +65,7 @@
@section Scripts @section Scripts
{ {
<script type="text/javascript"> <script type="text/javascript">
@Model.ToJSVariableModel("srvModel") var srvModel = @Html.Raw(Json.Serialize(Model));
</script> </script>
<script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script> <script src="~/js/ledgerwebsocket.js" type="text/javascript" defer="defer"></script>
<script src="~/js/StoreWallet.js" type="text/javascript" defer="defer"></script> <script src="~/js/StoreWallet.js" type="text/javascript" defer="defer"></script>

View File

@@ -3,6 +3,7 @@
<div class="nav flex-column nav-pills"> <div class="nav flex-column nav-pills">
<a class="nav-link @StoreNavPages.IndexNavClass(ViewContext)" asp-action="UpdateStore">General settings</a> <a class="nav-link @StoreNavPages.IndexNavClass(ViewContext)" asp-action="UpdateStore">General settings</a>
<a class="nav-link @StoreNavPages.RatesNavClass(ViewContext)" asp-action="Rates">Rates</a>
<a class="nav-link @StoreNavPages.CheckoutNavClass(ViewContext)" asp-action="CheckoutExperience">Checkout experience</a> <a class="nav-link @StoreNavPages.CheckoutNavClass(ViewContext)" asp-action="CheckoutExperience">Checkout experience</a>
<a class="nav-link @StoreNavPages.TokensNavClass(ViewContext)" asp-action="ListTokens">Access Tokens</a> <a class="nav-link @StoreNavPages.TokensNavClass(ViewContext)" asp-action="ListTokens">Access Tokens</a>
<a class="nav-link @StoreNavPages.UsersNavClass(ViewContext)" asp-action="StoreUsers">Users</a> <a class="nav-link @StoreNavPages.UsersNavClass(ViewContext)" asp-action="StoreUsers">Users</a>