feat(marketplace): pay an accepted offer in-app

Phases 1-2. The seller may attach a ["payment", value, kind] pointer to the
accepted 30403 message (lninvoice|lnaddress|creq|clink, optional payment_hash);
ListingOffersPanel attaches the seller's own lud16 on accept, falling back to
their profile lud16 on the buyer's side. The pointer rides the accepted event so
it flows through the existing decrypt-free trade notification path (a NIP-17 DM
can't reach the Trades tab without decrypting).

Buyer gets a 'Pay with sats' button -> PayWithSats dialog -> usePayPaymentPointer
pays via NWC -> WebLN -> Cashu melt (mirrors useZaps). Nothing is held or signed
by us; recording the trade stays the existing 31871 flow. creq/clink are
recognized but not payable yet. No new event kind.
This commit is contained in:
SkateSpots 2026-10-01 15:08:17 +07:00
parent a355a90408
commit b1d21f89ba
6 changed files with 355 additions and 8 deletions

View file

@ -104,6 +104,21 @@ session with **no other memory of this project**. So:
reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support
`price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still `price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still
`selling|wtb`. `selling|wtb`.
- **In-app payment on an accepted offer (2026-10-01):** the seller may attach a
**`["payment", "<value>", "<kind>"]`** pointer to the **`accepted`** `30403`
message (`kind` = `lninvoice|lnaddress|creq|clink`; optional
`["payment_hash","<hex>"]`). `ListingOffersPanel.respond('accepted')` attaches
the seller's own profile `lud16` when present; the pointer lives on the
accepted event so it rides the **same decrypt-free notification path** as the
offer (a NIP-17 DM was rejected — it can't reach the Trades tab without
decrypting, which `OsNotificationBridge` forbids). Buyer sees a **Pay with
sats** button → `PayWithSats` dialog → `usePayPaymentPointer` pays via
**NWC → WebLN → Cashu melt** (mirrors `useZaps`), falling back to the seller's
`lud16` if the accept carried no pointer. **Nothing is held or signed by us** —
recording the trade stays the existing `31871` flow (`proof` tag documented in
NIP.md). `creq`/`clink` are recognized but **not payable yet**. This is the
**Numo/BTCPay-derived** "pay the seller in-app" rail; the request format is
ours (offer tags), not a new kind.
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

24
NIP.md
View file

@ -796,6 +796,8 @@ of a negotiation is simply its newest message.
| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` | | `status` | `open` \| `accepted` \| `declined` \| `withdrawn` |
| `amount` | optional `["amount","45000","SATS"]` | | `amount` | optional `["amount","45000","SATS"]` |
| `item` | optional `["item","my 8.25 deck + spare wheels"]` — an **in-kind offer (barter)**; may be combined with `amount` | | `item` | optional `["item","my 8.25 deck + spare wheels"]` — an **in-kind offer (barter)**; may be combined with `amount` |
| `payment` | On an **`accepted`** message: `["payment", "<value>", "<kind>"]` — where the buyer sends sats. `kind` ∈ `lninvoice` \| `lnaddress` \| `creq` \| `clink`. |
| `payment_hash` | Optional `["payment_hash","<hex>"]` when `kind=lninvoice` — a proof reference for the `31871`. |
| `alt` | `SkateSpots: marketplace offer` | | `alt` | `SkateSpots: marketplace offer` |
`content` is an optional human message. Either party may answer the newest `open` `content` is an optional human message. Either party may answer the newest `open`
@ -809,10 +811,18 @@ its newest `open` message.
> Clients MUST still parse those (`role` inferred from the `d` marker when the > Clients MUST still parse those (`role` inferred from the `d` marker when the
> `role` tag is absent), but new messages use the unique-`d` thread form. > `role` tag is absent), but new messages use the unique-`d` thread form.
Payment is still arranged directly between the two skaters. **Bartering is Payment is still arranged **directly between the two skaters** — SkateSpots never
first-class**: an offer or counter may carry an `item` (what the sender is holds funds. To make that easier, when a seller **accepts** they may attach a
offering) instead of, or alongside, `amount` — and listings already support **`payment` pointer** for the buyer to pay in-app (a BOLT11 invoice, their
`price_type=trade`. Lightning address, a NUT-18 `creq`, or a CLINK code). The pointer rides the
accepted message itself, so it flows through the same event + notification path
as the offer — no DM, and nothing to decrypt to show it. If the accept carries no
pointer, clients fall back to the seller's profile `lud16`. `lninvoice` /
`lnaddress` are payable today; `creq` / `clink` are recognized but not yet payable.
**Bartering is first-class**: an offer or counter may carry an `item` (what the
sender is offering) instead of, or alongside, `amount` — and listings already
support `price_type=trade`.
## Community Contributions ## Community Contributions
@ -3224,10 +3234,14 @@ The base NIP has no counterparty field for this use case, so we add:
| `p` | The **other party** in the trade (required by us, marks a trade attestation) | | `p` | The **other party** in the trade (required by us, marks a trade attestation) |
| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` |
| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | | `amount` | Optional self-reported amount, `["amount","45000","SATS"]` |
| `proof` | Optional `["proof","lninvoice","<payment_hash>"]` when the payer paid a BOLT11 that carried a `payment_hash` (e.g. from an accepted offer's `payment` pointer). |
| `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. | | `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. |
`method`/`amount`/`rating` are **self-reported**, never proof of payment or a `method`/`amount`/`rating` are **self-reported**, never proof of payment or a
verified score — the app cannot observe off-app (cash/trade) settlement. verified score — the app cannot observe off-app (cash/trade) settlement. `proof`
is a **reference the author supplies**, not a check the app performs: it lets a
client (or the seller's own BTCPay/Numo webhook, if they run one) confirm the
invoice settled, but until something verifies it, treat it as a soft signal.
## Trust model, sybil/collusion limits (honest) ## Trust model, sybil/collusion limits (honest)

View file

@ -17,7 +17,8 @@ import {
import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2 } from 'lucide-react'; import { PayWithSats } from '@/components/PayWithSats';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
function PeerName({ pubkey }: { pubkey: string }) { function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey); const author = useAuthor(pubkey);
@ -55,7 +56,7 @@ function amountLabel(m: MarketplaceOffer): string {
* whose turn it is. * whose turn it is.
*/ */
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) { export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
const { user } = useCurrentUser(); const { user, metadata } = useCurrentUser();
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier }); const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { respondToOffer, counterOffer } = useOfferActions(); const { respondToOffer, counterOffer } = useOfferActions();
const { mutateAsync: sendDM } = useSendDirectMessage(); const { mutateAsync: sendDM } = useSendDirectMessage();
@ -64,6 +65,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const [counterFor, setCounterFor] = useState<string | null>(null); const [counterFor, setCounterFor] = useState<string | null>(null);
const [counterAmount, setCounterAmount] = useState(''); const [counterAmount, setCounterAmount] = useState('');
const [counterItem, setCounterItem] = useState(''); const [counterItem, setCounterItem] = useState('');
const [payFor, setPayFor] = useState<OfferThread | null>(null);
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
if (!user || !data) return null; if (!user || !data) return null;
@ -81,7 +84,15 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => { const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => {
setBusy(thread.latest.id); setBusy(thread.latest.id);
try { try {
await respondToOffer(thread.latest, status); // On accept, attach my Lightning address (if I have one) so the buyer can
// pay in-app. If I have none, their side falls back to my profile `lud16`.
const myLud16 = metadata?.lud16?.trim();
await respondToOffer(
thread.latest,
status,
undefined,
status === 'accepted' && myLud16 ? { pointer: myLud16, kind: 'lnaddress' } : undefined,
);
await nudge( await nudge(
thread.latest.author, thread.latest.author,
status === 'accepted' status === 'accepted'
@ -194,6 +205,23 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
{latest.message && <p className="text-xs text-gray-400">{latest.message}</p>} {latest.message && <p className="text-xs text-gray-400">{latest.message}</p>}
{/* Buyer on an accepted deal: settle it straight from the wallet. */}
{latest.status === 'accepted' && user.pubkey === thread.buyer && (
paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (
<p className="flex items-center gap-1.5 text-xs font-bold text-green-400">
<CheckCircle className="h-3.5 w-3.5" /> Paid — ask the seller to confirm the trade.
</p>
) : (
<Button
size="sm"
onClick={() => setPayFor(thread)}
className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Zap className="h-3.5 w-3.5" /> Pay with sats
</Button>
)
)}
{/* My turn: accept, decline, or counter. */} {/* My turn: accept, decline, or counter. */}
{myTurn && ( {myTurn && (
<> <>
@ -284,6 +312,20 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
</div> </div>
); );
})} })}
{payFor && (
<PayWithSats
open={!!payFor}
onOpenChange={(o) => { if (!o) setPayFor(null); }}
sellerPubkey={payFor.latest.seller}
amountSats={payFor.latest.amount}
pointer={payFor.latest.payment}
pointerKind={payFor.latest.paymentKind}
onPaid={() =>
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`))
}
/>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View file

@ -0,0 +1,142 @@
/**
* PayWithSats — settle an accepted marketplace offer from the buyer's wallet.
*
* The seller's pointer is preferred from the accepted offer message
* (`["payment", value, kind]`); if they attached none, we fall back to the
* Lightning address on their profile (kind 0 `lud16`). Payment goes straight to
* the seller — SkateSpots never holds funds and never signs a payment event.
*/
import { useEffect, useState } from 'react';
import { Loader2, Zap, CheckCircle2, AlertTriangle } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { usePayPaymentPointer } from '@/hooks/usePayPaymentPointer';
import { toast } from '@/hooks/useToast';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
import { genUserName } from '@/lib/genUserName';
interface PayWithSatsProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Who receives the sats. */
sellerPubkey: string;
/** Accepted amount in sats (required to pay a Lightning address). */
amountSats?: number;
/** Pointer attached by the seller on accept, if any. */
pointer?: string;
pointerKind?: PaymentKind;
/** Called once a payment is sent successfully. */
onPaid?: () => void;
}
export function PayWithSats({
open,
onOpenChange,
sellerPubkey,
amountSats,
pointer,
pointerKind,
onPaid,
}: PayWithSatsProps) {
const { user } = useCurrentUser();
const author = useAuthor(sellerPubkey);
const { pay, isPaying } = usePayPaymentPointer();
const [paid, setPaid] = useState(false);
const [error, setError] = useState<string | null>(null);
const sellerName = author.data?.metadata?.name ?? genUserName(sellerPubkey);
const attached = pointer?.trim();
const lud16 = author.data?.metadata?.lud16?.trim();
const effectivePointer = attached || lud16 || '';
const effectiveKind: PaymentKind | undefined = attached
? (pointerKind ?? 'lnaddress')
: lud16
? 'lnaddress'
: undefined;
// Reset transient state whenever the sheet is (re)opened.
useEffect(() => {
if (open) {
setPaid(false);
setError(null);
}
}, [open]);
const handlePay = async () => {
if (!effectiveKind) return;
setError(null);
try {
await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
setPaid(true);
toast({
title: 'Payment sent ⚡',
description: 'Let the seller know — they can confirm the trade so it counts for both of you.',
});
onPaid?.();
} catch (err) {
const message = err instanceof Error ? err.message : 'Payment failed.';
setError(message);
toast({ title: 'Payment failed', description: message, variant: 'destructive' });
}
};
const isSelf = !!user && user.pubkey === sellerPubkey;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" /> Pay with sats
</DialogTitle>
<DialogDescription>
{amountSats
? <>Send <strong className="text-white">{amountSats.toLocaleString()} sats</strong> to {sellerName} to settle this deal.</>
: <>Settle this deal directly with {sellerName}.</>}
</DialogDescription>
</DialogHeader>
{paid ? (
<div className="space-y-2 py-4 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-green-500" />
<p className="text-sm font-bold text-white">Paid!</p>
<p className="text-xs text-muted-foreground">
Ask the seller to confirm the trade — two confirmations build both reputations.
</p>
</div>
) : !effectivePointer ? (
<div className="space-y-2 py-4 text-center">
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground">
{sellerName} hasn’t set a payment method (no Lightning invoice or address). Message them to arrange payment.
</p>
</div>
) : isSelf ? (
<p className="py-4 text-center text-sm text-muted-foreground">This is your own listing.</p>
) : (
<div className="space-y-3">
{error && (
<p className="rounded-lg bg-destructive/10 p-2 text-xs text-destructive">{error}</p>
)}
<Button
onClick={handlePay}
disabled={isPaying}
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{isPaying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
{isPaying ? 'Paying…' : `Pay${amountSats ? ` ${amountSats.toLocaleString()} sats` : ''}`}
</Button>
<p className="text-center text-[10px] leading-relaxed text-muted-foreground">
Pays from your connected wallet (NWC, WebLN, or Cashu) straight to {sellerName} — SkateSpots never holds the funds.
</p>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -26,6 +26,15 @@ export const OFFER_KIND = 30403;
export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn'; export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn';
export type OfferRole = 'offer' | 'response'; export type OfferRole = 'offer' | 'response';
/**
* What kind of payment pointer a seller attaches when accepting an offer.
* - `lninvoice` BOLT11 — amount-bound; the exact request
* - `lnaddress` Lightning address — resolved to an invoice at pay time
* - `creq` NUT-18 Cashu request — recognized, not payable yet
* - `clink` CLINK `noffer1…` — recognized, not payable yet
*/
export type PaymentKind = 'lninvoice' | 'lnaddress' | 'creq' | 'clink';
export interface MarketplaceOffer { export interface MarketplaceOffer {
id: string; id: string;
role: OfferRole; role: OfferRole;
@ -40,6 +49,15 @@ export interface MarketplaceOffer {
currency?: string; currency?: string;
/** What the sender is offering in kind — how a swap/barter is expressed. */ /** What the sender is offering in kind — how a swap/barter is expressed. */
offerItem?: string; offerItem?: string;
/**
* Payment pointer the seller attaches when accepting — where the buyer sends
* sats. Carried on the accepted message so it rides the SAME event and
* notification path as the offer (no DM, no decryption needed to surface it).
*/
payment?: string;
paymentKind?: PaymentKind;
/** LN invoice payment hash, when `paymentKind === 'lninvoice'`. */
paymentHash?: string;
status: OfferStatus; status: OfferStatus;
message: string; message: string;
/** The message this one answers (`e`), when it's a counter/accept/decline. */ /** The message this one answers (`e`), when it's a counter/accept/decline. */
@ -79,6 +97,8 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
const counterparty = tag(event, 'p'); const counterparty = tag(event, 'p');
const amountTag = event.tags.find(([n]) => n === 'amount'); const amountTag = event.tags.find(([n]) => n === 'amount');
const status = (tag(event, 'status') as OfferStatus) ?? 'open'; const status = (tag(event, 'status') as OfferStatus) ?? 'open';
// `["payment", "<value>", "<kind>"]` — the seller's pointer, attached on accept.
const paymentTag = event.tags.find(([n]) => n === 'payment');
const buyer = role === 'offer' ? event.pubkey : (counterparty ?? ''); const buyer = role === 'offer' ? event.pubkey : (counterparty ?? '');
const seller = role === 'offer' ? (counterparty ?? coord.pubkey) : event.pubkey; const seller = role === 'offer' ? (counterparty ?? coord.pubkey) : event.pubkey;
@ -95,6 +115,9 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
amount: amountTag?.[1] ? Number(amountTag[1]) : undefined, amount: amountTag?.[1] ? Number(amountTag[1]) : undefined,
currency: amountTag?.[2], currency: amountTag?.[2],
offerItem: tag(event, 'item'), offerItem: tag(event, 'item'),
payment: paymentTag?.[1],
paymentKind: paymentTag?.[2] as PaymentKind | undefined,
paymentHash: tag(event, 'payment_hash'),
status, status,
message: event.content, message: event.content,
replyTo: tag(event, 'e'), replyTo: tag(event, 'e'),
@ -244,6 +267,9 @@ export function useOfferActions() {
note, note,
status = 'open', status = 'open',
replyTo, replyTo,
payment,
paymentKind,
paymentHash,
}: { }: {
listing: { pubkey: string; identifier: string }; listing: { pubkey: string; identifier: string };
/** The other party — tagged `p` so the message reaches them. */ /** The other party — tagged `p` so the message reaches them. */
@ -255,6 +281,10 @@ export function useOfferActions() {
note?: string; note?: string;
status?: OfferStatus; status?: OfferStatus;
replyTo?: string; replyTo?: string;
/** Payment pointer to attach (the seller sets this on an `accepted` message). */
payment?: string;
paymentKind?: PaymentKind;
paymentHash?: string;
}) => { }) => {
if (!user) throw new Error('Log in to make an offer.'); if (!user) throw new Error('Log in to make an offer.');
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.'); if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
@ -273,6 +303,10 @@ export function useOfferActions() {
if (replyTo) tags.push(['e', replyTo]); if (replyTo) tags.push(['e', replyTo]);
if (amount && amount > 0) tags.push(['amount', String(amount), currency]); if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
if (offerItem?.trim()) tags.push(['item', offerItem.trim().slice(0, 120)]); if (offerItem?.trim()) tags.push(['item', offerItem.trim().slice(0, 120)]);
if (payment?.trim()) {
tags.push(['payment', payment.trim(), paymentKind ?? 'lnaddress']);
if (paymentHash?.trim()) tags.push(['payment_hash', paymentHash.trim()]);
}
await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags }); await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags });
}; };
@ -311,6 +345,8 @@ export function useOfferActions() {
previous: MarketplaceOffer, previous: MarketplaceOffer,
status: Extract<OfferStatus, 'accepted' | 'declined' | 'withdrawn'>, status: Extract<OfferStatus, 'accepted' | 'declined' | 'withdrawn'>,
note?: string, note?: string,
/** On accept, the seller may attach where to pay (a pointer + its kind). */
payment?: { pointer: string; kind?: PaymentKind; hash?: string },
) => ) =>
publishMessage({ publishMessage({
listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier }, listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier },
@ -320,6 +356,9 @@ export function useOfferActions() {
note: note ?? previous.message, note: note ?? previous.message,
status, status,
replyTo: previous.id, replyTo: previous.id,
payment: status === 'accepted' ? payment?.pointer : undefined,
paymentKind: status === 'accepted' ? payment?.kind : undefined,
paymentHash: status === 'accepted' ? payment?.hash : undefined,
}); });
return { createOffer, counterOffer, respondToOffer }; return { createOffer, counterOffer, respondToOffer };

View file

@ -0,0 +1,95 @@
/**
* usePayPaymentPointer — pay a seller's payment pointer from whatever wallet the
* buyer has, mirroring the zap rails: **NWC → WebLN → Cashu melt**.
*
* The pointer comes from an accepted marketplace offer (`["payment", value, kind]`):
* - `lninvoice` / `lnaddress` are payable today;
* - `creq` (NUT-18 Cashu request) and `clink` are recognized but not payable yet.
*
* This NEVER holds funds: it pays the seller's own invoice/address directly.
* No Nostr event is published here — recording the trade is the existing `31871`
* attestation flow.
*/
import { useCallback, useState } from 'react';
import type { WebLNProvider } from '@webbtc/webln-types';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useLightningAddress } from '@/hooks/useLightningAddress';
import { useCashu } from '@/hooks/wallet/useCashu';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
export interface PayPointerArgs {
/** The pointer string (BOLT11, Lightning address, `creq1…`, `noffer1…`). */
pointer: string;
kind: PaymentKind;
/** Required to pay a Lightning address (an invoice carries its own amount). */
amountSats?: number;
}
export function usePayPaymentPointer() {
const { sendPayment } = useNWC();
const { activeNWC, webln } = useWallet();
const { getInvoice } = useLightningAddress();
const { coco, mints } = useCashu();
const [isPaying, setIsPaying] = useState(false);
const pay = useCallback(
async ({ pointer, kind, amountSats }: PayPointerArgs): Promise<string> => {
setIsPaying(true);
try {
// 1) Resolve the pointer to a BOLT11 invoice.
let invoice: string;
if (kind === 'lninvoice') {
invoice = pointer;
} else if (kind === 'lnaddress') {
if (!amountSats || amountSats <= 0) {
throw new Error('An amount is required to pay a Lightning address.');
}
invoice = await getInvoice({ lightningAddress: pointer, amountSats });
} else if (kind === 'creq') {
throw new Error(
'Cashu (creq) requests aren’t supported yet — ask the seller for a Lightning invoice or address.',
);
} else {
throw new Error(
'That payment code isn’t supported yet — ask the seller for a Lightning invoice or address.',
);
}
// 2) Pay it with the best available rail.
if (activeNWC?.connectionString && activeNWC.isConnected) {
await sendPayment(activeNWC, invoice);
return invoice;
}
if (webln) {
let provider: WebLNProvider = webln;
if (typeof webln.enable === 'function') {
const enabled = (await webln.enable()) as WebLNProvider | undefined;
if (enabled) provider = enabled;
}
await provider.sendPayment(invoice);
return invoice;
}
const mint = mints[0];
if (coco && mint) {
const quote = await coco.quotes.createMeltQuote(mint.url, invoice);
await coco.quotes.payMeltQuote(mint.url, quote.quote);
return invoice;
}
throw new Error(
'No wallet connected. Add a Lightning wallet (NWC) in Settings, connect a WebLN wallet, or top up your Cashu balance.',
);
} finally {
setIsPaying(false);
}
},
[sendPayment, activeNWC, webln, getInvoice, coco, mints],
);
return { pay, isPaying };
}