SkateSpots/MARKETPLACE_FLOW_AUDIT.md
SkateSpots b48f0e53ee feat(escrow): seller-advertised mint, preferred by the buyer
Finishes the escrow-mint work: advertiseEscrowKey now also publishes
cashu_mint (the seller's preferred mint from Wallet > Escrow) on kind 0,
documented in NIP.md. The buyer's EscrowRequestDialog reads the seller's
cashu_mint, includes it in the mint picker even if it is not in the buyer's
trust list, and prefers it (falling back to the buyer's preference, then
first trusted mint). Funds locked at a mint the seller actually uses are
the ones they can redeem. Still open: seller-side receive from a mint they
do not already have. New marketUi.escrowRequest.sellerMintHint (en/es).
2026-10-10 08:57:33 +07:00

14 KiB

Marketplace flow, escrow, and error audit — 2026-10-09

Companion to UX_AUDIT_2026-10-09.md. This is the deep, sequenced audit of the marketplace: the full trade lifecycle, escrow, the offers/payment path, and every error/empty/confusing state. It is meant to be the reference before touching the marketplace.

Method: three focused exploration passes (trade/escrow; offers/payment; error and confusion matrix) against the source, plus direct verification of the highest severity findings. Confidence legend:

  • [verified] — I read the code and confirmed it.
  • [reported] — found by the exploration pass; confirm in source before acting.

Severity: P0 = money loss, data loss, or a wrong outcome; P1 = flow dead-end / stale or misleading state; P2 = polish (outage copy, native-only visibility, terminology, translation).


1. The lifecycle, end to end

  1. Browse — /marketplace hub, Browse tab -> GearSwapPage grid (GearSwapItemCard).
  2. Listing detail — /listing/:pubkey/:identifier -> seller block, price, ListingTradeActions (buy / trade / escrow), optional "Make an offer", ListingOffersPanel, TradeReputationCard.
  3. Offer negotiation (kind 30403) — offer -> counter -> accept / decline / withdraw; on accept a payment pointer may be attached; then in-app pay.
  4. Close the deal — seller "Mark as sold" -> MarkSoldDialog (buyer, method, amount) -> markSold (kind 30402 status=sold) + publishTrade (kind 31871).
  5. Mutual reputation — buyer "Confirm this trade" -> second 31871 with optional payment proof -> useTradeReputation.
  6. Shipment (kind 34967) — seller "shipped", buyer "received"/"issue".
  7. Escrow (native only) — buyer locks Cashu P2PK to the seller with a locktime and buyer refund key; seller redeems; buyer refunds after locktime.

2. P0 — money / data / correctness

  • [verified] Offers "accept" attaches the wrong payee. respond in ListingOffersPanel.tsx (~line 95) attaches the accepting user's lud16 as the payment pointer with no role check. When the buyer accepts the seller's counter, the buyer's address is published as the payee; PayWithSats is passed pointer = latest.payment and sellerPubkey = latest.seller (ListingOffersPanel.tsx ~332), so the pointer and the payee can be different parties — the payment can be misdirected. Fixed this pass (only the listing owner attaches a pointer).
  • [verified] advertiseEscrowKey could wipe the profile. It published kind 0 from author.data?.metadata ?? {} (src/hooks/wallet/useCashuEscrow.ts:134); if the profile query had not resolved, it wrote a profile containing only cashu_escrow, erasing name/picture/about/lud16/nip05. Fixed this pass (refuses to publish until author.data has loaded).
  • [reported] Escrow funding can strand funds. EscrowRequestDialog.tsx:64-87 mints and P2PK-locks the funds, then publishes the 31872; if the publish throws, the minted token exists only in a local variable, and a retry mints a second escrow with no way to recover the first. Related: src/lib/cashuEscrow.ts:282-284 never persists the proofs. Native-only and unverified (see ESCROW.md), but this is the worst-case path.
  • [reported] Buyer refund is unreachable on web. ListingTradeActions.tsx:196-208 gates refund on escrow.available (native), so a web user holding an escrowed token can see the EscrowPanel but cannot reclaim after locktime.
  • [reported] A sold listing can be left with no attestation. MarkSoldDialog.tsx:70-77 runs markSold then publishTrade non-atomically; if the second fails (or is queued offline, which resolves — useNostrPublish.ts:77-89), the listing is sold but no 31871 exists, so no reputation and no confirmation path.
  • [verified] Escrow mint is chosen by the buyer, not the payee. useCashuEscrow resolves the mint as prefs.preferredMintUrl || mints[0].url (the buyer's first trusted mint, useCashuEscrow.ts:40,103), and EscrowRequestDialog did not show or offer a choice. The token is P2PK-locked to the seller but lives at the buyer's mint, so a seller who does not trust/reach that mint may fail to redeem. There was also no P2PK (NUT-11) capability check before minting (cashuEscrow.ts:261-282). Partly fixed this pass: the buyer now sees and picks the mint in the dialog, and a mint that advertises no NUT-11 is blocked with a clear message. Extended: the seller can advertise a preferred mint (cashu_mint on kind 0, documented in NIP.md) and the buyer's escrow dialog prefers it, so the buyer locks at a mint the seller can actually redeem from. Still open: seller-side receive from an unfamiliar mint (the edge case where the buyer ignores the advertised mint).

3. P1 — flow dead-ends and misleading state

  • [verified] Grid "Mark as sold" skipped the trade record. GearSwapItemCard called only markSold, so no 31871/buyer/reputation came from the grid, unlike the detail and dashboard. Fixed this pass (opens MarkSoldDialog).
  • [reported] Ask-seller is only when already sold (ListingTradeActions.tsx:254): a buyer who paid off-app while the listing is still active cannot ask the seller to record it — a dead end until the seller acts.
  • [reported] escrow = required does not block Buy (ListingTradeActions.tsx:90-99): the badge shows but direct Buy-with-sats still proceeds.
  • [reported] Revocation is not reliably honored. Attestations are deduped by event id only (useTradeAttestations.ts:224-226, 406-408), so a stale valid copy from one relay can outlive a newer revoked; iConfirmed can stay true.
  • [reported] useTradeReputation skips onlyVerified (:216-222), unlike useTradeIntegrity/useShipmentStatus, so a hostile relay can inject forged reputation events.
  • [reported] Request back-reference is wrong. publishTrade writes the listing a coord as the request tag (useTradeAttestations.ts:141), but NIP.md documents 31872:<requestor>:<d>; both call sites pass the coord (TradesPage.tsx:210, ConversationPage.tsx:193).
  • [reported] Receipts are one global blob, keyed by listing coord, not account-scoped (receipts.ts:64-71), and ListingOffersPanel never reads them back (:74,335), so the Pay button can reappear and a prior trade's receipt can be attached to a later deal.
  • [reported] Pay path correctness: unsupported pointer kinds (creq/clink) render an enabled Pay button (PayWithSats.tsx:117-138); pointerKind is trusted verbatim (:55-63); no BOLT11 expiry check (bolt11.ts:37-67); a non-SATS amount set by another client is displayed as one currency and paid as sats (ListingOffersPanel.tsx:52 vs usePayPaymentPointer.ts:70).
  • [reported] Wallet reliability: a normal NWC failure aborts with no WebLN/Cashu fallback (usePayPaymentPointer.ts:85-111), unlike useZaps; an NWC settled-after-timeout returns an empty preimage (useNWC.ts:237), silently downgrading a would-be verifiable receipt.
  • [verified] Offers never refreshed after a message. publishMessage did not invalidate the offer queries, so accept/counter/decline lagged behind the 60s stale window. Fixed this pass.
  • [reported] Offer UI confusions: every offer/counter is SATS with no currency selector (OfferDialog.tsx:54, useMarketplaceOffers.ts:264); the counter rejects note-only while the offer allows it (ListingOffersPanel.tsx:141 vs OfferDialog.tsx:48); decline/withdraw default their content to the other party's previous note (useMarketplaceOffers.ts:355); the accept button never restates the amount being accepted.

4. P2 — outage-as-empty, native-only, terminology, i18n

Outage renders as truth (a relay failure looks like "none"):

  • useTradeReputation -> TradeReputationCard returns null, TradeReputationPanel shows the empty state, TradesPage shows "No trades".
  • usePendingTradeConfirmations / useIncomingTradeRequests -> "Nothing waiting".
  • useListingTrade / useShipmentStatus -> ShippingStatusCard shows nothing shipped.
  • useSellerSales catches to 0 -> every seller is "new seller"; with an active trust filter, useBatchTrustScores zeros hide every listing.
  • useSellerInfo -> null (drops account-age/verified signals); useListingReports -> 0 (hides report warnings); SellerDashboard -> "no listings yet".
  • useListingOffers now passes requireAny and ListingOffersPanel shows an error — fixed this pass (previously an outage looked like "no offers").

Native-only, visible-but-dead on web:

  • escrow "required" badge on web (MarketplaceItemCard.tsx:366); the escrow action and EscrowRequestDialog native-only warning are unreachable because the dialog mounts only when canEscrow (ListingTradeActions.tsx:388); EscrowRequestDialog.tsx:134 is dead code; refund gated on escrow.available; Buy-with-sats falls back to a native-only Cashu melt.

Terminology drift: "Marketplace" / "Gear Swap" / "Trades" / "My Trades" used for the same surfaces; "Want to Buy" vs "WTB"; "Sold" vs "SOLD"; pickup/ships/both labelled from different keys; two loadFailed namespaces.

Hardcoded and untranslated: raw status.toUpperCase() (ListingDetailPage, MarketplaceItemCard), raw trade state (TradesPage), raw offer status (ListingOffersPanel), raw currency codes (ListingOffersPanel, SellerDashboard, types.ts formatPrice), raw price frequency, raw shipping value (GearSwapItemCard), hardcoded "PoW", TRADE_METHODS values, and the "Plebeian Market" promo page.


5. State coverage matrix (summary)

Surface loading empty error/outage not-logged-in own sold/expired native-only
GearSwap grid yes yes yes n/a card menu filtered silently n/a
Listing detail yes yes yes partial yes sold/inactive; no expired badge only
Hub tabs no no no selling/reputation n/a n/a n/a
Seller dashboard yes yes missing hub gates n/a sold; expired as active n/a
Trades page yes yes missing yes n/a n/a n/a
Reputation card none none none n/a n/a n/a n/a
Offers panel no no fixed (now fires) returns null returns null no state guard n/a
Escrow panel no returns null no n/a n/a no trade tie read-only on web
Shipping card no no missing returns null seller/buyer no sold/expired tie n/a

6. Fixed in this pass

First batch (correctness):

  1. Grid "Mark as sold" now opens MarkSoldDialog (records the 31871).
  2. Offers accept: only the listing owner attaches a payment pointer (payee fix).
  3. advertiseEscrowKey refuses to publish kind 0 before the profile has loaded.
  4. useListingOffers passes requireAny; the offers panel error state now fires.
  5. Offer messages invalidate the offer queries (no more stale Pay button). (Plus, earlier today: useMarketplaceItem moderation filter, offer CTA guards, URL-addressable hub tabs, offers panel error branch.)

Second batch (payment, attestation, escrow): 6. BOLT11 expiry is checked before paying (bolt11.ts getBolt11Expiry / isBolt11Expired), with a clear "invoice expired" error. 7. Unsupported pointer kinds (creq / clink) are shown as unavailable up front in PayWithSats instead of enabling a button that fails after the click. 8. Signature + revocation: useTradeReputation and useListingTrade verify events (onlyVerified) and keep the newest per (author, kind, d), so a newer s=revoked supersedes a stale valid copy; the pending/request queries are verified too. 9. Escrow fund safety: creation persists the minted token locally (lib/pendingEscrow.ts) before publishing the request, so a failed send cannot strand the locked funds, and the failure message says so. (Still a mitigation: there is no re-send/recover UI yet.) 10. Escrow mint selection: the escrow dialog now shows the mint and lets the buyer pick from their trusted mints; a mint whose NUT-06 info advertises no NUT-11 (P2PK) support is blocked with a clear message (cashuEscrow.mintSupportsP2PK), instead of failing deep in minting. 11. Payment cross-checks: the buyer now verifies the seller's committed payment_hash on the accepted offer against the invoice actually paid (a swapped pointer is rejected), and a non-SATS offer no longer renders an in-app sats Pay button — a fiat amount can never be paid as if it were sats. (Still open: WebLN/Cashu fallback after a normal NWC failure, and the empty-preimage case.) 12. Seller-advertised escrow mint: advertiseEscrowKey also publishes cashu_mint (the seller's preferred mint, from Wallet → Escrow) on kind 0, documented in NIP.md; the buyer's escrow dialog includes that mint in the picker and prefers it, so the buyer locks at a mint the seller can redeem from. (Still open: seller-side receive from a mint they do not already have.)

  1. P0 escrow fund-stranding: persist the token before publishing. Done (item 9); the remaining gap is a re-send/recover affordance for a saved pending escrow.
  2. P0 payee/pointer hardening + BOLT11 expiry. Partly done (items 2, 6, 7); still open: cross-check payment_hash and reject non-SATS amounts paid as sats.
  3. P1 correctness: honor escrow=required, allow ask-seller on an active listing, make Mark-sold atomic or verify the attestation, account-scope and read back receipts, fix the request tag back-reference.
  4. P1 state: NWC fallback (WebLN/Cashu on a normal failure) and empty-preimage handling; outage states for trade/shipment/seller/reputation surfaces.
  5. P2: native-only explanation on web, terminology consolidation, and the untranslated values.

Items tagged [reported] should be re-confirmed in source before a fix.