Exchange api no kraken (#3679)

* WIP New APIs for dealing with custodians/exchanges

* Simplified things

* More API refinements + index.html file for quick viewing

* Finishing touches on spec

* Switched cryptoCode to paymentMethod as this allows us to differentiate between onchain and lightning

* Moved draft API docs to "/docs-draft"

* WIP baby steps

* Added DB migration for CustodianAccountData

* Rough but working POST /v1/api/custodian-account + GET /v1/api/custodian

* WIP + early Kraken API client

* Moved service registration to proper location

* Working create + list custodian accounts + permissions + WIP Kraken client

* Kraken API Balances call is working

* Added asset balances to response

* List Custodian Accounts call does not load assetBalances by default, because it can fail. Can be requested when needed.

* Call to get the details of 1 specific custodian account

* Added permissions to swagger

* Added "tradableAssetPairs" to Kraken custodian response + cache the tradable pairs in memory for 24 hours

* Removed unused file

* WIP + Moved files to better locations

* Updated docs

* Working API endpoint to get info on a trade (same response as creating a new trade)

* Working API endpoints for Deposit + Trade + untested Withdraw

* Delete custodian account

* Trading works, better error handling, cleanup

* Working withdrawals + New endpoint for getting bid/ask prices

* Completed withdrawals + new endpoint for getting info on a past withdrawal to simplify testing, Enums are output as strings,

* Better error handling when withdrawing to a wrong destination

* WithdrawalAddressName in config is now a string per currency (dictionary)

* Added TODOs

* Only show the custodian account "config" to users who are allowed

* Added the new permissions to the API Keys UI

* Renamed KrakenClient to KrakenExchange

* WIP Kraken Config Form

* Removed files for UI again, will make separate PR later

* Fixed docs + Refactored to use PaymentMethod more + Added "name" to custodian account + Using cancelationToken everywhere

* Updated withdrawal info docs

* First unit test

* Complete tests for /api/v1/custodians and /api/v1/custodian-accounts endpoints + Various improvements and fixes

* Mock custodian and more exceptions

* Many more tests + cleanup, moved files to better locations

* More tests

* WIP more tests

* Greenfield API tests complete

* Added missing "Name" column

* Cleanup, TODOs and beginning of Kraken Tests

* Added Kraken tests using public endpoints + handling of "SATS" currency

* Added 1st mocked Kraken API call: GetAssetBalancesAsync

* Added assert for bad config

* Mocked more Kraken API responses + added CreationDate to withdrawal response

* pr review club changes

* Make Kraken Custodian a plugin

* Re-added User-Agent header as it is required

* Fixed bug in market trade on Kraken using a percentage as qty

* A short delay so Kraken has the time to execute the market order and we don't fetch the details too quickly.

* Merged the draft swagger into the main swagger since it didn't work anymore

* Fixed API permissions test

* Removed 2 TODOs

* Fixed unit test

* Remove Kraken Api as it should be separate opt-in plugin

* Flatten namespace hierarchy and use InnerExeption instead of OriginalException

* Remove useless line

* Make sure account is from a specific store

* Proper error if custodian code not found

* Remove various warnings

* Remove various warnings

* Handle CustodianApiException through an exception filter

* Store custodian-account blob directly

* Remove duplications, transform methods into property

* Improve docs tags

* Make sure the custodianCode saved is canonical

* Fix test

Co-authored-by: Wouter Samaey <wouter.samaey@storefront.be>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri
2022-05-18 07:59:56 +02:00
committed by GitHub
parent 6d76771b16
commit 76a6d94bbe
57 changed files with 3022 additions and 3 deletions

View File

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

View File

@@ -0,0 +1,11 @@
namespace BTCPayServer.Abstractions.Custodians;
public class AssetBalancesUnavailableException : CustodianApiException
{
public AssetBalancesUnavailableException(System.Exception e) : base(500, "asset-balances-unavailable", $"Cannot fetch the asset balances: {e.Message}", e)
{
}
}

View File

@@ -0,0 +1,13 @@
using BTCPayServer.Client.Models;
namespace BTCPayServer.Abstractions.Custodians;
public class AssetQuoteUnavailableException : CustodianApiException
{
public AssetPairData AssetPair { get; }
public AssetQuoteUnavailableException(AssetPairData assetPair) : base(400, "asset-price-unavailable", "Cannot find a quote for pair " + assetPair)
{
this.AssetPair = assetPair;
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace BTCPayServer.Abstractions.Custodians;
public class BadConfigException : CustodianApiException
{
public string[] BadConfigKeys { get; set; }
public BadConfigException(string[] badConfigKeys) : base(500, "bad-custodian-account-config", "Wrong config values: " + String.Join(", ", badConfigKeys))
{
this.BadConfigKeys = badConfigKeys;
}
}

View File

@@ -0,0 +1,13 @@
namespace BTCPayServer.Abstractions.Custodians;
public class CannotWithdrawException : CustodianApiException
{
public CannotWithdrawException(ICustodian custodian, string paymentMethod, string message) : base(403, "cannot-withdraw", message)
{
}
public CannotWithdrawException(ICustodian custodian, string paymentMethod, string targetAddress, CustodianApiException originalException) : base(403, "cannot-withdraw", $"{custodian.Name} cannot withdraw {paymentMethod} to '{targetAddress}': {originalException.Message}")
{
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace BTCPayServer.Abstractions.Custodians;
public class CustodianApiException: Exception {
public int HttpStatus { get; }
public string Code { get; }
public CustodianApiException(int httpStatus, string code, string message, System.Exception ex) : base(message, ex)
{
HttpStatus = httpStatus;
Code = code;
}
public CustodianApiException( int httpStatus, string code, string message) : this(httpStatus, code, message, null)
{
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Abstractions.Custodians;
public class CustodianFeatureNotImplementedException: CustodianApiException
{
public CustodianFeatureNotImplementedException(string message) : base(400, "not-implemented", message)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace BTCPayServer.Abstractions.Custodians;
public class DepositsUnavailableException : CustodianApiException
{
public DepositsUnavailableException(string message) : base(404, "deposits-unavailable", message)
{
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Abstractions.Custodians;
public class InsufficientFundsException : CustodianApiException
{
public InsufficientFundsException(string message) : base(400, "insufficient-funds", message)
{
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Abstractions.Custodians;
public class InvalidWithdrawalTargetException : CustodianApiException
{
public InvalidWithdrawalTargetException(ICustodian custodian, string paymentMethod, string targetAddress, CustodianApiException originalException) : base(403, "invalid-withdrawal-target", $"{custodian.Name} cannot withdraw {paymentMethod} to '{targetAddress}': {originalException.Message}")
{
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Abstractions.Custodians;
public class PermissionDeniedCustodianApiException : CustodianApiException
{
public PermissionDeniedCustodianApiException(ICustodian custodian) : base(403, "custodian-api-permission-denied", $"{custodian.Name}'s API reported that you don't have permission.")
{
}
}

View File

@@ -0,0 +1,11 @@
namespace BTCPayServer.Abstractions.Custodians;
public class TradeNotFoundException : CustodianApiException
{
private string tradeId { get; }
public TradeNotFoundException(string tradeId) : base(404,"trade-not-found","Could not find trade ID " + tradeId)
{
this.tradeId = tradeId;
}
}

View File

@@ -0,0 +1,11 @@
namespace BTCPayServer.Abstractions.Custodians;
public class WithdrawalNotFoundException : CustodianApiException
{
private string WithdrawalId { get; }
public WithdrawalNotFoundException(string withdrawalId) : base(404, "withdrawal-not-found", $"Could not find withdrawal ID {withdrawalId}.")
{
WithdrawalId = withdrawalId;
}
}

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Abstractions.Custodians;
public class WrongTradingPairException: CustodianApiException
{
public const int HttpCode = 404;
public WrongTradingPairException(string fromAsset, string toAsset) : base(HttpCode, "wrong-trading-pair", $"Cannot find a trading pair for converting {fromAsset} into {toAsset}.")
{
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Abstractions.Custodians;
/**
* The result of a market trade. Used as a return type for custodians implementing ICanTrade
*/
public class MarketTradeResult
{
public string FromAsset { get; }
public string ToAsset { get; }
/**
* The ledger entries that show the balances that were affected by the trade.
*/
public List<LedgerEntryData> LedgerEntries { get; }
/**
* The unique ID of the trade that was executed.
*/
public string TradeId { get; }
public MarketTradeResult(string fromAsset, string toAsset, List<LedgerEntryData> ledgerEntries, string tradeId)
{
this.FromAsset = fromAsset;
this.ToAsset = toAsset;
this.LedgerEntries = ledgerEntries;
this.TradeId = tradeId;
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Abstractions.Custodians;
public class WithdrawResult
{
public string PaymentMethod { get; }
public string Asset { get; set; }
public List<LedgerEntryData> LedgerEntries { get; }
public string WithdrawalId { get; }
public WithdrawalResponseData.WithdrawalStatus Status { get; }
public DateTimeOffset CreatedTime { get; }
public string TargetAddress { get; }
public string TransactionId { get; }
public WithdrawResult(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries, string withdrawalId, WithdrawalResponseData.WithdrawalStatus status, DateTimeOffset createdTime, string targetAddress, string transactionId)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
WithdrawalId = withdrawalId;
CreatedTime = createdTime;
Status = status;
TargetAddress = targetAddress;
TransactionId = transactionId;
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
public interface ICanDeposit
{
/**
* Get the address where we can deposit for the chosen payment method (crypto code + network).
* The result can be a string in different formats like a bitcoin address or even a LN invoice.
*/
public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken);
public string[] GetDepositablePaymentMethods();
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
public interface ICanTrade
{
/**
* A list of tradable asset pairs, or NULL if the custodian cannot trade/convert assets. if thr asset pair contains fiat, fiat is always put last. If both assets are a cyrptocode or both are fiat, the pair is written alphabetically. Always in uppercase. Example: ["BTC/EUR","BTC/USD", "EUR/USD", "BTC/ETH",...]
*/
public List<AssetPairData> GetTradableAssetPairs();
/**
* Execute a market order right now.
*/
public Task<MarketTradeResult> TradeMarketAsync(string fromAsset, string toAsset, decimal qty, JObject config, CancellationToken cancellationToken);
/**
* Get the details about a previous market trade.
*/
public Task<MarketTradeResult> GetTradeInfoAsync(string tradeId, JObject config, CancellationToken cancellationToken);
public Task<AssetQuoteResult> GetQuoteForAssetAsync(string fromAsset, string toAsset, JObject config, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
public interface ICanWithdraw
{
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken);
public Task<WithdrawResult> GetWithdrawalInfoAsync(string paymentMethod, string withdrawalId, JObject config, CancellationToken cancellationToken);
public string[] GetWithdrawablePaymentMethods();
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
public interface ICustodian
{
/**
* Get the unique code that identifies this custodian.
*/
string Code { get; }
string Name { get; }
/**
* Get a list of assets and their qty in custody.
*/
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<CustodianAccountData>> GetCustodianAccounts(string storeId, bool includeAssetBalances = false, CancellationToken token = default)
{
var queryPayload = new Dictionary<string, object>();
if (includeAssetBalances)
{
queryPayload.Add("assetBalances", "true");
}
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts", queryPayload), token);
return await HandleResponse<IEnumerable<CustodianAccountData>>(response);
}
public virtual async Task<CustodianAccountResponse> GetCustodianAccount(string storeId, string accountId, bool includeAssetBalances = false, CancellationToken token = default)
{
var queryPayload = new Dictionary<string, object>();
if (includeAssetBalances)
{
queryPayload.Add("assetBalances", "true");
}
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}", queryPayload), token);
return await HandleResponse<CustodianAccountResponse>(response);
}
public virtual async Task<CustodianAccountData> CreateCustodianAccount(string storeId, CreateCustodianAccountRequest request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<CustodianAccountData>(response);
}
public virtual async Task<CustodianAccountData> UpdateCustodianAccount(string storeId, string accountId, CreateCustodianAccountRequest request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}", bodyPayload: request, method: HttpMethod.Put), token);
return await HandleResponse<CustodianAccountData>(response);
}
public virtual async Task DeleteCustodianAccount(string storeId, string accountId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}", method: HttpMethod.Delete), token);
await HandleResponse(response);
}
public virtual async Task<DepositAddressData> GetDepositAddress(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);
}
public virtual async Task<MarketTradeResponseData> TradeMarket(string storeId, string accountId, TradeRequestData request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<MarketTradeResponseData>(response);
}
public virtual async Task<MarketTradeResponseData> GetTradeInfo(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)
{
var queryPayload = new Dictionary<string, object>();
queryPayload.Add("fromAsset", fromAsset);
queryPayload.Add("toAsset", toAsset);
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token);
return await HandleResponse<TradeQuoteResponseData>(response);
}
public virtual async Task<WithdrawalResponseData> CreateWithdrawal(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<WithdrawalResponseData> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/{paymentMethod}/{withdrawalId}", method: HttpMethod.Get), token);
return await HandleResponse<WithdrawalResponseData>(response);
}
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<CustodianData>> GetCustodians(CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/custodians"), token);
return await HandleResponse<IEnumerable<CustodianData>>(response);
}
}
}

View File

@@ -0,0 +1,23 @@
namespace BTCPayServer.Client.Models;
public class AssetPairData
{
public AssetPairData()
{
}
public AssetPairData(string AssetBought, string AssetSold)
{
this.AssetBought = AssetBought;
this.AssetSold = AssetSold;
}
public string AssetBought { set; get; }
public string AssetSold { set; get; }
public string ToString()
{
return AssetBought + "/" + AssetSold;
}
}

View File

@@ -0,0 +1,12 @@
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
public class CreateCustodianAccountRequest
{
public string CustodianCode { get; set; }
public string Name { get; set; }
public JObject Config { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
public abstract class CustodianAccountBaseData
{
public string CustodianCode { get; set; }
public string Name { get; set; }
public string StoreId { get; set; }
public JObject Config { get; set; }
}
}

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models
{
public class CustodianAccountData : CustodianAccountBaseData
{
public string Id { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models;
public class CustodianAccountResponse: CustodianAccountData
{
public IDictionary<string, decimal> AssetBalances { get; set; }
public CustodianAccountResponse()
{
}
}

View File

@@ -0,0 +1,11 @@
namespace BTCPayServer.Client.Models;
public class CustodianData
{
public string Code { get; set; }
public string Name { get; set; }
public string[] TradableAssetPairs { get; set; }
public string[] WithdrawablePaymentMethods { get; set; }
public string[] DepositablePaymentMethods { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace BTCPayServer.Client.Models;
public class DepositAddressData
{
// /**
// * Example: P2PKH, P2SH, P2WPKH, P2TR, BOLT11, ...
// */
// public string Type { get; set; }
/**
* Format depends hugely on the type.
*/
public string Address { get; set; }
}

View File

@@ -0,0 +1,27 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class LedgerEntryData
{
public string Asset { get; }
public decimal Qty { get; }
[JsonConverter(typeof(StringEnumConverter))]
public LedgerEntryType Type { get; }
public LedgerEntryData(string asset, decimal qty, LedgerEntryType type)
{
Asset = asset;
Qty = qty;
Type = type;
}
public enum LedgerEntryType
{
Trade = 0,
Fee = 1,
Withdrawal = 2
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models;
public class MarketTradeResponseData
{
public string FromAsset { get; }
public string ToAsset { get; }
/**
* The ledger entries that show the balances that were affected by the trade.
*/
public List<LedgerEntryData> LedgerEntries { get; }
/**
* The unique ID of the trade that was executed.
*/
public string TradeId { get; }
public string AccountId { get; }
public string CustodianCode { get; }
public MarketTradeResponseData(string fromAsset, string toAsset, List<LedgerEntryData> ledgerEntries, string tradeId, string accountId, string custodianCode)
{
FromAsset = fromAsset;
ToAsset = toAsset;
LedgerEntries = ledgerEntries;
TradeId = tradeId;
AccountId = accountId;
CustodianCode = custodianCode;
}
}

View File

@@ -0,0 +1,17 @@
namespace BTCPayServer.Client.Models;
public class TradeQuoteResponseData
{
public decimal Bid { get; }
public decimal Ask { get; }
public string ToAsset { get; }
public string FromAsset { get; }
public TradeQuoteResponseData(string fromAsset, string toAsset, decimal bid, decimal ask)
{
FromAsset = fromAsset;
ToAsset = toAsset;
Bid = bid;
Ask = ask;
}
}

View File

@@ -0,0 +1,8 @@
namespace BTCPayServer.Client.Models;
public class TradeRequestData
{
public string FromAsset { set; get; }
public string ToAsset { set; get; }
public string Qty { set; get; }
}

View File

@@ -0,0 +1,13 @@
namespace BTCPayServer.Client.Models;
public class WithdrawRequestData
{
public string PaymentMethod { set; get; }
public decimal Qty { set; get; }
public WithdrawRequestData(string paymentMethod, decimal qty)
{
PaymentMethod = paymentMethod;
Qty = qty;
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class WithdrawalResponseData
{
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 DateTimeOffset CreatedTime { get; }
public string TransactionId { get; }
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)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
WithdrawalId = withdrawalId;
AccountId = accountId;
CustodianCode = custodianCode;
TargetAddress = targetAddress;
TransactionId = transactionId;
Status = status;
CreatedTime = createdTime;
}
public enum WithdrawalStatus
{
Unknown = 0,
Queued = 1,
Complete = 2,
Failed = 3
}
}

View File

@@ -28,6 +28,11 @@ namespace BTCPayServer.Client
public const string CanCreateUser = "btcpay.server.cancreateuser"; public const string CanCreateUser = "btcpay.server.cancreateuser";
public const string CanDeleteUser = "btcpay.user.candeleteuser"; public const string CanDeleteUser = "btcpay.user.candeleteuser";
public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments"; public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments";
public const string CanViewCustodianAccounts = "btcpay.store.canviewcustodianaccounts";
public const string CanManageCustodianAccounts = "btcpay.store.canmanagecustodianaccounts";
public const string CanDepositToCustodianAccounts = "btcpay.store.candeposittocustodianaccount";
public const string CanWithdrawFromCustodianAccounts = "btcpay.store.canwithdrawfromcustodianaccount";
public const string CanTradeCustodianAccount = "btcpay.store.cantradecustodianaccount";
public const string Unrestricted = "unrestricted"; public const string Unrestricted = "unrestricted";
public static IEnumerable<string> AllPolicies public static IEnumerable<string> AllPolicies
{ {
@@ -55,6 +60,11 @@ namespace BTCPayServer.Client
yield return CanUseLightningNodeInStore; yield return CanUseLightningNodeInStore;
yield return CanCreateLightningInvoiceInStore; yield return CanCreateLightningInvoiceInStore;
yield return CanManagePullPayments; yield return CanManagePullPayments;
yield return CanViewCustodianAccounts;
yield return CanManageCustodianAccounts;
yield return CanDepositToCustodianAccounts;
yield return CanWithdrawFromCustodianAccounts;
yield return CanTradeCustodianAccount;
} }
} }
public static bool IsValidPolicy(string policy) public static bool IsValidPolicy(string policy)
@@ -184,6 +194,9 @@ namespace BTCPayServer.Client
case Policies.CanCreateLightningInvoiceInStore when this.Policy == Policies.CanUseLightningNodeInStore: case Policies.CanCreateLightningInvoiceInStore when this.Policy == Policies.CanUseLightningNodeInStore:
case Policies.CanViewNotificationsForUser when this.Policy == Policies.CanManageNotificationsForUser: case Policies.CanViewNotificationsForUser when this.Policy == Policies.CanManageNotificationsForUser:
case Policies.CanUseInternalLightningNode when this.Policy == Policies.CanModifyServerSettings: case Policies.CanUseInternalLightningNode when this.Policy == Policies.CanModifyServerSettings:
case Policies.CanViewCustodianAccounts when this.Policy == Policies.CanManageCustodianAccounts:
case Policies.CanViewCustodianAccounts when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanManageCustodianAccounts when this.Policy == Policies.CanModifyStoreSettings:
return true; return true;
default: default:
return false; return false;

View File

@@ -34,6 +34,7 @@ namespace BTCPayServer.Data
public DbSet<AddressInvoiceData> AddressInvoices { get; set; } public DbSet<AddressInvoiceData> AddressInvoices { get; set; }
public DbSet<APIKeyData> ApiKeys { get; set; } public DbSet<APIKeyData> ApiKeys { get; set; }
public DbSet<AppData> Apps { get; set; } public DbSet<AppData> Apps { get; set; }
public DbSet<CustodianAccountData> CustodianAccount { get; set; }
public DbSet<StoredFile> Files { get; set; } public DbSet<StoredFile> Files { get; set; }
public DbSet<HistoricalAddressInvoiceData> HistoricalAddressInvoices { get; set; } public DbSet<HistoricalAddressInvoiceData> HistoricalAddressInvoices { get; set; }
public DbSet<InvoiceEventData> InvoiceEvents { get; set; } public DbSet<InvoiceEventData> InvoiceEvents { get; set; }
@@ -82,6 +83,7 @@ namespace BTCPayServer.Data
AddressInvoiceData.OnModelCreating(builder); AddressInvoiceData.OnModelCreating(builder);
APIKeyData.OnModelCreating(builder); APIKeyData.OnModelCreating(builder);
AppData.OnModelCreating(builder); AppData.OnModelCreating(builder);
CustodianAccountData.OnModelCreating(builder);
//StoredFile.OnModelCreating(builder); //StoredFile.OnModelCreating(builder);
HistoricalAddressInvoiceData.OnModelCreating(builder); HistoricalAddressInvoiceData.OnModelCreating(builder);
InvoiceEventData.OnModelCreating(builder); InvoiceEventData.OnModelCreating(builder);

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data;
public class CustodianAccountData
{
[Required]
[MaxLength(50)]
public string Id { get; set; }
[Required]
[MaxLength(50)]
public string StoreId { get; set; }
[Required]
[MaxLength(50)]
public string CustodianCode { get; set; }
[MaxLength(50)]
public string Name { get; set; }
[JsonIgnore]
public byte[] Blob { get; set; }
[JsonIgnore]
public StoreData StoreData { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<CustodianAccountData>()
.HasOne(o => o.StoreData)
.WithMany(i => i.CustodianAccounts)
.HasForeignKey(i => i.StoreId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasIndex(o => o.StoreId);
}
}

View File

@@ -46,5 +46,6 @@ namespace BTCPayServer.Data
public IEnumerable<LightningAddressData> LightningAddresses { get; set; } public IEnumerable<LightningAddressData> LightningAddresses { get; set; }
public IEnumerable<PayoutProcessorData> PayoutProcessors { get; set; } public IEnumerable<PayoutProcessorData> PayoutProcessors { get; set; }
public IEnumerable<PayoutData> Payouts { get; set; } public IEnumerable<PayoutData> Payouts { get; set; }
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
} }
} }

View File

@@ -0,0 +1,6 @@
namespace BTCPayServer.Data;
public class TradeResultData
{
}

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20220115184620_AddCustodianAccountData")]
public partial class AddCustodianAccountData : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustodianAccount",
columns: table => new
{
Id = table.Column<string>(maxLength: 50, nullable: false),
StoreId = table.Column<string>(maxLength: 50, nullable: false),
CustodianCode = table.Column<string>(maxLength: 50, nullable: false),
Name = table.Column<string>(maxLength: 50, nullable: true),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustodianAccount", x => x.Id);
table.ForeignKey(
name: "FK_CustodianAccount_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CustodianAccount_StoreId",
table: "CustodianAccount",
column: "StoreId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustodianAccount");
}
}
}

View File

@@ -7,11 +7,13 @@ using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Hosting; using BTCPayServer.Hosting;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
@@ -179,6 +181,7 @@ namespace BTCPayServer.Tests
.ConfigureServices(services => .ConfigureServices(services =>
{ {
services.TryAddSingleton<IFeeProviderFactory>(new BTCPayServer.Services.Fees.FixedFeeProvider(new FeeRate(100L, 1))); services.TryAddSingleton<IFeeProviderFactory>(new BTCPayServer.Services.Fees.FixedFeeProvider(new FeeRate(100L, 1)));
services.AddSingleton<ICustodian, MockCustodian>();
}) })
.UseKestrel() .UseKestrel()
.UseStartup<Startup>() .UseStartup<Startup>()

View File

@@ -1,11 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
@@ -15,6 +18,8 @@ using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Logging;
@@ -777,6 +782,13 @@ namespace BTCPayServer.Tests
Assert.Equal(code, ex.HttpCode); Assert.Equal(code, ex.HttpCode);
} }
private async Task AssertApiError(int httpStatus, string errorCode, Func<Task> act)
{
var ex = await Assert.ThrowsAsync<GreenfieldAPIException>(act);
Assert.Equal(httpStatus, ex.HttpCode);
Assert.Equal(errorCode, ex.APIError.Code);
}
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task UsersControllerTests() public async Task UsersControllerTests()
@@ -2500,5 +2512,474 @@ namespace BTCPayServer.Tests
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress)); Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
}); });
} }
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CustodiansControllerTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
await AssertHttpError(401, async () => await unauthClient.GetCustodians());
var user = tester.NewAccount();
await user.GrantAccessAsync();
var clientBasic = await user.CreateClient();
var custodians = await clientBasic.GetCustodians();
Assert.NotNull(custodians);
Assert.Single(custodians);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CustodianAccountControllerTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
var authedButLackingPermissionsClient = await admin.CreateClient(Policies.CanViewStoreSettings);
var viewerOnlyClient = await admin.CreateClient(Policies.CanViewCustodianAccounts);
var managerClient = await admin.CreateClient(Policies.CanManageCustodianAccounts);
var store = await adminClient.GetStore(admin.StoreId);
var storeId = store.Id;
// Load a custodian, we use the first one we find.
var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>();
var custodian = custodians.First();
// List custodian accounts
// Unauth
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccounts(storeId));
// Auth, but wrong permission
await AssertHttpError(403, async () => await authedButLackingPermissionsClient.GetCustodianAccounts(storeId));
// Auth, correct permission, empty result
var emptyCustodianAccounts = await viewerOnlyClient.GetCustodianAccounts(storeId);
Assert.Empty(emptyCustodianAccounts);
// Create custodian account
JObject config = JObject.Parse(@"{
'WithdrawToAddressNamePerPaymentMethod': {
'BTC-OnChain': 'My Ledger Nano'
},
'ApiKey': 'APIKEY',
'PrivateKey': 'UFJJVkFURUtFWQ=='
}");
var createCustodianAccountRequest = new CreateCustodianAccountRequest();
createCustodianAccountRequest.Config = config;
createCustodianAccountRequest.CustodianCode = custodian.Code;
// Unauthorized
await AssertHttpError(401, async () => await unauthClient.CreateCustodianAccount(storeId, createCustodianAccountRequest));
// Auth, but wrong permission
await AssertHttpError(403, async () => await viewerOnlyClient.CreateCustodianAccount(storeId, createCustodianAccountRequest));
// Auth, correct permission
var custodianAccountData = await managerClient.CreateCustodianAccount(storeId, createCustodianAccountRequest);
Assert.NotNull(custodianAccountData);
Assert.NotNull(custodianAccountData.Id);
var accountId = custodianAccountData.Id;
Assert.Equal(custodian.Code, custodianAccountData.CustodianCode);
// We did not provide a name, so the custodian's name should've been picked as a fallback
Assert.Equal(custodian.Name, custodianAccountData.Name);
Assert.Equal(storeId, custodianAccountData.StoreId);
Assert.True(JToken.DeepEquals(config, custodianAccountData.Config));
// List all Custodian Accounts, now that we have 1 result
// Admin can see all
var adminCustodianAccounts = await adminClient.GetCustodianAccounts(storeId);
Assert.Single(adminCustodianAccounts);
var adminCustodianAccount = adminCustodianAccounts.First();
Assert.Equal(adminCustodianAccount.CustodianCode, custodian.Code);
// Manager can see all, including config
var managerCustodianAccounts = await managerClient.GetCustodianAccounts(storeId);
Assert.Single(managerCustodianAccounts);
Assert.Equal(managerCustodianAccounts.First().CustodianCode, custodian.Code);
Assert.NotNull(managerCustodianAccounts.First().Config);
Assert.True(JToken.DeepEquals(config, managerCustodianAccounts.First().Config));
// Viewer can see all, but no config
var viewerCustodianAccounts = await viewerOnlyClient.GetCustodianAccounts(storeId);
Assert.Single(viewerCustodianAccounts);
Assert.Equal(viewerCustodianAccounts.First().CustodianCode, custodian.Code);
Assert.Null(viewerCustodianAccounts.First().Config);
// Try to fetch 1
// Admin
var singleAdminCustodianAccount = await adminClient.GetCustodianAccount(storeId, accountId);
Assert.NotNull(singleAdminCustodianAccount);
Assert.Equal(singleAdminCustodianAccount.CustodianCode, custodian.Code);
// Manager can see, including config
var singleManagerCustodianAccount = await managerClient.GetCustodianAccount(storeId, accountId);
Assert.NotNull(singleManagerCustodianAccount);
Assert.Equal(singleManagerCustodianAccount.CustodianCode, custodian.Code);
Assert.NotNull(singleManagerCustodianAccount.Config);
Assert.True(JToken.DeepEquals(config, singleManagerCustodianAccount.Config));
// Viewer can see, but no config
var singleViewerCustodianAccount = await viewerOnlyClient.GetCustodianAccount(storeId, accountId);
Assert.NotNull(singleViewerCustodianAccount);
Assert.Equal(singleViewerCustodianAccount.CustodianCode, custodian.Code);
Assert.Null(singleViewerCustodianAccount.Config);
// Test updating the custodian account we created
var updateCustodianAccountRequest = createCustodianAccountRequest;
updateCustodianAccountRequest.Name = "My Custodian";
updateCustodianAccountRequest.Config["ApiKey"] = "ZZZ";
// Unauth
await AssertHttpError(401, async () => await unauthClient.UpdateCustodianAccount(storeId, accountId, updateCustodianAccountRequest));
// Auth, but wrong permission
await AssertHttpError(403, async () => await viewerOnlyClient.UpdateCustodianAccount(storeId, accountId, updateCustodianAccountRequest));
// Correct auth: update permissions
var updatedCustodianAccountData = await managerClient.UpdateCustodianAccount(storeId, accountId, createCustodianAccountRequest);
Assert.NotNull(updatedCustodianAccountData);
Assert.Equal(custodian.Code, updatedCustodianAccountData.CustodianCode);
Assert.Equal(updateCustodianAccountRequest.Name, updatedCustodianAccountData.Name);
Assert.Equal(storeId, custodianAccountData.StoreId);
Assert.True(JToken.DeepEquals(updateCustodianAccountRequest.Config, createCustodianAccountRequest.Config));
// Admin
updateCustodianAccountRequest.Name = "Admin Account";
updateCustodianAccountRequest.Config["ApiKey"] = "AAA";
updatedCustodianAccountData = await adminClient.UpdateCustodianAccount(storeId, accountId, createCustodianAccountRequest);
Assert.NotNull(updatedCustodianAccountData);
Assert.Equal(custodian.Code, updatedCustodianAccountData.CustodianCode);
Assert.Equal(updateCustodianAccountRequest.Name, updatedCustodianAccountData.Name);
Assert.Equal(storeId, custodianAccountData.StoreId);
Assert.True(JToken.DeepEquals(updateCustodianAccountRequest.Config, createCustodianAccountRequest.Config));
// Admin tries to update a non-existing custodian account
await AssertHttpError(404, async () => await adminClient.UpdateCustodianAccount(storeId, "WRONG-ACCOUNT-ID", updateCustodianAccountRequest));
// Get asset balances, but we cannot because of misconfiguration (we did enter dummy data)
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccounts(storeId, true));
// // Auth, viewer permission => Error 500 because of BadConfigException (dummy data)
// await AssertHttpError(500, async () => await viewerOnlyClient.GetCustodianAccounts(storeId, true));
//
// Delete custodian account
// Unauth
await AssertHttpError(401, async () => await unauthClient.DeleteCustodianAccount(storeId, accountId));
// Auth, but wrong permission
await AssertHttpError(403, async () => await viewerOnlyClient.DeleteCustodianAccount(storeId, accountId));
// Auth, correct permission
await managerClient.DeleteCustodianAccount(storeId, accountId);
// Check if the Custodian Account was actually deleted
await AssertHttpError(404, async () => await managerClient.GetCustodianAccount(storeId, accountId));
// TODO what if we try to create a custodian account for a custodian code that does not exist?
// TODO what if we try so set config data that is not valid? In phase 2 we will validate the config and only allow you to save a config that makes sense!
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CustodianTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
var authClientNoPermissions = await admin.CreateClient(Policies.CanViewInvoices);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
var managerClient = await admin.CreateClient(Policies.CanManageCustodianAccounts);
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;
// Load a custodian, we use the first one we find.
var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>();
var mockCustodian = custodians.First(c => c.Code == "mock");
// Create custodian account
var createCustodianAccountRequest = new CreateCustodianAccountRequest();
createCustodianAccountRequest.CustodianCode = mockCustodian.Code;
var custodianAccountData = await managerClient.CreateCustodianAccount(storeId, createCustodianAccountRequest);
Assert.NotNull(custodianAccountData);
Assert.Equal(mockCustodian.Code, custodianAccountData.CustodianCode);
Assert.NotNull(custodianAccountData.Id);
var accountId = custodianAccountData.Id;
// Test: Get Asset Balances
var custodianAccountWithBalances = await adminClient.GetCustodianAccount(storeId, accountId,true);
Assert.NotNull(custodianAccountWithBalances);
Assert.NotNull(custodianAccountWithBalances.AssetBalances);
Assert.Equal(4, custodianAccountWithBalances.AssetBalances.Count);
Assert.True(custodianAccountWithBalances.AssetBalances.Keys.Contains("BTC"));
Assert.True(custodianAccountWithBalances.AssetBalances.Keys.Contains("LTC"));
Assert.True(custodianAccountWithBalances.AssetBalances.Keys.Contains("EUR"));
Assert.True(custodianAccountWithBalances.AssetBalances.Keys.Contains("USD"));
Assert.Equal(MockCustodian.BalanceBTC, custodianAccountWithBalances.AssetBalances["BTC"]);
Assert.Equal(MockCustodian.BalanceLTC, custodianAccountWithBalances.AssetBalances["LTC"]);
Assert.Equal(MockCustodian.BalanceEUR, custodianAccountWithBalances.AssetBalances["EUR"]);
Assert.Equal(MockCustodian.BalanceUSD, custodianAccountWithBalances.AssetBalances["USD"]);
// Test: Get Asset Balances omitted if we choose so
var custodianAccountWithoutBalances = await adminClient.GetCustodianAccount(storeId, accountId,false);
Assert.NotNull(custodianAccountWithoutBalances);
Assert.Null(custodianAccountWithoutBalances.AssetBalances);
// Test: GetDepositAddress, unauth
await AssertHttpError(401, async () => await unauthClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong payment method
await AssertHttpError(400, async () => await depositClient.GetDepositAddress(storeId, accountId, "WRONG-PaymentMethod"));
// Test: GetDepositAddress, wrong store ID
await AssertHttpError(403, async () => await depositClient.GetDepositAddress("WRONG-STORE", accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong account ID
await AssertHttpError(404, async () => await depositClient.GetDepositAddress(storeId, "WRONG-ACCOUNT-ID", MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, correct payment method
var depositAddress = await depositClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod);
Assert.NotNull(depositAddress);
Assert.Equal(MockCustodian.DepositAddress, depositAddress.Address);
// Test: Trade, unauth
var tradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)};
await AssertHttpError(401, async () => await unauthClient.TradeMarket(storeId, accountId, tradeRequest));
// Test: Trade, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.TradeMarket(storeId, accountId, tradeRequest));
// Test: Trade, correct permission, correct assets, correct amount
var newTradeResult = await tradeClient.TradeMarket(storeId, accountId, tradeRequest);
Assert.NotNull(newTradeResult);
Assert.Equal(accountId, newTradeResult.AccountId);
Assert.Equal(mockCustodian.Code, newTradeResult.CustodianCode);
Assert.Equal(MockCustodian.TradeId, newTradeResult.TradeId);
Assert.Equal(tradeRequest.FromAsset, newTradeResult.FromAsset);
Assert.Equal(tradeRequest.ToAsset, newTradeResult.ToAsset);
Assert.NotNull( newTradeResult.LedgerEntries);
Assert.Equal( 3, newTradeResult.LedgerEntries.Count);
Assert.Equal( MockCustodian.TradeQtyBought, newTradeResult.LedgerEntries[0].Qty);
Assert.Equal( tradeRequest.ToAsset, newTradeResult.LedgerEntries[0].Asset);
Assert.Equal(LedgerEntryData.LedgerEntryType.Trade , newTradeResult.LedgerEntries[0].Type);
Assert.Equal( -1 * MockCustodian.TradeQtyBought * MockCustodian.BtcPriceInEuro, newTradeResult.LedgerEntries[1].Qty);
Assert.Equal( tradeRequest.FromAsset, newTradeResult.LedgerEntries[1].Asset);
Assert.Equal(LedgerEntryData.LedgerEntryType.Trade , newTradeResult.LedgerEntries[1].Type);
Assert.Equal( -1 * MockCustodian.TradeFeeEuro, newTradeResult.LedgerEntries[2].Qty);
Assert.Equal( tradeRequest.FromAsset, newTradeResult.LedgerEntries[2].Asset);
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee , newTradeResult.LedgerEntries[2].Type);
// Test: GetTradeQuote, SATS
var satsTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = "SATS", Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)};
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.TradeMarket(storeId, accountId, satsTradeRequest));
// TODO Test: Trade with percentage qty
// Test: Trade, wrong assets method
var wrongAssetsTradeRequest = new TradeRequestData {FromAsset = "WRONG", ToAsset = MockCustodian.TradeToAsset, Qty = MockCustodian.TradeQtyBought.ToString(CultureInfo.InvariantCulture)};
await AssertHttpError(WrongTradingPairException.HttpCode, async () => await tradeClient.TradeMarket(storeId, accountId, wrongAssetsTradeRequest));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.TradeMarket(storeId, "WRONG-ACCOUNT-ID", tradeRequest));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.TradeMarket("WRONG-STORE-ID", accountId, tradeRequest));
// Test: Trade, correct assets, wrong amount
var wrongQtyTradeRequest = new TradeRequestData {FromAsset = MockCustodian.TradeFromAsset, ToAsset = MockCustodian.TradeToAsset, Qty = "0.01"};
await AssertApiError(400, "insufficient-funds", async () => await tradeClient.TradeMarket(storeId, accountId, wrongQtyTradeRequest));
// Test: GetTradeQuote, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeQuote(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));
// Test: GetTradeQuote, auth, correct permission
var tradeQuote = await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset);
Assert.NotNull(tradeQuote);
Assert.Equal(MockCustodian.TradeFromAsset, tradeQuote.FromAsset);
Assert.Equal(MockCustodian.TradeToAsset, tradeQuote.ToAsset);
Assert.Equal(MockCustodian.BtcPriceInEuro, tradeQuote.Bid);
Assert.Equal(MockCustodian.BtcPriceInEuro, tradeQuote.Ask);
// Test: GetTradeQuote, SATS
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.GetTradeQuote(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"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(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));
// Test: GetTradeInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, correct permission
var tradeResult = await tradeClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId);
Assert.NotNull(tradeResult);
Assert.Equal(accountId, tradeResult.AccountId);
Assert.Equal(mockCustodian.Code, tradeResult.CustodianCode);
Assert.Equal(MockCustodian.TradeId, tradeResult.TradeId);
Assert.Equal(tradeRequest.FromAsset, tradeResult.FromAsset);
Assert.Equal(tradeRequest.ToAsset, tradeResult.ToAsset);
Assert.NotNull( tradeResult.LedgerEntries);
Assert.Equal( 3, tradeResult.LedgerEntries.Count);
Assert.Equal( MockCustodian.TradeQtyBought, tradeResult.LedgerEntries[0].Qty);
Assert.Equal( tradeRequest.ToAsset, tradeResult.LedgerEntries[0].Asset);
Assert.Equal(LedgerEntryData.LedgerEntryType.Trade , tradeResult.LedgerEntries[0].Type);
Assert.Equal( -1 * MockCustodian.TradeQtyBought * MockCustodian.BtcPriceInEuro, tradeResult.LedgerEntries[1].Qty);
Assert.Equal( tradeRequest.FromAsset, tradeResult.LedgerEntries[1].Asset);
Assert.Equal(LedgerEntryData.LedgerEntryType.Trade , tradeResult.LedgerEntries[1].Type);
Assert.Equal( -1 * MockCustodian.TradeFeeEuro, tradeResult.LedgerEntries[2].Qty);
Assert.Equal( tradeRequest.FromAsset, tradeResult.LedgerEntries[2].Asset);
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"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeId));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.GetTradeInfo("WRONG-STORE-ID", accountId, MockCustodian.TradeId));
// Test: CreateWithdrawal, unauth
var createWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalAmount );
await AssertHttpError(401, async () => await unauthClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, correct payment method, correct amount
var withdrawResponse = await withdrawalClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest);
AssertMockWithdrawal(withdrawResponse, 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));
// Test: CreateWithdrawal, wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.CreateWithdrawal(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));
// 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));
// Test: GetWithdrawalInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetWithdrawalInfo(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));
// Test: GetWithdrawalInfo, auth, correct permission
var withdrawalInfo = await withdrawalClient.GetWithdrawalInfo(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"));
// Test: wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.GetWithdrawalInfo(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));
// 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
// TODO create a mock custodian with only ICustodian + ICanTrade
// TODO create a mock custodian with only ICustodian + ICanDeposit
}
private void AssertMockWithdrawal(WithdrawalResponseData 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);
Assert.Equal(2, withdrawResponse.LedgerEntries.Count);
Assert.Equal(MockCustodian.WithdrawalAsset, withdrawResponse.LedgerEntries[0].Asset);
Assert.Equal(MockCustodian.WithdrawalAmount - MockCustodian.WithdrawalFee, withdrawResponse.LedgerEntries[0].Qty);
Assert.Equal(LedgerEntryData.LedgerEntryType.Withdrawal, withdrawResponse.LedgerEntries[0].Type);
Assert.Equal(MockCustodian.WithdrawalAsset, withdrawResponse.LedgerEntries[1].Asset);
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);
}
} }
} }

