Compare commits

..

18 Commits

Author SHA1 Message Date
2719849a54 bump 2019-05-12 14:51:57 +09:00
3011fecf0f Add tests for PSBT 2019-05-12 14:51:24 +09:00
6da0a9a201 Can combine PSBT 2019-05-12 13:13:52 +09:00
572fe3eacb Moveonly: Move all PSBT stuff in separate file 2019-05-12 11:13:04 +09:00
ff82f15246 Always rebase keys before signing, refacotring some code 2019-05-12 11:07:41 +09:00
b214e3f6df bump minimum version of ledger wallet 2019-05-12 01:35:13 +09:00
cb9130fdf9 Can broadcast PSBT, can decide to export something signed by the ledger via PSBT 2019-05-12 00:05:30 +09:00
925dc869a2 Add wasabi wallet to the wallet list supporting P2P connections 2019-05-11 22:25:10 +09:00
5f1aa619cd Can sign and export arbitrary PSBT 2019-05-11 20:26:31 +09:00
541c748ecb WalletSendLedger and LedgerConnection only depends on PSBT 2019-05-11 20:02:32 +09:00
e853bddbc8 Add utility tool to decode PSBT 2019-05-11 00:29:29 +09:00
79d26b5d95 Push rebase keypath and min fee logic down nbxplorer 2019-05-10 19:30:10 +09:00
840f52a75b Fix build 2019-05-10 14:36:57 +09:00
f955302c74 remove CF modal text 2019-05-10 11:35:51 +09:00
95e7d3dfc4 Don't scan 49' or 84' if not segwit 2019-05-10 10:55:10 +09:00
75f2749b19 Decouple HardwareWalletService into two classes: LedgerHardwareWalletService and HardwareWalletService 2019-05-10 10:48:30 +09:00
01e5b319d1 Save the fingerprint of the root of LedgerWallet, and use it. Simplify HardwareWallet 2019-05-10 01:05:37 +09:00
e504163bc7 Add NonAction to CreatePSBT 2019-05-09 19:34:45 +09:00
33 changed files with 989 additions and 429 deletions

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Xunit;
namespace BTCPayServer.Tests
{
public static class Extensions
{
public static T AssertViewModel<T>(this IActionResult result)
{
Assert.NotNull(result);
var vr = Assert.IsType<ViewResult>(result);
return Assert.IsType<T>(vr.Model);
}
public static async Task<T> AssertViewModelAsync<T>(this Task<IActionResult> task)
{
var result = await task;
Assert.NotNull(result);
var vr = Assert.IsType<ViewResult>(result);
return Assert.IsType<T>(vr.Model);
}
}
}

View File

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class PSBTTests
{
public PSBTTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) { Name = "Tests" };
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanPlayWithPSBT()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Price = 10,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some \", description",
FullNotifications = true
}, Facade.Merchant);
var cashCow = tester.ExplorerNode;
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network);
cashCow.SendToAddress(invoiceAddress, Money.Coins(1.5m));
TestUtils.Eventually(() =>
{
invoice = user.BitPay.GetInvoice(invoice.Id);
Assert.Equal("paid", invoice.Status);
});
var walletController = tester.PayTester.GetController<WalletsController>(user.UserId);
var walletId = new WalletId(user.StoreId, "BTC");
var sendModel = new WalletSendModel()
{
Destination = new Key().PubKey.Hash.GetAddress(user.SupportedNetwork.NBitcoinNetwork).ToString(),
Amount = 0.1m,
FeeSatoshiPerByte = 1,
CurrentBalance = 1.5m
};
var vmLedger = await walletController.WalletSend(walletId, sendModel, command: "ledger").AssertViewModelAsync<WalletSendLedgerModel>();
PSBT.Parse(vmLedger.PSBT, user.SupportedNetwork.NBitcoinNetwork);
BitcoinAddress.Create(vmLedger.HintChange, user.SupportedNetwork.NBitcoinNetwork);
Assert.NotNull(vmLedger.SuccessPath);
Assert.NotNull(vmLedger.WebsocketPath);
var vmPSBT = await walletController.WalletSend(walletId, sendModel, command: "analyze-psbt").AssertViewModelAsync<WalletPSBTViewModel>();
var unsignedPSBT = PSBT.Parse(vmPSBT.PSBT, user.SupportedNetwork.NBitcoinNetwork);
Assert.NotNull(vmPSBT.Decoded);
var filePSBT = (FileContentResult)(await walletController.WalletSend(walletId, sendModel, command: "save-psbt"));
PSBT.Load(filePSBT.FileContents, user.SupportedNetwork.NBitcoinNetwork);
await walletController.WalletPSBT(walletId, vmPSBT, "ledger").AssertViewModelAsync<WalletSendLedgerModel>();
var vmPSBT2 = await walletController.WalletPSBT(walletId, vmPSBT, "broadcast").AssertViewModelAsync<WalletPSBTViewModel>();
Assert.NotEmpty(vmPSBT2.Errors);
Assert.Equal(vmPSBT.Decoded, vmPSBT2.Decoded);
Assert.Equal(vmPSBT.PSBT, vmPSBT2.PSBT);
var signedPSBT = unsignedPSBT.Clone();
signedPSBT.SignAll(user.ExtKey);
vmPSBT.PSBT = signedPSBT.ToBase64();
var redirect = Assert.IsType<RedirectToActionResult>(await walletController.WalletPSBT(walletId, vmPSBT, "broadcast"));
Assert.Equal(nameof(walletController.WalletTransactions), redirect.ActionName);
vmPSBT.PSBT = unsignedPSBT.ToBase64();
var combineVM = await walletController.WalletPSBT(walletId, vmPSBT, "combine").AssertViewModelAsync<WalletPSBTCombineViewModel>();
Assert.Equal(vmPSBT.PSBT, combineVM.OtherPSBT);
combineVM.PSBT = signedPSBT.ToBase64();
vmPSBT = await walletController.WalletPSBTCombine(walletId, combineVM).AssertViewModelAsync<WalletPSBTViewModel>();
var signedPSBT2 = PSBT.Parse(vmPSBT.PSBT, user.SupportedNetwork.NBitcoinNetwork);
Assert.True(signedPSBT.TryFinalize(out _));
Assert.True(signedPSBT2.TryFinalize(out _));
Assert.Equal(signedPSBT, signedPSBT2);
// Can use uploaded file?
combineVM.PSBT = null;
combineVM.UploadedPSBTFile = TestUtils.GetFormFile("signedPSBT", signedPSBT.ToBytes());
vmPSBT = await walletController.WalletPSBTCombine(walletId, combineVM).AssertViewModelAsync<WalletPSBTViewModel>();
signedPSBT2 = PSBT.Parse(vmPSBT.PSBT, user.SupportedNetwork.NBitcoinNetwork);
Assert.True(signedPSBT.TryFinalize(out _));
Assert.True(signedPSBT2.TryFinalize(out _));
Assert.Equal(signedPSBT, signedPSBT2);
var ready = walletController.WalletPSBTReady(walletId, signedPSBT.ToBase64()).AssertViewModel<WalletPSBTReadyViewModel>();
Assert.Equal(signedPSBT.ToBase64(), ready.PSBT);
vmPSBT = await walletController.WalletPSBTReady(walletId, ready, command: "analyze-psbt").AssertViewModelAsync<WalletPSBTViewModel>();
Assert.Equal(signedPSBT.ToBase64(), vmPSBT.PSBT);
redirect = Assert.IsType<RedirectToActionResult>(await walletController.WalletPSBTReady(walletId, ready, command: "broadcast"));
Assert.Equal(nameof(walletController.WalletTransactions), redirect.ActionName);
}
}
}
}

View File

