Custodian withdrawal support + Some refactoring and cleanup (#4085)

* Renamed "WithdrawAsync" to "WithdrawToStoreWalletAsync"

* WIP

* WIP withdrawal + Refactored Form saving to JObject

* WIP

* Form to fix bad values during withdrawing appears correctly

* WIP

* Lots of cleanup and refactoring + Password field and toggle password view

* Cleanup + Finishing touches on withdrawals

* Added "Destination" dummy text as this is always the destination.

* Fixed broken test

* Added support for withdrawing using qty as a percentage if it ends with "%". Needs more testing.

* Fixed broken build

* Fixed broken build (2)

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.custodians.json

Co-authored-by: d11n <mail@dennisreimann.de>

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.custodians.json

Co-authored-by: d11n <mail@dennisreimann.de>

* Improved unit tests

* Fixed swagger bug

* Test improvements

Make string conversion of quantity explicitely.

* Fix build warnings

* Swagger: Add missing operationId

* Made change Dennis requested

* Removed unused file

* Removed incorrect comment

* Extra contructor

* Renamed client methods

* Cleanup config before saving

* Fixed broken controller

* Refactor custodian

* Fix build

* Make decimal fields strings to match the rest of Greenfield

* Improve parsing of % quantities

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Wouter Samaey 2023-03-20 02:45:32 +01:00 committed by GitHub
parent b26679ca14
commit 6f2b673021
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1291 additions and 319 deletions

View File

@ -5,4 +5,8 @@ public class AssetBalancesUnavailableException : CustodianApiException
public AssetBalancesUnavailableException(System.Exception e) : base(500, "asset-balances-unavailable", $"Cannot fetch the asset balances: {e.Message}", e)
{
}
public AssetBalancesUnavailableException(string errorMsg) : base(500, "asset-balances-unavailable", $"Cannot fetch the asset balances: {errorMsg}")
{
}
}

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.JsonConverters;
namespace BTCPayServer.Abstractions.Custodians.Client;
public class SimulateWithdrawalResult
{
public string PaymentMethod { get; }
public string Asset { get; }
public decimal MinQty { get; }
public decimal MaxQty { get; }
public List<LedgerEntryData> LedgerEntries { get; }
// Fee can be NULL if unknown.
public decimal? Fee { get; }
public SimulateWithdrawalResult(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries,
decimal minQty, decimal maxQty)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
MinQty = minQty;
MaxQty = maxQty;
}
}

View File

@ -5,9 +5,14 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
/// <summary>
/// Interface for custodians that can move funds to the store wallet.
/// </summary>
public interface ICanWithdraw
{
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken);
public Task<WithdrawResult> WithdrawToStoreWalletAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken);
public Task<SimulateWithdrawalResult> SimulateWithdrawalAsync(string paymentMethod, decimal qty, JObject config, CancellationToken cancellationToken);
public Task<WithdrawResult> GetWithdrawalInfoAsync(string paymentMethod, string withdrawalId, JObject config, CancellationToken cancellationToken);

View File

@ -20,7 +20,6 @@ public interface ICustodian
*/
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
public Task<Form.Form> GetConfigForm(JObject config, string locale,
CancellationToken cancellationToken = default);
public Task<Form.Form> GetConfigForm(CancellationToken cancellationToken = default);
}

View File

@ -28,7 +28,7 @@ public class Field
public bool Constant;
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
// HTML5 compatible type string like "text", "textarea", "email", "password", etc.
public string Type;
public static Field CreateFieldset()

View File

@ -50,7 +50,7 @@ namespace BTCPayServer.Client
await HandleResponse(response);
}
public virtual async Task<DepositAddressData> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken token = default)
public virtual async Task<DepositAddressData> GetCustodianAccountDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/addresses/{paymentMethod}"), token);
return await HandleResponse<DepositAddressData>(response);
@ -58,7 +58,6 @@ namespace BTCPayServer.Client
public virtual async Task<MarketTradeResponseData> MarketTradeCustodianAccountAsset(string storeId, string accountId, TradeRequestData request, CancellationToken token = default)
{
//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,
@ -67,13 +66,13 @@ namespace BTCPayServer.Client
return await HandleResponse<MarketTradeResponseData>(response);
}
public virtual async Task<MarketTradeResponseData> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken token = default)
public virtual async Task<MarketTradeResponseData> GetCustodianAccountTradeInfo(string storeId, string accountId, string tradeId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/{tradeId}", method: HttpMethod.Get), token);
return await HandleResponse<MarketTradeResponseData>(response);
}
public virtual async Task<TradeQuoteResponseData> GetTradeQuote(string storeId, string accountId, string fromAsset, string toAsset, CancellationToken token = default)
public virtual async Task<TradeQuoteResponseData> GetCustodianAccountTradeQuote(string storeId, string accountId, string fromAsset, string toAsset, CancellationToken token = default)
{
var queryPayload = new Dictionary<string, object>();
queryPayload.Add("fromAsset", fromAsset);
@ -81,14 +80,20 @@ namespace BTCPayServer.Client
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token);
return await HandleResponse<TradeQuoteResponseData>(response);
}
public virtual async Task<WithdrawalResponseData> CreateWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
public virtual async Task<WithdrawalResponseData> CreateCustodianAccountWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<WithdrawalResponseData>(response);
}
public virtual async Task<WithdrawalSimulationResponseData> SimulateCustodianAccountWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/simulation", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<WithdrawalSimulationResponseData>(response);
}
public virtual async Task<WithdrawalResponseData> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken token = default)
public virtual async Task<WithdrawalResponseData> GetCustodianAccountWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/{paymentMethod}/{withdrawalId}", method: HttpMethod.Get), token);
return await HandleResponse<WithdrawalResponseData>(response);

View File

@ -0,0 +1,29 @@
using System;
using System.Globalization;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.JsonConverters
{
public class TradeQuantityJsonConverter : JsonConverter<TradeQuantity>
{
public override TradeQuantity ReadJson(JsonReader reader, Type objectType, TradeQuantity existingValue, bool hasExistingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
if (reader.TokenType != JsonToken.String)
throw new JsonObjectException("Invalid TradeQuantity, expected string. Expected: \"1.50\" or \"50%\"", reader);
if (TradeQuantity.TryParse((string)reader.Value, out var q))
return q;
throw new JsonObjectException("Invalid format for TradeQuantity. Expected: \"1.50\" or \"50%\"", reader);
}
public override void WriteJson(JsonWriter writer, TradeQuantity value, JsonSerializer serializer)
{
if (value is not null)
writer.WriteValue(value.ToString());
}
}
}

View File

