mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
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:
parent
b26679ca14
commit
6f2b673021
@ -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}")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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))]
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
22
BTCPayServer.Client/Models/WithdrawalBaseResponseData.cs
Normal file
22
BTCPayServer.Client/Models/WithdrawalBaseResponseData.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}");
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -4,6 +4,6 @@ namespace BTCPayServer.Models.CustodianAccountViewModels;
|
||||
|
||||
public class TradePrepareViewModel : AssetQuoteResult
|
||||
{
|
||||
public decimal MaxQtyToTrade { get; set; }
|
||||
|
||||
public decimal MaxQty { get; set; }
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 () {
|
||||
|
15
BTCPayServer/wwwroot/js/toggle-password.js
Normal file
15
BTCPayServer/wwwroot/js/toggle-password.js
Normal 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';
|
||||
}
|
||||
});
|
||||
})
|
@ -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": {
|
||||
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user