mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-23 06:35:13 +01:00
Refactor the code to prepare the group to support of another hardware wallet
This commit is contained in:
parent
93fc12bb2e
commit
6181e8b3e4
5 changed files with 355 additions and 248 deletions
|
@ -2,7 +2,7 @@
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>netcoreapp2.0</TargetFramework>
|
<TargetFramework>netcoreapp2.0</TargetFramework>
|
||||||
<Version>1.0.1.24</Version>
|
<Version>1.0.1.25</Version>
|
||||||
<NoWarn>NU1701</NoWarn>
|
<NoWarn>NU1701</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
@ -120,61 +120,17 @@ namespace BTCPayServer.Controllers
|
||||||
return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/";
|
return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebSocketTransport : LedgerWallet.Transports.ILedgerTransport
|
public class GetInfoResult
|
||||||
{
|
|
||||||
private readonly WebSocket webSocket;
|
|
||||||
|
|
||||||
public WebSocketTransport(System.Net.WebSockets.WebSocket webSocket)
|
|
||||||
{
|
|
||||||
if (webSocket == null)
|
|
||||||
throw new ArgumentNullException(nameof(webSocket));
|
|
||||||
this.webSocket = webSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
|
|
||||||
public async Task<byte[][]> Exchange(byte[][] apdus)
|
|
||||||
{
|
|
||||||
List<byte[]> responses = new List<byte[]>();
|
|
||||||
using (CancellationTokenSource cts = new CancellationTokenSource(Timeout))
|
|
||||||
{
|
|
||||||
foreach (var apdu in apdus)
|
|
||||||
{
|
|
||||||
await this.webSocket.SendAsync(new ArraySegment<byte>(apdu), WebSocketMessageType.Binary, true, cts.Token);
|
|
||||||
}
|
|
||||||
foreach (var apdu in apdus)
|
|
||||||
{
|
|
||||||
byte[] response = new byte[300];
|
|
||||||
var result = await this.webSocket.ReceiveAsync(new ArraySegment<byte>(response), cts.Token);
|
|
||||||
Array.Resize(ref response, result.Count);
|
|
||||||
responses.Add(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return responses.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LedgerTestResult
|
|
||||||
{
|
|
||||||
public bool Success { get; set; }
|
|
||||||
public string Error { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class GetInfoResult
|
|
||||||
{
|
{
|
||||||
public int RecommendedSatoshiPerByte { get; set; }
|
public int RecommendedSatoshiPerByte { get; set; }
|
||||||
public double Balance { get; set; }
|
public double Balance { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
class SendToAddressResult
|
public class SendToAddressResult
|
||||||
{
|
{
|
||||||
public string TransactionId { get; set; }
|
public string TransactionId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
class GetXPubResult
|
|
||||||
{
|
|
||||||
public string ExtPubKey { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("{storeId}/ws/ledger")]
|
[Route("{storeId}/ws/ledger")]
|
||||||
public async Task<IActionResult> LedgerConnection(
|
public async Task<IActionResult> LedgerConnection(
|
||||||
|
@ -193,185 +149,131 @@ namespace BTCPayServer.Controllers
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||||
var ledgerTransport = new WebSocketTransport(webSocket);
|
|
||||||
var ledger = new LedgerWallet.LedgerClient(ledgerTransport);
|
var hw = new HardwareWalletService(webSocket);
|
||||||
|
object result = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (command == "test")
|
BTCPayNetwork network = null;
|
||||||
|
if (cryptoCode != null)
|
||||||
{
|
{
|
||||||
var version = await ledger.GetFirmwareVersionAsync();
|
network = _NetworkProvider.GetNetwork(cryptoCode);
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = true });
|
if (network == null)
|
||||||
}
|
throw new FormatException("Invalid value for crypto code");
|
||||||
if (command == "getxpub")
|
|
||||||
{
|
|
||||||
var network = _NetworkProvider.GetNetwork(cryptoCode);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pubkey = await GetExtPubKey(ledger, network, new KeyPath("49'").Derive(network.CoinType).Derive(0, true), false);
|
|
||||||
var derivation = new DerivationStrategyFactory(network.NBitcoinNetwork).CreateDirectDerivationStrategy(pubkey, new DerivationStrategyOptions()
|
|
||||||
{
|
|
||||||
P2SH = true,
|
|
||||||
Legacy = false
|
|
||||||
});
|
|
||||||
await Send(webSocket, new GetXPubResult() { ExtPubKey = derivation.ToString() });
|
|
||||||
}
|
|
||||||
catch(FormatException)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = "Unsupported ledger app" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (command == "getinfo")
|
|
||||||
{
|
|
||||||
var network = _NetworkProvider.GetNetwork(cryptoCode);
|
|
||||||
var strategy = store.GetDerivationStrategies(_NetworkProvider).FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork);
|
|
||||||
if (strategy == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"Derivation strategy for {cryptoCode} is not set" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
DirectDerivationStrategy directStrategy = GetDirectStrategy(strategy);
|
|
||||||
if (directStrategy == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"The feature does not work for multi-sig or non-segwit wallets" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
var foundKeyPath = await GetKeyPath(ledger, network, directStrategy);
|
|
||||||
|
|
||||||
if (foundKeyPath == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"This store is not configured to use this ledger" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
var feeProvider = _FeeRateProvider.CreateFeeProvider(network);
|
|
||||||
var recommendedFees = feeProvider.GetFeeRateAsync();
|
|
||||||
var balance = _WalletProvider.GetWallet(network).GetBalance(strategy.DerivationStrategyBase);
|
|
||||||
|
|
||||||
await Send(webSocket, new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command == "sendtoaddress")
|
|
||||||
{
|
|
||||||
var network = _NetworkProvider.GetNetwork(cryptoCode);
|
|
||||||
var strategy = store.GetDerivationStrategies(_NetworkProvider).FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork);
|
|
||||||
if (strategy == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"Derivation strategy for {cryptoCode} is not set" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
DirectDerivationStrategy directStrategy = GetDirectStrategy(strategy);
|
|
||||||
if (directStrategy == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"The feature does not work for multi-sig or non-segwit wallets" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
var foundKeyPath = await GetKeyPath(ledger, network, directStrategy);
|
|
||||||
|
|
||||||
if (foundKeyPath == null)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"This store is not configured to use this ledger" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BitcoinAddress destinationAddress = null;
|
BitcoinAddress destinationAddress = null;
|
||||||
|
if (destination != null)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
destinationAddress = BitcoinAddress.Create(destination.Trim());
|
destinationAddress = BitcoinAddress.Create(destination);
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
if (destinationAddress == null)
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"Invalid destination address" });
|
throw new FormatException("Invalid value for destination");
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
Money amountBTC = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
amountBTC = Money.Parse(amount);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"Invalid amount" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
if (amount <= Money.Zero)
|
|
||||||
{
|
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = "The amount should be above zero" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FeeRate feeRateValue = null;
|
FeeRate feeRateValue = null;
|
||||||
|
if (feeRate != null)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate)), 1);
|
feeRateValue = new FeeRate(Money.Satoshis(int.Parse(feeRate)), 1);
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
if (feeRateValue == null || feeRateValue.FeePerK <= Money.Zero)
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = "Invalid fee rate" });
|
throw new FormatException("Invalid value for fee rate");
|
||||||
return new EmptyResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feeRateValue.FeePerK <= Money.Zero)
|
Money amountBTC = null;
|
||||||
|
if (amount != null)
|
||||||
{
|
{
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = "The fee rate should be above zero" });
|
|
||||||
return new EmptyResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool substractFeeBool = bool.Parse(substractFees);
|
|
||||||
|
|
||||||
var wallet = _WalletProvider.GetWallet(network);
|
|
||||||
var unspentCoins = await wallet.GetUnspentCoins(strategy.DerivationStrategyBase);
|
|
||||||
|
|
||||||
TransactionBuilder builder = new TransactionBuilder();
|
|
||||||
builder.AddCoins(unspentCoins.Item1);
|
|
||||||
builder.Send(destinationAddress, amountBTC);
|
|
||||||
if (substractFeeBool)
|
|
||||||
builder.SubtractFees();
|
|
||||||
var change = await wallet.GetChangeAddressAsync(strategy.DerivationStrategyBase);
|
|
||||||
builder.SetChange(change.Item1);
|
|
||||||
builder.SendEstimatedFees(feeRateValue);
|
|
||||||
builder.Shuffle();
|
|
||||||
var unsigned = builder.BuildTransaction(false);
|
|
||||||
|
|
||||||
Dictionary<OutPoint, KeyPath> keyPaths = unspentCoins.Item2;
|
|
||||||
var hasChange = unsigned.Outputs.Count == 2;
|
|
||||||
var usedCoins = builder.FindSpentCoins(unsigned);
|
|
||||||
ledgerTransport.Timeout = TimeSpan.FromMinutes(5);
|
|
||||||
var fullySigned = await ledger.SignTransactionAsync(
|
|
||||||
usedCoins.Select(c => new SignatureRequest
|
|
||||||
{
|
|
||||||
InputCoin = c,
|
|
||||||
KeyPath = foundKeyPath.Derive(keyPaths[c.Outpoint]),
|
|
||||||
PubKey = directStrategy.Root.Derive(keyPaths[c.Outpoint]).PubKey
|
|
||||||
}).ToArray(),
|
|
||||||
unsigned,
|
|
||||||
hasChange ? foundKeyPath.Derive(change.Item2) : null);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await wallet.BroadcastTransactionsAsync(new List<Transaction>() { fullySigned });
|
amountBTC = Money.Parse(amount);
|
||||||
if (!result[0].Success)
|
}
|
||||||
|
catch { }
|
||||||
|
if (amountBTC == null || amountBTC <= Money.Zero)
|
||||||
|
throw new FormatException("Invalid value for amount");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool subsctractFeesValue = false;
|
||||||
|
if (substractFees != null)
|
||||||
{
|
{
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = $"RPC Error while broadcasting: {result[0].RPCCode} {result[0].RPCCodeMessage} {result[0].RPCMessage}" });
|
try
|
||||||
return new EmptyResult();
|
{
|
||||||
|
subsctractFeesValue = bool.Parse(substractFees);
|
||||||
|
}
|
||||||
|
catch { throw new FormatException("Invalid value for substract fees"); }
|
||||||
|
}
|
||||||
|
if (command == "test")
|
||||||
|
{
|
||||||
|
result = await hw.Test();
|
||||||
|
}
|
||||||
|
if (command == "getxpub")
|
||||||
|
{
|
||||||
|
result = await hw.GetExtPubKey(network);
|
||||||
|
}
|
||||||
|
if (command == "getinfo")
|
||||||
|
{
|
||||||
|
var strategy = GetDirectDerivationStrategy(store, network);
|
||||||
|
var strategyBase = GetDerivationStrategy(store, network);
|
||||||
|
if (!await hw.SupportDerivation(network, strategy))
|
||||||
|
{
|
||||||
|
throw new Exception($"This store is not configured to use this ledger");
|
||||||
|
}
|
||||||
|
|
||||||
|
var feeProvider = _FeeRateProvider.CreateFeeProvider(network);
|
||||||
|
var recommendedFees = feeProvider.GetFeeRateAsync();
|
||||||
|
var balance = _WalletProvider.GetWallet(network).GetBalance(strategyBase);
|
||||||
|
result = new GetInfoResult() { Balance = (double)(await balance).ToDecimal(MoneyUnit.BTC), RecommendedSatoshiPerByte = (int)(await recommendedFees).GetFee(1).Satoshi };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "sendtoaddress")
|
||||||
|
{
|
||||||
|
var strategy = GetDirectDerivationStrategy(store, network);
|
||||||
|
var strategyBase = GetDerivationStrategy(store, network);
|
||||||
|
var wallet = _WalletProvider.GetWallet(network);
|
||||||
|
var change = wallet.GetChangeAddressAsync(strategyBase);
|
||||||
|
var unspentCoins = await wallet.GetUnspentCoins(strategyBase);
|
||||||
|
var changeAddress = await change;
|
||||||
|
unspentCoins.Item2.TryAdd(changeAddress.Item1.ScriptPubKey, changeAddress.Item2);
|
||||||
|
var transaction = await hw.SendToAddress(strategy, unspentCoins.Item1, network,
|
||||||
|
new[] { (destinationAddress as IDestination, amountBTC, subsctractFeesValue) },
|
||||||
|
feeRateValue,
|
||||||
|
changeAddress.Item1,
|
||||||
|
unspentCoins.Item2);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var broadcastResult = await wallet.BroadcastTransactionsAsync(new List<Transaction>() { transaction });
|
||||||
|
if (!broadcastResult[0].Success)
|
||||||
|
{
|
||||||
|
throw new Exception($"RPC Error while broadcasting: {broadcastResult[0].RPCCode} {broadcastResult[0].RPCCodeMessage} {broadcastResult[0].RPCMessage}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Send(webSocket, new LedgerTestResult() { Success = false, Error = "Error while broadcasting: " + ex.Message });
|
throw new Exception("Error while broadcasting: " + ex.Message);
|
||||||
return new EmptyResult();
|
|
||||||
}
|
}
|
||||||
await Send(webSocket, new SendToAddressResult() { TransactionId = fullySigned.GetHash().ToString() });
|
result = new SendToAddressResult() { TransactionId = transaction.GetHash().ToString() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (LedgerWallet.LedgerWalletException ex)
|
|
||||||
{ try { await Send(webSocket, new LedgerTestResult() { Success = false, Error = ex.Message }); } catch { } }
|
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{ try { await Send(webSocket, new LedgerTestResult() { Success = false, Error = "timeout" }); } catch { } }
|
{ result = new LedgerTestResult() { Success = false, Error = "Timeout" }; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{ try { await Send(webSocket, new LedgerTestResult() { Success = false, Error = ex.Message }); } catch { } }
|
{ result = new LedgerTestResult() { Success = false, Error = ex.Message }; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
UTF8Encoding UTF8NOBOM = new UTF8Encoding(false);
|
||||||
|
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings));
|
||||||
|
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await webSocket.CloseSocket();
|
await webSocket.CloseSocket();
|
||||||
|
@ -380,60 +282,26 @@ namespace BTCPayServer.Controllers
|
||||||
return new EmptyResult();
|
return new EmptyResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<KeyPath> GetKeyPath(LedgerClient ledger, BTCPayNetwork network, DirectDerivationStrategy directStrategy)
|
private DirectDerivationStrategy GetDirectDerivationStrategy(StoreData store, BTCPayNetwork network)
|
||||||
{
|
{
|
||||||
KeyPath foundKeyPath = null;
|
var strategy = GetDerivationStrategy(store, network);
|
||||||
foreach (var account in
|
var directStrategy = strategy as DirectDerivationStrategy;
|
||||||
new[] { new KeyPath("49'"), new KeyPath("44'") }
|
|
||||||
.Select(purpose => purpose.Derive(network.CoinType))
|
|
||||||
.SelectMany(coinType => Enumerable.Range(0, 5).Select(i => coinType.Derive(i, true))))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var extpubkey = await GetExtPubKey(ledger, network, account, true);
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return foundKeyPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<BitcoinExtPubKey> GetExtPubKey(LedgerClient ledger, BTCPayNetwork network, KeyPath account, bool onlyChaincode)
|
|
||||||
{
|
|
||||||
var pubKey = await ledger.GetWalletPubKeyAsync(account);
|
|
||||||
if (pubKey.Address.Network != network.NBitcoinNetwork)
|
|
||||||
{
|
|
||||||
if (network.DefaultSettings.ChainType == NBXplorer.ChainType.Main)
|
|
||||||
throw new Exception($"The opened ledger app should be for {network.NBitcoinNetwork.Name}, not for {pubKey.Address.Network}");
|
|
||||||
}
|
|
||||||
var fingerprint = onlyChaincode ? new byte[4] : (await ledger.GetWalletPubKeyAsync(account.Parent)).UncompressedPublicKey.Compress().Hash.ToBytes().Take(4).ToArray();
|
|
||||||
var extpubkey = new ExtPubKey(pubKey.UncompressedPublicKey.Compress(), pubKey.ChainCode, (byte)account.Indexes.Length, fingerprint, account.Indexes.Last()).GetWif(network.NBitcoinNetwork);
|
|
||||||
return extpubkey;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DirectDerivationStrategy GetDirectStrategy(DerivationStrategy strategy)
|
|
||||||
{
|
|
||||||
var directStrategy = strategy.DerivationStrategyBase as DirectDerivationStrategy;
|
|
||||||
if (directStrategy == null)
|
if (directStrategy == null)
|
||||||
directStrategy = (strategy.DerivationStrategyBase as P2SHDerivationStrategy).Inner as DirectDerivationStrategy;
|
directStrategy = (strategy as P2SHDerivationStrategy).Inner as DirectDerivationStrategy;
|
||||||
if (!directStrategy.Segwit)
|
if (!directStrategy.Segwit)
|
||||||
return null;
|
return null;
|
||||||
return directStrategy;
|
return directStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
UTF8Encoding UTF8NOBOM = new UTF8Encoding(false);
|
private DerivationStrategyBase GetDerivationStrategy(StoreData store, BTCPayNetwork network)
|
||||||
private async Task Send(WebSocket webSocket, object result)
|
|
||||||
{
|
{
|
||||||
var bytes = UTF8NOBOM.GetBytes(JsonConvert.SerializeObject(result, _MvcJsonOptions.SerializerSettings));
|
var strategy = store.GetDerivationStrategies(_NetworkProvider).FirstOrDefault(s => s.Network.NBitcoinNetwork == network.NBitcoinNetwork);
|
||||||
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, new CancellationTokenSource(2000).Token);
|
if (strategy == null)
|
||||||
|
{
|
||||||
|
throw new Exception($"Derivation strategy for {network.CryptoCode} is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
return strategy.DerivationStrategyBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
226
BTCPayServer/Services/HardwareWalletService.cs
Normal file
226
BTCPayServer/Services/HardwareWalletService.cs
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LedgerWallet;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBXplorer.DerivationStrategy;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Services
|
||||||
|
{
|
||||||
|
|
||||||
|
public class HardwareWalletException : Exception
|
||||||
|
{
|
||||||
|
public HardwareWalletException() { }
|
||||||
|
public HardwareWalletException(string message) : base(message) { }
|
||||||
|
public HardwareWalletException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
|
public class HardwareWalletService
|
||||||
|
{
|
||||||
|
class WebSocketTransport : LedgerWallet.Transports.ILedgerTransport
|
||||||
|
{
|
||||||
|
private readonly WebSocket webSocket;
|
||||||
|
|
||||||
|
public WebSocketTransport(System.Net.WebSockets.WebSocket webSocket)
|
||||||
|
{
|
||||||
|
if (webSocket == null)
|
||||||
|
throw new ArgumentNullException(nameof(webSocket));
|
||||||
|
this.webSocket = webSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
public async Task<byte[][]> Exchange(byte[][] apdus)
|
||||||
|
{
|
||||||
|
List<byte[]> responses = new List<byte[]>();
|
||||||
|
using (CancellationTokenSource cts = new CancellationTokenSource(Timeout))
|
||||||
|
{
|
||||||
|
foreach (var apdu in apdus)
|
||||||
|
{
|
||||||
|
await this.webSocket.SendAsync(new ArraySegment<byte>(apdu), WebSocketMessageType.Binary, true, cts.Token);
|
||||||
|
}
|
||||||
|
foreach (var apdu in apdus)
|
||||||
|
{
|
||||||
|
byte[] response = new byte[300];
|
||||||
|
var result = await this.webSocket.ReceiveAsync(new ArraySegment<byte>(response), cts.Token);
|
||||||
|
Array.Resize(ref response, result.Count);
|
||||||
|
responses.Add(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return responses.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LedgerTestResult> Test()
|
||||||
|
{
|
||||||
|
var version = await _Ledger.GetFirmwareVersionAsync();
|
||||||
|
return new LedgerTestResult() { Success = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetXPubResult> GetExtPubKey(BTCPayNetwork network)
|
||||||
|
{
|
||||||
|
if (network == null)
|
||||||
|
throw new ArgumentNullException(nameof(network));
|
||||||
|
|
||||||
|
var pubkey = await GetExtPubKey(_Ledger, network, new KeyPath("49'").Derive(network.CoinType).Derive(0, true), false);
|
||||||
|
var derivation = new DerivationStrategyFactory(network.NBitcoinNetwork).CreateDirectDerivationStrategy(pubkey, new DerivationStrategyOptions()
|
||||||
|
{
|
||||||
|
P2SH = true,
|
||||||
|
Legacy = false
|
||||||
|
});
|
||||||
|
return new GetXPubResult() { ExtPubKey = derivation.ToString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<BitcoinExtPubKey> GetExtPubKey(LedgerClient ledger, BTCPayNetwork network, KeyPath account, bool onlyChaincode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pubKey = await ledger.GetWalletPubKeyAsync(account);
|
||||||
|
if (pubKey.Address.Network != network.NBitcoinNetwork)
|
||||||
|
{
|
||||||
|
if (network.DefaultSettings.ChainType == NBXplorer.ChainType.Main)
|
||||||
|
throw new Exception($"The opened ledger app should be for {network.NBitcoinNetwork.Name}, not for {pubKey.Address.Network}");
|
||||||
|
}
|
||||||
|
var fingerprint = onlyChaincode ? new byte[4] : (await ledger.GetWalletPubKeyAsync(account.Parent)).UncompressedPublicKey.Compress().Hash.ToBytes().Take(4).ToArray();
|
||||||
|
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> SupportDerivation(BTCPayNetwork network, DirectDerivationStrategy strategy)
|
||||||
|
{
|
||||||
|
if (network == null)
|
||||||
|
throw new ArgumentNullException(nameof(Network));
|
||||||
|
if (strategy == null)
|
||||||
|
throw new ArgumentNullException(nameof(strategy));
|
||||||
|
return await GetKeyPath(_Ledger, network, strategy) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<KeyPath> GetKeyPath(LedgerClient ledger, BTCPayNetwork network, DirectDerivationStrategy directStrategy)
|
||||||
|
{
|
||||||
|
KeyPath foundKeyPath = null;
|
||||||
|
foreach (var account in
|
||||||
|
new[] { new KeyPath("49'"), new KeyPath("44'") }
|
||||||
|
.Select(purpose => purpose.Derive(network.CoinType))
|
||||||
|
.SelectMany(coinType => Enumerable.Range(0, 5).Select(i => coinType.Derive(i, true))))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extpubkey = await GetExtPubKey(ledger, network, account, true);
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundKeyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Transaction> SendToAddress(DirectDerivationStrategy strategy,
|
||||||
|
Coin[] coins, BTCPayNetwork network,
|
||||||
|
(IDestination destination, Money amount, bool substractFees)[] send,
|
||||||
|
FeeRate feeRate,
|
||||||
|
IDestination changeAddress,
|
||||||
|
Dictionary<Script, KeyPath> keypaths = null)
|
||||||
|
{
|
||||||
|
if (strategy == null)
|
||||||
|
throw new ArgumentNullException(nameof(strategy));
|
||||||
|
if (network == null)
|
||||||
|
throw new ArgumentNullException(nameof(network));
|
||||||
|
if (feeRate == null)
|
||||||
|
throw new ArgumentNullException(nameof(feeRate));
|
||||||
|
if (changeAddress == null)
|
||||||
|
throw new ArgumentNullException(nameof(changeAddress));
|
||||||
|
if (keypaths == null)
|
||||||
|
throw new ArgumentNullException(nameof(keypaths));
|
||||||
|
if (feeRate.FeePerK <= Money.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(feeRate), "The fee rate should be above zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var element in send)
|
||||||
|
{
|
||||||
|
if (element.destination == null)
|
||||||
|
throw new ArgumentNullException(nameof(element.destination));
|
||||||
|
if (element.amount == null)
|
||||||
|
throw new ArgumentNullException(nameof(element.amount));
|
||||||
|
if (element.amount <= Money.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(element.amount), "The amount should be above zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundKeyPath = await GetKeyPath(Ledger, network, strategy);
|
||||||
|
|
||||||
|
if (foundKeyPath == null)
|
||||||
|
{
|
||||||
|
throw new HardwareWalletException($"This store is not configured to use this ledger");
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionBuilder builder = new TransactionBuilder();
|
||||||
|
builder.AddCoins(coins);
|
||||||
|
|
||||||
|
foreach (var element in send)
|
||||||
|
{
|
||||||
|
builder.Send(element.destination, element.amount);
|
||||||
|
if (element.substractFees)
|
||||||
|
builder.SubtractFees();
|
||||||
|
}
|
||||||
|
builder.SetChange(changeAddress);
|
||||||
|
builder.SendEstimatedFees(feeRate);
|
||||||
|
builder.Shuffle();
|
||||||
|
var unsigned = builder.BuildTransaction(false);
|
||||||
|
|
||||||
|
var hasChange = unsigned.Outputs.Count == 2;
|
||||||
|
var usedCoins = builder.FindSpentCoins(unsigned);
|
||||||
|
_Transport.Timeout = TimeSpan.FromMinutes(5);
|
||||||
|
var fullySigned = await Ledger.SignTransactionAsync(
|
||||||
|
usedCoins.Select(c => new SignatureRequest
|
||||||
|
{
|
||||||
|
InputCoin = c,
|
||||||
|
KeyPath = foundKeyPath.Derive(keypaths[c.TxOut.ScriptPubKey]),
|
||||||
|
PubKey = strategy.Root.Derive(keypaths[c.TxOut.ScriptPubKey]).PubKey
|
||||||
|
}).ToArray(),
|
||||||
|
unsigned,
|
||||||
|
hasChange ? foundKeyPath.Derive(keypaths[changeAddress.ScriptPubKey]) : null);
|
||||||
|
return fullySigned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LedgerTestResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetXPubResult
|
||||||
|
{
|
||||||
|
public string ExtPubKey { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -117,13 +117,13 @@ namespace BTCPayServer.Services.Wallets
|
||||||
return Task.WhenAll(tasks);
|
return Task.WhenAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(Coin[], Dictionary<OutPoint, KeyPath>)> GetUnspentCoins(DerivationStrategyBase derivationStrategy, CancellationToken cancellation = default(CancellationToken))
|
public async Task<(Coin[], Dictionary<Script, KeyPath>)> GetUnspentCoins(DerivationStrategyBase derivationStrategy, CancellationToken cancellation = default(CancellationToken))
|
||||||
{
|
{
|
||||||
var changes = await _Client.GetUTXOsAsync(derivationStrategy, null, false, cancellation).ConfigureAwait(false);
|
var changes = await _Client.GetUTXOsAsync(derivationStrategy, null, false, cancellation).ConfigureAwait(false);
|
||||||
var keyPaths = new Dictionary<OutPoint, KeyPath>();
|
var keyPaths = new Dictionary<Script, KeyPath>();
|
||||||
foreach (var coin in changes.GetUnspentUTXOs())
|
foreach (var coin in changes.GetUnspentUTXOs())
|
||||||
{
|
{
|
||||||
keyPaths.TryAdd(coin.Outpoint, coin.KeyPath);
|
keyPaths.TryAdd(coin.ScriptPubKey, coin.KeyPath);
|
||||||
}
|
}
|
||||||
return (changes.GetUnspentCoins(), keyPaths);
|
return (changes.GetUnspentCoins(), keyPaths);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
$(function () {
|
$(function () {
|
||||||
var ledgerDetected = false;
|
var ledgerDetected = false;
|
||||||
var bridge = new ledgerwebsocket.LedgerWebSocketBridge(srvModel.serverUrl + "ws/ledger");
|
var bridge = new ledgerwebsocket.LedgerWebSocketBridge(srvModel.serverUrl + "ws/ledger");
|
||||||
|
var recommendedFees = "";
|
||||||
|
var recommendedBalance = "";
|
||||||
|
|
||||||
function WriteAlert(type, message) {
|
function WriteAlert(type, message) {
|
||||||
$(".alert").removeClass("alert-danger");
|
$(".alert").removeClass("alert-danger");
|
||||||
|
@ -25,6 +27,15 @@
|
||||||
$("#sendform").on("submit", function (elem) {
|
$("#sendform").on("submit", function (elem) {
|
||||||
elem.preventDefault();
|
elem.preventDefault();
|
||||||
|
|
||||||
|
if ($("#amount-textbox").val() === "") {
|
||||||
|
$("#amount-textbox").val(recommendedBalance);
|
||||||
|
$("#substract-checkbox").prop("checked", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($("#fee-textbox").val() === "") {
|
||||||
|
$("#fee-textbox").val(recommendedFees);
|
||||||
|
}
|
||||||
|
|
||||||
var args = "";
|
var args = "";
|
||||||
args += "cryptoCode=" + $("#cryptoCurrencies").val();
|
args += "cryptoCode=" + $("#cryptoCurrencies").val();
|
||||||
args += "&destination=" + $("#destination-textbox").val();
|
args += "&destination=" + $("#destination-textbox").val();
|
||||||
|
@ -95,6 +106,8 @@
|
||||||
else {
|
else {
|
||||||
Write('check', 'success', 'This store is configured to use your ledger');
|
Write('check', 'success', 'This store is configured to use your ledger');
|
||||||
$(".crypto-info").css("display", "block");
|
$(".crypto-info").css("display", "block");
|
||||||
|
recommendedFees = result.recommendedSatoshiPerByte;
|
||||||
|
recommendedBalance = result.balance;
|
||||||
$("#crypto-fee").text(result.recommendedSatoshiPerByte);
|
$("#crypto-fee").text(result.recommendedSatoshiPerByte);
|
||||||
$("#crypto-balance").text(result.balance);
|
$("#crypto-balance").text(result.balance);
|
||||||
$("#crypto-code").text(cryptoCode);
|
$("#crypto-code").text(cryptoCode);
|
||||||
|
|
Loading…
Add table
Reference in a new issue