Skip to content

Commit 5cdd241

Browse files
committed
feat(swaps): add bolt12 submarine swap to SwapsManagemntService,
E2E tests
1 parent 6e40f93 commit 5cdd241

5 files changed

Lines changed: 280 additions & 5 deletions

File tree

NArk.Swaps/Boltz/BoltzSwapsService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public async Task<SubmarineSwapResult> CreateSubmarineSwap(BOLT11PaymentRequest
6767
throw new Exception(
6868
$"Address mismatch! Expected {address.ToString(operatorTerms.Network.ChainName == ChainName.Mainnet)} got {response.Address}");
6969

70-
return new SubmarineSwapResult(vhtlcContract, response, address);
70+
return new SubmarineSwapResult(vhtlcContract, response, address, invoice.ToString());
7171
}
7272

7373
/// <summary>
@@ -149,7 +149,7 @@ public async Task<SubmarineSwapResult> CreateSubmarineSwapBolt12(
149149
$"Address mismatch — expected {address.ToString(operatorTerms.Network.ChainName == ChainName.Mainnet)}, " +
150150
$"Boltz returned {response.Address}");
151151

152-
return new SubmarineSwapResult(vhtlcContract, response, address);
152+
return new SubmarineSwapResult(vhtlcContract, response, address, fetchResponse.Invoice);
153153
}
154154

155155
// Reverse Swaps

NArk.Swaps/Boltz/Models/SubmarineSwapResult.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,20 @@
44

55
namespace NArk.Swaps.Boltz.Models;
66

7-
public record SubmarineSwapResult(VHTLCContract Contract, SubmarineResponse Swap, ArkAddress Address);
7+
/// <summary>
8+
/// Result of creating an Arkade submarine swap (Arkade → Lightning) via Boltz.
9+
/// </summary>
10+
/// <param name="Contract">VHTLC contract that the sender must fund.</param>
11+
/// <param name="Swap">Raw response from the Boltz API.</param>
12+
/// <param name="Address">Arkade lockup address derived from the VHTLC contract.</param>
13+
/// <param name="Invoice">
14+
/// The Lightning invoice string to be paid by Boltz once the VHTLC is funded.
15+
/// For BOLT 11 swaps this echoes the invoice supplied by the caller.
16+
/// For BOLT 12 swaps this is the invoice fetched from the offer by Boltz.
17+
/// <c>null</c> when not applicable (e.g. older call sites that predate BOLT 12 support).
18+
/// </param>
19+
public record SubmarineSwapResult(
20+
VHTLCContract Contract,
21+
SubmarineResponse Swap,
22+
ArkAddress Address,
23+
string? Invoice = null);

NArk.Swaps/Services/SwapsManagementService.cs

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212
using NArk.Core.Extensions;
1313
using NArk.Core.Services;
1414
using NArk.Swaps.Abstractions;
15+
using NArk.Swaps.Bolt12;
1516
using NArk.Swaps.Boltz;
16-
using NArk.Swaps.Boltz.Client;
17-
using NArk.Swaps.Boltz.Models;
1817
using NArk.Swaps.Boltz.Models.Restore;
1918
using NArk.Swaps.Models;
2019
using NArk.Core.Transport;
@@ -234,6 +233,127 @@ await _swapsStorage.SaveSwap(
234233
}
235234
}
236235

