Skip to content

Commit a5f17d5

Browse files
committed
fix(swaps): derive preimage from the x-only pubkey, not descriptor.ToString()
The deterministic-preimage message bundled UTF8(descriptor.ToString()), which is non-canonical: the HD signing descriptor used at swap creation and the bare receiver descriptor a wallet reconstructs at restore time stringify differently for the same key (origin/path/checksum/key form all vary). The re-derived preimage then differed from the one Boltz holds, so reverse-swap restore silently failed to recover the preimage. Anchor the message on the canonical x-only public key extracted from the descriptor instead: PreimageTag || xonly(32) || u32le(index). Create-time and restore-time now agree, and the scheme is reproducible by any Arkade SDK (a descriptor string is NBitcoin-specific; a pubkey is universal). The descriptor is still the signing-key selector passed to signer.Sign — only the hashed message changed. Scheme stays v1 (unreleased). Extracts BuildPreimageMessage as an internal pure function + tests pinning the wire format and descriptor-form independence.
1 parent b21dca2 commit a5f17d5

2 files changed

Lines changed: 97 additions & 14 deletions

File tree

NArk.Swaps/Services/SwapsManagementService.cs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,14 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI
107107
// message bundles:
108108
// tag: domain-separates this signature from any other use of the signing key
109109
// (versioned so a future scheme bump can coexist on recovery)
110-
// descriptor: scopes the preimage to the specific swap descriptor
111-
// index: lets the caller derive multiple preimages from the same descriptor;
112-
// always 0 today, but baked into v1 so recovery iteration is forward-
113-
// compatible without a scheme bump
114-
// Same (wallet, descriptor, index) → same signature → same preimage, so a restored
110+
// pubkey: the descriptor's x-only public key — scopes the preimage to the swap
111+
// key. Canonical, unlike descriptor.ToString() (which differs between a
112+
// signing descriptor and the bare receiver descriptor a restore
113+
// reconstructs), so create-time and restore-time derive the same value.
114+
// index: lets the caller derive multiple preimages from the same key; always 0
115+
// today, but baked into v1 so recovery iteration is forward-compatible
116+
// without a scheme bump
117+
// Same (wallet, pubkey, index) → same signature → same preimage, so a restored
115118
// wallet that rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive
116119
// the preimage and claim the VHTLC. Watch-only and remote-signer-less wallets fall
117120
// through to a random preimage.
@@ -125,22 +128,32 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI
125128
private const string PreimageTag = "Arkade-Boltz-Preimage-v1";
126129
private static readonly byte[] PreimageTagBytes = Encoding.UTF8.GetBytes(PreimageTag);
127130

