From 915f1ad91273dd6eab8894a63dbb3a496161c839 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20A=2EP?=
<53834183+Jossec101@users.noreply.github.com>
Date: Wed, 19 Aug 2026 10:18:29 +0200
Subject: [PATCH] Bump NBitcoin to 10.0.9 and NBXplorer.Client to 5.0.8
Paired bump: NBXplorer.Client 5.0.8 targets net10.0 and requires
NBitcoin >= 10.0.8. Also NBitcoin.TestFramework 4.0.2 -> 5.0.2.
NBitcoin 10 removed the NBitcoin.Scripting OutputDescriptor API; its
Miniscript/WalletPolicies replacement only parses BIP388 multipath
descriptors (/**), which NodeGuard's watch-only import explicitly rejects
(only external-chain /0/* keys are accepted). To preserve the exact
accepted grammar and error behavior, WalletParser now parses the small
supported descriptor surface directly (wpkh/pkh/multi/sortedmulti/
wsh(multi), origins, checksum validation) and renders export descriptors
manually with Miniscript.AddChecksum for the BIP380 checksum. The
lexicographical-order test rebuilds the descriptor from the multisig
script parameters since OutputDescriptor.InferFromScript is gone.
All 389 tests pass, including the pinned descriptor parse/export vectors
(checksums, key ordering, error types).
---
src/Data/Models/Wallet.cs | 1 -
src/Helpers/WalletParser.cs | 252 +++++++++---------
src/NodeGuard.csproj | 4 +-
.../Data/Models/WalletTests.cs | 9 +-
test/NodeGuard.Tests/NodeGuard.Tests.csproj | 2 +-
5 files changed, 137 insertions(+), 131 deletions(-)
diff --git a/src/Data/Models/Wallet.cs b/src/Data/Models/Wallet.cs
index c9fcdee4..9a909174 100644
--- a/src/Data/Models/Wallet.cs
+++ b/src/Data/Models/Wallet.cs
@@ -20,7 +20,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using NodeGuard.Helpers;
using NBitcoin;
-using NBitcoin.Scripting;
using NBXplorer.DerivationStrategy;
namespace NodeGuard.Data.Models
diff --git a/src/Helpers/WalletParser.cs b/src/Helpers/WalletParser.cs
index 83a4d745..fb171633 100644
--- a/src/Helpers/WalletParser.cs
+++ b/src/Helpers/WalletParser.cs
@@ -1,7 +1,5 @@
-using System.Text;
-using Humanizer;
using NBitcoin;
-using NBitcoin.Scripting;
+using NBitcoin.WalletPolicies;
using NBXplorer.DerivationStrategy;
using NodeGuard.Data.Models;
@@ -10,9 +8,15 @@ namespace NodeGuard.Helpers;
public static class WalletParser
{
///
- /// Parse the output descriptor string to get the wallet info, Took from BTCPAYServer codebase
+ /// Parse the output descriptor string to get the wallet info.
+ /// NodeGuard accepts a deliberately small descriptor grammar: wpkh/pkh single-sig and
+ /// (w)sh-less multi/sortedmulti or wsh(multi/sortedmulti), with external-chain-only keys
+ /// ("xpub" or "xpub/0/*"). NBitcoin 10 removed the NBitcoin.Scripting OutputDescriptor API and
+ /// its Miniscript replacement only parses BIP388 multipath descriptors ("/**"), which NodeGuard
+ /// rejects — so the accepted grammar is parsed here directly, preserving the old behaviour.
///
///
+ ///
public static (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ParseOutputDescriptor(
string outputDescriptorStr, Network currentNetwork)
{
@@ -28,77 +32,116 @@ public static (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) Pars
throw new ArgumentException("Descriptor contains <0;1> which is not supported, please use <0/*>");
}
- var outputDescriptor = OutputDescriptor.Parse(outputDescriptorStr, currentNetwork);
- switch (outputDescriptor)
+ var body = StripAndValidateChecksum(outputDescriptorStr);
+
+ var (fragment, inner) = ReadFragment(body);
+ switch (fragment)
{
//TODO TR descriptor when NBitcoin supports it
- case OutputDescriptor.PK _:
- throw new FormatException("Output descriptor not supported: " + outputDescriptorStr);
- case OutputDescriptor.Raw _:
- throw new FormatException("Output descriptor not supported: " + outputDescriptorStr);
-
- case OutputDescriptor.Addr _:
- throw new FormatException("Output descriptor not supported: " + outputDescriptorStr);
- case OutputDescriptor.Combo _:
+ case "pk":
+ case "raw":
+ case "addr":
+ case "combo":
+ case "tr":
throw new FormatException("Output descriptor not supported: " + outputDescriptorStr);
- case OutputDescriptor.Multi multi:
- return ExtractFromMulti(multi);
- case OutputDescriptor.PKH pkh:
- return ExtractFromPkProvider(pkh.PkProvider, "-[legacy]");
- case OutputDescriptor.SH _:
+ case "multi":
+ case "sortedmulti":
+ return ExtractFromMulti(fragment == "sortedmulti", inner);
+ case "pkh":
+ return ExtractFromKey(inner, "-[legacy]");
+ case "sh":
throw new FormatException(
"Legacy multisig is not supported, please use segwit multisig instead.");
- case OutputDescriptor.WPKH wpkh:
- return ExtractFromPkProvider(wpkh.PkProvider, "");
- case OutputDescriptor.WSH {Inner: OutputDescriptor.Multi multi}:
- return ExtractFromMulti(multi);
- case OutputDescriptor.WSH:
+ case "wpkh":
+ return ExtractFromKey(inner, "");
+ case "wsh":
+ var (innerFragment, innerExpression) = ReadFragment(inner);
+ if (innerFragment is "multi" or "sortedmulti")
+ return ExtractFromMulti(innerFragment == "sortedmulti", innerExpression);
throw new FormatException("wsh descriptors are only supported with multisig");
default:
- throw new ArgumentOutOfRangeException(nameof(outputDescriptor));
+ throw new FormatException("Output descriptor not supported: " + outputDescriptorStr);
+ }
+
+ static string StripAndValidateChecksum(string descriptor)
+ {
+ var hashIndex = descriptor.IndexOf('#');
+ if (hashIndex < 0) return descriptor;
+
+ var body = descriptor[..hashIndex];
+ var checksum = descriptor[(hashIndex + 1)..];
+ if (Miniscript.GetCheckSum(body) != checksum)
+ throw new FormatException("Invalid checksum in output descriptor: " + descriptor);
+
+ return body;
}
- (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ExtractFromMulti(OutputDescriptor.Multi multi)
+ static (string fragment, string inner) ReadFragment(string expression)
{
- var multiPkProviders = multi.PkProviders;
-
- var xpubs = multiPkProviders.Select(provider => ExtractFromPkProvider(provider)).ToArray();
+ var open = expression.IndexOf('(');
+ if (open <= 0 || !expression.EndsWith(')'))
+ throw new FormatException("Output descriptor not supported: " + expression);
+
+ return (expression[..open], expression[(open + 1)..^1]);
+ }
+
+ (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ExtractFromMulti(bool isSorted, string inner)
+ {
+ var parts = inner.Split(',');
+ if (parts.Length < 2)
+ throw new FormatException("Output descriptor not supported: " + inner);
+
+ var threshold = uint.Parse(parts[0].Trim());
+
+ var xpubs = parts.Skip(1).Select(key => ExtractFromKey(key)).ToArray();
var xpubsStrings = xpubs.Select(tuple => tuple.Item1.ToString()).ToArray();
-
- if(multi.IsSorted)
+
+ if (isSorted)
xpubsStrings = xpubsStrings.OrderBy(x => x).ToArray();
-
+
var extractFromMulti = (
Parse(
- $"{multi.Threshold}-of-{(string.Join('-', xpubsStrings))}{(multi.IsSorted ? "" : "-[keeporder]")}"),
+ $"{threshold}-of-{(string.Join('-', xpubsStrings))}{(isSorted ? "" : "-[keeporder]")}"),
xpubs.SelectMany(tuple => tuple.Item2).ToArray());
return extractFromMulti;
}
- (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ExtractFromPkProvider(
- PubKeyProvider pubKeyProvider,
+ (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ExtractFromKey(
+ string keyExpression,
string suffix = "")
{
- switch (pubKeyProvider)
+ keyExpression = keyExpression.Trim();
+
+ RootedKeyPath? keyOriginInfo = null;
+ if (keyExpression.StartsWith('['))
+ {
+ var close = keyExpression.IndexOf(']');
+ if (close < 0)
+ throw new FormatException("Output descriptor not supported: " + keyExpression);
+
+ keyOriginInfo = RootedKeyPath.Parse(keyExpression[1..close]);
+ keyExpression = keyExpression[(close + 1)..];
+ }
+
+ var slash = keyExpression.IndexOf('/');
+ var xpubString = slash < 0 ? keyExpression : keyExpression[..slash];
+ var derivation = slash < 0 ? null : keyExpression[(slash + 1)..];
+
+ //Only the external chain ("0/*") is supported, like the previous OutputDescriptor-based parser
+ if (derivation != null && derivation != "0/*")
{
- case PubKeyProvider.Const _:
- throw new FormatException("Only HD output descriptors are supported.");
- case PubKeyProvider.HD hd:
- if (hd.Path != null && hd.Path.ToString() != "0")
- {
- 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);
- var bitcoinExtPubKey = innerResult.Item1.GetExtPubKeys().First().GetWif(currentNetwork);
- var rootedKeyPath = origin.KeyOriginInfo;
- return (innerResult.Item1, new[] {(extPubKey: bitcoinExtPubKey, KeyOriginInfo: rootedKeyPath)});
- default:
- throw new ArgumentOutOfRangeException();
+ throw new FormatException("Custom change paths are not supported.");
}
+
+ var bitcoinExtPubKey = new BitcoinExtPubKey(xpubString, currentNetwork);
+
+ var strategy = Parse($"{bitcoinExtPubKey}{suffix}");
+
+ if (keyOriginInfo == null)
+ return (strategy, null);
+
+ return (strategy, new[] {(extPubKey: bitcoinExtPubKey, KeyOriginInfo: keyOriginInfo)});
}
DerivationStrategyBase Parse(string str)
@@ -121,93 +164,56 @@ DerivationStrategyBase Parse(string str)
/// This method first determines the network based on the provided string. It then checks if the wallet is a hot wallet or not.
/// If it is, it generates the output descriptor based on the first key in the wallet and the wallet's address type.
/// If it's not a hot wallet, it generates a multi-signature output descriptor based on all the keys in the wallet and the wallet's address type.
+ /// The BIP380 checksum is appended, like the previous OutputDescriptor-based implementation did.
///
public static string GetOutputDescriptor(this Wallet wallet, string bitcoinNetwork)
{
var network = Network.GetNetwork(bitcoinNetwork);
- OutputDescriptor outputDescriptor = null;
- PubKeyProvider pubKeyProvider;
- if (wallet.IsHotWallet)
+ string RenderKey(Data.Models.Key key)
{
- var key = wallet.Keys.FirstOrDefault();
- pubKeyProvider = PubKeyProvider.NewHD(
- new BitcoinExtPubKey(
- ExtPubKey.Parse(key.XPUB, network),
- network
- ),
- new KeyPath("/0"),
- PubKeyProvider.DeriveType.UNHARDENED
- );
- var fingerprint = GetMasterFingerprint(key.MasterFingerprint);
var rootedKeyPath = new RootedKeyPath(
- new HDFingerprint(fingerprint),
+ new HDFingerprint(GetMasterFingerprint(key.MasterFingerprint)),
KeyPath.Parse(key.Path)
);
- pubKeyProvider = PubKeyProvider.NewOrigin(rootedKeyPath, pubKeyProvider);
-
- switch (wallet.WalletAddressType)
+ var bitcoinExtPubKey = new BitcoinExtPubKey(ExtPubKey.Parse(key.XPUB, network), network);
+ return $"[{rootedKeyPath}]{bitcoinExtPubKey}/0/*";
+ }
+
+ string body;
+ if (wallet.IsHotWallet)
+ {
+ var key = wallet.Keys.FirstOrDefault();
+ var keyExpression = RenderKey(key);
+
+ body = wallet.WalletAddressType switch
{
- case WalletAddressType.NativeSegwit:
- outputDescriptor = OutputDescriptor.NewWPKH(pubKeyProvider, network);
- break;
- case WalletAddressType.NestedSegwit:
- outputDescriptor = OutputDescriptor.NewWPKH(pubKeyProvider, network);
- outputDescriptor = OutputDescriptor.NewSH(outputDescriptor, network);
- break;
- case WalletAddressType.Legacy:
- outputDescriptor = OutputDescriptor.NewPKH(pubKeyProvider, network);
- break;
- case WalletAddressType.Taproot:
- throw new NotImplementedException();
- }
+ WalletAddressType.NativeSegwit => $"wpkh({keyExpression})",
+ WalletAddressType.NestedSegwit => $"sh(wpkh({keyExpression}))",
+ WalletAddressType.Legacy => $"pkh({keyExpression})",
+ WalletAddressType.Taproot => throw new NotImplementedException(),
+ _ => throw new Exception("Something went wrong")
+ };
}
else
{
- var pubKeyProviders = new List();
- foreach (var k in wallet.Keys)
- {
- var rootedKeyPath = new RootedKeyPath(
- new HDFingerprint(GetMasterFingerprint(k.MasterFingerprint)),
- KeyPath.Parse(k.Path)
- );
- pubKeyProvider = PubKeyProvider.NewOrigin(
- rootedKeyPath,
- PubKeyProvider.NewHD(
- new BitcoinExtPubKey(
- ExtPubKey.Parse(k.XPUB, network),
- network
- ),
- new KeyPath("/0"),
- PubKeyProvider.DeriveType.UNHARDENED
- )
- );
- pubKeyProviders.Add(pubKeyProvider);
- }
- outputDescriptor = OutputDescriptor.NewMulti(
- (uint)wallet.MofN,
- pubKeyProviders,
- !wallet.IsUnSortedMultiSig,
- network);
-
- switch (wallet.WalletAddressType)
+ var keyExpressions = string.Join(',', wallet.Keys.Select(RenderKey));
+ var multi =
+ $"{(wallet.IsUnSortedMultiSig ? "multi" : "sortedmulti")}({wallet.MofN},{keyExpressions})";
+
+ body = wallet.WalletAddressType switch
{
- case WalletAddressType.NativeSegwit:
- outputDescriptor = OutputDescriptor.NewWSH(outputDescriptor, network);
- break;
- case WalletAddressType.NestedSegwit:
- outputDescriptor = OutputDescriptor.NewSH(outputDescriptor, network);
- break;
- case WalletAddressType.Legacy:
- break;
- case WalletAddressType.Taproot:
- throw new NotImplementedException();
- }
+ WalletAddressType.NativeSegwit => $"wsh({multi})",
+ WalletAddressType.NestedSegwit => $"sh({multi})",
+ WalletAddressType.Legacy => multi,
+ WalletAddressType.Taproot => throw new NotImplementedException(),
+ _ => throw new Exception("Something went wrong")
+ };
}
- return outputDescriptor is not null ? outputDescriptor.ToString() : throw new Exception("Something went wrong");
+ return Miniscript.AddChecksum(body);
}
-
+
///
/// Converts a hexadecimal string representation of a master fingerprint into a byte array.
///
@@ -224,4 +230,4 @@ public static byte[] GetMasterFingerprint(string masterFingerprint)
.ToArray();
return internalBytes;
}
-}
\ No newline at end of file
+}
diff --git a/src/NodeGuard.csproj b/src/NodeGuard.csproj
index eaa6c42b..48372dbe 100644
--- a/src/NodeGuard.csproj
+++ b/src/NodeGuard.csproj
@@ -34,8 +34,8 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/test/NodeGuard.Tests/Data/Models/WalletTests.cs b/test/NodeGuard.Tests/Data/Models/WalletTests.cs
index 13b71bf1..31e2e2c6 100644
--- a/test/NodeGuard.Tests/Data/Models/WalletTests.cs
+++ b/test/NodeGuard.Tests/Data/Models/WalletTests.cs
@@ -2,7 +2,6 @@
using NodeGuard.Data.Models;
using NodeGuard.TestHelpers;
using NBitcoin;
-using NBitcoin.Scripting;
using NBXplorer.DerivationStrategy;
namespace NodeGuard.Tests;
@@ -169,9 +168,11 @@ public void DerivationScheme_Lexicographicalorder(int mOfN, string xpub1, string
testingMultisigWallet.Keys = testingMultisigWallet.Keys.OrderBy(a => Guid.NewGuid()).ToList();
//We derive address 0/0 and check against the output descriptor by sparrow
var script = ((P2WSHDerivationStrategy)testingMultisigWallet.GetDerivationStrategy()).GetDerivation(new KeyPath("0/0"));
- var outputDescriptor = OutputDescriptor.InferFromScript(script.Redeem, new FlatSigningRepository(), Network.RegTest);
- //We split after the #checksum
- var outputDescriptorString = outputDescriptor.ToString().Split("#", StringSplitOptions.TrimEntries).First();
+ //NBitcoin 10 removed OutputDescriptor.InferFromScript, so the descriptor is rebuilt from
+ //the multisig script parameters (the script pubkey order IS the lexicographical order)
+ var multisigParameters = PayToMultiSigTemplate.Instance.ExtractScriptPubKeyParameters(script.Redeem);
+ var outputDescriptorString =
+ $"sortedmulti({multisigParameters!.SignatureCount},{string.Join(',', multisigParameters.PubKeys.Select(x => x.ToHex()))})";
//Assert
outputDescriptorString.Should().Be(expectedOutputDescriptor.Trim());
diff --git a/test/NodeGuard.Tests/NodeGuard.Tests.csproj b/test/NodeGuard.Tests/NodeGuard.Tests.csproj
index c36730f1..2269cfe8 100644
--- a/test/NodeGuard.Tests/NodeGuard.Tests.csproj
+++ b/test/NodeGuard.Tests/NodeGuard.Tests.csproj
@@ -18,7 +18,7 @@
-
+
all