@ -28,6 +28,22 @@ namespace BTCPayServer.Tests
formFile.ContentDisposition = $"form-data; name=\"file\"; filename=\"{fileInfo.Name}\"";
return formFile;
}
public static FormFile GetFormFile(string filename, byte[] content)
{
File.WriteAllBytes(filename, content);
var fileInfo = new FileInfo(filename);
FormFile formFile = new FormFile(
new FileStream(filename, FileMode.OpenOrCreate),
0,
fileInfo.Length, fileInfo.Name, fileInfo.Name)
{
Headers = new HeaderDictionary()
};
formFile.ContentType = "application/octet-stream";
formFile.ContentDisposition = $"form-data; name=\"file\"; filename=\"{fileInfo.Name}\"";
return formFile;
}
public static void Eventually(Action act)
{
CancellationTokenSource cts = new CancellationTokenSource(20000);

View File

@ -1634,7 +1634,7 @@ namespace BTCPayServer.Tests
Assert.Equal("paid", invoice.Status);
});
var wallet = tester.PayTester.GetController<WalletsController>();
var psbt = wallet.CreatePSBT(btcNetwork, onchainBTC, new WalletSendLedgerModel()
var psbt = wallet.CreatePSBT(btcNetwork, onchainBTC, new WalletSendModel()
{
Amount = 0.5m,
Destination = new Key().PubKey.GetAddress(btcNetwork.NBitcoinNetwork).ToString(),
@ -2080,8 +2080,8 @@ donation:
var firstPayment = productPartDue - missingMoney;
cashCow.SendToAddress(invoiceAddress, Money.Coins(firstPayment));
TestUtils.Eventually(() =>
{
TestUtils.Eventually(() =>
{
invoice = user.BitPay.GetInvoice(invoice.Id);
// Check that for the second payment, network fee are included
due = Money.Parse(invoice.CryptoInfo[0].Due);

View File

@ -71,7 +71,7 @@ services:
nbxplorer:
image: nicolasdorier/nbxplorer:2.0.0.39
image: nicolasdorier/nbxplorer:2.0.0.40
restart: unless-stopped
ports:
- "32838:32838"

View File

@ -50,7 +50,6 @@ namespace BTCPayServer
public string CryptoCode { get; internal set; }
public string BlockExplorerLink { get; internal set; }
public string UriScheme { get; internal set; }
public Money MinFee { get; internal set; }
public string DisplayName { get; set; }
[Obsolete("Should not be needed")]

View File

@ -27,8 +27,7 @@ namespace BTCPayServer
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
//https://github.com/satoshilabs/slips/blob/master/slip-0044.md
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("5'")
: new KeyPath("1'"),
MinFee = Money.Satoshis(1m)
: new KeyPath("1'")
});
}
}

View File

@ -28,8 +28,7 @@ namespace BTCPayServer
},
CryptoImagePath = "imlegacy/dogecoin.png",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("3'") : new KeyPath("1'"),
MinFee = Money.Coins(1m)
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("3'") : new KeyPath("1'")
});
}
}

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
<Version>1.0.3.98</Version>
<Version>1.0.3.100</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<PropertyGroup>
@ -47,10 +47,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="NBitcoin" Version="4.1.2.15" />
<PackageReference Include="NBitcoin" Version="4.1.2.20" />
<PackageReference Include="NBitpayClient" Version="1.0.0.34" />
<PackageReference Include="DBriize" Version="1.0.0.4" />
<PackageReference Include="NBXplorer.Client" Version="2.0.0.11" />
<PackageReference Include="NBXplorer.Client" Version="2.0.0.12" />
<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" />
@ -182,6 +182,15 @@
<Content Update="Views\Wallets\ListWallets.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletPSBTCombine.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletPSBTReady.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletPSBT.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletRescan.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>

View File

@ -43,6 +43,14 @@ namespace BTCPayServer.Controllers
return View(vm);
}
class GetXPubs
{
public BitcoinExtPubKey ExtPubKey { get; set; }
public DerivationStrategyBase DerivationScheme { get; set; }
public HDFingerprint RootFingerprint { get; set; }
public string Source { get; set; }
}
[HttpGet]
[Route("{storeId}/derivations/{cryptoCode}/ledger/ws")]
public async Task<IActionResult> AddDerivationSchemeLedger(
@ -55,7 +63,7 @@ namespace BTCPayServer.Controllers
return NotFound();
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var hw = new HardwareWalletService(webSocket);
var hw = new LedgerHardwareWalletService(webSocket);
object result = null;
var network = _NetworkProvider.GetNetwork(cryptoCode);
@ -73,7 +81,18 @@ namespace BTCPayServer.Controllers
var k = KeyPath.Parse(keyPath);
if (k.Indexes.Length == 0)
throw new FormatException("Invalid key path");
var getxpubResult = await hw.GetExtPubKey(network, k, normalOperationTimeout.Token);
var getxpubResult = new GetXPubs();
getxpubResult.ExtPubKey = await hw.GetExtPubKey(network, k, normalOperationTimeout.Token);
var segwit = network.NBitcoinNetwork.Consensus.SupportSegwit;
var derivation = new DerivationStrategyFactory(network.NBitcoinNetwork).CreateDirectDerivationStrategy(getxpubResult.ExtPubKey, new DerivationStrategyOptions()
{
P2SH = segwit,
Legacy = !segwit
});
getxpubResult.DerivationScheme = derivation;
getxpubResult.RootFingerprint = (await hw.GetExtPubKey(network, new KeyPath(), normalOperationTimeout.Token)).ExtPubKey.PubKey.GetHDFingerPrint();
getxpubResult.Source = hw.Device;
result = getxpubResult;
}
}
@ -87,7 +106,7 @@ namespace BTCPayServer.Controllers
if (result != null)
{
UTF8Encoding UTF8NOBOM = new UTF8Encoding(false);
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, MvcJsonOptions.Value.SerializerSettings));
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, network.NBXplorerNetwork.JsonSerializerSettings));
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token);
}
}
@ -185,6 +204,9 @@ namespace BTCPayServer.Controllers
{
strategy = newStrategy;
strategy.AccountKeyPath = vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath);
strategy.RootFingerprint = string.IsNullOrEmpty(vm.RootFingerprint) ? (HDFingerprint?)null : new HDFingerprint(NBitcoin.DataEncoders.Encoders.Hex.DecodeData(vm.RootFingerprint));
strategy.ExplicitAccountKey = string.IsNullOrEmpty(vm.AccountKey) ? null : new BitcoinExtPubKey(vm.AccountKey, network.NBitcoinNetwork);
strategy.Source = vm.Source;
vm.DerivationScheme = strategy.AccountDerivation.ToString();
}
}

View File

