Allow multi-step settings in custodian (#4838)

* Allow multi-step settings in custodian

* Fix CustodianAccount.Name not saved

* Reuse TradeQuantity for SimulateTrade

* TradeQuantityJsonConverter accepts numerics

* Fix build
This commit is contained in:
Nicolas Dorier 2023-04-04 14:48:29 +09:00 committed by GitHub
parent 60d6e98c67
commit 1b672a1ace
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 109 additions and 53 deletions

View file

@ -1,3 +1,4 @@
#nullable enable
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -20,6 +21,6 @@ public interface ICustodian
*/ */
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken); Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
public Task<Form.Form> GetConfigForm(CancellationToken cancellationToken = default); public Task<Form.Form> GetConfigForm(JObject config, CancellationToken cancellationToken = default);
} }

View file

@ -32,6 +32,8 @@ public class Form
// Are all the fields valid in the form? // Are all the fields valid in the form?
public bool IsValid() public bool IsValid()
{ {
if (TopMessages?.Any(t => t.Type == AlertMessage.AlertMessageType.Danger) is true)
return false;
return Fields.Select(f => f.IsValid()).All(o => o); return Fields.Select(f => f.IsValid()).All(o => o);
} }

View file

@ -30,9 +30,9 @@ namespace BTCPayServer.JsonConverters
case JTokenType.Integer: case JTokenType.Integer:
case JTokenType.String: case JTokenType.String:
if (objectType == typeof(decimal) || objectType == typeof(decimal?)) if (objectType == typeof(decimal) || objectType == typeof(decimal?))
return decimal.Parse(token.ToString(), CultureInfo.InvariantCulture); return decimal.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
if (objectType == typeof(double) || objectType == typeof(double?)) if (objectType == typeof(double) || objectType == typeof(double?))
return double.Parse(token.ToString(), CultureInfo.InvariantCulture); return double.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
throw new JsonSerializationException("Unexpected object type: " + objectType); throw new JsonSerializationException("Unexpected object type: " + objectType);
case JTokenType.Null when objectType == typeof(decimal?) || objectType == typeof(double?): case JTokenType.Null when objectType == typeof(decimal?) || objectType == typeof(double?):
return null; return null;

View file

