From c5a0e28420928b86c0513da899479f3fb5771cc0 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Wed, 17 Jan 2024 10:08:39 +0100 Subject: [PATCH] Refactor Wallet import code (#5638) * Refactor Wallet import code The code for wallet import was incredibly messy as it evolved over time from various requests. This PR: * splits up each supported format into its own file * Supports taproot descriptors (through a hack until NBitcoin supports it internally) fixes #5518 * Reduces different paths for handling electrum/non-electrum xpubs * Allows plugins to add their own import support formats for onchain wallets. * Update NBitcoin to parse tr descriptors * Fix warnings * Use dedicated type OnChainWalletParsers --------- Co-authored-by: nicolas.dorier --- .../BTCPayServer.Client.csproj | 2 +- .../BTCPayServer.Rating.csproj | 2 +- .../AltcoinTests/AltcoinTests.cs | 2 +- BTCPayServer.Tests/BTCPayServer.Tests.csproj | 4 +- BTCPayServer.Tests/FastTests.cs | 85 +++-- BTCPayServer.Tests/SeleniumTester.cs | 2 +- BTCPayServer/BTCPayServer.csproj | 6 +- ...eldStoreOnChainPaymentMethodsController.cs | 35 +- .../Controllers/UIStoresController.Onchain.cs | 4 +- .../Controllers/UIStoresController.cs | 7 +- BTCPayServer/DerivationSchemeParser.cs | 66 ++-- BTCPayServer/DerivationSchemeSettings.cs | 298 +----------------- BTCPayServer/Extensions.cs | 68 +++- BTCPayServer/Hosting/BTCPayServerServices.cs | 30 +- BTCPayServer/Services/WalletFileParsers.cs | 47 +++ .../WalletFileParsing/BSMSWalletFileParser.cs | 76 +++++ .../ElectrumWalletFileParser.cs | 88 ++++++ .../WalletFileParsing/IWalletFileParser.cs | 7 + .../NBXDerivGenericWalletFileParser.cs | 21 ++ .../OutputDescriptorJsonWalletFileParser.cs | 36 +++ .../OutputDescriptorWalletFileParser.cs | 42 +++ .../SpecterWalletFileParser.cs | 41 +++ .../WasabiWalletFileParser.cs | 105 ++++++ 23 files changed, 664 insertions(+), 410 deletions(-) create mode 100644 BTCPayServer/Services/WalletFileParsers.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/BSMSWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/ElectrumWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/IWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/NBXDerivGenericWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/OutputDescriptorJsonWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/OutputDescriptorWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/SpecterWalletFileParser.cs create mode 100644 BTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.cs diff --git a/BTCPayServer.Client/BTCPayServer.Client.csproj b/BTCPayServer.Client/BTCPayServer.Client.csproj index a6aebcef0..e345913a9 100644 --- a/BTCPayServer.Client/BTCPayServer.Client.csproj +++ b/BTCPayServer.Client/BTCPayServer.Client.csproj @@ -31,7 +31,7 @@ - + diff --git a/BTCPayServer.Rating/BTCPayServer.Rating.csproj b/BTCPayServer.Rating/BTCPayServer.Rating.csproj index 666249f73..02599b911 100644 --- a/BTCPayServer.Rating/BTCPayServer.Rating.csproj +++ b/BTCPayServer.Rating/BTCPayServer.Rating.csproj @@ -6,7 +6,7 @@ - + diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs index 1c5605114..908214e06 100644 --- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs +++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs @@ -176,7 +176,7 @@ namespace BTCPayServer.Tests #pragma warning disable CS0618 // Type or member is obsolete .OfType().First(o => o.PaymentId.IsBTCOnChain); #pragma warning restore CS0618 // Type or member is obsolete - DerivationSchemeSettings.TryParseFromWalletFile(content, onchainBTC.Network, out var expected, out var error); + FastTests.GetParsers().TryParseWalletFile(content, onchainBTC.Network, out var expected, out var error); Assert.Equal(expected.ToJson(), onchainBTC.ToJson()); Assert.Null(error); diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index ed4b315e3..5951f6af1 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -25,8 +25,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index 19dbde8eb..8392b7f66 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -822,7 +822,8 @@ namespace BTCPayServer.Tests // xpub var xpub = "xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw"; - DerivationStrategyBase strategyBase = parser.Parse(xpub); + Assert.Throws(() => parser.Parse(xpub, false, false, true)); + DerivationStrategyBase strategyBase = parser.Parse(xpub, false, false, false); Assert.IsType(strategyBase); Assert.True(((DirectDerivationStrategy)strategyBase).Segwit); Assert.Equal("tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS", strategyBase.ToString()); @@ -882,6 +883,14 @@ namespace BTCPayServer.Tests Assert.Equal(TradeQuantity.Parse(qty2.ToString()), TradeQuantity.Parse(" 1.3 ")); } + + public static WalletFileParsers GetParsers() + { + var service = new ServiceCollection(); + BTCPayServerServices.AddOnchainWalletParsers(service); + return service.BuildServiceProvider().GetRequiredService(); + } + [Fact] public void ParseDerivationSchemeSettings() { @@ -890,10 +899,10 @@ namespace BTCPayServer.Tests var root = new Mnemonic( "usage fever hen zero slide mammal silent heavy donate budget pulse say brain thank sausage brand craft about save attract muffin advance illegal cabbage") .DeriveExtKey(); - + var parsers = GetParsers(); // xpub var tpub = "tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS"; - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(tpub, testnet, out var settings, out var error)); + Assert.True(parsers.TryParseWalletFile(tpub, testnet, out var settings, out var error)); Assert.Null(error); Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false }); Assert.Equal($"{tpub}-[legacy]", ((DirectDerivationStrategy)settings.AccountDerivation).ToString()); @@ -904,7 +913,7 @@ namespace BTCPayServer.Tests var fingerprint = "e5746fd9"; var account = "84'/1'/0'"; var str = $"[{fingerprint}/{account}]{vpub}"; - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(str, testnet, out settings, out error)); + Assert.True(parsers.TryParseWalletFile(str, testnet, out settings, out error)); Assert.Null(error); Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: true }); Assert.Equal(vpub, settings.AccountOriginal); @@ -913,7 +922,7 @@ namespace BTCPayServer.Tests Assert.Equal(account, settings.AccountKeySettings[0].AccountKeyPath.ToString()); // ColdCard - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.True(parsers.TryParseWalletFile( "{\"keystore\": {\"ckcc_xpub\": \"xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw\", \"xpub\": \"ypub6WWc2gWwHbdnAAyJDnR4SPL1phRh7REqrPBfZeizaQ1EmTshieRXJC3Z5YoU4wkcdKHEjQGkh6AYEzCQC1Kz3DNaWSwdc1pc8416hAjzqyD\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}", mainnet, out settings, out error)); Assert.Null(error); @@ -929,28 +938,28 @@ namespace BTCPayServer.Tests settings.AccountDerivation.GetDerivation().ScriptPubKey); // Should be legacy - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.True(parsers.TryParseWalletFile( "{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"tpubDDWYqT3P24znfsaGX7kZcQhNc5LAjnQiKQvUCHF2jS6dsgJBRtymopEU5uGpMaR5YChjuiExZG1X2aTbqXkp82KqH5qnqwWHp6EWis9ZvKr\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/44'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}", testnet, out settings, out error)); Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false }); Assert.Null(error); // Should be segwit p2sh - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.True(parsers.TryParseWalletFile( "{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"upub5DSddA9NoRUyJrQ4p86nsCiTSY7kLHrSxx3joEJXjHd4HPARhdXUATuk585FdWPVC2GdjsMePHb6BMDmf7c6KG4K4RPX6LVqBLtDcWpQJmh\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}", testnet, out settings, out error)); Assert.True(settings.AccountDerivation is P2SHDerivationStrategy { Inner: DirectDerivationStrategy { Segwit: true } }); Assert.Null(error); // Should be segwit - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.True(parsers.TryParseWalletFile( "{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"vpub5YjYxTemJ39tFRnuAhwduyxG2tKGjoEpmvqVQRPqdYrqa6YGoeSzBtHXaJUYB19zDbXs3JjbEcVWERjQBPf9bEfUUMZNMv1QnMyHV8JPqyf\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/84'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}", testnet, out settings, out error)); Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: true }); Assert.Null(error); // Specter - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.True(parsers.TryParseWalletFile( "{\"label\": \"Specter\", \"blockheight\": 123456, \"descriptor\": \"wpkh([8bafd160/49h/0h/0h]xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw/0/*)#9x4vkw48\"}", mainnet, out var specter, out error)); Assert.Equal(root.GetPublicKey().GetHDFingerPrint(), specter.AccountKeySettings[0].RootFingerprint); @@ -958,7 +967,7 @@ namespace BTCPayServer.Tests Assert.Equal("49'/0'/0'", specter.AccountKeySettings[0].AccountKeyPath.ToString()); Assert.Equal("Specter", specter.Label); Assert.Null(error); - + //BSMS BIP129, Nunchuk var bsms = @"BSMS 1.0 @@ -966,39 +975,49 @@ wsh(sortedmulti(1,[5c9e228d/48'/0'/0'/2']xpub6EgGHjcvovyN3nK921zAGPfuB41cJXkYRdt /0/*,/1/* bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku "; - - Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(bsms, + + Assert.True(parsers.TryParseWalletFile(bsms, mainnet, out var nunchuk, out error)); - - Assert.Equal(2, nunchuk.AccountKeySettings.Length); + + Assert.Equal(2, nunchuk.AccountKeySettings.Length); //check that the account key settings match those in bsms string Assert.Equal("5c9e228d", nunchuk.AccountKeySettings[0].RootFingerprint.ToString()); Assert.Equal("48'/0'/0'/2'", nunchuk.AccountKeySettings[0].AccountKeyPath.ToString()); -Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString()); + Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString()); Assert.Equal("48'/0'/0'/2'", nunchuk.AccountKeySettings[1].AccountKeyPath.ToString()); - var multsig = Assert.IsType < MultisigDerivationStrategy > + var multsig = Assert.IsType (Assert.IsType(nunchuk.AccountDerivation).Inner); - + Assert.True(multsig.LexicographicOrder); - Assert.Equal(1, multsig.RequiredSignatures); - - var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); - var line =nunchuk.AccountDerivation.GetLineFor(deposit).Derive(0); - - Assert.Equal(BitcoinAddress.Create("bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku", Network.Main).ScriptPubKey, - line.ScriptPubKey); - + Assert.Equal(1, multsig.RequiredSignatures); + + var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); + var line = nunchuk.AccountDerivation.GetLineFor(deposit).Derive(0); + + Assert.Equal(BitcoinAddress.Create("bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku", Network.Main).ScriptPubKey, + line.ScriptPubKey); + Assert.Equal("BSMS", nunchuk.Source); Assert.Null(error); - + // Failure case - Assert.False(DerivationSchemeSettings.TryParseFromWalletFile( + Assert.False(parsers.TryParseWalletFile( "{\"keystore\": {\"ckcc_xpub\": \"tpubFailure\", \"xpub\": \"tpubFailure\", \"label\": \"Failure\"}, \"wallet_type\": \"standard\"}", testnet, out settings, out error)); Assert.Null(settings); Assert.NotNull(error); + + + //passport + var passportText = + "{\"Source\": \"Passport\", \"Descriptor\": \"tr([5c9e228d/86'/0'/0']xpub6EgGHjcvovyN3nK921zAGPfuB41cJXkYRdt3tLGmiMyvbgHpss4X1eRZwShbEBb1znz2e2bCkCED87QZpin3sSYKbmCzQ9Sc7LaV98ngdeX/0/*)\", \"FirmwareVersion\": \"v1.0.0\"}"; + Assert.True(parsers.TryParseWalletFile(passportText, mainnet, out var passport, out error)); + Assert.Equal("Passport", passport.Source); + Assert.True(passport.AccountDerivation is TaprootDerivationStrategy); + Assert.Equal("5c9e228d", passport.AccountKeySettings[0].RootFingerprint.ToString()); + Assert.Equal("86'/0'/0'", passport.AccountKeySettings[0].AccountKeyPath.ToString()); } [Fact] @@ -1879,7 +1898,7 @@ Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString( // Passing electrum stuff // Passing a native segwit from mainnet to a testnet parser, means the testnet parser will try to convert it into segwit result = testnetParser.Parse( - "zpub6nL6PUGurpU3DfPDSZaRS6WshpbNc9ctCFFzrCn54cssnheM31SZJZUcFHKtjJJNhAueMbh6ptFMfy1aeiMQJr3RJ4DDt1hAPx7sMTKV48t"); + "zpub6nL6PUGurpU3DfPDSZaRS6WshpbNc9ctCFFzrCn54cssnheM31SZJZUcFHKtjJJNhAueMbh6ptFMfy1aeiMQJr3RJ4DDt1hAPx7sMTKV48t", false, false, false); Assert.Equal( "tpubD93CJNkmGjLXnsBqE2zGDqfEh1Q8iJ8wueordy3SeWt1RngbbuxXCsqASuVWFywmfoCwUE1rSfNJbaH4cBNcbp8WcyZgPiiRSTazLGL8U9w", result.ToString()); @@ -1903,7 +1922,7 @@ Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString( // if prefix not recognize, assume it is segwit result = testnetParser.Parse( - "xpub661MyMwAqRbcGeVGU5e5KBcau1HHEUGf9Wr7k4FyLa8yRPNQrrVa7Ndrgg8Afbe2UYXMSL6tJBFd2JewwWASsePPLjkcJFL1tTVEs3UQ23X"); + "xpub661MyMwAqRbcGeVGU5e5KBcau1HHEUGf9Wr7k4FyLa8yRPNQrrVa7Ndrgg8Afbe2UYXMSL6tJBFd2JewwWASsePPLjkcJFL1tTVEs3UQ23X", false, false, false); Assert.Equal( "tpubD6NzVbkrYhZ4YSg7vGdAX6wxE8NwDrmih9SR6cK7gUtsAg37w5LfFpJgviCxC6bGGT4G3uckqH5fiV9ZLN1gm5qgQLVuymzFUR5ed7U7ksu", result.ToString()); @@ -1912,13 +1931,13 @@ Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString( var tpub = "tpubD6NzVbkrYhZ4Wc65tjhmcKdWFauAo7bGLRTxvggygkNyp6SMGutJp7iociwsinU33jyNBp1J9j2hJH5yQsayfiS3LEU2ZqXodAcnaygra8o"; - result = testnetParser.Parse(tpub); + result = testnetParser.Parse(tpub, false, true); Assert.Equal(tpub, result.ToString()); var regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork("BTC")); var parsed = regtestParser.Parse( - "xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]"); + "xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]", false, false, false); Assert.Equal( "tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]", parsed.ToString()); @@ -1926,14 +1945,14 @@ Assert.Equal("2b0e251e", nunchuk.AccountKeySettings[1].RootFingerprint.ToString( // Let's make sure we can't generate segwit with dogecoin regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork("DOGE")); parsed = regtestParser.Parse( - "xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]"); + "xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]", false, false, false); Assert.Equal( "tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[legacy]", parsed.ToString()); regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork("DOGE")); parsed = regtestParser.Parse( - "tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]"); + "tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]", false, false, false); Assert.Equal( "tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[legacy]", parsed.ToString()); diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 5a07f8dad..0dfce128a 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -288,7 +288,7 @@ namespace BTCPayServer.Tests /// /// /// - public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "xpub661MyMwAqRbcGABgHMUXDzPzH1tU7eZaAaJQXhDXsSxsqyQzQeU6kznNfSuAyqAK9UaWSaZaMFdNiY5BCF4zBPAzSnwfUAwUhwttuAKwfRX-[legacy]") + public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]") { if (!Driver.PageSource.Contains($"Setup {cryptoCode} Wallet")) { diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 22d2fb36e..abf55dfa7 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -1,4 +1,4 @@ - + @@ -46,13 +46,13 @@ - + - + diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs index 955935ab6..9b1ef5393 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs @@ -122,10 +122,11 @@ namespace BTCPayServer.Controllers.Greenfield try { - var strategy = DerivationSchemeSettings.Parse(paymentMethod.DerivationScheme, network); + + var strategy = network.GetDerivationSchemeParser().Parse(paymentMethod.DerivationScheme, false, true); var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); - var line = strategy.AccountDerivation.GetLineFor(deposit); + var line = strategy.GetLineFor(deposit); var result = new OnChainPaymentMethodPreviewResultData(); for (var i = offset; i < amount; i++) { @@ -134,8 +135,9 @@ namespace BTCPayServer.Controllers.Greenfield new OnChainPaymentMethodPreviewResultData.OnChainPaymentMethodPreviewResultAddressItem() { KeyPath = deposit.GetKeyPath((uint)i).ToString(), - Address = address.ScriptPubKey.GetDestinationAddress(strategy.Network.NBitcoinNetwork) - .ToString() + Address = + network.NBXplorerNetwork.CreateAddress(strategy,deposit.GetKeyPath((uint)i), address.ScriptPubKey) + .ToString() }); } @@ -168,10 +170,10 @@ namespace BTCPayServer.Controllers.Greenfield if (!ModelState.IsValid) return this.CreateValidationError(ModelState); - DerivationSchemeSettings strategy; + DerivationStrategyBase strategy; try { - strategy = DerivationSchemeSettings.Parse(paymentMethodData.DerivationScheme, network); + strategy = network.GetDerivationSchemeParser().Parse(paymentMethodData.DerivationScheme, false, true); } catch { @@ -181,7 +183,7 @@ namespace BTCPayServer.Controllers.Greenfield } var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); - var line = strategy.AccountDerivation.GetLineFor(deposit); + var line = strategy.GetLineFor(deposit); var result = new OnChainPaymentMethodPreviewResultData(); for (var i = offset; i < amount; i++) { @@ -192,9 +194,9 @@ namespace BTCPayServer.Controllers.Greenfield OnChainPaymentMethodPreviewResultAddressItem() { KeyPath = deposit.GetKeyPath((uint)i).ToString(), - Address = strategy.Network.NBXplorerNetwork.CreateAddress(strategy.AccountDerivation, - line.KeyPathTemplate.GetKeyPath((uint)i), - derivation.ScriptPubKey).ToString() + Address = + network.NBXplorerNetwork.CreateAddress(strategy,deposit.GetKeyPath((uint)i), derivation.ScriptPubKey) + .ToString() }); } @@ -244,12 +246,13 @@ namespace BTCPayServer.Controllers.Greenfield { var store = Store; var storeBlob = store.GetStoreBlob(); - var strategy = DerivationSchemeSettings.Parse(request.DerivationScheme, network); + var strategy = network.GetDerivationSchemeParser().Parse(request.DerivationScheme, false, true); if (strategy != null) - await wallet.TrackAsync(strategy.AccountDerivation); - strategy.Label = request.Label; - var signing = strategy.GetSigningAccountKeySettings(); - if (request.AccountKeyPath is RootedKeyPath r) + await wallet.TrackAsync(strategy); + + var dss = new DerivationSchemeSettings(strategy, network) {Label = request.Label,}; + var signing = dss.GetSigningAccountKeySettings(); + if (request.AccountKeyPath is { } r) { signing.AccountKeyPath = r.KeyPath; signing.RootFingerprint = r.MasterFingerprint; @@ -260,7 +263,7 @@ namespace BTCPayServer.Controllers.Greenfield signing.RootFingerprint = null; } - store.SetSupportedPaymentMethod(id, strategy); + store.SetSupportedPaymentMethod(id, dss); storeBlob.SetExcluded(id, !request.Enabled); store.SetStoreBlob(storeBlob); await _storeRepository.UpdateStore(store); diff --git a/BTCPayServer/Controllers/UIStoresController.Onchain.cs b/BTCPayServer/Controllers/UIStoresController.Onchain.cs index 85b94650d..171326a93 100644 --- a/BTCPayServer/Controllers/UIStoresController.Onchain.cs +++ b/BTCPayServer/Controllers/UIStoresController.Onchain.cs @@ -90,7 +90,7 @@ namespace BTCPayServer.Controllers if (vm.WalletFile != null) { - if (!DerivationSchemeSettings.TryParseFromWalletFile(await ReadAllText(vm.WalletFile), network, out strategy, out var error)) + if (!_onChainWalletParsers.TryParseWalletFile(await ReadAllText(vm.WalletFile), network, out strategy, out var error)) { ModelState.AddModelError(nameof(vm.WalletFile), $"Importing wallet failed: {error}"); return View(vm.ViewName, vm); @@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers } else if (!string.IsNullOrEmpty(vm.WalletFileContent)) { - if (!DerivationSchemeSettings.TryParseFromWalletFile(vm.WalletFileContent, network, out strategy, out var error)) + if (!_onChainWalletParsers.TryParseWalletFile(vm.WalletFileContent, network, out strategy, out var error)) { ModelState.AddModelError(nameof(vm.WalletFileContent), $"QR import failed: {error}"); return View(vm.ViewName, vm); diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index 2b99fecfe..dbc3fbaab 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -71,7 +71,8 @@ namespace BTCPayServer.Controllers IOptions externalServiceOptions, IHtmlHelper html, LightningClientFactoryService lightningClientFactoryService, - EmailSenderFactory emailSenderFactory) + EmailSenderFactory emailSenderFactory, + WalletFileParsers onChainWalletParsers) { _RateFactory = rateFactory; _Repo = repo; @@ -97,6 +98,7 @@ namespace BTCPayServer.Controllers _externalServiceOptions = externalServiceOptions; _lightningClientFactoryService = lightningClientFactoryService; _emailSenderFactory = emailSenderFactory; + _onChainWalletParsers = onChainWalletParsers; Html = html; } @@ -121,6 +123,7 @@ namespace BTCPayServer.Controllers private readonly IOptions _externalServiceOptions; private readonly LightningClientFactoryService _lightningClientFactoryService; private readonly EmailSenderFactory _emailSenderFactory; + private readonly WalletFileParsers _onChainWalletParsers; public string? GeneratedPairingCode { get; set; } public WebhookSender WebhookNotificationManager { get; } @@ -916,7 +919,7 @@ namespace BTCPayServer.Controllers return derivationSchemeSettings; } - var strategy = parser.Parse(derivationScheme); + var strategy = parser.Parse(derivationScheme, false, true); return new DerivationSchemeSettings(strategy, network); } diff --git a/BTCPayServer/DerivationSchemeParser.cs b/BTCPayServer/DerivationSchemeParser.cs index 5d7956ab7..7e2ac2a75 100644 --- a/BTCPayServer/DerivationSchemeParser.cs +++ b/BTCPayServer/DerivationSchemeParser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using NBitcoin; using NBitcoin.Scripting; @@ -33,7 +34,7 @@ namespace BTCPayServer { throw new FormatException("Custom change paths are not supported."); } - return (Parse($"{hd.Extkey}{suffix}"), null); + return (Parse($"{hd.Extkey}{suffix}", true, false, false), null); case PubKeyProvider.Origin origin: var innerResult = ExtractFromPkProvider(origin.Inner, suffix); return (innerResult.Item1, new[] { origin.KeyOriginInfo }); @@ -47,12 +48,14 @@ namespace BTCPayServer var xpubs = multi.PkProviders.Select(provider => ExtractFromPkProvider(provider)); return ( Parse( - $"{multi.Threshold}-of-{(string.Join('-', xpubs.Select(tuple => tuple.Item1.ToString())))}{(multi.IsSorted ? "" : "-[keeporder]")}"), + $"{multi.Threshold}-of-{(string.Join('-', xpubs.Select(tuple => tuple.Item1.ToString())))}{(multi.IsSorted ? "" : "-[keeporder]")}", true ,false, false), xpubs.SelectMany(tuple => tuple.Item2).ToArray()); } ArgumentNullException.ThrowIfNull(str); str = str.Trim(); + //nbitcoin output descriptor does not support taproot, so let's check if it is a taproot descriptor and fake until it is supported + var outputDescriptor = OutputDescriptor.Parse(str, Network); switch (outputDescriptor) { @@ -78,11 +81,13 @@ namespace BTCPayServer sh.Inner is OutputDescriptor.WSH) { var ds = ParseOutputDescriptor(sh.Inner.ToString()); - return (Parse(ds.Item1 + suffix), ds.Item2); + return (Parse(ds.Item1 + suffix, true, false, false), ds.Item2); }; throw new FormatException("sh descriptors are only supported with multsig(legacy or p2wsh) and segwit(p2wpkh)"); + case OutputDescriptor.Tr tr: + return ExtractFromPkProvider(tr.InnerPubkey, "-[taproot]"); case OutputDescriptor.WPKH wpkh: - return ExtractFromPkProvider(wpkh.PkProvider, ""); + return ExtractFromPkProvider(wpkh.PkProvider); case OutputDescriptor.WSH { Inner: OutputDescriptor.Multi multi }: return ExtractFromMulti(multi); case OutputDescriptor.WSH: @@ -91,36 +96,7 @@ namespace BTCPayServer throw new ArgumentOutOfRangeException(nameof(outputDescriptor)); } } - - public DerivationStrategyBase ParseElectrum(string str) - { - - ArgumentNullException.ThrowIfNull(str); - str = str.Trim(); - var data = Network.GetBase58CheckEncoder().DecodeData(str); - if (data.Length < 4) - throw new FormatException(); - var prefix = Utils.ToUInt32(data, false); - - var standardPrefix = Utils.ToBytes(0x0488b21eU, false); - for (int ii = 0; ii < 4; ii++) - data[ii] = standardPrefix[ii]; - var extPubKey = GetBitcoinExtPubKeyByNetwork(Network, data); - if (!BtcPayNetwork.ElectrumMapping.TryGetValue(prefix, out var type)) - { - throw new FormatException(); - } - if (type == DerivationType.Segwit) - return new DirectDerivationStrategy(extPubKey, true); - if (type == DerivationType.Legacy) - return new DirectDerivationStrategy(extPubKey, false); - if (type == DerivationType.SegwitP2SH) - return BtcPayNetwork.NBXplorerNetwork.DerivationStrategyFactory.Parse(extPubKey.ToString() + "-[p2sh]"); - throw new FormatException(); - } - - - public DerivationStrategyBase Parse(string str) + public DerivationStrategyBase Parse(string str, bool ignorePrefix = false, bool ignoreBasePrefix = false, bool enforceNetworkPrefix = true) { ArgumentNullException.ThrowIfNull(str); str = str.Trim(); @@ -133,14 +109,6 @@ namespace BTCPayServer str = str.Replace("-[p2sh]", string.Empty, StringComparison.OrdinalIgnoreCase); } - try - { - return BtcPayNetwork.NBXplorerNetwork.DerivationStrategyFactory.Parse(str); - } - catch - { - } - var parts = str.Split('-'); bool hasLabel = false; for (int i = 0; i < parts.Length; i++) @@ -157,6 +125,7 @@ namespace BTCPayServer hintedLabels.Add(parts[i].Substring(1, parts[i].Length - 2).ToLowerInvariant()); continue; } + try { var data = Network.GetBase58CheckEncoder().DecodeData(parts[i]); @@ -170,11 +139,15 @@ namespace BTCPayServer var derivationScheme = GetBitcoinExtPubKeyByNetwork(Network, data).ToString(); - if (BtcPayNetwork.ElectrumMapping.TryGetValue(prefix, out var type)) + if (enforceNetworkPrefix && !BtcPayNetwork.ElectrumMapping.ContainsKey(prefix)) + throw new FormatException( + $"Invalid xpub. Is this really for {BtcPayNetwork.CryptoCode} {Network.ChainName}?"); + + if (!ignorePrefix && !hasLabel && BtcPayNetwork.ElectrumMapping.TryGetValue(prefix, out var type)) { switch (type) { - case DerivationType.Legacy: + case DerivationType.Legacy when !ignoreBasePrefix: hintedLabels.Add("legacy"); break; case DerivationType.SegwitP2SH: @@ -182,8 +155,13 @@ namespace BTCPayServer break; } } + parts[i] = derivationScheme; } + catch (FormatException e) when (e.Message.StartsWith("Invalid xpub")) + { + throw; + } catch { continue; } } diff --git a/BTCPayServer/DerivationSchemeSettings.cs b/BTCPayServer/DerivationSchemeSettings.cs index 02eb7b570..8fc1e8b36 100644 --- a/BTCPayServer/DerivationSchemeSettings.cs +++ b/BTCPayServer/DerivationSchemeSettings.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text; @@ -22,8 +23,8 @@ namespace BTCPayServer ArgumentNullException.ThrowIfNull(derivationStrategy); var result = new DerivationSchemeSettings(); result.Network = network; - var parser = new DerivationSchemeParser(network); - if (TryParseXpub(derivationStrategy, parser, ref result, ref error, false) || TryParseXpub(derivationStrategy, parser, ref result, ref error, true)) + var parser = network.GetDerivationSchemeParser(); + if (parser.TryParseXpub(derivationStrategy, ref result, out error)) { return result; } @@ -50,299 +51,6 @@ namespace BTCPayServer return AccountDerivation is null ? null : DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, AccountDerivation.ToString()); } - private static bool TryParseXpub(string xpub, DerivationSchemeParser derivationSchemeParser, ref DerivationSchemeSettings derivationSchemeSettings, ref string error, bool electrum = true) - { - if (!electrum) - { - var isOD = Regex.Match(xpub, @"\(.*?\)").Success; - try - { - var result = derivationSchemeParser.ParseOutputDescriptor(xpub); - derivationSchemeSettings.AccountOriginal = xpub.Trim(); - derivationSchemeSettings.AccountDerivation = result.Item1; - derivationSchemeSettings.AccountKeySettings = result.Item2.Select((path, i) => new AccountKeySettings() - { - RootFingerprint = path?.MasterFingerprint, - AccountKeyPath = path?.KeyPath, - AccountKey = result.Item1.GetExtPubKeys().ElementAt(i).GetWif(derivationSchemeParser.Network) - }).ToArray(); - return true; - } - catch (Exception exception) - { - error = exception.Message; - if (isOD) - { - return false; - } // otherwise continue and try to parse input as xpub - } - } - try - { - // Extract fingerprint and account key path from export formats that contain them. - // Possible formats: [fingerprint/account_key_path]xpub, [fingerprint]xpub, xpub - HDFingerprint? rootFingerprint = null; - KeyPath accountKeyPath = null; - var derivationRegex = new Regex(@"^(?:\[(\w+)(?:\/(.*?))?\])?(\w+)$", RegexOptions.IgnoreCase); - var match = derivationRegex.Match(xpub.Trim()); - if (match.Success) - { - if (!string.IsNullOrEmpty(match.Groups[1].Value)) - rootFingerprint = HDFingerprint.Parse(match.Groups[1].Value); - if (!string.IsNullOrEmpty(match.Groups[2].Value)) - accountKeyPath = KeyPath.Parse(match.Groups[2].Value); - if (!string.IsNullOrEmpty(match.Groups[3].Value)) - xpub = match.Groups[3].Value; - } - derivationSchemeSettings.AccountOriginal = xpub.Trim(); - derivationSchemeSettings.AccountDerivation = electrum ? derivationSchemeParser.ParseElectrum(derivationSchemeSettings.AccountOriginal) : derivationSchemeParser.Parse(derivationSchemeSettings.AccountOriginal); - derivationSchemeSettings.AccountKeySettings = derivationSchemeSettings.AccountDerivation.GetExtPubKeys() - .Select(key => new AccountKeySettings - { - AccountKey = key.GetWif(derivationSchemeParser.Network) - }).ToArray(); - if (derivationSchemeSettings.AccountDerivation is DirectDerivationStrategy direct && !direct.Segwit) - derivationSchemeSettings.AccountOriginal = null; // Saving this would be confusing for user, as xpub of electrum is legacy derivation, but for btcpay, it is segwit derivation - // apply initial matches if there were no results from parsing - if (rootFingerprint != null && derivationSchemeSettings.AccountKeySettings[0].RootFingerprint == null) - { - derivationSchemeSettings.AccountKeySettings[0].RootFingerprint = rootFingerprint; - } - if (accountKeyPath != null && derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath == null) - { - derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath = accountKeyPath; - } - return true; - } - catch (Exception exception) - { - error = exception.Message; - return false; - } - } - - public static bool TryParseBSMSFile(string filecontent, DerivationSchemeParser derivationSchemeParser, ref DerivationSchemeSettings derivationSchemeSettings, - out string error) - { - error = null; - try - { - string[] lines = filecontent.Split( - new[] {"\r\n", "\r", "\n"}, - StringSplitOptions.None - ); - - if (!lines[0].Trim().Equals("BSMS 1.0")) - {; - return false; - } - - var descriptor = lines[1]; - var derivationPath = lines[2].Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?? "/0/*"; - if (derivationPath == "No path restrictions") - { - derivationPath = "/0/*"; - } - if(derivationPath != "/0/*") - { - error = "BTCPay Server can only derive address to the deposit and change paths"; - return false; - } - - - descriptor = descriptor.Replace("/**", derivationPath); - var testAddress = BitcoinAddress.Create( lines[3], derivationSchemeParser.Network); - var result = derivationSchemeParser.ParseOutputDescriptor(descriptor); - - var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); - var line = result.Item1.GetLineFor(deposit).Derive(0); - - if (testAddress.ScriptPubKey != line.ScriptPubKey) - { - error = "BSMS test address did not match our generated address"; - return false; - } - - derivationSchemeSettings.Source = "BSMS"; - derivationSchemeSettings.AccountDerivation = result.Item1; - derivationSchemeSettings.AccountOriginal = descriptor.Trim(); - derivationSchemeSettings.AccountKeySettings = result.Item2.Select((path, i) => new AccountKeySettings() - { - RootFingerprint = path?.MasterFingerprint, - AccountKeyPath = path?.KeyPath, - AccountKey = result.Item1.GetExtPubKeys().ElementAt(i).GetWif(derivationSchemeParser.Network) - }).ToArray(); - return true; - } - catch (Exception e) - { - error = $"BSMS parse error: {e.Message}"; - return false; - } - } - public static bool TryParseFromWalletFile(string fileContents, BTCPayNetwork network, out DerivationSchemeSettings settings, out string error) - { - settings = null; - error = null; - ArgumentNullException.ThrowIfNull(fileContents); - ArgumentNullException.ThrowIfNull(network); - var result = new DerivationSchemeSettings(); - var derivationSchemeParser = new DerivationSchemeParser(network); - JObject jobj; - try - { - if (HexEncoder.IsWellFormed(fileContents)) - { - fileContents = Encoding.UTF8.GetString(Encoders.Hex.DecodeData(fileContents)); - } - jobj = JObject.Parse(fileContents); - } - catch - { - if (TryParseBSMSFile(fileContents, derivationSchemeParser,ref result, out var bsmsError)) - { - settings = result; - settings.Network = network; - return true; - } - if (bsmsError is not null) - { - error = bsmsError; - return false; - } - result.Source = "GenericFile"; - if (TryParseXpub(fileContents, derivationSchemeParser, ref result, ref error) || - TryParseXpub(fileContents, derivationSchemeParser, ref result, ref error, false)) - { - settings = result; - settings.Network = network; - return true; - } - - return false; - } - - // Electrum - if (jobj.ContainsKey("keystore")) - { - result.Source = "ElectrumFile"; - jobj = (JObject)jobj["keystore"]; - - if (!jobj.ContainsKey("xpub") || - !TryParseXpub(jobj["xpub"].Value(), derivationSchemeParser, ref result, ref error)) - { - return false; - } - - if (jobj.ContainsKey("label")) - { - try - { - result.Label = jobj["label"].Value(); - } - catch { return false; } - } - - if (jobj.ContainsKey("ckcc_xfp")) - { - try - { - result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(jobj["ckcc_xfp"].Value()); - } - catch { return false; } - } - - if (jobj.ContainsKey("derivation")) - { - try - { - result.AccountKeySettings[0].AccountKeyPath = new KeyPath(jobj["derivation"].Value()); - } - catch { return false; } - } - } - // Specter - else if (jobj.ContainsKey("descriptor") && jobj.ContainsKey("blockheight")) - { - result.Source = "SpecterFile"; - - if (!TryParseXpub(jobj["descriptor"].Value(), derivationSchemeParser, ref result, ref error, false)) - { - return false; - } - - if (jobj.ContainsKey("label")) - { - try - { - result.Label = jobj["label"].Value(); - } - catch { return false; } - } - } - // Wasabi - else - { - result.Source = "WasabiFile"; - if (!jobj.ContainsKey("ExtPubKey") || - !TryParseXpub(jobj["ExtPubKey"].Value(), derivationSchemeParser, ref result, ref error, false)) - { - return false; - } - if (jobj.ContainsKey("MasterFingerprint")) - { - try - { - var mfpString = jobj["MasterFingerprint"].ToString().Trim(); - // https://github.com/zkSNACKs/WalletWasabi/pull/1663#issuecomment-508073066 - - if (uint.TryParse(mfpString, out var fingerprint)) - { - result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(fingerprint); - } - else - { - var shouldReverseMfp = jobj.ContainsKey("ColdCardFirmwareVersion") && - jobj["ColdCardFirmwareVersion"].ToString() == "2.1.0"; - var bytes = Encoders.Hex.DecodeData(mfpString); - result.AccountKeySettings[0].RootFingerprint = shouldReverseMfp ? new HDFingerprint(bytes.Reverse().ToArray()) : new HDFingerprint(bytes); - } - } - - catch { return false; } - } - if (jobj.ContainsKey("AccountKeyPath")) - { - try - { - result.AccountKeySettings[0].AccountKeyPath = new KeyPath(jobj["AccountKeyPath"].Value()); - } - catch { return false; } - } - if (jobj.ContainsKey("DerivationPath")) - { - try - { - result.AccountKeySettings[0].AccountKeyPath = new KeyPath(jobj["DerivationPath"].Value().ToLowerInvariant()); - } - catch { return false; } - } - - if (jobj.ContainsKey("ColdCardFirmwareVersion")) - { - result.Source = "ColdCard"; - } - - if (jobj.ContainsKey("CoboVaultFirmwareVersion")) - { - result.Source = "CoboVault"; - } - } - settings = result; - settings.Network = network; - return true; - } - public DerivationSchemeSettings() { diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index de00c308c..51a94e404 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -9,6 +10,7 @@ using System.Net.WebSockets; using System.Reflection; using System.Security.Claims; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BTCPayServer.BIP78.Sender; @@ -16,7 +18,6 @@ using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Lightning; -using BTCPayServer.Logging; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.NTag424; @@ -30,10 +31,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using NBitcoin; +using NBitcoin.DataEncoders; using NBitcoin.Payment; -using NBitpayClient; +using NBitcoin.Scripting; +using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; using InvoiceCryptoInfo = BTCPayServer.Services.Invoices.InvoiceCryptoInfo; @@ -42,6 +44,66 @@ namespace BTCPayServer { public static class Extensions { + + private static readonly ConcurrentDictionary _derivationSchemeParsers = + new(); + + public static DerivationSchemeParser GetDerivationSchemeParser(this BTCPayNetwork network) + { + return _derivationSchemeParsers.GetOrAdd(network, n => new DerivationSchemeParser(n)); + } + + public static bool TryParseXpub(this DerivationSchemeParser derivationSchemeParser, string xpub, + ref DerivationSchemeSettings derivationSchemeSettings, out string error) + { + try + { + // Extract fingerprint and account key path from export formats that contain them. + // Possible formats: [fingerprint/account_key_path]xpub, [fingerprint]xpub, xpub + HDFingerprint? rootFingerprint = null; + KeyPath accountKeyPath = null; + var derivationRegex = new Regex(@"^(?:\[(\w+)(?:\/(.*?))?\])?(\w+)$", RegexOptions.IgnoreCase); + var match = derivationRegex.Match(xpub.Trim()); + if (match.Success) + { + if (!string.IsNullOrEmpty(match.Groups[1].Value)) + rootFingerprint = HDFingerprint.Parse(match.Groups[1].Value); + if (!string.IsNullOrEmpty(match.Groups[2].Value)) + accountKeyPath = KeyPath.Parse(match.Groups[2].Value); + if (!string.IsNullOrEmpty(match.Groups[3].Value)) + xpub = match.Groups[3].Value; + } + + derivationSchemeSettings.AccountOriginal = xpub.Trim(); + derivationSchemeSettings.AccountDerivation = + derivationSchemeParser.Parse(derivationSchemeSettings.AccountOriginal, false, false, false); + derivationSchemeSettings.AccountKeySettings = derivationSchemeSettings.AccountDerivation.GetExtPubKeys() + .Select(key => new AccountKeySettings {AccountKey = key.GetWif(derivationSchemeParser.Network)}) + .ToArray(); + if (derivationSchemeSettings.AccountDerivation is DirectDerivationStrategy direct && !direct.Segwit) + derivationSchemeSettings.AccountOriginal = + null; // Saving this would be confusing for user, as xpub of electrum is legacy derivation, but for btcpay, it is segwit derivation + // apply initial matches if there were no results from parsing + if (rootFingerprint != null && derivationSchemeSettings.AccountKeySettings[0].RootFingerprint == null) + { + derivationSchemeSettings.AccountKeySettings[0].RootFingerprint = rootFingerprint; + } + + if (accountKeyPath != null && derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath == null) + { + derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath = accountKeyPath; + } + + error = null; + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + public static CardKey CreatePullPaymentCardKey(this IssuerKey issuerKey, byte[] uid, int version, string pullPaymentId) { var data = Encoding.UTF8.GetBytes(pullPaymentId); diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index eda5472e5..f68f6d030 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -73,6 +73,8 @@ using Newtonsoft.Json; using NicolasDorier.RateLimits; using Serilog; using BTCPayServer.Services.Reporting; +using BTCPayServer.Services.WalletFileParsing; + #if ALTCOINS using BTCPayServer.Services.Altcoins.Monero; using BTCPayServer.Services.Altcoins.Zcash; @@ -129,7 +131,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.TryAddSingleton(); - + services.AddSingleton>(client => new ChargeLightningConnectionStringHandler(client)); services.AddSingleton>(_ => @@ -145,8 +147,8 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.AddHttpClient(LightningClientFactoryService.OnionNamedClient) .ConfigurePrimaryHttpMessageHandler(); - - + + services.TryAddSingleton(); services.TryAddSingleton(o => o.GetRequiredService>().Value); @@ -159,6 +161,9 @@ namespace BTCPayServer.Hosting AddSettingsAccessor(services); AddSettingsAccessor(services); // + + AddOnchainWalletParsers(services); + services.AddStartupTask(); services.TryAddSingleton(); services.AddSingleton(); @@ -246,7 +251,7 @@ namespace BTCPayServer.Hosting { error = e.Message; } - + if (error is not null) { logs.Configuration.LogWarning($"Invalid setting {net.CryptoCode}.lightning, " + @@ -363,7 +368,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.AddSingleton(provider => provider.GetService()); services.TryAddSingleton(CurrencyNameTable.Instance); - services.TryAddSingleton(); + services.TryAddSingleton(); services.Configure((o) => { @@ -412,7 +417,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddScoped(); - + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -513,6 +518,19 @@ namespace BTCPayServer.Hosting return services; } + public static void AddOnchainWalletParsers(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + internal static void RegisterRateSources(IServiceCollection services) { // We need to be careful to only add exchanges which OnGetTickers implementation make only 1 request diff --git a/BTCPayServer/Services/WalletFileParsers.cs b/BTCPayServer/Services/WalletFileParsers.cs new file mode 100644 index 000000000..a5b09dc1d --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsers.cs @@ -0,0 +1,47 @@ +#nullable enable +using System; +using System.Collections.Generic; +using NBitcoin.DataEncoders; +using System.Text; +using System.Diagnostics.CodeAnalysis; +using BTCPayServer.Services.WalletFileParsing; + +namespace BTCPayServer.Services; + +public class WalletFileParsers +{ + public WalletFileParsers(IEnumerable parsers) + { + Parsers = parsers; + } + public IEnumerable Parsers { get; } + + public bool TryParseWalletFile(string fileContents, BTCPayNetwork network, [MaybeNullWhen(false)] out DerivationSchemeSettings settings, [MaybeNullWhen(true)] out string error) + { + settings = null; + error = string.Empty; + ArgumentNullException.ThrowIfNull(fileContents); + ArgumentNullException.ThrowIfNull(network); + if (HexEncoder.IsWellFormed(fileContents)) + { + fileContents = Encoding.UTF8.GetString(Encoders.Hex.DecodeData(fileContents)); + } + + foreach (IWalletFileParser onChainWalletParser in Parsers) + { + var result = onChainWalletParser.TryParse(network, fileContents); + if (result.DerivationSchemeSettings is not null) + { + settings = result.DerivationSchemeSettings; + error = null; + return true; + } + + if (result.Error is not null) + { + error = result.Error; + } + } + return false; + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/BSMSWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/BSMSWalletFileParser.cs new file mode 100644 index 000000000..36803ce74 --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/BSMSWalletFileParser.cs @@ -0,0 +1,76 @@ +#nullable enable +using System; +using System.Linq; +using BTCPayServer; +using NBitcoin; +using NBXplorer.DerivationStrategy; +using AccountKeySettings = BTCPayServer.AccountKeySettings; +using BTCPayNetwork = BTCPayServer.BTCPayNetwork; + +namespace BTCPayServer.Services.WalletFileParsing; +public class BSMSWalletFileParser : IWalletFileParser +{ + public (BTCPayServer.DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse( + BTCPayNetwork network, + string data) + { + try + { + string[] lines = data.Split( + new[] {"\r\n", "\r", "\n"}, + StringSplitOptions.None + ); + + if (!lines[0].Trim().Equals("BSMS 1.0")) + { + return (null, null); + } + + var descriptor = lines[1]; + var derivationPath = lines[2].Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? + "/0/*"; + if (derivationPath == "No path restrictions") + { + derivationPath = "/0/*"; + } + + if (derivationPath != "/0/*") + { + return (null, "BTCPay Server can only derive address to the deposit and change paths"); + } + + + descriptor = descriptor.Replace("/**", derivationPath); + var testAddress = BitcoinAddress.Create(lines[3], network.NBitcoinNetwork); + + var result = network.GetDerivationSchemeParser().ParseOutputDescriptor(descriptor); + + var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit); + var line = result.Item1.GetLineFor(deposit).Derive(0); + + if (testAddress.ScriptPubKey != line.ScriptPubKey) + { + return (null, "BSMS test address did not match our generated address"); + } + + var derivationSchemeSettings = new BTCPayServer.DerivationSchemeSettings() + { + Network = network, + Source = "BSMS", + AccountDerivation = result.Item1, + AccountOriginal = descriptor.Trim(), + AccountKeySettings = result.Item2.Select((path, i) => new AccountKeySettings() + { + RootFingerprint = path?.MasterFingerprint, + AccountKeyPath = path?.KeyPath, + AccountKey = result.Item1.GetExtPubKeys().ElementAt(i).GetWif(network.NBitcoinNetwork) + }).ToArray() + }; + return (derivationSchemeSettings, null); + } + catch (Exception e) + { + return (null, $"BSMS parse error: {e.Message}"); + } + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/ElectrumWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/ElectrumWalletFileParser.cs new file mode 100644 index 000000000..65c53190b --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/ElectrumWalletFileParser.cs @@ -0,0 +1,88 @@ +#nullable enable +using System; +using BTCPayServer; +using NBitcoin; +using Newtonsoft.Json.Linq; +namespace BTCPayServer.Services.WalletFileParsing; +public class ElectrumWalletFileParser : IWalletFileParser +{ + public (BTCPayServer.DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var derivationSchemeParser = network.GetDerivationSchemeParser(); + var jobj = JObject.Parse(data); + var result = new BTCPayServer.DerivationSchemeSettings() {Network = network}; + + if (jobj["keystore"] is JObject keyStore) + { + result.Source = "ElectrumFile"; + jobj = keyStore; + + if (!jobj.TryGetValue("xpub", StringComparison.InvariantCultureIgnoreCase, out var xpubToken)) + { + return (null, "no xpub"); + } + var strategy = derivationSchemeParser.Parse(xpubToken.Value(), false, false, true); + result.AccountDerivation = strategy; + result.AccountOriginal = xpubToken.Value(); + result.GetSigningAccountKeySettings(); + + if (jobj["label"]?.Value() is string label) + { + try + { + result.Label = label; + } + catch + { + return (null, "Label was not a string"); + } + } + + if (jobj["ckcc_xfp"]?.Value() is uint xfp) + { + try + { + result.AccountKeySettings[0].RootFingerprint = + new HDFingerprint(xfp); + } + catch + { + return (null, "fingerprint was not a uint"); + } + } + + if (jobj["derivation"]?.Value() is string derivation) + { + try + { + result.AccountKeySettings[0].AccountKeyPath = new KeyPath(derivation); + } + catch + { + return (null, "derivation keypath was not valid"); + } + } + + + if (jobj.ContainsKey("ColdCardFirmwareVersion")) + { + result.Source = "ColdCard"; + } + else if (jobj.ContainsKey("CoboVaultFirmwareVersion")) + { + result.Source = "CoboVault"; + } + return (result, null); + } + + } + catch (FormatException) + { + return (null, "invalid xpub"); + } + return (null, null); + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/IWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/IWalletFileParser.cs new file mode 100644 index 000000000..1276ddead --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/IWalletFileParser.cs @@ -0,0 +1,7 @@ +#nullable enable +using BTCPayServer; +namespace BTCPayServer.Services.WalletFileParsing; +public interface IWalletFileParser +{ + (BTCPayServer.DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, string data); +} diff --git a/BTCPayServer/Services/WalletFileParsing/NBXDerivGenericWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/NBXDerivGenericWalletFileParser.cs new file mode 100644 index 000000000..723daef54 --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/NBXDerivGenericWalletFileParser.cs @@ -0,0 +1,21 @@ +#nullable enable +using System; +using BTCPayServer; +namespace BTCPayServer.Services.WalletFileParsing; +public class NBXDerivGenericWalletFileParser : IWalletFileParser +{ + public (BTCPayServer.DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var result = BTCPayServer.DerivationSchemeSettings.Parse(data, network); + result.Source = "Generic"; + return (result, null); + } + catch (Exception) + { + return (null, null); + } + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/OutputDescriptorJsonWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/OutputDescriptorJsonWalletFileParser.cs new file mode 100644 index 000000000..01471c456 --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/OutputDescriptorJsonWalletFileParser.cs @@ -0,0 +1,36 @@ +#nullable enable +using System; +using BTCPayServer; +using Newtonsoft.Json.Linq; +namespace BTCPayServer.Services.WalletFileParsing; +public class OutputDescriptorJsonWalletFileParser : IWalletFileParser +{ + private readonly OutputDescriptorWalletFileParser _outputDescriptorOnChainWalletParser; + + public OutputDescriptorJsonWalletFileParser(OutputDescriptorWalletFileParser outputDescriptorOnChainWalletParser) + { + _outputDescriptorOnChainWalletParser = outputDescriptorOnChainWalletParser; + } + public (DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var jobj = JObject.Parse(data); + if (!jobj.TryGetValue("Descriptor", StringComparison.InvariantCultureIgnoreCase, out var descriptorToken) || + descriptorToken?.Value() is not string desc) + return (null, null); + + + var result = _outputDescriptorOnChainWalletParser.TryParse(network, desc); + if (result.DerivationSchemeSettings is not null && jobj.TryGetValue("Source", StringComparison.InvariantCultureIgnoreCase, out var sourceToken)) + result.DerivationSchemeSettings.Source = sourceToken.Value(); + return result; + + } + catch (Exception) + { + return (null, null); + } + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/OutputDescriptorWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/OutputDescriptorWalletFileParser.cs new file mode 100644 index 000000000..90c7f079d --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/OutputDescriptorWalletFileParser.cs @@ -0,0 +1,42 @@ +#nullable enable +using System; +using System.Linq; +using BTCPayServer; +namespace BTCPayServer.Services.WalletFileParsing; +public class OutputDescriptorWalletFileParser : IWalletFileParser +{ + public (BTCPayServer.DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var maybeOutputDesc = !data.Trim().StartsWith("{", StringComparison.OrdinalIgnoreCase); + if (!maybeOutputDesc) + return (null, null); + + var derivationSchemeParser = network.GetDerivationSchemeParser(); + + var descriptor = derivationSchemeParser.ParseOutputDescriptor(data); + + var derivationSchemeSettings = new DerivationSchemeSettings() + { + Network = network, + Source = "OutputDescriptor", + AccountOriginal = data.Trim(), + AccountDerivation = descriptor.Item1, + AccountKeySettings = descriptor.Item2.Select((path, i) => new AccountKeySettings() + { + RootFingerprint = path?.MasterFingerprint, + AccountKeyPath = path?.KeyPath, + AccountKey = + descriptor.Item1.GetExtPubKeys().ElementAt(i).GetWif(derivationSchemeParser.Network) + }).ToArray() + }; + return (derivationSchemeSettings, null); + } + catch (Exception exception) + { + return (null, exception.Message); + } + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/SpecterWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/SpecterWalletFileParser.cs new file mode 100644 index 000000000..3c232974c --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/SpecterWalletFileParser.cs @@ -0,0 +1,41 @@ +#nullable enable +using System; +using BTCPayServer; +using Newtonsoft.Json.Linq; +namespace BTCPayServer.Services.WalletFileParsing; +public class SpecterWalletFileParser : IWalletFileParser +{ + private readonly OutputDescriptorWalletFileParser _outputDescriptorOnChainWalletParser; + + public SpecterWalletFileParser(OutputDescriptorWalletFileParser outputDescriptorOnChainWalletParser) + { + _outputDescriptorOnChainWalletParser = outputDescriptorOnChainWalletParser; + } + public (DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var jobj = JObject.Parse(data); + if (!jobj.TryGetValue("descriptor", StringComparison.InvariantCultureIgnoreCase, out var descriptorObj) + || !jobj.ContainsKey("blockheight") + || descriptorObj?.Value() is not string desc) + return (null, null); + + + var result = _outputDescriptorOnChainWalletParser.TryParse(network, desc); + if (result.DerivationSchemeSettings is not null) + result.DerivationSchemeSettings.Source = "Specter"; + + if (result.DerivationSchemeSettings is not null && jobj.TryGetValue("label", + StringComparison.InvariantCultureIgnoreCase, out var label) && label?.Value() is string labelValue) + result.DerivationSchemeSettings.Label = labelValue; + return result; + + } + catch (Exception) + { + return (null, null); + } + } +} diff --git a/BTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.cs b/BTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.cs new file mode 100644 index 000000000..246cb1655 --- /dev/null +++ b/BTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.cs @@ -0,0 +1,105 @@ +#nullable enable +using System; +using System.Linq; +using BTCPayServer; +using NBitcoin; +using NBitcoin.DataEncoders; +using Newtonsoft.Json.Linq; +namespace BTCPayServer.Services.WalletFileParsing; +public class WasabiWalletFileParser : IWalletFileParser +{ + + public (DerivationSchemeSettings? DerivationSchemeSettings, string? Error) TryParse(BTCPayNetwork network, + string data) + { + try + { + var jobj = JObject.Parse(data); + if (jobj["ExtPubKey"]?.Value() is not string extPubKey) + return (null, null); + + var derivationSchemeParser = network.GetDerivationSchemeParser(); + var result = new DerivationSchemeSettings() + { + Network = network + }; + + if (!derivationSchemeParser.TryParseXpub(extPubKey, ref result, out var error)) + { + return (null, error); + } + + if (jobj["MasterFingerprint"]?.ToString()?.Trim() is string mfpString) + { + try + { + // https://github.com/zkSNACKs/WalletWasabi/pull/1663#issuecomment-508073066 + if (uint.TryParse(mfpString, out var fingerprint)) + { + result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(fingerprint); + } + else + { + var bytes = Encoders.Hex.DecodeData(mfpString); + var shouldReverseMfp = jobj["ColdCardFirmwareVersion"]?.Value() == "2.1.0"; + if (shouldReverseMfp) // Bug in previous version of coldcard + bytes = bytes.Reverse().ToArray(); + result.AccountKeySettings[0].RootFingerprint = new HDFingerprint(bytes); + } + } + + catch + { + return (null, "MasterFingerprint was not valid"); + } + } + + if (jobj["AccountKeyPath"]?.Value() is string accountKeyPath) + { + try + { + result.AccountKeySettings[0].AccountKeyPath = new KeyPath(accountKeyPath); + } + catch + { + return (null, "AccountKeyPath was not valid"); + } + } + + if (jobj["DerivationPath"]?.Value()?.ToLowerInvariant() is string derivationPath) + { + try + { + result.AccountKeySettings[0].AccountKeyPath = new KeyPath(derivationPath); + } + catch + { + return (null, "Derivation path was not valid"); + } + } + + if (jobj.ContainsKey("ColdCardFirmwareVersion")) + { + result.Source = "ColdCard"; + } + else if (jobj.ContainsKey("CoboVaultFirmwareVersion")) + { + result.Source = "CoboVault"; + } + else if (jobj.TryGetValue("Source", StringComparison.InvariantCultureIgnoreCase, out var source)) + { + result.Source = source.Value(); + } + else + result.Source = "WasabiFile"; + + + return (result, null); + } + catch (Exception) + { + return (null, null); + } + + } +}