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 <mail@dennisreimann.de>
This commit is contained in:
Wouter Samaey
2022-08-04 04:38:49 +02:00
committed by GitHub
parent 2ea6eb09e6
commit c71e671311
26 changed files with 1058 additions and 251 deletions

View File

@@ -1,18 +1,19 @@
namespace BTCPayServer.Abstractions.Custodians; namespace BTCPayServer.Abstractions.Custodians.Client;
public class AssetQuoteResult public class AssetQuoteResult
{ {
public string FromAsset { get; } public string FromAsset { get; set; }
public string ToAsset { get; set; }
public decimal Bid { get; set; }
public decimal Ask { get; set; }
public string ToAsset { get; } public AssetQuoteResult() { }
public decimal Bid { get; }
public decimal Ask { get; }
public AssetQuoteResult(string fromAsset, string toAsset,decimal bid, decimal ask) public AssetQuoteResult(string fromAsset, string toAsset,decimal bid, decimal ask)
{ {
this.FromAsset = fromAsset; FromAsset = fromAsset;
this.ToAsset = toAsset; ToAsset = toAsset;
this.Bid = bid; Bid = bid;
this.Ask = ask; Ask = ask;
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Client.Models; 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 * The result of a market trade. Used as a return type for custodians implementing ICanTrade

View File

@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
namespace BTCPayServer.Abstractions.Custodians; namespace BTCPayServer.Abstractions.Custodians.Client;
public class WithdrawResult public class WithdrawResult
{ {

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;

View File

@@ -1,5 +1,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians.Client;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians; namespace BTCPayServer.Abstractions.Custodians;

View File

@@ -56,9 +56,14 @@ namespace BTCPayServer.Client
return await HandleResponse<DepositAddressData>(response); return await HandleResponse<DepositAddressData>(response);
} }
public virtual async Task<MarketTradeResponseData> TradeMarket(string storeId, string accountId, TradeRequestData request, CancellationToken token = default) public virtual async Task<MarketTradeResponseData> 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<ApplicationUserData>(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<MarketTradeResponseData>(response); return await HandleResponse<MarketTradeResponseData>(response);
} }
@@ -73,7 +78,6 @@ namespace BTCPayServer.Client
var queryPayload = new Dictionary<string, object>(); var queryPayload = new Dictionary<string, object>();
queryPayload.Add("fromAsset", fromAsset); queryPayload.Add("fromAsset", fromAsset);
queryPayload.Add("toAsset", toAsset); queryPayload.Add("toAsset", toAsset);
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token);
return await HandleResponse<TradeQuoteResponseData>(response); return await HandleResponse<TradeQuoteResponseData>(response);
} }

View File