236+
/// <summary>
237+
/// Initiates an Arkade → Lightning submarine swap where the destination is a
238+
/// BOLT 12 offer (<c>lno1…</c>) rather than a BOLT 11 invoice.
239+
/// </summary>
240+
/// <remarks>
241+
/// Boltz is asked to fetch a BOLT 12 invoice from the offer first; the resulting
242+
/// invoice is then used to create the submarine swap in the same way as the
243+
/// standard BOLT 11 path (<see cref="InitiateSubmarineSwap"/>).
244+
///
245+
/// The swap is stored with <see cref="ArkSwapType.Submarine"/> and the fetched
246+
/// BOLT 12 invoice string in the <c>Invoice</c> field.
247+
///
248+
/// When <paramref name="autoPay"/> is <c>true</c> (default) the method funds the
249+
/// VHTLC immediately and returns the Arkade batch transaction ID.
250+
/// When <c>false</c> it saves the swap and returns the Boltz swap ID so the caller
251+
/// can fund it later via <see cref="PayExistingSubmarineSwap"/>.
252+
/// </remarks>
253+
/// <param name="walletId">Identifier of the Arkade wallet that will pay.</param>
254+
/// <param name="bolt12Offer">A BOLT 12 offer string (<c>lno1…</c>).</param>
255+
/// <param name="amountSats">Amount to pay in satoshis.</param>
256+
/// <param name="autoPay">
257+
/// When <c>true</c> the VHTLC is funded immediately; returns the batch tx ID.
258+
/// When <c>false</c> returns the Boltz swap ID for deferred payment.
259+
/// </param>
260+
/// <param name="currency">Lightning currency forwarded to Boltz, defaults to <c>"BTC"</c>.</param>
261+
/// <param name="cancellationToken">Cancellation token.</param>
262+
/// <returns>
263+
/// The Arkade batch transaction ID (when <paramref name="autoPay"/> is <c>true</c>)
264+
/// or the Boltz swap ID (when <c>false</c>).
265+
/// </returns>
266+
public async Task<string> InitiateSubmarineSwapBolt12(
267+
string walletId,
268+
string bolt12Offer,
269+
long amountSats,
270+
bool autoPay = true,
271+
string currency = "BTC",
272+
CancellationToken cancellationToken = default)
273+
{
274+
using var _walletScope = _logger?.BeginScope(("WalletId", walletId));
275+
var boltz = GetBoltzProvider();
276+
277+
_logger?.LogInformation(
278+
"Initiating BOLT 12 submarine swap for wallet {WalletId}, amount={Amount}, autoPay={AutoPay}",
279+
walletId, amountSats, autoPay);
280+
281+
var serverInfo = await _clientTransport.GetServerInfoAsync(cancellationToken);
282+
var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken);
283+
var swap = await boltz.BoltzService.CreateSubmarineSwapBolt12(
284+
bolt12Offer,
285+
amountSats,
286+
await addressProvider!.GetNextSigningDescriptor(cancellationToken),
287+
currency,
288+
cancellationToken);
289+
290+
var invoiceStr = swap.Invoice
291+
?? throw new InvalidOperationException("BOLT 12 submarine swap did not return an invoice string");
292+
293+
var paymentHashBytes = Bolt12InvoiceParser.ExtractPaymentHash(invoiceStr);
294+
var paymentHashHex = Convert.ToHexString(paymentHashBytes).ToLowerInvariant();
295+
296+
await _contractService.ImportContract(walletId, swap.Contract,
297+
ContractActivityState.AwaitingFundsBeforeDeactivate,
298+
metadata: new Dictionary<string, string> { ["Source"] = $"swap:{swap.Swap.Id}" },
299+
cancellationToken: cancellationToken);
300+
301+
var isMainnet = serverInfo.Network.ChainName == ChainName.Mainnet;
302+
await _swapsStorage.SaveSwap(
303+
walletId,
304+
new ArkSwap(
305+
swap.Swap.Id,
306+
walletId,
307+
ArkSwapType.Submarine,
308+
invoiceStr,
309+
swap.Swap.ExpectedAmount,
310+
swap.Contract.GetArkAddress().ScriptPubKey.ToHex(),
311+
swap.Address.ToString(isMainnet),
312+
ArkSwapStatus.Pending,
313+
null,
314+
DateTimeOffset.UtcNow,
315+
DateTimeOffset.UtcNow,
316+
paymentHashHex
317+
)
318+
{
319+
ProviderId = BoltzSwapProvider.Id,
320+
Route = new SwapRoute(SwapAsset.ArkBtc, SwapAsset.BtcLightning)
321+
}, cancellationToken);
322+
323+
try
324+
{
325+
return autoPay
326+
? (await _spendingService.Spend(walletId,
327+
[new ArkTxOut(ArkTxOutType.Vtxo, swap.Swap.ExpectedAmount, swap.Address)],
328+
cancellationToken)).ToString()
329+
: swap.Swap.Id;
330+
}
331+
catch (Exception e)
332+
{
333+
await _swapsStorage.SaveSwap(
334+
walletId,
335+
new ArkSwap(
336+
swap.Swap.Id,
337+
walletId,
338+
ArkSwapType.Submarine,
339+
invoiceStr,
340+
swap.Swap.ExpectedAmount,
341+
swap.Contract.GetArkAddress().ScriptPubKey.ToHex(),
342+
swap.Address.ToString(isMainnet),
343+
ArkSwapStatus.Failed,
344+
e.ToString(),
345+
DateTimeOffset.UtcNow,
346+
DateTimeOffset.UtcNow,
347+
paymentHashHex
348+
)
349+
{
350+
ProviderId = BoltzSwapProvider.Id,
351+
Route = new SwapRoute(SwapAsset.ArkBtc, SwapAsset.BtcLightning)
352+
}, cancellationToken);
353+
throw;
354+
}
355+
}
356+
237357
public async Task<uint256> PayExistingSubmarineSwap(string walletId, string swapId,
238358
CancellationToken cancellationToken = default)
239359
{

NArk.Tests.End2End/Common/DockerHelper.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,37 @@ public static async Task<string> BitcoinGetNewAddress(CancellationToken ct = def
169169
$"bitcoin-cli getnewaddress failed (exit={result.ExitCode}): {result.StandardError.Trim()}");
170170
return result.StandardOutput.Trim();
171171
}
172+
173+
/// <summary>
174+
/// Creates a BOLT 12 offer on the Core Lightning (<c>cln</c>) regtest container.
175+
/// Returns the <c>lno1…</c> offer string.
176+
/// </summary>
177+
/// <remarks>
178+
/// Requires a <c>cln</c> Docker container running Core Lightning with
179+
/// <c>lightning-cli</c> available at <c>/usr/local/bin/lightning-cli</c>.
180+
/// The regtest docker-compose stack must include CLN alongside LND for
181+
/// BOLT 12 end-to-end tests to pass.
182+
/// </remarks>
183+
/// <param name="amountSats">Amount in satoshis embedded in the offer; use <c>0</c> for an any-amount offer.</param>
184+
/// <param name="description">Short human-readable description included in the offer.</param>
185+
/// <param name="ct">Cancellation token.</param>
186+
/// <returns>The BOLT 12 offer string (<c>lno1…</c>).</returns>
187+
public static async Task<string> CreateClnBolt12Offer(
188+
long amountSats = 10000,
189+
string description = "test offer",
190+
CancellationToken ct = default)
191+
{
192+
var amountArg = amountSats > 0
193+
? $"{amountSats * 1000}msat" // CLN uses millisatoshis
194+
: "any";
195+
196+
var output = await Exec("cln",
197+
["lightning-cli", "--network=regtest", "offer", amountArg, description], ct);
198+
199+
var offer = JsonSerializer.Deserialize<JsonObject>(output)?["bolt12"]
200+
?.GetValue<string>()
201+
?? throw new InvalidOperationException(
202+
$"BOLT 12 offer creation on CLN failed. Output: {output}");
203+
return offer.Trim();
204+
}
172205
}

