mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-03 17:36:59 +01:00
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:
parent
2ea6eb09e6
commit
c71e671311
26 changed files with 1058 additions and 251 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Custodians.Client;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Abstractions.Custodians;
|
||||
|
|
|
@ -56,9 +56,14 @@ namespace BTCPayServer.Client
|
|||
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);
|
||||
}
|
||||
|
||||
|
@ -73,7 +78,6 @@ namespace BTCPayServer.Client
|
|||
var queryPayload = new Dictionary<string, object>();
|
||||
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<TradeQuoteResponseData>(response);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<string, AssetPairData> TradableAssetPairs { get; set; }
|
||||
public string[] WithdrawablePaymentMethods { get; set; }
|
||||
public string[] DepositablePaymentMethods { get; set; }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AssetPairData> GetTradableAssetPairs()
|
||||
{
|
||||
var r = new List<AssetPairData>();
|
||||
r.Add(new AssetPairData("BTC", "EUR"));
|
||||
r.Add(new AssetPairData("BTC", "EUR", (decimal) 0.0001));
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
</li>
|
||||
}
|
||||
<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"/>
|
||||
<span>Add Custodian</span>
|
||||
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Custodians;
|
||||
using BTCPayServer.Abstractions.Custodians.Client;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Client;
|
||||
|
@ -231,7 +232,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market")]
|
||||
[Authorize(Policy = Policies.CanTradeCustodianAccount,
|
||||
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)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
decimal Qty;
|
||||
if (request.Qty.EndsWith("%", StringComparison.InvariantCultureIgnoreCase))
|
||||
bool isPercentage = 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
|
||||
var percentage = Decimal.Parse( request.Qty.Substring(0, request.Qty.Length - 1), CultureInfo.InvariantCulture);
|
||||
return this.CreateAPIError(400, "bad-qty-format",
|
||||
$"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 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 * percentage / 100;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Qty is an exact amount
|
||||
Qty = Decimal.Parse(request.Qty, CultureInfo.InvariantCulture);
|
||||
|
||||
qty = fromAssetBalance / priceQuote.Ask * qty / 100;
|
||||
}
|
||||
|
||||
var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, Qty,
|
||||
try
|
||||
{
|
||||
var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, qty,
|
||||
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",
|
||||
|
|
|
@ -40,12 +40,12 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
if (custodian is ICanTrade tradableCustodian)
|
||||
{
|
||||
var tradableAssetPairs = tradableCustodian.GetTradableAssetPairs();
|
||||
var tradableAssetPairStrings = new string[tradableAssetPairs.Count];
|
||||
for (int i = 0; i < tradableAssetPairs.Count; i++)
|
||||
var tradableAssetPairsDict = new Dictionary<string, AssetPairData>(tradableAssetPairs.Count);
|
||||
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)
|
||||
|
|
|
@ -90,7 +90,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
else
|
||||
{
|
||||
context.User =
|
||||
new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>()
|
||||
new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new List<Claim>()
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
|
@ -460,8 +469,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
return result switch
|
||||
{
|
||||
JsonResult jsonResult => (T)jsonResult.Value,
|
||||
OkObjectResult {Value: T res} => res,
|
||||
OkObjectResult {Value: JValue res} => res.Value<T>(),
|
||||
OkObjectResult { Value: T res } => res,
|
||||
OkObjectResult { Value: JValue res } => res.Value<T>(),
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
@ -470,9 +479,11 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
{
|
||||
switch (result)
|
||||
{
|
||||
case UnprocessableEntityObjectResult {Value: List<GreenfieldValidationError> validationErrors}:
|
||||
case UnprocessableEntityObjectResult { Value: List<GreenfieldValidationError> validationErrors }:
|
||||
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);
|
||||
case NotFoundResult _:
|
||||
throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", ""));
|
||||
|
@ -1085,7 +1096,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
}
|
||||
|
||||
public override async Task<PointOfSaleAppData> CreatePointOfSaleApp(
|
||||
string storeId,
|
||||
string storeId,
|
||||
CreatePointOfSaleAppRequest request, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<PointOfSaleAppData>(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
|
@ -7,6 +8,8 @@ using BTCPayServer.Abstractions.Custodians;
|
|||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models.CustodianAccountViewModels;
|
||||
|
@ -26,30 +29,32 @@ namespace BTCPayServer.Controllers
|
|||
[ExperimentalRouteAttribute]
|
||||
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(
|
||||
CurrencyNameTable currencyNameTable,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
CustodianAccountRepository custodianAccountRepository,
|
||||
IEnumerable<ICustodian> custodianRegistry
|
||||
IEnumerable<ICustodian> custodianRegistry,
|
||||
BTCPayServerClient btcPayServerClient
|
||||
)
|
||||
{
|
||||
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
|
||||
_userManager = userManager;
|
||||
_custodianAccountRepository = custodianAccountRepository;
|
||||
_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; }
|
||||
|
||||
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}")]
|
||||
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId)
|
||||
{
|
||||
var vm = new ViewCustodianAccountViewModel();
|
||||
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||
|
||||
if (custodianAccount == null)
|
||||
|
@ -62,14 +67,34 @@ namespace BTCPayServer.Controllers
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
var vm = new ViewCustodianAccountViewModel();
|
||||
vm.Custodian = custodian;
|
||||
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 storeBlob = BTCPayServer.Data.StoreDataExtensions.GetStoreBlob(store);
|
||||
var storeBlob = StoreDataExtensions.GetStoreBlob(store);
|
||||
var defaultCurrency = storeBlob.DefaultCurrency;
|
||||
vm.DefaultCurrency = defaultCurrency;
|
||||
|
||||
|
||||
vm.DustThresholdInFiat = 1;
|
||||
vm.StoreDefaultFiat = defaultCurrency;
|
||||
try
|
||||
{
|
||||
var assetBalances = new Dictionary<string, AssetBalanceInfo>();
|
||||
|
@ -81,11 +106,15 @@ namespace BTCPayServer.Controllers
|
|||
var asset = pair.Key;
|
||||
|
||||
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)
|
||||
{
|
||||
var config = custodianAccount.GetBlob();
|
||||
|
@ -95,10 +124,20 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
var asset = pair.Key;
|
||||
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))
|
||||
{
|
||||
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
|
||||
assetBalance.FormattedFiatValue =
|
||||
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
|
||||
assetBalance.FiatValue = pair.Value.Qty;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -108,11 +147,14 @@ namespace BTCPayServer.Controllers
|
|||
config, default);
|
||||
assetBalance.Bid = quote.Bid;
|
||||
assetBalance.Ask = quote.Ask;
|
||||
assetBalance.FiatAsset = defaultCurrency;
|
||||
assetBalance.FormattedBid = _currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
|
||||
assetBalance.FormattedAsk = _currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
|
||||
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid, pair.Value.FiatAsset);
|
||||
assetBalance.TradableAssetPairs = tradableAssetPairs.Where(o => o.AssetBought == asset || o.AssetSold == asset);
|
||||
assetBalance.FormattedBid =
|
||||
_currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
|
||||
assetBalance.FormattedAsk =
|
||||
_currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
|
||||
assetBalance.FormattedFiatValue =
|
||||
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid,
|
||||
defaultCurrency);
|
||||
assetBalance.FiatValue = pair.Value.Qty * quote.Bid;
|
||||
}
|
||||
catch (WrongTradingPairException)
|
||||
{
|
||||
|
@ -136,8 +178,10 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
vm.CanDeposit = false;
|
||||
if (custodian is ICanDeposit depositableCustodian)
|
||||
{
|
||||
vm.CanDeposit = true;
|
||||
var depositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods();
|
||||
foreach (var depositablePaymentMethod in depositablePaymentMethods)
|
||||
{
|
||||
|
@ -154,10 +198,10 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
vm.GetAssetBalanceException = e;
|
||||
vm.AssetBalanceExceptionMessage = e.Message;
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
return Ok(vm);
|
||||
}
|
||||
|
||||
[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?
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
|
||||
|
||||
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?
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
|
||||
|
||||
var newData = new JObject();
|
||||
|
@ -255,12 +301,16 @@ namespace BTCPayServer.Controllers
|
|||
ModelState.AddModelError(nameof(vm.SelectedCustodian), "Invalid Custodian");
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(vm.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();
|
||||
|
@ -287,8 +337,7 @@ namespace BTCPayServer.Controllers
|
|||
return View(vm);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
|
||||
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
|
||||
public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId)
|
||||
{
|
||||
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||
|
@ -301,7 +350,7 @@ namespace BTCPayServer.Controllers
|
|||
if (isDeleted)
|
||||
{
|
||||
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";
|
||||
|
@ -335,6 +384,98 @@ namespace BTCPayServer.Controllers
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
|
@ -11,9 +10,10 @@ public class AssetBalanceInfo
|
|||
public decimal? Bid { get; set; }
|
||||
public decimal? Ask { get; set; }
|
||||
public decimal Qty { get; set; }
|
||||
public string FiatAsset { get; set; }
|
||||
public string FormattedQty { 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 CanDeposit { get; set; }
|
||||
public string FormattedBid { get; set; }
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
using BTCPayServer.Abstractions.Custodians.Client;
|
||||
|
||||
namespace BTCPayServer.Models.CustodianAccountViewModels;
|
||||
|
||||
public class TradePrepareViewModel : AssetQuoteResult
|
||||
{
|
||||
public decimal MaxQtyToTrade { get; set; }
|
||||
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Abstractions.Custodians;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
|
@ -9,8 +7,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels
|
|||
{
|
||||
public ICustodian Custodian { get; set; }
|
||||
public CustodianAccountData CustodianAccount { get; set; }
|
||||
public Dictionary<string,AssetBalanceInfo> AssetBalances { get; set; }
|
||||
public Exception GetAssetBalanceException { get; set; }
|
||||
public string DefaultCurrency { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
@foreach (var fieldset in Model.Fieldsets)
|
||||
{
|
||||
<fieldset>
|
||||
<legend>@fieldset.Label</legend>
|
||||
<legend class="h3 mt-4 mb-3">@fieldset.Label</legend>
|
||||
@foreach (var field in fieldset.Fields)
|
||||
{
|
||||
@if ("text".Equals(field.Type))
|
||||
|
@ -22,7 +22,7 @@
|
|||
</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">
|
||||
@field.HelpText
|
||||
</small>
|
||||
|
|
|
@ -16,8 +16,10 @@
|
|||
<div class="row">
|
||||
<div class="col-xl-8 col-xxl-constrain">
|
||||
<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))
|
||||
{
|
||||
<partial name="_FormTopMessages" model="Model.ConfigForm" />
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@using BTCPayServer.Views.Apps
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@model BTCPayServer.Models.CustodianAccountViewModels.EditCustodianAccountViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(AppsNavPages.Update, "Edit custodian account");
|
||||
|
@ -15,9 +16,11 @@
|
|||
|
||||
<div class="row">
|
||||
<div class="col-xl-8 col-xxl-constrain">
|
||||
<form asp-action="EditCustodianAccount">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
|
||||
<form asp-action="EditCustodianAccount" class="mb-5">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
}
|
||||
<partial name="_FormTopMessages" model="Model.ConfigForm"/>
|
||||
|
||||
<div class="form-group">
|
||||
|
@ -32,5 +35,10 @@
|
|||
<input type="submit" value="Continue" class="btn btn-primary" id="Save"/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel("Delete custodian account", "The custodian account will be permanently deleted.", "Delete"))" />
|
||||
|
|
|
@ -10,180 +10,338 @@
|
|||
<partial name="_ValidationScriptsPartial"/>
|
||||
}
|
||||
|
||||
<style>
|
||||
.trade-qty label{display: block; }
|
||||
</style>
|
||||
|
||||
<partial name="_StatusMessage"/>
|
||||
|
||||
<div class="sticky-header-setup"></div>
|
||||
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||
<h2 class="mb-0">
|
||||
@ViewData["Title"]
|
||||
</h2>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<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-gear"></span> Configure
|
||||
</a>
|
||||
<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-trash"></span> Delete
|
||||
</a>
|
||||
<!--
|
||||
<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>
|
||||
-->
|
||||
<div id="custodianAccountView" v-cloak>
|
||||
<div class="sticky-header-setup"></div>
|
||||
<div class="sticky-header d-flex flex-wrap gap-3 align-items-center justify-content-between">
|
||||
<h2 class="mb-0">
|
||||
@ViewData["Title"]
|
||||
</h2>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<a class="btn btn-primary" role="button" v-if="account && account.canDeposit" v-on:click="openDepositModal()" href="#">
|
||||
<span class="fa fa-download"></span> Deposit
|
||||
</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">
|
||||
<span class="fa fa-gear"></span> Configure
|
||||
</a>
|
||||
<!--
|
||||
<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>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<partial name="_StatusMessage"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-12">
|
||||
<div class="row">
|
||||
<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)
|
||||
{
|
||||
<p class="alert alert-danger">
|
||||
@Model.GetAssetBalanceException.Message
|
||||
</p>
|
||||
}
|
||||
<div v-if="account">
|
||||
<p class="alert alert-danger" v-if="account.assetBalanceExceptionMessage">
|
||||
{{ account.assetBalanceExceptionMessage }}
|
||||
</p>
|
||||
|
||||
<h2>Balances</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-responsive-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
@if (Model.AssetBalances != null && Model.AssetBalances.Count > 0)
|
||||
{
|
||||
@foreach (var pair in Model.AssetBalances.OrderBy(key => key.Key))
|
||||
{
|
||||
<h2>Balances</h2>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="hideDustAmounts" id="flexCheckDefault">
|
||||
<label class="form-check-label" for="flexCheckDefault" >
|
||||
Hide holdings worth less than {{ account.dustThresholdInFiat }} {{ account.storeDefaultFiat }}.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-responsive-md">
|
||||
<thead>
|
||||
<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? -->
|
||||
<th class="text-end">@pair.Value.Qty</th>
|
||||
<th class="text-end">{{ row.formattedQty }}</th>
|
||||
<th class="text-end">
|
||||
@pair.Value.FormattedBid
|
||||
{{ row.formattedBid }}
|
||||
</th>
|
||||
<th class="text-end">
|
||||
@pair.Value.FormattedAsk
|
||||
{{ row.formattedAsk }}
|
||||
</th>
|
||||
<th class="text-end">
|
||||
@(pair.Value.FormattedFiatValue)
|
||||
{{ row.formattedFiatValue }}
|
||||
</th>
|
||||
<th class="text-end">
|
||||
@if (pair.Value.TradableAssetPairs != null)
|
||||
{
|
||||
<a data-bs-toggle="modal" data-bs-target="#tradeModal" href="#">Trade</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>
|
||||
}
|
||||
<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 v-if="row.canWithdraw" v-on:click="openWithdrawModal(row)" href="#">Withdraw</a>
|
||||
</th>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
else if (Model.GetAssetBalanceException == null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="999" class="text-center">No assets are stored with this custodian (yet).</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr>
|
||||
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@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>
|
||||
|
||||
|
||||
<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 class="modal" tabindex="-1" role="dialog" id="withdrawModal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Withdraw</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<vc:icon symbol="close"/>
|
||||
</button>
|
||||
<div class="modal" tabindex="-1" role="dialog" id="withdrawModal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Withdraw</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>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 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 class="modal" tabindex="-1" role="dialog" id="depositModal">
|
||||
<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 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">
|
||||
|
||||
<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 class="modal" tabindex="-1" role="dialog" id="depositModal">
|
||||
<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 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>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script type="text/javascript">
|
||||
var ajaxBalanceUrl = "@Url.Action("ViewCustodianAccountAjax", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
|
||||
var ajaxTradePrepareUrl = "@Url.Action("GetTradePrepareAjax", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
|
||||
</script>
|
||||
<script src="~/js/custodian-account.js" asp-append-version="true"></script>
|
||||
|
|
372
BTCPayServer/wwwroot/js/custodian-account.js
Normal file
372
BTCPayServer/wwwroot/js/custodian-account.js
Normal 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
|
||||
}
|
||||
});
|
|
@ -528,3 +528,23 @@ svg.icon-note {
|
|||
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; }
|
||||
|
|
|
@ -637,19 +637,38 @@
|
|||
},
|
||||
"tradableAssetPairs": {
|
||||
"type": "array",
|
||||
"description": "A list of tradable asset pairs, or NULL if the custodian cannot trades/convert assets.",
|
||||
"nullable": true
|
||||
"description": "A list of tradable asset pair objects, or NULL if the custodian cannot trades/convert assets.",
|
||||
"nullable": true,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetPairData"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"code": "kraken",
|
||||
"name": "Kraken",
|
||||
"tradableAssetPairs": [
|
||||
"BTC/USD",
|
||||
"BTC/EUR",
|
||||
"LTC/USD",
|
||||
"LTC/EUR"
|
||||
],
|
||||
"tradableAssetPairs": {
|
||||
"BTC/USD": {
|
||||
"assetBought": "BTC",
|
||||
"assetSold": "USD",
|
||||
"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": [
|
||||
"BTC-OnChain",
|
||||
"LTC-OnChain"
|
||||
|
@ -1023,6 +1042,27 @@
|
|||
"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": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
Loading…
Add table
Reference in a new issue