@@ -1,20 +1,31 @@
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models; namespace BTCPayServer.Client.Models;
[JsonObject(MemberSerialization.OptIn)]
public class AssetPairData public class AssetPairData
{ {
public AssetPairData() public AssetPairData()
{ {
} }
public AssetPairData(string assetBought, string assetSold) public AssetPairData(string assetBought, string assetSold, decimal minimumTradeQty)
{ {
AssetBought = assetBought; AssetBought = assetBought;
AssetSold = assetSold; AssetSold = assetSold;
MinimumTradeQty = minimumTradeQty;
} }
[JsonProperty]
public string AssetBought { set; get; } public string AssetBought { set; get; }
[JsonProperty]
public string AssetSold { set; get; } public string AssetSold { set; get; }
[JsonProperty]
public decimal MinimumTradeQty { set; get; }
public override string ToString() public override string ToString()
{ {
return AssetBought + "/" + AssetSold; return AssetBought + "/" + AssetSold;

View File

@@ -1,10 +1,12 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models; namespace BTCPayServer.Client.Models;
public class CustodianData public class CustodianData
{ {
public string Code { get; set; } public string Code { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string[] TradableAssetPairs { get; set; } public Dictionary<string, AssetPairData> TradableAssetPairs { get; set; }
public string[] WithdrawablePaymentMethods { get; set; } public string[] WithdrawablePaymentMethods { get; set; }
public string[] DepositablePaymentMethods { get; set; } public string[] DepositablePaymentMethods { get; set; }

View File

@@ -2859,13 +2859,13 @@ namespace BTCPayServer.Tests
// 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 = 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 // 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 // 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.NotNull(newTradeResult);
Assert.Equal(accountId, newTradeResult.AccountId); Assert.Equal(accountId, newTradeResult.AccountId);
Assert.Equal(mockCustodian.Code, newTradeResult.CustodianCode); Assert.Equal(mockCustodian.Code, newTradeResult.CustodianCode);
@@ -2886,23 +2886,27 @@ namespace BTCPayServer.Tests
// 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 = 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 // 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 = 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 // 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 // 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 // Test: Trade, correct assets, wrong amount
var wrongQtyTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01"}; var insufficientFundsTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01"};
await AssertApiError(400, "insufficient-funds", async () => await tradeClient.TradeMarket(storeId, accountId, wrongQtyTradeRequest)); await AssertApiError(400, "insufficient-funds", async () => await tradeClient.MarketTradeCustodianAccountAsset(storeId, accountId, insufficientFundsTradeRequest));
// Test: GetTradeQuote, unauth // Test: GetTradeQuote, unauth

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians; using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Custodians.Client;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -76,7 +77,7 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
public List<AssetPairData> GetTradableAssetPairs() public List<AssetPairData> GetTradableAssetPairs()
{ {
var r = new List<AssetPairData>(); var r = new List<AssetPairData>();
r.Add(new AssetPairData("BTC", "EUR")); r.Add(new AssetPairData("BTC", "EUR", (decimal) 0.0001));
return r; return r;
} }

View File

@@ -109,7 +109,7 @@
</li> </li>
} }
<li class="nav-item"> <li class="nav-item">
<a asp-area="" asp-controller="UICustodianAccounts" asp-action="CreateCustodianAccount" asp-route-storeId="@Model.Store.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.Create)" id="StoreNav-CreateApp"> <a asp-area="" asp-controller="UICustodianAccounts" asp-action="CreateCustodianAccount" asp-route-storeId="@Model.Store.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.Create)" id="StoreNav-CreateCustodianAccount">
<vc:icon symbol="new"/> <vc:icon symbol="new"/>
<span>Add Custodian</span> <span>Add Custodian</span>
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span> <span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>

View File

@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Custodians; using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Custodians.Client;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client; using BTCPayServer.Client;
@@ -231,7 +232,7 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market")] [HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market")]
[Authorize(Policy = Policies.CanTradeCustodianAccount, [Authorize(Policy = Policies.CanTradeCustodianAccount,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> TradeMarket(string storeId, string accountId, public async Task<IActionResult> MarketTradeCustodianAccountAsset(string storeId, string accountId,
TradeRequestData request, CancellationToken cancellationToken = default) TradeRequestData request, CancellationToken cancellationToken = default)
{ {
// TODO add SATS check everywhere. We cannot change to 'BTC' ourselves because the qty / price would be different too. // TODO add SATS check everywhere. We cannot change to 'BTC' ourselves because the qty / price would be different too.
@@ -246,29 +247,38 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanTrade tradableCustodian) if (custodian is ICanTrade tradableCustodian)
{ {
decimal Qty; bool isPercentage = request.Qty.EndsWith("%", StringComparison.InvariantCultureIgnoreCase);
if (request.Qty.EndsWith("%", StringComparison.InvariantCultureIgnoreCase)) string qtyString = isPercentage ? request.Qty.Substring(0, request.Qty.Length - 1) : request.Qty;
bool canParseQty = Decimal.TryParse(qtyString, out decimal qty);
if (!canParseQty)
{ {
// Qty is a percentage of current holdings return this.CreateAPIError(400, "bad-qty-format",
var percentage = Decimal.Parse( request.Qty.Substring(0, request.Qty.Length - 1), CultureInfo.InvariantCulture); $"Quantity should be a number or a number ending with '%' for percentages.");
}
if (isPercentage)
{
// Percentage of current holdings => calculate the amount
var config = custodianAccount.GetBlob(); var config = custodianAccount.GetBlob();
var balances = custodian.GetAssetBalancesAsync(config, cancellationToken).Result; var balances = custodian.GetAssetBalancesAsync(config, cancellationToken).Result;
var fromAssetBalance = balances[request.FromAsset]; var fromAssetBalance = balances[request.FromAsset];
var priceQuote = var priceQuote =
await tradableCustodian.GetQuoteForAssetAsync(request.FromAsset, request.ToAsset, config, cancellationToken); await tradableCustodian.GetQuoteForAssetAsync(request.FromAsset, request.ToAsset, config, cancellationToken);
Qty = fromAssetBalance / priceQuote.Ask * percentage / 100; qty = fromAssetBalance / priceQuote.Ask * qty / 100;
} }
else
try
{ {
// Qty is an exact amount var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, qty,
Qty = Decimal.Parse(request.Qty, CultureInfo.InvariantCulture);
}
var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, Qty,
custodianAccount.GetBlob(), cancellationToken); custodianAccount.GetBlob(), cancellationToken);
return Ok(ToModel(result, accountId, custodianAccount.CustodianCode)); return Ok(ToModel(result, accountId, custodianAccount.CustodianCode));
}
catch (CustodianApiException e)
{
return this.CreateAPIError(e.HttpStatus, e.Code,
e.Message);
}
} }
return this.CreateAPIError(400, "market-trade-not-supported", return this.CreateAPIError(400, "market-trade-not-supported",

View File

@@ -40,12 +40,12 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanTrade tradableCustodian) if (custodian is ICanTrade tradableCustodian)
{ {
var tradableAssetPairs = tradableCustodian.GetTradableAssetPairs(); var tradableAssetPairs = tradableCustodian.GetTradableAssetPairs();
var tradableAssetPairStrings = new string[tradableAssetPairs.Count]; var tradableAssetPairsDict = new Dictionary<string, AssetPairData>(tradableAssetPairs.Count);
for (int i = 0; i < tradableAssetPairs.Count; i++) foreach (var tradableAssetPair in tradableAssetPairs)
{ {
tradableAssetPairStrings[i] = tradableAssetPairs[i].ToString(); tradableAssetPairsDict.Add(tradableAssetPair.ToString(), tradableAssetPair);
} }
result.TradableAssetPairs = tradableAssetPairStrings; result.TradableAssetPairs = tradableAssetPairsDict;
} }
if (custodian is ICanDeposit depositableCustodian) if (custodian is ICanDeposit depositableCustodian)

View File

@@ -90,7 +90,8 @@ namespace BTCPayServer.Controllers.Greenfield
else else
{ {
context.User = context.User =
new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>() new ClaimsPrincipal(new ClaimsIdentity(
new List<Claim>()
{ {
new(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, Roles.ServerAdmin) new(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, Roles.ServerAdmin)
}, },
@@ -204,7 +205,7 @@ namespace BTCPayServer.Controllers.Greenfield
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName) public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName)
{ {
return AuthorizeAsync(user, resource, return AuthorizeAsync(user, resource,
new List<IAuthorizationRequirement>(new[] {new PolicyRequirement(policyName)})); new List<IAuthorizationRequirement>(new[] { new PolicyRequirement(policyName) }));
} }
} }
@@ -214,6 +215,14 @@ namespace BTCPayServer.Controllers.Greenfield
throw new NotSupportedException("This method is not supported by the LocalBTCPayServerClient."); throw new NotSupportedException("This method is not supported by the LocalBTCPayServerClient.");
} }
public override async Task<MarketTradeResponseData> MarketTradeCustodianAccountAsset(string storeId, string accountId,
TradeRequestData request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<MarketTradeResponseData>(
await GetController<GreenfieldCustodianAccountController>().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken));
}
public override async Task<StoreWebhookData> CreateWebhook(string storeId, CreateStoreWebhookRequest create, public override async Task<StoreWebhookData> CreateWebhook(string storeId, CreateStoreWebhookRequest create,
CancellationToken token = default) CancellationToken token = default)
{ {
@@ -460,8 +469,8 @@ namespace BTCPayServer.Controllers.Greenfield
return result switch return result switch
{ {
JsonResult jsonResult => (T)jsonResult.Value, JsonResult jsonResult => (T)jsonResult.Value,
OkObjectResult {Value: T res} => res, OkObjectResult { Value: T res } => res,
OkObjectResult {Value: JValue res} => res.Value<T>(), OkObjectResult { Value: JValue res } => res.Value<T>(),
_ => default _ => default
}; };
} }
@@ -470,9 +479,11 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
switch (result) switch (result)
{ {
case UnprocessableEntityObjectResult {Value: List<GreenfieldValidationError> validationErrors}: case UnprocessableEntityObjectResult { Value: List<GreenfieldValidationError> validationErrors }:
throw new GreenfieldValidationException(validationErrors.ToArray()); throw new GreenfieldValidationException(validationErrors.ToArray());
case BadRequestObjectResult {Value: GreenfieldAPIError error}: case BadRequestObjectResult { Value: GreenfieldAPIError error }:
throw new GreenfieldAPIException(400, error);
case ObjectResult { Value: GreenfieldAPIError error }:
throw new GreenfieldAPIException(400, error); throw new GreenfieldAPIException(400, error);
case NotFoundResult _: case NotFoundResult _:
throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", "")); throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", ""));
@@ -1085,7 +1096,7 @@ namespace BTCPayServer.Controllers.Greenfield
} }
public override async Task<PointOfSaleAppData> CreatePointOfSaleApp( public override async Task<PointOfSaleAppData> CreatePointOfSaleApp(
string storeId, string storeId,
CreatePointOfSaleAppRequest request, CancellationToken token = default) CreatePointOfSaleAppRequest request, CancellationToken token = default)
{ {
return GetFromActionResult<PointOfSaleAppData>( return GetFromActionResult<PointOfSaleAppData>(

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
@@ -7,6 +8,8 @@ using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Models.CustodianAccountViewModels; using BTCPayServer.Models.CustodianAccountViewModels;
@@ -26,30 +29,32 @@ namespace BTCPayServer.Controllers
[ExperimentalRouteAttribute] [ExperimentalRouteAttribute]
public class UICustodianAccountsController : Controller public class UICustodianAccountsController : Controller
{ {
private readonly IEnumerable<ICustodian> _custodianRegistry;
private readonly UserManager<ApplicationUser> _userManager;
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly CurrencyNameTable _currencyNameTable;
private readonly BTCPayServerClient _btcPayServerClient;
public UICustodianAccountsController( public UICustodianAccountsController(
CurrencyNameTable currencyNameTable, CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
CustodianAccountRepository custodianAccountRepository, CustodianAccountRepository custodianAccountRepository,
IEnumerable<ICustodian> custodianRegistry IEnumerable<ICustodian> custodianRegistry,
BTCPayServerClient btcPayServerClient
) )
{ {
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); _currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_userManager = userManager; _userManager = userManager;
_custodianAccountRepository = custodianAccountRepository; _custodianAccountRepository = custodianAccountRepository;
_custodianRegistry = custodianRegistry; _custodianRegistry = custodianRegistry;
_btcPayServerClient = btcPayServerClient;
} }
private readonly IEnumerable<ICustodian> _custodianRegistry;
private readonly UserManager<ApplicationUser> _userManager;
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly CurrencyNameTable _currencyNameTable;
public string CreatedCustodianAccountId { get; set; } public string CreatedCustodianAccountId { get; set; }
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}")] [HttpGet("/stores/{storeId}/custodian-accounts/{accountId}")]
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId) public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId)
{ {
var vm = new ViewCustodianAccountViewModel();
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId); var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null) if (custodianAccount == null)
@@ -62,14 +67,34 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
} }
var vm = new ViewCustodianAccountViewModel();
vm.Custodian = custodian; vm.Custodian = custodian;
vm.CustodianAccount = custodianAccount; vm.CustodianAccount = custodianAccount;
return View(vm);
}
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}.json")]
public async Task<IActionResult> ViewCustodianAccountAjax(string storeId, string accountId)
{
var vm = new ViewCustodianAccountBalancesViewModel();
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
var store = GetCurrentStore(); var store = GetCurrentStore();
var storeBlob = BTCPayServer.Data.StoreDataExtensions.GetStoreBlob(store); var storeBlob = StoreDataExtensions.GetStoreBlob(store);
var defaultCurrency = storeBlob.DefaultCurrency; var defaultCurrency = storeBlob.DefaultCurrency;
vm.DefaultCurrency = defaultCurrency; vm.DustThresholdInFiat = 1;
vm.StoreDefaultFiat = defaultCurrency;
try try
{ {
var assetBalances = new Dictionary<string, AssetBalanceInfo>(); var assetBalances = new Dictionary<string, AssetBalanceInfo>();
@@ -81,11 +106,15 @@ namespace BTCPayServer.Controllers
var asset = pair.Key; var asset = pair.Key;
assetBalances.Add(asset, assetBalances.Add(asset,
new AssetBalanceInfo { Asset = asset, Qty = pair.Value } new AssetBalanceInfo
{
Asset = asset,
Qty = pair.Value,
FormattedQty = pair.Value.ToString(CultureInfo.InvariantCulture)
}
); );
} }
if (custodian is ICanTrade tradingCustodian) if (custodian is ICanTrade tradingCustodian)
{ {
var config = custodianAccount.GetBlob(); var config = custodianAccount.GetBlob();
@@ -95,10 +124,20 @@ namespace BTCPayServer.Controllers
{ {
var asset = pair.Key; var asset = pair.Key;
var assetBalance = assetBalances[asset]; var assetBalance = assetBalances[asset];
var tradableAssetPairsList =
tradableAssetPairs.Where(o => o.AssetBought == asset || o.AssetSold == asset).ToList();
var tradableAssetPairsDict = new Dictionary<string, AssetPairData>(tradableAssetPairsList.Count);
foreach (var assetPair in tradableAssetPairsList)
{
tradableAssetPairsDict.Add(assetPair.ToString(), assetPair);
}
assetBalance.TradableAssetPairs = tradableAssetPairsDict;
if (asset.Equals(defaultCurrency)) if (asset.Equals(defaultCurrency))
{ {
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency); assetBalance.FormattedFiatValue =
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
assetBalance.FiatValue = pair.Value.Qty;
} }
else else
{ {
@@ -108,11 +147,14 @@ namespace BTCPayServer.Controllers
config, default); config, default);
assetBalance.Bid = quote.Bid; assetBalance.Bid = quote.Bid;
assetBalance.Ask = quote.Ask; assetBalance.Ask = quote.Ask;
assetBalance.FiatAsset = defaultCurrency; assetBalance.FormattedBid =
assetBalance.FormattedBid = _currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset); _currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
assetBalance.FormattedAsk = _currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset); assetBalance.FormattedAsk =
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid, pair.Value.FiatAsset); _currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
assetBalance.TradableAssetPairs = tradableAssetPairs.Where(o => o.AssetBought == asset || o.AssetSold == asset); assetBalance.FormattedFiatValue =
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid,
defaultCurrency);
assetBalance.FiatValue = pair.Value.Qty * quote.Bid;
} }
catch (WrongTradingPairException) catch (WrongTradingPairException)
{ {
@@ -136,8 +178,10 @@ namespace BTCPayServer.Controllers
} }
} }
vm.CanDeposit = false;
if (custodian is ICanDeposit depositableCustodian) if (custodian is ICanDeposit depositableCustodian)
{ {
vm.CanDeposit = true;
var depositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods(); var depositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods();
foreach (var depositablePaymentMethod in depositablePaymentMethods) foreach (var depositablePaymentMethod in depositablePaymentMethods)
{ {
@@ -154,10 +198,10 @@ namespace BTCPayServer.Controllers
} }
catch (Exception e) catch (Exception e)
{ {
vm.GetAssetBalanceException = e; vm.AssetBalanceExceptionMessage = e.Message;
} }
return View(vm); return Ok(vm);
} }
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/edit")] [HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/edit")]
@@ -173,6 +217,7 @@ 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 custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US"); var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
var vm = new EditCustodianAccountViewModel(); var vm = new EditCustodianAccountViewModel();
@@ -198,6 +243,7 @@ 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 custodian.GetConfigForm(custodianAccount.GetBlob(), locale); var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
var newData = new JObject(); var newData = new JObject();
@@ -255,12 +301,16 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.SelectedCustodian), "Invalid Custodian"); ModelState.AddModelError(nameof(vm.SelectedCustodian), "Invalid Custodian");
return View(vm); return View(vm);
} }
if (string.IsNullOrEmpty(vm.Name)) if (string.IsNullOrEmpty(vm.Name))
{ {
vm.Name = custodian.Name; vm.Name = custodian.Name;
} }
var custodianAccountData = new CustodianAccountData { CustodianCode = vm.SelectedCustodian, StoreId = vm.StoreId, Name = custodian.Name }; var custodianAccountData = new CustodianAccountData
{
CustodianCode = vm.SelectedCustodian, StoreId = vm.StoreId, Name = custodian.Name
};
var configData = new JObject(); var configData = new JObject();
@@ -287,8 +337,7 @@ namespace BTCPayServer.Controllers
return View(vm); return View(vm);
} }
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId) public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId)
{ {
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId); var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
@@ -301,7 +350,7 @@ namespace BTCPayServer.Controllers
if (isDeleted) if (isDeleted)
{ {
TempData[WellKnownTempData.SuccessMessage] = "Custodian account deleted"; TempData[WellKnownTempData.SuccessMessage] = "Custodian account deleted";
return RedirectToAction("Dashboard", "UIStores", new { storeId }); return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId });
} }
TempData[WellKnownTempData.ErrorMessage] = "Could not delete custodian account"; TempData[WellKnownTempData.ErrorMessage] = "Could not delete custodian account";
@@ -335,6 +384,98 @@ namespace BTCPayServer.Controllers
return filteredData; return filteredData;
} }
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/trade/prepare")]
public async Task<IActionResult> GetTradePrepareAjax(string storeId, string accountId,
[FromQuery] string assetToTrade, [FromQuery] string assetToTradeInto)
{
if (string.IsNullOrEmpty(assetToTrade) || string.IsNullOrEmpty(assetToTradeInto))
{
return BadRequest();
}
TradePrepareViewModel vm = new();
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
var store = GetCurrentStore();
var storeBlob = BTCPayServer.Data.StoreDataExtensions.GetStoreBlob(store);
var defaultCurrency = storeBlob.DefaultCurrency;
try
{
var assetBalancesData =
await custodian.GetAssetBalancesAsync(custodianAccount.GetBlob(), cancellationToken: default);
if (custodian is ICanTrade tradingCustodian)
{
var config = custodianAccount.GetBlob();
foreach (var pair in assetBalancesData)
{
var oneAsset = pair.Key;
if (assetToTrade.Equals(oneAsset))
{
vm.MaxQtyToTrade = pair.Value;
//vm.FormattedMaxQtyToTrade = pair.Value;
if (assetToTrade.Equals(assetToTradeInto))
{
// We cannot trade the asset for itself
return BadRequest();
}
try
{
var quote = await tradingCustodian.GetQuoteForAssetAsync(assetToTrade, assetToTradeInto,
config, default);
// TODO Ask is normally a higher number than Bid!! Let's check this!! Maybe a Unit Test?
vm.Ask = quote.Ask;
vm.Bid = quote.Bid;
vm.FromAsset = quote.FromAsset;
vm.ToAsset = quote.ToAsset;
}
catch (WrongTradingPairException)
{
// Cannot trade this asset, just ignore
}
}
}
}
}
catch (Exception e)
{
return BadRequest();
}
return Ok(vm);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/trade")]
public async Task<IActionResult> Trade(string storeId, string accountId,
[FromBody] TradeRequestData request)
{
try
{
var result = await _btcPayServerClient.MarketTradeCustodianAccountAsset(storeId, accountId, request);
return Ok(result);
}
catch (GreenfieldAPIException e)
{
var result = new ObjectResult(e.APIError) { StatusCode = e.HttpCode };
return result;
}
}
private StoreData GetCurrentStore() => HttpContext.GetStoreData(); private StoreData GetCurrentStore() => HttpContext.GetStoreData();
} }
} }

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
@@ -11,9 +10,10 @@ public class AssetBalanceInfo
public decimal? Bid { get; set; } public decimal? Bid { get; set; }
public decimal? Ask { get; set; } public decimal? Ask { get; set; }
public decimal Qty { get; set; } public decimal Qty { get; set; }
public string FiatAsset { get; set; } public string FormattedQty { get; set; }
public string FormattedFiatValue { get; set; } public string FormattedFiatValue { get; set; }
public IEnumerable<AssetPairData> TradableAssetPairs { get; set; } public decimal FiatValue { get; set; }
public Dictionary<string, AssetPairData> TradableAssetPairs { get; set; }
public bool CanWithdraw { get; set; } public bool CanWithdraw { get; set; }
public bool CanDeposit { get; set; } public bool CanDeposit { get; set; }
public string FormattedBid { get; set; } public string FormattedBid { get; set; }

