Skip to content

Commit c881ef2

Browse files
authored
Cleanup: extract DescriptorParser + unit tests + bump to 1.1.0 (#13)
* Extract DescriptorParser + add unit tests + bump to 1.1.0 - Plugins/SamRockProtocol/Services/DescriptorParser.cs: pure static helpers (NormalizeDescriptor, NormalizeDerivationPath, TryParseBitcoinDescriptor, TryParseLiquidDescriptor) extracted from ProtocolController. Compiled regexes, anchored on both ends per PR #11 hardening, wrapped-elsh script-type validation per PR #10 silent fix. No behavior change for the existing call sites. - ProtocolController.cs: now delegates to DescriptorParser. Removes the inline BTC regex + the unused addressSuffix-combine TODO. - SamRockProtocol.Tests/DescriptorParserTests.cs: pure unit tests covering AQUA wrapped LBTC, BULL native elwpkh + h-style hardened markers (PR #10), whitespace normalization, trailing-garbage rejection (PR #11), elsh-wrapped non-wpkh rejection (PR #10 silent fix). 16 tests, no DI, no BTCPay test stack required. - .github/workflows/playwright.yml: drop the PlaywrightUITest category filter so the new unit tests run alongside the integration test. - Version bumped 1.0.4 -> 1.1.0 (minor: BULL wallet multi-format support added + crash hardening landed). * Fix trailing-garbage tests + disable xunit parallelism - Trailing-garbage tests appended alphanumeric extensions which fell inside the checksum [a-zA-Z0-9]+ character class - the anchored regex correctly accepted them as valid checksums. Switch to '!!'- prefixed extensions so the trailing chars cannot be absorbed. - Add [assembly: CollectionBehavior(DisableTestParallelization = true)] via AssemblyInfo.cs. Without it xunit runs different test classes in parallel and the integration test's ServerTester boot races with unit-test discovery, which sometimes leaves MVC parts unregistered and the plugin's Greenfield route 404. --------- Co-authored-by: r1ckstardev <r1ckstardev@users.noreply.github.com>
1 parent 8220903 commit c881ef2

6 files changed

Lines changed: 357 additions & 93 deletions

File tree

.github/workflows/playwright.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ jobs:
8686
run: docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" up -d dev --build
8787

8888
- name: Run tests
89-
run: dotnet test SamRockProtocol.Tests --logger "console;verbosity=detailed" --filter "Category=PlaywrightUITest" /p:EnableBoltzSupport=false
89+
run: dotnet test SamRockProtocol.Tests --logger "console;verbosity=detailed" /p:EnableBoltzSupport=false
9090

9191
- name: Cleanup Docker
9292
run: docker compose -f submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml down --volumes

Plugins/SamRockProtocol/Controllers/ProtocolController.cs

Lines changed: 7 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -147,35 +147,21 @@ private async Task<IActionResult> processSamRockProtocolRequest(SamRockProtocolR
147147
var key = SamRockProtocolKeys.BTC;
148148
try
149149
{
150-
var descriptor = NormalizeDescriptor(setupModel.BTC.Descriptor);
151-
152-
// Extract script type, fingerprint, derivation path, xpub, and address derivation suffix
153-
var match = Regex.Match(descriptor, @"^(\w+)\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)(?:#[a-zA-Z0-9]+)?$");
154-
if (!match.Success)
150+
var descriptor = DescriptorParser.NormalizeDescriptor(setupModel.BTC.Descriptor);
151+
if (!DescriptorParser.TryParseBitcoinDescriptor(descriptor,
152+
out var scriptType, out var fingerprint, out var derivationPath, out var xpub, out var error))
155153
{
156-
result.Results[key] = new SamRockProtocolResponse(false,
157-
"Invalid BTC descriptor format - could not parse script type, fingerprint, derivation path, and xpub.", null);
154+
result.Results[key] = new SamRockProtocolResponse(false, error, null);
158155
}
159156
else
160157
{
161-
var scriptType = match.Groups[1].Value;
162-
var fingerprint = match.Groups[2].Value;
163-
var basePath = NormalizeDerivationPath(match.Groups[3].Value);
164-
var xpub = match.Groups[4].Value;
165-
var addressSuffix = match.Groups[5].Value; // e.g., "/0/*"
166-
167-
// TODO: Check whether you need to combine base derivation path with address derivation suffix
168-
var derivationPath = basePath; // + (addressSuffix ?? "");
169-
170-
// Convert script type to NBXplorer suffix format
171158
var suffix = GetNBXplorerSuffix(scriptType, descriptor);
172159
if (suffix == null)
173160
{
174161
result.Results[key] = new SamRockProtocolResponse(false, $"Unsupported BTC script type: {scriptType}", null);
175162
}
176163
else
177164
{
178-
// Create NBXplorer format derivation scheme
179165
var derivationScheme = xpub + suffix;
180166
await SetupWalletAsync(derivationScheme, fingerprint, derivationPath, "BTC", storeData, key, result);
181167
}
@@ -195,8 +181,8 @@ private async Task<IActionResult> processSamRockProtocolRequest(SamRockProtocolR
195181
{
196182
try
197183
{
198-
var descriptor = NormalizeDescriptor(setupModel.LBTC.Descriptor);
199-
if (!TryParseLiquidDescriptor(descriptor, out var blindingKey, out var suffix, out var fingerprint,
184+
var descriptor = DescriptorParser.NormalizeDescriptor(setupModel.LBTC.Descriptor);
185+
if (!DescriptorParser.TryParseLiquidDescriptor(descriptor, out var blindingKey, out var suffix, out var fingerprint,
200186
out var derivationPath, out var xpub, out var error))
201187
{
202188
result.Results[key] = new SamRockProtocolResponse(false, error, null);
@@ -230,7 +216,7 @@ private async Task<IActionResult> processSamRockProtocolRequest(SamRockProtocolR
230216
}
231217
else
232218
{
233-
await boltzWrapper.SetBoltz(StoreId, NormalizeDescriptor(setupModel.BTCLN.LBTC.Descriptor), result);
219+
await boltzWrapper.SetBoltz(StoreId, DescriptorParser.NormalizeDescriptor(setupModel.BTCLN.LBTC.Descriptor), result);
234220
}
235221
}
236222
else
@@ -262,76 +248,6 @@ private async Task<IActionResult> processSamRockProtocolRequest(SamRockProtocolR
262248
});
263249
}
264250

265-
private static string NormalizeDescriptor(string descriptor)
266-
{
267-
if (descriptor == null)
268-
return null;
269-
270-
return Regex.Replace(descriptor, @"\s+", string.Empty);
271-
}
272-
273-
private static string NormalizeDerivationPath(string path)
274-
{
275-
if (string.IsNullOrEmpty(path))
276-
return path;
277-
278-
return string.Join('/', path.Split('/').Select(component =>
279-
component.EndsWith("h", StringComparison.OrdinalIgnoreCase)
280-
? component[..^1] + "'"
281-
: component));
282-
}
283-
284-
private static bool TryParseLiquidDescriptor(string descriptor, out string blindingKey, out string suffix,
285-
out string fingerprint, out string derivationPath, out string xpub, out string error)
286-
{
287-
blindingKey = null;
288-
suffix = null;
289-
fingerprint = null;
290-
derivationPath = null;
291-
xpub = null;
292-
error = null;
293-
294-
if (string.IsNullOrWhiteSpace(descriptor))
295-
{
296-
error = "Invalid LBTC descriptor format - descriptor is empty.";
297-
return false;
298-
}
299-
300-
var nativeMatch = Regex.Match(descriptor,
301-
@"^ct\(slip77\(([a-fA-F0-9]{64})\),elwpkh\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)\)(?:#[a-zA-Z0-9]+)?$");
302-
if (nativeMatch.Success)
303-
{
304-
blindingKey = nativeMatch.Groups[1].Value;
305-
fingerprint = nativeMatch.Groups[2].Value;
306-
derivationPath = NormalizeDerivationPath(nativeMatch.Groups[3].Value);
307-
xpub = nativeMatch.Groups[4].Value;
308-
suffix = "";
309-
return true;
310-
}
311-
312-
var wrappedMatch = Regex.Match(descriptor,
313-
@"^ct\(slip77\(([a-fA-F0-9]{64})\),elsh\((\w+)\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)\)\)(?:#[a-zA-Z0-9]+)?$");
314-
if (wrappedMatch.Success)
315-
{
316-
var scriptType = wrappedMatch.Groups[2].Value;
317-
if (!string.Equals(scriptType, "wpkh", StringComparison.OrdinalIgnoreCase))
318-
{
319-
error = $"Unsupported LBTC script type: elsh({scriptType})";
320-
return false;
321-
}
322-
323-
blindingKey = wrappedMatch.Groups[1].Value;
324-
fingerprint = wrappedMatch.Groups[3].Value;
325-
derivationPath = NormalizeDerivationPath(wrappedMatch.Groups[4].Value);
326-
xpub = wrappedMatch.Groups[5].Value;
327-
suffix = "-[p2sh]";
328-
return true;
329-
}
330-
331-
error = "Invalid LBTC descriptor format - expected ct(slip77(...),elwpkh(...)) or ct(slip77(...),elsh(wpkh(...))).";
332-
return false;
333-
}
334-
335251
private async Task SetupWalletAsync(string derivationScheme, string fingerprint, string derivationPath, string networkCode,
336252
StoreData storeData, SamRockProtocolKeys key, SamRockProtocolSetupResponse result)
337253
{

Plugins/SamRockProtocol/SamRockProtocol.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Description>Get paid online, receive funds on your phone.
1212
SamRock Protocol links your store to self-custodial wallet via a simple QR scan. Try it with Aqua Wallet!
1313
</Description>
14-
<Version>1.0.4</Version>
14+
<Version>1.1.0</Version>
1515
</PropertyGroup>
1616

1717
<!-- Plugin development properties -->
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System;
2+
using System.Linq;
3+
using System.Text.RegularExpressions;
4+
5+
namespace SamRockProtocol.Services;
6+
7+
/// <summary>
8+
/// Output descriptor parsing helpers for SamRock protocol. Pure static functions
9+
/// over input strings - no DI dependencies - so they can be unit-tested directly
10+
/// without the BTCPay test stack.
11+
/// </summary>
12+
public static class DescriptorParser
13+
{
14+
/// <summary>
15+
/// Strips all whitespace from a descriptor. Output descriptors have no
16+
/// legitimate internal whitespace; this normalizes wallets that emit
17+
/// stray whitespace around the body or checksum.
18+
/// </summary>
19+
public static string NormalizeDescriptor(string descriptor)
20+
{
21+
if (descriptor == null)
22+
return null;
23+
return Regex.Replace(descriptor, @"\s+", string.Empty);
24+
}
25+
26+
/// <summary>
27+
/// Converts h-style hardened markers ("84h/0h/0h") to apostrophe form
28+
/// ("84'/0'/0'") component-by-component. Handles both lowercase "h" and
29+
/// uppercase "H". Components already in apostrophe form are untouched.
30+
/// </summary>
31+
public static string NormalizeDerivationPath(string path)
32+
{
33+
if (string.IsNullOrEmpty(path))
34+
return path;
35+
return string.Join('/', path.Split('/').Select(component =>
36+
component.EndsWith("h", StringComparison.OrdinalIgnoreCase)
37+
? component[..^1] + "'"
38+
: component));
39+
}
40+
41+
// BTC descriptor: wpkh / pkh / sh / tr enclosing a [fingerprint/path]xpub.../range
42+
// followed by an optional #checksum. Anchored at both ends to reject
43+
// malformed trailing input.
44+
private static readonly Regex BtcDescriptorRegex = new(
45+
@"^(\w+)\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)(?:#[a-zA-Z0-9]+)?$",
46+
RegexOptions.Compiled);
47+
48+
// LBTC native: ct(slip77(blinding),elwpkh([fingerprint/path]xpub.../range))
49+
private static readonly Regex LbtcNativeRegex = new(
50+
@"^ct\(slip77\(([a-fA-F0-9]{64})\),elwpkh\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)\)(?:#[a-zA-Z0-9]+)?$",
51+
RegexOptions.Compiled);
52+
53+
// LBTC wrapped: ct(slip77(blinding),elsh(<innerType>([fingerprint/path]xpub.../range)))
54+
private static readonly Regex LbtcWrappedRegex = new(
55+
@"^ct\(slip77\(([a-fA-F0-9]{64})\),elsh\((\w+)\(\[([a-fA-F0-9]{8})/([^\]]+)\](xpub[^/\)]+)(/[^\)]+)?\)\)\)(?:#[a-zA-Z0-9]+)?$",
56+
RegexOptions.Compiled);
57+
58+
/// <summary>
59+
/// Parses a BTC output descriptor. On success, returns true and populates
60+
/// the out fields. On failure, returns false and sets <paramref name="error"/>.
61+
/// Derivation path is normalized to apostrophe form.
62+
/// </summary>
63+
public static bool TryParseBitcoinDescriptor(string descriptor,
64+
out string scriptType, out string fingerprint, out string derivationPath, out string xpub, out string error)
65+
{
66+
scriptType = null;
67+
fingerprint = null;
68+
derivationPath = null;
69+
xpub = null;
70+
error = null;
71+
72+
if (string.IsNullOrWhiteSpace(descriptor))
73+
{
74+
error = "Invalid BTC descriptor format - descriptor is empty.";
75+
return false;
76+
}
77+
78+
var match = BtcDescriptorRegex.Match(descriptor);
79+
if (!match.Success)
80+
{
81+
error = "Invalid BTC descriptor format - could not parse script type, fingerprint, derivation path, and xpub.";
82+
return false;
83+
}
84+
85+
scriptType = match.Groups[1].Value;
86+
fingerprint = match.Groups[2].Value;
87+
derivationPath = NormalizeDerivationPath(match.Groups[3].Value);
88+
xpub = match.Groups[4].Value;
89+
return true;
90+
}
91+
92+
/// <summary>
93+
/// Parses a Liquid output descriptor in either native form
94+
/// (ct(slip77(...),elwpkh(...))) or wrapped form
95+
/// (ct(slip77(...),elsh(wpkh(...)))). On success, returns true and
96+
/// populates the out fields including the NBXplorer suffix
97+
/// ("" for native, "-[p2sh]" for wrapped). Only wpkh is supported inside
98+
/// the elsh wrapper - other inner script types are rejected with an error.
99+
/// </summary>
100+
public static bool TryParseLiquidDescriptor(string descriptor,
101+
out string blindingKey, out string suffix, out string fingerprint,
102+
out string derivationPath, out string xpub, out string error)
103+
{
104+
blindingKey = null;
105+
suffix = null;
106+
fingerprint = null;
107+
derivationPath = null;
108+
xpub = null;
109+
error = null;
110+
111+
if (string.IsNullOrWhiteSpace(descriptor))
112+
{
113+
error = "Invalid LBTC descriptor format - descriptor is empty.";
114+
return false;
115+
}
116+
117+
var nativeMatch = LbtcNativeRegex.Match(descriptor);
118+
if (nativeMatch.Success)
119+
{
120+
blindingKey = nativeMatch.Groups[1].Value;
121+
fingerprint = nativeMatch.Groups[2].Value;
122+
derivationPath = NormalizeDerivationPath(nativeMatch.Groups[3].Value);
123+
xpub = nativeMatch.Groups[4].Value;
124+
suffix = "";
125+
return true;
126+
}
127+
128+
var wrappedMatch = LbtcWrappedRegex.Match(descriptor);
129+
if (wrappedMatch.Success)
130+
{
131+
var scriptType = wrappedMatch.Groups[2].Value;
132+
if (!string.Equals(scriptType, "wpkh", StringComparison.OrdinalIgnoreCase))
133+
{
134+
error = $"Unsupported LBTC script type: elsh({scriptType})";
135+
return false;
136+
}
137+
138+
blindingKey = wrappedMatch.Groups[1].Value;
139+
fingerprint = wrappedMatch.Groups[3].Value;
140+
derivationPath = NormalizeDerivationPath(wrappedMatch.Groups[4].Value);
141+
xpub = wrappedMatch.Groups[5].Value;
142+
suffix = "-[p2sh]";
143+
return true;
144+
}
145+
146+
error = "Invalid LBTC descriptor format - expected ct(slip77(...),elwpkh(...)) or ct(slip77(...),elsh(wpkh(...))).";
147+
return false;
148+
}
149+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Xunit;
2+
3+
// xunit by default runs different test classes in parallel. The integration
4+
// test (SamRockProtocolHappyPathTest) boots a full BTCPay ServerTester via
5+
// SharedPluginTestFixture which binds to a fixed port. Running unit-test
6+
// classes in parallel with that boot can confuse fixture-init ordering and
7+
// MVC ApplicationParts discovery on the running server. Disable parallelism
8+
// so the integration test runs in isolation after the (fast) unit tests.
9+
[assembly: CollectionBehavior(DisableTestParallelization = true)]

0 commit comments

Comments
 (0)