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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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 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;
}
}

View file

@ -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

View file

@ -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
{

View file

@ -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;

View file

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

View file

@ -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);
}

View file

@ -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;

View file

@ -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; }

View file

@ -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

View file

@ -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;
}

View file

@ -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>

View file

@ -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",

View file

@ -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)

View file

@ -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>(

View file

@ -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();
}
}

View file

@ -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; }

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.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; }
}
}

View file

@ -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>

View file

@ -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" />

View file

@ -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"))" />

View file

@ -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">
&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 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>

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;
}
}
#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": {
"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": [