new feature: Wallet Receive Page (#1065)

* new feature: Wallet Receive Page

closes #965

* Conserve addresses by waiting till address is spent before generating each run

* fix tests

* Filter by cryptocode before matching outpoint

* fix build

* fix edge case issue

* use address in keypathinfo directly

* rebase fixes

* rebase fixes

* remove duplicate code

* fix messy condition

* fixes

* fix e2e

* fix
This commit is contained in:
Andrew Camilleri 2020-01-18 06:12:27 +01:00 committed by Nicolas Dorier
parent 4ac79a7ea3
commit 025da0261d
15 changed files with 398 additions and 11 deletions

View File

@ -415,7 +415,7 @@ namespace BTCPayServer.Tests
s.Driver.Quit();
}
}
[Fact(Timeout = TestTimeout)]
public async Task CanManageWallet()
{
@ -427,16 +427,55 @@ namespace BTCPayServer.Tests
// In this test, we try to spend from a manual seed. We import the xpub 49'/0'/0', then try to use the seed
// to sign the transaction
var mnemonic = s.GenerateWallet("BTC", "", true, false);
s.GenerateWallet("BTC", "", true, false);
//let's test quickly the receive wallet page
s.Driver.FindElement(By.Id("Wallets")).Click();
s.Driver.FindElement(By.LinkText("Manage")).Click();
s.Driver.FindElement(By.Id("WalletReceive")).Click();
//generate a receiving address
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed);
var receiveAddr = s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value");
//unreserve
s.Driver.FindElement(By.CssSelector("button[value=unreserve-current-address]")).Click();
//generate it again, should be the same one as before as nothign got used in the meantime
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed);
Assert.Equal( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"));
//send money to addr and ensure it changed
var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync();
sess.ListenAllTrackedSource();
var nextEvent = sess.NextEventAsync();
s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(receiveAddr, Network.RegTest),
Money.Parse("0.1"));
await nextEvent;
await Task.Delay(200);
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"));
receiveAddr = s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value");
//change the wallet and ensure old address is not there and generating a new one does not result in the prev one
s.GoToStore(storeId.storeId);
s.GenerateWallet("BTC", "", true, false);
s.Driver.FindElement(By.Id("Wallets")).Click();
s.Driver.FindElement(By.LinkText("Manage")).Click();
s.Driver.FindElement(By.Id("WalletReceive")).Click();
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"));
var invoiceId = s.CreateInvoice(storeId.storeId);
var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
var address = invoice.EntityToDTO().Addresses["BTC"];
var result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
Assert.True(result.IsWatchOnly);
s.GoToStore(storeId.storeId);
mnemonic = s.GenerateWallet("BTC", "", true, true);
var mnemonic = s.GenerateWallet("BTC", "", true, true);
var root = new Mnemonic(mnemonic).DeriveExtKey();
invoiceId = s.CreateInvoice(storeId.storeId);

View File

