Skip to main content

Lightning → Smart Chain

Swap Bitcoin Lightning to Starknet or EVM tokens. These swaps are based on an LP-initiated HTLC on the destination chain. After the LP receives the Lightning payment, it creates the HTLC and the user broadcasts the payment secret over Nostr so watchtowers can claim on the user's behalf. The user can always self-claim as a fallback.

Looking for Solana?

Solana uses a different (legacy) swap protocol. See Lightning to Solana.

Executing the Swap

Here is a full flow for creating and executing the swap in the Lightning → Smart chain direction via the execute() helper function. The flow is the same for all the cases, with the only difference being how the Lightning payment is sourced.

Uses the FromBTCLNAutoSwap swap class.

Pass a MinimalLightningNetworkWalletInterface object to pay the invoice programmatically.

import {MinimalLightningNetworkWalletInterface, SwapAmountType} from "@atomiqlabs/sdk";

// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN, // Lightning BTC input
Tokens.STARKNET.STRK, // Destination smart-chain token, e.g. STRK on Starknet or cBTC on Citrea
10_000n, // Amount in sats
SwapAmountType.EXACT_IN,
undefined, // Source address is not used for standard Lightning source swaps
starknetSigner.getAddress(), // Destination smart-chain address
{
gasAmount: 0n // Optional: request native destination token as gas drop
}
); // Type gets inferred as FromBTCLNAutoSwap

// Execute the swap
const automaticallySettled = await swap.execute(
{
payInvoice: async (bolt11) => {
// Pay the BOLT11 invoice with WebLN, NWC, or any other Lightning wallet integration
return ""; // Empty string is fine if the wallet does not expose the payment preimage
}
}, // Pass the MinimalLightningNetworkWalletInterface object
{
onSourceTransactionReceived: (paymentHash) => {
console.log(`Lightning payment received by LP: ${paymentHash}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on destination chain: ${destinationTxId}`);
}
}
);

if (!automaticallySettled) {
// Handle the edge-case when watchtowers do not settle the HTLC automatically
console.log("Automatic settlement timed out, claiming manually...");
await swap.claim(starknetSigner);
console.log("Claimed!");
} else {
console.log("Success! Output transaction ID:", swap.getOutputTxId());
}
warning

Ensure that the SDK stays online when the lightning network payment is sent, this is necessary since the SDK needs to actively broadcast the swap secret over Nostr upon receiving the swap HTLC.

Manual Execution Flow

import {SwapAmountType} from "@atomiqlabs/sdk";

// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.STARKNET.STRK,
10_000n,
SwapAmountType.EXACT_IN,
undefined,
starknetSigner.getAddress()
);

// 1. Show the invoice to the user
const lightningInvoice = swap.getAddress();
const lightningUri = swap.getHyperlink();

// 2. Wait for the Lightning payment and for the LP to create the destination HTLC
// This is important to make sure the swap is properly initiated before the user attempts a lightning network payment
const paymentReceived = await swap.waitForPayment();
if (!paymentReceived) {
console.log("Lightning payment was not received before the quote expired.");
return;
}

// 3. Wait for watchtower settlement on the destination chain
const automaticallySettled = await swap.waitTillClaimed(60);

// 4. If watchtowers do not settle in time, claim manually
if (!automaticallySettled) {
await swap.claim(starknetSigner);
}
info

If you need to sign the destination-chain claim manually, use txsClaim() instead of claim().

The returned transactions use the following types:

For more information about how to sign and send these transactions manually refer to the Manual Transactions page.

Claiming Past Unsettled Swaps

If the app was offline when the LP created the destination HTLC and/or the watchtowers didn't settle the swap automatically, the swap can still be manually claimed as long as the HTLC has not expired yet.

Checking if a single swap is claimable and claiming it:

if (swap.isClaimable()) {
await swap.claim(starknetSigner);
}

Getting all swaps that are claimable and claiming them:

const claimable = await swapper.getClaimableSwaps(
"STARKNET", // Only get claimable swaps on STARKNET
starknetSigner.getAddress() // Only get claimable swaps for this destination address
); // This returns all claimable swaps, which could be either Bitcoin → Smart chain or Lightning → Smart chain

for (const swap of claimable) {
// All the claimable swap types have the same `claim()` function signature
await swap.claim(starknetSigner);
}
info

It is a good practice to query claimable swaps on your app's startup and either claim them automatically or prompt the user to claim them.

Unlike Bitcoin → Smart chain swaps, Lightning → Smart chain swaps are only claimable until the destination HTLC expires, so this recovery check should run promptly.

Gas Drop

You can request native tokens on the destination chain along with the main swap output. This helps cover gas fees for the first transactions on the destination chain.

const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.STARKNET.WBTC,
20_000n,
SwapAmountType.EXACT_IN,
undefined,
starknetSigner.getAddress(),
{
gasAmount: 1_000_000_000_000_000_000n // Request 1 STRK as gas drop
}
);

console.log("Main output:", swap.getOutput().toString());
console.log("Gas drop:", swap.getGasDropOutput().toString());

LNURL-withdraw

LNURL-withdraw links (lnurl1...) can be used as the source of a Lightning → Smart chain swap, letting the SDK generate a BOLT11 invoice for the quote and submit it to the LNURL-withdraw service automatically when you call waitForPayment() or execute().

You can parse LNURLs either with the generic SwapperUtils.parseAddress() function exposed by the swapper as swapper.Utils.parseAddress() or with the specific SwapperUtils.getLNURLTypeAndData() function, which return the LNURLPay or LNURLWithdraw object allowing you to read the LNURL's details.

import {isLNURLWithdraw, LNURLPay, LNURLWithdraw, SwapAmountType} from "@atomiqlabs/sdk";

// LNURL-withdraw link
const lnurlOrLightningAddress: string = "lnurl1...";
// Check if it has correct format
if (!swapper.Utils.isValidLNURL(lnurlOrLightningAddress))
throw new Error("Invalid LNURL specified!");
// Fetch the details of the LNURL, it can be either LNURL-pay or LNURL-withdraw
const lnurlDetails: LNURLPay | LNURLWithdraw = await swapper.Utils.getLNURLTypeAndData(lnurlOrLightningAddress);
// Check if the LNURL is of the LNURL-withdraw type
if (!isLNURLWithdraw(lnurlDetails))
throw new Error("LNURL isn't of the LNURL-withdraw type!");

// You can inspect the LNURL-withdraw details before quoting
const swapMinimumSats: bigint = lnurlDetails.min;
const swapMaximumSats: bigint = lnurlDetails.max;
const defaultDescription: string = lnurlDetails.params.defaultDescription;

// Create swap with LNURL-withdraw as the Lightning source
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.STARKNET.STRK,
10_000n,
SwapAmountType.EXACT_IN,
lnurlDetails, // Fetched details or raw LNURL-withdraw link string
starknetSigner.getAddress()
);

// No wallet is needed, funds come from the LNURL-withdraw source
const automaticallySettled = await swap.execute(
undefined,
{
onSourceTransactionReceived: (paymentHash) => {
console.log(`Lightning payment received by LP: ${paymentHash}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on destination chain: ${destinationTxId}`);
}
}
);

if (!automaticallySettled) {
await swap.claim(starknetSigner);
}
tip

If you created a normal Lightning quote first and later want to fund it from an LNURL-withdraw source instead, call settleWithLNURLWithdraw(lnurl) before or during waitForPayment()/execute(). This is useful when you want to display a standard invoice QR code and also allow LNURL-withdraw or NFC-card settlement.

Swap States

Read the current state of the swap in its FromBTCLNAutoSwapState enum form with getState() or in human readable SwapStateInfo form with description with getStateInfo():

import {FromBTCLNAutoSwapState, SwapStateInfo} from "@atomiqlabs/sdk";

const state: FromBTCLNAutoSwapState = swap.getState();
console.log(`State (numeric): ${state}`);

const richState: SwapStateInfo<FromBTCLNAutoSwapState> = swap.getStateInfo();
console.log(`State name: ${richState.name}`);
console.log(`State description: ${richState.description}`);

Subscribe to swap state updates with:

swap.events.on("swapState", () => {
const state: FromBTCLNAutoSwapState = swap.getState();
});

Table of States

StateValueDescription
FAILED-4The destination HTLC was not claimed before expiry, so the Lightning payment refunds.
QUOTE_EXPIRED-3Swap quote expired and can no longer be executed.
QUOTE_SOFT_EXPIRED-2Swap should be treated as expired, though it might still succeed if payment is already in flight.
EXPIRED-1The destination HTLC expired, so it is no longer safe to claim and the incoming Lightning payment will refund.
PR_CREATED0Quote created. Pay the invoice from getAddress() or getHyperlink(), then wait with waitForPayment().
PR_PAID1The LP received the Lightning payment, but the destination HTLC is not created yet. Continue waiting with waitForPayment().
CLAIM_COMMITED2The destination HTLC exists. Wait for watchtowers with waitTillClaimed() or claim manually with claim() / txsClaim().
CLAIM_CLAIMED3Swap settled on the destination chain and the Lightning payment can complete.

API Reference