SkateSpots/MARKETPLACE_FLOW_AUDIT.md
SkateSpots 7a60099bab fix(marketplace): show an outage instead of an empty trade/reputation state
TradesPage, SellerDashboard, TradeReputationPanel, TradeReputationCard and
ShippingStatusCard now surface a relay outage explicitly instead of
rendering nothing-waiting / no-trades / hidden reputation. Shared
marketUi.loadFailed (en/es).
2026-10-10 09:32:52 +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.) 13. Correctness guards: fixed-price Buy is disabled when the seller requires escrow (item.escrow === 'required'); ask-seller is available on an active listing (not only once sold); payment receipts are scoped to the payer's pubkey, so a stale/foreign receipt cannot be attached by another account. 14. Outage states (trade/reputation/seller/shipping): TradesPage, SellerDashboard, TradeReputationPanel, TradeReputationCard and ShippingStatusCard now show an explicit "could not reach the relays" notice (marketUi.loadFailed) instead of an empty / "nothing waiting" / "no trades" state when their query errors.

  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.