@ -9,6 +9,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
@ -267,6 +268,11 @@ namespace BTCPayServer.Controllers
}
await _Repo.UpdateStore(store);
_EventAggregator.Publish(new WalletChangedEvent()
{
WalletId = new WalletId(storeId, cryptoCode)
});
if (willBeExcluded != wasExcluded)
{
var label = willBeExcluded ? "disabled" : "enabled";

View File

@ -58,6 +58,7 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
SettingsRepository settingsRepository,
IAuthorizationService authorizationService,
EventAggregator eventAggregator,
CssThemeManager cssThemeManager)
{
_RateFactory = rateFactory;
@ -74,6 +75,7 @@ namespace BTCPayServer.Controllers
_settingsRepository = settingsRepository;
_authorizationService = authorizationService;
_CssThemeManager = cssThemeManager;
_EventAggregator = eventAggregator;
_NetworkProvider = networkProvider;
_ExplorerProvider = explorerProvider;
_FeeRateProvider = feeRateProvider;
@ -100,6 +102,7 @@ namespace BTCPayServer.Controllers
private readonly SettingsRepository _settingsRepository;
private readonly IAuthorizationService _authorizationService;
private readonly CssThemeManager _CssThemeManager;
private readonly EventAggregator _EventAggregator;
[TempData]
public bool StoreNotConfigured

View File

@ -8,10 +8,12 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
@ -23,6 +25,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
@ -49,6 +52,8 @@ namespace BTCPayServer.Controllers
private readonly IAuthorizationService _authorizationService;
private readonly IFeeProviderFactory _feeRateProvider;
private readonly BTCPayWalletProvider _walletProvider;
private readonly WalletReceiveStateService _WalletReceiveStateService;
private readonly EventAggregator _EventAggregator;
public RateFetcher RateFetcher { get; }
CurrencyNameTable _currencyTable;
@ -63,7 +68,9 @@ namespace BTCPayServer.Controllers
IAuthorizationService authorizationService,
ExplorerClientProvider explorerProvider,
IFeeProviderFactory feeRateProvider,
BTCPayWalletProvider walletProvider)
BTCPayWalletProvider walletProvider,
WalletReceiveStateService walletReceiveStateService,
EventAggregator eventAggregator)
{
_currencyTable = currencyTable;
Repository = repo;
@ -77,6 +84,8 @@ namespace BTCPayServer.Controllers
ExplorerClientProvider = explorerProvider;
_feeRateProvider = feeRateProvider;
_walletProvider = walletProvider;
_WalletReceiveStateService = walletReceiveStateService;
_EventAggregator = eventAggregator;
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
@ -231,6 +240,7 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("{walletId}")]
[Route("{walletId}/transactions")]
public async Task<IActionResult> WalletTransactions(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string labelFilter = null)
@ -294,6 +304,76 @@ namespace BTCPayServer.Controllers
return $"{walletId}:{txId}";
}
[HttpGet]
[Route("{walletId}/receive")]
public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string statusMessage = null)
{
if (walletId?.StoreId == null)
return NotFound();
DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId?.CryptoCode);
if (network == null)
return NotFound();
var address = _WalletReceiveStateService.Get(walletId)?.Address;
if (!string.IsNullOrEmpty(statusMessage))
{
TempData[WellKnownTempData.SuccessMessage] = statusMessage;
}
return View(new WalletReceiveViewModel()
{
CryptoCode = walletId.CryptoCode,
Address = address?.ToString(),
CryptoImage = GetImage(paymentMethod.PaymentId, network)
});
}
[HttpPost]
[Route("{walletId}/receive")]
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletReceiveViewModel viewModel, string command)
{
if (walletId?.StoreId == null)
return NotFound();
DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId?.CryptoCode);
if (network == null)
return NotFound();
var statusMessage = string.Empty;
var wallet = _walletProvider.GetWallet(network);
switch (command)
{
case "unreserve-current-address":
KeyPathInformation cachedAddress = _WalletReceiveStateService.Get(walletId);
if (cachedAddress == null)
{
break;
}
var address = cachedAddress.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork);
ExplorerClientProvider.GetExplorerClient(network)
.CancelReservation(cachedAddress.DerivationStrategy, new[] {cachedAddress.KeyPath});
statusMessage = new StatusMessageModel()
{
AllowDismiss =true,
Message = $"Address {address} was unreserved.",
Severity = StatusMessageModel.StatusSeverity.Success,
}.ToString();
_WalletReceiveStateService.Remove(walletId);
break;
case "generate-new-address":
var reserve = (await wallet.ReserveAddressAsync(paymentMethod.AccountDerivation));
_WalletReceiveStateService.Set(walletId, reserve);
break;
}
return RedirectToAction(nameof(WalletReceive), new {walletId, statusMessage});
}
[HttpGet]
[Route("{walletId}/send")]
public async Task<IActionResult> WalletSend(
@ -957,6 +1037,23 @@ namespace BTCPayServer.Controllers
return NotFound();
}
}
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
{
var res = paymentMethodId.PaymentType == PaymentTypes.BTCLike
? Url.Content(network.CryptoImagePath)
: Url.Content(network.LightningImagePath);
return "/" + res;
}
}
public class WalletReceiveViewModel
{
public string CryptoImage { get; set; }
public string CryptoCode { get; set; }
public string Address { get; set; }
}

View File

