Custodian Account UI: CRUD (#3923)

* 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

* After a utxo rescan, the cached balance should be invalidated

* Fixed Kraken plugin build issues

* Added Kraken plugin to build

* WIP UI + config form

* Create custodian account almost working - only need to add in the config form

* Working form, but lacks refinement

* Viewing balances + Editing custodian account works, but cannot change the withdrawal destination config because that is an object using a name with [] in it

* cleanup

* Minor cleanup, comments

* Working: Delete custodian account

* Moved the MockCustodian used in tests to a new plugin + linked it to the tests

* WIP viewing custodian account balances

* Split the Mock custodian into a Mock + Fake, various UI improvements and minor fixes

* Minor UI fixes

* Removed broken link

* Removed links to anchors as they cannot pass the tests since they use JavaScript

* Removed non-existing link. Even though it was commented out, the test still broke?

* Added TODOs

* Now throwing BadConfigException if API key is invalid

* UI improvements

* Commented out unfinished API endpoints. Can be finished later.

* Show fiat value for fiat assets

* Removed Kraken plugin so I can make a PR


Removed more Kraken files

* Add experimental route on UICustodianAccountsControllre

* Removed unneeded code

* Cleanup code

* Processed Nicolas' feedback

Co-authored-by: Kukks <evilkukka@gmail.com>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Wouter Samaey 2022-07-07 15:42:50 +02:00 committed by GitHub
parent 35f97a6013
commit 2abc35058b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1193 additions and 26 deletions

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians; namespace BTCPayServer.Abstractions.Custodians;
@ -18,4 +19,8 @@ public interface ICustodian
* Get a list of assets and their qty in custody. * Get a list of assets and their qty in custody.
*/ */
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken); Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
public Task<Form.Form> GetConfigForm(JObject config, string locale,
CancellationToken cancellationToken = default);
} }

View file

@ -0,0 +1,14 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Custodians;
namespace BTCPayServer.Abstractions.Extensions;
public static class CustodianExtensions
{
public static ICustodian? GetCustodianByCode(this IEnumerable<ICustodian> custodians, string code)
{
return custodians.FirstOrDefault(custodian => custodian.Code == code);
}
}

View file

@ -0,0 +1,37 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Abstractions.Form;
public class AlertMessage
{
// Corresponds to the Bootstrap CSS "alert alert-xxx" messages:
// Success = green
// Warning = orange
// Danger = red
// Info = blue
public enum AlertMessageType
{
Success,
Warning,
Danger,
Info
}
[JsonConverter(typeof(StringEnumConverter))]
public AlertMessageType Type;
// The translated message to be shown to the user
public string Message;
public AlertMessage()
{
}
public AlertMessage(AlertMessageType type, string message)
{
this.Type = type;
this.Message = message;
}
}

View file

@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace BTCPayServer.Abstractions.Form;
public abstract class Field
{
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type;
// The name of the HTML5 node. Should be used as the key for the posted data.
public string Name;
// The translated label of the field.
public string Label;
// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
// If this is the first the user sees the form, then value and original value are the same. Value changes as the user starts interacting with the form.
public string Value;
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
public string OriginalValue;
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
public string HelpText;
// The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>();
public bool Required = false;
public bool IsValid()
{
return ValidationErrors.Count == 0;
}
}

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace BTCPayServer.Abstractions.Form;
public class Fieldset
{
public Fieldset()
{
this.Fields = new List<Field>();
}
public string Label { get; set; }
public List<Field> Fields { get; set; }
}

View file

@ -0,0 +1,60 @@
using System.Collections.Generic;
namespace BTCPayServer.Abstractions.Form;
public class Form
{
// Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc...
public List<AlertMessage> TopMessages { get; set; } = new();
// Groups of fields in the form
public List<Fieldset> Fieldsets { get; set; } = new();
// Are all the fields valid in the form?
public bool IsValid()
{
foreach (var fieldset in Fieldsets)
{
foreach (var field in fieldset.Fields)
{
if (!field.IsValid())
{
return false;
}
}
}
return true;
}
public Field GetFieldByName(string name)
{
foreach (var fieldset in Fieldsets)
{
foreach (var field in fieldset.Fields)
{
if (name.Equals(field.Name))
{
return field;
}
}
}
return null;
}
public List<string> GetAllNames()
{
var names = new List<string>();
foreach (var fieldset in Fieldsets)
{
foreach (var field in fieldset.Fields)
{
names.Add(field.Name);
}
}
return names;
}
}

View file

@ -0,0 +1,19 @@
namespace BTCPayServer.Abstractions.Form;
public class TextField : Field
{
public TextField(string label, string name, string value, bool required, string helpText)
{
this.Label = label;
this.Name = name;
this.Value = value;
this.OriginalValue = value;
this.Required = required;
this.HelpText = helpText;
this.Type = "text";
}
// TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types.
}