@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models.WalletViewModels;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.Models;
namespace BTCPayServer.Controllers
{
public partial class WalletsController
{
[NonAction]
public async Task<CreatePSBTResponse> CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken)
{
var nbx = ExplorerClientProvider.GetExplorerClient(network);
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
CreatePSBTDestination psbtDestination = new CreatePSBTDestination();
psbtRequest.Destinations.Add(psbtDestination);
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;
}
psbtDestination.SubstractFees = sendModel.SubstractFees;
psbtRequest.RebaseKeyPaths = derivationSettings.GetPSBTRebaseKeyRules().ToList();
var psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken));
if (psbt == null)
throw new NotSupportedException("You need to update your version of NBXplorer");
return psbt;
}
[HttpGet]
[Route("{walletId}/psbt")]
public IActionResult WalletPSBT()
{
return View(new WalletPSBTViewModel());
}
[HttpPost]
[Route("{walletId}/psbt")]
public async Task<IActionResult> WalletPSBT(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId,
WalletPSBTViewModel vm, string command = null)
{
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
var psbt = vm.GetPSBT(network.NBitcoinNetwork);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
if (command == null)
{
vm.Decoded = psbt.ToString();
return View(vm);
}
else if (command == "ledger")
{
return ViewWalletSendLedger(psbt);
}
else if (command == "broadcast")
{
if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors))
{
return ViewPSBT(psbt, errors);
}
var transaction = psbt.ExtractTransaction();
try
{
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
if (!broadcastResult.Success)
{
return ViewPSBT(psbt, new[] { $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}" });
}
}
catch (Exception ex)
{
return ViewPSBT(psbt, "Error while broadcasting: " + ex.Message);
}
return await RedirectToWalletTransaction(walletId, transaction);
}
else if (command == "combine")
{
ModelState.Remove(nameof(vm.PSBT));
return View(nameof(WalletPSBTCombine), new WalletPSBTCombineViewModel() { OtherPSBT = psbt.ToBase64() });
}
else
{
(await GetDerivationSchemeSettings(walletId)).RebaseKeyPaths(psbt);
return FilePSBT(psbt, "psbt-export.psbt");
}
}
[HttpGet]
[Route("{walletId}/psbt/ready")]
public IActionResult WalletPSBTReady(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string psbt = null)
{
return View(new WalletPSBTReadyViewModel() { PSBT = psbt });
}
[HttpPost]
[Route("{walletId}/psbt/ready")]
public async Task<IActionResult> WalletPSBTReady(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null)
{
PSBT psbt = null;
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
try
{
psbt = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
}
catch
{
vm.Errors = new List<string>();
vm.Errors.Add("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()));
return View(vm);
}
var transaction = psbt.ExtractTransaction();
try
{
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}");
return View(vm);
}
}
catch (Exception ex)
{
vm.Errors = new List<string>();
vm.Errors.Add("Error while broadcasting: " + ex.Message);
return View(vm);
}
return await RedirectToWalletTransaction(walletId, transaction);
}
else if (command == "analyze-psbt")
{
return ViewPSBT(psbt);
}
else
{
vm.Errors = new List<string>();
vm.Errors.Add("Unknown command");
return View(vm);
}
}
private IActionResult ViewPSBT<T>(PSBT psbt, IEnumerable<T> errors = null)
{
return ViewPSBT(psbt, errors?.Select(e => e.ToString()).ToList());
}
private IActionResult ViewPSBT(PSBT psbt, IEnumerable<string> errors = null)
{
return View(nameof(WalletPSBT), new WalletPSBTViewModel()
{
Decoded = psbt.ToString(),
PSBT = psbt.ToBase64(),
Errors = errors?.ToList()
});
}
private IActionResult FilePSBT(PSBT psbt, string fileName)
{
return File(psbt.ToBytes(), "application/octet-stream", fileName);
}
[HttpPost]
[Route("{walletId}/psbt/combine")]
public async Task<IActionResult> WalletPSBTCombine([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTCombineViewModel vm)
{
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork);
if (sourcePSBT == null)
{
ModelState.AddModelError(nameof(vm.OtherPSBT), "Invalid PSBT");
return View(vm);
}
sourcePSBT = sourcePSBT.Combine(psbt);
StatusMessage = "PSBT Successfully combined!";
return ViewPSBT(sourcePSBT);
}
}
}

View File