131+
// Builds the message that gets BIP-340-signed. Format (cross-SDK):
132+
// PreimageTag || x-only pubkey (32B) || u32le(index). Anchoring on the canonical
133+
// x-only key — not descriptor.ToString(), which is non-canonical and differs
134+
// between a signing descriptor and a reconstructed bare receiver descriptor —
135+
// keeps create-time and restore-time derivation identical and reproducible by any
136+
// Arkade SDK.
137+
internal static byte[] BuildPreimageMessage(OutputDescriptor descriptor, uint index)
138+
{
139+
var keyBytes = OutputDescriptorHelpers.Extract(descriptor).XOnlyPubKey.ToBytes();
140+
var indexBytes = BitConverter.GetBytes(index);
141+
if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE
142+
var message = new byte[PreimageTagBytes.Length + keyBytes.Length + indexBytes.Length];
143+
Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length);
144+
Buffer.BlockCopy(keyBytes, 0, message, PreimageTagBytes.Length, keyBytes.Length);
145+
Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + keyBytes.Length, indexBytes.Length);
146+
return message;
147+
}
148+
128149
private async Task<byte[]> DerivePreimageAsync(
129150
string walletId, OutputDescriptor descriptor, uint index, CancellationToken cancellationToken)
130151
{
131152
var signer = await _walletProvider.GetSignerAsync(walletId, cancellationToken);
132153
if (signer is null)
133154
return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from
134155

135-
var descriptorBytes = Encoding.UTF8.GetBytes(descriptor.ToString());
136-
var indexBytes = BitConverter.GetBytes(index);
137-
if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE
138-
var message = new byte[PreimageTagBytes.Length + descriptorBytes.Length + indexBytes.Length];
139-
Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length);
140-
Buffer.BlockCopy(descriptorBytes, 0, message, PreimageTagBytes.Length, descriptorBytes.Length);
141-
Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + descriptorBytes.Length, indexBytes.Length);
142-
var messageHash = new uint256(SHA256.HashData(message));
143-
156+
var messageHash = new uint256(SHA256.HashData(BuildPreimageMessage(descriptor, index)));
144157
var (_, sig) = await signer.Sign(descriptor, messageHash, cancellationToken);
145158
return SHA256.HashData(sig.ToBytes());
146159
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Text;
2+
using NArk.Abstractions.Extensions;
3+
using NArk.Swaps.Services;
4+
using NBitcoin;
5+
using NBitcoin.Scripting;
6+
7+
namespace NArk.Tests;
8+
9+
/// <summary>
10+
/// Pins the cross-SDK deterministic-preimage message format produced by
11+
/// <see cref="SwapsManagementService.BuildPreimageMessage"/>:
12+
/// <c>PreimageTag || x-only pubkey (32B) || u32 LE index</c>.
13+
///
14+
/// Anchoring on the canonical x-only public key — not the descriptor's
15+
/// non-canonical <c>.ToString()</c> — is what lets a restored wallet, which
16+
/// rediscovers the swap via a <em>reconstructed</em> bare receiver descriptor,
17+
/// re-derive the same preimage the original (HD signing) descriptor produced.
18+
/// </summary>
19+
[TestFixture]
20+
public class PreimageDerivationTests
21+
{
22+
private const string Tag = "Arkade-Boltz-Preimage-v1";
23+
24+
[Test]
25+
public void Message_IsTagPlusXOnlyPubkeyPlusIndexLittleEndian()
26+
{
27+
var key = new Key();
28+
var descriptor = OutputDescriptor.Parse($"tr({key.PubKey.ToHex()})", Network.Main);
29+
var xOnly = descriptor.Extract().XOnlyPubKey.ToBytes(); // 32 bytes
30+
31+
var message = SwapsManagementService.BuildPreimageMessage(descriptor, index: 0);
32+
33+
var expected = Encoding.UTF8.GetBytes(Tag)
34+
.Concat(xOnly)
35+
.Concat(new byte[] { 0x00, 0x00, 0x00, 0x00 }) // u32 LE, index 0
36+
.ToArray();
37+
Assert.That(message, Is.EqualTo(expected));
38+
}
39+
40+
[Test]
41+
public void Message_IndexEncodedAsUInt32LittleEndian()
42+
{
43+
var descriptor = OutputDescriptor.Parse($"tr({new Key().PubKey.ToHex()})", Network.Main);
44+
45+
var message = SwapsManagementService.BuildPreimageMessage(descriptor, index: 1);
46+
47+
Assert.That(message[^4..], Is.EqualTo(new byte[] { 0x01, 0x00, 0x00, 0x00 }));
48+
}
49+
50+
[Test]
51+
public void Message_IsIndependentOfDescriptorStringForm()
52+
{
53+
// The real restore scenario: an HD signing descriptor (key origin + wildcard)
54+
// at create time vs the bare receiver descriptor a restore reconstructs — same
55+
// key, very different .ToString(). The derived message must match regardless.
56+
var accountXpub = new ExtKey().Neuter().ToString(Network.Main);
57+
var hdDescriptor = OutputDescriptor.Parse(
58+
$"tr([d34db33f/86'/0'/0']{accountXpub}/0/*)", Network.Main);
59+
var derivedXOnly = hdDescriptor.Extract().XOnlyPubKey;
60+
var bareDescriptor = OutputDescriptor.Parse(
61+
$"tr({Convert.ToHexString(derivedXOnly.ToBytes()).ToLowerInvariant()})", Network.Main);
62+
63+
Assert.That(hdDescriptor.ToString(), Is.Not.EqualTo(bareDescriptor.ToString()),
64+
"precondition: HD signing vs bare receiver descriptors serialise differently");
65+
66+
Assert.That(
67+
SwapsManagementService.BuildPreimageMessage(hdDescriptor, 0),
68+
Is.EqualTo(SwapsManagementService.BuildPreimageMessage(bareDescriptor, 0)));
69+
}
70+
}

0 commit comments

Comments
 (0)