View file

@ -19,6 +19,7 @@ public class CustodianAccountData
[MaxLength(50)] [MaxLength(50)]
public string CustodianCode { get; set; } public string CustodianCode { get; set; }
[Required]
[MaxLength(50)] [MaxLength(50)]
public string Name { get; set; } public string Name { get; set; }

View file

@ -1,28 +1,23 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
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.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;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.JsonConverters;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Custodian.Client.MockCustodian; using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
@ -31,9 +26,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using Xunit.Sdk;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
using JsonReader = Newtonsoft.Json.JsonReader;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@ -2586,7 +2579,7 @@ namespace BTCPayServer.Tests
var clientBasic = await user.CreateClient(); var clientBasic = await user.CreateClient();
var custodians = await clientBasic.GetCustodians(); var custodians = await clientBasic.GetCustodians();
Assert.NotNull(custodians); Assert.NotNull(custodians);
Assert.Single(custodians); Assert.NotEmpty(custodians);
} }
@ -2680,13 +2673,23 @@ namespace BTCPayServer.Tests
Assert.Single(viewerCustodianAccounts); Assert.Single(viewerCustodianAccounts);
Assert.Equal(viewerCustodianAccounts.First().CustodianCode, custodian.Code); Assert.Equal(viewerCustodianAccounts.First().CustodianCode, custodian.Code);
Assert.Null(viewerCustodianAccounts.First().Config); Assert.Null(viewerCustodianAccounts.First().Config);
// Wrong store ID
await AssertApiError(403, "missing-permission", async () => await adminClient.GetCustodianAccounts("WRONG-STORE-ID"));
// Try to fetch 1 // Try to fetch 1 custodian account
// Admin // Admin
var singleAdminCustodianAccount = await adminClient.GetCustodianAccount(storeId, accountId); var singleAdminCustodianAccount = await adminClient.GetCustodianAccount(storeId, accountId);
Assert.NotNull(singleAdminCustodianAccount); Assert.NotNull(singleAdminCustodianAccount);
Assert.Equal(singleAdminCustodianAccount.CustodianCode, custodian.Code); Assert.Equal(singleAdminCustodianAccount.CustodianCode, custodian.Code);
// Wrong store ID
await AssertApiError(403, "missing-permission",async () => await adminClient.GetCustodianAccount("WRONG-STORE-ID", accountId));
// Wrong account ID
await AssertApiError(404, "custodian-account-not-found",async () => await adminClient.GetCustodianAccount(storeId, "WRONG-ACCOUNT-ID"));
// Manager can see, including config // Manager can see, including config
var singleManagerCustodianAccount = await managerClient.GetCustodianAccount(storeId, accountId); var singleManagerCustodianAccount = await managerClient.GetCustodianAccount(storeId, accountId);
@ -2789,8 +2792,6 @@ namespace BTCPayServer.Tests
// Load a custodian, we use the first one we find. // Load a custodian, we use the first one we find.
var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>(); var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>();
var mockCustodian = custodians.First(c => c.Code == "mock"); var mockCustodian = custodians.First(c => c.Code == "mock");
// Create custodian account // Create custodian account
var createCustodianAccountRequest = new CreateCustodianAccountRequest(); var createCustodianAccountRequest = new CreateCustodianAccountRequest();

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians; using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -50,6 +51,11 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
return Task.FromResult(r); return Task.FromResult(r);
} }
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
{
return null;
}
public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken) public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken)
{ {
if (paymentMethod.Equals(DepositPaymentMethod)) if (paymentMethod.Equals(DepositPaymentMethod))

View file

@ -147,6 +147,7 @@
<ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" /> <ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" />
<ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" /> <ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" />
<ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" /> <ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" />
<ProjectReference Include="..\Plugins\BTCPayServer.Plugins.Custodians.FakeCustodian\BTCPayServer.Plugins.Custodians.FakeCustodian.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -8,7 +8,14 @@
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.Components.Icon
@using BTCPayServer.Components.ThemeSwitch
@using BTCPayServer.Components.UIExtensionPoint
@using BTCPayServer.Security
@using BTCPayServer.Services @using BTCPayServer.Services
@using BTCPayServer.TagHelpers
@using BTCPayServer.Views.CustodianAccounts
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject BTCPayServer.Services.BTCPayServerEnvironment Env @inject BTCPayServer.Services.BTCPayServerEnvironment Env
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
@inject PoliciesSettings PoliciesSettings @inject PoliciesSettings PoliciesSettings
@ -92,6 +99,30 @@
</li> </li>
} }
@if (PoliciesSettings.Experimental)
{
@foreach (var custodianAccount in Model.CustodianAccounts)
{
<li class="nav-item">
<a asp-area="" asp-controller="UICustodianAccounts" asp-action="ViewCustodianAccount" asp-route-storeId="@custodianAccount.StoreId" asp-route-accountId="@custodianAccount.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.View, custodianAccount.Id)" id="@($"StoreNav-CustodianAccount-{custodianAccount.Id}")">
<!--
TODO which icon should we use?
-->
<span>@custodianAccount.Name</span>
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>
</a>
</li>
}
<li class="nav-item">
<a asp-area="" asp-controller="UICustodianAccounts" asp-action="CreateCustodianAccount" asp-route-storeId="@Model.Store.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.Create)" id="StoreNav-CreateApp">
<vc:icon symbol="new"/>
<span>Add Custodian</span>
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>
</a>
</li>
}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -6,7 +6,9 @@ using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -25,6 +27,7 @@ namespace BTCPayServer.Components.MainNav
private readonly BTCPayNetworkProvider _networkProvider; private readonly BTCPayNetworkProvider _networkProvider;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
private readonly CustodianAccountRepository _custodianAccountRepository;
public MainNav( public MainNav(
AppService appService, AppService appService,
@ -32,7 +35,9 @@ namespace BTCPayServer.Components.MainNav
UIStoresController storesController, UIStoresController storesController,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary) PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
CustodianAccountRepository custodianAccountRepository,
PoliciesSettings policiesSettings)
{ {
_storeRepo = storeRepo; _storeRepo = storeRepo;
_appService = appService; _appService = appService;
@ -40,6 +45,8 @@ namespace BTCPayServer.Components.MainNav
_networkProvider = networkProvider; _networkProvider = networkProvider;
_storesController = storesController; _storesController = storesController;
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
_custodianAccountRepository = custodianAccountRepository;
PoliciesSettings = policiesSettings;
} }
public async Task<IViewComponentResult> InvokeAsync() public async Task<IViewComponentResult> InvokeAsync()
@ -68,11 +75,21 @@ namespace BTCPayServer.Components.MainNav
AppType = a.AppType, AppType = a.AppType,
IsOwner = a.IsOwner IsOwner = a.IsOwner
}).ToList(); }).ToList();
if (PoliciesSettings.Experimental)
{
// Custodian Accounts
var custodianAccounts = await _custodianAccountRepository.FindByStoreId(store.Id);
vm.CustodianAccounts = custodianAccounts;
}
} }
return View(vm); return View(vm);
} }
private string UserId => _userManager.GetUserId(HttpContext.User); private string UserId => _userManager.GetUserId(HttpContext.User);
public PoliciesSettings PoliciesSettings { get; }
} }
} }

