Can actually upload PSBT file in PSBT Combine and PSBT view.

Validate transaction before allowing any broadcast and show errors nicely.
This commit is contained in:
nicolas.dorier 2019-05-19 23:27:18 +09:00
parent 55a48ff84a
commit 87df34e064
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
8 changed files with 162 additions and 65 deletions

View file

@ -47,10 +47,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="NBitcoin" Version="4.1.2.28" />
<PackageReference Include="NBitcoin" Version="4.1.2.32" />
<PackageReference Include="NBitpayClient" Version="1.0.0.34" />
<PackageReference Include="DBriize" Version="1.0.0.4" />
<PackageReference Include="NBXplorer.Client" Version="2.0.0.13" />
<PackageReference Include="NBXplorer.Client" Version="2.0.0.16" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.2" />
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" />

View file

@ -42,13 +42,14 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("{walletId}/psbt")]
public IActionResult WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))]
public async Task<IActionResult> WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTViewModel vm)
{
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
if (vm?.PSBT != null)
if (await vm.GetPSBT(network.NBitcoinNetwork) is PSBT psbt)
{
vm.Decoded = vm.GetPSBT(network.NBitcoinNetwork)?.ToString();
vm.Decoded = psbt.ToString();
vm.PSBT = psbt.ToBase64();
}
return View(vm ?? new WalletPSBTViewModel());
}
@ -60,25 +61,26 @@ namespace BTCPayServer.Controllers
WalletPSBTViewModel vm, string command = null)
{
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
var psbt = vm.GetPSBT(network.NBitcoinNetwork);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
switch (command)
{
case null:
vm.Decoded = psbt.ToString();
vm.FileName = string.Empty;
ModelState.Remove(nameof(vm.PSBT));
ModelState.Remove(nameof(vm.FileName));
ModelState.Remove(nameof(vm.UploadedPSBTFile));
vm.PSBT = psbt.ToBase64();
vm.FileName = vm.UploadedPSBTFile?.FileName;
return View(vm);
case "ledger":
return ViewWalletSendLedger(psbt);
case "seed":
return SignWithSeed(walletId, psbt.ToBase64());
case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors):
return ViewPSBT(psbt, errors);
case "broadcast":
{
return await WalletPSBTReady(walletId, psbt.ToBase64());
@ -132,9 +134,10 @@ namespace BTCPayServer.Controllers
}
catch { }
var derivationSchemeSettings = await GetDerivationSchemeSettings(walletId);
if (signingKey == null || signingKeyPath == null)
{
var signingKeySettings = (await GetDerivationSchemeSettings(walletId)).GetSigningAccountKeySettings();
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
if (signingKey == null)
{
signingKey = signingKeySettings.AccountKey;
@ -147,16 +150,23 @@ namespace BTCPayServer.Controllers
}
}
var balanceChange = psbtObject.GetBalance(signingKey, signingKeyPath);
vm.BalanceChange = ValueToString(balanceChange, network);
vm.Positive = balanceChange >= Money.Zero;
if (psbtObject.IsAllFinalized())
{
vm.CanCalculateBalance = false;
}
else
{
var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath);
vm.BalanceChange = ValueToString(balanceChange, network);
vm.CanCalculateBalance = true;
vm.Positive = balanceChange >= Money.Zero;
}
foreach (var output in psbtObject.Outputs)
{
var dest = new WalletPSBTReadyViewModel.DestinationViewModel();
vm.Destinations.Add(dest);
var mine = output.HDKeysFor(signingKey, signingKeyPath).Any();
var mine = output.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
var balanceChange2 = output.Value;
if (!mine)
balanceChange2 = -balanceChange2;
@ -167,12 +177,22 @@ namespace BTCPayServer.Controllers
if (psbtObject.TryGetFee(out var fee))
{
vm.Fee = ValueToString(fee, network);
vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel()
{
Positive = false,
Balance = ValueToString(- fee, network),
Destination = "Mining fees"
});
}
if (psbtObject.TryGetEstimatedFeeRate(out var feeRate))
{
vm.FeeRate = feeRate.ToString();
}
if (!psbtObject.IsAllFinalized() && !psbtObject.TryFinalize(out var errors))
{
vm.SetErrors(errors);
}
}
[HttpPost]
@ -190,16 +210,14 @@ namespace BTCPayServer.Controllers
}
catch
{
vm.Errors = new List<string>();
vm.Errors.Add("Invalid PSBT");
vm.GlobalError = "Invalid PSBT";
return View(vm);
}
if (command == "broadcast")
{
if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors))
{
vm.Errors = new List<string>();
vm.Errors.AddRange(errors.Select(e => e.ToString()));
vm.SetErrors(errors);
return View(vm);
}
var transaction = psbt.ExtractTransaction();
@ -208,15 +226,13 @@ namespace BTCPayServer.Controllers
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
if (!broadcastResult.Success)
{
vm.Errors = new List<string>();
vm.Errors.Add($"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}");
vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
return View(vm);
}
}
catch (Exception ex)
{
vm.Errors = new List<string>();
vm.Errors.Add("Error while broadcasting: " + ex.Message);
vm.GlobalError = "Error while broadcasting: " + ex.Message;
return View(vm);
}
return await RedirectToWalletTransaction(walletId, transaction);
@ -227,8 +243,7 @@ namespace BTCPayServer.Controllers
}
else
{
vm.Errors = new List<string>();
vm.Errors.Add("Unknown command");
vm.GlobalError = "Unknown command";
return View(vm);
}
}
@ -243,6 +258,8 @@ namespace BTCPayServer.Controllers
}
private IActionResult ViewPSBT(PSBT psbt, string fileName, IEnumerable<string> errors = null)
{
ModelState.Remove(nameof(WalletPSBTViewModel.PSBT));
ModelState.Remove(nameof(WalletPSBTViewModel.FileName));
return View(nameof(WalletPSBT), new WalletPSBTViewModel()
{
Decoded = psbt.ToString(),

View file

@ -331,13 +331,13 @@ namespace BTCPayServer.Controllers
{
signingKey = extKey;
}
var balanceChange = psbt.GetBalance(signingKey, rootedKeyPath);
var balanceChange = psbt.GetBalance(settings.AccountDerivation, signingKey, rootedKeyPath);
if (balanceChange == Money.Zero)
{
ModelState.AddModelError(nameof(viewModel.SeedOrKey), "This seed does not seem to be able to sign this transaction. Either this is the wrong key, or Wallet Settings have not the correct account path in the wallet settings.");
return View(viewModel);
}
psbt.SignAll(signingKey, rootedKeyPath);
psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath);
ModelState.Remove(nameof(viewModel.PSBT));
return await WalletPSBTReady(walletId, psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath.ToString());
}

View file

@ -2,15 +2,22 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTReadyViewModel
{
public class FinalizeError
{
public int Index { get; set; }
public string Error { get; set; }
}
public string PSBT { get; set; }
public string SigningKey { get; set; }
public string SigningKeyPath { get; set; }
public List<string> Errors { get; set; }
public string GlobalError { get; set; }
public List<FinalizeError> Errors { get; set; }
public class DestinationViewModel
{
@ -20,9 +27,18 @@ namespace BTCPayServer.Models.WalletViewModels
}
public string BalanceChange { get; set; }
public bool CanCalculateBalance { get; set; }
public bool Positive { get; set; }
public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>();
public string Fee { get; set; }
public string FeeRate { get; set; }
internal void SetErrors(IList<PSBTError> errors)
{
Errors = new List<FinalizeError>();
foreach (var err in errors)
{
Errors.Add(new FinalizeError() { Index = (int)err.InputIndex, Error = err.Message });
}
}
}
}

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
@ -24,13 +26,38 @@ namespace BTCPayServer.Models.WalletViewModels
public string PSBT { get; set; }
public List<string> Errors { get; set; } = new List<string>();
internal PSBT GetPSBT(Network network)
[Display(Name = "Upload PSBT from file...")]
public IFormFile UploadedPSBTFile { get; set; }
public async Task<PSBT> GetPSBT(Network network)
{
try
if (UploadedPSBTFile != null)
{
return NBitcoin.PSBT.Parse(PSBT, network);
if (UploadedPSBTFile.Length > 500 * 1024)
return null;
byte[] bytes = new byte[UploadedPSBTFile.Length];
using (var stream = UploadedPSBTFile.OpenReadStream())
{
await stream.ReadAsync(bytes, 0, (int)UploadedPSBTFile.Length);
}
try
{
return NBitcoin.PSBT.Load(bytes, network);
}
catch
{
return null;
}
}
if (!string.IsNullOrEmpty(PSBT))
{
try
{
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch
{ }
}
catch { }
return null;
}
}

