From b91df1b4b091338ff17a275aff28365aa2a6f72d Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 20:47:29 +0700 Subject: [PATCH] feat(marketplace): trade confirmation requests (kind 31872) + fix boot smoke test Marketplace: - Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872 Attestation Request (d/a/p), which the seller fulfils in one tap - Seller sees incoming requests in the DM thread ('Confirm this sale?') and the fulfilling 31871 carries the spec's 'request' tag - useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest - NIP.md documents 31872 and states escrow is intentionally NOT implemented (coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on- attestation needs a witness) so nothing looks trustless that isn't Hardening: - scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with HashRouter it only ever loaded the homepage and reported PASS. It now puts the route in the hash, covers settings/stash/collections/stats/marketplace, and fails on the 'This page slammed' error boundary (not just boot failure) --- NIP.md | 56 ++++++++- scripts/smoke-test.mjs | 30 ++++- src/components/MarketplaceItemCard.tsx | 46 ++++++- src/hooks/useTradeAttestations.ts | 161 +++++++++++++++++++++++++ src/pages/ConversationPage.tsx | 59 ++++++++- 5 files changed, 340 insertions(+), 12 deletions(-) diff --git a/NIP.md b/NIP.md index ee2ac35..b9111c6 100644 --- a/NIP.md +++ b/NIP.md @@ -3125,6 +3125,37 @@ cannot observe off-app (cash/trade) settlement. `31871` is addressable, re-publishing the same `d` with `["s","revoked"]` retracts an attestation (e.g. a disputed or cancelled deal). +## Attestation Request (kind 31872) + +When a buyer's deal wasn't recorded by the seller, the buyer publishes a `31872` +asking the seller to attest it. The seller's client surfaces the request and +fulfils it in one tap, publishing a `31871` that carries the spec's `request` tag +pointing back at the `31872`. + +```json +{ + "kind": 31872, + "tags": [ + ["d", ":trade-request:"], + ["a", "30402::"], + ["p", ""], + ["alt", "SkateSpots: trade confirmation requested"] + ], + "content": "Please confirm our trade for “Deck”." +} +``` + +| Tag | Meaning | +| --- | --- | +| `d` | `:trade-request:` — re-requesting replaces the prior one | +| `a` | The listing (Assertion Event) being attested | +| `p` | The requested **attestor** (the seller / listing author) | +| `request` (on the reply) | On the fulfilling `31871`: `31872::` | + +Only the listing author can attest a sale, so the UI only prompts a seller to +fulfil requests whose `a` author is them, and hides requests they've already +answered (they have a `31871` for that listing). + ## Reputation semantics - **sales** — listings where the attestor is the listing author @@ -3133,10 +3164,29 @@ retracts an attestation (e.g. a disputed or cancelled deal). - Consumers **trust-weight counterparties** with the existing web-of-trust score (as with Scene vouches) so fresh-account rings cannot inflate reputation. +## Escrow (planned — not implemented) + +The NIP allows a `31872` to carry a `["cashu_token", ""]`: a Cashu token +locked to spending conditions (P2PK/HTLC) so funds release when the requested +attestation is observed. **We do not implement this yet**, and the client accepts +an optional `cashu_token` only as a passthrough (no locking). Fully shipping it +needs: + +1. **Locked tokens** — `@cashu/cashu-ts` can create P2PK/HTLC outputs, but our + wallet (`coco-cashu-core`) only exposes plain `send()`. Escrow would bypass + coco and use `cashu-ts` directly with the same seed. +2. **Native only** — the bundled Cashu wallet is gated to Capacitor builds + (`useCashu` returns early on web, where mints are CORS-blocked), so an escrow + UI can only run in the app. +3. **A release witness** — Cashu conditions lock to a key or a hash preimage, not + to "a Nostr event was seen". Releasing *on attestation* requires a trusted + witness/oracle that watches for the `31871` and releases. Without one, a token + locked to the seller's key is prepayment, not escrow. + +Until all three exist, escrow stays off to avoid a payment flow that looks +trustless but isn't. + ## Future (from the NIP) -- `31872` **Attestation Request** — a buyer can request the seller's attestation; - it carries a `cashu_token` lockable to spending conditions (HTLC/P2PK) → a - Cashu-based escrow / release-on-attestation flow. - `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader endorsements and attestor discovery. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index d3705fd..a6b1c75 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -122,25 +122,43 @@ await send('Page.enable'); const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value; // ── boot each route (lazy route chunks can fail independently) ────────────── -const ROUTES = ['/', '/leaderboards', '/bounties', '/messages/group/abc123']; +// NOTE: the app uses HashRouter, so the route MUST be in the hash — a bare +// `/settings` path loads index.html with no hash and silently renders the +// homepage, which made this test pass without exercising those chunks. +const ROUTES = [ + '/', + '/marketplace', + '/leaderboards', + '/bounties', + '/settings', + '/stash', + '/collections', + '/stats', + '/messages/group/abc123', +]; const problems = []; let lastText = ''; for (const route of ROUTES) { - const url = appUrl.replace(/\/$/, '') + route; + const url = appUrl.replace(/\/$/, '') + '/#' + route; await send('Page.navigate', { url }); let mounted = false; + let stable = 0; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { lastText = (await evalJS('document.body ? document.body.innerText : ""')) || ''; const kids = await evalJS('document.getElementById("root")?.children.length ?? 0'); - if (kids > 0) { mounted = true; break; } - if (/failed to start/i.test(lastText)) break; - await sleep(400); + if (kids > 0) mounted = true; + // Stop early on a hard crash; otherwise require the page to stay rendered + // for a few polls so a lazy route chunk has time to load (and, if broken, + // to fall through to the error boundary). + if (/failed to start|this page slammed/i.test(lastText)) break; + if (mounted) { stable++; if (stable >= 3) break; } + await sleep(500); } - const crashed = /failed to start/i.test(lastText); + const crashed = /failed to start|this page slammed/i.test(lastText); if (!mounted) problems.push(`${route}: #root never mounted`); if (crashed) problems.push(`${route}: crash screen`); } diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index 62433bb..b8dfe9f 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -33,7 +33,7 @@ import { useSellerInfo } from '@/hooks/useSellerInfo'; import { useTrustScore } from '@/hooks/useTrustScore'; import { useMarkListingSold } from '@/hooks/useMarkListingSold'; import { MarkSoldDialog } from '@/components/MarkSoldDialog'; -import { useListingTrade, useTradeAttestation, useTradeReputation } from '@/hooks/useTradeAttestations'; +import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { genUserName } from '@/lib/genUserName'; import { TrustBadge } from '@/components/TrustBadge'; @@ -53,6 +53,7 @@ import { RotateCcw, Trash2, Loader2, + Send, } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import type { MarketplaceItem } from '@/lib/types'; @@ -97,6 +98,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa const { data: trade } = useListingTrade(listingRef, user?.pubkey); const { data: sellerRep } = useTradeReputation(item.pubkey); const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); + const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey); + const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest(); // Format price display const priceDisplay = item.price @@ -200,6 +203,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa (ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey), ); const iConfirmedTrade = !!trade?.iConfirmed; + const iRequestedConfirmation = !!requestState?.iRequested; const handleConfirmTrade = async () => { if (!user) return; @@ -215,6 +219,27 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa } }; + const handleRequestConfirmation = async () => { + if (!user) return; + try { + await publishRequest({ + listing: listingRef, + attestor: item.pubkey, + note: `Please confirm our trade for “${item.title}”.`, + }); + toast({ + title: 'Confirmation requested', + description: 'The seller can now confirm the sale and build both reputations.', + }); + } catch (e) { + toast({ + title: 'Could not send the request', + description: e instanceof Error ? e.message : 'Something went wrong.', + variant: 'destructive', + }); + } + }; + const handleRelist = async () => { try { await relist(item.event); @@ -500,6 +525,25 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa )} + {/* Buyer: ask the seller to record the sale (kind 31872 request) */} + {user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( + + )} + {iRequestedConfirmation && !iConfirmedTrade && ( +

+ Confirmation requested — waiting on the seller. +

+ )} + {/* Contact button */} + + + )} + {/* Messages Area */}