@ -117,8 +117,7 @@ namespace BTCPayServer.Controllers
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId)
{
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
DerivationSchemeSettings paymentMethod = GetPaymentMethod(walletId, store);
DerivationSchemeSettings paymentMethod = await GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
@ -151,7 +150,7 @@ namespace BTCPayServer.Controllers
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
DerivationSchemeSettings paymentMethod = GetPaymentMethod(walletId, store);
DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId, store);
if (paymentMethod == null)
return NotFound();
var network = this.NetworkProvider.GetNetwork(walletId?.CryptoCode);
@ -227,27 +226,21 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
return View(vm);
var sendModel = new WalletSendLedgerModel()
{
Destination = vm.Destination,
Amount = vm.Amount.Value,
SubstractFees = vm.SubstractFees,
FeeSatoshiPerByte = vm.FeeSatoshiPerByte,
NoChange = vm.NoChange,
DisableRBF = vm.DisableRBF
};
DerivationSchemeSettings derivationScheme = await GetDerivationSchemeSettings(walletId);
var psbt = await CreatePSBT(network, derivationScheme, vm, cancellation);
if (command == "ledger")
{
return RedirectToAction(nameof(WalletSendLedger), sendModel);
return ViewWalletSendLedger(psbt.PSBT, psbt.ChangeAddress);
}
else
{
var storeData = (await Repository.FindStore(walletId.StoreId, GetUserId()));
var derivationScheme = GetPaymentMethod(walletId, storeData);
try
{
var psbt = await CreatePSBT(network, derivationScheme, sendModel, cancellation);
return File(psbt.PSBT.ToBytes(), "application/octet-stream", $"Send-{vm.Amount.Value}-{network.CryptoCode}-to-{destination[0].ToString()}.psbt");
if (command == "analyze-psbt")
return ViewPSBT(psbt.PSBT);
derivationScheme.RebaseKeyPaths(psbt.PSBT);
return FilePSBT(psbt.PSBT, $"Send-{vm.Amount.Value}-{network.CryptoCode}-to-{destination[0].ToString()}.psbt");
}
catch (NBXplorerException ex)
{
@ -262,76 +255,15 @@ namespace BTCPayServer.Controllers
}
}
public async Task<CreatePSBTResponse> CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendLedgerModel sendModel, CancellationToken cancellationToken)
private ViewResult ViewWalletSendLedger(PSBT psbt, BitcoinAddress hintChange = null)
{
var nbx = ExplorerClientProvider.GetExplorerClient(network);
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
CreatePSBTDestination psbtDestination = new CreatePSBTDestination();
psbtRequest.Destinations.Add(psbtDestination);
if (network.SupportRBF)
return View("WalletSendLedger", new WalletSendLedgerModel()
{
psbtRequest.RBF = !sendModel.DisableRBF;
}
psbtDestination.Destination = BitcoinAddress.Create(sendModel.Destination, network.NBitcoinNetwork);
psbtDestination.Amount = Money.Coins(sendModel.Amount);
psbtRequest.FeePreference = new FeePreference();
psbtRequest.FeePreference.ExplicitFeeRate = new FeeRate(Money.Satoshis(sendModel.FeeSatoshiPerByte), 1);
if (sendModel.NoChange)
{
psbtRequest.ExplicitChangeAddress = psbtDestination.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");
if (network.MinFee != null)
{
psbt.PSBT.TryGetFee(out var fee);
if (fee < network.MinFee)
{
psbtRequest.FeePreference = new FeePreference() { ExplicitFee = network.MinFee };
psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken));
}
}
if (derivationSettings.AccountKeyPath != null && derivationSettings.AccountKeyPath.Indexes.Length != 0)
{
// NBX only know the path relative to the account xpub.
// Here we rebase the hd_keys in the PSBT to have a keypath relative to the root HD so the wallet can sign
// Note that the fingerprint of the hd keys are now 0, which is wrong
// However, hardware wallets does not give a damn, and sometimes does not even allow us to get this fingerprint anyway.
foreach (var o in psbt.PSBT.Inputs.OfType<PSBTCoin>().Concat(psbt.PSBT.Outputs))
{
var rootFP = derivationSettings.RootFingerprint is HDFingerprint fp ? fp : default;
foreach (var keypath in o.HDKeyPaths.ToList())
{
var newKeyPath = derivationSettings.AccountKeyPath.Derive(keypath.Value.Item2);
o.HDKeyPaths.Remove(keypath.Key);
o.HDKeyPaths.Add(keypath.Key, Tuple.Create(rootFP, newKeyPath));
}
}
}
return psbt;
}
[HttpGet]
[Route("{walletId}/send/ledger")]
public async Task<IActionResult> WalletSendLedger(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendLedgerModel vm)
{
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
DerivationSchemeSettings paymentMethod = GetPaymentMethod(walletId, store);
if (paymentMethod == null)
return NotFound();
var network = this.NetworkProvider.GetNetwork(walletId?.CryptoCode);
if (network == null)
return NotFound();
return View(vm);
PSBT = psbt.ToBase64(),
HintChange = hintChange?.ToString(),
WebsocketPath = this.Url.Action(nameof(LedgerConnection)),
SuccessPath = this.Url.Action(nameof(WalletPSBTReady))
});
}
private IDestination[] ParseDestination(string destination, Network network)
@ -347,6 +279,19 @@ namespace BTCPayServer.Controllers
}
}
private async Task<IActionResult> RedirectToWalletTransaction(WalletId walletId, Transaction transaction)
{
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
if (transaction != null)
{
var wallet = _walletProvider.GetWallet(network);
var derivationSettings = await GetDerivationSchemeSettings(walletId);
wallet.InvalidateCache(derivationSettings.AccountDerivation);
StatusMessage = $"Transaction broadcasted successfully ({transaction.GetHash().ToString()})";
}
return RedirectToAction(nameof(WalletTransactions));
}
[HttpGet]
[Route("{walletId}/rescan")]
public async Task<IActionResult> WalletRescan(
@ -355,8 +300,7 @@ namespace BTCPayServer.Controllers
{
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
DerivationSchemeSettings paymentMethod = GetPaymentMethod(walletId, store);
DerivationSchemeSettings paymentMethod = await GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
@ -399,8 +343,7 @@ namespace BTCPayServer.Controllers
{
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
DerivationSchemeSettings paymentMethod = GetPaymentMethod(walletId, store);
DerivationSchemeSettings paymentMethod = await GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
@ -428,7 +371,7 @@ namespace BTCPayServer.Controllers
return null;
}
private DerivationSchemeSettings GetPaymentMethod(WalletId walletId, StoreData store)
private DerivationSchemeSettings GetDerivationSchemeSettings(WalletId walletId, StoreData store)
{
if (store == null || !store.HasClaim(Policies.CanModifyStoreSettings.Key))
return null;
@ -440,6 +383,12 @@ namespace BTCPayServer.Controllers
return paymentMethod;
}
private async Task<DerivationSchemeSettings> GetDerivationSchemeSettings(WalletId walletId)
{
var store = (await Repository.FindStore(walletId.StoreId, GetUserId()));
return GetDerivationSchemeSettings(walletId, store);
}
private static async Task<string> GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy)
{
using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
@ -460,17 +409,6 @@ namespace BTCPayServer.Controllers
return _userManager.GetUserId(User);
}
[HttpGet]
[Route("{walletId}/send/ledger/success")]
public IActionResult WalletSendLedgerSuccess(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId,
string txid)
{
StatusMessage = $"Transaction broadcasted ({txid})";
return RedirectToAction(nameof(this.WalletTransactions), new { walletId = walletId.ToString() });
}
[HttpGet]
[Route("{walletId}/send/ledger/ws")]
public async Task<IActionResult> LedgerConnection(
@ -481,16 +419,18 @@ namespace BTCPayServer.Controllers
// getxpub
int account = 0,
// sendtoaddress
bool noChange = false,
string destination = null, string amount = null, string feeRate = null, bool substractFees = false, bool disableRBF = false
string psbt = null,
string hintChange = null
)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
return NotFound();
var cryptoCode = walletId.CryptoCode;
var network = NetworkProvider.GetNetwork(walletId.CryptoCode);
if (network == null)
throw new FormatException("Invalid value for crypto code");
var storeData = (await Repository.FindStore(walletId.StoreId, GetUserId()));
var derivationSettings = GetPaymentMethod(walletId, storeData);
var derivationSettings = GetDerivationSchemeSettings(walletId, storeData);
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
@ -498,55 +438,11 @@ namespace BTCPayServer.Controllers
using (var signTimeout = new CancellationTokenSource())
{
normalOperationTimeout.CancelAfter(TimeSpan.FromMinutes(30));
var hw = new HardwareWalletService(webSocket);
var hw = new LedgerHardwareWalletService(webSocket);
var model = new WalletSendLedgerModel();
object result = null;
try
{
BTCPayNetwork network = null;
if (cryptoCode != null)
{
network = NetworkProvider.GetNetwork(cryptoCode);
if (network == null)
throw new FormatException("Invalid value for crypto code");
}
if (destination != null)
{
try
{
BitcoinAddress.Create(destination.Trim(), network.NBitcoinNetwork);
model.Destination = destination.Trim();
}
catch { }
}
if (feeRate != null)
{
try
{
model.FeeSatoshiPerByte = int.Parse(feeRate, CultureInfo.InvariantCulture);
}
catch { }
if (model.FeeSatoshiPerByte <= 0)
throw new FormatException("Invalid value for fee rate");
}
if (amount != null)
{
try
{
model.Amount = Money.Parse(amount).ToDecimal(MoneyUnit.BTC);
}
catch { }
if (model.Amount <= 0m)
throw new FormatException("Invalid value for amount");
}
model.SubstractFees = substractFees;
model.NoChange = noChange;
model.DisableRBF = disableRBF;
if (command == "test")
{
result = await hw.Test(normalOperationTimeout.Token);
@ -556,46 +452,58 @@ namespace BTCPayServer.Controllers
if (!_dashboard.IsFullySynched(network.CryptoCode, out var summary))
throw new Exception($"{network.CryptoCode}: not started or fully synched");
var strategy = GetDirectDerivationStrategy(derivationSettings.AccountDerivation);
// Some deployment have the wallet root key path saved in the store blob
// If it does, we only have to make 1 call to the hw to check if it can sign the given strategy,
if (derivationSettings.AccountKeyPath == null || !await hw.CanSign(network, strategy, derivationSettings.AccountKeyPath, normalOperationTimeout.Token))
// Some deployment does not have the AccountKeyPath set, let's fix this...
if (derivationSettings.AccountKeyPath == null)
{
// If the saved wallet key path is not present or incorrect, let's scan the wallet to see if it can sign strategy
var foundKeyPath = await hw.FindKeyPath(network, strategy, normalOperationTimeout.Token);
if (foundKeyPath == null)
throw new HardwareWalletException($"This store is not configured to use this ledger");
derivationSettings.AccountKeyPath = foundKeyPath;
var foundKeyPath = await hw.FindKeyPathFromDerivation(network,
derivationSettings.AccountDerivation,
normalOperationTimeout.Token);
derivationSettings.AccountKeyPath = foundKeyPath ?? throw new HardwareWalletException($"This store is not configured to use this ledger");
storeData.SetSupportedPaymentMethod(derivationSettings);
await Repository.UpdateStore(storeData);
}
// If it has already the AccountKeyPath, we did not looked up for it, so we need to check if we are on the right ledger
else
{
// Checking if ledger is right with the RootFingerprint is faster as it does not need to make a query to the parent xpub,
// but some deployment does not have it, so let's use AccountKeyPath instead
if (derivationSettings.RootFingerprint == null)
{
var actualPubKey = await hw.GetExtPubKey(network, derivationSettings.AccountKeyPath, normalOperationTimeout.Token);
if (!derivationSettings.AccountDerivation.GetExtPubKeys().Any(p => p.GetPublicKey() == actualPubKey.GetPublicKey()))
throw new HardwareWalletException($"This store is not configured to use this ledger");
}
// We have the root fingerprint, we can check the root from it
else
{
var actualPubKey = await hw.GetPubKey(network, new KeyPath(), normalOperationTimeout.Token);
if (actualPubKey.GetHDFingerPrint() != derivationSettings.RootFingerprint.Value)
throw new HardwareWalletException($"This store is not configured to use this ledger");
}
}
// Some deployment does not have the RootFingerprint set, let's fix this...
if (derivationSettings.RootFingerprint == null)
{
derivationSettings.RootFingerprint = (await hw.GetPubKey(network, new KeyPath(), normalOperationTimeout.Token)).GetHDFingerPrint();
storeData.SetSupportedPaymentMethod(derivationSettings);
await Repository.UpdateStore(storeData);
}
var psbtResponse = new CreatePSBTResponse()
{
PSBT = PSBT.Parse(psbt, network.NBitcoinNetwork),
ChangeAddress = string.IsNullOrEmpty(hintChange) ? null : BitcoinAddress.Create(hintChange, network.NBitcoinNetwork)
};
derivationSettings.RebaseKeyPaths(psbtResponse.PSBT);
var psbt = await CreatePSBT(network, derivationSettings, model, normalOperationTimeout.Token);
signTimeout.CancelAfter(TimeSpan.FromMinutes(5));
psbt.PSBT = await hw.SignTransactionAsync(psbt.PSBT, psbt.ChangeAddress?.ScriptPubKey, signTimeout.Token);
if(!psbt.PSBT.TryFinalize(out var errors))
{
throw new Exception($"Error while finalizing the transaction ({new PSBTException(errors).ToString()})");
}
var transaction = psbt.PSBT.ExtractTransaction();
try
{
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
if (!broadcastResult.Success)
{
throw new Exception($"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}");
}
}
catch (Exception ex)
{
throw new Exception("Error while broadcasting: " + ex.Message);
}
var wallet = _walletProvider.GetWallet(network);
wallet.InvalidateCache(derivationSettings.AccountDerivation);
result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() };
psbtResponse.PSBT = await hw.SignTransactionAsync(psbtResponse.PSBT, psbtResponse.ChangeAddress?.ScriptPubKey, signTimeout.Token);
result = new SendToAddressResult() { PSBT = psbtResponse.PSBT.ToBase64() };
}
}
catch (OperationCanceledException)
@ -620,16 +528,6 @@ namespace BTCPayServer.Controllers
}
return new EmptyResult();
}
private DirectDerivationStrategy GetDirectDerivationStrategy(DerivationStrategyBase strategy)
{
if (strategy == null)
throw new Exception("The derivation scheme is not provided");
var directStrategy = strategy as DirectDerivationStrategy;
if (directStrategy == null)
directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy;
return directStrategy;
}
}
@ -639,6 +537,7 @@ namespace BTCPayServer.Controllers
public class SendToAddressResult
{
public string TransactionId { get; set; }
[JsonProperty("psbt")]
public string PSBT { get; set; }
}
}