View file

@ -10,6 +10,7 @@ namespace BTCPayServer.Components.MainNav
public List<StoreDerivationScheme> DerivationSchemes { get; set; } public List<StoreDerivationScheme> DerivationSchemes { get; set; }
public List<StoreLightningNode> LightningNodes { get; set; } public List<StoreLightningNode> LightningNodes { get; set; }
public List<StoreApp> Apps { get; set; } public List<StoreApp> Apps { get; set; }
public CustodianAccountData[] CustodianAccounts { get; set; }
public bool AltcoinsBuild { get; set; } public bool AltcoinsBuild { get; set; }
} }

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Custodians; using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -18,6 +19,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Newtonsoft.Json.Linq;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData; using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData; using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
@ -80,10 +82,51 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId, public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId,
[FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default) [FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default)
{ {
var custodianAccountData = await GetCustodian(storeId, accountId); var custodianAccountData = await GetCustodianAccount(storeId, accountId);
if (custodianAccountData == null)
{
return this.CreateAPIError(404, "custodian-account-not-found", "The custodian account was not found.");
}
var custodianAccount = await ToModel(custodianAccountData, assetBalances, cancellationToken); var custodianAccount = await ToModel(custodianAccountData, assetBalances, cancellationToken);
return Ok(custodianAccount); return Ok(custodianAccount);
} }
// [HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/config")]
// [Authorize(Policy = Policies.CanManageCustodianAccounts,
// AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
// public async Task<IActionResult> FetchCustodianAccountConfigForm(string storeId, string accountId,
// [FromQuery] string locale = "en-US", CancellationToken cancellationToken = default)
// {
// // TODO this endpoint needs tests
// var custodianAccountData = await GetCustodianAccount(storeId, accountId);
// var custodianAccount = await ToModel(custodianAccountData, false, cancellationToken);
//
// var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
// var form = await custodian.GetConfigForm(custodianAccount.Config, locale, cancellationToken);
//
// return Ok(form);
// }
//
// [HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/config")]
// [Authorize(Policy = Policies.CanManageCustodianAccounts,
// AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
// public async Task<IActionResult> PostCustodianAccountConfigForm(string storeId, string accountId, JObject values,
// [FromQuery] string locale = "en-US", CancellationToken cancellationToken = default)
// {
// // TODO this endpoint needs tests
// var custodianAccountData = await GetCustodianAccount(storeId, accountId);
// var custodianAccount = await ToModel(custodianAccountData, false, cancellationToken);
//
// var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
// var form = await custodian.GetConfigForm(values, locale, cancellationToken);
//
// if (form.IsValid())
// {
// // TODO save the data to the config so it is persisted
// }
//
// return Ok(form);
// }
private async Task<bool> CanSeeCustodianAccountConfig() private async Task<bool> CanSeeCustodianAccountConfig()
{ {
@ -138,7 +181,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
request ??= new CreateCustodianAccountRequest(); request ??= new CreateCustodianAccountRequest();
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(request.CustodianCode); 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? // 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?
@ -171,7 +214,7 @@ namespace BTCPayServer.Controllers.Greenfield
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken cancellationToken = default) public async Task<IActionResult> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken cancellationToken = default)
{ {
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
var config = custodianAccount.GetBlob(); var config = custodianAccount.GetBlob();
@ -198,7 +241,7 @@ namespace BTCPayServer.Controllers.Greenfield
$"Please use 'BTC' instead of 'SATS'."); $"Please use 'BTC' instead of 'SATS'.");
} }
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanTrade tradableCustodian) if (custodian is ICanTrade tradableCustodian)
@ -248,7 +291,7 @@ namespace BTCPayServer.Controllers.Greenfield
$"Please use 'BTC' instead of 'SATS'."); $"Please use 'BTC' instead of 'SATS'.");
} }
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
@ -267,7 +310,7 @@ namespace BTCPayServer.Controllers.Greenfield
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken cancellationToken = default) public async Task<IActionResult> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken cancellationToken = default)
{ {
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanTrade tradableCustodian) if (custodian is ICanTrade tradableCustodian)
@ -292,7 +335,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> CreateWithdrawal(string storeId, string accountId, public async Task<IActionResult> CreateWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default) WithdrawRequestData request, CancellationToken cancellationToken = default)
{ {
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian) if (custodian is ICanWithdraw withdrawableCustodian)
@ -309,7 +352,7 @@ namespace BTCPayServer.Controllers.Greenfield
} }
async Task<CustodianAccountData> GetCustodian(string storeId, string accountId) async Task<CustodianAccountData> GetCustodianAccount(string storeId, string accountId)
{ {
var cust = await _custodianAccountRepository.FindById(storeId, accountId); var cust = await _custodianAccountRepository.FindById(storeId, accountId);
if (cust is null) if (cust is null)
@ -335,7 +378,7 @@ namespace BTCPayServer.Controllers.Greenfield
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken cancellationToken = default) public async Task<IActionResult> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken cancellationToken = default)
{ {
var custodianAccount = await GetCustodian(storeId, accountId); var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode); var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian) if (custodian is ICanWithdraw withdrawableCustodian)