View File

@@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Custodian.Client.MockCustodian;
public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
{
public const string DepositPaymentMethod = "BTC-OnChain";
public const string DepositAddress = "bc1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
public const string TradeId = "TRADE-ID-001";
public const string TradeFromAsset = "EUR";
public const string TradeToAsset = "BTC";
public static readonly decimal TradeQtyBought = new decimal(1);
public static readonly decimal TradeFeeEuro = new decimal(12.5);
public static readonly decimal BtcPriceInEuro = new decimal(30000);
public const string WithdrawalPaymentMethod = "BTC-OnChain";
public const string WithdrawalAsset = "BTC";
public const string WithdrawalId = "WITHDRAWAL-ID-001";
public static readonly decimal WithdrawalAmount = new decimal(0.5);
public static readonly decimal WithdrawalFee = new decimal(0.0005);
public const string WithdrawalTransactionId = "yyy";
public const string WithdrawalTargetAddress = "bc1qyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";
public const WithdrawalResponseData.WithdrawalStatus WithdrawalStatus = WithdrawalResponseData.WithdrawalStatus.Queued;
public static readonly decimal BalanceBTC = new decimal(1.23456);
public static readonly decimal BalanceLTC = new decimal(50.123456);
public static readonly decimal BalanceUSD = new decimal(1500.55);
public static readonly decimal BalanceEUR = new decimal(1235.15);
public string Code
{
get => "mock";
}
public string Name
{
get => "MOCK Exchange";
}
public Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken)
{
var r = new Dictionary<string, decimal>()
{
{ "BTC", BalanceBTC }, { "LTC", BalanceLTC }, { "USD", BalanceUSD }, { "EUR", BalanceEUR },
};
return Task.FromResult(r);
}
public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod.Equals(DepositPaymentMethod))
{
var r = new DepositAddressData();
r.Address = DepositAddress;
return Task.FromResult(r);
}
throw new CustodianFeatureNotImplementedException($"Only BTC-OnChain is implemented for {this.Name}");
}
public string[] GetDepositablePaymentMethods()
{
return new[] { "BTC-OnChain" };
}
public List<AssetPairData> GetTradableAssetPairs()
{
var r = new List<AssetPairData>();
r.Add(new AssetPairData("BTC", "EUR"));
return r;
}
private MarketTradeResult GetMarketTradeResult()
{
var ledgerEntries = new List<LedgerEntryData>();
ledgerEntries.Add(new LedgerEntryData("BTC", TradeQtyBought, LedgerEntryData.LedgerEntryType.Trade));
ledgerEntries.Add(new LedgerEntryData("EUR", -1 * TradeQtyBought * BtcPriceInEuro, LedgerEntryData.LedgerEntryType.Trade));
ledgerEntries.Add(new LedgerEntryData("EUR", -1 * TradeFeeEuro, LedgerEntryData.LedgerEntryType.Fee));
return new MarketTradeResult(TradeFromAsset, TradeToAsset, ledgerEntries, TradeId);
}
public Task<MarketTradeResult> TradeMarketAsync(string fromAsset, string toAsset, decimal qty, JObject config, CancellationToken cancellationToken)
{
if (!fromAsset.Equals("EUR") || !toAsset.Equals("BTC"))
{
throw new WrongTradingPairException(fromAsset, toAsset);
}
if (qty != TradeQtyBought)
{
throw new InsufficientFundsException($"With {Name}, you can only buy {TradeQtyBought} {TradeToAsset} with {TradeFromAsset} and nothing else.");
}
return Task.FromResult(GetMarketTradeResult());
}
public Task<MarketTradeResult> GetTradeInfoAsync(string tradeId, JObject config, CancellationToken cancellationToken)
{
if (tradeId == TradeId)
{
return Task.FromResult(GetMarketTradeResult());
}
return Task.FromResult<MarketTradeResult>(null);
}
public Task<AssetQuoteResult> GetQuoteForAssetAsync(string fromAsset, string toAsset, JObject config, CancellationToken cancellationToken)
{
if (fromAsset.Equals(TradeFromAsset) && toAsset.Equals(TradeToAsset))
{
return Task.FromResult(new AssetQuoteResult(TradeFromAsset, TradeToAsset, BtcPriceInEuro, BtcPriceInEuro));
}
throw new WrongTradingPairException(fromAsset, toAsset);
//throw new AssetQuoteUnavailableException(pair);
}
private WithdrawResult CreateWithdrawResult()
{
var ledgerEntries = new List<LedgerEntryData>();
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalAmount - WithdrawalFee, LedgerEntryData.LedgerEntryType.Withdrawal));
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalFee, LedgerEntryData.LedgerEntryType.Fee));
DateTimeOffset createdTime = new DateTimeOffset(2021, 9, 1, 6, 45, 0, new TimeSpan(-7, 0, 0));
var r = new WithdrawResult(WithdrawalPaymentMethod, WithdrawalAsset, ledgerEntries, WithdrawalId, WithdrawalStatus, createdTime, WithdrawalTargetAddress, WithdrawalTransactionId);
return r;
}
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod == WithdrawalPaymentMethod)
{
if (amount == WithdrawalAmount)
{
return Task.FromResult(CreateWithdrawResult());
}
throw new InsufficientFundsException($"{Name} only supports withdrawals of {WithdrawalAmount}");
}
throw new CannotWithdrawException(this, paymentMethod, $"Only {WithdrawalPaymentMethod} can be withdrawn from {Name}");
}
public Task<WithdrawResult> GetWithdrawalInfoAsync(string paymentMethod, string withdrawalId, JObject config, CancellationToken cancellationToken)
{
if (withdrawalId == WithdrawalId && WithdrawalPaymentMethod.Equals(paymentMethod))
{
return Task.FromResult(CreateWithdrawResult());
}
return Task.FromResult<WithdrawResult>(null);
}
public string[] GetWithdrawablePaymentMethods()
{
return GetDepositablePaymentMethods();
}
}