View file

@ -46,11 +46,15 @@
<pre><code class="json">@Model.Decoded</code></pre>
}
<h3>PSBT to decode</h3>
<form class="form-group" method="post" asp-action="WalletPSBT">
<form class="form-group" method="post" asp-action="WalletPSBT" enctype="multipart/form-data">
<div class="form-group">
<textarea class="form-control" rows="5" asp-for="PSBT"></textarea>
<span asp-validation-for="PSBT" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UploadedPSBTFile"></label>
<input type="file" class="form-control-file" asp-for="UploadedPSBTFile">
</div>
<button type="submit" class="btn btn-primary">Decode</button>
</form>
</div>

View file

@ -8,7 +8,7 @@
<h4>Combine PSBT</h4>
<div class="row">
<div class="col-md-10">
<form class="form-group" method="post" asp-action="WalletPSBTCombine">
<form class="form-group" method="post" asp-action="WalletPSBTCombine" enctype="multipart/form-data">
<input type="hidden" asp-for="OtherPSBT" />
<div class="form-group">
<label asp-for="PSBT"></label>

View file

@ -4,30 +4,34 @@
}
<section>
<div class="container">
@if (Model.Errors != null)
@if (Model.GlobalError != null)
{
<div class="alert alert-danger alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
@foreach (var error in Model.Errors)
{
<span>@error</span><br />
}
<span>@Model.GlobalError</span><br />
</div>
}
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">Transaction review</h2>
<hr class="primary">
<p>
If you broadcast this transaction, your balance will change: @if (Model.Positive)
{
<span style="color:green;">@Model.BalanceChange</span>
}
else
{
<span style="color:red;">@Model.BalanceChange</span>
}, do you want to continue?
</p>
@if (Model.CanCalculateBalance)
{
<p>
If you broadcast this transaction, your balance will change: @if (Model.Positive)
{
<span style="color:green;">@Model.BalanceChange</span>
}
else
{
<span style="color:red;">@Model.BalanceChange</span>
}, do you want to continue?
</p>
}
else
{
<p>This PSBT is already finalized. We can't properly detect which input or output belongs to you.</p>
}
</div>
</div>
<div class="row">
@ -62,22 +66,48 @@
</div>
<div class="col-lg-3 text-center"></div>
</div>
@if (Model.Fee != null)
{
<div class="row">
<div class="col-lg-3 text-center"></div>
<div class="col-lg-6 text-left">
<p>Transaction fee: <span style="color: red">@Model.Fee</span></p>
</div>
<div class="col-lg-3 text-center"></div>
</div>
}
@if (Model.FeeRate != null)
{
<div class="row">
<div class="col-lg-3 text-center"></div>
<div class="col-lg-6 text-left">
<p>Transaction fee rate: <b>@Model.FeeRate</b></p>
<div class="col-lg-6 text-right">
<p class="text-muted">Transaction fee rate: <b>@Model.FeeRate</b></p>
</div>
<div class="col-lg-3 text-center"></div>
</div>
}
@if (Model.Errors != null)
{
<div class="row">
<div class="col-lg-12 text-center">
<h4>Errors</h4>
<p>
This PSBT can't be finalized for broadcast. Please review the errors.
</p>
</div>
</div>
<div class="row">
<div class="col-lg-3 text-center"></div>
<div class="col-lg-6 text-center">
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th style="text-align:left" class="col-md-1">
Input index
</th>
<th style="text-align:right">Error</th>
</tr>
</thead>
<tbody>
@foreach (var err in Model.Errors)
{
<tr>
<td style="text-align:left">@err.Index</td>
<td style="text-align:right; color:red;"><span class="fa fa-exclamation-triangle" title="@err.Error"></span></td>
</tr>
}
</tbody>
</table>
</div>
<div class="col-lg-3 text-center"></div>
</div>
@ -88,7 +118,10 @@
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="SigningKey" />
<input type="hidden" asp-for="SigningKeyPath" />
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast it</button> or
@if (Model.Errors == null)
{
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast it</button> <span> or </span>
}
<button type="submit" class="btn btn-secondary" name="command" value="analyze-psbt">Export as PSBT</button>
</form>
</div>