View file

@ -75,7 +75,7 @@ namespace BTCPayServer
CreationStore.Remove(userId, out _); CreationStore.Remove(userId, out _);
return true; return true;
} }
catch (Exception) catch (Exception e)
{ {
return false; return false;
} }

View file

@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.CustodianAccountViewModels;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
[ExperimentalRouteAttribute]
public class UICustodianAccountsController : Controller
{
public UICustodianAccountsController(
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
CustodianAccountRepository custodianAccountRepository,
IEnumerable<ICustodian> custodianRegistry
)
{
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_userManager = userManager;
_custodianAccountRepository = custodianAccountRepository;
_custodianRegistry = custodianRegistry;
}
private readonly IEnumerable<ICustodian> _custodianRegistry;
private readonly UserManager<ApplicationUser> _userManager;
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly CurrencyNameTable _currencyNameTable;
public string CreatedCustodianAccountId { get; set; }
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}")]
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId)
{
var vm = new ViewCustodianAccountViewModel();
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
vm.Custodian = custodian;
vm.CustodianAccount = custodianAccount;
var store = GetCurrentStore();
var storeBlob = BTCPayServer.Data.StoreDataExtensions.GetStoreBlob(store);
var defaultCurrency = storeBlob.DefaultCurrency;
vm.DefaultCurrency = defaultCurrency;
try
{
var assetBalances = new Dictionary<string, AssetBalanceInfo>();
var assetBalancesData =
await custodian.GetAssetBalancesAsync(custodianAccount.GetBlob(), cancellationToken: default);
foreach (var pair in assetBalancesData)
{
var asset = pair.Key;
assetBalances.Add(asset,
new AssetBalanceInfo { Asset = asset, Qty = pair.Value }
);
}
if (custodian is ICanTrade tradingCustodian)
{
var config = custodianAccount.GetBlob();
var tradableAssetPairs = tradingCustodian.GetTradableAssetPairs();
foreach (var pair in assetBalances)
{
var asset = pair.Key;
var assetBalance = assetBalances[asset];
if (asset.Equals(defaultCurrency))
{
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
}
else
{
try
{
var quote = await tradingCustodian.GetQuoteForAssetAsync(defaultCurrency, asset,
config, default);
assetBalance.Bid = quote.Bid;
assetBalance.Ask = quote.Ask;
assetBalance.FiatAsset = defaultCurrency;
assetBalance.FormattedBid = _currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
assetBalance.FormattedAsk = _currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid, pair.Value.FiatAsset);
assetBalance.TradableAssetPairs = tradableAssetPairs.Where(o => o.AssetBought == asset || o.AssetSold == asset);
}
catch (WrongTradingPairException e)
{
// Cannot trade this asset, just ignore
}
}
}
}
if (custodian is ICanWithdraw withdrawableCustodian)
{
var withdrawableePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
foreach (var withdrawableePaymentMethod in withdrawableePaymentMethods)
{
var withdrawableAsset = withdrawableePaymentMethod.Split("-")[0];
if (assetBalances.ContainsKey(withdrawableAsset))
{
var assetBalance = assetBalances[withdrawableAsset];
assetBalance.CanWithdraw = true;
}
}
}
if (custodian is ICanDeposit depositableCustodian)
{
var depositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods();
foreach (var depositablePaymentMethod in depositablePaymentMethods)
{
var depositableAsset = depositablePaymentMethod.Split("-")[0];
if (assetBalances.ContainsKey(depositableAsset))
{
var assetBalance = assetBalances[depositableAsset];
assetBalance.CanDeposit = true;
}
}
}
vm.AssetBalances = assetBalances;
}
catch (Exception e)
{
vm.GetAssetBalanceException = e;
}
return View(vm);
}
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/edit")]
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId)
{
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
var vm = new EditCustodianAccountViewModel();
vm.CustodianAccount = custodianAccount;
vm.ConfigForm = configForm;
return View(vm);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/edit")]
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId,
EditCustodianAccountViewModel vm)
{
// The locale is not important yet, but keeping it here so we can find it easily when localization becomes a thing.
var locale = "en-US";
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
var newData = new JObject();
foreach (var pair in Request.Form)
{
if ("CustodianAccount.Name".Equals(pair.Key))
{
custodianAccount.Name = pair.Value;
}
else
{
// TODO support posted array notation, like a field called "WithdrawToAddressNamePerPaymentMethod[BTC-OnChain]". The data should be nested in the JSON.
newData.Add(pair.Key, pair.Value.ToString());
}
}
var newConfigData = RemoveUnusedFieldsFromConfig(custodianAccount.GetBlob(), newData, configForm);
var newConfigForm = await custodian.GetConfigForm(newConfigData, locale);
if (newConfigForm.IsValid())
{
custodianAccount.SetBlob(newConfigData);
custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// Form not valid: The user must fix the errors before we can save
vm.CustodianAccount = custodianAccount;
vm.ConfigForm = newConfigForm;
return View(vm);
}
[HttpGet("/stores/{storeId}/custodian-accounts/create")]
public IActionResult CreateCustodianAccount(string storeId)
{
var vm = new CreateCustodianAccountViewModel();
vm.StoreId = storeId;
vm.SetCustodianRegistry(_custodianRegistry);
return View(vm);
}
[HttpPost("/stores/{storeId}/custodian-accounts/create")]
public async Task<IActionResult> CreateCustodianAccount(string storeId, CreateCustodianAccountViewModel vm)
{
var store = GetCurrentStore();
vm.StoreId = store.Id;
vm.SetCustodianRegistry(_custodianRegistry);
var custodian = _custodianRegistry.GetCustodianByCode(vm.SelectedCustodian);
if (custodian == null)
{
ModelState.AddModelError(nameof(vm.SelectedCustodian), "Invalid Custodian");
return View(vm);
}
if (string.IsNullOrEmpty(vm.Name))
{
vm.Name = custodian.Name;
}
var custodianAccountData = new CustodianAccountData { CustodianCode = vm.SelectedCustodian, StoreId = vm.StoreId, Name = custodian.Name };
var configData = new JObject();
foreach (var pair in Request.Form)
{
configData.Add(pair.Key, pair.Value.ToString());
}
var configForm = await custodian.GetConfigForm(configData, "en-US");
if (configForm.IsValid())
{
// configForm.removeUnusedKeys();
custodianAccountData.SetBlob(configData);
custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData);
TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created";
CreatedCustodianAccountId = custodianAccountData.Id;
return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccountData.StoreId, accountId = custodianAccountData.Id });
}
// Ask for more data
vm.ConfigForm = configForm;
return View(vm);
}
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId)
{
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
{
return NotFound();
}
var isDeleted = await _custodianAccountRepository.Remove(custodianAccount.Id, custodianAccount.StoreId);
if (isDeleted)
{
TempData[WellKnownTempData.SuccessMessage] = "Custodian account deleted";
return RedirectToAction("Dashboard", "UIStores", new { storeId });
}
TempData[WellKnownTempData.ErrorMessage] = "Could not delete custodian account";
return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// The JObject may contain too much data because we used ALL post values and this may be more than we needed.
// Because we don't know the form fields beforehand, we will filter out the superfluous data afterwards.
// We will keep all the old keys + merge the new keys as per the current form.
// Since the form can differ by circumstances, we will never remove any keys that were previously stored. We just limit what we add.
private JObject RemoveUnusedFieldsFromConfig(JObject storedData, JObject newData, Form form)
{
JObject filteredData = new JObject();
var storedKeys = new List<string>();
foreach (var item in storedData)
{
storedKeys.Add(item.Key);
}
var formKeys = form.GetAllNames();
foreach (var item in newData)
{
if (storedKeys.Contains(item.Key) || formKeys.Contains(item.Key))
{
filteredData[item.Key] = item.Value;
}
}
return filteredData;
}
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
}
}

