SkateSpots/src/hooks/usePurchasePro.ts

240 lines
9.5 KiB
TypeScript

/**
* usePurchasePro — buy a Sasquatch Pro supporter pass via Lightning.
* ─────────────────────────────────────────────────────────────────────────────
* Flow:
* 1. Build & sign a NIP-57 zap request to the platform, tagged with the tier
* + computed expiry (`pro-tier`, `pro-expiry`). Because it's signed by the
* user's npub, the resulting zap receipt binds payment → identity.
* 2. Resolve an invoice from the platform Lightning address (as a zap if the
* provider supports NIP-57, else a plain invoice).
* 3. Pay via NWC → WebLN → else return the invoice for manual QR payment.
* 4. On confirmed payment, publish a self-signed kind-32671 receipt as a
* reliable local fallback (in case the provider emits no zap receipt).
*
* Verification is handled separately by useSasquatchPro.
*/
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNWC } from '@/hooks/useNWCContext';
import { PAYMENT_STATUS_UNKNOWN } from '@/hooks/useNWC';
import { useWallet } from '@/hooks/useWallet';
import { useRelays } from '@/lib/relays';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { useTranslation } from '@/contexts/language';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import {
getZapProviderPubkey,
getZapInvoiceFromLightningAddress,
getInvoiceFromLightningAddress,
} from '@/lib/lightningAddress';
import { SASQUATCH_PRO_KIND } from '@/lib/types';
import { PRO_LIGHTNING_ADDRESS, DAY_SECONDS, type ProTier } from '@/lib/sasquatchPro';
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
export type PurchaseState = 'idle' | 'preparing' | 'awaiting_payment' | 'paid' | 'error';
interface PurchaseResult {
/** Set when we couldn't auto-pay — show this invoice as a QR for manual payment. */
invoice: string | null;
state: PurchaseState;
error: string | null;
}
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
export function usePurchasePro() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { getActiveConnection, sendPayment } = useNWC();
const { webln } = useWallet();
const { read, write } = useRelays();
const { mutateAsync: sendDM } = useSendDirectMessage();
const { t } = useTranslation();
const [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
/**
* Send the buyer an encrypted (NIP-17) confirmation to their own inbox — a
* durable, private receipt of what they bought. Best-effort: a failure here
* never blocks the purchase (they already paid + got the on-relay receipt).
*/
const sendConfirmationDM = useCallback(
async (tier: ProTier, expiry: number) => {
if (!user?.pubkey || !user.signer?.nip44) return;
const until = new Date(expiry * 1000).toLocaleDateString(dateLocaleCode(), {
year: 'numeric', month: 'long', day: 'numeric',
});
const content = [
t('feesUi.proReceiptTitle'),
``,
t('feesUi.proReceiptPass', { pass: t(tier.labelKey), sats: tier.sats.toLocaleString() }),
t('feesUi.proReceiptActiveUntil', { until }),
``,
t('feesUi.proReceiptUnlocked'),
t('feesUi.proReceiptBullet1'),
t('feesUi.proReceiptBullet2'),
t('feesUi.proReceiptBullet3'),
t('feesUi.proReceiptBullet4'),
``,
t('feesUi.proReceiptThanks'),
].join('\n');
try {
await sendDM({ recipients: user.pubkey, content, subject: t('feesUi.proReceiptSubject') });
} catch {
/* best-effort — don't fail the purchase over a receipt DM */
}
},
[user, sendDM, t],
);
/** Publish the self-signed fallback receipt for a paid tier. */
const publishReceipt = useCallback(
async (tier: ProTier, expiry: number) => {
if (!user?.signer) return;
const now = Math.floor(Date.now() / 1000);
const event = await user.signer.signEvent({
kind: SASQUATCH_PRO_KIND,
content: t('feesUi.receiptContent', { tier: t(tier.labelKey) }),
tags: [
['d', 'sasquatch-pro'],
['tier', tier.id],
['expiry', String(expiry)],
['amount', String(tier.sats)],
['p', PLATFORM_PUBKEY],
['alt', t('feesUi.receiptAlt', { tier: t(tier.labelKey), until: new Date(expiry * 1000).toISOString() })],
],
created_at: now,
});
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
},
[user, nostr, t],
);
const purchase = useCallback(
async (tier: ProTier) => {
if (!user?.signer) {
setResult({ invoice: null, state: 'error', error: t('feesUi.errLogin') });
return;
}
setResult({ invoice: null, state: 'preparing', error: null });
try {
const now = Math.floor(Date.now() / 1000);
const expiry = now + tier.days * DAY_SECONDS;
const amountMsats = tier.sats * 1000;
const relays = [...new Set([...read, ...write])];
const comment = t('feesUi.purchaseComment', { tier: t(tier.labelKey) });
// Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain
// invoice if the provider doesn't publish zap receipts.
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS);
let invoice: string;
if (providerPubkey) {
// Build the NIP-57 zap request event directly (profile zap, no event
// target). Constructed by hand rather than via nip57.makeZapRequest so
// we're not coupled to that helper's changing type signature.
const zapRequest = {
kind: 9734,
content: comment,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relays', ...relays],
['amount', String(amountMsats)],
['p', PLATFORM_PUBKEY],
// Tag the purchase so verification can read tier + expiry from the receipt.
['pro-tier', tier.id],
['pro-expiry', String(expiry)],
] as string[][],
};
const signed = await user.signer.signEvent(zapRequest);
invoice = await getZapInvoiceFromLightningAddress({
lightningAddress: PRO_LIGHTNING_ADDRESS,
amountSats: tier.sats,
zapRequest: JSON.stringify(signed),
});
} else {
invoice = await getInvoiceFromLightningAddress({
lightningAddress: PRO_LIGHTNING_ADDRESS,
amountSats: tier.sats,
comment,
});
}
// SECURITY: never auto-pay an invoice whose amount doesn't match the tier.
assertInvoiceAmount(invoice, tier.sats);
// Attempt automatic payment: NWC → WebLN.
const nwc = getActiveConnection();
if (nwc?.connectionString && nwc.isConnected) {
try {
await sendPayment(nwc, invoice);
await publishReceipt(tier, expiry);
await sendConfirmationDM(tier, expiry);
setResult({ invoice: null, state: 'paid', error: null });
return;
} catch (err) {
// Unknown status: the payment may have gone through — do NOT retry on
// another rail. Surface the invoice so the user can verify.
if ((err as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) {
setResult({ invoice, state: 'awaiting_payment', error: t('feesUi.payStatusUnknown') });
return;
}
/* fall through to WebLN / manual */
}
}
if (webln) {
try {
if (typeof webln.enable === 'function') await webln.enable();
await webln.sendPayment(invoice);
await publishReceipt(tier, expiry);
await sendConfirmationDM(tier, expiry);
setResult({ invoice: null, state: 'paid', error: null });
return;
} catch {
/* fall through to manual */
}
}
// No auto-pay available — surface the invoice for manual QR payment.
// The user taps "I've paid" to publish the receipt (see confirmManual).
setResult({ invoice, state: 'awaiting_payment', error: null });
} catch (err) {
setResult({
invoice: null,
state: 'error',
error: err instanceof Error ? err.message : t('feesUi.errStart'),
});
}
},
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM, t],
);
/** Called after a manual (QR) payment to record the pass locally. */
const confirmManual = useCallback(
async (tier: ProTier) => {
const expiry = Math.floor(Date.now() / 1000) + tier.days * DAY_SECONDS;
try {
await publishReceipt(tier, expiry);
await sendConfirmationDM(tier, expiry);
setResult({ invoice: null, state: 'paid', error: null });
} catch (err) {
setResult({
invoice: null,
state: 'error',
error: err instanceof Error ? err.message : t('feesUi.errRecord'),
});
}
},
[publishReceipt, sendConfirmationDM, t],
);
const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []);
return { ...result, purchase, confirmManual, reset };
}