SkateSpots/src/lib/lightningAddress.ts

232 lines
6.8 KiB
TypeScript

/**
* 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().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
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<LNURLPayResponse> {
if (!isValidLightningAddress(lightningAddress)) {
throw new Error('Invalid Lightning address format. Expected: username@domain.com');
}
const normalized = lightningAddress.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
const [username, domain] = normalized.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<string | null> {
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<string> {
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<string> {
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');
}