View File

@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Services.Custodian;
using BTCPayServer.Services.Custodian.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
namespace BTCPayServer.Controllers.Greenfield
{
public class CustodianExceptionFilter : Attribute, IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is CustodianApiException ex)
{
context.Result = new ObjectResult(new GreenfieldAPIError(ex.Code, ex.Message)) { StatusCode = ex.HttpStatus };
context.ExceptionHandled = true;
}
}
}
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.GreenfieldAPIKeys)]
[EnableCors(CorsPolicies.All)]
[CustodianExceptionFilter]
public class GreenfieldCustodianAccountController : ControllerBase
{
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly IEnumerable<ICustodian> _custodianRegistry;
private readonly IAuthorizationService _authorizationService;
public GreenfieldCustodianAccountController(CustodianAccountRepository custodianAccountRepository,
IEnumerable<ICustodian> custodianRegistry,
IAuthorizationService authorizationService)
{
_custodianAccountRepository = custodianAccountRepository;
_custodianRegistry = custodianRegistry;
_authorizationService = authorizationService;
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts")]
[Authorize(Policy = Policies.CanViewCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> ListCustodianAccount(string storeId, [FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default)
{
var custodianAccounts = await _custodianAccountRepository.FindByStoreId(storeId);
CustodianAccountDataClient[] responses = new CustodianAccountDataClient[custodianAccounts.Length];
for (int i = 0; i < custodianAccounts.Length; i++)
{
var custodianAccountData = custodianAccounts[i];
responses[i] = await ToModel(custodianAccountData, assetBalances, cancellationToken);
}
return Ok(responses);
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}")]
[Authorize(Policy = Policies.CanViewCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId,
[FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default)
{
var custodianAccountData = await GetCustodian(storeId, accountId);
var custodianAccount = await ToModel(custodianAccountData, assetBalances, cancellationToken);
return Ok(custodianAccount);
}
private async Task<bool> CanSeeCustodianAccountConfig()
{
return (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanManageCustodianAccounts))).Succeeded;
}
private async Task<CustodianAccountDataClient> ToModel(CustodianAccountData custodianAccount, bool includeAsset, CancellationToken cancellationToken)
{
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
var r = includeAsset ? new CustodianAccountResponse() : new CustodianAccountDataClient();
r.Id = custodianAccount.Id;
r.CustodianCode = custodian.Code;
r.Name = custodianAccount.Name;
r.StoreId = custodianAccount.StoreId;
if (await CanSeeCustodianAccountConfig())
{
// Only show the "config" field if the user can create or manage the Custodian Account, because config contains sensitive information (API key, etc).
r.Config = custodianAccount.GetBlob();
}
if (includeAsset)
{
var balances = await GetCustodianByCode(r.CustodianCode).GetAssetBalancesAsync(r.Config, cancellationToken);
((CustodianAccountResponse)r).AssetBalances = balances;
}
return r;
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts")]
[Authorize(Policy = Policies.CanManageCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateCustodianAccount(string storeId, CreateCustodianAccountRequest request, CancellationToken cancellationToken)
{
request ??= new CreateCustodianAccountRequest();
var custodian = GetCustodianByCode(request.CustodianCode);
// Use the name provided or if none provided use the name of the custodian.
string name = string.IsNullOrEmpty(request.Name) ? custodian.Name : request.Name;
var custodianAccount = new CustodianAccountData() { CustodianCode = custodian.Code, Name = name, StoreId = storeId, };
custodianAccount.SetBlob(request.Config);
await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return Ok(await ToModel(custodianAccount, false, cancellationToken));
}
[HttpPut("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}")]
[Authorize(Policy = Policies.CanManageCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> UpdateCustodianAccount(string storeId, string accountId,
CreateCustodianAccountRequest request, CancellationToken cancellationToken = default)
{
request ??= new CreateCustodianAccountRequest();
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(request.CustodianCode);
// TODO If storeId is not valid, we get a foreign key SQL error. Is this okay or do we want to check the storeId first?
custodianAccount.CustodianCode = custodian.Code;
custodianAccount.StoreId = storeId;
custodianAccount.Name = request.Name;
custodianAccount.SetBlob(request.Config);
await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return Ok(await ToModel(custodianAccount, false, cancellationToken));
}
[HttpDelete("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}")]
[Authorize(Policy = Policies.CanManageCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId)
{
var isDeleted = await _custodianAccountRepository.Remove(accountId, storeId);
if (isDeleted)
{
return Ok();
}
throw CustodianAccountNotFound();
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/addresses/{paymentMethod}")]
[Authorize(Policy = Policies.CanDepositToCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
var config = custodianAccount.GetBlob();
if (custodian is ICanDeposit depositableCustodian)
{
var result = await depositableCustodian.GetDepositAddressAsync(paymentMethod, config, cancellationToken);
return Ok(result);
}
return this.CreateAPIError(400, "deposit-payment-method-not-supported",
$"Deposits to \"{custodian.Name}\" are not supported using \"{paymentMethod}\".");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market")]
[Authorize(Policy = Policies.CanTradeCustodianAccount,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> TradeMarket(string storeId, string accountId,
TradeRequestData request, CancellationToken cancellationToken = default)
{
// TODO add SATS check everywhere. We cannot change to 'BTC' ourselves because the qty / price would be different too.
if ("SATS".Equals(request.FromAsset) || "SATS".Equals(request.ToAsset))
{
return this.CreateAPIError(400, "use-asset-synonym",
$"Please use 'BTC' instead of 'SATS'.");
}
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanTrade tradableCustodian)
{
decimal Qty;
if (request.Qty.EndsWith("%", StringComparison.InvariantCultureIgnoreCase))
{
// Qty is a percentage of current holdings
var percentage = Decimal.Parse( request.Qty.Substring(0, request.Qty.Length - 1), CultureInfo.InvariantCulture);
var config = custodianAccount.GetBlob();
var balances = custodian.GetAssetBalancesAsync(config, cancellationToken).Result;
var fromAssetBalance = balances[request.FromAsset];
var priceQuote =
await tradableCustodian.GetQuoteForAssetAsync(request.FromAsset, request.ToAsset, config, cancellationToken);
Qty = fromAssetBalance / priceQuote.Ask * percentage / 100;
}
else
{
// Qty is an exact amount
Qty = Decimal.Parse(request.Qty, CultureInfo.InvariantCulture);
}
var result = await tradableCustodian.TradeMarketAsync(request.FromAsset, request.ToAsset, Qty,
custodianAccount.GetBlob(), cancellationToken);
return Ok(ToModel(result, accountId, custodianAccount.CustodianCode));
}
return this.CreateAPIError(400, "market-trade-not-supported",
$"Placing market orders on \"{custodian.Name}\" is not supported.");
}
private MarketTradeResponseData ToModel(MarketTradeResult marketTrade, string accountId, string custodianCode)
{
return new MarketTradeResponseData(marketTrade.FromAsset, marketTrade.ToAsset, marketTrade.LedgerEntries, marketTrade.TradeId, accountId, custodianCode);
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote")]
[Authorize(Policy = Policies.CanTradeCustodianAccount, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetTradeQuote(string storeId, string accountId, [FromQuery] string fromAsset, [FromQuery] string toAsset, CancellationToken cancellationToken = default)
{
// TODO add SATS check everywhere. We cannot change to 'BTC' ourselves because the qty / price would be different too.
if ("SATS".Equals(fromAsset) || "SATS".Equals(toAsset))
{
return this.CreateAPIError(400, "use-asset-synonym",
$"Please use 'BTC' instead of 'SATS'.");
}
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanTrade tradableCustodian)
{
var priceQuote = await tradableCustodian.GetQuoteForAssetAsync(fromAsset, toAsset, custodianAccount.GetBlob(), cancellationToken);
return Ok(new TradeQuoteResponseData(priceQuote.FromAsset, priceQuote.ToAsset, priceQuote.Bid, priceQuote.Ask));
}
return this.CreateAPIError(400, "getting-quote-not-supported",
$"Getting a price quote on \"{custodian.Name}\" is not supported.");
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/{tradeId}")]
[Authorize(Policy = Policies.CanTradeCustodianAccount,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanTrade tradableCustodian)
{
var result = await tradableCustodian.GetTradeInfoAsync(tradeId, custodianAccount.GetBlob(), cancellationToken);
if (result == null)
{
return this.CreateAPIError(404, "trade-not-found",
$"Could not find the the trade with ID {tradeId} on {custodianAccount.Name}");
}
return Ok(ToModel(result, accountId, custodianAccount.CustodianCode));
}
return this.CreateAPIError(400, "fetching-trade-info-not-supported",
$"Fetching past trade info on \"{custodian.Name}\" is not supported.");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian)
{
var withdrawResult =
await withdrawableCustodian.WithdrawAsync(request.PaymentMethod, request.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);
}
return this.CreateAPIError(400, "withdrawals-not-supported",
$"Withdrawals are not supported for \"{custodian.Name}\".");
}
async Task<CustodianAccountData> GetCustodian(string storeId, string accountId)
{
var cust = await _custodianAccountRepository.FindById(storeId, accountId);
if (cust is null)
throw CustodianAccountNotFound();
return cust;
}
JsonHttpException CustodianAccountNotFound()
{
return new JsonHttpException(this.CreateAPIError(404, "custodian-account-not-found", "Could not find the custodian account"));
}
ICustodian GetCustodianByCode(string custodianCode)
{
var cust = _custodianRegistry.FirstOrDefault(custodian => custodian.Code.Equals(custodianCode, StringComparison.OrdinalIgnoreCase));
if (cust is null)
throw new JsonHttpException(this.CreateAPIError(422, "custodian-code-not-found", "The custodian of this account isn't referenced in /api/v1/custodians"));
return cust;
}
[HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/{paymentMethod}/{withdrawalId}")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodian(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian)
{
var withdrawResult = await withdrawableCustodian.GetWithdrawalInfoAsync(paymentMethod, withdrawalId, custodianAccount.GetBlob(), cancellationToken);
if (withdrawResult == null)
{
return this.CreateAPIError(404, "withdrawal-not-found", "The withdrawal was not found.");
}
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);
}
return this.CreateAPIError(400, "fetching-withdrawal-info-not-supported",
$"Fetching withdrawal information is not supported for \"{custodian.Name}\".");
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.GreenfieldAPIKeys)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldCustodianController : ControllerBase
{
private readonly IEnumerable<ICustodian> _custodianRegistry;
public GreenfieldCustodianController(IEnumerable<ICustodian> custodianRegistry)
{
_custodianRegistry = custodianRegistry;
}
[HttpGet("~/api/v1/custodians")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public IActionResult ListCustodians()
{
var all = _custodianRegistry.ToList().Select(ToModel);
return Ok(all);
}
private CustodianData ToModel(ICustodian custodian)
{
var result = new CustodianData();
result.Code = custodian.Code;
result.Name = custodian.Name;
if (custodian is ICanTrade tradableCustodian)
{
var tradableAssetPairs = tradableCustodian.GetTradableAssetPairs();
var tradableAssetPairStrings = new string[tradableAssetPairs.Count];
for (int i = 0; i < tradableAssetPairs.Count; i++)
{
tradableAssetPairStrings[i] = tradableAssetPairs[i].ToString();
}
result.TradableAssetPairs = tradableAssetPairStrings;
}
if (custodian is ICanDeposit depositableCustodian)
{
result.DepositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods();
}
if (custodian is ICanWithdraw withdrawableCustodian)
{
result.WithdrawablePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
}
return result;
}
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Fido2; using BTCPayServer.Fido2;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using NBitcoin.Crypto; using NBitcoin.Crypto;
@@ -27,10 +28,12 @@ namespace BTCPayServer
public readonly ConcurrentDictionary<string, byte[]> FinalLoginStore = public readonly ConcurrentDictionary<string, byte[]> FinalLoginStore =
new ConcurrentDictionary<string, byte[]>(); new ConcurrentDictionary<string, byte[]>();
private readonly ApplicationDbContextFactory _contextFactory; private readonly ApplicationDbContextFactory _contextFactory;
private readonly ILogger<LnurlAuthService> _logger;
public LnurlAuthService(ApplicationDbContextFactory contextFactory) public LnurlAuthService(ApplicationDbContextFactory contextFactory, ILogger<LnurlAuthService> logger)
{ {
_contextFactory = contextFactory; _contextFactory = contextFactory;
_logger = logger;
} }
public async Task<byte[]> RequestCreation(string userId) public async Task<byte[]> RequestCreation(string userId)

View File

@@ -143,6 +143,7 @@ namespace BTCPayServer.Controllers
.ToArray()); .ToArray());
return Json(json); return Json(json);
} }
[Route("docs")] [Route("docs")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult SwaggerDocs() public IActionResult SwaggerDocs()

View File

@@ -510,6 +510,16 @@ namespace BTCPayServer.Controllers
{BTCPayServer.Client.Policies.CanDeleteUser, ("Delete user", "The app will be able to delete the user to whom it is assigned. Admin users can delete any user without this permission.")}, {BTCPayServer.Client.Policies.CanDeleteUser, ("Delete user", "The app will be able to delete the user to whom it is assigned. Admin users can delete any user without this permission.")},
{BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to manage invoices on all your stores and modify their settings.")}, {BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to manage invoices on all your stores and modify their settings.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to manage invoices on the selected stores and modify their settings.")}, {$"{BTCPayServer.Client.Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to manage invoices on the selected stores and modify their settings.")},
{BTCPayServer.Client.Policies.CanViewCustodianAccounts, ("View exchange accounts linked to your stores", "The app will be able to see exchange accounts linked to your stores.")},
{$"{BTCPayServer.Client.Policies.CanViewCustodianAccounts}:", ("View exchange accounts linked to selected stores", "The app will be able to see exchange accounts linked to the selected stores.")},
{BTCPayServer.Client.Policies.CanManageCustodianAccounts, ("Manage exchange accounts linked to your stores", "The app will be able to modify exchange accounts linked to your stores.")},
{$"{BTCPayServer.Client.Policies.CanManageCustodianAccounts}:", ("Manage exchange accounts linked to selected stores", "The app will be able to modify exchange accounts linked to selected stores.")},
{BTCPayServer.Client.Policies.CanDepositToCustodianAccounts, ("Deposit funds to exchange accounts linked to your stores", "The app will be able to deposit funds to your exchange accounts.")},
{$"{BTCPayServer.Client.Policies.CanDepositToCustodianAccounts}:", ("Deposit funds to exchange accounts linked to selected stores", "The app will be able to deposit funds to selected store's exchange accounts.")},
{BTCPayServer.Client.Policies.CanWithdrawFromCustodianAccounts, ("Withdraw funds from exchange accounts to your store", "The app will be able to withdraw funds from your exchange accounts to your store.")},
{$"{BTCPayServer.Client.Policies.CanWithdrawFromCustodianAccounts}:", ("Withdraw funds from selected store's exchange accounts", "The app will be able to withdraw funds from your selected store's exchange accounts.")},
{BTCPayServer.Client.Policies.CanTradeCustodianAccount, ("Trade funds on your store's exchange accounts", "The app will be able to trade funds on your store's exchange accounts.")},
{$"{BTCPayServer.Client.Policies.CanTradeCustodianAccount}:", ("Trade funds on selected store's exchange accounts", "The app will be able to trade funds on selected store's exchange accounts.")},
{BTCPayServer.Client.Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "The app will modify the webhooks of all your stores.")}, {BTCPayServer.Client.Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "The app will modify the webhooks of all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "The app will modify the webhooks of the selected stores.")}, {$"{BTCPayServer.Client.Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "The app will modify the webhooks of the selected stores.")},
{BTCPayServer.Client.Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")}, {BTCPayServer.Client.Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")},

View File

@@ -0,0 +1,25 @@
using System;
using NBXplorer;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data;
public static class CustodianAccountDataExtensions
{
public static JObject GetBlob(this CustodianAccountData custodianAccountData)
{
var result = custodianAccountData.Blob == null
? new JObject()
: JObject.Parse(ZipUtils.Unzip(custodianAccountData.Blob));
return result;
}
public static bool SetBlob(this CustodianAccountData custodianAccountData, JObject blob)
{
var original = custodianAccountData.GetBlob();
if (JToken.DeepEquals(original, blob))
return false;
custodianAccountData.Blob = blob is null ? null : ZipUtils.Zip(blob.ToString(Newtonsoft.Json.Formatting.None));
return true;
}
}

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services; using BTCPayServer.Abstractions.Services;
@@ -28,6 +29,7 @@ using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.Greenfield; using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Fees; using BTCPayServer.Services.Fees;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels; using BTCPayServer.Services.Labels;
@@ -118,7 +120,11 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<EventAggregator>(); services.TryAddSingleton<EventAggregator>();
services.TryAddSingleton<PaymentRequestService>(); services.TryAddSingleton<PaymentRequestService>();
services.TryAddSingleton<UserService>(); services.TryAddSingleton<UserService>();
services.AddSingleton<CustodianAccountRepository>();
services.TryAddSingleton<WalletHistogramService>(); services.TryAddSingleton<WalletHistogramService>();
services.TryAddSingleton<CustodianAccountRepository>();
services.AddSingleton<ApplicationDbContextFactory>(); services.AddSingleton<ApplicationDbContextFactory>();
services.AddOptions<BTCPayServerOptions>().Configure( services.AddOptions<BTCPayServerOptions>().Configure(
(options) => (options) =>

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@@ -280,7 +280,9 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
} }
}; };
#pragma warning disable CA1416 // Validate platform compatibility
process.Start(); process.Start();
#pragma warning restore CA1416 // Validate platform compatibility
process.WaitForExit(); process.WaitForExit();
} }

View File

@@ -0,0 +1,71 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services.Custodian.Client
{
public class CustodianAccountRepository
{
private readonly ApplicationDbContextFactory _contextFactory;
public CustodianAccountRepository(ApplicationDbContextFactory contextFactory)
{
_contextFactory = contextFactory;
}
public async Task<CustodianAccountData> CreateOrUpdate(CustodianAccountData entity)
{
await using var context = _contextFactory.CreateContext();
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = Guid.NewGuid().ToString();
await context.CustodianAccount.AddAsync(entity);
}
else
{
context.CustodianAccount.Update(entity);
}
await context.SaveChangesAsync();
return entity;
}
public async Task<bool> Remove(string id, string storeId)
{
await using var context = _contextFactory.CreateContext();
var key = await context.CustodianAccount.SingleOrDefaultAsync(data => data.Id == id && data.StoreId == storeId);
if (key == null)
return false;
context.CustodianAccount.Remove(key);
await context.SaveChangesAsync();
return true;
}
public async Task<CustodianAccountData[]> FindByStoreId(string storeId,
CancellationToken cancellationToken = default)
{
if (storeId is null)
throw new ArgumentNullException(nameof(storeId));
await using var context = _contextFactory.CreateContext();
IQueryable<CustodianAccountData> query = context.CustodianAccount
.Where(ca => ca.StoreId == storeId);
//.SelectMany(c => c.StoreData.Invoices);
var data = await query.ToArrayAsync( cancellationToken).ConfigureAwait(false);
return data;
}
public async Task<CustodianAccountData> FindById(string storeId, string accountId)
{
await using var context = _contextFactory.CreateContext();
IQueryable<CustodianAccountData> query = context.CustodianAccount
.Where(ca => ca.StoreId == storeId && ca.Id == accountId);
var custodianAccountData = (await query.ToListAsync()).FirstOrDefault();
return custodianAccountData;
}
}
}

View File

@@ -0,0 +1,33 @@
@inject BTCPayServer.Security.ContentSecurityPolicies csp
@{
Layout = null;
csp.Add("script-src", "https://cdn.jsdelivr.net");
csp.Add("worker-src", "blob:");
}
<!DOCTYPE html>
<html>
<head>
<title>DRAFT: BTCPay Server Greenfield API</title>
<!-- needed for adaptive design -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="~/main/fonts/Roboto.css" rel="stylesheet" asp-append-version="true">
<link href="~/main/fonts/Montserrat.css" rel="stylesheet" asp-append-version="true">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
@*Ignore this, this is for making the test ClickOnAllSideMenus happy*@
<div class="navbar-brand" style="visibility:collapse;"></div>
<redoc spec-url="@Url.ActionLink("SwaggerDraft")"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.54/bundles/redoc.standalone.js" integrity="sha384-pxWFJkxrlfignEDb+sJ8XrdnJQ+V2bsiRqgPnfmOk1i3KKSubbydbolVZJeKisNY" crossorigin="anonymous"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -76,7 +76,7 @@
"securitySchemes": { "securitySchemes": {
"API_Key": { "API_Key": {
"type": "apiKey", "type": "apiKey",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n", "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.canviewcustodianaccounts`: View exchange accounts linked to your stores\n* `btcpay.store.canmanagecustodianaccounts`: Manage exchange accounts linked to your stores\n* `btcpay.store.candeposittocustodianaccount`: Deposit funds to exchange accounts linked to your stores\n* `btcpay.store.canwithdrawfromcustodianaccount`: Withdraw funds from exchange accounts to your store\n* `btcpay.store.cantradecustodianaccount`: Trade funds on your store's exchange accounts\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
"name": "Authorization", "name": "Authorization",
"in": "header" "in": "header"
}, },