NArk.Tests.End2End/SwapManagementServiceTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,4 +752,110 @@ public async Task CanRestoreSwapsFromBoltz()
752752
var swapsAfterRestore = await swapStorage.GetSwaps(walletIds: [testingPrerequisite.walletIdentifier]);
753753
Assert.That(swapsAfterRestore, Has.Count.GreaterThanOrEqualTo(1));
754754
}
755+
756+
// BOLT 12 submarine swap test.
757+
//
758+
// Nigiri ships a CLN container so the offer can be generated with:
759+
// docker exec cln lightning-cli --network=regtest offer any "nark bolt12 e2e"
760+
//
761+
// Pass the returned `bolt12` value via BOLT12_OFFER when running this test:
762+
// BOLT12_OFFER=lno1... dotnet test --filter CanPayBolt12OfferWithArkadeUsingBoltz
763+
//
764+
// The test is [Explicit] because Boltz must also be configured to use a BOLT12-capable
765+
// Lightning backend (CLN or LDK). The default regtest stack connects Boltz only to LND,
766+
// which does not support BOLT12.
767+
private const string Bolt12OfferEnv = "BOLT12_OFFER";
768+
private const long Bolt12TestAmountSats = 2_000;
769+
770+
[Test]
771+
[Explicit("Requires BOLT12_OFFER env var and Boltz configured with a BOLT12-capable Lightning backend (CLN/LDK)")]
772+
[Category("Bolt12")]
773+
public async Task CanPayBolt12OfferWithArkadeUsingBoltz()
774+
{
775+
var offer = Environment.GetEnvironmentVariable(Bolt12OfferEnv)
776+
?? await DockerHelper.CreateClnBolt12Offer(amountSats: 0, description: "nark bolt12 e2e");
777+
778+
var testingPrerequisite = await FundedWalletHelper.GetFundedWallet();
779+
var swapStorage = TestStorage.CreateSwapStorage();
780+
var boltzClient = new BoltzClient(new HttpClient(),
781+
new OptionsWrapper<BoltzClientOptions>(new BoltzClientOptions
782+
{
783+
BoltzUrl = SharedSwapInfrastructure.BoltzEndpoint.ToString(),
784+
WebsocketUrl = SharedSwapInfrastructure.BoltzWsEndpoint.ToString()
785+
}));
786+
var intentStorage = TestStorage.CreateIntentStorage();
787+
var chainTimeProvider = new NBXplorerBlockchain(Network.RegTest, SharedArkInfrastructure.NbxplorerEndpoint);
788+
var coinService = new CoinService(
789+
testingPrerequisite.clientTransport,
790+
testingPrerequisite.contracts,
791+
[
792+
new PaymentContractTransformer(testingPrerequisite.walletProvider),
793+
new HashLockedContractTransformer(testingPrerequisite.walletProvider)
794+
]);
795+
var spendingService = new SpendingService(
796+
testingPrerequisite.vtxoStorage,
797+
testingPrerequisite.contracts,
798+
testingPrerequisite.walletProvider,
799+
coinService,
800+
testingPrerequisite.contractService,
801+
testingPrerequisite.clientTransport,
802+
new DefaultCoinSelector(),
803+
testingPrerequisite.safetyService,
804+
intentStorage);
805+
var boltzProvider = new BoltzSwapProvider(
806+
boltzClient,
807+
new BoltzLimitsValidator(new CachedBoltzClient(
808+
new HttpClient(),
809+
new OptionsWrapper<BoltzClientOptions>(new BoltzClientOptions
810+
{
811+
BoltzUrl = SharedSwapInfrastructure.BoltzEndpoint.ToString(),
812+
WebsocketUrl = SharedSwapInfrastructure.BoltzWsEndpoint.ToString()
813+
}))),
814+
testingPrerequisite.clientTransport,
815+
testingPrerequisite.vtxoStorage,
816+
testingPrerequisite.walletProvider,
817+
swapStorage,
818+
testingPrerequisite.contractService,
819+
testingPrerequisite.contracts,
820+
testingPrerequisite.safetyService,
821+
spendingService,
822+
intentStorage,
823+
chainTimeProvider);
824+
825+
await using var swapMgr = new SwapsManagementService(
826+
new ISwapProvider[] { boltzProvider },
827+
spendingService,
828+
testingPrerequisite.clientTransport,
829+
testingPrerequisite.vtxoStorage,
830+
testingPrerequisite.walletProvider,
831+
swapStorage,
832+
testingPrerequisite.contractService,
833+
testingPrerequisite.contracts,
834+
testingPrerequisite.safetyService,
835+
intentStorage,
836+
chainTimeProvider);
837+
838+
var settledSwapTcs = new TaskCompletionSource();
839+
swapStorage.SwapsChanged += (_, swap) =>
840+
{
841+
if (swap.Status == ArkSwapStatus.Settled)
842+
settledSwapTcs.TrySetResult();
843+
};
844+
845+
await swapMgr.StartAsync(CancellationToken.None);
846+
847+
await swapMgr.InitiateSubmarineSwapBolt12(
848+
testingPrerequisite.walletIdentifier,
849+
offer,
850+
amountSats: Bolt12TestAmountSats,
851+
autoPay: true,
852+
cancellationToken: CancellationToken.None);
853+
854+
await settledSwapTcs.Task.WaitAsync(TimeSpan.FromMinutes(2));
855+
856+
var swaps = (await swapStorage.GetSwaps(walletIds: [testingPrerequisite.walletIdentifier])).ToList();
857+
Assert.That(swaps, Has.Count.EqualTo(1));
858+
Assert.That(swaps[0].Status, Is.EqualTo(ArkSwapStatus.Settled));
859+
Assert.That(swaps[0].Invoice, Does.StartWith("lni"));
860+
}
755861
}

0 commit comments

Comments
 (0)