@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using NBitcoin.JsonConverters; using NBitcoin.JsonConverters;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.JsonConverters namespace BTCPayServer.Client.JsonConverters
{ {
@ -11,13 +12,19 @@ namespace BTCPayServer.Client.JsonConverters
{ {
public override TradeQuantity ReadJson(JsonReader reader, Type objectType, TradeQuantity existingValue, bool hasExistingValue, JsonSerializer serializer) public override TradeQuantity ReadJson(JsonReader reader, Type objectType, TradeQuantity existingValue, bool hasExistingValue, JsonSerializer serializer)
{ {
if (reader.TokenType == JsonToken.Null) JToken token = JToken.Load(reader);
return null; switch (token.Type)
if (reader.TokenType != JsonToken.String) {
throw new JsonObjectException("Invalid TradeQuantity, expected string. Expected: \"1.50\" or \"50%\"", reader); case JTokenType.Float:
if (TradeQuantity.TryParse((string)reader.Value, out var q)) case JTokenType.Integer:
return q; case JTokenType.String:
throw new JsonObjectException("Invalid format for TradeQuantity. Expected: \"1.50\" or \"50%\"", reader); if (TradeQuantity.TryParse(token.ToString(), out var q))
return q;
break;
case JTokenType.Null:
return null;
}
throw new JsonObjectException("Invalid TradeQuantity, expected string. Expected: \"1.50\" or \"50%\"", reader);
} }
public override void WriteJson(JsonWriter writer, TradeQuantity value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, TradeQuantity value, JsonSerializer serializer)

View file

@ -1,8 +1,11 @@
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models; namespace BTCPayServer.Client.Models;
public class TradeRequestData public class TradeRequestData
{ {
public string FromAsset { set; get; } public string FromAsset { set; get; }
public string ToAsset { set; get; } public string ToAsset { set; get; }
public string Qty { set; get; } [JsonConverter(typeof(JsonConverters.TradeQuantityJsonConverter))]
public TradeQuantity Qty { set; get; }
} }

View file

@ -134,6 +134,33 @@ namespace BTCPayServer.Tests
} }
} }
[Fact]
public void CanParseDecimals()
{
CanParseDecimalsCore("{\"qty\": 1}", 1.0m);
CanParseDecimalsCore("{\"qty\": \"1\"}", 1.0m);
CanParseDecimalsCore("{\"qty\": 1.0}", 1.0m);
CanParseDecimalsCore("{\"qty\": \"1.0\"}", 1.0m);
CanParseDecimalsCore("{\"qty\": 6.1e-7}", 6.1e-7m);
CanParseDecimalsCore("{\"qty\": \"6.1e-7\"}", 6.1e-7m);
var data = JsonConvert.DeserializeObject<TradeRequestData>("{\"qty\": \"6.1e-7\", \"fromAsset\":\"Test\"}");
Assert.Equal(6.1e-7m, data.Qty.Value);
Assert.Equal("Test", data.FromAsset);
data = JsonConvert.DeserializeObject<TradeRequestData>("{\"fromAsset\":\"Test\", \"qty\": \"6.1e-7\"}");
Assert.Equal(6.1e-7m, data.Qty.Value);
Assert.Equal("Test", data.FromAsset);
}
private void CanParseDecimalsCore(string str, decimal expected)
{
var d = JsonConvert.DeserializeObject<LedgerEntryData>(str);
Assert.Equal(expected, d.Qty);
var d2 = JsonConvert.DeserializeObject<TradeRequestData>(str);
Assert.Equal(new TradeQuantity(expected, TradeQuantity.ValueType.Exact), d2.Qty);
}
[Fact] [Fact]
public void CanMergeReceiptOptions() public void CanMergeReceiptOptions()
{ {

View file

@ -4001,7 +4001,7 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// Test: Trade, unauth // Test: Trade, unauth
var tradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture) }; var tradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = new TradeQuantity(MockCustodian.TradeQtyBought, TradeQuantity.ValueType.Exact)};
await AssertHttpError(401, async () => await unauthClient.MarketTradeCustodianAccountAsset(storeId, accountId, tradeRequest)); await AssertHttpError(401, async () => await unauthClient.MarketTradeCustodianAccountAsset(storeId, accountId, tradeRequest));
// Test: Trade, auth, but wrong permission // Test: Trade, auth, but wrong permission
@ -4028,17 +4028,13 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, newTradeResult.LedgerEntries[2].Type); Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, newTradeResult.LedgerEntries[2].Type);
// Test: GetTradeQuote, SATS // Test: GetTradeQuote, SATS
var satsTradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = "SATS", Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture) }; var satsTradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = "SATS", Qty = new TradeQuantity(MockCustodian.TradeQtyBought, TradeQuantity.ValueType.Exact) };
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, satsTradeRequest)); await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, satsTradeRequest));
// TODO Test: Trade with percentage qty // TODO Test: Trade with percentage qty
// Test: Trade with wrong decimal format (example: JavaScript scientific format)
var wrongQtyTradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "6.1e-7" };
await AssertApiError(400, "bad-qty-format", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, wrongQtyTradeRequest));
// Test: Trade, wrong assets method // Test: Trade, wrong assets method
var wrongAssetsTradeRequest = new TradeRequestData { FromAsset = "WRONG", ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture) }; var wrongAssetsTradeRequest = new TradeRequestData { FromAsset = "WRONG", ToAsset = MockCustodian.TradeToAsset, Qty = new TradeQuantity(MockCustodian.TradeQtyBought, TradeQuantity.ValueType.Exact) };
await AssertHttpError(WrongTradingPairException.HttpCode, async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, wrongAssetsTradeRequest)); await AssertHttpError(WrongTradingPairException.HttpCode, async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, wrongAssetsTradeRequest));
// Test: wrong account ID // Test: wrong account ID
@ -4048,7 +4044,7 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
await AssertHttpError(403, async () => await tradeClient.MarketTradeCustodianAccountAsset("WRONG-STORE-ID", accountId, tradeRequest)); await AssertHttpError(403, async () => await tradeClient.MarketTradeCustodianAccountAsset("WRONG-STORE-ID", accountId, tradeRequest));
// Test: Trade, correct assets, wrong amount // Test: Trade, correct assets, wrong amount
var insufficientFundsTradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01" }; var insufficientFundsTradeRequest = new TradeRequestData { FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = new TradeQuantity(0.01m, TradeQuantity.ValueType.Exact) };
await AssertApiError(400, "insufficient-funds", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, insufficientFundsTradeRequest)); await AssertApiError(400, "insufficient-funds", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, insufficientFundsTradeRequest));

View file