View file

@ -1,5 +1,3 @@
using System;
using NBXplorer;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data; namespace BTCPayServer.Data;

View file

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Models.CustodianAccountViewModels;
public class AssetBalanceInfo
{
public string Asset { get; set; }
public decimal? Bid { get; set; }
public decimal? Ask { get; set; }
public decimal Qty { get; set; }
public string FiatAsset { get; set; }
public string FormattedFiatValue { get; set; }
public IEnumerable<AssetPairData> TradableAssetPairs { get; set; }
public bool CanWithdraw { get; set; }
public bool CanDeposit { get; set; }
public string FormattedBid { get; set; }
public string FormattedAsk { get; set; }
}

View file

@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Form;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.CustodianAccountViewModels
{
public class CreateCustodianAccountViewModel
{
public void SetCustodianRegistry(IEnumerable<ICustodian> custodianRegistry)
{
var choices = custodianRegistry.Select(o => new Format
{
Name = o.Name,
Value = o.Code
}).ToArray();
var chosen = choices.FirstOrDefault();
Custodians = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
}
class Format
{
public string Name { get; set; }
public string Value { get; set; }
}
[Required]
[MaxLength(50)]
[MinLength(1)]
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Store")]
public string StoreId { get; set; }
[Required]
[Display(Name = "Custodian")]
public string SelectedCustodian { get; set; }
//
public SelectList Custodians { get; set; }
public Form ConfigForm { get; set; }
}
}