View File

@ -126,12 +126,36 @@ namespace BTCPayServer
public BTCPayNetwork Network { get; set; }
public string Source { get; set; }
public KeyPath AccountKeyPath { get; set; }
public DerivationStrategyBase AccountDerivation { get; set; }
public string AccountOriginal { get; set; }
public HDFingerprint? RootFingerprint { get; set; }
public BitcoinExtPubKey ExplicitAccountKey { get; set; }
[JsonIgnore]
public BitcoinExtPubKey AccountKey
{
get
{
return ExplicitAccountKey ?? new BitcoinExtPubKey(AccountDerivation.GetExtPubKeys().First(), Network.NBitcoinNetwork);
}
}
public IEnumerable<NBXplorer.Models.PSBTRebaseKeyRules> GetPSBTRebaseKeyRules()
{
if (AccountKey != null && AccountKeyPath != null && RootFingerprint is HDFingerprint fp)
{
yield return new NBXplorer.Models.PSBTRebaseKeyRules()
{
AccountKey = AccountKey,
AccountKeyPath = AccountKeyPath,
MasterFingerprint = fp
};
}
}
public string Label { get; set; }
[JsonIgnore]
@ -152,5 +176,13 @@ namespace BTCPayServer
{
return Network.NBXplorerNetwork.Serializer.ToString(this);
}
public void RebaseKeyPaths(PSBT psbt)
{
foreach (var rebase in GetPSBTRebaseKeyRules())
{
psbt.RebaseKeyPaths(rebase.AccountKey, rebase.AccountKeyPath, rebase.MasterFingerprint);
}
}
}
}

View File

@ -26,6 +26,7 @@ namespace BTCPayServer.Models.StoreViewModels
public string CryptoCode { get; set; }
public string KeyPath { get; set; }
public string RootFingerprint { get; set; }
[Display(Name = "Hint address")]
public string HintAddress { get; set; }
public bool Confirmation { get; set; }
@ -37,5 +38,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Coldcard Wallet File")]
public IFormFile ColdcardPublicFile{ get; set; }
public string Config { get; set; }
public string Source { get; set; }
public string AccountKey { get; set; }
}
}