@ -0,0 +1,10 @@
using NBXplorer.Models;
namespace BTCPayServer.Events
{
public class NewOnChainTransactionEvent
{
public NewTransactionEvent NewTransactionEvent { get; set; }
public string CryptoCode { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace BTCPayServer.Events
{
public class WalletChangedEvent
{
public WalletId WalletId { get; set; }
}
}

View File

@ -0,0 +1,51 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Events;
using BTCPayServer.Services.Wallets;
using Microsoft.Extensions.Hosting;
using NBXplorer;
namespace BTCPayServer.HostedServices
{
public class WalletReceiveCacheUpdater : IHostedService
{
private readonly EventAggregator _EventAggregator;
private readonly WalletReceiveStateService _WalletReceiveStateService;
private readonly CompositeDisposable _Leases = new CompositeDisposable();
public WalletReceiveCacheUpdater(EventAggregator eventAggregator,
WalletReceiveStateService walletReceiveStateService)
{
_EventAggregator = eventAggregator;
_WalletReceiveStateService = walletReceiveStateService;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_Leases.Add(_EventAggregator.Subscribe<WalletChangedEvent>(evt =>
_WalletReceiveStateService.Remove(evt.WalletId)));
_Leases.Add(_EventAggregator.Subscribe<NewOnChainTransactionEvent>(evt =>
{
var matching = _WalletReceiveStateService
.GetByDerivation(evt.CryptoCode, evt.NewTransactionEvent.DerivationStrategy).Where(pair =>
evt.NewTransactionEvent.Outputs.Any(output => output.ScriptPubKey == pair.Value.ScriptPubKey));
foreach (var keyValuePair in matching)
{
_WalletReceiveStateService.Remove(keyValuePair.Key);
}
}));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_Leases.Dispose();
return Task.CompletedTask;
}
}
}

View File

@ -177,6 +177,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<StoreRepository>();
services.TryAddSingleton<PaymentRequestRepository>();
services.TryAddSingleton<BTCPayWalletProvider>();
services.TryAddSingleton<WalletReceiveStateService>();
services.TryAddSingleton<CurrencyNameTable>();
services.TryAddSingleton<IFeeProviderFactory>(o => new NBXplorerFeeProviderFactory(o.GetRequiredService<ExplorerClientProvider>())
{
@ -216,6 +217,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>();
services.AddSingleton<IHostedService, WalletReceiveCacheUpdater>();
services.AddSingleton<IBackgroundJobClient, BackgroundJobClient>();
services.AddScoped<IAuthorizationHandler, CookieAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OpenIdAuthorizationHandler>();

View File

@ -10,6 +10,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using NBitcoin;
using NBXplorer.Models;
namespace BTCPayServer.Payments.Bitcoin
{
@ -35,7 +36,7 @@ namespace BTCPayServer.Payments.Bitcoin
{
public Task<FeeRate> GetFeeRate;
public Task<FeeRate> GetNetworkFeeRate;
public Task<BitcoinAddress> ReserveAddress;
public Task<KeyPathInformation> ReserveAddress;
}
public override void PreparePaymentModel(PaymentModel model, InvoiceResponse invoiceResponse,
@ -141,7 +142,7 @@ namespace BTCPayServer.Payments.Bitcoin
onchainMethod.NextNetworkFee = Money.Zero;
break;
}
onchainMethod.DepositAddress = (await prepare.ReserveAddress).ToString();
onchainMethod.DepositAddress = (await prepare.ReserveAddress).Address.ToString();
return onchainMethod;
}
}

View File

@ -145,6 +145,11 @@ namespace BTCPayServer.Payments.Bitcoin
break;
case NBXplorer.Models.NewTransactionEvent evt:
wallet.InvalidateCache(evt.DerivationStrategy);
_Aggregator.Publish(new NewOnChainTransactionEvent()
{
CryptoCode = wallet.Network.CryptoCode,
NewTransactionEvent = evt
});
foreach (var output in network.GetValidOutputs(evt))
{
var key = output.Item1.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
@ -373,7 +378,7 @@ namespace BTCPayServer.Payments.Bitcoin
paymentMethod.Calculate().Due > Money.Zero)
{
var address = await wallet.ReserveAddressAsync(strategy);
btc.DepositAddress = address.ToString();
btc.DepositAddress = address.Address.ToString();
await _InvoiceRepository.NewAddress(invoice.Id, btc, wallet.Network);
_Aggregator.Publish(new InvoiceNewAddressEvent(invoice.Id, address.ToString(), wallet.Network));
paymentMethod.SetPaymentMethodDetails(btc);

View File

@ -63,7 +63,7 @@ namespace BTCPayServer.Services.Wallets
public TimeSpan CacheSpan { get; private set; } = TimeSpan.FromMinutes(5);
public async Task<BitcoinAddress> ReserveAddressAsync(DerivationStrategyBase derivationStrategy)
public async Task<KeyPathInformation> ReserveAddressAsync(DerivationStrategyBase derivationStrategy)
{
if (derivationStrategy == null)
throw new ArgumentNullException(nameof(derivationStrategy));
@ -74,8 +74,7 @@ namespace BTCPayServer.Services.Wallets
await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false);
pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).ConfigureAwait(false);
}
return pathInfo.Address;
return pathInfo;
}
public async Task<(BitcoinAddress, KeyPath)> GetChangeAddressAsync(DerivationStrategyBase derivationStrategy)

View File

@ -0,0 +1,44 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
namespace BTCPayServer.Services.Wallets
{
public class WalletReceiveStateService
{
private readonly ConcurrentDictionary<WalletId, KeyPathInformation> _walletReceiveState =
new ConcurrentDictionary<WalletId, KeyPathInformation>();
public void Remove(WalletId walletId)
{
_walletReceiveState.TryRemove(walletId, out _);
}
public KeyPathInformation Get(WalletId walletId)
{
if (_walletReceiveState.ContainsKey(walletId))
{
return _walletReceiveState[walletId];
}
return null;
}
public void Set(WalletId walletId, KeyPathInformation information)
{
_walletReceiveState.AddOrReplace(walletId, information);
}
public IEnumerable<KeyValuePair<WalletId, KeyPathInformation>> GetByDerivation(string cryptoCode,
DerivationStrategyBase derivationStrategyBase)
{
return _walletReceiveState.Where(pair =>
pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) &&
pair.Value.DerivationStrategy == derivationStrategyBase);
}
}
}

View File

@ -0,0 +1,121 @@
@using Microsoft.AspNetCore.Mvc.ModelBinding
@model BTCPayServer.Controllers.WalletReceiveViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Manage wallet";
ViewData.SetActivePageAndTitle(WalletsNavPages.Receive);
}
<div class="row no-gutters">
<div class="col-lg-6 mx-auto my-auto ">
<form method="post" asp-action="WalletReceive" class="card text-center">
@if (string.IsNullOrEmpty(Model.Address))
{
<h2 class="card-title">Receive @Model.CryptoCode</h2>
<button class="btn btn-lg btn-primary m-2" type="submit" name="command" value="generate-new-address">Generate @Model.CryptoCode address</button>
}
else
{
<h2 class="card-title">Next available @Model.CryptoCode address</h2>
<noscript>
<div class="card-body m-sm-0 p-sm-0">
<div class="input-group">
<input type="text" class="form-control " readonly="readonly" asp-for="Address" id="address"/>
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-link">Unreserve this address</button>
</div>
</noscript>
<div class="only-for-js card-body m-sm-0 p-sm-0" id="app">
<div class="qr-container mb-2">
<img v-bind:src="srvModel.cryptoImage" class="qr-icon" />
<qrcode v-bind:value="srvModel.address" :options="{ width: 256, margin: 0, color: {dark:'#000', light:'#fff'} }" tag="svg">
</qrcode>
</div>
<div class="input-group copy" data-clipboard-target="#vue-address">
<input type="text" class=" form-control " readonly="readonly" :value="srvModel.address" id="vue-address"/>
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-link">Unreserve this address</button>
</div>
}
</form>
</div>
</div>
@section HeadScripts
{
<script src="~/bundles/lightning-node-info-bundle.min.js" type="text/javascript"></script>
<script type="text/javascript">
var srvModel = @Safe.Json(Model);
window.onload = function() {
if($("#app").length <1){
return;
}
Vue.use(Toasted);
var app = new Vue({
el: '#app',
components: {
qrcode: VueQrcode
},
data: {
srvModel: srvModel
},
mounted: function() {
this.$nextTick(function() {
var copyInput = new Clipboard('.copy');
copyInput.on("success",
function(e) {
Vue.toasted.show('Copied',
{
iconPack: "fontawesome",
icon: "copy",
duration: 5000
});
});
});
}
});
}
</script>
<style>
.qr-icon {
height: 64px;
width: 64px;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}
.qr-container {
position: relative;
text-align: center;
}
.qr-container svg {
width: 256px;
height: 256px;
}
.copy {
cursor: copy;
}
</style>
}

View File

@ -11,6 +11,7 @@ namespace BTCPayServer.Views.Wallets
Transactions,
Rescan,
PSBT,
Settings
Settings,
Receive
}
}

View File

@ -10,6 +10,7 @@
{
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Send)" asp-action="WalletSend" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSend">Send</a>
}
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Receive)" asp-action="WalletReceive" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletReceive">Receive</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Rescan)" asp-action="WalletRescan" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletRescan">Rescan</a>
@if (!network.ReadonlyWallet)
{