View file

@ -0,0 +1,12 @@
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Data;
namespace BTCPayServer.Models.CustodianAccountViewModels
{
public class EditCustodianAccountViewModel
{
public CustodianAccountData CustodianAccount { get; set; }
public Form ConfigForm { get; set; }
}
}

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Data;
namespace BTCPayServer.Models.CustodianAccountViewModels
{
public class ViewCustodianAccountViewModel
{
public ICustodian Custodian { get; set; }
public CustodianAccountData CustodianAccount { get; set; }
public Dictionary<string,AssetBalanceInfo> AssetBalances { get; set; }
public Exception GetAssetBalanceException { get; set; }
public string DefaultCurrency { get; set; }
}
}

View file

@ -52,7 +52,6 @@ namespace BTCPayServer.Services.Custodian.Client
await using var context = _contextFactory.CreateContext(); await using var context = _contextFactory.CreateContext();
IQueryable<CustodianAccountData> query = context.CustodianAccount IQueryable<CustodianAccountData> query = context.CustodianAccount
.Where(ca => ca.StoreId == storeId); .Where(ca => ca.StoreId == storeId);
//.SelectMany(c => c.StoreData.Invoices);
var data = await query.ToArrayAsync( cancellationToken).ConfigureAwait(false); var data = await query.ToArrayAsync( cancellationToken).ConfigureAwait(false);
return data; return data;

View file

@ -0,0 +1,34 @@
@model BTCPayServer.Abstractions.Form.Form
@foreach (var fieldset in Model.Fieldsets)
{
<fieldset>
<legend>@fieldset.Label</legend>
@foreach (var field in fieldset.Fields)
{
@if ("text".Equals(field.Type))
{
<div class="form-group">
@if (field.Required)
{
<label class="form-label" for="@field.Name" data-required>
@field.Label
</label>
}
else
{
<label class="form-label" for="@field.Name">
@field.Label
</label>
}
<input class="form-control @(@field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="text" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="HelpText@field.Name"/>
<small id="HelpText@field.Name" class="form-text text-muted">
@field.HelpText
</small>
</div>
}
}
</fieldset>
}

