240 lines
9.5 KiB
TypeScript
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 };
|
|
}
|