View File

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTCombineViewModel
{
public string OtherPSBT { get; set; }
[Display(Name = "PSBT to combine with...")]
public string PSBT { get; set; }
[Display(Name = "Upload PSBT from file...")]
public IFormFile UploadedPSBTFile { get; set; }
public PSBT GetSourcePSBT(Network network)
{
if (!string.IsNullOrEmpty(OtherPSBT))
{
try
{
return NBitcoin.PSBT.Parse(OtherPSBT, network);
}
catch
{ }
}
return null;
}
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
{
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch
{ }
}
return null;
}
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTReadyViewModel
{
public string PSBT { get; set; }
public List<string> Errors { get; set; }
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
{
public class WalletPSBTViewModel
{
public string Decoded { get; set; }
public string PSBT { get; set; }
public List<string> Errors { get; set; } = new List<string>();
internal PSBT GetPSBT(Network network)
{
try
{
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch { }
return null;
}
}
}

View File

@ -7,11 +7,9 @@ namespace BTCPayServer.Models.WalletViewModels
{
public class WalletSendLedgerModel
{
public int FeeSatoshiPerByte { get; set; }
public bool SubstractFees { get; set; }
public bool DisableRBF { get; set; }
public decimal Amount { get; set; }
public string Destination { get; set; }
public bool NoChange { get; set; }
public string WebsocketPath { get; set; }
public string PSBT { get; set; }
public string HintChange { get; set; }
public string SuccessPath { get; set; }
}
}

View File

@ -20,125 +20,30 @@ namespace BTCPayServer.Services
public HardwareWalletException(string message) : base(message) { }
public HardwareWalletException(string message, Exception inner) : base(message, inner) { }
}
public class HardwareWalletService : IDisposable
public abstract class HardwareWalletService : IDisposable
{
class WebSocketTransport : LedgerWallet.Transports.ILedgerTransport, IDisposable
public abstract string Device { get; }
public abstract Task<LedgerTestResult> Test(CancellationToken cancellation);
public abstract Task<BitcoinExtPubKey> GetExtPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation);
public virtual async Task<PubKey> GetPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation)
{
private readonly WebSocket webSocket;
public WebSocketTransport(System.Net.WebSockets.WebSocket webSocket)
{
if (webSocket == null)
throw new ArgumentNullException(nameof(webSocket));
this.webSocket = webSocket;
}
SemaphoreSlim _Semaphore = new SemaphoreSlim(1, 1);
public async Task<byte[][]> Exchange(byte[][] apdus, CancellationToken cancellationToken)
{
await _Semaphore.WaitAsync();
List<byte[]> responses = new List<byte[]>();
try
{
foreach (var apdu in apdus)
{
await this.webSocket.SendAsync(new ArraySegment<byte>(apdu), WebSocketMessageType.Binary, true, cancellationToken);
}
foreach (var apdu in apdus)
{
byte[] response = new byte[300];
var result = await this.webSocket.ReceiveAsync(new ArraySegment<byte>(response), cancellationToken);
Array.Resize(ref response, result.Count);
responses.Add(response);
}
}
finally
{
_Semaphore.Release();
}
return responses.ToArray();
}
public void Dispose()
{
_Semaphore.Dispose();
}
return (await GetExtPubKey(network, keyPath, cancellation)).GetPublicKey();
}
private readonly LedgerClient _Ledger;
public LedgerClient Ledger
{
get
{
return _Ledger;
}
}
WebSocketTransport _Transport = null;
public HardwareWalletService(System.Net.WebSockets.WebSocket ledgerWallet)
{
if (ledgerWallet == null)
throw new ArgumentNullException(nameof(ledgerWallet));
_Transport = new WebSocketTransport(ledgerWallet);
_Ledger = new LedgerClient(_Transport);
_Ledger.MaxAPDUSize = 90;
}
public async Task<LedgerTestResult> Test(CancellationToken cancellation)
{
var version = await Ledger.GetFirmwareVersionAsync(cancellation);
return new LedgerTestResult() { Success = true };
}
public async Task<GetXPubResult> GetExtPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation)
{
if (network == null)
throw new ArgumentNullException(nameof(network));
var segwit = network.NBitcoinNetwork.Consensus.SupportSegwit;
var pubkey = await GetExtPubKey(Ledger, network, keyPath, false, cancellation);
var derivation = new DerivationStrategyFactory(network.NBitcoinNetwork).CreateDirectDerivationStrategy(pubkey, new DerivationStrategyOptions()
{
P2SH = segwit,
Legacy = !segwit
});
return new GetXPubResult() { ExtPubKey = derivation.ToString(), KeyPath = keyPath };
}
private static async Task<BitcoinExtPubKey> GetExtPubKey(LedgerClient ledger, BTCPayNetwork network, KeyPath account, bool onlyChaincode, CancellationToken cancellation)
{
try
{
var pubKey = await ledger.GetWalletPubKeyAsync(account, cancellation: cancellation);
try
{
pubKey.GetAddress(network.NBitcoinNetwork);
}
catch
{
if (network.NBitcoinNetwork.NetworkType == NetworkType.Mainnet)
throw new Exception($"The opened ledger app does not seems to support {network.NBitcoinNetwork.Name}.");
}
var fingerprint = onlyChaincode ? default : (await ledger.GetWalletPubKeyAsync(account.Parent, cancellation: cancellation)).UncompressedPublicKey.Compress().GetHDFingerPrint();
var extpubkey = new ExtPubKey(pubKey.UncompressedPublicKey.Compress(), pubKey.ChainCode, (byte)account.Indexes.Length, fingerprint, account.Indexes.Last()).GetWif(network.NBitcoinNetwork);
return extpubkey;
}
catch (FormatException)
{
throw new HardwareWalletException("Unsupported ledger app");
}
}
public async Task<bool> CanSign(BTCPayNetwork network, DirectDerivationStrategy strategy, KeyPath keyPath, CancellationToken cancellation)
{
var hwKey = await GetExtPubKey(Ledger, network, keyPath, true, cancellation);
return hwKey.ExtPubKey.PubKey == strategy.Root.PubKey;
}
public async Task<KeyPath> FindKeyPath(BTCPayNetwork network, DirectDerivationStrategy directStrategy, CancellationToken cancellation)
public async Task<KeyPath> FindKeyPathFromDerivation(BTCPayNetwork network, DerivationStrategyBase derivationScheme, CancellationToken cancellation)
{
var pubKeys = derivationScheme.GetExtPubKeys().Select(k => k.GetPublicKey()).ToArray();
var derivation = derivationScheme.Derive(new KeyPath(0));
List<KeyPath> derivations = new List<KeyPath>();
if (network.NBitcoinNetwork.Consensus.SupportSegwit)
derivations.Add(new KeyPath("49'"));
{
if (derivation.Redeem?.IsWitness is true ||
derivation.ScriptPubKey.IsWitness) // Native or p2sh segwit
derivations.Add(new KeyPath("49'"));
if (derivation.Redeem == null && derivation.ScriptPubKey.IsWitness) // Native segwit
derivations.Add(new KeyPath("84'"));
}
derivations.Add(new KeyPath("44'"));
KeyPath foundKeyPath = null;
foreach (var account in
@ -146,70 +51,22 @@ namespace BTCPayServer.Services
.Select(purpose => purpose.Derive(network.CoinType))
.SelectMany(coinType => Enumerable.Range(0, 5).Select(i => coinType.Derive(i, true))))
{
try
var pubkey = await GetPubKey(network, account, cancellation);
if (pubKeys.Contains(pubkey))
{
var extpubkey = await GetExtPubKey(Ledger, network, account, true, cancellation);
if (directStrategy.Root.PubKey == extpubkey.ExtPubKey.PubKey)
{
foundKeyPath = account;
break;
}
}
catch (FormatException)
{
throw new Exception($"The opened ledger app does not support {network.NBitcoinNetwork.Name}");
foundKeyPath = account;
break;
}
}
return foundKeyPath;
}
public async Task<PSBT> SignTransactionAsync(PSBT psbt, Script changeHint,
CancellationToken cancellationToken)
{
try
{
var unsigned = psbt.GetGlobalTransaction();
var changeKeyPath = psbt.Outputs
.Where(o => changeHint == null ? true : changeHint == o.ScriptPubKey)
.Where(o => o.HDKeyPaths.Any())
.Select(o => o.HDKeyPaths.First().Value.Item2)
.FirstOrDefault();
var signatureRequests = psbt
.Inputs
.Where(o => o.HDKeyPaths.Any())
.Where(o => !o.PartialSigs.ContainsKey(o.HDKeyPaths.First().Key))
.Select(i => new SignatureRequest()
{
InputCoin = i.GetSignableCoin(),
InputTransaction = i.NonWitnessUtxo,
KeyPath = i.HDKeyPaths.First().Value.Item2,
PubKey = i.HDKeyPaths.First().Key
}).ToArray();
var signedTransaction = await Ledger.SignTransactionAsync(signatureRequests, unsigned, changeKeyPath, cancellationToken);
if (signedTransaction == null)
throw new Exception("The ledger failed to sign the transaction");
public abstract Task<PSBT> SignTransactionAsync(PSBT psbt, Script changeHint,
CancellationToken cancellationToken);
psbt = psbt.Clone();
foreach (var signature in signatureRequests)
{
var input = psbt.Inputs.FindIndexedInput(signature.InputCoin.Outpoint);
if (input == null)
continue;
input.PartialSigs.Add(signature.PubKey, signature.Signature);
}
return psbt;
}
catch (Exception ex)
{
throw new Exception("The ledger failed to sign the transaction", ex);
}
}
public void Dispose()
public virtual void Dispose()
{
if (_Transport != null)
_Transport.Dispose();
}
}
@ -218,11 +75,4 @@ namespace BTCPayServer.Services
public bool Success { get; set; }
public string Error { get; set; }
}
public class GetXPubResult
{
public string ExtPubKey { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.KeyPathJsonConverter))]
public KeyPath KeyPath { get; set; }
}
}

