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)
This commit is contained in:
SkateSpots 2026-09-26 20:47:29 +07:00
parent 4e17852df3
commit b91df1b4b0
5 changed files with 340 additions and 12 deletions

56
NIP.md
View file

@ -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", "<buyer_pubkey>:trade-request:<listing-d>"],
["a", "30402:<seller>:<listing-d>"],
["p", "<seller_pubkey>"],
["alt", "SkateSpots: trade confirmation requested"]
],
"content": "Please confirm our trade for “Deck”."
}
```
| Tag | Meaning |
| --- | --- |
| `d` | `<requestor>:trade-request:<listing-d>` — 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:<requestor>:<d>` |
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", "<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.

View file

@ -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`);
}

View file

@ -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
</Button>
)}
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
{user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
<Button
size="sm"
variant="outline"
onClick={handleRequestConfirmation}
disabled={requestingTrade}
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Ask seller to confirm
</Button>
)}
{iRequestedConfirmation && !iConfirmedTrade && (
<p className="text-xs text-center text-gray-500">
Confirmation requested — waiting on the seller.
</p>
)}
{/* Contact button */}
<Button
variant="outline"

View file

@ -20,6 +20,14 @@ import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
*/
export const TRADE_ATTESTATION_KIND = 31871;
/**
* Attestation Request (kind 31872) — the request side of the flow. A buyer
* whose deal wasn't recorded by the seller can ask them to publish a `31871`;
* the seller's client surfaces it and fulfils it in one tap.
* See NIP.md → "Trade Attestations".
*/
export const TRADE_REQUEST_KIND = 31872;
export const TRADE_METHODS = {
cash: 'Cash',
lightning: 'Lightning',
@ -57,6 +65,17 @@ export function isTradeAttestation(ev: NostrEvent): boolean {
return ev.tags.some(([t]) => t === 'p');
}
/**
* Is this kind-31872 event a *trade* request? Same marker as attestations: a
* 30402 assertion (`a`) plus a requested attestor (`p`).
*/
export function isTradeRequest(ev: NostrEvent): boolean {
if (ev.kind !== TRADE_REQUEST_KIND) return false;
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
if (!coord || coord.kind !== MARKETPLACE_ITEM_KIND) return false;
return ev.tags.some(([t]) => t === 'p');
}
/**
* Publish a trade attestation (kind 31871, addressable).
*
@ -75,6 +94,7 @@ export function useTradeAttestation() {
amountSats,
state = 'valid',
note,
requestCoord,
}: {
/** The listing being attested. */
listing: { pubkey: string; identifier: string };
@ -85,6 +105,8 @@ export function useTradeAttestation() {
amountSats?: number;
state?: 'verifying' | 'valid' | 'revoked';
note?: string;
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
requestCoord?: string;
}) => {
if (!user) throw new Error('You must be logged in.');
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
@ -102,6 +124,7 @@ export function useTradeAttestation() {
if (typeof amountSats === 'number' && amountSats > 0) {
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
}
if (requestCoord) tags.push(['request', requestCoord]);
const name = user.pubkey.slice(0, 8);
const content =
@ -306,3 +329,141 @@ export function useListingTrade(
},
});
}
/**
* Publish a trade confirmation request (kind 31872, addressable).
*
* A buyer who completed a deal the seller hasn't recorded asks the seller (the
* listing author) to publish a `31871`. `d` is derived from the listing + the
* viewer, so re-requesting replaces the previous request.
*/
export function useTradeAttestationRequest() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending } = useNostrPublish();
const publishRequest = async ({
listing,
attestor,
note,
cashuToken,
}: {
/** The listing the deal was for. */
listing: { pubkey: string; identifier: string };
/** The party asked to attest — normally the seller (listing author). */
attestor: string;
note?: string;
/**
* Optional escrowed Cashu token (spec `cashu_token`). Only relevant when
* you created and locked one yourself; a plain token is not escrow.
*/
cashuToken?: string;
}) => {
if (!user) throw new Error('You must be logged in.');
if (attestor === user.pubkey) throw new Error('Pick the other party, not yourself.');
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
const tags: string[][] = [
['d', `${user.pubkey}:trade-request:${listing.identifier}`],
['a', coord],
['p', attestor],
['alt', 'SkateSpots: trade confirmation requested'],
];
if (cashuToken?.trim()) tags.push(['cashu_token', cashuToken.trim()]);
await publish({
kind: TRADE_REQUEST_KIND,
content: note?.trim() || 'Please confirm our trade so it counts for reputation.',
tags,
});
};
return { publishRequest, isPending };
}
export interface IncomingTradeRequest {
coord: string;
listing: { pubkey: string; identifier: string };
requestor: string;
note: string;
event: NostrEvent;
}
/**
* Requests naming the viewer as the attestor (i.e. they are the seller) that
* they have not yet fulfilled. Drives the "confirm this sale" prompt.
*/
export function useIncomingTradeRequests(viewerPubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery<IncomingTradeRequest[]>({
queryKey: ['incoming-trade-requests', viewerPubkey],
enabled: !!viewerPubkey,
staleTime: 60 * 1000,
queryFn: async (c) => {
if (!viewerPubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
const [requests, mine] = await Promise.all([
nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
]);
// Requests the viewer already fulfilled (they attested that listing).
const fulfilled = new Set(
mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean),
);
const out: IncomingTradeRequest[] = [];
const seen = new Set<string>();
for (const ev of requests.filter(isTradeRequest)) {
if (ev.pubkey === viewerPubkey) continue;
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
const parsed = parseListingCoord(coord);
// Only the listing author (seller) can attest the sale.
if (!coord || !parsed || parsed.pubkey !== viewerPubkey) continue;
if (fulfilled.has(coord) || seen.has(coord)) continue;
seen.add(coord);
out.push({
coord,
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
requestor: ev.pubkey,
note: ev.content,
event: ev,
});
}
return out;
},
});
}
/**
* I added a new trade request for a listing. Replaces any previous one (same `d`).
*
* For a plain token this is just "a token you happen to include". Real escrow
* needs the token locked to spending conditions (P2PK/HTLC) plus a release
* witness — see NIP.md → Trade Attestations → Escrow (not yet implemented).
*/
export function useListingTradeRequest(
listing: { pubkey: string; identifier: string } | undefined,
viewerPubkey: string | undefined,
) {
const { nostr } = useNostr();
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
return useQuery({
queryKey: ['listing-trade-request', coord, viewerPubkey],
enabled: !!coord,
staleTime: 60 * 1000,
queryFn: async (c) => {
if (!coord) return { requests: [] as NostrEvent[], iRequested: false };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
const events = (
await nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#a': [coord], limit: 50 }], { signal })
).filter(isTradeRequest);
return {
requests: events,
iRequested: !!viewerPubkey && events.some((e) => e.pubkey === viewerPubkey),
};
},
});
}

View file

@ -14,7 +14,7 @@ import { useToast } from '@/hooks/useToast';
import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
import { usePendingTradeConfirmations, useTradeAttestation } from '@/hooks/useTradeAttestations';
import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
import { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@ -27,6 +27,7 @@ import {
AlertCircle,
RefreshCw,
CheckCircle2,
Handshake,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
@ -105,6 +106,7 @@ export default function ConversationPage() {
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
const { data: incomingRequests } = useIncomingTradeRequests(user?.pubkey);
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
const { markRead } = useDMRead();
const author = useAuthor(otherPubkey || undefined);
@ -171,7 +173,36 @@ export default function ConversationPage() {
}
};
const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) {
// The other party asked us (the seller) to record a sale (kind 31872).
const incomingRequest = otherPubkey
? incomingRequests?.find((r) => r.requestor === otherPubkey)
: undefined;
const handleFulfillRequest = async () => {
if (!incomingRequest || !otherPubkey) return;
try {
await publishTrade({
listing: incomingRequest.listing,
role: 'sold',
counterparty: otherPubkey,
requestCoord: incomingRequest.coord,
note: `Sold to ${displayName}.`,
});
toast({
title: 'Sale confirmed',
description: `${displayName} can now confirm their side.`,
});
} catch (e) {
toast({
title: 'Could not confirm the sale',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
@ -292,6 +323,30 @@ export default function ConversationPage() {
</Card>
)}
{/* Incoming request: the other party asked us (seller) to record the sale */}
{incomingRequest && (
<Card className="mb-3 flex-shrink-0 border-2 border-[#ff6600]/40 bg-[#ff6600]/5">
<CardContent className="flex items-center gap-3 p-3">
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Confirm this sale?</p>
<p className="text-xs text-gray-400">
{displayName} asked you to record it. Confirming builds reputation for both of you.
</p>
</div>
<Button
size="sm"
onClick={handleFulfillRequest}
disabled={confirmingTrade}
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
Confirm sale
</Button>
</CardContent>
</Card>
)}
{/* Messages Area */}
<div
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"