View file

@ -0,0 +1,10 @@
@model BTCPayServer.Abstractions.Form.Form
@using ExchangeSharp
@if (Model.TopMessages.Count > 0)
{
@foreach (var alertMessage in Model.TopMessages)
{
<p class="alert alert-@alertMessage.Type.ToStringLowerInvariant()">@alertMessage.Message</p>
}
}

View file

@ -0,0 +1,60 @@
@using BTCPayServer.Views.Apps
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Models.CustodianAccountViewModels.CreateCustodianAccountViewModel
@{
ViewData.SetActivePage(AppsNavPages.Create, "Add a custodian account");
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}
<partial name="_StatusMessage" />
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<form asp-action="CreateCustodianAccount">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@if (!string.IsNullOrEmpty(Model.SelectedCustodian))
{
<partial name="_FormTopMessages" model="Model.ConfigForm" />
}
else if(Model.Custodians.Count() > 0)
{
<div class="form-group">
<label asp-for="SelectedCustodian" class="form-label" data-required></label>
<select asp-for="SelectedCustodian" asp-items="Model.Custodians" class="form-select"></select>
</div>
}
else
{
<p>No custodians available. Install some plugins to add custodian / exchange support.</p>
}
@if (!string.IsNullOrEmpty(Model.SelectedCustodian))
{
<div class="form-group">
<label asp-for="SelectedCustodian" class="form-label" data-required></label>
<select disabled asp-for="SelectedCustodian" asp-items="Model.Custodians" class="form-select"></select>
<input type="hidden" asp-for="SelectedCustodian" />
</div>
<div class="form-group">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required/>
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<partial name="_Form" model="Model.ConfigForm" />
}
<div class="form-group mt-4">
<input type="submit" value="Continue" class="btn btn-primary" id="Continue" />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,7 @@
namespace BTCPayServer.Views.CustodianAccounts
{
public enum CustodianAccountsNavPages
{
View, Create, Update
}
}

View file