View File

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using LedgerWallet;
using NBitcoin;
namespace BTCPayServer.Services
{
public class LedgerHardwareWalletService : HardwareWalletService
{
class WebSocketTransport : LedgerWallet.Transports.ILedgerTransport, IDisposable
{
private readonly WebSocket webSocket;
public WebSocketTransport(System.Net.WebSockets.WebSocket webSocket)
{
if (webSocket == null)
throw new ArgumentNullException(nameof(webSocket));
this.webSocket = webSocket;
}
SemaphoreSlim _Semaphore = new SemaphoreSlim(1, 1);
public async Task<byte[][]> Exchange(byte[][] apdus, CancellationToken cancellationToken)
{
await _Semaphore.WaitAsync();
List<byte[]> responses = new List<byte[]>();
try
{
foreach (var apdu in apdus)
{
await this.webSocket.SendAsync(new ArraySegment<byte>(apdu), WebSocketMessageType.Binary, true, cancellationToken);
}
foreach (var apdu in apdus)
{
byte[] response = new byte[300];
var result = await this.webSocket.ReceiveAsync(new ArraySegment<byte>(response), cancellationToken);
Array.Resize(ref response, result.Count);
responses.Add(response);
}
}
finally
{
_Semaphore.Release();
}
return responses.ToArray();
}
public void Dispose()
{
_Semaphore.Dispose();
}
}
private readonly LedgerClient _Ledger;
public LedgerClient Ledger
{
get
{
return _Ledger;
}
}
public override string Device => "Ledger wallet";
WebSocketTransport _Transport = null;
public LedgerHardwareWalletService(System.Net.WebSockets.WebSocket ledgerWallet)
{
if (ledgerWallet == null)
throw new ArgumentNullException(nameof(ledgerWallet));
_Transport = new WebSocketTransport(ledgerWallet);
_Ledger = new LedgerClient(_Transport);
_Ledger.MaxAPDUSize = 90;
}
public override async Task<LedgerTestResult> Test(CancellationToken cancellation)
{
var version = await Ledger.GetFirmwareVersionAsync(cancellation);
return new LedgerTestResult() { Success = true };
}
public override async Task<BitcoinExtPubKey> GetExtPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation)
{
if (network == null)
throw new ArgumentNullException(nameof(network));
return await GetExtPubKey(network, keyPath, false, cancellation);
}
public override async Task<PubKey> GetPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation)
{
if (network == null)
throw new ArgumentNullException(nameof(network));
return (await GetExtPubKey(network, keyPath, false, cancellation)).GetPublicKey();
}
private async Task<BitcoinExtPubKey> GetExtPubKey(BTCPayNetwork network, KeyPath account, bool onlyChaincode, CancellationToken cancellation)
{
var pubKey = await Ledger.GetWalletPubKeyAsync(account, cancellation: cancellation);
try
{
pubKey.GetAddress(network.NBitcoinNetwork);
}
catch
{
if (network.NBitcoinNetwork.NetworkType == NetworkType.Mainnet)
throw new HardwareWalletException($"The opened ledger app does not seems to support {network.NBitcoinNetwork.Name}.");
}
var parentFP = onlyChaincode || account.Indexes.Length == 0 ? default : (await Ledger.GetWalletPubKeyAsync(account.Parent, cancellation: cancellation)).UncompressedPublicKey.Compress().GetHDFingerPrint();
var extpubkey = new ExtPubKey(pubKey.UncompressedPublicKey.Compress(),
pubKey.ChainCode,
(byte)account.Indexes.Length,
parentFP,
account.Indexes.Length == 0 ? 0 : account.Indexes.Last()).GetWif(network.NBitcoinNetwork);
return extpubkey;
}
public override async Task<PSBT> SignTransactionAsync(PSBT psbt, Script changeHint, CancellationToken cancellationToken)
{
var unsigned = psbt.GetGlobalTransaction();
var changeKeyPath = psbt.Outputs
.Where(o => changeHint == null ? true : changeHint == o.ScriptPubKey)
.Where(o => o.HDKeyPaths.Any())
.Select(o => o.HDKeyPaths.First().Value.Item2)
.FirstOrDefault();
var signatureRequests = psbt
.Inputs
.Where(o => o.HDKeyPaths.Any())
.Where(o => !o.PartialSigs.ContainsKey(o.HDKeyPaths.First().Key))
.Select(i => new SignatureRequest()
{
InputCoin = i.GetSignableCoin(),
InputTransaction = i.NonWitnessUtxo,
KeyPath = i.HDKeyPaths.First().Value.Item2,
PubKey = i.HDKeyPaths.First().Key
}).ToArray();
var signedTransaction = await Ledger.SignTransactionAsync(signatureRequests, unsigned, changeKeyPath, cancellationToken);
if (signedTransaction == null)
throw new HardwareWalletException("The ledger failed to sign the transaction");
psbt = psbt.Clone();
foreach (var signature in signatureRequests)
{
if (signature.Signature == null)
continue;
var input = psbt.Inputs.FindIndexedInput(signature.InputCoin.Outpoint);
if (input == null)
continue;
input.PartialSigs.Add(signature.PubKey, signature.Signature);
}
return psbt;
}
public override void Dispose()
{
if (_Transport != null)
_Transport.Dispose();
}
}
}

View File

@ -16,7 +16,7 @@
</div>
<div class="modal-body">
</div>
<div class="modal-footer">PRS
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="js-product-save btn btn-primary">Save Changes</button>
</div>

View File

@ -43,7 +43,10 @@
<p><a href="https://play.google.com/store/apps/details?id=com.greenaddress.greenbits_android_wallet" target="_blank">Blockstream Green Wallet</a></p>
</div>
<div class="col-lg-3 mr-auto text-center">
<a href="https://www.wasabiwallet.io/" target="_blank">
<img src="~/img/wasabi.png" height="100" />
</a>
<p><a href="https://www.wasabiwallet.io/" target="_blank">Wasabi Wallet</a> <a href="https://www.reddit.com/r/WasabiWallet/comments/aqlyia/how_to_connect_wasabi_wallet_to_my_own_full/" target="_blank"><span class="fa fa-question-circle-o" title="More information..."></span></a></p>
</div>
<div class="col-lg-3 mr-auto text-center">

View File

@ -50,6 +50,9 @@
</div>
<input id="CryptoCurrency" asp-for="CryptoCode" type="hidden" />
<input id="KeyPath" asp-for="KeyPath" type="hidden" />
<input id="Source" asp-for="Source" type="hidden" />
<input id="RootFingerprint" asp-for="RootFingerprint" type="hidden" />
<input id="AccountKey" asp-for="AccountKey" type="hidden" />
<input id="Config" asp-for="Config" type="hidden" />
<div class="form-group">
<label asp-for="DerivationScheme"></label>
@ -66,7 +69,6 @@
</p>
<div id="ledger-info" class="form-text text-muted display-when-ledger-connected">
<span>A ledger wallet is detected, which account do you want to use? No need to paste manually xpub if your ledger device was detected. Just select derivation scheme from the list bellow and xpub will automatically populate.</span>
</div>
<div class="d-flex">
<button type="button" class="btn btn-primary mr-2 " data-toggle="modal" data-target="#coldcardimport">
@ -84,10 +86,8 @@
</div>
</div>
</div>
</div>
<div class="form-group">
<span>BTCPay format memo</span>
@ -140,6 +140,9 @@
</div>
<input type="hidden" asp-for="Confirmation" />
<input id="KeyPath" asp-for="KeyPath" type="hidden" />
<input id="Source" asp-for="Source" type="hidden" />
<input id="RootFingerprint" asp-for="RootFingerprint" type="hidden" />
<input id="AccountKey" asp-for="AccountKey" type="hidden" />
<input type="hidden" asp-for="DerivationScheme" />
<input type="hidden" asp-for="Enabled" />
<input id="Config" asp-for="Config" type="hidden" />

View File