View File

@@ -0,0 +1,9 @@
using BTCPayServer.Abstractions.Custodians.Client;
namespace BTCPayServer.Models.CustodianAccountViewModels;
public class TradePrepareViewModel : AssetQuoteResult
{
public decimal MaxQtyToTrade { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer.Models.CustodianAccountViewModels
{
public class ViewCustodianAccountBalancesViewModel
{
public Dictionary<string,AssetBalanceInfo> AssetBalances { get; set; }
public string AssetBalanceExceptionMessage { get; set; }
public string StoreDefaultFiat { get; set; }
public decimal DustThresholdInFiat { get; set; }
public bool CanDeposit { get; set; }
}
}

View File

@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Abstractions.Custodians; using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Data; using BTCPayServer.Data;
@@ -9,8 +7,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels
{ {
public ICustodian Custodian { get; set; } public ICustodian Custodian { get; set; }
public CustodianAccountData CustodianAccount { get; set; } public CustodianAccountData CustodianAccount { get; set; }
public Dictionary<string,AssetBalanceInfo> AssetBalances { get; set; }
public Exception GetAssetBalanceException { get; set; }
public string DefaultCurrency { get; set; }
} }
} }

View File

@@ -3,7 +3,7 @@
@foreach (var fieldset in Model.Fieldsets) @foreach (var fieldset in Model.Fieldsets)
{ {
<fieldset> <fieldset>
<legend>@fieldset.Label</legend> <legend class="h3 mt-4 mb-3">@fieldset.Label</legend>
@foreach (var field in fieldset.Fields) @foreach (var field in fieldset.Fields)
{ {
@if ("text".Equals(field.Type)) @if ("text".Equals(field.Type))
@@ -22,7 +22,7 @@
</label> </label>
} }
<input class="form-control @(@field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="text" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="HelpText@field.Name"/> <input class="form-control @(field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="text" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="HelpText@field.Name"/>
<small id="HelpText@field.Name" class="form-text text-muted"> <small id="HelpText@field.Name" class="form-text text-muted">
@field.HelpText @field.HelpText
</small> </small>

View File

@@ -16,8 +16,10 @@
<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">
<div asp-validation-summary="ModelOnly" class="text-danger"></div> @if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
}
@if (!string.IsNullOrEmpty(Model.SelectedCustodian)) @if (!string.IsNullOrEmpty(Model.SelectedCustodian))
{ {
<partial name="_FormTopMessages" model="Model.ConfigForm" /> <partial name="_FormTopMessages" model="Model.ConfigForm" />

View File

@@ -1,5 +1,6 @@
@using BTCPayServer.Views.Apps @using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Models
@model BTCPayServer.Models.CustodianAccountViewModels.EditCustodianAccountViewModel @model BTCPayServer.Models.CustodianAccountViewModels.EditCustodianAccountViewModel
@{ @{
ViewData.SetActivePage(AppsNavPages.Update, "Edit custodian account"); ViewData.SetActivePage(AppsNavPages.Update, "Edit custodian account");
@@ -15,9 +16,11 @@
<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"> <form asp-action="EditCustodianAccount" class="mb-5">
<div asp-validation-summary="ModelOnly" class="text-danger"></div> @if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
}
<partial name="_FormTopMessages" model="Model.ConfigForm"/> <partial name="_FormTopMessages" model="Model.ConfigForm"/>
<div class="form-group"> <div class="form-group">
@@ -32,5 +35,10 @@
<input type="submit" value="Continue" class="btn btn-primary" id="Save"/> <input type="submit" value="Continue" class="btn btn-primary" id="Save"/>
</div> </div>
</form> </form>
<a asp-action="DeleteCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-outline-danger" role="button" id="DeleteCustodianAccountConfig" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The custodian account <strong>@Model.CustodianAccount.Name</strong> will be permanently deleted." data-confirm-input="DELETE">
<span class="fa fa-trash"></span> Delete this custodian account
</a>
</div> </div>
</div> </div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete custodian account", "The custodian account will be permanently deleted.", "Delete"))" />

View File

@@ -10,180 +10,338 @@
<partial name="_ValidationScriptsPartial"/> <partial name="_ValidationScriptsPartial"/>
} }
<style>
.trade-qty label{display: block; }
</style>
<partial name="_StatusMessage"/> <div id="custodianAccountView" v-cloak>
<div class="sticky-header-setup"></div>
<div class="sticky-header-setup"></div> <div class="sticky-header d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div class="sticky-header d-sm-flex align-items-center justify-content-between"> <h2 class="mb-0">
<h2 class="mb-0"> @ViewData["Title"]
@ViewData["Title"] </h2>
</h2> <div class="d-flex flex-wrap gap-3">
<div class="d-flex gap-3 mt-3 mt-sm-0"> <a class="btn btn-primary" role="button" v-if="account && account.canDeposit" v-on:click="openDepositModal()" href="#">
<a asp-action="EditCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-primary mt-3 mt-sm-0" role="button" id="EditCustodianAccountConfig"> <span class="fa fa-download"></span> Deposit
<span class="fa fa-gear"></span> Configure </a>
</a> <a asp-action="EditCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-primary" role="button" id="EditCustodianAccountConfig">
<a asp-action="DeleteCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-danger mt-3 mt-sm-0" role="button" id="DeleteCustodianAccountConfig"> <span class="fa fa-gear"></span> Configure
<span class="fa fa-trash"></span> Delete </a>
</a> <!--
<!-- <button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button> <a class="btn btn-secondary" id="ViewApp" target="app_" href="/apps/MQ2sCVsmQ95JBZ4aZDtoSwMAnBY/pos">View</a>
<a class="btn btn-secondary" id="ViewApp" target="app_" href="/apps/MQ2sCVsmQ95JBZ4aZDtoSwMAnBY/pos">View</a> -->
--> </div>
</div> </div>
</div> <partial name="_StatusMessage"/>
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div v-if="!account" class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
@if (@Model.GetAssetBalanceException != null) <div v-if="account">
{ <p class="alert alert-danger" v-if="account.assetBalanceExceptionMessage">
<p class="alert alert-danger"> {{ account.assetBalanceExceptionMessage }}
@Model.GetAssetBalanceException.Message </p>
</p>
}
<h2>Balances</h2> <h2>Balances</h2>
<div class="table-responsive">
<table class="table table-hover table-responsive-md"> <div class="form-check">
<thead> <input class="form-check-input" type="checkbox" v-model="hideDustAmounts" id="flexCheckDefault">
<tr> <label class="form-check-label" for="flexCheckDefault" >
<th>Asset</th> Hide holdings worth less than {{ account.dustThresholdInFiat }} {{ account.storeDefaultFiat }}.
<th class="text-end">Balance</th> </label>
<th class="text-end">Unit Price (Bid)</th> </div>
<th class="text-end">Unit Price (Ask)</th>
<th class="text-end">Fiat Value</th>
<th class="text-end">Actions</th> <div class="table-responsive">
</tr> <table class="table table-hover table-responsive-md">
</thead> <thead>
<tbody>
@if (Model.AssetBalances != null && Model.AssetBalances.Count > 0)
{
@foreach (var pair in Model.AssetBalances.OrderBy(key => key.Key))
{
<tr> <tr>
<td>@pair.Key</td> <th>Asset</th>
<th class="text-end">Balance</th>
<th class="text-end">Unit Price (Bid)</th>
<th class="text-end">Unit Price (Ask)</th>
<th class="text-end">Fiat Value</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in sortedAssetRows" :key="row.asset">
<td>{{ row.asset }}</td>
<!-- TODO format as number? How? --> <!-- TODO format as number? How? -->
<th class="text-end">@pair.Value.Qty</th> <th class="text-end">{{ row.formattedQty }}</th>
<th class="text-end"> <th class="text-end">
@pair.Value.FormattedBid {{ row.formattedBid }}
</th> </th>
<th class="text-end"> <th class="text-end">
@pair.Value.FormattedAsk {{ row.formattedAsk }}
</th> </th>
<th class="text-end"> <th class="text-end">
@(pair.Value.FormattedFiatValue) {{ row.formattedFiatValue }}
</th> </th>
<th class="text-end"> <th class="text-end">
@if (pair.Value.TradableAssetPairs != null) <a v-if="row.tradableAssetPairs" v-on:click="openTradeModal(row)" href="#">Trade</a>
{ <a v-if="row.canDeposit" v-on:click="openDepositModal(row)" href="#">Deposit</a>
<a data-bs-toggle="modal" data-bs-target="#tradeModal" href="#">Trade</a> <a v-if="row.canWithdraw" v-on:click="openWithdrawModal(row)" href="#">Withdraw</a>
}
@if (pair.Value.CanDeposit)
{
<a data-bs-toggle="modal" data-bs-target="#depositModal" href="#">Deposit</a>
}
@if (pair.Value.CanWithdraw)
{
<a data-bs-toggle="modal" data-bs-target="#withdrawModal" href="#">Withdraw</a>
}
</th> </th>
</tr> </tr>
<tr v-if="account.assetBalances.length === 0">
<td colspan="999" class="text-center">No assets are stored with this custodian (yet).</td>
</tr>
<tr v-if="account.assetBalanceExceptionMessage !== null">
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
</tr>
</tbody>
</table>
</div>
<h2>Features</h2>
<p>The @Model?.Custodian.Name custodian supports:</p>
<ul>
<li>Viewing asset account</li>
@if (Model?.Custodian is ICanTrade)
{
<li>Trading</li>
} }
} @if (Model?.Custodian is ICanDeposit)
else if (Model.GetAssetBalanceException == null) {
{ <li>Depositing (Greenfield API only, for now)</li>
<tr> }
<td colspan="999" class="text-center">No assets are stored with this custodian (yet).</td> @if (Model?.Custodian is ICanWithdraw)
</tr> {
} <li>Withdrawing (Greenfield API only, for now)</li>
else }
{ </ul>
<tr> </div>
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
</tr>
}
</tbody>
</table>
</div> </div>
<h2>Features</h2>
<p>The @Model.Custodian.Name custodian supports:</p>
<ul>
<li>Viewing asset balances</li>
@if (Model.Custodian is ICanTrade)
{
<li>Trading (Greenfield API only, for now)</li>
}
@if (Model.Custodian is ICanDeposit)
{
<li>Depositing (Greenfield API only, for now)</li>
}
@if (Model.Custodian is ICanWithdraw)
{
<li>Withdrawing (Greenfield API only, for now)</li>
}
</ul>
</div> </div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="withdrawModal"> <div class="modal" tabindex="-1" role="dialog" id="withdrawModal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Withdraw</h5> <h5 class="modal-title">Withdraw</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/> <vc:icon symbol="close"/>
</button> </button>
</div>
<div class="modal-body">
<p>Withdrawals are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Withdraw to store wallet" endpoint</a> to execute a withdrawal.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div> </div>
<div class="modal-body"> </div>
<p>Withdrawals are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Withdraw to store wallet" endpoint</a> to execute a withdrawal.</p> </div>
</div>
<div class="modal-footer"> <div class="modal" tabindex="-1" role="dialog" id="depositModal">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deposit</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Deposits are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Get a deposit address for custodian" endpoint</a> to get a deposit address to send your assets to.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal" tabindex="-1" role="dialog" id="tradeModal" :data-bs-keyboard="!trade.isExecuting">
<div class="modal-dialog" role="document">
<form class="modal-content" v-on:submit="onTradeSubmit" method="post" asp-action="Trade" asp-route-accountId="@Model?.CustodianAccount?.Id" asp-route-storeId="@Model?.CustodianAccount?.StoreId">
<div class="modal-header">
<h5 class="modal-title">Trade {{ trade.qty }} {{ trade.assetToTrade }} into {{ trade.assetToTradeInto }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" v-if="!trade.isExecuting">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<div class="loading d-flex justify-content-center p-3" v-if="trade.isExecuting">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-if="!trade.isExecuting && trade.results === null">
<p v-if="trade.errorMsg" class="alert alert-danger">{{ trade.errorMsg }}</p>
<div class="row mb-2 trade-qty">
<div class="col-side">
<label class="form-label">
Convert
<div class="input-group has-validation">
<!--
getMinQtyToTrade() = {{ getMinQtyToTrade(this.trade.assetToTradeInto, this.trade.assetToTrade) }}
<br/>
Max Qty to Trade = {{ trade.maxQtyToTrade }}
-->
<input name="Qty" type="number" min="0" step="any" :max="trade.maxQtyToTrade" :min="getMinQtyToTrade()" class="form-control qty" v-bind:class="{ 'is-invalid': trade.qty < getMinQtyToTrade() || trade.qty > trade.maxQtyToTrade }" v-model="trade.qty"/>
<select name="FromAsset" v-model="trade.assetToTrade" class="form-control">
<option v-for="option in availableAssetsToTrade" v-bind:value="option">
{{ option }}
</option>
</select>
</div>
</label>
</div>
<div class="col-center text-center">
&nbsp;
<br/>
<button v-if="canSwapTradeAssets()" type="button" class="btn btn-secondary btn-square" v-on:click="swapTradeAssets()" aria-label="Swap assets">
<i class="fa fa-arrows-h" aria-hidden="true"></i>
</button>
</div>
<div class="col-side">
<label class="form-label">
Into
<div class="input-group">
<input disabled="disabled" type="number" class="form-control qty" v-model="tradeQtyToReceive"/>
<select name="ToAsset" v-model="trade.assetToTradeInto" class="form-control">
<option v-for="option in availableAssetsToTradeInto" v-bind:value="option">
{{ option }}
</option>
</select>
</div>
</label>
</div>
</div>
<div>
<div class="btn-group" role="group" aria-label="Set qty to a percentage of holdings">
<button v-on:click="setTradeQtyPercent(10)" class="btn btn-secondary" type="button">10%</button>
<button v-on:click="setTradeQtyPercent(25)" class="btn btn-secondary" type="button">25%</button>
<button v-on:click="setTradeQtyPercent(50)" class="btn btn-secondary" type="button">50%</button>
<button v-on:click="setTradeQtyPercent(75)" class="btn btn-secondary" type="button">75%</button>
<button v-on:click="setTradeQtyPercent(90)" class="btn btn-secondary" type="button">90%</button>
<button v-on:click="setTradeQtyPercent(100)" class="btn btn-secondary" type="button">100%</button>
</div>
</div>
<p v-if="trade.price">
<br/>
1 {{ trade.assetToTradeInto }} = {{ trade.price }} {{ trade.assetToTrade }}
<br/>
1 {{ trade.assetToTrade }} = {{ 1 / trade.price }} {{ trade.assetToTradeInto }}
</p>
<p v-if="canExecuteTrade">
After the trade
{{ trade.maxQtyToTrade - trade.qty }} {{ trade.assetToTrade }} will remain in your account.
</p>
<!--
<p>
trade.priceForPair = {{ trade.priceForPair }}
</p>
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Min Qty to Trade</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr>
<td>EUR</td>
<td>BTC</td>
<td>{{getMinQtyToTrade('EUR', 'BTC')}}</td>
<td>{{trade.priceForPair['EUR/BTC']}}</td>
</tr>
<tr>
<td>BTC</td>
<td>EUR</td>
<td>{{getMinQtyToTrade('BTC', 'EUR')}}</td>
<td>{{trade.priceForPair['BTC/EUR']}}</td>
</tr>
<tr>
<td>EUR</td>
<td>LTC</td>
<td>{{getMinQtyToTrade('EUR', 'LTC')}}</td>
<td>{{trade.priceForPair['EUR/LTC']}}</td>
</tr>
<tr>
<td>LTC</td>
<td>EUR</td>
<td>{{getMinQtyToTrade('LTC', 'EUR')}}</td>
<td>{{trade.priceForPair['LTC/EUR']}}</td>
</tr>
<tr>
<td>BTC</td>
<td>LTC</td>
<td>{{getMinQtyToTrade('BTC', 'LTC')}}</td>
<td>{{trade.priceForPair['BTC/LTC']}}</td>
</tr>
<tr>
<td>LTC</td>
<td>BTC</td>
<td>{{getMinQtyToTrade('LTC', 'BTC')}}</td>
<td>{{trade.priceForPair['LTC/BTC']}}</td>
</tr>
</tbody>
</table>
-->
<small class="form-text text-muted">Final results may vary due to trading fees and slippage.</small>
</div>
<div v-if="trade.results !== null">
<p class="alert alert-success">Successfully traded {{ trade.results.fromAsset}} into {{ trade.results.toAsset}}.</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2">Asset</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in trade.results.ledgerEntries">
<td class="text-end" v-bind:class="{ 'text-success': entry.qty > 0, 'text-danger': entry.qty < 0 }"><span v-if="entry.qty > 0">+</span>{{ entry.qty }}</td>
<td v-bind:class="{ 'text-success': entry.qty > 0, 'text-danger': entry.qty < 0 }">{{ entry.asset }}</td>
<td v-bind:class="{ 'text-success': entry.qty > 0, 'text-danger': entry.qty < 0 }">
<span v-if="entry.type !== 'Trade'">{{ entry.type}}</span>
</td>
</tr>
</tbody>
</table>
<p>Trade ID: {{ trade.results.tradeId }}</p>
</div>
</div>
<div class="modal-footer" v-if="!trade.isExecuting">
<div class="modal-footer-left">
<span v-if="trade.isUpdating">
Updating quote...
</span>
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<span v-if="trade.results">Close</span>
<span v-if="!trade.results">Cancel</span>
</button>
<button v-if="canExecuteTrade" type="submit" class="btn btn-primary">Execute</button>
</div>
</form>
</div>
</div>
</div> </div>
<div class="modal" tabindex="-1" role="dialog" id="depositModal"> <script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<div class="modal-dialog" role="document"> <script type="text/javascript">
<div class="modal-content"> var ajaxBalanceUrl = "@Url.Action("ViewCustodianAccountAjax", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
<div class="modal-header"> var ajaxTradePrepareUrl = "@Url.Action("GetTradePrepareAjax", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
<h5 class="modal-title">Deposit</h5> </script>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"> <script src="~/js/custodian-account.js" asp-append-version="true"></script>
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Deposits are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Get a deposit address for custodian" endpoint</a> to get a deposit address to send your assets to.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="tradeModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Trade</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Trades are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank"> Greenfield API "Trade one asset for another" endpoint</a> to convert an asset to another via a market order.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,372 @@
new Vue({
el: '#custodianAccountView',
data: {
account: null,
hideDustAmounts: true,
modals: {
trade: null,
withdraw: null,
deposit: null
},
trade: {
row: null,
results: null,
errorMsg: null,
isExecuting: false,
isUpdating: false,
updateTradePriceAbortController: new AbortController(),
priceRefresherInterval: null,
assetToTrade: null,
assetToTradeInto: null,
qty: null,
maxQtyToTrade: null,
price: null,
priceForPair: {}
}
},
computed: {
tradeQtyToReceive: function () {
return this.trade.qty / this.trade.price;
},
canExecuteTrade: function () {
return this.trade.qty >= this.getMinQtyToTrade() && this.trade.price !== null && this.trade.assetToTrade !== null && this.trade.assetToTradeInto !== null && !this.trade.isExecuting && this.trade.results === null;
},
availableAssetsToTrade: function () {
let r = [];
let balances = this?.account?.assetBalances;
if (balances) {
let t = this;
let rows = Object.values(balances);
rows = rows.filter(function (row) {
return row.fiatValue > t.account.dustThresholdInFiat;
});
for (let i in rows) {
r.push(rows[i].asset);
}
}
return r.sort();
},
availableAssetsToTradeInto: function () {
let r = [];
let pairs = this.account?.assetBalances?.[this.trade.assetToTrade]?.tradableAssetPairs;
if (pairs) {
for (let i in pairs) {
let pair = pairs[i];
if (pair.assetBought === this.trade.assetToTrade) {
r.push(pair.assetSold);
} else if (pair.assetSold === this.trade.assetToTrade) {
r.push(pair.assetBought);
}
}
}
return r.sort();
},
sortedAssetRows: function () {
if (this.account?.assetBalances) {
let rows = Object.values(this.account.assetBalances);
let t = this;
if (this.hideDustAmounts) {
rows = rows.filter(function (row) {
return row.fiatValue > t.account.dustThresholdInFiat;
});
}
rows = rows.sort(function (a, b) {
return b.fiatValue - a.fiatValue;
});
return rows;
}
}
},
methods: {
getMaxQtyToTrade: function (assetToTrade) {
let row = this.account?.assetBalances?.[assetToTrade];
if (row) {
return row.qty;
}
return null;
},
getMinQtyToTrade: function (assetToTrade = this.trade.assetToTrade, assetToTradeInto = this.trade.assetToTradeInto) {
if (assetToTrade && assetToTradeInto && this.account?.assetBalances) {
for (let asset in this.account.assetBalances) {
let row = this.account.assetBalances[asset];
let pairCode = assetToTrade + "/" + assetToTradeInto;
let pairCodeReverse = assetToTradeInto + "/" + assetToTrade;
let pair = row.tradableAssetPairs?.[pairCode];
let pairReverse = row.tradableAssetPairs?.[pairCodeReverse];
if(pair !== null || pairReverse !== null){
if (pair && !pairReverse) {
return pair.minimumTradeQty;
} else if (!pair && pairReverse) {
// TODO price here could not be what we expect it to be...
let price = this.trade.priceForPair?.[pairCode];
if(!price){
return null;
}
// if (reverse) {
// return price / pairReverse.minimumTradeQty;
// }else {
return price * pairReverse.minimumTradeQty;
// }
}
}
}
}
return 0;
},
setTradeQtyPercent: function (percent) {
this.trade.qty = percent / 100 * this.trade.maxQtyToTrade;
},
openTradeModal: function (row) {
let _this = this;
this.trade.row = row;
this.trade.results = null;
this.trade.errorMsg = null;
this.trade.assetToTrade = row.asset;
if (row.asset === this.account.storeDefaultFiat) {
this.trade.assetToTradeInto = "BTC";
} else {
this.trade.assetToTradeInto = this.account.storeDefaultFiat;
}
// TODO watch "this.trade.assetToTrade" for changes and if so, set "qty" to max + fill "maxQtyToTrade" and "price"
this.trade.qty = row.qty;
this.trade.maxQtyToTrade = row.qty;
this.trade.price = row.bid;
if (this.modals.trade === null) {
this.modals.trade = new window.bootstrap.Modal('#tradeModal');
// Disable price refreshing when modal closes...
const tradeModelElement = document.getElementById('tradeModal')
tradeModelElement.addEventListener('hide.bs.modal', event => {
_this.setTradePriceRefresher(false);
});
}
this.setTradePriceRefresher(true);
this.modals.trade.show();
},
openWithdrawModal: function (row) {
if (this.modals.withdraw === null) {
this.modals.withdraw = new window.bootstrap.Modal('#withdrawModal');
}
this.modals.withdraw.show();
},
openDepositModal: function (row) {
if (this.modals.deposit === null) {
this.modals.deposit = new window.bootstrap.Modal('#depositModal');
}
this.modals.deposit.show();
},
onTradeSubmit: async function (e) {
e.preventDefault();
const form = e.currentTarget;
const url = form.getAttribute('action');
const method = form.getAttribute('method');
this.trade.isExecuting = true;
// Prevent the modal from closing by clicking outside or via the keyboard
this.modals.trade._config.backdrop = 'static';
this.modals.trade._config.keyboard = false;
const _this = this;
const token = document.querySelector("input[name='__RequestVerificationToken']").value;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({
fromAsset: _this.trade.assetToTrade,
toAsset: _this.trade.assetToTradeInto,
qty: _this.trade.qty
})
});
let data = null;
try {
data = await response.json();
} catch (e) {
}
if (response.ok) {
_this.trade.results = data;
_this.trade.errorMsg = null;
_this.setTradePriceRefresher(false);
_this.refreshAccountBalances();
} else {
_this.trade.errorMsg = data && data.message || "Error";
}
_this.modals.trade._config.backdrop = true;
_this.modals.trade._config.keyboard = true;
_this.trade.isExecuting = false;
},
setTradePriceRefresher: function (enabled) {
if (enabled) {
// Update immediately...
this.updateTradePrice();
// And keep updating every few seconds...
let _this = this;
this.trade.priceRefresherInterval = setInterval(function () {
_this.updateTradePrice();
}, 5000);
} else {
clearInterval(this.trade.priceRefresherInterval);
}
},
updateTradePrice: function () {
if (!this.trade.assetToTrade || !this.trade.assetToTradeInto) {
// We need to know the 2 assets or we cannot do anything...
return;
}
if (this.trade.assetToTrade === this.trade.assetToTradeInto) {
// The 2 assets must be different
this.trade.price = null;
return;
}
if (this.trade.isUpdating) {
console.log("Previous request is still running. No need to hammer the server.");
return;
}
this.trade.isUpdating = true;
let _this = this;
var searchParams = new URLSearchParams(window.location.search);
if (this.trade.assetToTrade) {
searchParams.set("assetToTrade", this.trade.assetToTrade);
}
if (this.trade.assetToTradeInto) {
searchParams.set("assetToTradeInto", this.trade.assetToTradeInto);
}
let url = window.ajaxTradePrepareUrl + "?" + searchParams.toString();
this.trade.updateTradePriceAbortController = new AbortController();
fetch(url, {
signal: this.trade.updateTradePriceAbortController.signal,
headers: {
'Content-Type': 'application/json'
}
}
).then(function (response) {
_this.trade.isUpdating = false;
if (response.ok) {
return response.json();
}
// _this.trade.results = data;
// _this.trade.errorMsg = null; }
// Do nothing on error
}
).then(function (data) {
_this.trade.maxQtyToTrade = data.maxQtyToTrade;
// By default trade everything
if (_this.trade.qty === null) {
_this.trade.qty = _this.trade.maxQtyToTrade;
}
// Cannot trade more than what we have
if (data.maxQtyToTrade < _this.trade.qty) {
_this.trade.qty = _this.trade.maxQtyToTrade;
}
let pair = data.fromAsset+"/"+data.toAsset;
let pairReverse = data.toAsset+"/"+data.fromAsset;
// TODO Should we use "bid" in some cases? The spread can be huge with some shitcoins.
_this.trade.price = data.ask;
_this.trade.priceForPair[pair] = data.ask;
_this.trade.priceForPair[pairReverse] = 1 / data.ask;
}).catch(function (e) {
_this.trade.isUpdating = false;
if (e instanceof DOMException && e.code === DOMException.ABORT_ERR) {
console.log("User aborted fetch request");
} else {
throw e;
}
});
},
canSwapTradeAssets: function () {
let minQtyToTrade = this.getMinQtyToTrade(this.trade.assetToTradeInto, this.trade.assetToTrade);
let assetToTradeIntoHoldings = this.account?.assetBalances?.[this.trade.assetToTradeInto];
if (assetToTradeIntoHoldings) {
return assetToTradeIntoHoldings.qty >= minQtyToTrade;
}
},
swapTradeAssets: function () {
// Swap the 2 assets
let tmp = this.trade.assetToTrade;
this.trade.assetToTrade = this.trade.assetToTradeInto;
this.trade.assetToTradeInto = tmp;
this.trade.price = 1 / this.trade.price;
this._refreshTradeDataAfterAssetChange();
},
_refreshTradeDataAfterAssetChange: function(){
let maxQtyToTrade = this.getMaxQtyToTrade(this.trade.assetToTrade);
this.trade.qty = maxQtyToTrade
this.trade.maxQtyToTrade = maxQtyToTrade;
this.killAjaxIfRunning(this.trade.updateTradePriceAbortController);
// Update the price asap, so we can continue
let _this = this;
setTimeout(function () {
_this.updateTradePrice();
}, 100);
},
killAjaxIfRunning: function (abortController) {
abortController.abort();
},
refreshAccountBalances: function () {
let _this = this;
fetch(window.ajaxBalanceUrl).then(function (response) {
return response.json();
}).then(function (result) {
_this.account = result;
});
}
},
watch: {
'trade.assetToTrade': function(newValue, oldValue){
if(newValue === this.trade.assetToTradeInto){
// This is the same as swapping the 2 assets
this.trade.assetToTradeInto = oldValue;
this.trade.price = 1 / this.trade.price;
this._refreshTradeDataAfterAssetChange();
}
if(newValue !== oldValue){
// The qty is going to be wrong, so set to 100%
this.trade.qty = this.getMaxQtyToTrade(this.trade.assetToTrade);
}
}
},
created: function () {
this.refreshAccountBalances();
},
mounted: function () {
// Runs when the app is ready
}
});

View File

@@ -528,3 +528,23 @@ svg.icon-note {
margin-right: 0; margin-right: 0;
} }
} }
#tradeModal .qty{ width: 53%; }
#tradeModal .btn-square{ padding: 0; width: 2.5rem; height: 2.5rem; }
#tradeModal .trade-qty {
display: flex;
justify-content: space-between;
}
#tradeModal .trade-qty .col-center {
flex: 0 0 2rem;
padding-left: 0;
padding-right: 0;
}
#tradeModal .trade-qty .col-side {
flex: 1;
}
.modal-footer .modal-footer-left{ margin-right: auto; }

