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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </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="NBitpayClient" Version="1.0.0.34" />
<PackageReference Include="DBriize" Version="1.0.0.4" /> <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="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.2" /> <PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.2" />
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" /> <PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" />

View file

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

View file

@ -331,13 +331,13 @@ namespace BTCPayServer.Controllers
{ {
signingKey = extKey; signingKey = extKey;
} }
var balanceChange = psbt.GetBalance(signingKey, rootedKeyPath); var balanceChange = psbt.GetBalance(settings.AccountDerivation, signingKey, rootedKeyPath);
if (balanceChange == Money.Zero) 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."); 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); return View(viewModel);
} }
psbt.SignAll(signingKey, rootedKeyPath); psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath);
ModelState.Remove(nameof(viewModel.PSBT)); ModelState.Remove(nameof(viewModel.PSBT));
return await WalletPSBTReady(walletId, psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath.ToString()); 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.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels namespace BTCPayServer.Models.WalletViewModels
{ {
public class WalletPSBTReadyViewModel public class WalletPSBTReadyViewModel
{ {
public class FinalizeError
{
public int Index { get; set; }
public string Error { get; set; }
}
public string PSBT { get; set; } public string PSBT { get; set; }
public string SigningKey { get; set; } public string SigningKey { get; set; }
public string SigningKeyPath { 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 public class DestinationViewModel
{ {
@ -20,9 +27,18 @@ namespace BTCPayServer.Models.WalletViewModels
} }
public string BalanceChange { get; set; } public string BalanceChange { get; set; }
public bool CanCalculateBalance { get; set; }
public bool Positive { get; set; } public bool Positive { get; set; }
public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>(); public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>();
public string Fee { get; set; }
public string FeeRate { 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using NBitcoin; using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels namespace BTCPayServer.Models.WalletViewModels
@ -24,13 +26,38 @@ namespace BTCPayServer.Models.WalletViewModels
public string PSBT { get; set; } public string PSBT { get; set; }
public List<string> Errors { get; set; } = new List<string>(); 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)
{
if (UploadedPSBTFile != null)
{
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 try
{ {
return NBitcoin.PSBT.Parse(PSBT, network); return NBitcoin.PSBT.Parse(PSBT, network);
} }
catch { } catch
{ }
}
return null; return null;
} }
} }

View file

@ -46,11 +46,15 @@
<pre><code class="json">@Model.Decoded</code></pre> <pre><code class="json">@Model.Decoded</code></pre>
} }
<h3>PSBT to decode</h3> <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"> <div class="form-group">
<textarea class="form-control" rows="5" asp-for="PSBT"></textarea> <textarea class="form-control" rows="5" asp-for="PSBT"></textarea>
<span asp-validation-for="PSBT" class="text-danger"></span> <span asp-validation-for="PSBT" class="text-danger"></span>
</div> </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> <button type="submit" class="btn btn-primary">Decode</button>
</form> </form>
</div> </div>

View file

@ -8,7 +8,7 @@
<h4>Combine PSBT</h4> <h4>Combine PSBT</h4>
<div class="row"> <div class="row">
<div class="col-md-10"> <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" /> <input type="hidden" asp-for="OtherPSBT" />
<div class="form-group"> <div class="form-group">
<label asp-for="PSBT"></label> <label asp-for="PSBT"></label>

View file

@ -4,20 +4,19 @@
} }
<section> <section>
<div class="container"> <div class="container">
@if (Model.Errors != null) @if (Model.GlobalError != null)
{ {
<div class="alert alert-danger alert-dismissible" role="alert"> <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> <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>@Model.GlobalError</span><br />
{
<span>@error</span><br />
}
</div> </div>
} }
<div class="row"> <div class="row">
<div class="col-lg-12 text-center"> <div class="col-lg-12 text-center">
<h2 class="section-heading">Transaction review</h2> <h2 class="section-heading">Transaction review</h2>
<hr class="primary"> <hr class="primary">
@if (Model.CanCalculateBalance)
{
<p> <p>
If you broadcast this transaction, your balance will change: @if (Model.Positive) If you broadcast this transaction, your balance will change: @if (Model.Positive)
{ {
@ -28,6 +27,11 @@
<span style="color:red;">@Model.BalanceChange</span> <span style="color:red;">@Model.BalanceChange</span>
}, do you want to continue? }, do you want to continue?
</p> </p>
}
else
{
<p>This PSBT is already finalized. We can't properly detect which input or output belongs to you.</p>
}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -62,22 +66,48 @@
</div> </div>
<div class="col-lg-3 text-center"></div> <div class="col-lg-3 text-center"></div>
</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) @if (Model.FeeRate != null)
{ {
<div class="row"> <div class="row">
<div class="col-lg-3 text-center"></div> <div class="col-lg-3 text-center"></div>
<div class="col-lg-6 text-left"> <div class="col-lg-6 text-right">
<p>Transaction fee rate: <b>@Model.FeeRate</b></p> <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>
<div class="col-lg-3 text-center"></div> <div class="col-lg-3 text-center"></div>
</div> </div>
@ -88,7 +118,10 @@
<input type="hidden" asp-for="PSBT" /> <input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="SigningKey" /> <input type="hidden" asp-for="SigningKey" />
<input type="hidden" asp-for="SigningKeyPath" /> <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> <button type="submit" class="btn btn-secondary" name="command" value="analyze-psbt">Export as PSBT</button>
</form> </form>
</div> </div>