From b1d21f89baca90eff880dae5d9558af70f6c5946 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 15:08:17 +0700 Subject: [PATCH] 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. --- AGENTS.md | 15 +++ NIP.md | 24 ++++- src/components/ListingOffersPanel.tsx | 48 ++++++++- src/components/PayWithSats.tsx | 142 ++++++++++++++++++++++++++ src/hooks/useMarketplaceOffers.ts | 39 +++++++ src/hooks/usePayPaymentPointer.ts | 95 +++++++++++++++++ 6 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 src/components/PayWithSats.tsx create mode 100644 src/hooks/usePayPaymentPointer.ts diff --git a/AGENTS.md b/AGENTS.md index 728531c..35c8177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still `selling|wtb`. + - **In-app payment on an accepted offer (2026-10-01):** the seller may attach a + **`["payment", "", ""]`** pointer to the **`accepted`** `30403` + message (`kind` = `lninvoice|lnaddress|creq|clink`; optional + `["payment_hash",""]`). `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 refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a diff --git a/NIP.md b/NIP.md index ec054a1..93a857e 100644 --- a/NIP.md +++ b/NIP.md @@ -796,6 +796,8 @@ of a negotiation is simply its newest message. | `status` | `open` \| `accepted` \| `declined` \| `withdrawn` | | `amount` | optional `["amount","45000","SATS"]` | | `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", "", ""]` — where the buyer sends sats. `kind` ∈ `lninvoice` \| `lnaddress` \| `creq` \| `clink`. | +| `payment_hash` | Optional `["payment_hash",""]` when `kind=lninvoice` — a proof reference for the `31871`. | | `alt` | `SkateSpots: marketplace offer` | `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 > `role` tag is absent), but new messages use the unique-`d` thread form. -Payment is still arranged directly between the two skaters. **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`. +Payment is still arranged **directly between the two skaters** — SkateSpots never +holds funds. To make that easier, when a seller **accepts** they may attach a +**`payment` pointer** for the buyer to pay in-app (a BOLT11 invoice, their +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 @@ -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) | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | +| `proof` | Optional `["proof","lninvoice",""]` 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. | `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) diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index 4c096a8..c6f3873 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -17,7 +17,8 @@ import { import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { genUserName } from '@/lib/genUserName'; 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 }) { const author = useAuthor(pubkey); @@ -55,7 +56,7 @@ function amountLabel(m: MarketplaceOffer): string { * whose turn it is. */ 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 { respondToOffer, counterOffer } = useOfferActions(); const { mutateAsync: sendDM } = useSendDirectMessage(); @@ -64,6 +65,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i const [counterFor, setCounterFor] = useState(null); const [counterAmount, setCounterAmount] = useState(''); const [counterItem, setCounterItem] = useState(''); + const [payFor, setPayFor] = useState(null); + const [paidThreads, setPaidThreads] = useState>(() => new Set()); 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') => { setBusy(thread.latest.id); 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( thread.latest.author, status === 'accepted' @@ -194,6 +205,23 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i {latest.message &&

{latest.message}

} + {/* Buyer on an accepted deal: settle it straight from the wallet. */} + {latest.status === 'accepted' && user.pubkey === thread.buyer && ( + paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? ( +

+ Paid — ask the seller to confirm the trade. +

+ ) : ( + + ) + )} + {/* My turn: accept, decline, or counter. */} {myTurn && ( <> @@ -284,6 +312,20 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i ); })} + + {payFor && ( + { 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}`)) + } + /> + )} ); diff --git a/src/components/PayWithSats.tsx b/src/components/PayWithSats.tsx new file mode 100644 index 0000000..484a301 --- /dev/null +++ b/src/components/PayWithSats.tsx @@ -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(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 ( + + + + + Pay with sats + + + {amountSats + ? <>Send {amountSats.toLocaleString()} sats to {sellerName} to settle this deal. + : <>Settle this deal directly with {sellerName}.} + + + + {paid ? ( +
+ +

Paid!

+

+ Ask the seller to confirm the trade — two confirmations build both reputations. +

+
+ ) : !effectivePointer ? ( +
+ +

+ {sellerName} hasn’t set a payment method (no Lightning invoice or address). Message them to arrange payment. +

+
+ ) : isSelf ? ( +

This is your own listing.

+ ) : ( +
+ {error && ( +

{error}

+ )} + +

+ Pays from your connected wallet (NWC, WebLN, or Cashu) straight to {sellerName} — SkateSpots never holds the funds. +

+
+ )} +
+
+ ); +} diff --git a/src/hooks/useMarketplaceOffers.ts b/src/hooks/useMarketplaceOffers.ts index 45bf8bf..c5cd1df 100644 --- a/src/hooks/useMarketplaceOffers.ts +++ b/src/hooks/useMarketplaceOffers.ts @@ -26,6 +26,15 @@ export const OFFER_KIND = 30403; export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn'; 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 { id: string; role: OfferRole; @@ -40,6 +49,15 @@ export interface MarketplaceOffer { currency?: string; /** What the sender is offering in kind — how a swap/barter is expressed. */ 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; message: string; /** 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 amountTag = event.tags.find(([n]) => n === 'amount'); const status = (tag(event, 'status') as OfferStatus) ?? 'open'; + // `["payment", "", ""]` — the seller's pointer, attached on accept. + const paymentTag = event.tags.find(([n]) => n === 'payment'); const buyer = role === 'offer' ? event.pubkey : (counterparty ?? ''); 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, currency: amountTag?.[2], offerItem: tag(event, 'item'), + payment: paymentTag?.[1], + paymentKind: paymentTag?.[2] as PaymentKind | undefined, + paymentHash: tag(event, 'payment_hash'), status, message: event.content, replyTo: tag(event, 'e'), @@ -244,6 +267,9 @@ export function useOfferActions() { note, status = 'open', replyTo, + payment, + paymentKind, + paymentHash, }: { listing: { pubkey: string; identifier: string }; /** The other party — tagged `p` so the message reaches them. */ @@ -255,6 +281,10 @@ export function useOfferActions() { note?: string; status?: OfferStatus; 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 (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 (amount && amount > 0) tags.push(['amount', String(amount), currency]); 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 }); }; @@ -311,6 +345,8 @@ export function useOfferActions() { previous: MarketplaceOffer, status: Extract, note?: string, + /** On accept, the seller may attach where to pay (a pointer + its kind). */ + payment?: { pointer: string; kind?: PaymentKind; hash?: string }, ) => publishMessage({ listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier }, @@ -320,6 +356,9 @@ export function useOfferActions() { note: note ?? previous.message, status, 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 }; diff --git a/src/hooks/usePayPaymentPointer.ts b/src/hooks/usePayPaymentPointer.ts new file mode 100644 index 0000000..9c8072c --- /dev/null +++ b/src/hooks/usePayPaymentPointer.ts @@ -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 => { + 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 }; +}