View File

@@ -637,19 +637,38 @@
}, },
"tradableAssetPairs": { "tradableAssetPairs": {
"type": "array", "type": "array",
"description": "A list of tradable asset pairs, or NULL if the custodian cannot trades/convert assets.", "description": "A list of tradable asset pair objects, or NULL if the custodian cannot trades/convert assets.",
"nullable": true "nullable": true,
"items": {
"$ref": "#/components/schemas/AssetPairData"
}
} }
}, },
"example": { "example": {
"code": "kraken", "code": "kraken",
"name": "Kraken", "name": "Kraken",
"tradableAssetPairs": [ "tradableAssetPairs": {
"BTC/USD", "BTC/USD": {
"BTC/EUR", "assetBought": "BTC",
"LTC/USD", "assetSold": "USD",
"LTC/EUR" "minimumTradeQty": 0.001
], },
"BTC/EUR": {
"assetBought": "BTC",
"assetSold": "EUR",
"minimumTradeQty": 0.001
},
"LTC/USD": {
"assetBought": "LTC",
"assetSold": "USD",
"minimumTradeQty": 0.05
},
"LTC/EUR": {
"assetBought": "LTC",
"assetSold": "EUR",
"minimumTradeQty": 0.05
}
},
"withdrawablePaymentMethods": [ "withdrawablePaymentMethods": [
"BTC-OnChain", "BTC-OnChain",
"LTC-OnChain" "LTC-OnChain"
@@ -1023,6 +1042,27 @@
"qty": 1.23456 "qty": 1.23456
} }
}, },
"AssetPairData": {
"type": "object",
"description": "An asset pair we can trade.",
"properties": {
"pair": {
"type": "string",
"description": "The name of the asset pair.",
"nullable": false
},
"minimumTradeQty": {
"type": "number",
"description": "The smallest amount we can buy or sell.",
"nullable": false
}
},
"example": {
"assetBought": "BTC",
"assetSold": "USD",
"minimumTradeQty": 0.0001
}
},
"LedgerEntryType": { "LedgerEntryType": {
"type": "string", "type": "string",
"enum": [ "enum": [