@ -56,7 +56,7 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
return Task.FromResult(r); return Task.FromResult(r);
} }
public Task<Form> GetConfigForm(CancellationToken cancellationToken = default) public Task<Form> GetConfigForm(JObject config, CancellationToken cancellationToken = default)
{ {
return null; return null;
} }

View file

@ -255,26 +255,15 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanTrade tradableCustodian) if (custodian is ICanTrade tradableCustodian)
{ {
bool isPercentage = request.Qty.EndsWith("%", StringComparison.InvariantCultureIgnoreCase); decimal qty;
string qtyString = isPercentage ? request.Qty.Substring(0, request.Qty.Length - 1) : request.Qty; try
bool canParseQty = Decimal.TryParse(qtyString, out decimal qty);
if (!canParseQty)
{ {
return this.CreateAPIError(400, "bad-qty-format", qty = await ParseQty(request.Qty, request.FromAsset, custodianAccount, custodian, cancellationToken);
$"Quantity should be a number or a number ending with '%' for percentages.");
} }
catch (Exception ex)
if (isPercentage)
{ {
// Percentage of current holdings => calculate the amount return UnsupportedAsset(request.FromAsset, ex.Message);
var config = custodianAccount.GetBlob();
var balances = custodian.GetAssetBalancesAsync(config, cancellationToken).Result;
var fromAssetBalance = balances[request.FromAsset];
var priceQuote =
await tradableCustodian.GetQuoteForAssetAsync(request.FromAsset, request.ToAsset, config, cancellationToken);
qty = fromAssetBalance / priceQuote.Ask * qty / 100;
} }
try try
{ {
var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, qty, var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, qty,

View file

@ -221,12 +221,14 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
} }
var configForm = await custodian.GetConfigForm(); var blob = custodianAccount.GetBlob();
configForm.SetValues(custodianAccount.GetBlob()); var configForm = await custodian.GetConfigForm(blob, HttpContext.RequestAborted);
configForm.SetValues(blob);
var vm = new EditCustodianAccountViewModel(); var vm = new EditCustodianAccountViewModel();
vm.CustodianAccount = custodianAccount; vm.CustodianAccount = custodianAccount;
vm.ConfigForm = configForm; vm.ConfigForm = configForm;
vm.Config = _formDataService.GetValues(configForm).ToString();
return View(vm); return View(vm);
} }
@ -244,15 +246,13 @@ namespace BTCPayServer.Controllers
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account? // TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound(); return NotFound();
} }
var configForm = await GetNextForm(custodian, vm.Config);
var configForm = await custodian.GetConfigForm();
configForm.ApplyValuesFromForm(Request.Form);
if (configForm.IsValid()) if (configForm.IsValid())
{ {
var newData = _formDataService.GetValues(configForm); var newData = _formDataService.GetValues(configForm);
custodianAccount.SetBlob(newData); custodianAccount.SetBlob(newData);
custodianAccount.Name = vm.CustodianAccount.Name;
custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount); custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return RedirectToAction(nameof(ViewCustodianAccount), return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id }); new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
@ -261,9 +261,36 @@ namespace BTCPayServer.Controllers
// Form not valid: The user must fix the errors before we can save // Form not valid: The user must fix the errors before we can save
vm.CustodianAccount = custodianAccount; vm.CustodianAccount = custodianAccount;
vm.ConfigForm = configForm; vm.ConfigForm = configForm;
vm.Config = _formDataService.GetValues(configForm).ToString();
return View(vm); return View(vm);
} }
private async Task<Form> GetNextForm(ICustodian custodian, string config)
{
JObject b = null;
try
{
if (config != null)
b = JObject.Parse(config);
}
catch
{
}
b ??= new JObject();
// First, we restore the previous form based on the previous blob that was
// stored in config
var form = await custodian.GetConfigForm(b, HttpContext.RequestAborted);
form.SetValues(b);
// Then we apply new values overriding the previous blob from the Form params
form.ApplyValuesFromForm(Request.Form);
// We extract the new resulting blob, and request what is the next form based on it
b = _formDataService.GetValues(form);
form = await custodian.GetConfigForm(_formDataService.GetValues(form), HttpContext.RequestAborted);
// We set all the values to this blob, and validate the form
form.SetValues(b);
_formDataService.Validate(form, ModelState);
return form;
}
[HttpGet("/stores/{storeId}/custodian-accounts/create")] [HttpGet("/stores/{storeId}/custodian-accounts/create")]
public IActionResult CreateCustodianAccount(string storeId) public IActionResult CreateCustodianAccount(string storeId)
@ -301,12 +328,12 @@ namespace BTCPayServer.Controllers
}; };
var configForm = await custodian.GetConfigForm(); var configForm = await GetNextForm(custodian, vm.Config);
configForm.ApplyValuesFromForm(Request.Form);
if (configForm.IsValid()) if (configForm.IsValid())
{ {
var configData = _formDataService.GetValues(configForm); var configData = _formDataService.GetValues(configForm);
custodianAccountData.SetBlob(configData); custodianAccountData.SetBlob(configData);
custodianAccountData.Name = vm.Name;
custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData); custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData);
TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created"; TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created";
CreatedCustodianAccountId = custodianAccountData.Id; CreatedCustodianAccountId = custodianAccountData.Id;
@ -317,6 +344,7 @@ namespace BTCPayServer.Controllers
// Ask for more data // Ask for more data
vm.ConfigForm = configForm; vm.ConfigForm = configForm;
vm.Config = _formDataService.GetValues(configForm).ToString();
return View(vm); return View(vm);
} }
@ -574,7 +602,7 @@ namespace BTCPayServer.Controllers
} }
catch (BadConfigException e) catch (BadConfigException e)
{ {
Form configForm = await custodian.GetConfigForm(); Form configForm = await custodian.GetConfigForm(config);
configForm.SetValues(config); configForm.SetValues(config);
string[] badConfigFields = new string[e.BadConfigKeys.Length]; string[] badConfigFields = new string[e.BadConfigKeys.Length];
int i = 0; int i = 0;

View file

@ -19,13 +19,13 @@ public class FormComponentProviders
public bool Validate(Form form, ModelStateDictionary modelState) public bool Validate(Form form, ModelStateDictionary modelState)
{ {
foreach (var field in form.Fields) foreach (var field in form.GetAllFields())
{ {
if (TypeToComponentProvider.TryGetValue(field.Type, out var provider)) if (TypeToComponentProvider.TryGetValue(field.Field.Type, out var provider))
{ {
provider.Validate(form, field); provider.Validate(form, field.Field);
foreach (var err in field.ValidationErrors) foreach (var err in field.Field.ValidationErrors)
modelState.TryAddModelError(field.Name, err); modelState.TryAddModelError(field.Field.Name, err);
} }
} }
return modelState.IsValid; return modelState.IsValid;

View file

@ -43,6 +43,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels
public SelectList Custodians { get; set; } public SelectList Custodians { get; set; }
public Form ConfigForm { get; set; } public Form ConfigForm { get; set; }
public string Config { get; set; }
} }
} }

