Make wallet able to send to multiple destinations (#847)

* Make wallet able to send to multiple destinations

* fix tests

* update e2e tests

* fix e2e part 2

* make headless again

* pr changes

* make wallet look exactly as old one when only 1 dest
This commit is contained in:
Andrew Camilleri 2019-05-21 08:10:07 +00:00 committed by Nicolas Dorier
parent 3d436c3b0e
commit 88c931ec13
11 changed files with 304 additions and 98 deletions

View File

@ -53,8 +53,14 @@ namespace BTCPayServer.Tests
var sendDestination = new Key().PubKey.Hash.GetAddress(user.SupportedNetwork.NBitcoinNetwork).ToString();
var sendModel = new WalletSendModel()
{
Destination = sendDestination,
Amount = 0.1m,
Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
{
DestinationAddress = sendDestination,
Amount = 0.1m,
}
},
FeeSatoshiPerByte = 1,
CurrentBalance = 1.5m
};

View File

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

View File

@ -287,8 +287,7 @@ namespace BTCPayServer.Tests
// Send to bob
s.Driver.FindElement(By.Id("WalletSend")).Click();
var bob = new Key().PubKey.Hash.GetAddress(Network.RegTest);
s.Driver.FindElement(By.Id("Destination")).SendKeys(bob.ToString());
s.Driver.FindElement(By.Id("Amount")).SendKeys("1");
SetTransactionOutput(0, bob, 1);
s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=seed]")).Click();
@ -302,6 +301,19 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
Assert.Equal(walletTransactionLink, s.Driver.Url);
}
void SetTransactionOutput(int index, BitcoinAddress dest, decimal amount, bool subtract = false)
{
s.Driver.FindElement(By.Id($"Outputs_{index}__DestinationAddress")).SendKeys(dest.ToString());
var amountElement = s.Driver.FindElement(By.Id($"Outputs_{index}__Amount"));
amountElement.Clear();
amountElement.SendKeys(amount.ToString());
var checkboxElement = s.Driver.FindElement(By.Id($"Outputs_{index}__SubtractFeesFromOutput"));
if (checkboxElement.Selected != subtract)
{
checkboxElement.Click();
}
}
SignWith(mnemonic);
var accountKey = root.Derive(new KeyPath("m/49'/0'/0'")).GetWif(Network.RegTest).ToString();
SignWith(accountKey);

View File

@ -1672,8 +1672,14 @@ namespace BTCPayServer.Tests
var wallet = tester.PayTester.GetController<WalletsController>();
var psbt = wallet.CreatePSBT(btcNetwork, onchainBTC, new WalletSendModel()
{
Amount = 0.5m,
Destination = new Key().PubKey.GetAddress(btcNetwork.NBitcoinNetwork).ToString(),
Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
{
Amount = 0.5m,
DestinationAddress = new Key().PubKey.GetAddress(btcNetwork.NBitcoinNetwork).ToString(),
}
},
FeeSatoshiPerByte = 1
}, default).GetAwaiter().GetResult();

View File

@ -19,21 +19,28 @@ namespace BTCPayServer.Controllers
{
var nbx = ExplorerClientProvider.GetExplorerClient(network);
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
CreatePSBTDestination psbtDestination = new CreatePSBTDestination();
psbtRequest.Destinations.Add(psbtDestination);
foreach (var transactionOutput in sendModel.Outputs)
{
var psbtDestination = new CreatePSBTDestination();
psbtRequest.Destinations.Add(psbtDestination);
psbtDestination.Destination = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
psbtDestination.Amount = Money.Coins(transactionOutput.Amount.Value);
psbtDestination.SubstractFees = transactionOutput.SubtractFeesFromOutput;
}
if (network.SupportRBF)
{
psbtRequest.RBF = !sendModel.DisableRBF;
}
psbtDestination.Destination = BitcoinAddress.Create(sendModel.Destination, network.NBitcoinNetwork);
psbtDestination.Amount = Money.Coins(sendModel.Amount.Value);
psbtRequest.FeePreference = new FeePreference();
psbtRequest.FeePreference.ExplicitFeeRate = new FeeRate(Money.Satoshis(sendModel.FeeSatoshiPerByte), 1);
if (sendModel.NoChange)
{
psbtRequest.ExplicitChangeAddress = psbtDestination.Destination;
psbtRequest.ExplicitChangeAddress = psbtRequest.Destinations.First().Destination;
}
psbtDestination.SubstractFees = sendModel.SubstractFees;
var psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken));
if (psbt == null)
throw new NotSupportedException("You need to update your version of NBXplorer");

