From c71e671311b2cacaf1c99f177dbfc84d02e41e34 Mon Sep 17 00:00:00 2001 From: Wouter Samaey Date: Thu, 4 Aug 2022 04:38:49 +0200 Subject: [PATCH] Added custodian account trade support (#3978) * Added custodian account trade support * UI updates * Improved UI spacing and field sizes + Fixed input validation * Reset error message when opening trade modal * Better error handing + test + surface error in trade modal in UI * Add delete confirmation modal * Fixed duplicate ID in site nav * Replace jQuery.ajax with fetch for onTradeSubmit * Added support for minimumTradeQty to trading pairs * Fixed LocalBTCPayServerClient after previous refactoring * Handling dust amounts + minor API change * Replaced jQuery with Fetch API + UX improvements + more TODOs * Moved namespace because Rider was unhappy * Major UI improvements when swapping or changing assets, fixed bugs in min trade qty, fixed initial qty after an asset change etc * Commented out code for easier debugging * Fixed missing default values Co-authored-by: Dennis Reimann --- .../Custodians/Client/AssetQuoteResult.cs | 21 +- .../Custodians/Client/MarketTradeResult.cs | 2 +- .../Custodians/Client/WithdrawResult.cs | 2 +- .../Custodians/ICanTrade.cs | 1 + .../Custodians/ICanWithdraw.cs | 1 + .../BTCPayServerClient.CustodianAccounts.cs | 10 +- BTCPayServer.Client/Models/AssetPairData.cs | 15 +- BTCPayServer.Client/Models/CustodianData.cs | 4 +- BTCPayServer.Tests/GreenfieldAPITests.cs | 22 +- .../MockCustodian/MockCustodian.cs | 3 +- .../Components/MainNav/Default.cshtml | 2 +- .../GreenfieldCustodianAccountController.cs | 38 +- .../GreenfieldCustodianController.cs | 8 +- .../GreenField/LocalBTCPayServerClient.cs | 25 +- .../UICustodianAccountsController.cs | 191 +++++++- .../AssetBalanceInfo.cs | 6 +- .../TradePrepareViewModel.cs | 9 + .../ViewCustodianAccountBalancesViewModel.cs | 15 + .../ViewCustodianAccountViewModel.cs | 6 +- BTCPayServer/Views/Shared/_Form.cshtml | 4 +- .../CreateCustodianAccount.cshtml | 6 +- .../EditCustodianAccount.cshtml | 14 +- .../ViewCustodianAccount.cshtml | 456 ++++++++++++------ BTCPayServer/wwwroot/js/custodian-account.js | 372 ++++++++++++++ BTCPayServer/wwwroot/main/site.css | 20 + .../v1/swagger.template.custodians.json | 56 ++- 26 files changed, 1058 insertions(+), 251 deletions(-) create mode 100644 BTCPayServer/Models/CustodianAccountViewModels/TradePrepareViewModel.cs create mode 100644 BTCPayServer/Models/CustodianAccountViewModels/ViewCustodianAccountBalancesViewModel.cs create mode 100644 BTCPayServer/wwwroot/js/custodian-account.js diff --git a/BTCPayServer.Abstractions/Custodians/Client/AssetQuoteResult.cs b/BTCPayServer.Abstractions/Custodians/Client/AssetQuoteResult.cs index 28a7caec8..26964598e 100644 --- a/BTCPayServer.Abstractions/Custodians/Client/AssetQuoteResult.cs +++ b/BTCPayServer.Abstractions/Custodians/Client/AssetQuoteResult.cs @@ -1,18 +1,19 @@ -namespace BTCPayServer.Abstractions.Custodians; +namespace BTCPayServer.Abstractions.Custodians.Client; public class AssetQuoteResult { - public string FromAsset { get; } - - public string ToAsset { get; } - public decimal Bid { get; } - public decimal Ask { get; } + public string FromAsset { get; set; } + public string ToAsset { get; set; } + public decimal Bid { get; set; } + public decimal Ask { get; set; } + + public AssetQuoteResult() { } public AssetQuoteResult(string fromAsset, string toAsset,decimal bid, decimal ask) { - this.FromAsset = fromAsset; - this.ToAsset = toAsset; - this.Bid = bid; - this.Ask = ask; + FromAsset = fromAsset; + ToAsset = toAsset; + Bid = bid; + Ask = ask; } } diff --git a/BTCPayServer.Abstractions/Custodians/Client/MarketTradeResult.cs b/BTCPayServer.Abstractions/Custodians/Client/MarketTradeResult.cs index b84821ed6..d393e1c08 100644 --- a/BTCPayServer.Abstractions/Custodians/Client/MarketTradeResult.cs +++ b/BTCPayServer.Abstractions/Custodians/Client/MarketTradeResult.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using BTCPayServer.Client.Models; -namespace BTCPayServer.Abstractions.Custodians; +namespace BTCPayServer.Abstractions.Custodians.Client; /** * The result of a market trade. Used as a return type for custodians implementing ICanTrade diff --git a/BTCPayServer.Abstractions/Custodians/Client/WithdrawResult.cs b/BTCPayServer.Abstractions/Custodians/Client/WithdrawResult.cs index 9e9eebe51..0cfb255c4 100644 --- a/BTCPayServer.Abstractions/Custodians/Client/WithdrawResult.cs +++ b/BTCPayServer.Abstractions/Custodians/Client/WithdrawResult.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using BTCPayServer.Client.Models; -namespace BTCPayServer.Abstractions.Custodians; +namespace BTCPayServer.Abstractions.Custodians.Client; public class WithdrawResult { diff --git a/BTCPayServer.Abstractions/Custodians/ICanTrade.cs b/BTCPayServer.Abstractions/Custodians/ICanTrade.cs index df5624516..24c426ac3 100644 --- a/BTCPayServer.Abstractions/Custodians/ICanTrade.cs +++ b/BTCPayServer.Abstractions/Custodians/ICanTrade.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Custodians.Client; using BTCPayServer.Client.Models; using Newtonsoft.Json.Linq; diff --git a/BTCPayServer.Abstractions/Custodians/ICanWithdraw.cs b/BTCPayServer.Abstractions/Custodians/ICanWithdraw.cs index 74677911e..883120bd5 100644 --- a/BTCPayServer.Abstractions/Custodians/ICanWithdraw.cs +++ b/BTCPayServer.Abstractions/Custodians/ICanWithdraw.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Custodians.Client; using Newtonsoft.Json.Linq; namespace BTCPayServer.Abstractions.Custodians; diff --git a/BTCPayServer.Client/BTCPayServerClient.CustodianAccounts.cs b/BTCPayServer.Client/BTCPayServerClient.CustodianAccounts.cs index 012150eeb..91a7b46a0 100644 --- a/BTCPayServer.Client/BTCPayServerClient.CustodianAccounts.cs +++ b/BTCPayServer.Client/BTCPayServerClient.CustodianAccounts.cs @@ -56,9 +56,14 @@ namespace BTCPayServer.Client return await HandleResponse(response); } - public virtual async Task TradeMarket(string storeId, string accountId, TradeRequestData request, CancellationToken token = default) + public virtual async Task MarketTradeCustodianAccountAsset(string storeId, string accountId, TradeRequestData request, CancellationToken token = default) { - var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market", bodyPayload: request, method: HttpMethod.Post), token); + + //var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users", null, request, HttpMethod.Post), token); + //return await HandleResponse(response); + var internalRequest = CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market", null, + request, HttpMethod.Post); + var response = await _httpClient.SendAsync(internalRequest, token); return await HandleResponse(response); } @@ -73,7 +78,6 @@ namespace BTCPayServer.Client var queryPayload = new Dictionary(); queryPayload.Add("fromAsset", fromAsset); queryPayload.Add("toAsset", toAsset); - var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token); return await HandleResponse(response); } diff --git a/BTCPayServer.Client/Models/AssetPairData.cs b/BTCPayServer.Client/Models/AssetPairData.cs index 1503695bf..a3075ef2a 100644 --- a/BTCPayServer.Client/Models/AssetPairData.cs +++ b/BTCPayServer.Client/Models/AssetPairData.cs @@ -1,20 +1,31 @@ +using Newtonsoft.Json; + namespace BTCPayServer.Client.Models; +[JsonObject(MemberSerialization.OptIn)] public class AssetPairData { public AssetPairData() { } - public AssetPairData(string assetBought, string assetSold) + public AssetPairData(string assetBought, string assetSold, decimal minimumTradeQty) { AssetBought = assetBought; AssetSold = assetSold; + MinimumTradeQty = minimumTradeQty; } - + + [JsonProperty] public string AssetBought { set; get; } + + [JsonProperty] public string AssetSold { set; get; } + [JsonProperty] + public decimal MinimumTradeQty { set; get; } + + public override string ToString() { return AssetBought + "/" + AssetSold; diff --git a/BTCPayServer.Client/Models/CustodianData.cs b/BTCPayServer.Client/Models/CustodianData.cs index c9311c59d..67631274d 100644 --- a/BTCPayServer.Client/Models/CustodianData.cs +++ b/BTCPayServer.Client/Models/CustodianData.cs @@ -1,10 +1,12 @@ +using System.Collections.Generic; + namespace BTCPayServer.Client.Models; public class CustodianData { public string Code { get; set; } public string Name { get; set; } - public string[] TradableAssetPairs { get; set; } + public Dictionary TradableAssetPairs { get; set; } public string[] WithdrawablePaymentMethods { get; set; } public string[] DepositablePaymentMethods { get; set; } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index e7fe044d1..652189aa1 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2859,13 +2859,13 @@ namespace BTCPayServer.Tests // Test: Trade, unauth var tradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)}; - await AssertHttpError(401, async () => await unauthClient.TradeMarket(storeId, accountId, tradeRequest)); + await AssertHttpError(401, async () => await unauthClient.MarketTradeCustodianAccountAsset(storeId, accountId, tradeRequest)); // Test: Trade, auth, but wrong permission - await AssertHttpError(403, async () => await managerClient.TradeMarket(storeId, accountId, tradeRequest)); + await AssertHttpError(403, async () => await managerClient.MarketTradeCustodianAccountAsset(storeId, accountId, tradeRequest)); // Test: Trade, correct permission, correct assets, correct amount - var newTradeResult = await tradeClient.TradeMarket(storeId, accountId, tradeRequest); + var newTradeResult = await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, tradeRequest); Assert.NotNull(newTradeResult); Assert.Equal(accountId, newTradeResult.AccountId); Assert.Equal(mockCustodian.Code, newTradeResult.CustodianCode); @@ -2886,23 +2886,27 @@ namespace BTCPayServer.Tests // Test: GetTradeQuote, SATS var satsTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = "SATS", Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)}; - await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.TradeMarket(storeId, accountId, satsTradeRequest)); + await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, satsTradeRequest)); // 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 var wrongAssetsTradeRequest = new TradeRequestData {FromAsset = "WRONG", ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)}; - await AssertHttpError(WrongTradingPairException.HttpCode, async () => await tradeClient.TradeMarket(storeId, accountId, wrongAssetsTradeRequest)); + await AssertHttpError(WrongTradingPairException.HttpCode, async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, wrongAssetsTradeRequest)); // Test: wrong account ID - await AssertHttpError(404, async () => await tradeClient.TradeMarket(storeId, "WRONG-ACCOUNT-ID", tradeRequest)); + await AssertHttpError(404, async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, "WRONG-ACCOUNT-ID", tradeRequest)); // Test: wrong store ID - await AssertHttpError(403, async () => await tradeClient.TradeMarket("WRONG-STORE-ID", accountId, tradeRequest)); + await AssertHttpError(403, async () => await tradeClient.MarketTradeCustodianAccountAsset("WRONG-STORE-ID", accountId, tradeRequest)); // Test: Trade, correct assets, wrong amount - var wrongQtyTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01"}; - await AssertApiError(400, "insufficient-funds", async () => await tradeClient.TradeMarket(storeId, accountId, wrongQtyTradeRequest)); + var insufficientFundsTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01"}; + await AssertApiError(400, "insufficient-funds", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, insufficientFundsTradeRequest)); // Test: GetTradeQuote, unauth diff --git a/BTCPayServer.Tests/MockCustodian/MockCustodian.cs b/BTCPayServer.Tests/MockCustodian/MockCustodian.cs index 22b359bcc..d27df7529 100644 --- a/BTCPayServer.Tests/MockCustodian/MockCustodian.cs +++ b/BTCPayServer.Tests/MockCustodian/MockCustodian.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Custodians; +using BTCPayServer.Abstractions.Custodians.Client; using BTCPayServer.Abstractions.Form; using BTCPayServer.Client.Models; using Newtonsoft.Json.Linq; @@ -76,7 +77,7 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw public List GetTradableAssetPairs() { var r = new List(); - r.Add(new AssetPairData("BTC", "EUR")); + r.Add(new AssetPairData("BTC", "EUR", (decimal) 0.0001)); return r; } diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml index bd5695548..5ada7ec9d 100644 --- a/BTCPayServer/Components/MainNav/Default.cshtml +++ b/BTCPayServer/Components/MainNav/Default.cshtml @@ -109,7 +109,7 @@ }