View file

@ -8,5 +8,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels
public CustodianAccountData CustodianAccount { get; set; } public CustodianAccountData CustodianAccount { get; set; }
public Form ConfigForm { get; set; } public Form ConfigForm { get; set; }
public string Config { get; set; }
} }
} }

View file

@ -34,7 +34,7 @@ public class FakeCustodian : ICustodian
return Task.FromResult(r); return Task.FromResult(r);
} }
public Task<Form> GetConfigForm(CancellationToken cancellationToken = default) public Task<Form> GetConfigForm(JObject config, CancellationToken cancellationToken = default)
{ {
var form = new Form(); var form = new Form();

View file

@ -1,4 +1,4 @@
@using BTCPayServer.Views.Apps @using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Models.CustodianAccountViewModels.CreateCustodianAccountViewModel @model BTCPayServer.Models.CustodianAccountViewModels.CreateCustodianAccountViewModel
@{ @{
@ -16,6 +16,7 @@
<div class="row"> <div class="row">
<div class="col-xl-8 col-xxl-constrain"> <div class="col-xl-8 col-xxl-constrain">
<form asp-action="CreateCustodianAccount"> <form asp-action="CreateCustodianAccount">
<input asp-for="Config" type="hidden" />
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<div asp-validation-summary="ModelOnly" class="text-danger"></div> <div asp-validation-summary="ModelOnly" class="text-danger"></div>

View file

@ -17,6 +17,7 @@
<div class="row"> <div class="row">
<div class="col-xl-8 col-xxl-constrain"> <div class="col-xl-8 col-xxl-constrain">
<form asp-action="EditCustodianAccount" class="mb-5"> <form asp-action="EditCustodianAccount" class="mb-5">
<input asp-for="Config" type="hidden" />
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<div asp-validation-summary="ModelOnly" class="text-danger"></div> <div asp-validation-summary="ModelOnly" class="text-danger"></div>