btcpayserver/BTCPayServer/Controllers/GreenField/GreenfieldCustodianAccountController.cs
Andrew Camilleri 76a6d94bbe
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>
2022-05-18 14:59:56 +09:00

357 lines
18 KiB
C#

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}\".");
}
}
}