Skip to content
Merged
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
10 changes: 10 additions & 0 deletions NArk.Abstractions/Wallets/IRemoteSignerTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ Task<MusigPartialSignature> SignMusigAsync(
/// the descriptor's private key, returning the x-only pubkey alongside the
/// signature.
/// </summary>
/// <remarks>
/// Implementations <b>SHOULD</b> sign with <c>aux_rand</c> set to null (or all-zero) so the
/// signature is deterministic per <c>(key, hash)</c>. The SDK relies on this for the
/// swap-preimage recovery scheme in <c>SwapsManagementService.DerivePreimageAsync</c>:
/// 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 <c>aux_rand</c> (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.
/// </remarks>
/// <param name="walletId">The wallet whose key signs.</param>
/// <param name="descriptor">The descriptor identifying the signing key.</param>
/// <param name="hash">The 32-byte hash to sign.</param>
Expand Down
14 changes: 9 additions & 5 deletions NArk.Swaps/Boltz/BoltzSwapsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,18 @@ public async Task<SubmarineSwapResult> CreateSubmarineSwap(BOLT11PaymentRequest

public async Task<ReverseSwapResult> CreateReverseSwap(CreateInvoiceParams createInvoiceRequest,
OutputDescriptor receiver,
byte[]? preimage = null,
CancellationToken cancellationToken = default)
{
var extractedReceiver = receiver.Extract();

// 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
Expand Down Expand Up @@ -167,14 +169,15 @@ public async Task<ReverseSwapResult> CreateReverseSwap(CreateInvoiceParams creat
public async Task<ChainSwapResult> CreateBtcToArkSwapAsync(
long amountSats,
OutputDescriptor claimDescriptor,
byte[]? preimage = null,
CancellationToken ct = default)
{
var operatorTerms = await clientTransport.GetServerInfoAsync(ct);
var extractedClaim = claimDescriptor.Extract();
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();

Expand Down Expand Up @@ -242,6 +245,7 @@ public async Task<ChainSwapResult> CreateBtcToArkSwapAsync(
public async Task<ChainSwapResult> CreateArkToBtcSwapAsync(
long amountSats,
OutputDescriptor refundDescriptor,
byte[]? preimage = null,
CancellationToken ct = default)
{
var operatorTerms = await clientTransport.GetServerInfoAsync(ct);
Expand All @@ -252,7 +256,7 @@ public async Task<ChainSwapResult> CreateArkToBtcSwapAsync(
).ToLowerInvariant();


var preimage = RandomUtils.GetBytes(32);
preimage ??= RandomUtils.GetBytes(32);
var preimageHash = Hashes.SHA256(preimage);
var ephemeralKey = new Key();

Expand Down
93 changes: 91 additions & 2 deletions NArk.Swaps/Services/SwapsManagementService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using BTCPayServer.Lightning;
using Microsoft.Extensions.Logging;
using NArk.Abstractions;
Expand All @@ -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;

Expand Down Expand Up @@ -97,6 +100,62 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI
/// </summary>
public IReadOnlyList<ISwapProvider> 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<byte[]> 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)
Expand Down Expand Up @@ -270,10 +329,12 @@ public async Task<string> 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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -662,6 +725,32 @@ await _contractService.ImportContract(
ContractActivityState.Active,
metadata: new Dictionary<string, string> { ["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<string, string>(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);
Expand Down
70 changes: 70 additions & 0 deletions NArk.Tests/PreimageDerivationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Text;
using NArk.Abstractions.Extensions;
using NArk.Swaps.Services;
using NBitcoin;
using NBitcoin.Scripting;

namespace NArk.Tests;

/// <summary>
/// Pins the cross-SDK deterministic-preimage message format produced by
/// <see cref="SwapsManagementService.BuildPreimageMessage"/>:
/// <c>PreimageTag || x-only pubkey (32B) || u32 LE index</c>.
///
/// Anchoring on the canonical x-only public key — not the descriptor's
/// non-canonical <c>.ToString()</c> — is what lets a restored wallet, which
/// rediscovers the swap via a <em>reconstructed</em> bare receiver descriptor,
/// re-derive the same preimage the original (HD signing) descriptor produced.
/// </summary>
[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)));
}
}
22 changes: 22 additions & 0 deletions docs/articles/swaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
Loading