/** * Lightning Address resolution via LNURL-pay protocol */ interface LNURLPayResponse { status?: 'OK' | 'ERROR'; tag: 'payRequest'; commentAllowed?: number; minSendable: number; maxSendable: number; metadata: string; callback: string; } interface LNURLInvoiceResponse { status?: 'OK' | 'ERROR'; reason?: string; pr?: string; } /** * Validate if a string is a valid Lightning address */ export function isValidLightningAddress(address: string): boolean { if (!address || typeof address !== 'string') { return false; } const trimmed = address.trim(); const parts = trimmed.split('@'); if (parts.length !== 2) { return false; } const [username, domain] = parts; if (!username || username.length === 0) { return false; } if (!domain || !domain.includes('.')) { return false; } const domainParts = domain.split('.'); if (domainParts.length < 2 || domainParts[domainParts.length - 1].length < 2) { return false; } return true; } /** * Resolve Lightning address to LNURL-pay endpoint */ export async function resolveLightningAddress( lightningAddress: string ): Promise { if (!isValidLightningAddress(lightningAddress)) { throw new Error('Invalid Lightning address format. Expected: username@domain.com'); } const [username, domain] = lightningAddress.split('@'); const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`; const response = await fetch(lnurlEndpoint, { headers: { 'Accept': 'application/json' }, }); if (!response.ok) { throw new Error( `Failed to resolve Lightning address: ${response.status} ${response.statusText}` ); } const data: LNURLPayResponse = await response.json(); if (data.status && data.status !== 'OK') { throw new Error('LNURL endpoint returned error status'); } if (data.tag !== 'payRequest') { throw new Error(`Unexpected LNURL tag: ${data.tag}`); } return data; } export interface GetInvoiceParams { lightningAddress: string; amountSats: number; comment?: string; } /** * Does this Lightning address support NIP-57 zaps? * Returns the provider's nostr pubkey (hex) if so, else null. */ export async function getZapProviderPubkey( lightningAddress: string, ): Promise { try { const data = await resolveLightningAddress(lightningAddress); // `allowsNostr` + a valid `nostrPubkey` means the provider publishes zap receipts. const withNostr = data as LNURLPayResponse & { allowsNostr?: boolean; nostrPubkey?: string; }; if (withNostr.allowsNostr && /^[0-9a-f]{64}$/i.test(withNostr.nostrPubkey ?? '')) { return withNostr.nostrPubkey!.toLowerCase(); } return null; } catch { return null; } } export interface GetZapInvoiceParams { lightningAddress: string; amountSats: number; /** Signed kind-9734 zap request event (JSON string via JSON.stringify). */ zapRequest: string; } /** * Get a BOLT11 invoice for a NIP-57 zap by passing a signed zap request to the * LNURL callback. The provider will publish a kind-9735 zap receipt when paid. */ export async function getZapInvoiceFromLightningAddress( params: GetZapInvoiceParams, ): Promise { const { lightningAddress, amountSats, zapRequest } = params; if (!isValidLightningAddress(lightningAddress)) { throw new Error('Invalid Lightning address format. Expected: username@domain.com'); } if (amountSats <= 0) throw new Error('Amount must be greater than 0'); const lnurlData = await resolveLightningAddress(lightningAddress); const minSats = lnurlData.minSendable / 1000; const maxSats = lnurlData.maxSendable / 1000; if (amountSats < minSats) throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`); if (amountSats > maxSats) throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`); const amountMsats = amountSats * 1000; const callbackUrl = new URL(lnurlData.callback); callbackUrl.searchParams.set('amount', amountMsats.toString()); callbackUrl.searchParams.set('nostr', zapRequest); const invoiceResponse = await fetch(callbackUrl.toString(), { headers: { Accept: 'application/json' }, }); if (!invoiceResponse.ok) { throw new Error(`Failed to get zap invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}`); } const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json(); if (invoiceData.pr) return invoiceData.pr; if (invoiceData.status && invoiceData.status !== 'OK') { throw new Error(`Zap invoice generation failed: ${invoiceData.reason || 'unknown'}`); } throw new Error('No invoice returned from provider'); } /** * Get BOLT11 invoice from Lightning address */ export async function getInvoiceFromLightningAddress( params: GetInvoiceParams ): Promise { const { lightningAddress, amountSats, comment } = params; if (!isValidLightningAddress(lightningAddress)) { throw new Error('Invalid Lightning address format. Expected: username@domain.com'); } if (amountSats <= 0) { throw new Error('Amount must be greater than 0'); } // Step 1: Resolve LNURL-pay endpoint const lnurlData = await resolveLightningAddress(lightningAddress); // Step 2: Validate amount against min/max bounds const minSats = lnurlData.minSendable / 1000; const maxSats = lnurlData.maxSendable / 1000; if (amountSats < minSats) { throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`); } if (amountSats > maxSats) { throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`); } // Step 3: Request invoice from callback URL const amountMsats = amountSats * 1000; const callbackUrl = new URL(lnurlData.callback); callbackUrl.searchParams.set('amount', amountMsats.toString()); if (comment && lnurlData.commentAllowed) { if (comment.length > lnurlData.commentAllowed) { throw new Error( `Comment length ${comment.length} exceeds maximum ${lnurlData.commentAllowed} characters` ); } callbackUrl.searchParams.set('comment', comment); } const invoiceResponse = await fetch(callbackUrl.toString(), { headers: { 'Accept': 'application/json' }, }); if (!invoiceResponse.ok) { throw new Error( `Failed to get invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}` ); } const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json(); if (invoiceData.pr) { return invoiceData.pr; } if (invoiceData.status && invoiceData.status !== 'OK') { const errorReason = invoiceData.reason || 'Invoice generation failed'; throw new Error(`Invoice generation failed: ${errorReason}`); } throw new Error('No invoice returned from provider'); }