View File

@ -163,13 +163,20 @@ namespace BTCPayServer.Controllers
var rateRules = store.GetStoreBlob().GetRateRules(NetworkProvider);
rateRules.Spread = 0.0m;
var currencyPair = new Rating.CurrencyPair(paymentMethod.PaymentId.CryptoCode, GetCurrencyCode(storeData.DefaultLang) ?? "USD");
WalletSendModel model = new WalletSendModel()
double.TryParse(defaultAmount, out var amount);
var model = new WalletSendModel()
{
Destination = defaultDestination,
Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
{
Amount = Convert.ToDecimal(amount),
DestinationAddress = defaultDestination
}
},
CryptoCode = walletId.CryptoCode
};
if (double.TryParse(defaultAmount, out var amount))
model.Amount = (decimal)amount;
var feeProvider = _feeRateProvider.CreateFeeProvider(network);
var recommendedFees = feeProvider.GetFeeRateAsync();
@ -204,7 +211,7 @@ namespace BTCPayServer.Controllers
[Route("{walletId}/send")]
public async Task<IActionResult> WalletSend(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendModel vm, string command = null, CancellationToken cancellation = default)
WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default)
{
if (walletId?.StoreId == null)
return NotFound();
@ -215,17 +222,76 @@ namespace BTCPayServer.Controllers
if (network == null)
return NotFound();
vm.SupportRBF = network.SupportRBF;
var destination = ParseDestination(vm.Destination, network.NBitcoinNetwork);
if (destination == null)
ModelState.AddModelError(nameof(vm.Destination), "Invalid address");
if (vm.Amount.HasValue)
decimal transactionAmountSum = 0;
if (command == "add-output")
{
if (vm.CurrentBalance == vm.Amount.Value && !vm.SubstractFees)
ModelState.AddModelError(nameof(vm.Amount), "You are sending all your balance to the same destination, you should substract the fees");
if (vm.CurrentBalance < vm.Amount.Value)
ModelState.AddModelError(nameof(vm.Amount), "You are sending more than what you own");
vm.Outputs.Add(new WalletSendModel.TransactionOutput());
return View(vm);
}
if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase))
{
var index = int.Parse(command.Substring(command.IndexOf(":",StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture);
vm.Outputs.RemoveAt(index);
return View(vm);
}
if (!vm.Outputs.Any())
{
ModelState.AddModelError(string.Empty,
"Please add at least one transaction output");
return View(vm);
}
var subtractFeesOutputsCount = new List<int>();
for (var i = 0; i < vm.Outputs.Count; i++)
{
var transactionOutput = vm.Outputs[i];
if (transactionOutput.SubtractFeesFromOutput)
{
subtractFeesOutputsCount.Add(i);
}
var destination = ParseDestination(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
if (destination == null)
ModelState.AddModelError(nameof(transactionOutput.DestinationAddress), "Invalid address");
if (transactionOutput.Amount.HasValue)
{
transactionAmountSum += transactionOutput.Amount.Value;
if (vm.CurrentBalance == transactionOutput.Amount.Value &&
!transactionOutput.SubtractFeesFromOutput)
vm.AddModelError(model => model.Outputs[i].SubtractFeesFromOutput,
"You are sending your entire balance to the same destination, you should subtract the fees",
ModelState);
}
}
if (subtractFeesOutputsCount.Count > 1)
{
foreach (var subtractFeesOutput in subtractFeesOutputsCount)
{
vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput,
"You can only subtract fees from one output", ModelState);
}
}else if (vm.CurrentBalance == transactionAmountSum && vm.Outputs.Count > 1)
{
ModelState.AddModelError(string.Empty,
"You are sending your entire balance, you should subtract the fees from an output");
}
if (vm.CurrentBalance < transactionAmountSum)
{
for (var i = 0; i < vm.Outputs.Count; i++)
{
vm.AddModelError(model => model.Outputs[i].Amount,
"You are sending more than what you own", ModelState);
}
}
if (!ModelState.IsValid)
return View(vm);
@ -238,15 +304,16 @@ namespace BTCPayServer.Controllers
}
catch (NBXplorerException ex)
{
ModelState.AddModelError(nameof(vm.Amount), ex.Error.Message);
ModelState.AddModelError(string.Empty, ex.Error.Message);
return View(vm);
}
catch (NotSupportedException)
{
ModelState.AddModelError(nameof(vm.Destination), "You need to update your version of NBXplorer");
ModelState.AddModelError(string.Empty, "You need to update your version of NBXplorer");
return View(vm);
}
derivationScheme.RebaseKeyPaths(psbt.PSBT);
switch (command)
{
case "ledger":
@ -254,10 +321,13 @@ namespace BTCPayServer.Controllers
case "seed":
return SignWithSeed(walletId, psbt.PSBT.ToBase64());
case "analyze-psbt":
return RedirectToAction(nameof(WalletPSBT), new { walletId = walletId, psbt = psbt.PSBT.ToBase64(), FileName= $"Send-{vm.Amount.Value}-{network.CryptoCode}-to-{destination[0].ToString()}.psbt" });
var name =
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
return RedirectToAction(nameof(WalletPSBT), new { walletId = walletId, psbt = psbt.PSBT.ToBase64(), FileName= name });
default:
return View(vm);
}
}
private ViewResult ViewWalletSendLedger(PSBT psbt, BitcoinAddress hintChange = null)