@ -0,0 +1,37 @@
@using BTCPayServer.Views.Apps
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Models.CustodianAccountViewModels.EditCustodianAccountViewModel
@{
ViewData.SetActivePage(AppsNavPages.Update, "Edit custodian account");
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
}
<partial name="_StatusMessage"/>
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<form asp-action="EditCustodianAccount">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<partial name="_FormTopMessages" model="Model.ConfigForm"/>
<div class="form-group">
<label asp-for="CustodianAccount.Name" class="form-label" data-required></label>
<input asp-for="CustodianAccount.Name" class="form-control" required/>
<span asp-validation-for="CustodianAccount.Name" class="text-danger"></span>
</div>
<partial name="_Form" model="Model.ConfigForm"/>
<div class="form-group mt-4">
<input type="submit" value="Continue" class="btn btn-primary" id="Save"/>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,190 @@
@using BTCPayServer.Views.Apps
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Custodians
@model BTCPayServer.Models.CustodianAccountViewModels.ViewCustodianAccountViewModel
@{
ViewData.SetActivePage(AppsNavPages.Create, "Custodian account: " + @Model?.CustodianAccount.Name);
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
}
<partial name="_StatusMessage"/>
<div class="sticky-header-setup"></div>
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
<h2 class="mb-0">
@ViewData["Title"]
</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a asp-action="EditCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-primary mt-3 mt-sm-0" role="button" id="EditCustodianAccountConfig">
<span class="fa fa-gear"></span> Configure
</a>
<a asp-action="DeleteCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-danger mt-3 mt-sm-0" role="button" id="DeleteCustodianAccountConfig">
<span class="fa fa-trash"></span> Delete
</a>
<!--
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
<a class="btn btn-secondary" id="ViewApp" target="app_" href="/apps/MQ2sCVsmQ95JBZ4aZDtoSwMAnBY/pos">View</a>
-->
</div>
</div>
<div class="row">
<div class="col-xl-12">
@if (@Model.GetAssetBalanceException != null)
{
<p class="alert alert-danger">
@Model.GetAssetBalanceException.Message
</p>
}
<h2>Balances</h2>
<div class="table-responsive">
<table class="table table-hover table-responsive-md">
<thead>
<tr>
<th>Asset</th>
<th class="text-end">Balance</th>
<th class="text-end">Unit Price (Bid)</th>
<th class="text-end">Unit Price (Ask)</th>
<th class="text-end">Fiat Value</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@if (Model.AssetBalances != null && Model.AssetBalances.Count > 0)
{
@foreach (var pair in Model.AssetBalances.OrderBy(key => key.Key))
{
<tr>
<td>@pair.Key</td>
<!-- TODO format as number? How? -->
<th class="text-end">@pair.Value.Qty</th>
<th class="text-end">
@pair.Value.FormattedBid
</th>
<th class="text-end">
@pair.Value.FormattedAsk
</th>
<th class="text-end">
@(pair.Value.FormattedFiatValue)
</th>
<th class="text-end">
@if (pair.Value.TradableAssetPairs != null)
{
<a data-bs-toggle="modal" data-bs-target="#tradeModal" href="#">Trade</a>
}
@if (pair.Value.CanDeposit)
{
<a data-bs-toggle="modal" data-bs-target="#depositModal" href="#">Deposit</a>
}
@if (pair.Value.CanWithdraw)
{
<a data-bs-toggle="modal" data-bs-target="#withdrawModal" href="#">Withdraw</a>
}
</th>
</tr>
}
}
else if (Model.GetAssetBalanceException == null)
{
<tr>
<td colspan="999" class="text-center">No assets are stored with this custodian (yet).</td>
</tr>
}
else
{
<tr>
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
</tr>
}
</tbody>
</table>
</div>
<h2>Features</h2>
<p>The @Model.Custodian.Name custodian supports:</p>
<ul>
<li>Viewing asset balances</li>
@if (Model.Custodian is ICanTrade)
{
<li>Trading (Greenfield API only, for now)</li>
}
@if (Model.Custodian is ICanDeposit)
{
<li>Depositing (Greenfield API only, for now)</li>
}
@if (Model.Custodian is ICanWithdraw)
{
<li>Withdrawing (Greenfield API only, for now)</li>
}
</ul>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="withdrawModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Withdraw</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Withdrawals are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Withdraw to store wallet" endpoint</a> to execute a withdrawal.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="depositModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deposit</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Deposits are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Get a deposit address for custodian" endpoint</a> to get a deposit address to send your assets to.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog" id="tradeModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Trade</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<p>Trades are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank"> Greenfield API "Trade one asset for another" endpoint</a> to convert an asset to another via a market order.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>BTCPayServer.Plugins.Custodians.FakeCustodian</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,75 @@
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Form;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Custodians.FakeCustodian;
public class FakeCustodian : ICustodian
{
public string Code
{
get => "fake";
}
public string Name
{
get => "Fake Exchange";
}
public Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken)
{
var fakeConfig = ParseConfig(config);
var r = new Dictionary<string, decimal>()
{
{ "BTC", fakeConfig.BTCBalance },
{ "LTC", fakeConfig.LTCBalance },
{ "USD", fakeConfig.USDBalance },
{ "EUR", fakeConfig.EURBalance }
};
return Task.FromResult(r);
}
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
{
var fakeConfig = ParseConfig(config);
var form = new Form();
var fieldset = new Fieldset();
// Maybe a decimal type field would be better?
var fakeBTCBalance = new TextField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
"Enter the amount of BTC you want to have.");
var fakeLTCBalance = new TextField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
"Enter the amount of LTC you want to have.");
var fakeEURBalance = new TextField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
"Enter the amount of EUR you want to have.");
var fakeUSDBalance = new TextField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
"Enter the amount of USD you want to have.");
fieldset.Label = "Your fake balances";
fieldset.Fields.Add(fakeBTCBalance);
fieldset.Fields.Add(fakeLTCBalance);
fieldset.Fields.Add(fakeEURBalance);
fieldset.Fields.Add(fakeUSDBalance);
form.Fieldsets.Add(fieldset);
return Task.FromResult(form);
}
private FakeCustodianConfig? ParseConfig(JObject config)
{
return config?.ToObject<FakeCustodianConfig>();
}
}
public class FakeCustodianConfig
{
public decimal BTCBalance { get; set; }
public decimal LTCBalance { get; set; }
public decimal USDBalance { get; set; }
public decimal EURBalance { get; set; }
public FakeCustodianConfig()
{
}
}

View file

@ -0,0 +1,19 @@
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Models;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Plugins.Custodians.FakeCustodian
{
public class FakeCustodianPlugin : BaseBTCPayServerPlugin
{
public override string Identifier { get; } = "BTCPayServer.Plugins.Custodians.Fake";
public override string Name { get; } = "Custodian: Fake";
public override string Description { get; } = "Adds a fake custodian for testing";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<FakeCustodian>();
services.AddSingleton<ICustodian, FakeCustodian>(provider => provider.GetRequiredService<FakeCustodian>());
}
}
}

View file

@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{1FC7
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Custodians.FakeCustodian", "Plugins\BTCPayServer.Plugins.Custodians.FakeCustodian\BTCPayServer.Plugins.Custodians.FakeCustodian.csproj", "{49E1FE45-FE71-49DF-8701-8394E974BE82}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Altcoins-Debug|Any CPU = Altcoins-Debug|Any CPU Altcoins-Debug|Any CPU = Altcoins-Debug|Any CPU