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);
ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork);
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;
await store.UpdateStore(vm);

View File

@ -297,7 +297,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
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"));
var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel()
@ -312,7 +312,7 @@ namespace BTCPayServer.Tests
Url = tester.MerchantCharge.Client.Uri.AbsoluteUri
}, "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)));
}
}
@ -678,9 +678,9 @@ namespace BTCPayServer.Tests
private static decimal CreateInvoice(ServerTester tester, TestAccount user, string exchange)
{
var storeController = user.GetController<StoresController>();
var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model;
var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model;
vm.PreferredExchange = exchange;
storeController.UpdateStore(vm).Wait();
storeController.Rates(vm).Wait();
var invoice2 = user.BitPay.CreateInvoice(new Invoice()
{
Price = 5000.0,
@ -717,10 +717,10 @@ namespace BTCPayServer.Tests
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);
vm.RateMultiplier = 0.5;
storeController.UpdateStore(vm).Wait();
storeController.Rates(vm).Wait();
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]
public void CanPayWithTwoCurrencies()
{
@ -1255,7 +1315,7 @@ namespace BTCPayServer.Tests
public void CheckRatesProvider()
{
var provider = new BTCPayNetworkProvider(NetworkType.Mainnet);
var coinAverage = new CoinAverageRateProvider(provider);
var coinAverage = new CoinAverageRateProvider();
var rates = coinAverage.GetRatesAsync().GetAwaiter().GetResult();
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();

View File

@ -460,9 +460,9 @@ namespace BTCPayServer.Controllers
StatusMessage = $"Invoice {result.Data.Id} just created!";
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);
}
}

View File

@ -4,6 +4,7 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
@ -20,6 +21,7 @@ using NBitcoin.DataEncoders;
using NBXplorer.DerivationStrategy;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@ -33,6 +35,7 @@ namespace BTCPayServer.Controllers
[AutoValidateAntiforgeryToken]
public partial class StoresController : Controller
{
BTCPayRateProviderFactory _RateFactory;
public string CreatedStoreId { get; set; }
public StoresController(
NBXplorerDashboard dashboard,
@ -46,12 +49,14 @@ namespace BTCPayServer.Controllers
AccessTokenController tokenController,
BTCPayWalletProvider walletProvider,
BTCPayNetworkProvider networkProvider,
BTCPayRateProviderFactory rateFactory,
ExplorerClientProvider explorerProvider,
IFeeProviderFactory feeRateProvider,
LanguageService langService,
IHostingEnvironment env,
CoinAverageSettings coinAverage)
{
_RateFactory = rateFactory;
_Dashboard = dashboard;
_Repo = repo;
_TokenRepository = tokenRepo;
@ -191,6 +196,143 @@ namespace BTCPayServer.Controllers
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]
[Route("{storeId}/checkout")]
public IActionResult CheckoutExperience()
@ -268,7 +410,7 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("{storeId}")]
public IActionResult UpdateStore(string storeId)
public IActionResult UpdateStore()
{
var store = HttpContext.GetStoreData();
if (store == null)
@ -276,7 +418,6 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob();
var vm = new StoreViewModel();
vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName);
vm.Id = store.Id;
vm.StoreName = store.StoreName;
vm.StoreWebsite = store.StoreWebsite;
@ -285,7 +426,6 @@ namespace BTCPayServer.Controllers
AddPaymentMethods(store, vm);
vm.MonitoringExpiration = storeBlob.MonitoringExpiration;
vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
vm.RateMultiplier = (double)storeBlob.GetRateMultiplier();
vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate;
return View(vm);
}
@ -328,13 +468,6 @@ namespace BTCPayServer.Controllers
[Route("{storeId}")]
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);
bool needUpdate = false;
@ -360,26 +493,11 @@ namespace BTCPayServer.Controllers
blob.InvoiceExpiration = model.InvoiceExpiration;
blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty;
bool newExchange = blob.PreferredExchange != model.PreferredExchange;
blob.PreferredExchange = model.PreferredExchange;
blob.SetRateMultiplier(model.RateMultiplier);
if (StoreData.SetStoreBlob(blob))
{
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)
{
await _Repo.UpdateStore(StoreData);

View File

@ -200,13 +200,5 @@ namespace BTCPayServer
var res = JsonConvert.SerializeObject(o, Formatting.None, jsonSettings);
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;
}
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
{
class Format
{
public string Name { get; set; }
public string Value { get; set; }
}
public class DerivationScheme
{
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 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")]
[Range(1, 60 * 24 * 24)]
public int InvoiceExpiration

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,14 @@ namespace BTCPayServer.Services.Rates
}
public string Name { 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>
{

View File

@ -16,7 +16,7 @@
<bundle name="wwwroot/bundles/checkout-bundle.min.css" />
<script type="text/javascript">
@Model.ToJSVariableModel("srvModel")
var srvModel = @Html.Raw(Json.Serialize(Model));
</script>
<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>
<div id="help" class="collapse text-left">
<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
</p>
<ul>

View File

@ -15,7 +15,7 @@
<div class="row">
<div class="col-lg-12 text-center">
<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>
</div>
</div>

View File

@ -125,7 +125,7 @@
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<script type="text/javascript">
@Model.ServerUrl.ToJSVariableModel("srvModel");
var srvModel = @Html.Raw(Json.Serialize(Model.ServerUrl));
</script>
<script src="~/js/ledgerwebsocket.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 Index => "Index";
public static string Rates => "Rates";
public static string Checkout => "Checkout experience";
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 IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
public static string RatesNavClass(ViewContext viewContext) => PageNavClass(viewContext, Rates);
public static string PageNavClass(ViewContext viewContext, string page)
{

View File

@ -34,19 +34,6 @@
<label asp-for="NetworkFee"></label>
<input asp-for="NetworkFee" type="checkbox" class="form-check" />
</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">
<label asp-for="InvoiceExpiration"></label>
<input asp-for="InvoiceExpiration" class="form-control" />

View File

@ -65,7 +65,7 @@
@section Scripts
{
<script type="text/javascript">
@Model.ToJSVariableModel("srvModel")
var srvModel = @Html.Raw(Json.Serialize(Model));
</script>
<script src="~/js/ledgerwebsocket.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">
<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.TokensNavClass(ViewContext)" asp-action="ListTokens">Access Tokens</a>
<a class="nav-link @StoreNavPages.UsersNavClass(ViewContext)" asp-action="StoreUsers">Users</a>