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 }; +}