- Add 4 duration tiers (7d/30d/90d/365d) with per-day price discrimination to usePlatformFee - PlatformFeePayment: tier selector UI + attempt NIP-57 zap request (a/p tags), graceful fallback to plain LNURL invoice - Add getZapProviderPubkey + getZapInvoiceFromLightningAddress helpers - Add useFeaturedVerification hook (verified/claimed/none) querying kind 9735 zap receipts - ProBadge: optional entityCoord to show green Verified state on detail pages - Wire entityCoord through all 5 Become* Lab pages + CoachDetailPage - Add featuredTour fee type; FeaturedUpgrade generalized for events + tours - CreateTourPage: featured tag on publish + FeaturedUpgrade UI - TourCard/TourDetailPage: render ProBadge; sort featured tours first - Document tiers + zap-receipt verification scheme in NIP.md Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
231 lines
6.7 KiB
TypeScript
231 lines
6.7 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();
|
|
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 [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<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');
|
|
}
|