diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index ecb685d58..2a8378554 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -645,6 +645,49 @@ namespace BTCPayServer.Tests Assert.Equal(4, tor.Services.Length); } + [Fact] + public void CanParseDerivationSchemes() + { + var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest); + var parser = new DerivationSchemeParser(networkProvider.BTC); + + // xpub + var xpub = "xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw"; + DerivationStrategyBase strategyBase = parser.Parse(xpub); + Assert.IsType(strategyBase); + Assert.True(((DirectDerivationStrategy)strategyBase).Segwit); + Assert.Equal("tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS", strategyBase.ToString()); + + // Multisig + var multisig = "wsh(sortedmulti(2,[62a7956f/84'/1'/0']tpubDDXgATYzdQkHHhZZCMcNJj8BGDENvzMVou5v9NdxiP4rxDLj33nS233dGFW4htpVZSJ6zds9eVqAV9RyRHHiKtwQKX8eR4n4KN3Dwmj7A3h/0/*,[11312aa2/84'/1'/0']tpubDC8a54NFtQtMQAZ97VhoU9V6jVTvi9w4Y5SaAXJSBYETKg3AoX5CCKndznhPWxJUBToPCpT44s86QbKdGpKAnSjcMTGW4kE6UQ8vpBjcybW/0/*,[8f71b834/84'/1'/0']tpubDChjnP9LXNrJp43biqjY7FH93wgRRNrNxB4Q8pH7PPRy8UPcH2S6V46WGVJ47zVGF7SyBJNCpnaogsFbsybVQckGtVhCkng3EtFn8qmxptS/0/*))"; + var expected = "2-of-tpubDDXgATYzdQkHHhZZCMcNJj8BGDENvzMVou5v9NdxiP4rxDLj33nS233dGFW4htpVZSJ6zds9eVqAV9RyRHHiKtwQKX8eR4n4KN3Dwmj7A3h-tpubDC8a54NFtQtMQAZ97VhoU9V6jVTvi9w4Y5SaAXJSBYETKg3AoX5CCKndznhPWxJUBToPCpT44s86QbKdGpKAnSjcMTGW4kE6UQ8vpBjcybW-tpubDChjnP9LXNrJp43biqjY7FH93wgRRNrNxB4Q8pH7PPRy8UPcH2S6V46WGVJ47zVGF7SyBJNCpnaogsFbsybVQckGtVhCkng3EtFn8qmxptS"; + (strategyBase, RootedKeyPath[] rootedKeyPath) = parser.ParseOutputDescriptor(multisig); + Assert.Equal(3, rootedKeyPath.Length); + Assert.IsType(strategyBase); + Assert.IsType(((P2WSHDerivationStrategy)strategyBase).Inner); + Assert.Equal(expected, strategyBase.ToString()); + + var inner = (MultisigDerivationStrategy)((P2WSHDerivationStrategy)strategyBase).Inner; + Assert.False(inner.IsLegacy); + Assert.Equal(3, inner.Keys.Count); + Assert.Equal(2, inner.RequiredSignatures); + Assert.Equal(expected, inner.ToString()); + + // Output Descriptor + networkProvider = new BTCPayNetworkProvider(ChainName.Mainnet); + parser = new DerivationSchemeParser(networkProvider.BTC); + var od = "wpkh([8bafd160/49h/0h/0h]xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw/0/*)#9x4vkw48"; + (strategyBase, rootedKeyPath) = parser.ParseOutputDescriptor(od); + Assert.Equal(1, rootedKeyPath.Length); + Assert.IsType(strategyBase); + Assert.True(((DirectDerivationStrategy)strategyBase).Segwit); + + // Failure cases + Assert.Throws(() => { parser.Parse("xpub 661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw"); }); // invalid format because of space + Assert.Throws(() => { parser.ParseOutputDescriptor("invalid"); }); // invalid in general + Assert.Throws(() => { parser.ParseOutputDescriptor("wpkh([8b60afd1/49h/0h/0h]xpub661MyMwAFXkMnyoBjyHndD3QwRbcGVBsTGeNZN6QGVHcfz4MPzBUxjSevweNFQx7SqmMHLdSA4FteGsRrEriu4pnVZMZWnruFFAYZATtcDw/0/*)#9x4vkw48"); }); // invalid checksum + } + [Fact] public void ParseDerivationSchemeSettings() { diff --git a/BTCPayServer/DerivationSchemeParser.cs b/BTCPayServer/DerivationSchemeParser.cs index f74dfc0e3..8d7cdb834 100644 --- a/BTCPayServer/DerivationSchemeParser.cs +++ b/BTCPayServer/DerivationSchemeParser.cs @@ -33,7 +33,6 @@ namespace BTCPayServer { throw new FormatException("Custom change paths are not supported."); } - return (Parse($"{hd.Extkey}{suffix}"), null); case PubKeyProvider.Origin origin: var innerResult = ExtractFromPkProvider(origin.Inner, suffix); @@ -42,7 +41,16 @@ namespace BTCPayServer throw new ArgumentOutOfRangeException(); } } - + + (DerivationStrategyBase, RootedKeyPath[]) ExtractFromMulti(OutputDescriptor.Multi multi) + { + var xpubs = multi.PkProviders.Select(provider => ExtractFromPkProvider(provider)); + return ( + Parse( + $"{multi.Threshold}-of-{(string.Join('-', xpubs.Select(tuple => tuple.Item1.ToString())))}{(multi.IsSorted ? "" : "-[keeporder]")}"), + xpubs.SelectMany(tuple => tuple.Item2).ToArray()); + } + ArgumentNullException.ThrowIfNull(str); str = str.Trim(); var outputDescriptor = OutputDescriptor.Parse(str, Network); @@ -55,11 +63,7 @@ namespace BTCPayServer case OutputDescriptor.Combo _: throw new FormatException("Only output descriptors of one format are supported."); case OutputDescriptor.Multi multi: - var xpubs = multi.PkProviders.Select(provider => ExtractFromPkProvider(provider)); - return ( - Parse( - $"{multi.Threshold}-of-{(string.Join('-', xpubs.Select(tuple => tuple.Item1.ToString())))}{(multi.IsSorted ? "" : "-[keeporder]")}"), - xpubs.SelectMany(tuple => tuple.Item2).ToArray()); + return ExtractFromMulti(multi); case OutputDescriptor.PKH pkh: return ExtractFromPkProvider(pkh.PkProvider, "-[legacy]"); case OutputDescriptor.SH sh: @@ -79,11 +83,9 @@ namespace BTCPayServer throw new FormatException("sh descriptors are only supported with multsig(legacy or p2wsh) and segwit(p2wpkh)"); case OutputDescriptor.WPKH wpkh: return ExtractFromPkProvider(wpkh.PkProvider, ""); - case OutputDescriptor.WSH wsh: - if (wsh.Inner is OutputDescriptor.Multi) - { - return ParseOutputDescriptor(wsh.Inner.ToString()); - } + case OutputDescriptor.WSH { Inner: OutputDescriptor.Multi multi }: + return ExtractFromMulti(multi); + case OutputDescriptor.WSH: throw new FormatException("wsh descriptors are only supported with multisig"); default: throw new ArgumentOutOfRangeException(nameof(outputDescriptor));