@ -0,0 +1,61 @@
@model WalletPSBTViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "PSBT";
ViewData.SetActivePageAndTitle(WalletsNavPages.PSBT);
}
<div class="row">
<div class="col-md-10 text-center">
<partial name="_StatusMessage" for="@TempData["TempDataProperty-StatusMessage"]" />
</div>
</div>
<div class="row">
<div class="col-md-10">
@if (Model.Errors != null && Model.Errors.Count != 0)
{
<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 />
}
</div>
}
@if (!string.IsNullOrEmpty(Model.Decoded))
{
<h3>Decoded PSBT</h3>
<div class="form-group">
<form method="post" asp-action="WalletPSBT">
<div class="dropdown" style="margin-top:16px;">
<button class="btn btn-primary dropdown-toggle" type="button" id="SendMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Sign with...
</button>
<input type="hidden" asp-for="PSBT" />
<div class="dropdown-menu" aria-labelledby="SendMenu">
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
<button name="command" type="submit" class="dropdown-item" value="save-psbt">... a wallet supporting PSBT files</button>
</div>
<button name="command" type="submit" class="btn btn-primary" value="broadcast">Broadcast</button>
<button name="command" type="submit" class="btn btn-primary" value="combine">Combine with another PSBT</button>
</div>
</form>
</div>
<pre><code class="json">@Model.Decoded</code></pre>
}
<h3>PSBT to decode</h3>
<form class="form-group" method="post" asp-action="WalletPSBT">
<div class="form-group">
<textarea class="form-control" rows="5" asp-for="PSBT"></textarea>
<span asp-validation-for="PSBT" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Decode</button>
</form>
</div>
</div>
@section Scripts {
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css">
<script src="~/vendor/highlightjs/highlight.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
}

View File

@ -0,0 +1,25 @@
@model WalletPSBTCombineViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "PSBT";
ViewData.SetActivePageAndTitle(WalletsNavPages.PSBT);
}
<h4>Combine PSBT</h4>
<div class="row">
<div class="col-md-10">
<form class="form-group" method="post" asp-action="WalletPSBTCombine">
<input type="hidden" asp-for="OtherPSBT" />
<div class="form-group">
<label asp-for="PSBT"></label>
<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">Combine</button>
</form>
</div>
</div>

View File

@ -0,0 +1,35 @@
@model WalletPSBTReadyViewModel
@{
Layout = "../Shared/_Layout.cshtml";
}
<section>
<div class="container">
@if (Model.Errors != 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 />
}
</div>
}
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">Transaction signed</h2>
<hr class="primary">
<p>Your transaction has been signed, what do you want to do next?</p>
</div>
</div>
<div class="row">
<div class="col-lg-12 text-center">
<form method="post" asp-action="WalletPSBTReady">
<input type="hidden" asp-for="PSBT" />
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast it</button> or
<button type="submit" class="btn btn-primary" name="command" value="analyze-psbt">Export as PSBT</button>
</form>
</div>
</div>
</div>
</section>

View File

@ -90,6 +90,7 @@
<div class="dropdown-menu" aria-labelledby="SendMenu">
<button name="command" type="submit" class="dropdown-item" value="ledger">... your Ledger Wallet device</button>
<button name="command" type="submit" class="dropdown-item" value="save-psbt">... a wallet supporting PSBT</button>
<button name="command" type="submit" class="dropdown-item" value="analyze-psbt">... analyze the PSBT</button>
</div>
</div>
</div>

View File

@ -12,19 +12,17 @@
</div>
<div class="row">
<div class="col-md-10">
<input type="hidden" asp-for="Destination" />
<input type="hidden" asp-for="Amount" />
<input type="hidden" asp-for="FeeSatoshiPerByte" />
<input type="hidden" asp-for="DisableRBF" />
<input type="hidden" asp-for="SubstractFees" />
<input type="hidden" asp-for="NoChange" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="HintChange" />
<input type="hidden" asp-for="SuccessPath" />
<input type="hidden" asp-for="WebsocketPath" />
<p>
You can send money received by this store to an address with the help of your Ledger Wallet. <br />
If you don't have a Ledger Wallet, use Electrum with your favorite hardware wallet to transfer crypto. <br />
If your Ledger wallet is not detected:
</p>
<ul>
<li>Make sure you are running the Ledger app with version superior or equal to 1.2.4</li>
<li>Make sure you are running the Ledger app with version superior or equal to <b>1.3.9</b></li>
<li>Use Google Chrome browser and open the coin app on your Ledger</li>
</ul>
<p id="hw-loading"><span class="fa fa-question-circle" style="color:orange"></span> <span>Detecting hardware wallet...</span></p>

View File

@ -9,6 +9,7 @@ namespace BTCPayServer.Views.Wallets
{
Send,
Transactions,
Rescan
Rescan,
PSBT
}
}

View File

@ -4,5 +4,6 @@
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Transactions)" asp-action="WalletTransactions">Transactions</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Send)" asp-action="WalletSend">Send</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Rescan)" asp-action="WalletRescan">Rescan</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT">PSBT</a>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -48,7 +48,10 @@
showFeedback("ledger-info");
$("#DerivationScheme").val(result.extPubKey);
$("#DerivationScheme").val(result.derivationScheme);
$("#RootFingerprint").val(result.rootFingerprint);
$("#AccountKey").val(result.extPubKey);
$("#Source").val(result.source);
$("#DerivationSchemeFormat").val("BTCPay");
$("#KeyPath").val(keypath);
})

View File

@ -1,11 +1,9 @@
$(function () {
var destination = $("#Destination").val();
var amount = $("#Amount").val();
var fee = $("#FeeSatoshiPerByte").val();
var substractFee = $("#SubstractFees").val();
var noChange = $("#NoChange").val();
var disableRBF = $("#DisableRBF").val();
var psbt = $("#PSBT").val();
var hintChange = $("#HintChange").val();
var successPath = $("#SuccessPath").val();
var websocketPath = $("#WebsocketPath").val();
var loc = window.location, ws_uri;
if (loc.protocol === "https:") {
ws_uri = "wss:";
@ -13,10 +11,7 @@
ws_uri = "ws:";
}
ws_uri += "//" + loc.host;
ws_uri += loc.pathname + "/ws";
var successCallback = loc.protocol + "//" + loc.host + loc.pathname + "/success";
ws_uri += websocketPath;
var ledgerDetected = false;
var bridge = new ledgerwebsocket.LedgerWebSocketBridge(ws_uri);
function WriteAlert(type, message) {
@ -44,19 +39,10 @@
return false;
$(".crypto-info").css("display", "block");
var args = "";
args += "&destination=" + destination;
args += "&amount=" + amount;
args += "&feeRate=" + fee;
args += "&substractFees=" + substractFee;
args += "&noChange=" + noChange;
args += "&disableRBF=" + disableRBF;
if (noChange === "True") {
WriteAlert("warning", 'WARNING: Because you want to make sure no change UTXO is created, you will end up sending more than the chosen amount to your destination. Please validate the transaction on your ledger');
}
else {
WriteAlert("warning", 'Please validate the transaction on your ledger');
}
args += "&psbt=" + encodeURIComponent(psbt);
args += "&hintChange=" + encodeURIComponent(hintChange);
WriteAlert("warning", 'Please validate the transaction on your ledger');
var confirmButton = $("#confirm-button");
confirmButton.prop("disabled", true);
@ -76,8 +62,7 @@
if (result.error) {
WriteAlert("danger", result.error);
} else {
WriteAlert("success", 'Transaction broadcasted (' + result.transactionId + ')');
window.location.replace(successCallback + "?txid=" + result.transactionId);
window.location.replace(loc.protocol + "//" + loc.host + successPath + "?psbt=" + encodeURIComponent(result.psbt));
}
});
};