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/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..55d42cbf 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,62 @@ 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). 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) + // 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. + // + // 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. + // 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); + + // 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) + { + var signer = await _walletProvider.GetSignerAsync(walletId, cancellationToken); + if (signer is null) + return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from + + var messageHash = new uint256(SHA256.HashData(BuildPreimageMessage(descriptor, index))); + var (_, sig) = await signer.Sign(descriptor, messageHash, cancellationToken); + return SHA256.HashData(sig.ToBytes()); + } + // ─── Event Routing ───────────────────────────────────────────── private void OnVtxosChanged(object? sender, ArkVtxo e) @@ -270,10 +329,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, index: 0, cancellationToken); var revSwap = await boltz.BoltzService.CreateReverseSwap( invoiceParams, destinationDescriptor, + preimage, cancellationToken ); await _contractService.ImportContract(walletId, revSwap.Contract, @@ -323,9 +384,10 @@ await _swapsStorage.SaveSwap( var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var claimDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); + var preimage = await DerivePreimageAsync(walletId, claimDescriptor, index: 0, 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 +497,10 @@ await _swapsStorage.SaveSwap(walletId, var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken); var refundDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken); + var preimage = await DerivePreimageAsync(walletId, refundDescriptor, index: 0, 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 +725,32 @@ 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)) + { + // 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)) + { + 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/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))); + } +} diff --git a/docs/articles/swaps.md b/docs/articles/swaps.md index 3825f90b..0d896b96 100644 --- a/docs/articles/swaps.md +++ b/docs/articles/swaps.md @@ -105,6 +105,28 @@ 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: + +``` +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 ) +``` + +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. +- **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, 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. + +> **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`: