Lightning → Solana
Swap Bitcoin Lightning to Solana tokens. Solana still uses the legacy HTLC based Lightning → smart chain flow. The user pays a Lightning invoice first, then manually initializes and claims the destination-side HTLC on Solana, revealing the secret that lets the LP settle the Lightning payment.
Unlike the newer LP-initiated Lightning → smart chain protocol used on Starknet and EVM, this legacy Solana flow requires native SOL upfront for the destination transactions and a security deposit. There is no watchtower auto-claim path here: after the Lightning payment is received by the LP, the user must initialize and settle the swap on Solana themselves.
Starknet and EVM use the newer LP-initiated HTLC protocol. See Lightning → Smart Chain.
Executing the Swap
Here is a full flow for creating and executing the swap in the Lightning → Solana 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 FromBTCLNSwap swap class.
- Lightning wallet
- External payment
- LNURL-withdraw
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.SOLANA.SOL, // Destination Solana token
10_000n, // Amount in sats
SwapAmountType.EXACT_IN,
undefined, // Source address is not used for standard Lightning source swaps
solanaSigner.getAddress() // Destination Solana address
); // Type gets inferred as FromBTCLNSwap
// Inspect the Lightning invoice and native-SOL requirements
console.log("Invoice:", swap.getAddress());
console.log("Deep link:", swap.getHyperlink());
console.log("Security deposit:", swap.getSecurityDeposit().toString());
// Execute the swap
await swap.execute(
solanaSigner, // Destination signer used to commit and claim the HTLC on Solana
{
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}`);
},
onDestinationCommitSent: (txId) => {
console.log(`HTLC committed on Solana: ${txId}`);
},
onDestinationClaimSent: (txId) => {
console.log(`HTLC claimed on Solana: ${txId}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on Solana: ${destinationTxId}`);
}
}
);
console.log("Success! Output transaction ID:", swap.getOutputTxId());
Pay the invoice or lightning: deeplink externally, then wait for the payment to be received by passing null or undefined to the execute() function.
Make sure to call execute() on the swap before you display the lightning invoice to the user. This ensures the swap is properly initiated before receiving the lightning network payment.
import {SwapAmountType} from "@atomiqlabs/sdk";
// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.SOLANA.SOL,
10_000n,
SwapAmountType.EXACT_IN,
undefined,
solanaSigner.getAddress()
);
// Show the Lightning invoice or `lightning:` deeplink to the user
const lightningInvoice = swap.getAddress();
const lightningUri = swap.getHyperlink();
// Execute the swap and wait for the external payment
await swap.execute(
solanaSigner,
undefined, // You can also pass `null` here
{
onSourceTransactionReceived: (paymentHash) => {
console.log(`Lightning payment received by LP: ${paymentHash}`);
},
onDestinationCommitSent: (txId) => {
console.log(`HTLC committed on Solana: ${txId}`);
},
onDestinationClaimSent: (txId) => {
console.log(`HTLC claimed on Solana: ${txId}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on Solana: ${destinationTxId}`);
}
}
);
console.log("Success! Output transaction ID:", swap.getOutputTxId());
Pass an LNURL-withdraw link string or LNURLWithdraw object to source the payment from LNURL-withdraw.
import {LNURLWithdraw, SwapAmountType} from "@atomiqlabs/sdk";
// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.SOLANA.SOL,
10_000n,
SwapAmountType.EXACT_IN,
undefined,
solanaSigner.getAddress()
);
await swap.execute(
solanaSigner,
"lnurl1...", // Pass an LNURL-withdraw link or parsed LNURLWithdraw object to source the payment
{
onSourceTransactionReceived: (paymentHash) => {
console.log(`Lightning payment received by LP: ${paymentHash}`);
},
onDestinationCommitSent: (txId) => {
console.log(`HTLC committed on Solana: ${txId}`);
},
onDestinationClaimSent: (txId) => {
console.log(`HTLC claimed on Solana: ${txId}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on Solana: ${destinationTxId}`);
}
}
);
console.log("Success! Output transaction ID:", swap.getOutputTxId());
For more information about how to parse LNURLs, distinguish between LNURL-withdraw and LNURL-pay variants, check out the LNURL-withdraw section.
execute() requires a destination-chain signer and enough native SOL, because the legacy Solana flow must actively commit and claim the HTLC on Solana, use SolanaSigner, or native @coral-xyz/anchor Wallet.
Manual Execution Flow
- Signer object
- Manually sign transactions
import {SwapAmountType} from "@atomiqlabs/sdk";
// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.SOLANA.SOL,
10_000n,
SwapAmountType.EXACT_IN,
undefined,
solanaSigner.getAddress()
);
// 1. Show the Lightning invoice to the user
const lightningInvoice = swap.getAddress();
const lightningUri = swap.getHyperlink();
// 2. Wait for the Lightning payment to be received by the LP
// 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. Commit and claim the HTLC on Solana
await swap.commitAndClaim(solanaSigner);
Type of the signer object passed to commitAndClaim() is SolanaSigner, or native @coral-xyz/anchor Wallet.
import {SwapAmountType} from "@atomiqlabs/sdk";
// Create a quote
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.SOLANA.SOL,
10_000n,
SwapAmountType.EXACT_IN,
undefined,
solanaSigner.getAddress()
);
// 1. Show the Lightning invoice to the user
const lightningInvoice = swap.getAddress();
const lightningUri = swap.getHyperlink();
// 2. Wait for the Lightning payment to be received by the LP
// 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. Get the Solana commit+claim transactions
const txsCommitAndClaim = await swap.txsCommitAndClaim();
// Sign and send these transactions sequentially here, always wait for the prior confirmation first
...
// 4. Important: wait till the SDK processes the successful claim and updates the swap state
await swap.waitTillClaimed();
The transactions returned by txsCommitAndClaim() use the SolanaTx type.
For more information about how to sign and send these transactions manually refer to the Manual Transactions page.
When sending txsCommitAndClaim() manually, do not broadcast the claim transaction before the commit transaction is confirmed. Revealing the HTLC preimage too early can let the LP settle the Lightning payment before your Solana claim is safely in place.
Claiming Past Unsettled Swaps
If the app was offline after the Lightning payment was received and the HTLC was already committed (but not claimed/settled) on Solana, 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(solanaSigner);
}
Getting all swaps that are claimable and claiming them:
const claimable = await swapper.getClaimableSwaps(
"SOLANA", // Only get claimable swaps on SOLANA
solanaSigner.getAddress() // Only get claimable swaps for this destination address
); // This returns claimable Bitcoin → Solana and Lightning → Solana swaps
for (const swap of claimable) {
// All the claimable swap types have the same `claim()` function signature
await swap.claim(solanaSigner);
}
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 the newer Lightning → Smart chain flow, there is no watchtower fallback here. Settlement remains time-limited by the HTLC expiry, so this recovery check should run promptly.
Native SOL Requirements
Legacy Lightning → Solana swaps require native SOL for both the slashable security deposit and the destination-chain transaction fees.
There is no watchtower claimer bounty in this legacy flow, because settlement is user-driven.
getSecurityDeposit(): the SOL bond the LP can keep if the user pays the Lightning invoice but never completes the Solana-side HTLC flow.getCommitAndClaimNetworkFee(): the estimated Solana network fee for the commit and claim transactions combined.
You can inspect the total native-token requirements before executing the swap:
const feeCheck = await swap.hasEnoughForTxFees();
console.log("Enough SOL:", feeCheck.enoughBalance);
console.log("Wallet balance:", feeCheck.balance.toString());
console.log("Required SOL:", feeCheck.required.toString());
console.log("Security deposit:", swap.getSecurityDeposit().toString());
console.log("Commit + claim fee:", (await swap.getCommitAndClaimNetworkFee()).toString());
You need enough SOL to cover the legacy deposit requirement and both destination transactions before calling execute() or commitAndClaim(). This cold-start requirement is specific to the legacy Solana flow and does not exist in the newer Lightning → Smart chain protocol used on Starknet and EVM.
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.
const swap = await swapper.swap(
Tokens.BITCOIN.BTCLN,
Tokens.SOLANA.SOL,
10_000n,
SwapAmountType.EXACT_IN,
"lnurl1...", // LNURL-withdraw link as the source
solanaSigner.getAddress()
);
await swap.execute(
solanaSigner,
undefined, // No wallet is needed, funds come from the LNURL-withdraw source
{
onSourceTransactionReceived: (paymentHash) => {
console.log(`Lightning payment received by LP: ${paymentHash}`);
},
onDestinationCommitSent: (txId) => {
console.log(`HTLC committed on Solana: ${txId}`);
},
onDestinationClaimSent: (txId) => {
console.log(`HTLC claimed on Solana: ${txId}`);
},
onSwapSettled: (destinationTxId) => {
console.log(`Swap settled on Solana: ${destinationTxId}`);
}
}
);
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 FromBTCLNSwapState enum form with getState() or in human readable SwapStateInfo form with description with getStateInfo():
import {FromBTCLNSwapState, SwapStateInfo} from "@atomiqlabs/sdk";
const state: FromBTCLNSwapState = swap.getState();
console.log(`State (numeric): ${state}`);
const richState: SwapStateInfo<FromBTCLNSwapState> = 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: FromBTCLNSwapState = swap.getState();
});
Table of States
| State | Value | Description |
|---|---|---|
FAILED | -4 | The user did not settle the destination HTLC before expiry, so the Lightning payment refunds. |
QUOTE_EXPIRED | -3 | Swap quote expired and can no longer be executed. |
QUOTE_SOFT_EXPIRED | -2 | Swap should be treated as expired, though it might still succeed if the destination initialization is already in flight. |
EXPIRED | -1 | The destination HTLC expired, so it is no longer safe to claim on Solana. |
PR_CREATED | 0 | Quote created. Pay the invoice from getAddress() or getHyperlink(), then continue with waitForPayment(). |
PR_PAID | 1 | The LP received the Lightning payment. Continue by settling on Solana with commitAndClaim(), or with commit() and claim() separately if needed. |
CLAIM_COMMITED | 2 | The destination HTLC exists on Solana. Continue by claiming it with claim() or txsClaim(). |
CLAIM_CLAIMED | 3 | Swap settled on Solana and the revealed secret lets the LP settle the Lightning payment. |
API Reference
- FromBTCLNSwap - Swap class for Lightning → Solana
- SwapAmountType - Amount type enum
- FromBTCLNSwapState - Swap states