View File

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
namespace BTCPayServer.Services

View File

@ -0,0 +1,29 @@
using System;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal;
namespace BTCPayServer
{
public static class ModelStateExtensions
{
public static void AddModelError<TModel, TProperty>(
this ModelStateDictionary modelState,
Expression<Func<TModel, TProperty>> ex,
string message
)
{
var key = ExpressionHelper.GetExpressionText(ex);
modelState.AddModelError(key, message);
}
public static void AddModelError<TModel, TProperty>(this TModel source,
Expression<Func<TModel, TProperty>> ex,
string message,
ModelStateDictionary modelState)
{
var key = ExpressionHelper.GetExpressionText(ex);
modelState.AddModelError(key, message);
}
}
}

View File

@ -8,22 +8,27 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendModel
{
[Required]
public string Destination { get; set; }
public List<TransactionOutput> Outputs { get; set; } = new List<TransactionOutput>();
[Range(0.0, double.MaxValue)]
[Required]
public decimal? Amount { get; set; }
public class TransactionOutput
{
[Display(Name = "Destination Address")]
[Required]
public string DestinationAddress { get; set; }
[Display(Name = "Amount")] [Required] [Range(0.0, double.MaxValue)]public decimal? Amount { get; set; }
[Display(Name = "Subtract fees from this output amount")]
public bool SubtractFeesFromOutput { get; set; }
}
public decimal CurrentBalance { get; set; }
public string CryptoCode { get; set; }
public int RecommendedSatoshiPerByte { get; set; }
[Display(Name = "Subtract fees from amount")]
public bool SubstractFees { get; set; }
[Range(1, int.MaxValue)]
[Display(Name = "Fee rate (satoshi per byte)")]
[Required]

View File

@ -1,4 +1,5 @@
@model WalletSendModel
@using Microsoft.AspNetCore.Mvc.ModelBinding
@model WalletSendModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Manage wallet";
@ -10,52 +11,96 @@
<div class="row">
<div class="col-md-10">
<p>
Send funds to a destination address.
Send funds to destinations
</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="row no-gutters">
<div class="col-md-12">
<form method="post">
<input type="hidden" asp-for="Divisibility" />
<input type="hidden" asp-for="Fiat" />
<input type="hidden" asp-for="Rate" />
<input type="hidden" asp-for="CurrentBalance" />
<input type="hidden" asp-for="RecommendedSatoshiPerByte" />
<input type="hidden" asp-for="CryptoCode" />
<div class="form-group">
<label asp-for="Destination"></label>
<input asp-for="Destination" class="form-control" />
<span asp-validation-for="Destination" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Amount"></label>
<div class="input-group">
<input asp-for="Amount" asp-format="{0}" class="form-control" onkeyup='updateFiatValue();' />
<div class="input-group-prepend">
<span class="input-group-text text-muted" style="display:none;" id="fiatValue"></span>
<input type="hidden" asp-for="Divisibility"/>
<input type="hidden" asp-for="Fiat"/>
<input type="hidden" asp-for="Rate"/>
<input type="hidden" asp-for="CurrentBalance"/>
<input type="hidden" asp-for="RecommendedSatoshiPerByte"/>
<input type="hidden" asp-for="CryptoCode"/>
<ul class="text-danger">
@foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid))
{
foreach (var error in
errors.Value.Errors)
{
<li>@error.ErrorMessage</li>
}
}
</ul>
<div class="list-group mb-2">
@if (Model.Outputs.Count > 1)
{
<div class="list-group-item ">
<h5 class="mb-1">Destinations</h5>
</div>
</div>
<span asp-validation-for="Amount" class="text-danger"></span>
<p class="form-text text-muted crypto-info">
Your current balance is <a id="crypto-balance-link" href="#"><span>@Model.CurrentBalance</span></a> <span>@Model.CryptoCode</span>.
</p>
}
@for (var index = 0; index < Model.Outputs.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2 @(Model.Outputs.Count==1? "border-0": "") ">
<div class="row">
<div class="col-sm-12 col-md-12 @(Model.Outputs.Count==1? "col-lg-12": "col-lg-10") py-2 ">
<div class="form-group">
<label asp-for="Outputs[index].Amount" class="control-label"></label>
<div class="input-group">
<input asp-for="Outputs[index].Amount" type="number" step="any" asp-format="{0}" class="form-control output-amount"/>
<div class="input-group-prepend">
<span class="input-group-text text-muted fiat-value" style="display:none;"></span>
</div>
</div>
<p class="form-text text-muted crypto-info">
Your current balance is
<button type="button" class="crypto-balance-link btn btn-link p-0">@Model.CurrentBalance</button> <span>@Model.CryptoCode</span>.
</p>
<span asp-validation-for="Outputs[index].Amount" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Outputs[index].DestinationAddress" class="control-label"></label>
<input asp-for="Outputs[index].DestinationAddress" class="form-control"/>
<span asp-validation-for="Outputs[index].DestinationAddress" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Outputs[index].SubtractFeesFromOutput" class="control-label"></label>
<input type="checkbox" asp-for="Outputs[index].SubtractFeesFromOutput" class="form-check subtract-fees"/>
<span asp-validation-for="Outputs[index].SubtractFeesFromOutput" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
@if (Model.Outputs.Count > 1)
{
<button type="submit" title="Remove Destination" name="command" value="@($"remove-output:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove Destination
</button>
<button type="submit" title="Remove Destination" name="command" value="@($"remove-output:{index}")"
class="d-none d-lg-block remove-destination-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
}
</div>
</div>
</div>
}
</div>
<div class="form-group">
<label asp-for="FeeSatoshiPerByte"></label>
<input asp-for="FeeSatoshiPerByte" class="form-control" />
<input asp-for="FeeSatoshiPerByte" type="number" step="any" class="form-control"/>
<span id="FeeRate-Error" class="text-danger"></span>
<p class="form-text text-muted crypto-info">
The recommended value is <a id="crypto-fee-link" href="#"><span>@Model.RecommendedSatoshiPerByte</span></a> satoshi per byte.
The recommended value is
<button type="button" id="crypto-fee-link" class="btn btn-link p-0">@Model.RecommendedSatoshiPerByte</button> satoshi per byte.
</p>
</div>
<div class="form-group">
<label asp-for="SubstractFees"></label>
<input asp-for="SubstractFees" class="form-check" />
</div>
<div class="card">
<div class="card-header" id="accordian-dev-info-notification-header">
<h2 class="mb-0">
@ -68,22 +113,26 @@
<div class="card-body">
<div class="form-group">
<label asp-for="NoChange"></label>
<a href="https://docs.btcpayserver.org/features/wallet#make-sure-no-change-utxo-is-created-expert-mode" target="_blank"><span class="fa fa-question-circle-o" title="More information..."></span></a>
<input asp-for="NoChange" class="form-check" />
<a href="https://docs.btcpayserver.org/features/wallet#make-sure-no-change-utxo-is-created-expert-mode" target="_blank">
<span class="fa fa-question-circle-o" title="More information..."></span>
</a>
<input asp-for="NoChange" class="form-check"/>
</div>
@if (Model.SupportRBF)
{
<div class="form-group">
<label asp-for="DisableRBF"></label>
<a href="https://bitcoin.org/en/glossary/rbf" target="_blank"><span class="fa fa-question-circle-o" title="More information..."></span></a>
<input asp-for="DisableRBF" class="form-check" />
</div>
<div class="form-group">
<label asp-for="DisableRBF"></label>
<a href="https://bitcoin.org/en/glossary/rbf" target="_blank">
<span class="fa fa-question-circle-o" title="More information..."></span>
</a>
<input asp-for="DisableRBF" class="form-check"/>
</div>
}
</div>
</div>
</div>
<div class="form-group">
<div class="dropdown" style="margin-top:16px;">
<div class="form-group d-flex mt-2">
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" id="SendMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Sign with...
</button>
@ -93,11 +142,23 @@
<button name="command" type="submit" class="dropdown-item" value="analyze-psbt">... a wallet supporting PSBT</button>
</div>
</div>
<button type="submit" name="command" value="add-output" class="ml-1 btn btn-secondary">Add another destination </button>
</div>
</form>
</div>
</div>
@section Scripts
{
<script src="~/js/WalletSend.js" type="text/javascript" defer="defer"></script>
<style>
.remove-destination-btn{
font-size: 1.5rem;
border-radius: 0;
}
.remove-destination-btn:hover{
background-color: #CCCCCC;
}
</style>
}

