Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/Data/Models/Wallet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using NodeGuard.Helpers;
using NBitcoin;
using NBitcoin.Scripting;
using NBXplorer.DerivationStrategy;

namespace NodeGuard.Data.Models
Expand Down
252 changes: 129 additions & 123 deletions src/Helpers/WalletParser.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Text;
using Humanizer;
using NBitcoin;
using NBitcoin.Scripting;
using NBitcoin.WalletPolicies;
using NBXplorer.DerivationStrategy;
using NodeGuard.Data.Models;

Expand All @@ -10,9 +8,15 @@ namespace NodeGuard.Helpers;
public static class WalletParser
{
/// <summary>
/// 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
Comment on lines +12 to +14
/// its Miniscript replacement only parses BIP388 multipath descriptors ("/**"), which NodeGuard
/// rejects — so the accepted grammar is parsed here directly, preserving the old behaviour.
/// </summary>
/// <param name="outputDescriptorStr"></param>
/// <param name="currentNetwork"></param>
public static (DerivationStrategyBase, (BitcoinExtPubKey, RootedKeyPath)[]) ParseOutputDescriptor(
string outputDescriptorStr, Network currentNetwork)
{
Expand All @@ -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.");
Comment on lines +52 to 54
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)
Expand All @@ -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.
/// </remarks>
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<PubKeyProvider>();
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,
Comment on lines +206 to +208
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);
}

/// <summary>
/// Converts a hexadecimal string representation of a master fingerprint into a byte array.
/// </summary>
Expand All @@ -224,4 +230,4 @@ public static byte[] GetMasterFingerprint(string masterFingerprint)
.ToArray();
return internalBytes;
}
}
}
4 changes: 2 additions & 2 deletions src/NodeGuard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.1" />
<PackageReference Include="NBitcoin" Version="9.0.4" />
<PackageReference Include="NBXplorer.Client" Version="5.0.5" />
<PackageReference Include="NBitcoin" Version="10.0.9" />
<PackageReference Include="NBXplorer.Client" Version="5.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
Expand Down
9 changes: 5 additions & 4 deletions test/NodeGuard.Tests/Data/Models/WalletTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using NodeGuard.Data.Models;
using NodeGuard.TestHelpers;
using NBitcoin;
using NBitcoin.Scripting;
using NBXplorer.DerivationStrategy;

namespace NodeGuard.Tests;
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion test/NodeGuard.Tests/NodeGuard.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Moq.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="NBitcoin.TestFramework" Version="4.0.2" />
<PackageReference Include="NBitcoin.TestFramework" Version="5.0.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
<PrivateAssets>all</PrivateAssets>
Expand Down
Loading