@ -1,3 +1,4 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@ -6,6 +7,7 @@ namespace BTCPayServer.Client.Models;
public class LedgerEntryData
{
public string Asset { get; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Qty { get; }
[JsonConverter(typeof(StringEnumConverter))]

View File

@ -1,8 +1,13 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class TradeQuoteResponseData
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Bid { get; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Ask { get; }
public string ToAsset { get; }
public string FromAsset { get; }

View File

@ -1,13 +1,85 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System;
using Newtonsoft.Json;
using System.Net.Http.Headers;
namespace BTCPayServer.Client.Models;
public class WithdrawRequestData
{
public string PaymentMethod { set; get; }
public decimal Qty { set; get; }
[JsonConverter(typeof(JsonConverters.TradeQuantityJsonConverter))]
public TradeQuantity Qty { set; get; }
public WithdrawRequestData(string paymentMethod, decimal qty)
public WithdrawRequestData()
{
}
public WithdrawRequestData(string paymentMethod, TradeQuantity qty)
{
PaymentMethod = paymentMethod;
Qty = qty;
}
}
#nullable enable
public record TradeQuantity
{
public TradeQuantity(decimal value, ValueType type)
{
Type = type;
Value = value;
}
public enum ValueType
{
Exact,
Percent
}
public ValueType Type { get; }
public decimal Value { get; set; }
public override string ToString()
{
if (Type == ValueType.Exact)
return Value.ToString(CultureInfo.InvariantCulture);
else
return Value.ToString(CultureInfo.InvariantCulture) + "%";
}
public static TradeQuantity Parse(string str)
{
if (!TryParse(str, out var r))
throw new FormatException("Invalid TradeQuantity");
return r;
}
public static bool TryParse(string str, [MaybeNullWhen(false)] out TradeQuantity quantity)
{
if (str is null)
throw new ArgumentNullException(nameof(str));
quantity = null;
str = str.Trim();
str = str.Replace(" ", "");
if (str.Length == 0)
return false;
if (str[^1] == '%')
{
if (!decimal.TryParse(str[..^1], NumberStyles.Any, CultureInfo.InvariantCulture, out var r))
return false;
if (r < 0.0m)
return false;
quantity = new TradeQuantity(r, TradeQuantity.ValueType.Percent);
}
else
{
if (!decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var r))
return false;
if (r < 0.0m)
return false;
quantity = new TradeQuantity(r, TradeQuantity.ValueType.Exact);
}
return true;
}
}

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models;
public abstract class WithdrawalBaseResponseData
{
public string Asset { get; }
public string PaymentMethod { get; }
public List<LedgerEntryData> LedgerEntries { get; }
public string AccountId { get; }
public string CustodianCode { get; }
public WithdrawalBaseResponseData(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries, string accountId,
string custodianCode)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
AccountId = accountId;
CustodianCode = custodianCode;
}
}

View File

@ -5,18 +5,13 @@ using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class WithdrawalResponseData
public class WithdrawalResponseData : WithdrawalBaseResponseData
{
public string Asset { get; }
public string PaymentMethod { get; }
public List<LedgerEntryData> LedgerEntries { get; }
public string WithdrawalId { get; }
public string AccountId { get; }
public string CustodianCode { get; }
[JsonConverter(typeof(StringEnumConverter))]
public WithdrawalStatus Status { get; }
public string WithdrawalId { get; }
public DateTimeOffset CreatedTime { get; }
public string TransactionId { get; }
@ -24,14 +19,10 @@ public class WithdrawalResponseData
public string TargetAddress { get; }
public WithdrawalResponseData(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries, string withdrawalId, string accountId,
string custodianCode, WithdrawalStatus status, DateTimeOffset createdTime, string targetAddress, string transactionId)
string custodianCode, WithdrawalStatus status, DateTimeOffset createdTime, string targetAddress, string transactionId) : base(paymentMethod, asset, ledgerEntries, accountId,
custodianCode)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
WithdrawalId = withdrawalId;
AccountId = accountId;
CustodianCode = custodianCode;
TargetAddress = targetAddress;
TransactionId = transactionId;
Status = status;

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class WithdrawalSimulationResponseData : WithdrawalBaseResponseData
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? MinQty { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? MaxQty { get; set; }
public WithdrawalSimulationResponseData(string paymentMethod, string asset, string accountId,
string custodianCode, List<LedgerEntryData> ledgerEntries, decimal? minQty, decimal? maxQty) : base(paymentMethod,
asset, ledgerEntries, accountId, custodianCode)
{
MinQty = minQty;
MaxQty = maxQty;
}
}

View File

@ -706,6 +706,31 @@ namespace BTCPayServer.Tests
Assert.Throws<ParsingException>(() => { parser.ParseOutputDescriptor("wpkh([8b60afd1/49h/0h/0h]xpub661MyMwAFXkMnyoBjyHndD3QwRbcGVBsTGeNZN6QGVHcfz4MPzBUxjSevweNFQx7SqmMHLdSA4FteGsRrEriu4pnVZMZWnruFFAYZATtcDw/0/*)#9x4vkw48"); }); // invalid checksum
}
[Fact]
public void ParseTradeQuantity()
{
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.2345o"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("o"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse(""));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.353%%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.353 %%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("-1.353%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("-1.353"));
var qty = TradeQuantity.Parse("1.3%");
Assert.Equal(1.3m, qty.Value);
Assert.Equal(TradeQuantity.ValueType.Percent, qty.Type);
var qty2 = TradeQuantity.Parse("1.3");
Assert.Equal(1.3m, qty2.Value);
Assert.Equal(TradeQuantity.ValueType.Exact, qty2.Type);
Assert.NotEqual(qty, qty2);
Assert.Equal(qty, TradeQuantity.Parse("1.3%"));
Assert.Equal(qty2, TradeQuantity.Parse("1.3"));
Assert.Equal(TradeQuantity.Parse(qty.ToString()), TradeQuantity.Parse("1.3%"));
Assert.Equal(TradeQuantity.Parse(qty2.ToString()), TradeQuantity.Parse("1.3"));
Assert.Equal(TradeQuantity.Parse(qty2.ToString()), TradeQuantity.Parse(" 1.3 "));
}
[Fact]
public void ParseDerivationSchemeSettings()
{

View File

@ -3940,8 +3940,7 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
var withdrawalClient = await admin.CreateClient(Policies.CanWithdrawFromCustodianAccounts);
var depositClient = await admin.CreateClient(Policies.CanDepositToCustodianAccounts);
var tradeClient = await admin.CreateClient(Policies.CanTradeCustodianAccount);
var store = await adminClient.GetStore(admin.StoreId);
var storeId = store.Id;
@ -3981,22 +3980,22 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// Test: GetDepositAddress, unauth
await AssertHttpError(401, async () => await unauthClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong payment method
await AssertHttpError(400, async () => await depositClient.GetDepositAddress(storeId, accountId, "WRONG-PaymentMethod"));
await AssertApiError( 400, "unsupported-payment-method", async () => await depositClient.GetCustodianAccountDepositAddress(storeId, accountId, "WRONG-PaymentMethod"));
// Test: GetDepositAddress, wrong store ID
await AssertHttpError(403, async () => await depositClient.GetDepositAddress("WRONG-STORE", accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(403, async () => await depositClient.GetCustodianAccountDepositAddress("WRONG-STORE", accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong account ID
await AssertHttpError(404, async () => await depositClient.GetDepositAddress(storeId, "WRONG-ACCOUNT-ID", MockCustodian.DepositPaymentMethod));
await AssertHttpError(404, async () => await depositClient.GetCustodianAccountDepositAddress(storeId, "WRONG-ACCOUNT-ID", MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, correct payment method
var depositAddress = await depositClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod);
var depositAddress = await depositClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod);
Assert.NotNull(depositAddress);
Assert.Equal(MockCustodian.DepositAddress, depositAddress.Address);
@ -4054,13 +4053,13 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// Test: GetTradeQuote, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeQuote, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeQuote, auth, correct permission
var tradeQuote = await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset);
var tradeQuote = await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset);
Assert.NotNull(tradeQuote);
Assert.Equal(MockCustodian.TradeFromAsset, tradeQuote.FromAsset);
Assert.Equal(MockCustodian.TradeToAsset, tradeQuote.ToAsset);
@ -4068,30 +4067,30 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(MockCustodian.BtcPriceInEuro, tradeQuote.Ask);
// Test: GetTradeQuote, SATS
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "SATS"));
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "SATS"));
// Test: GetTradeQuote, wrong asset
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, accountId, "WRONG-ASSET", MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "WRONG-ASSET"));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, "WRONG-ASSET", MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset , "WRONG-ASSET"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.GetTradeQuote("WRONG-STORE-ID", accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(403, async () => await tradeClient.GetCustodianAccountTradeQuote("WRONG-STORE-ID", accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, correct permission
var tradeResult = await tradeClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId);
var tradeResult = await tradeClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId);
Assert.NotNull(tradeResult);
Assert.Equal(accountId, tradeResult.AccountId);
Assert.Equal(mockCustodian.Code, tradeResult.CustodianCode);
@ -4111,66 +4110,93 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, tradeResult.LedgerEntries[2].Type);
// Test: GetTradeInfo, wrong trade ID
await AssertHttpError(404, async () => await tradeClient.GetTradeInfo(storeId, accountId, "WRONG-TRADE-ID"));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeInfo(storeId, accountId, "WRONG-TRADE-ID"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeId));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeId));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.GetTradeInfo("WRONG-STORE-ID", accountId, MockCustodian.TradeId));
await AssertHttpError(403, async () => await tradeClient.GetCustodianAccountTradeInfo("WRONG-STORE-ID", accountId, MockCustodian.TradeId));
var qty = new TradeQuantity(MockCustodian.WithdrawalAmount, TradeQuantity.ValueType.Exact);
// Test: SimulateWithdrawal, unauth
var simulateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
await AssertHttpError(401, async () => await unauthClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, correct payment method, correct amount
var simulateWithdrawResponse = await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest);
AssertMockWithdrawal(simulateWithdrawResponse, custodianAccountData);
// Test: SimulateWithdrawal, wrong payment method
var wrongPaymentMethodSimulateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", qty);
await AssertApiError( 400, "unsupported-payment-method", async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, wrongPaymentMethodSimulateWithdrawalRequest));
// Test: SimulateWithdrawal, wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, "WRONG-ACCOUNT-ID", simulateWithdrawalRequest));
// Test: SimulateWithdrawal, wrong store ID
// TODO it is wierd that 403 is considered normal, but it is like this for all calls where the store is wrong... I'd have preferred a 404 error, because the store cannot be found.
await AssertHttpError(403, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal( "WRONG-STORE-ID",accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, correct payment method, wrong amount
var wrongAmountSimulateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, TradeQuantity.Parse("0.666"));
await AssertHttpError(400, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, wrongAmountSimulateWithdrawalRequest));
// Test: CreateWithdrawal, unauth
var createWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalAmount);
await AssertHttpError(401, async () => await unauthClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
var createWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
var createWithdrawalRequestPercentage = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
await AssertHttpError(401, async () => await unauthClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
await AssertHttpError(403, async () => await managerClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, correct payment method, correct amount
var withdrawResponse = await withdrawalClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest);
var withdrawResponse = await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest);
AssertMockWithdrawal(withdrawResponse, custodianAccountData);
// Test: CreateWithdrawal, correct payment method, correct amount, but as a percentage
var withdrawWithPercentageResponse = await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequestPercentage);
AssertMockWithdrawal(withdrawWithPercentageResponse, custodianAccountData);
// Test: CreateWithdrawal, wrong payment method
var wrongPaymentMethodCreateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", MockCustodian.WithdrawalAmount);
await AssertHttpError(403, async () => await withdrawalClient.CreateWithdrawal(storeId, accountId, wrongPaymentMethodCreateWithdrawalRequest));
var wrongPaymentMethodCreateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", qty);
await AssertApiError( 400, "unsupported-payment-method", async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, wrongPaymentMethodCreateWithdrawalRequest));
// Test: CreateWithdrawal, wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.CreateWithdrawal(storeId, "WRONG-ACCOUNT-ID", createWithdrawalRequest));
await AssertHttpError(404, async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, "WRONG-ACCOUNT-ID", createWithdrawalRequest));
// Test: CreateWithdrawal, wrong store ID
// TODO it is wierd that 403 is considered normal, but it is like this for all calls where the store is wrong... I'd have preferred a 404 error, because the store cannot be found.
await AssertHttpError(403, async () => await withdrawalClient.CreateWithdrawal("WRONG-STORE-ID", accountId, createWithdrawalRequest));
await AssertHttpError(403, async () => await withdrawalClient.CreateCustodianAccountWithdrawal( "WRONG-STORE-ID",accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, correct payment method, wrong amount
var wrongAmountCreateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, new decimal(0.666));
await AssertHttpError(400, async () => await withdrawalClient.CreateWithdrawal(storeId, accountId, wrongAmountCreateWithdrawalRequest));
var wrongAmountCreateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, TradeQuantity.Parse("0.666"));
await AssertHttpError(400, async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, wrongAmountCreateWithdrawalRequest));
// Test: GetWithdrawalInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: GetWithdrawalInfo, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: GetWithdrawalInfo, auth, correct permission
var withdrawalInfo = await withdrawalClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId);
var withdrawalInfo = await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId);
AssertMockWithdrawal(withdrawalInfo, custodianAccountData);
// Test: GetWithdrawalInfo, wrong withdrawal ID
await AssertHttpError(404, async () => await withdrawalClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, "WRONG-WITHDRAWAL-ID"));
await AssertHttpError(404, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, "WRONG-WITHDRAWAL-ID"));
// Test: wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.GetWithdrawalInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(404, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: wrong store ID
// TODO shouldn't this be 404? I cannot change this without bigger impact, as it would affect all API endpoints that are store centered
await AssertHttpError(403, async () => await withdrawalClient.GetWithdrawalInfo("WRONG-STORE-ID", accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(403, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo("WRONG-STORE-ID", accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// TODO assert API error codes, not just status codes by using AssertCustodianApiError()
// TODO also test withdrawals for the various "Status" (Queued, Complete, Failed)
// TODO create a mock custodian with only ICustodian
// TODO create a mock custodian with only ICustodian + ICanWithdraw
@ -4178,12 +4204,11 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// TODO create a mock custodian with only ICustodian + ICanDeposit
}
private void AssertMockWithdrawal(WithdrawalResponseData withdrawResponse, CustodianAccountData account)
private void AssertMockWithdrawal(WithdrawalBaseResponseData withdrawResponse, CustodianAccountData account)
{
Assert.NotNull(withdrawResponse);
Assert.Equal(MockCustodian.WithdrawalAsset, withdrawResponse.Asset);
Assert.Equal(MockCustodian.WithdrawalPaymentMethod, withdrawResponse.PaymentMethod);
Assert.Equal(MockCustodian.WithdrawalStatus, withdrawResponse.Status);
Assert.Equal(account.Id, withdrawResponse.AccountId);
Assert.Equal(account.CustodianCode, withdrawResponse.CustodianCode);
@ -4197,10 +4222,20 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(MockCustodian.WithdrawalFee, withdrawResponse.LedgerEntries[1].Qty);
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, withdrawResponse.LedgerEntries[1].Type);
Assert.Equal(MockCustodian.WithdrawalTargetAddress, withdrawResponse.TargetAddress);
Assert.Equal(MockCustodian.WithdrawalTransactionId, withdrawResponse.TransactionId);
Assert.Equal(MockCustodian.WithdrawalId, withdrawResponse.WithdrawalId);
Assert.NotEqual(default, withdrawResponse.CreatedTime);
if (withdrawResponse is WithdrawalResponseData withdrawalResponseData)
{
Assert.Equal(MockCustodian.WithdrawalStatus, withdrawalResponseData.Status);
Assert.Equal(MockCustodian.WithdrawalTargetAddress, withdrawalResponseData.TargetAddress);
Assert.Equal(MockCustodian.WithdrawalTransactionId, withdrawalResponseData.TransactionId);
Assert.Equal(MockCustodian.WithdrawalId, withdrawalResponseData.WithdrawalId);
Assert.NotEqual(default, withdrawalResponseData.CreatedTime);
}
if (withdrawResponse is WithdrawalSimulationResponseData withdrawalSimulationResponseData)
{
Assert.Equal(MockCustodian.WithdrawalMinAmount, withdrawalSimulationResponseData.MinQty);
Assert.Equal(MockCustodian.WithdrawalMaxAmount, withdrawalSimulationResponseData.MaxQty);
}
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians;
@ -24,6 +25,9 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
public const string WithdrawalAsset = "BTC";
public const string WithdrawalId = "WITHDRAWAL-ID-001";
public static readonly decimal WithdrawalAmount = new decimal(0.5);
public static readonly string WithdrawalAmountPercentage = "12.5%";
public static readonly decimal WithdrawalMinAmount = new decimal(0.001);
public static readonly decimal WithdrawalMaxAmount = new decimal(0.6);
public static readonly decimal WithdrawalFee = new decimal(0.0005);
public const string WithdrawalTransactionId = "yyy";
public const string WithdrawalTargetAddress = "bc1qyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";
@ -52,7 +56,7 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
return Task.FromResult(r);
}
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
public Task<Form> GetConfigForm(CancellationToken cancellationToken = default)
{
return null;
}
@ -135,14 +139,38 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
var r = new WithdrawResult(WithdrawalPaymentMethod, WithdrawalAsset, ledgerEntries, WithdrawalId, WithdrawalStatus, createdTime, WithdrawalTargetAddress, WithdrawalTransactionId);
return r;
}
private SimulateWithdrawalResult CreateWithdrawSimulationResult()
{
var ledgerEntries = new List<LedgerEntryData>();
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalAmount - WithdrawalFee, LedgerEntryData.LedgerEntryType.Withdrawal));
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalFee, LedgerEntryData.LedgerEntryType.Fee));
var r = new SimulateWithdrawalResult(WithdrawalPaymentMethod, WithdrawalAsset, ledgerEntries, WithdrawalMinAmount, WithdrawalMaxAmount);
return r;
}
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
public Task<WithdrawResult> WithdrawToStoreWalletAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod == WithdrawalPaymentMethod)
{
if (amount.ToString(CultureInfo.InvariantCulture).Equals(""+WithdrawalAmount, StringComparison.InvariantCulture) || WithdrawalAmountPercentage.Equals(amount))
{
return Task.FromResult(CreateWithdrawResult());
}
throw new InsufficientFundsException($"{Name} only supports withdrawals of {WithdrawalAmount} or {WithdrawalAmountPercentage}");
}
throw new CannotWithdrawException(this, paymentMethod, $"Only {WithdrawalPaymentMethod} can be withdrawn from {Name}");
}
public Task<SimulateWithdrawalResult> SimulateWithdrawalAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod == WithdrawalPaymentMethod)
{
if (amount == WithdrawalAmount)
{
return Task.FromResult(CreateWithdrawResult());
return Task.FromResult(CreateWithdrawSimulationResult());
}
throw new InsufficientFundsException($"{Name} only supports withdrawals of {WithdrawalAmount}");

View File

@ -13,6 +13,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services.Custodian;
using BTCPayServer.Services.Custodian.Client;
@ -20,6 +21,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
@ -221,6 +223,12 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanDeposit depositableCustodian)
{
var pm = PaymentMethodId.TryParse(paymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var result = await depositableCustodian.GetDepositAddressAsync(paymentMethod, config, cancellationToken);
return Ok(result);
}
@ -338,6 +346,44 @@ namespace BTCPayServer.Controllers.Greenfield
$"Fetching past trade info on \"{custodian.Name}\" is not supported.");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/simulation")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> SimulateWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian)
{
var pm = PaymentMethodId.TryParse(request.PaymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var asset = pm.CryptoCode;
decimal qty;
try
{
qty = await ParseQty(request.Qty, asset, custodianAccount, custodian, cancellationToken);
}
catch (Exception ex)
{
return UnsupportedAsset(asset, ex.Message);
}
var simulateWithdrawResult =
await withdrawableCustodian.SimulateWithdrawalAsync(request.PaymentMethod, qty, custodianAccount.GetBlob(), cancellationToken);
var result = new WithdrawalSimulationResponseData(simulateWithdrawResult.PaymentMethod, simulateWithdrawResult.Asset,
accountId, custodian.Code, simulateWithdrawResult.LedgerEntries, simulateWithdrawResult.MinQty, simulateWithdrawResult.MaxQty);
return Ok(result);
}
return this.CreateAPIError(400, "withdrawals-not-supported",
$"Withdrawals are not supported for \"{custodian.Name}\".");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
@ -350,8 +396,25 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanWithdraw withdrawableCustodian)
{
var pm = PaymentMethodId.TryParse(request.PaymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var asset = pm.CryptoCode;
decimal qty;
try
{
qty = await ParseQty(request.Qty, asset, custodianAccount, custodian, cancellationToken);
}
catch (Exception ex)
{
return UnsupportedAsset(asset, ex.Message);
}
var withdrawResult =
await withdrawableCustodian.WithdrawAsync(request.PaymentMethod, request.Qty, custodianAccount.GetBlob(), cancellationToken);
await withdrawableCustodian.WithdrawToStoreWalletAsync(request.PaymentMethod, qty, custodianAccount.GetBlob(), cancellationToken);
var result = new WithdrawalResponseData(withdrawResult.PaymentMethod, withdrawResult.Asset, withdrawResult.LedgerEntries,
withdrawResult.WithdrawalId, accountId, custodian.Code, withdrawResult.Status, withdrawResult.CreatedTime, withdrawResult.TargetAddress, withdrawResult.TransactionId);
return Ok(result);
@ -361,6 +424,22 @@ namespace BTCPayServer.Controllers.Greenfield
$"Withdrawals are not supported for \"{custodian.Name}\".");
}
private IActionResult UnsupportedAsset(string asset, string err)
{
return this.CreateAPIError(400, "invalid-qty", $"It is impossible to use % quantity with this asset ({err})");
}
private async Task<decimal> ParseQty(TradeQuantity qty, string asset, CustodianAccountData custodianAccount, ICustodian custodian, CancellationToken cancellationToken = default)
{
if (qty.Type == TradeQuantity.ValueType.Exact)
return qty.Value;
// Percentage of current holdings => calculate the amount
var config = custodianAccount.GetBlob();
var balances = await custodian.GetAssetBalancesAsync(config, cancellationToken);
if (!balances.TryGetValue(asset, out var assetBalance))
return 0.0m;
return (assetBalance * qty.Value) / 100m;
}
async Task<CustodianAccountData> GetCustodianAccount(string storeId, string accountId)
{

View File

@ -223,6 +223,20 @@ namespace BTCPayServer.Controllers.Greenfield
return GetFromActionResult<MarketTradeResponseData>(
await GetController<GreenfieldCustodianAccountController>().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken));
}
public override async Task<WithdrawalSimulationResponseData> SimulateCustodianAccountWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<WithdrawalSimulationResponseData>(
await GetController<GreenfieldCustodianAccountController>().SimulateWithdrawal(storeId, accountId, request, cancellationToken));
}
public override async Task<WithdrawalResponseData> CreateCustodianAccountWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<WithdrawalResponseData>(
await GetController<GreenfieldCustodianAccountController>().CreateWithdrawal(storeId, accountId, request, cancellationToken));
}
public override async Task<OnChainWalletObjectData[]> GetOnChainWalletObjects(string storeId, string cryptoCode,
GetWalletObjectsRequest query = null,

View File

@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
using NLog.Config;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using StoreData = BTCPayServer.Data.StoreData;
@ -175,14 +176,14 @@ namespace BTCPayServer.Controllers
if (custodian is ICanWithdraw withdrawableCustodian)
{
var withdrawableePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
foreach (var withdrawableePaymentMethod in withdrawableePaymentMethods)
var withdrawablePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
foreach (var withdrawablePaymentMethod in withdrawablePaymentMethods)
{
var withdrawableAsset = withdrawableePaymentMethod.Split("-")[0];
var withdrawableAsset = withdrawablePaymentMethod.Split("-")[0];
if (assetBalances.ContainsKey(withdrawableAsset))
{
var assetBalance = assetBalances[withdrawableAsset];
assetBalance.CanWithdraw = true;
assetBalance.WithdrawablePaymentMethods.Add(withdrawablePaymentMethod);
}
}
}
@ -216,7 +217,8 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
var configForm = await custodian.GetConfigForm();
configForm.SetValues(custodianAccount.GetBlob());
var vm = new EditCustodianAccountViewModel();
vm.CustodianAccount = custodianAccount;
@ -228,9 +230,6 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId,
EditCustodianAccountViewModel vm)
{
// The locale is not important yet, but keeping it here so we can find it easily when localization becomes a thing.
var locale = "en-US";
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
@ -242,37 +241,22 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
var configForm = await custodian.GetConfigForm();
configForm.ApplyValuesFromForm(Request.Form);
var newData = new JObject();
foreach (var pair in Request.Form)
if (configForm.IsValid())
{
if ("CustodianAccount.Name".Equals(pair.Key))
{
custodianAccount.Name = pair.Value;
}
else
{
// TODO support posted array notation, like a field called "WithdrawToAddressNamePerPaymentMethod[BTC-OnChain]". The data should be nested in the JSON.
newData.Add(pair.Key, pair.Value.ToString());
}
}
var newConfigData = RemoveUnusedFieldsFromConfig(custodianAccount.GetBlob(), newData, configForm);
var newConfigForm = await custodian.GetConfigForm(newConfigData, locale);
if (newConfigForm.IsValid())
{
custodianAccount.SetBlob(newConfigData);
var newData = configForm.GetValues();
custodianAccount.SetBlob(newData);
custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// Form not valid: The user must fix the errors before we can save
vm.CustodianAccount = custodianAccount;
vm.ConfigForm = newConfigForm;
vm.ConfigForm = configForm;
return View(vm);
}
@ -313,16 +297,11 @@ namespace BTCPayServer.Controllers
};
var configData = new JObject();
foreach (var pair in Request.Form)
{
configData.Add(pair.Key, pair.Value.ToString());
}
var configForm = await custodian.GetConfigForm(configData, "en-US");
var configForm = await custodian.GetConfigForm();
configForm.ApplyValuesFromForm(Request.Form);
if (configForm.IsValid())
{
// configForm.removeUnusedKeys();
var configData = configForm.GetValues();
custodianAccountData.SetBlob(configData);
custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData);
TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created";
@ -358,37 +337,11 @@ namespace BTCPayServer.Controllers
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// The JObject may contain too much data because we used ALL post values and this may be more than we needed.
// Because we don't know the form fields beforehand, we will filter out the superfluous data afterwards.
// We will keep all the old keys + merge the new keys as per the current form.
// Since the form can differ by circumstances, we will never remove any keys that were previously stored. We just limit what we add.
private JObject RemoveUnusedFieldsFromConfig(JObject storedData, JObject newData, Form form)
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/trade/simulate")]
public async Task<IActionResult> SimulateTradeJson(string storeId, string accountId,
[FromBody] TradeRequestData request)
{
JObject filteredData = new JObject();
var storedKeys = new List<string>();
foreach (var item in storedData)
{
storedKeys.Add(item.Key);
}
var formKeys = form.GetAllFields().Select(f => f.FullName).ToHashSet();
foreach (var item in newData)
{
if (storedKeys.Contains(item.Key) || formKeys.Contains(item.Key))
{
filteredData[item.Key] = item.Value;
}
}
return filteredData;
}
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/trade/prepare")]
public async Task<IActionResult> GetTradePrepareJson(string storeId, string accountId,
[FromQuery] string assetToTrade, [FromQuery] string assetToTradeInto)
{
if (string.IsNullOrEmpty(assetToTrade) || string.IsNullOrEmpty(assetToTradeInto))
if (string.IsNullOrEmpty(request.FromAsset) || string.IsNullOrEmpty(request.ToAsset))
{
return BadRequest();
}
@ -422,12 +375,12 @@ namespace BTCPayServer.Controllers
foreach (var pair in assetBalancesData)
{
var oneAsset = pair.Key;
if (assetToTrade.Equals(oneAsset))
if (request.FromAsset.Equals(oneAsset))
{
vm.MaxQtyToTrade = pair.Value;
vm.MaxQty = pair.Value;
//vm.FormattedMaxQtyToTrade = pair.Value;
if (assetToTrade.Equals(assetToTradeInto))
if (request.FromAsset.Equals(request.ToAsset))
{
// We cannot trade the asset for itself
return BadRequest();
@ -435,7 +388,8 @@ namespace BTCPayServer.Controllers
try
{
var quote = await tradingCustodian.GetQuoteForAssetAsync(assetToTrade, assetToTradeInto,
var quote = await tradingCustodian.GetQuoteForAssetAsync(request.FromAsset,
request.ToAsset,
config, default);
// TODO Ask is normally a higher number than Bid!! Let's check this!! Maybe a Unit Test?
@ -575,6 +529,93 @@ namespace BTCPayServer.Controllers
return Request.GetRelativePathOrAbsolute(res);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/withdraw/simulate")]
public async Task<IActionResult> SimulateWithdrawJson(string storeId, string accountId,
[FromBody] WithdrawRequestData withdrawRequestData)
{
if (string.IsNullOrEmpty(withdrawRequestData.PaymentMethod))
{
return BadRequest();
}
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 vm = new WithdrawalPrepareViewModel();
try
{
if (custodian is ICanWithdraw withdrawableCustodian)
{
var config = custodianAccount.GetBlob();
try
{
var simulateWithdrawal =
await _btcPayServerClient.SimulateCustodianAccountWithdrawal(storeId, accountId, withdrawRequestData,
default);
vm = new WithdrawalPrepareViewModel(simulateWithdrawal);
// There are no bad config fields, so we need an empty array
vm.BadConfigFields = Array.Empty<string>();
}
catch (BadConfigException e)
{
Form configForm = await custodian.GetConfigForm();
configForm.SetValues(config);
string[] badConfigFields = new string[e.BadConfigKeys.Length];
int i = 0;
foreach (var oneField in configForm.GetAllFields())
{
foreach (var badConfigKey in e.BadConfigKeys)
{
if (oneField.FullName.Equals(badConfigKey))
{
var field = configForm.GetFieldByFullName(oneField.FullName);
badConfigFields[i] = field.Label;
i++;
}
}
}
vm.BadConfigFields = badConfigFields;
return Ok(vm);
}
}
}
catch (Exception e)
{
vm.ErrorMessage = e.Message;
}
return Ok(vm);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/withdraw")]
public async Task<IActionResult> Withdraw(string storeId, string accountId,
[FromBody] WithdrawRequestData request)
{
try
{
var result = await _btcPayServerClient.CreateCustodianAccountWithdrawal(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

@ -14,7 +14,8 @@ public class AssetBalanceInfo
public string FormattedFiatValue { get; set; }
public decimal FiatValue { get; set; }
public Dictionary<string, AssetPairData> TradableAssetPairs { get; set; }
public bool CanWithdraw { get; set; }
public List<string> WithdrawablePaymentMethods { get; set; } = new();
public string FormattedBid { get; set; }
public string FormattedAsk { get; set; }
}

View File

@ -4,6 +4,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels;
public class TradePrepareViewModel : AssetQuoteResult
{
public decimal MaxQtyToTrade { get; set; }
public decimal MaxQty { get; set; }
}

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Models.CustodianAccountViewModels;
public class WithdrawalPrepareViewModel : WithdrawalSimulationResponseData
{
public string ErrorMessage { get; set; }
public string[] BadConfigFields { get; set; }
public WithdrawalPrepareViewModel(string paymentMethod, string asset, string accountId, string custodianCode,
List<LedgerEntryData> ledgerEntries, decimal minQty, decimal maxQty) : base(paymentMethod, asset, accountId,
custodianCode, ledgerEntries, minQty, maxQty)
{
}
public WithdrawalPrepareViewModel(WithdrawalSimulationResponseData simulateWithdrawal) : base(
simulateWithdrawal.PaymentMethod, simulateWithdrawal.Asset, simulateWithdrawal.AccountId,
simulateWithdrawal.CustodianCode, simulateWithdrawal.LedgerEntries, simulateWithdrawal.MinQty,
simulateWithdrawal.MaxQty)
{
}
public WithdrawalPrepareViewModel() : base(null, null, null, null, null, null, null)
{
}
}

View File

@ -53,9 +53,12 @@
<partial name="_Form" model="Model.ConfigForm" />
}
<div class="form-group mt-4">
<input type="submit" value="Continue" class="btn btn-primary" id="Continue" />
</div>
@if (Model.Custodians.Count() > 0)
{
<div class="form-group mt-4">
<input type="submit" value="Continue" class="btn btn-primary" id="Continue"/>
</div>
}
</form>
</div>
</div>

View File

@ -59,13 +59,12 @@
<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">
<input class="form-check-input" type="checkbox" v-model="hideDustAmounts" id="hideDustAmounts">
<label class="form-check-label" for="hideDustAmounts">
Hide holdings worth less than {{ account.dustThresholdInFiat }} {{ account.storeDefaultFiat }}.
</label>
</div>
<div class="table-responsive">
<table class="table table-hover table-responsive-md">
<thead>
@ -95,12 +94,15 @@
<th class="text-end">
<a v-if="row.tradableAssetPairs" v-on:click="openTradeModal(row)" href="#">Trade</a>
<a v-if="canDepositAsset(row.asset)" v-on:click="openDepositModal(row)" href="#">Deposit</a>
<a v-if="row.canWithdraw" v-on:click="openWithdrawModal(row)" href="#">Withdraw</a>
<a v-if="row.withdrawablePaymentMethods.length" 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.assetBalances.length > 0 && sortedAssetRows.length === 0">
<td colspan="999" class="text-center">No holdings are worth more than {{ account.dustThresholdInFiat }} {{ account.storeDefaultFiat }}.</td>
</tr>
<tr v-if="account.assetBalanceExceptionMessage !== null">
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
</tr>
@ -108,14 +110,13 @@
</table>
</div>
<h2>Features</h2>
<p>The @Model?.Custodian.Name custodian supports:</p>
<ul>
<li>Viewing asset account</li>
<li>Viewing asset balances</li>
@if (Model?.Custodian is ICanTrade)
{
<li>Trading</li>
<li>Converting assets using market orders</li>
}
@if (Model?.Custodian is ICanDeposit)
{
@ -123,33 +124,163 @@
}
@if (Model?.Custodian is ICanWithdraw)
{
<li>Withdrawing (Greenfield API only, for now)</li>
<li>Withdrawing</li>
}
</ul>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="withdrawModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form class="modal-content" id="withdrawForm" v-on:submit="onWithdrawSubmit" method="post" asp-action="Withdraw" asp-route-accountId="@Model?.CustodianAccount?.Id" asp-route-storeId="@Model?.CustodianAccount?.StoreId">
<div class="modal-header">
<h5 class="modal-title">Withdraw</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<h5 class="modal-title">Withdraw {{ withdraw.qty}} {{ withdraw.asset }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" v-if="!withdraw.isExecuting">
<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 class="loading d-flex justify-content-center p-3" v-if="withdraw.isExecuting">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-if="!withdraw.isExecuting && withdraw.results === null">
<p v-if="withdraw.errorMsg" class="alert alert-danger">{{ withdraw.errorMsg }}</p>
<div v-if="withdraw.badConfigFields?.length > 0" class="alert alert-danger">
Please go to <a class="alert-link" asp-action="EditCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id">Configure</a> and fill out these fields to enable withdrawals for {{ withdraw.paymentMethod }}.
<ul>
<li v-for="badConfigField in withdraw.badConfigFields">
{{ badConfigField }}
</li>
</ul>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawAsset">Asset to Withdraw</label>
<select class="form-control" v-model="withdraw.asset" id="WithdrawAsset">
<option v-for="option in availableAssetsToWithdraw" v-bind:value="option">
{{ option }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawPaymentMethod">Payment Method</label>
<select class="form-control" v-model="withdraw.paymentMethod" id="WithdrawPaymentMethod">
<option v-for="option in availablePaymentMethodsToWithdraw" v-bind:value="option">
{{ option }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawDestination">Destination</label>
<select class="form-control" id="WithdrawDestination">
<option selected="selected">
Store wallet
</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawQty">Quantity</label>
<input type="number" :min="withdraw.minQty" :max="withdraw.maxQty" step="any" class="form-control" v-model="withdraw.qty" id="WithdrawQty"/>
</div>
<div class="form-group">
<div class="btn-group" role="group" aria-label="Set qty to a percentage of holdings">
<button v-on:click="setWithdrawQtyPercent(10)" class="btn btn-secondary" type="button">10%</button>
<button v-on:click="setWithdrawQtyPercent(25)" class="btn btn-secondary" type="button">25%</button>
<button v-on:click="setWithdrawQtyPercent(50)" class="btn btn-secondary" type="button">50%</button>
<button v-on:click="setWithdrawQtyPercent(75)" class="btn btn-secondary" type="button">75%</button>
<button v-on:click="setWithdrawQtyPercent(90)" class="btn btn-secondary" type="button">90%</button>
<button v-on:click="setWithdrawQtyPercent(100)" class="btn btn-secondary" type="button">100%</button>
</div>
</div>
<div v-if="withdraw.badConfigFields?.length === 0 && !withdraw.isUpdating">
<div class="form-group">
<span v-if="withdrawFees.length === 0">
No fee for this withdrawal
</span>
<span v-if="withdrawFees.length === 1">
Fee: {{ -1 * withdrawFees[0].qty}} {{ withdrawFees[0].asset}}
<span v-if="trade.priceForPair[withdrawFees[0].asset + '/' + account.storeDefaultFiat ]">
or {{ -1 * withdrawFees[0].qty * trade.priceForPair[withdrawFees[0].asset + '/' + account.storeDefaultFiat ] }} {{ account.storeDefaultFiat }}
</span>
</span>
<ul v-if="withdrawFees.length > 1">
<li v-for="entry in withdrawFees" v-if="entry.type === 'Fee'">
{{ entry.qty}} {{ entry.asset}}
</li>
</ul>
</div>
<p v-if="canExecuteWithdrawal">
After withdrawing {{ account.assetBalances[withdraw.asset].qty - withdraw.qty }} {{ withdraw.asset }} will remain in your account.
</p>
</div>
</div>
<div v-if="withdraw.results !== null">
<p class="alert alert-success" v-if="withdraw.results.status === 'Queued' || withdraw.results.status === 'Unknown'">
Successfully requested withdrawal.
</p>
<p class="alert alert-success" v-if="withdraw.results.status === 'Complete'">
Successfully completed withdrawal.
</p>
<p class="alert alert-danger" v-if="withdraw.results.status === 'Failed'">
Withdrawal failed.
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2">Asset</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in withdraw.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>
<!-- TODO Add "Copy to Clipboard" buttons -->
<div class="form-group">
<label class="form-label" for="WithdrawalId">Withdrawal ID</label>
<input type="text" class="form-control" v-model="withdraw.results.withdrawalId" id="WithdrawalId" readonly/>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawalTransactionId">Transaction ID</label>
<input type="text" class="form-control" v-model="withdraw.results.transactionId" id="WithdrawalTransactionId" readonly/>
</div>
<div class="form-group">
<label class="form-label" for="WithdrawalTargetAddress">Target Address</label>
<input type="text" class="form-control" v-model="withdraw.results.targetAddress" id="WithdrawalTargetAddress" readonly/>
</div>
</div>
</div>
<div class="modal-footer">
<div class="modal-footer" v-if="!withdraw.isExecuting">
<div class="modal-footer-left">
<span v-if="withdraw.isUpdating">
Updating...
</span>
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary" v-if="canExecuteWithdrawal">Withdraw</button>
</div>
</div>
</form>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="depositModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
@ -171,19 +302,19 @@
</div>
<div class="form-group">
<label class="form-label" for="DepositPaymentNetwork">Payment Method</label>
<select class="form-select" v-model="deposit.paymentMethod" name="DepositPaymentNetwork">
<select class="form-select" v-model="deposit.paymentMethod" id="DepositPaymentNetwork">
<option v-for="option in availablePaymentMethodsToDeposit" v-bind:value="option">
{{ option }}
</option>
</select>
</div>
<div class="loading d-flex justify-content-center p-3" v-if="deposit.isLoading">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-if="!deposit.isLoading && (deposit.link || deposit.address)">
<div class="tab-content text-center">
<div v-if="deposit.link" class="tab-pane" id="link-tab" role="tabpanel">
@ -232,11 +363,11 @@
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="tradeModal" :data-bs-keyboard="!trade.isExecuting">
<div class="modal" tabindex="-1" role="dialog" id="tradeModal">
<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">
<form class="modal-content" id="tradeForm" 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>
<h5 class="modal-title">Trade {{ trade.qty }} {{ trade.fromAsset }} into {{ trade.toAsset }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" v-if="!trade.isExecuting">
<vc:icon symbol="close"/>
</button>
@ -257,12 +388,12 @@
Convert
<div class="input-group has-validation">
<!--
getMinQtyToTrade() = {{ getMinQtyToTrade(this.trade.assetToTradeInto, this.trade.assetToTrade) }}
getMinQtyToTrade() = {{ getMinQtyToTrade(this.trade.toAsset, this.trade.fromAsset) }}
<br/>
Max Qty to Trade = {{ trade.maxQtyToTrade }}
Max Qty to Trade = {{ trade.maxQty }}
-->
<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-select">
<input name="Qty" type="number" min="0" step="any" :max="trade.maxQty" :min="getMinQtyToTrade()" class="form-control qty" v-bind:class="{ 'is-invalid': trade.qty < getMinQtyToTrade() || trade.qty > trade.maxQty }" v-model="trade.qty"/>
<select name="FromAsset" v-model="trade.fromAsset" class="form-select">
<option v-for="option in availableAssetsToTrade" v-bind:value="option">
{{ option }}
</option>
@ -282,7 +413,7 @@
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-select">
<select name="ToAsset" v-model="trade.toAsset" class="form-select">
<option v-for="option in availableAssetsToTradeInto" v-bind:value="option">
{{ option }}
</option>
@ -304,13 +435,13 @@
<p v-if="trade.price">
<br/>
1 {{ trade.assetToTradeInto }} = {{ trade.price }} {{ trade.assetToTrade }}
1 {{ trade.toAsset }} = {{ trade.price }} {{ trade.fromAsset }}
<br/>
1 {{ trade.assetToTrade }} = {{ 1 / trade.price }} {{ trade.assetToTradeInto }}
1 {{ trade.fromAsset }} = {{ 1 / trade.price }} {{ trade.toAsset }}
</p>
<p v-if="canExecuteTrade">
After the trade
{{ trade.maxQtyToTrade - trade.qty }} {{ trade.assetToTrade }} will remain in your account.
{{ trade.maxQty - trade.qty }} {{ trade.fromAsset }} will remain in your account.
</p>
<!--
@ -391,13 +522,11 @@
</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>
@ -413,7 +542,9 @@
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script type="text/javascript">
var ajaxBalanceUrl = "@Url.Action("ViewCustodianAccountJson", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
var ajaxTradePrepareUrl = "@Url.Action("GetTradePrepareJson", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
var ajaxTradeSimulateUrl = "@Url.Action("SimulateTradeJson", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
var ajaxDepositUrl = "@Url.Action("GetDepositPrepareJson", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
var ajaxWithdrawSimulateUrl = "@Url.Action("SimulateWithdrawJson", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
var ajaxWithdrawUrl = "@Url.Action("Withdraw", "UICustodianAccounts", new { storeId = Model?.CustodianAccount.StoreId, accountId = Model?.CustodianAccount.Id })";
</script>
<script src="~/js/custodian-account.js" asp-append-version="true"></script>

View File

@ -27,22 +27,36 @@ new Vue({
errorMsg: null,
isExecuting: false,
isUpdating: false,
updateTradePriceAbortController: new AbortController(),
simulationAbortController: null,
priceRefresherInterval: null,
assetToTrade: null,
assetToTradeInto: null,
fromAsset: null,
toAsset: null,
qty: null,
maxQtyToTrade: null,
maxQty: null,
price: null,
priceForPair: {}
}
},
withdraw: {
asset: null,
paymentMethod: null,
errorMsg: null,
qty: null,
minQty: null,
maxQty: null,
badConfigFields: null,
results: null,
isUpdating: null,
isExecuting: false,
simulationAbortController: null,
ledgerEntries: null
},
},
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;
return this.trade.qty >= this.getMinQtyToTrade() && this.trade.price !== null && this.trade.fromAsset !== null && this.trade.toAsset !== null && !this.trade.isExecuting && this.trade.results === null;
},
availableAssetsToTrade: function () {
let r = [];
@ -62,13 +76,13 @@ new Vue({
},
availableAssetsToTradeInto: function () {
let r = [];
let pairs = this.account?.assetBalances?.[this.trade.assetToTrade]?.tradableAssetPairs;
let pairs = this.account?.assetBalances?.[this.trade.fromAsset]?.tradableAssetPairs;
if (pairs) {
for (let i in pairs) {
let pair = pairs[i];
if (pair.assetBought === this.trade.assetToTrade) {
if (pair.assetBought === this.trade.fromAsset) {
r.push(pair.assetSold);
} else if (pair.assetSold === this.trade.assetToTrade) {
} else if (pair.assetSold === this.trade.fromAsset) {
r.push(pair.assetBought);
}
}
@ -119,23 +133,67 @@ new Vue({
return rows;
}
},
canExecuteWithdrawal: function () {
return (this.withdraw.minQty != null && this.withdraw.qty >= this.withdraw.minQty)
&& (this.withdraw.maxQty != null && this.withdraw.qty <= this.withdraw.maxQty)
&& this.withdraw.badConfigFields?.length === 0
&& this.withdraw.paymentMethod
&& !this.withdraw.isExecuting
&& !this.withdraw.isUpdating
&& this.withdraw.results === null;
},
availableAssetsToWithdraw: function () {
let r = [];
const balances = this?.account?.assetBalances;
if (balances) {
for (let asset in balances) {
const balance = balances[asset];
if (balance?.withdrawablePaymentMethods?.length) {
r.push(asset);
}
}
}
;
return r.sort();
},
availablePaymentMethodsToWithdraw: function () {
if (this.withdraw.asset) {
let paymentMethods = this?.account?.assetBalances?.[this.withdraw.asset]?.withdrawablePaymentMethods;
if (paymentMethods) {
return paymentMethods.sort();
}
}
return [];
},
withdrawFees: function(){
let r = [];
if(this.withdraw.ledgerEntries){
for (let i = 0; i< this.withdraw.ledgerEntries.length; i++){
let entry = this.withdraw.ledgerEntries[i];
if(entry.type === 'Fee'){
r.push(entry);
}
}
}
return r;
}
},
methods: {
getMaxQtyToTrade: function (assetToTrade) {
let row = this.account?.assetBalances?.[assetToTrade];
getMaxQty: function (fromAsset) {
let row = this.account?.assetBalances?.[fromAsset];
if (row) {
return row.qty;
}
return null;
},
getMinQtyToTrade: function (assetToTrade = this.trade.assetToTrade, assetToTradeInto = this.trade.assetToTradeInto) {
if (assetToTrade && assetToTradeInto && this.account?.assetBalances) {
getMinQtyToTrade: function (fromAsset = this.trade.fromAsset, toAsset = this.trade.toAsset) {
if (fromAsset && toAsset && 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 pairCode = fromAsset + "/" + toAsset;
let pairCodeReverse = toAsset + "/" + fromAsset;
let pair = row.tradableAssetPairs?.[pairCode];
let pairReverse = row.tradableAssetPairs?.[pairCodeReverse];
@ -144,7 +202,6 @@ new Vue({
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;
@ -161,22 +218,25 @@ new Vue({
return 0;
},
setTradeQtyPercent: function (percent) {
this.trade.qty = percent / 100 * this.trade.maxQtyToTrade;
this.trade.qty = percent / 100 * this.trade.maxQty;
},
setWithdrawQtyPercent: function (percent) {
this.withdraw.qty = percent / 100 * this.withdraw.maxQty;
},
openTradeModal: function (row) {
let _this = this;
this.trade.row = row;
this.trade.results = null;
this.trade.errorMsg = null;
this.trade.assetToTrade = row.asset;
this.trade.fromAsset = row.asset;
if (row.asset === this.account.storeDefaultFiat) {
this.trade.assetToTradeInto = "BTC";
this.trade.toAsset = "BTC";
} else {
this.trade.assetToTradeInto = this.account.storeDefaultFiat;
this.trade.toAsset = this.account.storeDefaultFiat;
}
this.trade.qty = row.qty;
this.trade.maxQtyToTrade = row.qty;
this.trade.maxQty = row.qty;
this.trade.price = row.bid;
if (this.modals.trade === null) {
@ -193,9 +253,20 @@ new Vue({
this.modals.trade.show();
},
openWithdrawModal: function (row) {
this.withdraw.asset = row.asset;
this.withdraw.qty = row.qty;
this.withdraw.paymentMethod = null;
this.withdraw.minQty = 0;
this.withdraw.maxQty = row.qty;
this.withdraw.results = null;
this.withdraw.errorMsg = null;
this.withdraw.isUpdating = null;
this.withdraw.isExecuting = false;
if (this.modals.withdraw === null) {
this.modals.withdraw = new window.bootstrap.Modal('#withdrawModal');
}
this.modals.withdraw.show();
},
openDepositModal: function (row) {
@ -204,10 +275,10 @@ new Vue({
}
if (row) {
this.deposit.asset = row.asset;
}else if(!this.deposit.asset && this.availableAssetsToDeposit.length > 0){
} else if (!this.deposit.asset && this.availableAssetsToDeposit.length > 0) {
this.deposit.asset = this.availableAssetsToDeposit[0];
}
this.modals.deposit.show();
},
onTradeSubmit: async function (e) {
@ -220,21 +291,18 @@ new Vue({
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;
this.setModalCanBeClosed(this.modals.trade, false);
const _this = this;
const token = this.getRequestVerificationToken();
const response = await fetch(url, {
method,
method: method,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
'RequestVerificationToken': this.getRequestVerificationToken()
},
body: JSON.stringify({
fromAsset: _this.trade.assetToTrade,
toAsset: _this.trade.assetToTradeInto,
fromAsset: _this.trade.fromAsset,
toAsset: _this.trade.toAsset,
qty: _this.trade.qty
})
});
@ -254,11 +322,53 @@ new Vue({
} else {
_this.trade.errorMsg = data && data.message || "Error";
}
_this.modals.trade._config.backdrop = true;
_this.modals.trade._config.keyboard = true;
_this.setModalCanBeClosed(_this.modals.trade, true);
_this.trade.isExecuting = false;
},
onWithdrawSubmit: async function (e) {
e.preventDefault();
const form = e.currentTarget;
const url = form.getAttribute('action');
const method = form.getAttribute('method');
this.withdraw.isExecuting = true;
this.setModalCanBeClosed(this.modals.withdraw, false);
let dataToSubmit = {
paymentMethod: this.withdraw.paymentMethod,
qty: this.withdraw.qty
};
const _this = this;
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': this.getRequestVerificationToken()
},
body: JSON.stringify(dataToSubmit)
});
let data = null;
try {
data = await response.json();
} catch (e) {
}
if (response.ok) {
_this.withdraw.results = data;
_this.withdraw.errorMsg = null;
_this.refreshAccountBalances();
} else {
_this.withdraw.errorMsg = data && data.message || "Error";
}
_this.setModalCanBeClosed(_this.modals.withdraw, true);
_this.withdraw.isExecuting = false;
},
setTradePriceRefresher: function (enabled) {
if (enabled) {
// Update immediately...
@ -276,12 +386,12 @@ new Vue({
},
updateTradePrice: function () {
if (!this.trade.assetToTrade || !this.trade.assetToTradeInto) {
if (!this.trade.fromAsset || !this.trade.toAsset) {
// We need to know the 2 assets or we cannot do anything...
return;
}
if (this.trade.assetToTrade === this.trade.assetToTradeInto) {
if (this.trade.fromAsset === this.trade.toAsset) {
// The 2 assets must be different
this.trade.price = null;
return;
@ -294,22 +404,23 @@ new Vue({
this.trade.isUpdating = true;
let dataToSubmit = {
fromAsset: this.trade.fromAsset,
toAsset: this.trade.toAsset,
qty: this.trade.qty
};
let url = window.ajaxTradeSimulateUrl;
this.trade.simulationAbortController = new AbortController();
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,
method: "POST",
body: JSON.stringify(dataToSubmit),
signal: this.trade.simulationAbortController.signal,
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'RequestVerificationToken': this.getRequestVerificationToken()
}
}
).then(function (response) {
@ -323,19 +434,19 @@ new Vue({
// Do nothing on error
}
).then(function (data) {
_this.trade.maxQtyToTrade = data.maxQtyToTrade;
_this.trade.maxQty = data.maxQty;
// By default trade everything
if (_this.trade.qty === null) {
_this.trade.qty = _this.trade.maxQtyToTrade;
_this.trade.qty = _this.trade.maxQty;
}
// Cannot trade more than what we have
if (data.maxQtyToTrade < _this.trade.qty) {
_this.trade.qty = _this.trade.maxQtyToTrade;
if (data.maxQty < _this.trade.qty) {
_this.trade.qty = _this.trade.maxQty;
}
let pair = data.fromAsset + "/" + data.toAsset;
let pairReverse = data.toAsset + "/" + data.fromAsset;
let pair = data.toAsset + "/" + data.fromAsset;
let pairReverse = data.fromAsset + "/" + data.toAsset;
// TODO Should we use "bid" in some cases? The spread can be huge with some shitcoins.
_this.trade.price = data.ask;
@ -364,27 +475,29 @@ new Vue({
return false;
},
canSwapTradeAssets: function () {
let minQtyToTrade = this.getMinQtyToTrade(this.trade.assetToTradeInto, this.trade.assetToTrade);
let assetToTradeIntoHoldings = this.account?.assetBalances?.[this.trade.assetToTradeInto];
let minQtyToTrade = this.getMinQtyToTrade(this.trade.toAsset, this.trade.fromAsset);
let assetToTradeIntoHoldings = this.account?.assetBalances?.[this.trade.toAsset];
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;
let tmp = this.trade.fromAsset;
this.trade.fromAsset = this.trade.toAsset;
this.trade.toAsset = tmp;
this.trade.price = 1 / this.trade.price;
this._refreshTradeDataAfterAssetChange();
this.refreshTradeSimulation();
},
_refreshTradeDataAfterAssetChange: function () {
let maxQtyToTrade = this.getMaxQtyToTrade(this.trade.assetToTrade);
this.trade.qty = maxQtyToTrade
this.trade.maxQtyToTrade = maxQtyToTrade;
refreshTradeSimulation: function () {
let maxQty = this.getMaxQty(this.trade.fromAsset);
this.trade.qty = maxQty
this.trade.maxQty = maxQty;
this.trade.updateTradePriceAbortController.abort();
if(this.trade.simulationAbortController) {
this.trade.simulationAbortController.abort();
}
// Update the price asap, so we can continue
let _this = this;
@ -398,24 +511,98 @@ new Vue({
return response.json();
}).then(function (result) {
_this.account = result;
for(let asset in _this.account.assetBalances){
let assetInfo = _this.account.assetBalances[asset];
if(asset !== _this.account.storeDefaultFiat) {
let pair1 = asset + '/' + _this.account.storeDefaultFiat;
_this.trade.priceForPair[pair1] = assetInfo.bid;
let pair2 = _this.account.storeDefaultFiat + '/' + asset;
_this.trade.priceForPair[pair2] = 1 / assetInfo.bid;
}
}
});
},
getRequestVerificationToken: function () {
return document.querySelector("input[name='__RequestVerificationToken']").value;
},
setModalCanBeClosed: function (modal, flag) {
modal._config.keyboard = flag;
if (flag) {
modal._config.backdrop = true;
} else {
modal._config.backdrop = 'static';
}
},
refreshWithdrawalSimulation: function () {
if(!this.withdraw.paymentMethod || !this.withdraw.qty){
// We are missing required data, stop now.
return;
}
if(this.withdraw.simulationAbortController) {
this.withdraw.simulationAbortController.abort();
}
let data = {
paymentMethod: this.withdraw.paymentMethod,
qty: this.withdraw.qty
};
const _this = this;
const token = this.getRequestVerificationToken();
this.withdraw.isUpdating = true;
this.withdraw.simulationAbortController = new AbortController();
fetch(window.ajaxWithdrawSimulateUrl, {
method: "POST",
body: JSON.stringify(data),
signal: this.withdraw.simulationAbortController.signal,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
}
}).then(function (response) {
_this.withdraw.isUpdating = false;
return response.json();
}).then(function (data) {
if (data.minQty === null) {
_this.withdraw.minQty = 0;
} else {
_this.withdraw.minQty = data.minQty;
}
if (data.maxQty === null) {
_this.withdraw.maxQty = _this.account.assetBalances?.[_this.withdraw.asset]?.qty;
} else {
_this.withdraw.maxQty = data.maxQty;
}
if (_this.withdraw.qty === null || _this.withdraw.qty > _this.withdraw.maxQty) {
_this.withdraw.qty = _this.withdraw.maxQty;
}
_this.withdraw.badConfigFields = data.badConfigFields;
_this.withdraw.errorMsg = data.errorMessage;
_this.withdraw.ledgerEntries = data.ledgerEntries;
});
},
getStoreDefaultFiatValueForAsset: function(asset){
// TODO
}
},
watch: {
'trade.assetToTrade': function (newValue, oldValue) {
if (newValue === this.trade.assetToTradeInto) {
'trade.fromAsset': function (newValue, oldValue) {
if (newValue === this.trade.toAsset) {
// This is the same as swapping the 2 assets
this.trade.assetToTradeInto = oldValue;
this.trade.toAsset = oldValue;
this.trade.price = 1 / this.trade.price;
this._refreshTradeDataAfterAssetChange();
this.refreshTradeSimulation();
}
if (newValue !== oldValue) {
// The qty is going to be wrong, so set to 100%
this.trade.qty = this.getMaxQtyToTrade(this.trade.assetToTrade);
this.trade.qty = this.getMaxQty(this.trade.fromAsset);
}
},
'deposit.asset': function (newValue, oldValue) {
@ -444,18 +631,39 @@ new Vue({
_this.deposit.createTransactionUrl = data.createTransactionUrl;
_this.deposit.cryptoImageUrl = data.cryptoImageUrl;
if(!_this.deposit.tab){
if (!_this.deposit.tab) {
_this.deposit.tab = 'address';
}
if(_this.deposit.tab === 'address' && !_this.deposit.address && _this.deposit.link){
if (_this.deposit.tab === 'address' && !_this.deposit.address && _this.deposit.link) {
// Tab "address" is not available, but tab "link" is.
_this.deposit.tab = 'link';
}
_this.deposit.errorMsg = data.errorMessage;
});
},
'withdraw.asset': function (newValue, oldValue) {
if (this.availablePaymentMethodsToWithdraw.length > 0) {
this.withdraw.paymentMethod = this.availablePaymentMethodsToWithdraw[0];
} else {
this.withdraw.paymentMethod = null;
}
},
'withdraw.paymentMethod': function (newValue, oldValue) {
if (this.withdraw.paymentMethod && this.withdraw.qty) {
this.withdraw.minQty = 0;
this.withdraw.maxQty = null;
this.withdraw.errorMsg = null;
this.withdraw.badConfigFields = null;
this.refreshWithdrawalSimulation();
}
},
'withdraw.qty': function (newValue, oldValue) {
if (newValue > this.withdraw.maxQty) {
this.withdraw.qty = this.withdraw.maxQty;
}
this.refreshWithdrawalSimulation();
}
},
created: function () {

View File

@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", () => {
delegate('click', 'button.toggle-password', e => {
const button = e.target.closest('button.toggle-password');
const input = button.previousSibling.previousSibling;
if(input.type === 'password'){
input.type = 'text';
button.querySelector('.shown-as-password').style.display = 'none';
button.querySelector('.shown-as-text').style.display = 'block';
}else{
input.type = 'password';
button.querySelector('.shown-as-text').style.display = 'none';
button.querySelector('.shown-as-password').style.display = 'block';
}
});
})

View File

@ -499,6 +499,109 @@
]
}
},
"/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/simulation": {
"post": {
"operationId": "Custodians_SimulateWithdrawFromStoreCustodianAccount",
"tags": [
"Custodians"
],
"summary": "Simulate a withdrawal",
"description": "Get more information about a potential withdrawal including fees, minimum and maximum quantities for the given asset and quantity.",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The Store ID",
"schema": {
"type": "string"
}
},
{
"name": "accountId",
"in": "path",
"required": true,
"description": "The Custodian Account ID.",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "query",
"required": true,
"description": "The payment method to be used for the withdrawal.",
"schema": {
"type": "string"
}
},
{
"name": "qty",
"in": "query",
"required": true,
"description": "The quantity to simulate a withdrawal for.",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WithdrawalRequestData"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Information about a potential withdrawal including fees, minimum and maximum quantities.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WithdrawalSimulationResultData"
}
}
}
},
"400": {
"description": "Withdrawal is not possible because you don't have this much in your account."
},
"404": {
"description": "Withdrawal is not possible for this payment method."
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "string",
"description": "If you are authenticated but forbidden to create withdrawals"
},
{
"type": "string",
"description": "Withdrawing to the address provided is not allowed"
}
]
}
}
}
}
},
"security": [
{
"API_Key": [
"btcpay.store.canwithdrawfromcustodianaccounts"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals": {
"post": {
"operationId": "Custodians_WithdrawFromStoreCustodianAccount",
@ -564,7 +667,7 @@
"oneOf": [
{
"type": "string",
"description": "If you are authenticated but forbidden to create trades"
"description": "If you are authenticated but forbidden to withdraw"
},
{
"type": "string",
@ -587,7 +690,7 @@
}
},
"/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/{withdrawalId}": {
"post": {
"get": {
"operationId": "Custodians_GetStoreCustodianAccountWithdrawalInfo",
"tags": [
"Custodians"
@ -779,11 +882,11 @@
"assetBalances": [
{
"asset": "BTC",
"qty": 1.23456
"qty": "1.23456"
},
{
"asset": "USD",
"qty": 123456.78
"qty": "123456.78"
}
],
"config": {
@ -849,12 +952,14 @@
"nullable": false
},
"bid": {
"type": "number",
"type": "string",
"format": "decimal",
"description": "The bid price.",
"nullable": false
},
"ask": {
"type": "number",
"type": "string",
"format": "decimal",
"description": "The ask price",
"nullable": false
}
@ -862,8 +967,8 @@
"example": {
"fromAsset": "USD",
"toAsset": "BTC",
"bid": 30000.12,
"ask": 30002.24
"bid": "30000.12",
"ask": "30002.24"
}
},
"TradeRequestData": {
@ -882,12 +987,15 @@
"qty": {
"oneOf": [
{
"type": "number",
"description": "The qty of fromAsset to convert into toAsset."
"type": "string",
"format": "decimal",
"description": "The qty of fromAsset to convert into toAsset.",
"example": "1.50"
},
{
"type": "string",
"description": "The percent of fromAsset to convert into toAsset. The value must end with \"%\" to be considered a percentage."
"description": "The percent of fromAsset to convert into toAsset. The value must end with \"%\" to be considered a percentage.",
"example": "50%"
}
],
"nullable": false
@ -939,22 +1047,22 @@
"ledgerEntries": [
{
"asset": "BTC",
"qty": 1.23456,
"qty": "1.23456",
"type": "Trade"
},
{
"asset": "USD",
"qty": -61728,
"qty": "-61728",
"type": "Trade"
},
{
"asset": "BTC",
"qty": -0.00123456,
"qty": "-0.00123456",
"type": "Fee"
},
{
"asset": "KFEE",
"qty": -123.456,
"qty": "-123.456",
"type": "Fee"
}
],
@ -972,14 +1080,22 @@
"nullable": false
},
"qty": {
"type": "number",
"description": "The qty to withdraw.",
"nullable": false
"oneOf": [
{
"type": "string",
"format": "decimal",
"description": "The quantity to withdraw."
},
{
"type": "string",
"description": "The percent of your holdings to withdraw. The value must end with \"%\" to be considered a percentage."
}
]
}
},
"example": {
"paymentMethod": "BTC-OnChain",
"qty": 0.123456
"qty": "0.123456"
}
},
"WithdrawalResultData": {
@ -1037,12 +1153,76 @@
"ledgerEntries": [
{
"asset": "BTC",
"qty": -0.123456,
"qty": "-0.123456",
"type": "Withdrawal"
},
{
"asset": "BTC",
"qty": -0.005,
"qty": "-0.005",
"type": "Fee"
}
],
"withdrawalId": "XXXX-XXXX-XXXX-XXXX",
"accountId": "xxxxxxxxxxxxxxx",
"custodianCode": "kraken",
"status": "Complete",
"transactionId": "xxxxxxxxxxxxxxx",
"targetAddress": "bc1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
},
"WithdrawalSimulationResultData": {
"type": "object",
"properties": {
"asset": {
"type": "string",
"description": "The asset that is being withdrawn."
},
"paymentMethod": {
"type": "string",
"description": "The payment method that is used (crypto code + network)."
},
"ledgerEntries": {
"type": "array",
"description": "The asset entries that would be changed if this were a real withdrawal. The first item is always the withdrawal itself. It could also includes ledger entries for the costs and may include credits or exchange tokens to give a discount.",
"items": {
"$ref": "#/components/schemas/LedgerEntryData"
}
},
"accountId": {
"type": "string",
"description": "The unique ID of the custodian account used.",
"nullable": false
},
"custodianCode": {
"type": "string",
"description": "The code of the custodian used.",
"nullable": false
},
"minQty": {
"type": "string",
"format": "decimal",
"description": "The minimum amount to withdraw",
"nullable": true
},
"maxQty": {
"type": "string",
"format": "decimal",
"description": "The maximum amount to withdraw",
"nullable": true
}
},
"example": {
"asset": "BTC",
"paymentMethod": "BTC-OnChain",
"ledgerEntries": [
{
"asset": "BTC",
"qty": "-0.123456",
"type": "Withdrawal"
},
{
"asset": "BTC",
"qty": "-0.005",
"type": "Fee"
}
],
@ -1064,7 +1244,8 @@
"nullable": false
},
"qty": {
"type": "number",
"type": "string",
"format": "decimal",
"description": "The quantity changed of the asset. Can be positive or negative.",
"nullable": false
},
@ -1076,7 +1257,7 @@
},
"example": {
"asset": "BTC",
"qty": 1.23456,
"qty": "1.23456",
"type": "Trade"
}
},
@ -1090,14 +1271,15 @@
"nullable": false
},
"qty": {
"type": "number",
"type": "string",
"format": "decimal",
"description": "The quantity changed of the asset. Can be positive or negative.",
"nullable": false
}
},
"example": {
"asset": "BTC",
"qty": 1.23456
"qty": "1.23456"
}
},
"AssetPairData": {

View File

@ -29,21 +29,20 @@ public class FakeCustodian : ICustodian
return Task.FromResult(r);
}
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
public Task<Form> GetConfigForm(CancellationToken cancellationToken = default)
{
var fakeConfig = ParseConfig(config);
var form = new Form();
var fieldset = Field.CreateFieldset();
// Maybe a decimal type field would be better?
var fakeBTCBalance = Field.Create("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
var fakeBTCBalance = Field.Create("BTC Balance", "BTCBalance", null, true,
"Enter the amount of BTC you want to have.");
var fakeLTCBalance = Field.Create("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
var fakeLTCBalance = Field.Create("LTC Balance", "LTCBalance", null, true,
"Enter the amount of LTC you want to have.");
var fakeEURBalance = Field.Create("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
var fakeEURBalance = Field.Create("EUR Balance", "EURBalance", null, true,
"Enter the amount of EUR you want to have.");
var fakeUSDBalance = Field.Create("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
var fakeUSDBalance = Field.Create("USD Balance", "USDBalance", null, true,
"Enter the amount of USD you want to have.");
fieldset.Label = "Your fake balances";