From 503ed10e562518291876209f12e8a3e1ce7fe88a Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Sat, 30 May 2026 18:29:28 +0200 Subject: [PATCH 1/5] feat(swaps): deterministic Boltz preimages via BIP-340 sign-and-hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the TODO at BoltzSwapsService.cs:83 ("deterministic hash somehow instead?"). Until now reverse and chain swaps generated their preimages via RandomUtils.GetBytes(32). That works for the happy path but loses the preimage on crash before SwapMetadata.Preimage is persisted, and a wallet re-imported from seed has no way to claim outstanding VHTLCs it rediscovers via Boltz /v2/swap/restore. The new scheme derives the preimage deterministically from the wallet's existing signing surface — no new abstraction: preimage = SHA-256( signer.Sign( receiver_descriptor, SHA-256("NArk-Boltz-Preimage-v1") ) ) BIP-340 Schnorr with aux_rand=null is deterministic per (key, message) so same descriptor + same wallet → same preimage. The signature reveals nothing about the key beyond the preimage itself, and the domain-separated tag prevents collision with any other use of the signing key. Pinning a "-v1" suffix in the tag leaves room to ship a "-v2" later that ALSO tries the v1 derivation on recovery, keeping pre-existing on-chain VHTLCs claimable. Plumbing: - BoltzSwapsService: the three swap-create methods (CreateReverseSwap, CreateBtcToArkSwapAsync, CreateArkToBtcSwapAsync) now take an optional byte[]? preimage parameter. null falls back to RandomUtils.GetBytes(32) so existing callers (and watch-only / no-signer scenarios) keep working unchanged. - SwapsManagementService: a single private DerivePreimageAsync helper fetches a signer for the wallet, signs the tag-hash with the descriptor key, and SHA-256s the signature. If GetSignerAsync returns null (watch-only) it falls back to a random preimage. The helper is invoked at all three Initiate*Swap call sites. - Restore path (RestoreSwaps): after ReconstructContract resolves the receiver descriptor for a reverse swap, re-derive the candidate preimage, verify SHA-256(candidate) == restored.PreimageHash, and attach it to swap.Metadata[Preimage] on match. Mismatch (legacy random preimage, or wrong descriptor matched) leaves the metadata out; EnrichReverseSwapPreimage remains the manual path. Submarine swaps are unaffected — Boltz controls that preimage and reveals it on LN payment, so there's nothing for us to derive. Watch-only wallets can still create swaps (with random preimages); they just don't get the deterministic-recovery guarantee until a signer is paired. Remote-signed wallets work the same way they sign any other message: the transport's SignAsync produces a deterministic Schnorr sig that hashes to the same preimage, so recovery works for remote wallets too without any IRemoteSignerTransport changes. The IArkadeWalletSigner / IDescriptorSigningSource surfaces stay unchanged — the derivation lives in the swap layer where it's used, and relies only on the existing Sign(descriptor, hash) API. --- NArk.Swaps/Boltz/BoltzSwapsService.cs | 14 +++-- NArk.Swaps/Services/SwapsManagementService.cs | 53 ++++++++++++++++++- docs/articles/swaps.md | 12 +++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/NArk.Swaps/Boltz/BoltzSwapsService.cs b/NArk.Swaps/Boltz/BoltzSwapsService.cs index 45721a05..0c22b307 100644 --- a/NArk.Swaps/Boltz/BoltzSwapsService.cs +++ b/NArk.Swaps/Boltz/BoltzSwapsService.cs @@ -73,6 +73,7 @@ public async Task CreateSubmarineSwap(BOLT11PaymentRequest public async Task CreateReverseSwap(CreateInvoiceParams createInvoiceRequest, OutputDescriptor receiver, + byte[]? preimage = null, CancellationToken cancellationToken = default) { var extractedReceiver = receiver.Extract(); @@ -80,9 +81,10 @@ public async Task CreateReverseSwap(CreateInvoiceParams creat // Get operator terms var operatorTerms = await clientTransport.GetServerInfoAsync(cancellationToken); - //TODO: deterministic hash somehow instead? - // Generate preimage and compute preimage hash using SHA256 for Boltz - var preimage = RandomUtils.GetBytes(32); + // Caller-supplied preimage enables deterministic derivation (so restored wallets can + // re-derive and claim outstanding swaps); null falls back to random for watch-only or + // any other no-signer scenario. + preimage ??= RandomUtils.GetBytes(32); var preimageHash = Hashes.SHA256(preimage); // First make the Boltz request to get the swap details including timeout block heights @@ -167,6 +169,7 @@ public async Task CreateReverseSwap(CreateInvoiceParams creat public async Task CreateBtcToArkSwapAsync( long amountSats, OutputDescriptor claimDescriptor, + byte[]? preimage = null, CancellationToken ct = default) { var operatorTerms = await clientTransport.GetServerInfoAsync(ct); @@ -174,7 +177,7 @@ public async Task CreateBtcToArkSwapAsync( var claimPubKeyHex = (extractedClaim.PubKey?.ToBytes() ?? extractedClaim.XOnlyPubKey.ToBytes()) .ToHexStringLower(); - var preimage = RandomUtils.GetBytes(32); + preimage ??= RandomUtils.GetBytes(32); var preimageHash = Hashes.SHA256(preimage); var ephemeralKey = new Key(); @@ -242,6 +245,7 @@ public async Task CreateBtcToArkSwapAsync( public async Task CreateArkToBtcSwapAsync( long amountSats, OutputDescriptor refundDescriptor, + byte[]? preimage = null, CancellationToken ct = default) { var operatorTerms = await clientTransport.GetServerInfoAsync(ct); @@ -252,7 +256,7 @@ public async Task CreateArkToBtcSwapAsync( ).ToLowerInvariant(); - var preimage = RandomUtils.GetBytes(32); + preimage ??= RandomUtils.GetBytes(32); var preimageHash = Hashes.SHA256(preimage); var ephemeralKey = new Key(); diff --git a/NArk.Swaps/Services/SwapsManagementService.cs b/NArk.Swaps/Services/SwapsManagementService.cs index cde1aa5c..54d0d4bf 100644 --- a/NArk.Swaps/Services/SwapsManagementService.cs +++ b/NArk.Swaps/Services/SwapsManagementService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using BTCPayServer.Lightning; using Microsoft.Extensions.Logging; using NArk.Abstractions; @@ -18,6 +20,7 @@ using NArk.Core.Transport; using NArk.Swaps.Utils; using NBitcoin; +using NBitcoin.Crypto; using NBitcoin.Scripting; using OutputDescriptorHelpers = NArk.Abstractions.Extensions.OutputDescriptorHelpers; @@ -97,6 +100,25 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI /// public IReadOnlyList Providers => _providers; + // BIP-340 sign+hash gives us a deterministic preimage rooted in the wallet's secret + // material without leaking the key (signatures reveal nothing about the key). Same + // descriptor + same wallet → same signature → same preimage, so a restored wallet that + // rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive the preimage + // and claim the VHTLC. Watch-only and remote-signer-less wallets fall through to a + // random preimage (recovery gap, but the swap still works at create time). + private static readonly uint256 PreimageSignTagHash = + new uint256(SHA256.HashData(Encoding.UTF8.GetBytes("NArk-Boltz-Preimage-v1"))); + + private async Task DerivePreimageAsync( + string walletId, OutputDescriptor descriptor, CancellationToken cancellationToken) + { + var signer = await _walletProvider.GetSignerAsync(walletId, cancellationToken); + if (signer is null) + return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from + var (_, sig) = await signer.Sign(descriptor, PreimageSignTagHash, cancellationToken); + return SHA256.HashData(sig.ToBytes()); + } + // ─── Event Routing ───────────────────────────────────────────── private void OnVtxosChanged(object? sender, ArkVtxo e) @@ -270,10 +292,12 @@ public async Task InitiateReverseSwap(string walletId, CreateInvoicePara walletId, invoiceParams.Amount); var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var destinationDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); + var preimage = await DerivePreimageAsync(walletId, destinationDescriptor, cancellationToken); var revSwap = await boltz.BoltzService.CreateReverseSwap( invoiceParams, destinationDescriptor, + preimage, cancellationToken ); await _contractService.ImportContract(walletId, revSwap.Contract, @@ -323,9 +347,10 @@ await _swapsStorage.SaveSwap( var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var claimDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); + var preimage = await DerivePreimageAsync(walletId, claimDescriptor, cancellationToken); var result = await boltz.BoltzService.CreateBtcToArkSwapAsync( - amountSats, claimDescriptor, cancellationToken); + amountSats, claimDescriptor, preimage, cancellationToken); var btcAddress = result.Swap.LockupDetails?.LockupAddress ?? throw new InvalidOperationException("Missing BTC lockup address"); @@ -435,9 +460,10 @@ await _swapsStorage.SaveSwap(walletId, var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var refundDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); + var preimage = await DerivePreimageAsync(walletId, refundDescriptor, cancellationToken); var result = await boltz.BoltzService.CreateArkToBtcSwapAsync( - amountSats, refundDescriptor, cancellationToken); + amountSats, refundDescriptor, preimage, cancellationToken); var arkLockupAddressStr = result.Swap.LockupDetails!.LockupAddress; var arkAddress = ArkAddress.Parse(arkLockupAddressStr); @@ -662,6 +688,29 @@ await _contractService.ImportContract( ContractActivityState.Active, metadata: new Dictionary { ["Source"] = $"swap:{restored.Id}" }, cancellationToken: cancellationToken); + + // Reverse swaps generated the preimage at create time via sign-and-hash of the + // receiver descriptor. On a fresh wallet that's rediscovering this swap via + // /v2/swap/restore, re-derive and attach so the sweeper can claim the VHTLC + // before it expires. Submarine swaps don't apply — Boltz controls that preimage. + if (restored.IsReverseSwap && !string.IsNullOrEmpty(restored.PreimageHash)) + { + var derived = await DerivePreimageAsync(walletId, contract.Receiver, cancellationToken); + var derivedHashHex = Convert.ToHexString(Hashes.SHA256(derived)).ToLowerInvariant(); + if (string.Equals(derivedHashHex, restored.PreimageHash, StringComparison.OrdinalIgnoreCase)) + { + swap = swap with + { + Metadata = new Dictionary(swap.Metadata ?? new()) + { + [SwapMetadata.Preimage] = Convert.ToHexString(derived).ToLowerInvariant() + } + }; + } + // Hash mismatch → this swap wasn't created with the deterministic scheme + // (legacy random preimage, or wrong descriptor was matched). Leave the + // preimage out; EnrichReverseSwapPreimage remains the manual path. + } } await _swapsStorage.SaveSwap(walletId, swap, cancellationToken); diff --git a/docs/articles/swaps.md b/docs/articles/swaps.md index 3825f90b..3a29057a 100644 --- a/docs/articles/swaps.md +++ b/docs/articles/swaps.md @@ -105,6 +105,18 @@ Typical states recorded against each `ArkSwap`: `SwapsManagementService.RestoreSwaps(walletId, ...)` rebuilds local swap state from Boltz's `/v2/swap/restore` endpoint, using wallet keys to identify owned swaps. Useful after re-importing a wallet from a mnemonic or nsec. +### Deterministic preimages (for recoverable claims) + +The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme is: + +``` +preimage = SHA-256( BIP-340-Schnorr( descriptor_key, SHA-256("NArk-Boltz-Preimage-v1") ) ) +``` + +— signing the constant tag-hash with the receiver descriptor's key. BIP-340 with `aux_rand=null` is deterministic, so the same descriptor + same wallet always produces the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback. + +Watch-only wallets (no signer) fall back to a random preimage on create — they don't get the recovery story but they can still execute swaps until they pair a signer. + ## Chain Swap Recovery (Renegotiation + Cooperative Refund) Chain swaps can fail to settle when the user funds the lockup with an amount that doesn't match Boltz's original quote, when an LN invoice times out, or when the swap window expires. The SDK handles these cases automatically inside the routine status-poll loop in `BoltzSwapProvider.PollSwapState`: From b07f8d4bbe015c275f1339dbcb05ecf0b8d783e0 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Sat, 30 May 2026 18:41:25 +0200 Subject: [PATCH 2/5] refactor(swaps): bind preimage scheme to (tag || descriptor || index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small but meaningful changes to the v1 derivation message: - Descriptor folded into the signed message in addition to the tag. Domain separation now defends against accidental signature reuse even within this codebase (a future feature that signs a descriptor-derived hash for some unrelated proof can't repurpose the same signature). The descriptor is also what would scope the preimage if the tag ever got reused outside our control. - u32 little-endian index baked into the message (always 0 today). Forward-compatibility for multi-preimage-per-descriptor flows without a scheme bump: when a caller wants two preimages from one descriptor, they pass index=1 and recovery iterates 0..N. So the v1 scheme is now: message = SHA-256( "NArk-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) sig = BIP-340-Schnorr(descriptor_key, message, aux_rand=null) preimage = SHA-256(sig) DerivePreimageAsync gains an explicit `uint index` parameter; all four call sites (three Initiate*Swap + the RestoreSwaps recovery branch) pass index=0. The restore path leaves a comment noting that a future multi-preimage variant should iterate index=0..MAX here. Also documents the determinism requirement on IRemoteSignerTransport.SignAsync: implementations MUST sign with aux_rand=null for remote-signed wallets to recover outstanding preimages from seed. The contract was implicit before — implementations that randomise (hardware wallet side-channel hardening) would silently break recovery; now it's explicit in XML doc. docs/articles/swaps.md updated to describe the bundled message construction and the remote-signer caveat. --- .../Wallets/IRemoteSignerTransport.cs | 10 ++++ NArk.Swaps/Services/SwapsManagementService.cs | 50 ++++++++++++++----- docs/articles/swaps.md | 16 ++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/NArk.Abstractions/Wallets/IRemoteSignerTransport.cs b/NArk.Abstractions/Wallets/IRemoteSignerTransport.cs index e066ff5f..cbbf0745 100644 --- a/NArk.Abstractions/Wallets/IRemoteSignerTransport.cs +++ b/NArk.Abstractions/Wallets/IRemoteSignerTransport.cs @@ -78,6 +78,16 @@ Task SignMusigAsync( /// the descriptor's private key, returning the x-only pubkey alongside the /// signature. /// + /// + /// Implementations SHOULD sign with aux_rand set to null (or all-zero) so the + /// signature is deterministic per (key, hash). The SDK relies on this for the + /// swap-preimage recovery scheme in SwapsManagementService.DerivePreimageAsync: + /// same descriptor + same wallet must produce the same signature, otherwise a restored + /// wallet that rediscovers an outstanding swap will re-derive a different preimage and + /// fail to claim the VHTLC. Implementations that randomise aux_rand (e.g. for + /// side-channel resistance on a hardware signer) break that contract, and remote-signed + /// wallets on such transports will not recover outstanding swap preimages from seed. + /// /// The wallet whose key signs. /// The descriptor identifying the signing key. /// The 32-byte hash to sign. diff --git a/NArk.Swaps/Services/SwapsManagementService.cs b/NArk.Swaps/Services/SwapsManagementService.cs index 54d0d4bf..0e67e97c 100644 --- a/NArk.Swaps/Services/SwapsManagementService.cs +++ b/NArk.Swaps/Services/SwapsManagementService.cs @@ -101,21 +101,42 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI public IReadOnlyList Providers => _providers; // BIP-340 sign+hash gives us a deterministic preimage rooted in the wallet's secret - // material without leaking the key (signatures reveal nothing about the key). Same - // descriptor + same wallet → same signature → same preimage, so a restored wallet that - // rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive the preimage - // and claim the VHTLC. Watch-only and remote-signer-less wallets fall through to a - // random preimage (recovery gap, but the swap still works at create time). - private static readonly uint256 PreimageSignTagHash = - new uint256(SHA256.HashData(Encoding.UTF8.GetBytes("NArk-Boltz-Preimage-v1"))); + // material without leaking the key (signatures reveal nothing about the key). The signed + // message bundles: + // tag: domain-separates this signature from any other use of the signing key + // (versioned so a future scheme bump can coexist on recovery) + // descriptor: scopes the preimage to the specific swap descriptor + // index: lets the caller derive multiple preimages from the same descriptor; + // always 0 today, but baked into v1 so recovery iteration is forward- + // compatible without a scheme bump + // Same (wallet, descriptor, index) → same signature → same preimage, so a restored + // wallet that rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive + // the preimage and claim the VHTLC. Watch-only and remote-signer-less wallets fall + // through to a random preimage. + // + // Local signing sources pass aux_rand=null to BIP-340, so signatures are deterministic. + // Remote-signer transports MUST honour the same convention or the preimage will rotate + // per call and recovery will silently fail — see IRemoteSignerTransport.SignAsync. + private const string PreimageTag = "NArk-Boltz-Preimage-v1"; + private static readonly byte[] PreimageTagBytes = Encoding.UTF8.GetBytes(PreimageTag); private async Task DerivePreimageAsync( - string walletId, OutputDescriptor descriptor, CancellationToken cancellationToken) + string walletId, OutputDescriptor descriptor, uint index, CancellationToken cancellationToken) { var signer = await _walletProvider.GetSignerAsync(walletId, cancellationToken); if (signer is null) return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from - var (_, sig) = await signer.Sign(descriptor, PreimageSignTagHash, cancellationToken); + + var descriptorBytes = Encoding.UTF8.GetBytes(descriptor.ToString()); + var indexBytes = BitConverter.GetBytes(index); + if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE + var message = new byte[PreimageTagBytes.Length + descriptorBytes.Length + indexBytes.Length]; + Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length); + Buffer.BlockCopy(descriptorBytes, 0, message, PreimageTagBytes.Length, descriptorBytes.Length); + Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + descriptorBytes.Length, indexBytes.Length); + var messageHash = new uint256(SHA256.HashData(message)); + + var (_, sig) = await signer.Sign(descriptor, messageHash, cancellationToken); return SHA256.HashData(sig.ToBytes()); } @@ -292,7 +313,7 @@ public async Task InitiateReverseSwap(string walletId, CreateInvoicePara walletId, invoiceParams.Amount); var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var destinationDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); - var preimage = await DerivePreimageAsync(walletId, destinationDescriptor, cancellationToken); + var preimage = await DerivePreimageAsync(walletId, destinationDescriptor, index: 0, cancellationToken); var revSwap = await boltz.BoltzService.CreateReverseSwap( invoiceParams, @@ -347,7 +368,7 @@ await _swapsStorage.SaveSwap( var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var claimDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); - var preimage = await DerivePreimageAsync(walletId, claimDescriptor, cancellationToken); + var preimage = await DerivePreimageAsync(walletId, claimDescriptor, index: 0, cancellationToken); var result = await boltz.BoltzService.CreateBtcToArkSwapAsync( amountSats, claimDescriptor, preimage, cancellationToken); @@ -460,7 +481,7 @@ await _swapsStorage.SaveSwap(walletId, var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var refundDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); - var preimage = await DerivePreimageAsync(walletId, refundDescriptor, cancellationToken); + var preimage = await DerivePreimageAsync(walletId, refundDescriptor, index: 0, cancellationToken); var result = await boltz.BoltzService.CreateArkToBtcSwapAsync( amountSats, refundDescriptor, preimage, cancellationToken); @@ -695,7 +716,10 @@ await _contractService.ImportContract( // before it expires. Submarine swaps don't apply — Boltz controls that preimage. if (restored.IsReverseSwap && !string.IsNullOrEmpty(restored.PreimageHash)) { - var derived = await DerivePreimageAsync(walletId, contract.Receiver, cancellationToken); + // index=0 is the only value used at create-time today. If a future scheme + // bump ships multi-preimage-per-descriptor, recovery should iterate + // 0..MAX_INDEX here looking for a hash match. + var derived = await DerivePreimageAsync(walletId, contract.Receiver, index: 0, cancellationToken); var derivedHashHex = Convert.ToHexString(Hashes.SHA256(derived)).ToLowerInvariant(); if (string.Equals(derivedHashHex, restored.PreimageHash, StringComparison.OrdinalIgnoreCase)) { diff --git a/docs/articles/swaps.md b/docs/articles/swaps.md index 3a29057a..acd9e558 100644 --- a/docs/articles/swaps.md +++ b/docs/articles/swaps.md @@ -107,16 +107,26 @@ Typical states recorded against each `ArkSwap`: ### Deterministic preimages (for recoverable claims) -The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme is: +The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme: ``` -preimage = SHA-256( BIP-340-Schnorr( descriptor_key, SHA-256("NArk-Boltz-Preimage-v1") ) ) +message = SHA-256( "NArk-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) +sig = BIP-340-Schnorr( descriptor_key, message, aux_rand=null ) +preimage = SHA-256( sig ) ``` -— signing the constant tag-hash with the receiver descriptor's key. BIP-340 with `aux_rand=null` is deterministic, so the same descriptor + same wallet always produces the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback. +The signed input bundles three things: + +- **Tag** — domain-separates this signature from any other use of the descriptor's key. Versioned (`-v1`) so a future scheme bump can ship as `-v2` while recovery still tries v1 for older swaps. +- **Descriptor** — scopes the preimage to the specific swap descriptor. +- **Index** — lets a caller derive multiple preimages from the same descriptor. Always `0` today; baked into v1 so recovery iteration is forward-compatible without a scheme bump. + +BIP-340 with `aux_rand=null` is deterministic per `(key, message)`, so same `(wallet, descriptor, index)` always yields the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage with `index=0`, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback. Watch-only wallets (no signer) fall back to a random preimage on create — they don't get the recovery story but they can still execute swaps until they pair a signer. +> **Remote signers and determinism.** `IRemoteSignerTransport.SignAsync` MUST use `aux_rand=null` for the recovery scheme to work end-to-end on remote-signed wallets. Implementations that randomise `aux_rand` (e.g. hardware-wallet side-channel hardening) will produce a different signature each call → different preimage → recovery silently fails. See the XML doc on `SignAsync`. Local sources (`Bip39SigningSource`, `NsecSigningSource`) already satisfy this. + ## Chain Swap Recovery (Renegotiation + Cooperative Refund) Chain swaps can fail to settle when the user funds the lockup with an amount that doesn't match Boltz's original quote, when an LN invoice times out, or when the swap window expires. The SDK handles these cases automatically inside the routine status-poll loop in `BoltzSwapProvider.PollSwapState`: From 37580c4062a47191e70ad1898aa9c0fa145df4b5 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Sat, 30 May 2026 18:58:05 +0200 Subject: [PATCH 3/5] =?UTF-8?q?refactor(swaps):=20rename=20preimage=20tag?= =?UTF-8?q?=20NArk=20=E2=86=92=20Arkade=20for=20cross-SDK=20interop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NArk is the .NET SDK's internal shorthand; using it in the on-the-wire derivation tag locks the scheme to one specific SDK implementation. The preimage is rooted in BIP-340 signing of a fixed message — there's nothing about it that's .NET-specific. Renaming the tag to "Arkade- Boltz-Preimage-v1" lets any Arkade SDK (ts-sdk, go-sdk, rust-sdk, NArk) implement the same scheme byte-for-byte and recover swaps that another SDK created, which is the entire point of having a documented spec for the message construction. Arkade is the protocol brand per CLAUDE.md; Boltz is the provider (future Loop/Submarine/etc. would have their own tag); -v1 stays as the version hook. Pre-merge: nothing has shipped under the old tag yet, so this is a free rename — no v1/v2 fallback needed. docs/articles/swaps.md updated to make the cross-SDK interop intent explicit so any future implementer knows the tag is canonical and not an SDK-specific opaque value. --- NArk.Swaps/Services/SwapsManagementService.cs | 5 ++++- docs/articles/swaps.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/NArk.Swaps/Services/SwapsManagementService.cs b/NArk.Swaps/Services/SwapsManagementService.cs index 0e67e97c..fe7ecbe6 100644 --- a/NArk.Swaps/Services/SwapsManagementService.cs +++ b/NArk.Swaps/Services/SwapsManagementService.cs @@ -117,7 +117,10 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI // Local signing sources pass aux_rand=null to BIP-340, so signatures are deterministic. // Remote-signer transports MUST honour the same convention or the preimage will rotate // per call and recovery will silently fail — see IRemoteSignerTransport.SignAsync. - private const string PreimageTag = "NArk-Boltz-Preimage-v1"; + // Tag is protocol+provider scoped (Arkade brand, Boltz provider), not SDK-scoped, so any + // Arkade SDK implementing the same scheme produces the same preimage and can recover + // swaps the .NET SDK created (and vice versa). Versioned ("-v1") for future scheme evolution. + private const string PreimageTag = "Arkade-Boltz-Preimage-v1"; private static readonly byte[] PreimageTagBytes = Encoding.UTF8.GetBytes(PreimageTag); private async Task DerivePreimageAsync( diff --git a/docs/articles/swaps.md b/docs/articles/swaps.md index acd9e558..5b401ceb 100644 --- a/docs/articles/swaps.md +++ b/docs/articles/swaps.md @@ -110,14 +110,14 @@ Typical states recorded against each `ArkSwap`: The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme: ``` -message = SHA-256( "NArk-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) +message = SHA-256( "Arkade-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) sig = BIP-340-Schnorr( descriptor_key, message, aux_rand=null ) preimage = SHA-256( sig ) ``` The signed input bundles three things: -- **Tag** — domain-separates this signature from any other use of the descriptor's key. Versioned (`-v1`) so a future scheme bump can ship as `-v2` while recovery still tries v1 for older swaps. +- **Tag** — domain-separates this signature from any other use of the descriptor's key. The string is intentionally protocol+provider scoped (`Arkade`+`Boltz`) rather than SDK-scoped, so an Arkade wallet implemented on top of any Arkade SDK (TypeScript, Go, Rust, .NET) can produce the same preimage from the same wallet material and recover swaps the .NET SDK created. Versioned (`-v1`) so a future scheme bump can ship as `-v2` while recovery still tries v1 for older swaps. - **Descriptor** — scopes the preimage to the specific swap descriptor. - **Index** — lets a caller derive multiple preimages from the same descriptor. Always `0` today; baked into v1 so recovery iteration is forward-compatible without a scheme bump. From fb6195a2e71ed6550cf4eb794138470164ce887b Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Thu, 4 Jun 2026 09:49:53 +0200 Subject: [PATCH 4/5] fix(swaps): derive preimage from the x-only pubkey, not descriptor.ToString() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- NArk.Swaps/Services/SwapsManagementService.cs | 41 +++++++---- NArk.Tests/PreimageDerivationTests.cs | 70 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 NArk.Tests/PreimageDerivationTests.cs diff --git a/NArk.Swaps/Services/SwapsManagementService.cs b/NArk.Swaps/Services/SwapsManagementService.cs index fe7ecbe6..55d42cbf 100644 --- a/NArk.Swaps/Services/SwapsManagementService.cs +++ b/NArk.Swaps/Services/SwapsManagementService.cs @@ -105,11 +105,14 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI // message bundles: // tag: domain-separates this signature from any other use of the signing key // (versioned so a future scheme bump can coexist on recovery) - // descriptor: scopes the preimage to the specific swap descriptor - // index: lets the caller derive multiple preimages from the same descriptor; - // always 0 today, but baked into v1 so recovery iteration is forward- - // compatible without a scheme bump - // Same (wallet, descriptor, index) → same signature → same preimage, so a restored + // pubkey: the descriptor's x-only public key — scopes the preimage to the swap + // key. Canonical, unlike descriptor.ToString() (which differs between a + // signing descriptor and the bare receiver descriptor a restore + // reconstructs), so create-time and restore-time derive the same value. + // index: lets the caller derive multiple preimages from the same key; always 0 + // today, but baked into v1 so recovery iteration is forward-compatible + // without a scheme bump + // Same (wallet, pubkey, index) → same signature → same preimage, so a restored // wallet that rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive // the preimage and claim the VHTLC. Watch-only and remote-signer-less wallets fall // through to a random preimage. @@ -123,6 +126,24 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI private const string PreimageTag = "Arkade-Boltz-Preimage-v1"; private static readonly byte[] PreimageTagBytes = Encoding.UTF8.GetBytes(PreimageTag); + // Builds the message that gets BIP-340-signed. Format (cross-SDK): + // PreimageTag || x-only pubkey (32B) || u32le(index). Anchoring on the canonical + // x-only key — not descriptor.ToString(), which is non-canonical and differs + // between a signing descriptor and a reconstructed bare receiver descriptor — + // keeps create-time and restore-time derivation identical and reproducible by any + // Arkade SDK. + internal static byte[] BuildPreimageMessage(OutputDescriptor descriptor, uint index) + { + var keyBytes = OutputDescriptorHelpers.Extract(descriptor).XOnlyPubKey.ToBytes(); + var indexBytes = BitConverter.GetBytes(index); + if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE + var message = new byte[PreimageTagBytes.Length + keyBytes.Length + indexBytes.Length]; + Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length); + Buffer.BlockCopy(keyBytes, 0, message, PreimageTagBytes.Length, keyBytes.Length); + Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + keyBytes.Length, indexBytes.Length); + return message; + } + private async Task DerivePreimageAsync( string walletId, OutputDescriptor descriptor, uint index, CancellationToken cancellationToken) { @@ -130,15 +151,7 @@ private async Task DerivePreimageAsync( if (signer is null) return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from - var descriptorBytes = Encoding.UTF8.GetBytes(descriptor.ToString()); - var indexBytes = BitConverter.GetBytes(index); - if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE - var message = new byte[PreimageTagBytes.Length + descriptorBytes.Length + indexBytes.Length]; - Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length); - Buffer.BlockCopy(descriptorBytes, 0, message, PreimageTagBytes.Length, descriptorBytes.Length); - Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + descriptorBytes.Length, indexBytes.Length); - var messageHash = new uint256(SHA256.HashData(message)); - + var messageHash = new uint256(SHA256.HashData(BuildPreimageMessage(descriptor, index))); var (_, sig) = await signer.Sign(descriptor, messageHash, cancellationToken); return SHA256.HashData(sig.ToBytes()); } diff --git a/NArk.Tests/PreimageDerivationTests.cs b/NArk.Tests/PreimageDerivationTests.cs new file mode 100644 index 00000000..3d5b209c --- /dev/null +++ b/NArk.Tests/PreimageDerivationTests.cs @@ -0,0 +1,70 @@ +using System.Text; +using NArk.Abstractions.Extensions; +using NArk.Swaps.Services; +using NBitcoin; +using NBitcoin.Scripting; + +namespace NArk.Tests; + +/// +/// Pins the cross-SDK deterministic-preimage message format produced by +/// : +/// PreimageTag || x-only pubkey (32B) || u32 LE index. +/// +/// Anchoring on the canonical x-only public key — not the descriptor's +/// non-canonical .ToString() — is what lets a restored wallet, which +/// rediscovers the swap via a reconstructed bare receiver descriptor, +/// re-derive the same preimage the original (HD signing) descriptor produced. +/// +[TestFixture] +public class PreimageDerivationTests +{ + private const string Tag = "Arkade-Boltz-Preimage-v1"; + + [Test] + public void Message_IsTagPlusXOnlyPubkeyPlusIndexLittleEndian() + { + var key = new Key(); + var descriptor = OutputDescriptor.Parse($"tr({key.PubKey.ToHex()})", Network.Main); + var xOnly = descriptor.Extract().XOnlyPubKey.ToBytes(); // 32 bytes + + var message = SwapsManagementService.BuildPreimageMessage(descriptor, index: 0); + + var expected = Encoding.UTF8.GetBytes(Tag) + .Concat(xOnly) + .Concat(new byte[] { 0x00, 0x00, 0x00, 0x00 }) // u32 LE, index 0 + .ToArray(); + Assert.That(message, Is.EqualTo(expected)); + } + + [Test] + public void Message_IndexEncodedAsUInt32LittleEndian() + { + var descriptor = OutputDescriptor.Parse($"tr({new Key().PubKey.ToHex()})", Network.Main); + + var message = SwapsManagementService.BuildPreimageMessage(descriptor, index: 1); + + Assert.That(message[^4..], Is.EqualTo(new byte[] { 0x01, 0x00, 0x00, 0x00 })); + } + + [Test] + public void Message_IsIndependentOfDescriptorStringForm() + { + // The real restore scenario: an HD signing descriptor (key origin + wildcard) + // at create time vs the bare receiver descriptor a restore reconstructs — same + // key, very different .ToString(). The derived message must match regardless. + var accountXpub = new ExtKey().Neuter().ToString(Network.Main); + var hdDescriptor = OutputDescriptor.Parse( + $"tr([d34db33f/86'/0'/0']{accountXpub}/0/*)", Network.Main); + var derivedXOnly = hdDescriptor.Extract().XOnlyPubKey; + var bareDescriptor = OutputDescriptor.Parse( + $"tr({Convert.ToHexString(derivedXOnly.ToBytes()).ToLowerInvariant()})", Network.Main); + + Assert.That(hdDescriptor.ToString(), Is.Not.EqualTo(bareDescriptor.ToString()), + "precondition: HD signing vs bare receiver descriptors serialise differently"); + + Assert.That( + SwapsManagementService.BuildPreimageMessage(hdDescriptor, 0), + Is.EqualTo(SwapsManagementService.BuildPreimageMessage(bareDescriptor, 0))); + } +} From f7577bae81d8eae9efb4c33e9ee2be41f0c0a54f Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Thu, 4 Jun 2026 09:51:46 +0200 Subject: [PATCH 5/5] docs(swaps): preimage scheme hashes the x-only pubkey, not the descriptor string --- docs/articles/swaps.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/articles/swaps.md b/docs/articles/swaps.md index 5b401ceb..0d896b96 100644 --- a/docs/articles/swaps.md +++ b/docs/articles/swaps.md @@ -110,7 +110,7 @@ Typical states recorded against each `ArkSwap`: The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme: ``` -message = SHA-256( "Arkade-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) +message = SHA-256( "Arkade-Boltz-Preimage-v1" || xonly_pubkey(32) || u32_le(index) ) sig = BIP-340-Schnorr( descriptor_key, message, aux_rand=null ) preimage = SHA-256( sig ) ``` @@ -118,10 +118,10 @@ preimage = SHA-256( sig ) The signed input bundles three things: - **Tag** — domain-separates this signature from any other use of the descriptor's key. The string is intentionally protocol+provider scoped (`Arkade`+`Boltz`) rather than SDK-scoped, so an Arkade wallet implemented on top of any Arkade SDK (TypeScript, Go, Rust, .NET) can produce the same preimage from the same wallet material and recover swaps the .NET SDK created. Versioned (`-v1`) so a future scheme bump can ship as `-v2` while recovery still tries v1 for older swaps. -- **Descriptor** — scopes the preimage to the specific swap descriptor. -- **Index** — lets a caller derive multiple preimages from the same descriptor. Always `0` today; baked into v1 so recovery iteration is forward-compatible without a scheme bump. +- **Public key** — the descriptor's x-only public key, *not* its string form. A descriptor stringifies differently for the same key (a signing descriptor carries key origin + derivation path + checksum; the bare receiver descriptor a restore reconstructs does not), so anchoring on the canonical pubkey keeps create-time and restore-time derivation identical — and lets any Arkade SDK reproduce it. +- **Index** — lets a caller derive multiple preimages from the same key. Always `0` today; baked into v1 so recovery iteration is forward-compatible without a scheme bump. -BIP-340 with `aux_rand=null` is deterministic per `(key, message)`, so same `(wallet, descriptor, index)` always yields the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage with `index=0`, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback. +BIP-340 with `aux_rand=null` is deterministic per `(key, message)`, so same `(wallet, pubkey, index)` always yields the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage with `index=0`, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong key) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback. Watch-only wallets (no signer) fall back to a random preimage on create — they don't get the recovery story but they can still execute swaps until they pair a signer.