Files
grimoire/src/lib/lnurl.ts
Claude d3201c4b4b feat: optimize zap UX with better error handling and UI improvements
LNURL improvements:
- Add 10s timeouts to Lightning address resolution and invoice fetching
- Better error messages with more context (response status, error text)
- Handle AbortError for timeout scenarios

UI improvements:
- Bigger amount buttons (default size instead of sm)
- Custom amount on separate line for better layout
- Disable all zap UI when recipient has no Lightning address
- Show clear warning when Lightning address is missing
- Only show comment editor when Lightning address is available

Toast cleanup:
- Remove chatty info toasts ("Resolving...", "Creating...", "Fetching...")
- Only show errors and success messages
- Cleaner, less noisy UX

This addresses common issues with LNURL requests timing out and makes
the UI more responsive and informative when zaps cannot be sent.
2026-01-18 22:02:54 +00:00

167 lines
4.6 KiB
TypeScript

/**
* LNURL utilities for Lightning address resolution and zap support (NIP-57)
*/
export interface LnUrlPayResponse {
callback: string;
maxSendable: number;
minSendable: number;
metadata: string;
tag: "payRequest";
allowsNostr?: boolean;
nostrPubkey?: string;
commentAllowed?: number;
}
export interface LnUrlCallbackResponse {
pr: string; // BOLT11 invoice
successAction?: {
tag: string;
message?: string;
};
routes?: any[];
}
/**
* Resolve a Lightning address (lud16) to LNURL-pay endpoint data
* Converts user@domain.com to https://domain.com/.well-known/lnurlp/user
*/
export async function resolveLightningAddress(
address: string,
): Promise<LnUrlPayResponse> {
const parts = address.split("@");
if (parts.length !== 2) {
throw new Error(
"Invalid Lightning address format. Expected: user@domain.com",
);
}
const [username, domain] = parts;
const url = `https://${domain}/.well-known/lnurlp/${username}`;
try {
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to fetch LNURL data: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as LnUrlPayResponse;
// Validate required fields
if (data.tag !== "payRequest") {
throw new Error(
`Invalid LNURL response: expected tag "payRequest", got "${data.tag}"`,
);
}
if (!data.callback) {
throw new Error("LNURL response missing callback URL");
}
return data;
} catch (error) {
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(
"Lightning address request timed out. Please try again.",
);
}
throw error;
}
throw new Error(`Failed to resolve Lightning address: ${error}`);
}
}
/**
* Decode LNURL (bech32-encoded URL) to plain HTTPS URL
*/
export function decodeLnurl(_lnurl: string): string {
// For simplicity, we'll require Lightning addresses (lud16) instead of lud06
// Most modern wallets use lud16 anyway
throw new Error(
"LNURL (lud06) not supported. Please use a Lightning address (lud16) instead.",
);
}
/**
* Fetch invoice from LNURL callback with zap request
* @param callbackUrl - The callback URL from LNURL-pay response
* @param amountMillisats - Amount in millisatoshis
* @param zapRequestEvent - Signed kind 9734 zap request event (URL-encoded JSON)
* @param comment - Optional comment (if allowed by LNURL service)
*/
export async function fetchInvoiceFromCallback(
callbackUrl: string,
amountMillisats: number,
zapRequestEvent: string,
comment?: string,
): Promise<LnUrlCallbackResponse> {
// Build query parameters
const url = new URL(callbackUrl);
url.searchParams.set("amount", amountMillisats.toString());
url.searchParams.set("nostr", zapRequestEvent);
if (comment) {
url.searchParams.set("comment", comment);
}
try {
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url.toString(), { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(
`Failed to fetch invoice (${response.status}): ${errorText || response.statusText}`,
);
}
const data = (await response.json()) as LnUrlCallbackResponse;
if (!data.pr) {
throw new Error("LNURL callback response missing invoice (pr field)");
}
return data;
} catch (error) {
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error("Invoice request timed out. Please try again.");
}
throw error;
}
throw new Error(`Failed to fetch invoice from callback: ${error}`);
}
}
/**
* Validate that a LNURL service supports Nostr zaps (NIP-57)
*/
export function validateZapSupport(lnurlData: LnUrlPayResponse): void {
if (!lnurlData.allowsNostr) {
throw new Error(
"This Lightning address does not support Nostr zaps (allowsNostr is false)",
);
}
if (!lnurlData.nostrPubkey) {
throw new Error("LNURL service missing nostrPubkey (required for zaps)");
}
// Validate pubkey format (64 hex chars)
if (!/^[0-9a-f]{64}$/i.test(lnurlData.nostrPubkey)) {
throw new Error("Invalid nostrPubkey format in LNURL response");
}
}