View File

@ -1,32 +1,47 @@
function updateFiatValue() {
function updateFiatValue(element) {
if (!element) {
element = $(this);
}
var rateStr = $("#Rate").val();
var divisibilityStr = $("#Divisibility").val();
var fiat = $("#Fiat").val();
var rate = parseFloat(rateStr);
var divisibility = parseInt(divisibilityStr);
if (!isNaN(rate) && !isNaN(divisibility)) {
var fiatValue = $("#fiatValue");
var amountValue = parseFloat($("#Amount").val());
var fiatValue = $(element).parents(".input-group").first().find(".fiat-value");
var amountValue = parseFloat($(element).val());
if (!isNaN(amountValue)) {
fiatValue.css("display", "inline");
fiatValue.show();
fiatValue.text("= " + (rate * amountValue).toFixed(divisibility) + " " + fiat);
} else {
fiatValue.text("");
}
}
}
function updateFiatValueWithCurrentElement() {
updateFiatValue($(this))
}
$(function () {
updateFiatValue();
$(".output-amount").on("input", updateFiatValueWithCurrentElement).each(updateFiatValueWithCurrentElement);
$("#crypto-fee-link").on("click", function (elem) {
elem.preventDefault();
var val = $("#crypto-fee-link").text();
var val = $(this).text();
$("#FeeSatoshiPerByte").val(val);
return false;
});
$("#crypto-balance-link").on("click", function (elem) {
elem.preventDefault();
var val = $("#crypto-balance-link").text();
$("#Amount").val(val);
$("#SubstractFees").prop('checked', true);
$(".crypto-balance-link").on("click", function (elem) {
var val = $(this).text();
var parentContainer = $(this).parents(".transaction-output-form");
var outputAmountElement = parentContainer.find(".output-amount");
outputAmountElement.val(val);
parentContainer.find(".subtract-fees").prop('checked', true);
updateFiatValue(outputAmountElement);
return false;
});
});