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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": {
"@getalby/lightning-tools": "^8.1.1",
"@getalby/sdk": "^8.0.1",
"@lendasat/lendaswap-sdk-pure": "^0.2.36",
"@noble/hashes": "^2.0.1",
"commander": "^14.0.3",
"nostr-tools": "^2.23.3"
Expand Down
68 changes: 68 additions & 0 deletions src/commands/pay-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Command } from "commander";
import { payInvoice } from "../tools/nwc/pay_invoice.js";
import { getClient, handleError, output } from "../utils.js";
import {
isPlausibleEvmAddress,
payCrypto,
findSupportedPair,
} from "../lendaswap/swap.js";

export function registerPayCryptoCommand(program: Command) {
program
.command("pay-crypto")
.description(
"Pay any supported crypto or stablecoin address from your Lightning wallet using an atomic swap (powered by Lendaswap).\n\n" +
"Supported currencies and networks are sourced live from the Lendaswap API; if a pair is not available you'll get an error listing what is.",
)
.argument("<address>", "Recipient address on the target network")
.requiredOption(
"-a, --amount <number>",
"Amount to send in target-currency units (e.g. 10 = 10 USDC)",
Number,
)
.option("--currency <name>", "Target currency", "USDC")
.option("--network <name>", "Target network (chain name or id, e.g. arbitrum / 42161)", "arbitrum")
.addHelpText(
"after",
"\nExample:\n" +
" $ npx @getalby/cli pay-crypto 0xabc... --amount 10 --currency USDC --network arbitrum\n",
)
.action(async (address: string, options) => {
await handleError(async () => {
if (!Number.isFinite(options.amount) || options.amount <= 0) {
throw new Error(`Invalid --amount: ${options.amount}`);
}
if (!isPlausibleEvmAddress(address)) {
throw new Error(
`Recipient address does not look valid (expected 0x + 40 hex chars): ${address}`,
);
}

// Validate the pair against the live Lendaswap catalog before
// asking the user for their wallet — fast feedback on typos.
const pair = await findSupportedPair(options.currency, options.network);

const nwc = await getClient(program);

const { swapId } = await payCrypto({
pair,
amount: options.amount,
targetAddress: address,
payInvoice: async (bolt11Invoice) => {
await payInvoice(nwc, { invoice: bolt11Invoice });
},
});

output({
swap_id: swapId,
status: "completed",
target: {
address,
currency: pair.symbol,
network: pair.network,
amount: options.amount,
},
});
});
});
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { registerParseInvoiceCommand } from "./commands/parse-invoice.js";
import { registerVerifyPreimageCommand } from "./commands/verify-preimage.js";
import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/request-invoice-from-lightning-address.js";
import { registerFetch402Command } from "./commands/fetch.js";
import { registerPayCryptoCommand } from "./commands/pay-crypto.js";
import { registerConnectCommand } from "./commands/connect.js";
import { registerAuthCommand } from "./commands/auth.js";
import { registerListWalletsCommand } from "./commands/list-wallets.js";
Expand Down Expand Up @@ -99,6 +100,10 @@ registerRequestInvoiceFromLightningAddressCommand(program);
program.commandsGroup("HTTP 402 Payments (requires wallet connection):");
registerFetch402Command(program);

// Register cross-currency payments (Lightning → EVM via atomic swap)
program.commandsGroup("Cross-Currency Payments (requires wallet connection):");
registerPayCryptoCommand(program);

// Register service discovery
program.commandsGroup("Service Discovery:");
registerDiscoverCommand(program);
Expand Down
263 changes: 263 additions & 0 deletions src/lendaswap/swap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import {
Asset,
Client,
InMemorySwapStorage,
InMemoryWalletStorage,
type LightningToEvmSwapResponse,
type SwapStatus,
type SwapStatusHandler,
toChain,
toChainName,
} from "@lendasat/lendaswap-sdk-pure";
Comment thread
rolznz marked this conversation as resolved.

// Allow tests (or local dev against staging) to override the API endpoint.
const API_BASE_URL = process.env.LENDASWAP_API_URL || "https://api.satora.io";

// Terminal statuses where the swap is irrecoverably done. Mirrors the same
// constants used by the bitcoin-card-topup reference frontend.
const SUCCESS_STATUSES: SwapStatus[] = ["clientredeemed", "serverredeemed"];
const FAILURE_STATUSES: SwapStatus[] = [
"expired",
"clientrefunded",
"clientrefundedserverrefunded",
"clientrefundedserverfunded",
"clientinvalidfunded",
"clientfundedtoolate",
"serverwontfund",
];

let clientPromise: Promise<Client> | null = null;

function getClient(): Promise<Client> {
if (!clientPromise) {
// In-memory storage: a single CLI invocation waits synchronously for a
// terminal swap status, so there's nothing to recover across runs. If the
// process is killed mid-swap, the HTLC refund timer is the safety net.
clientPromise = Client.builder()
.withBaseUrl(API_BASE_URL)
.withSignerStorage(new InMemoryWalletStorage())
.withSwapStorage(new InMemorySwapStorage())
.build();
}
return clientPromise;
}

export interface SupportedPair {
/** Token symbol as reported by the API (e.g. "USDC"). */
symbol: string;
/** Human-friendly chain name (e.g. "Arbitrum"). */
network: string;
decimals: number;
/** Canonical chain identifier from the SDK (e.g. "42161"). */
chain: string;
/** Token ID — ERC-20 contract address for EVM tokens. */
tokenId: string;
}

let supportedPairsPromise: Promise<SupportedPair[]> | null = null;

/**
* Fetch all (currency, network) pairs that can be the target of a
* Lightning → EVM swap. The list comes straight from the Lendaswap API:
* `getTokens()` for the token universe, intersected with `getSwapPairs()`
* filtered to source = Lightning.
*/
export function getSupportedPairs(): Promise<SupportedPair[]> {
if (!supportedPairsPromise) {
supportedPairsPromise = (async () => {
const client = await getClient();
const [tokens, swapPairs] = await Promise.all([
client.getTokens(),
client.getSwapPairs(),
]);
const lightningTargetChains = new Set(
swapPairs.pairs
.filter((p) => p.source === "Lightning")
.map((p) => p.target),
);
return tokens.evm_tokens
.filter((t) => lightningTargetChains.has(t.chain))
.map((t) => ({
symbol: t.symbol,
network: toChainName(t.chain),
decimals: t.decimals,
chain: t.chain,
tokenId: t.token_id,
}));
})();
}
return supportedPairsPromise;
}

function formatPairsList(pairs: SupportedPair[]): string {
return pairs.map((p) => ` - ${p.symbol} on ${p.network}`).join("\n");
}

/**
* Resolve a (currency, network) pair against the live API list, or throw
* with a human-readable error listing every supported pair. Network can be
* a chain name ("arbitrum") or chain id ("42161"); matching is case-insensitive.
*/
export async function findSupportedPair(
currency: string,
network: string,
): Promise<SupportedPair> {
const pairs = await getSupportedPairs();
const symbol = currency.toUpperCase();
// toChain normalizes "arbitrum"/"42161"/"Arbitrum" to the canonical chain id.
const chain = toChain(network);
const pair = pairs.find(
(p) => p.symbol.toUpperCase() === symbol && p.chain === chain,
);
if (!pair) {
throw new Error(
`Unsupported currency/network combination: ${currency} on ${network}.\n` +
`Supported:\n${formatPairsList(pairs)}`,
);
}
return pair;
}

/**
* EVM address shape check: every chain reachable from Lightning is EVM, so
* the universal `0x` + 40-hex format applies. Lendaswap does the
* authoritative validation when it builds the swap; this is just a sanity
* pre-check so an obvious typo fails fast before we lock funds.
*/
export function isPlausibleEvmAddress(address: string): boolean {
return /^0x[0-9a-fA-F]{40}$/.test(address);
}

function toSmallestUnit(amount: number, decimals: number): number {
return Math.round(amount * 10 ** decimals);
}
Comment thread
rolznz marked this conversation as resolved.

async function createPaymentSwap(params: {
pair: SupportedPair;
amount: number;
targetAddress: string;
}): Promise<LightningToEvmSwapResponse> {
const client = await getClient();
const targetAmount = toSmallestUnit(params.amount, params.pair.decimals);

const targetAsset: Asset = {
chain: params.pair.chain,
tokenId: params.pair.tokenId,
};

const result = await client.createSwap({
source: Asset.BTC_LIGHTNING,
target: targetAsset,
targetAmount,
targetAddress: params.targetAddress,
gasless: true,
referralCode: "lnds_2c07e38f10a28d47",
});
// Source is BTC_LIGHTNING and target is an EVM token, so the SDK routes
// through its Lightning→EVM path.
return result.response as LightningToEvmSwapResponse;
}

async function subscribeToSwap(
swapId: string,
onUpdate: SwapStatusHandler,
): Promise<() => void> {
const client = await getClient();
return client.subscribeToSwaps([swapId], onUpdate);
}

async function claimSwap(swapId: string) {
const client = await getClient();
return client.claim(swapId);
}

export interface PayCryptoParams {
/** Currency/network pair the recipient will be paid in — use {@link findSupportedPair} to obtain. */
pair: SupportedPair;
/** Amount of the target currency the recipient should receive (e.g. 10 for 10 USDC). */
amount: number;
/** Recipient address on the target network. */
targetAddress: string;
/**
* Pay the swap's bolt11 invoice. The caller owns the Lightning wallet; this
* keeps lendaswap independent of any specific wallet/NWC implementation.
*/
payInvoice: (bolt11Invoice: string) => Promise<void>;
}

export interface PayCryptoResult {
swapId: string;
}

/**
* Run a Lightning → on-chain crypto payment swap and block until it reaches a
* terminal status. Throws on any failure status. All swap-provider specifics
* (Lendaswap SDK calls, status handling, claim-on-serverfunded) live here so
* that swapping out the provider is a self-contained change.
*/
export async function payCrypto(
params: PayCryptoParams,
): Promise<PayCryptoResult> {
const swap = await createPaymentSwap({
pair: params.pair,
amount: params.amount,
targetAddress: params.targetAddress,
});

// Subscribe BEFORE paying so we don't miss the `serverfunded` event
// that triggers our claim. Unsubscribe in `finally` no matter what.
let unsubscribe: (() => void) | undefined;
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
fn();
};

let claimStarted = false;
subscribeToSwap(swap.id, (_id, status) => {
if (status === "serverfunded" && !claimStarted) {
claimStarted = true;
claimSwap(swap.id).catch((err) =>
settle(() =>
reject(err instanceof Error ? err : new Error(String(err))),
),
);
}
if (SUCCESS_STATUSES.includes(status)) {
settle(resolve);
} else if (FAILURE_STATUSES.includes(status)) {
settle(() => reject(new Error(`Swap ${status}`)));
}
})
.then((unsub) => {
unsubscribe = unsub;
// If the swap already terminated before subscribe resolved,
// tear down immediately.
if (settled) unsub();
})
.catch((err) =>
settle(() =>
reject(err instanceof Error ? err : new Error(String(err))),
),
);

// Pay the Lightning invoice. Failure here propagates as the
// overall swap failure; success doesn't resolve us — only a
// terminal swap status does.
params
.payInvoice(swap.bolt11_invoice)
.catch((err) =>
settle(() =>
reject(err instanceof Error ? err : new Error(String(err))),
),
);
});
} finally {
unsubscribe?.();
}

return { swapId: swap.id };
}
Loading
Loading