diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 6951ce720..d80d851b8 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -883,6 +883,14 @@ namespace BTCPayServer.Tests } } + [Fact] + [Trait("Fast", "Fast")] + public void CanParseColdcard() + { + var mnemonic = 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"); + var coldcardWallet = "{\"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}"; + } + [Fact] [Trait("Fast", "Fast")] public void CanParseFilter() @@ -2574,6 +2582,20 @@ donation: Assert.Throws(() => fetch.GetRatesAsync(default).GetAwaiter().GetResult()); } + [Fact] + [Trait("Fast", "Fast")] + public void ParseDerivationSchemeSettings() + { + var mainnet = new BTCPayNetworkProvider(NetworkType.Mainnet).GetNetwork("BTC"); + 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(); + Assert.True(DerivationSchemeSettings.TryParseFromColdcard("{\"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 var settings)); + Assert.Equal(root.GetPublicKey().GetHDFingerPrint(), settings.RootFingerprint); + Assert.Equal("Coldcard Import 0x60d1af8b", settings.Label); + Assert.Equal("49'/0'/0'", settings.AccountKeyPath.ToString()); + Assert.Equal("ypub6WWc2gWwHbdnAAyJDnR4SPL1phRh7REqrPBfZeizaQ1EmTshieRXJC3Z5YoU4wkcdKHEjQGkh6AYEzCQC1Kz3DNaWSwdc1pc8416hAjzqyD", settings.AccountOriginal); + Assert.Equal(root.Derive(new KeyPath("m/49'/0'/0'")).Neuter().PubKey.WitHash.ScriptPubKey.Hash.ScriptPubKey, settings.AccountDerivation.Derive(new KeyPath()).ScriptPubKey); + } + [Fact] [Trait("Fast", "Fast")] public void CheckParseStatusMessageModel() diff --git a/BTCPayServer/DerivationSchemeParser.cs b/BTCPayServer/DerivationSchemeParser.cs index a340f108f..52924bde9 100644 --- a/BTCPayServer/DerivationSchemeParser.cs +++ b/BTCPayServer/DerivationSchemeParser.cs @@ -14,12 +14,51 @@ namespace BTCPayServer { public Network Network { get; set; } public Script HintScriptPubKey { get; set; } + static Dictionary electrumMapping; + static DerivationSchemeParser() + { + //Source https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py + electrumMapping = new Dictionary(); + electrumMapping.Add(0x0488b21eU, new[] { "legacy" }); + electrumMapping.Add(0x049d7cb2U, new string[] { "p2sh" }); + electrumMapping.Add(0x4b24746U, Array.Empty()); + } public DerivationSchemeParser(Network expectedNetwork) { Network = expectedNetwork; } + + + public DerivationStrategyBase ParseElectrum(string str) + { + if (str == null) + throw new ArgumentNullException(nameof(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 = new BitcoinExtPubKey(Network.GetBase58CheckEncoder().EncodeData(data), Network.Main).ToNetwork(Network); + if (!electrumMapping.TryGetValue(prefix, out string[] labels)) + { + throw new FormatException(); + } + if (labels.Length == 0) + return new DirectDerivationStrategy(extPubKey) { Segwit = true }; + if (labels[0] == "legacy") + return new DirectDerivationStrategy(extPubKey) { Segwit = false }; + if (labels[0] == "p2sh") + return new DerivationStrategyFactory(Network).Parse(extPubKey.ToString() + "-[p2sh]"); + throw new FormatException(); + } + + public DerivationStrategyBase Parse(string str) { if (str == null) @@ -41,7 +80,7 @@ namespace BTCPayServer } } - if(!Network.Consensus.SupportSegwit) + if (!Network.Consensus.SupportSegwit) hintedLabels.Add("legacy"); try @@ -53,15 +92,6 @@ namespace BTCPayServer { } - Dictionary electrumMapping = new Dictionary(); - //Source https://github.com/spesmilo/electrum/blob/9edffd17542de5773e7284a8c8a2673c766bb3c3/lib/bitcoin.py - var standard = 0x0488b21eU; - electrumMapping.Add(standard, new[] { "legacy" }); - var p2wpkh_p2sh = 0x049d7cb2U; - electrumMapping.Add(p2wpkh_p2sh, new string[] { "p2sh" }); - var p2wpkh = 0x4b24746U; - electrumMapping.Add(p2wpkh, Array.Empty()); - var parts = str.Split('-'); bool hasLabel = false; for (int i = 0; i < parts.Length; i++) @@ -84,11 +114,12 @@ namespace BTCPayServer if (data.Length < 4) continue; var prefix = Utils.ToUInt32(data, false); - var standardPrefix = Utils.ToBytes(Network.NetworkType == NetworkType.Mainnet ? 0x0488b21eU : 0x043587cf, false); + + var standardPrefix = Utils.ToBytes(0x0488b21eU, false); for (int ii = 0; ii < 4; ii++) data[ii] = standardPrefix[ii]; + var derivationScheme = new BitcoinExtPubKey(Network.GetBase58CheckEncoder().EncodeData(data), Network.Main).ToNetwork(Network).ToString(); - var derivationScheme = new BitcoinExtPubKey(Network.GetBase58CheckEncoder().EncodeData(data), Network).ToString(); electrumMapping.TryGetValue(prefix, out string[] labels); if (labels != null) { @@ -136,7 +167,7 @@ namespace BTCPayServer resultNoLabels = string.Join('-', resultNoLabels.Split('-').Where(p => !IsLabel(p))); foreach (var labels in ItemCombinations(hintLabels.ToList())) { - var hinted = facto.Parse(resultNoLabels + '-' + string.Join('-', labels.Select(l=>$"[{l}]").ToArray())); + var hinted = facto.Parse(resultNoLabels + '-' + string.Join('-', labels.Select(l => $"[{l}]").ToArray())); if (HintScriptPubKey == hinted.Derive(firstKeyPath).ScriptPubKey) return hinted; } @@ -149,20 +180,20 @@ namespace BTCPayServer } /// - /// Method to create lists containing possible combinations of an input list of items. This is - /// basically copied from code by user "jaolho" on this thread: - /// http://stackoverflow.com/questions/7802822/all-possible-combinations-of-a-list-of-values - /// - /// type of the items on the input list - /// list of items - /// minimum number of items wanted in the generated combinations, - /// if zero the empty combination is included, - /// default is one - /// maximum number of items wanted in the generated combinations, - /// default is no maximum limit - /// list of lists for possible combinations of the input items - public static List> ItemCombinations(List inputList, int minimumItems = 1, - int maximumItems = int.MaxValue) + /// Method to create lists containing possible combinations of an input list of items. This is + /// basically copied from code by user "jaolho" on this thread: + /// http://stackoverflow.com/questions/7802822/all-possible-combinations-of-a-list-of-values + /// + /// type of the items on the input list + /// list of items + /// minimum number of items wanted in the generated combinations, + /// if zero the empty combination is included, + /// default is one + /// maximum number of items wanted in the generated combinations, + /// default is no maximum limit + /// list of lists for possible combinations of the input items + public static List> ItemCombinations(List inputList, int minimumItems = 1, + int maximumItems = int.MaxValue) { int nonEmptyCombinations = (int)Math.Pow(2, inputList.Count) - 1; List> listOfLists = new List>(nonEmptyCombinations + 1); diff --git a/BTCPayServer/DerivationSchemeSettings.cs b/BTCPayServer/DerivationSchemeSettings.cs index 3da3655f3..8f5a9ba1c 100644 --- a/BTCPayServer/DerivationSchemeSettings.cs +++ b/BTCPayServer/DerivationSchemeSettings.cs @@ -21,7 +21,79 @@ namespace BTCPayServer var result = new NBXplorer.DerivationStrategy.DerivationStrategyFactory(network.NBitcoinNetwork).Parse(derivationStrategy); return new DerivationSchemeSettings(result, network) { AccountOriginal = derivationStrategy.Trim() }; } - + + public static bool TryParseFromColdcard(string coldcardExport, BTCPayNetwork network, out DerivationSchemeSettings settings) + { + settings = null; + if (coldcardExport == null) + throw new ArgumentNullException(nameof(coldcardExport)); + if (network == null) + throw new ArgumentNullException(nameof(network)); + var result = new DerivationSchemeSettings(); + var derivationSchemeParser = new DerivationSchemeParser(network.NBitcoinNetwork); + JObject jobj = null; + try + { + jobj = JObject.Parse(coldcardExport); + jobj = (JObject)jobj["keystore"]; + } + catch + { + return false; + } + + if (jobj.ContainsKey("xpub")) + { + try + { + result.AccountOriginal = jobj["xpub"].Value().Trim(); + result.AccountDerivation = derivationSchemeParser.ParseElectrum(result.AccountOriginal); + } + catch + { + return false; + } + } + else + { + return false; + } + + if (jobj.ContainsKey("label")) + { + try + { + result.Label = jobj["label"].Value(); + } + catch { return false; } + } + + if (jobj.ContainsKey("ckcc_xfp")) + { + try + { + result.RootFingerprint = new HDFingerprint(jobj["ckcc_xfp"].Value()); + } + catch { return false; } + } + + if (jobj.ContainsKey("derivation")) + { + try + { + result.AccountKeyPath = new KeyPath(jobj["derivation"].Value()); + } + catch { return false; } + } + else + { + result.AccountKeyPath = new KeyPath(); + } + settings = result; + settings.Network = network; + return true; + } + public DerivationSchemeSettings() {