Swaps (Lightning & On-Chain Bridge)
The SDK integrates with Boltz Exchange for trustless submarine and chain swaps.
Setup
// Via Generic Host
builder.AddArk()
.EnableSwaps()
// or with a custom Boltz endpoint:
.OnCustomBoltz("https://api.boltz.exchange", websocketUrl: null);
// Via IServiceCollection
services.AddArkSwapServices();
services.AddHttpClient<BoltzClient>();
Boltz URIs are configured as part of ArkNetworkConfig, not as a separate options class. To override the default, pass a custom ArkNetworkConfig to AddArkNetwork.
All swap orchestration — creation, state tracking, cooperative signing, and refunds — goes through SwapsManagementService.
Submarine Swap (Arkade → Lightning)
Pay a Lightning invoice from your Arkade wallet:
var invoice = BOLT11PaymentRequest.Parse(bolt11String, network);
var swapId = await swapsManagement.InitiateSubmarineSwap(
walletId,
invoice,
autoPay: true, // pay the HTLC immediately from wallet VTXOs
cancellationToken);
If you need to defer the HTLC payment (e.g., show the user a quote first), pass autoPay: false and then call PayExistingSubmarineSwap(walletId, swapId, ...) once confirmed.
Reverse Swap (Lightning → Arkade)
Receive a Lightning payment as a VTXO:
var swapId = await swapsManagement.InitiateReverseSwap(
walletId,
new CreateInvoiceParams
{
AmountSats = 50_000,
Description = "Top up",
},
cancellationToken);
// The user pays the BOLT11 invoice that Boltz provides on the swap.
// The SDK watches the swap state and materializes the VTXO automatically.
Chain Swap (BTC ↔ Arkade)
BTC → Arkade (receive on-chain Bitcoin into an Arkade VTXO):
var (btcAddress, swapId, expectedLockupSats) =
await swapsManagement.InitiateBtcToArkChainSwap(
walletId,
amountSats: 100_000,
cancellationToken);
// Send `expectedLockupSats` to `btcAddress`. The SDK handles the
// cooperative MuSig2 cross-sign once Boltz broadcasts the claim.
Arkade → BTC (withdraw to on-chain Bitcoin):
var swapId = await swapsManagement.InitiateArkToBtcChainSwap(
walletId,
btcAddress: "bc1q...",
amountSats: 100_000,
cancellationToken);
Swap Lifecycle
SwapsManagementService runs as a hosted background service. It:
- Maintains a websocket subscription to Boltz for active swaps
- Polls swap state periodically as a failsafe
- Monitors VTXO events to detect settlement from the Arkade side
- Performs cooperative MuSig2 signing (chain-swap cross-sign, refund co-sign)
- Records state transitions in
ISwapStorage
Typical states recorded against each ArkSwap:
| State | Meaning |
|---|---|
pending |
Waiting for user/counterparty action |
active |
Boltz has accepted the swap, preimage lock is live |
completed |
Swap finalized |
failed |
Timed out or refunded |
Startup Behavior
SwapsManagementService.StartAsync does not block on the Ark server — if arkd is unreachable it defers initialization to a background retry loop, so hosts start up even when arkd is locked/syncing. Swap operations that depend on server info will queue until readiness is achieved.
Restore After Data Loss
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-v2while 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
0today; 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.SignAsyncMUST useaux_rand=nullfor the recovery scheme to work end-to-end on remote-signed wallets. Implementations that randomiseaux_rand(e.g. hardware-wallet side-channel hardening) will produce a different signature each call → different preimage → recovery silently fails. See the XML doc onSignAsync. 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:
| Boltz status | SDK behaviour |
|---|---|
transaction.lockupFailed |
Calls GET /v2/swap/chain/{id}/quote to obtain a renegotiated amount, accepts via POST if Boltz allows it, updates the local ArkSwap.ExpectedAmount, and lets the swap proceed. Falls through to refund on refusal. |
swap.expired, transaction.failed, transaction.refunded |
Triggers cooperative refund: BTC→ARK refunds the BTC lockup via MuSig2 with Boltz; ARK→BTC refunds the Ark VHTLC via Boltz's POST /v2/swap/chain/{id}/refund/ark. Marks the swap Refunded on success. |
swap.expired with no funds |
If the lockup was never funded, the swap is marked Failed with no recovery work needed. |
No manual call required — the same routine poll that drives the happy-path claim flow drives recovery. Hosts can subscribe to ISwapStorage.SwapsChanged to observe the resulting state transitions (Refunded, Failed).
Inspecting Recovery State
When you want to surface a "recovery available" indicator in a wallet UI without committing to a recovery transaction, use the read-only inspection helpers:
// Single swap — refreshes VTXOs from arkd before reporting
var info = await swapMgr.InspectSwapRecoveryAsync(walletId, swapId);
if (info.Status == SwapRecoveryStatus.Recoverable)
{
Console.WriteLine($"Swap {info.SwapId} has {info.AmountSats} sats stranded — recovery will run automatically.");
}
// Bulk audit — useful after a wallet restore
var report = await swapMgr.ScanRecoverableSwapsAsync(walletId);
foreach (var entry in report.Where(r => r.Status == SwapRecoveryStatus.Recoverable))
{
Console.WriteLine($" {entry.SwapId}: {entry.AmountSats} sats at {entry.VtxoCount} vtxo(s)");
}
SwapRecoveryStatus values: SwapNotFound, StillPending, AlreadySettled, AlreadyRefunded, NoFunds, Recoverable, InspectionError. These methods are side-effect-free — recovery itself happens automatically inside the provider's poll loop.