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).
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
- Browse —
/marketplacehub, Browse tab ->GearSwapPagegrid (GearSwapItemCard). - Listing detail —
/listing/:pubkey/:identifier-> seller block, price,ListingTradeActions(buy / trade / escrow), optional "Make an offer",ListingOffersPanel,TradeReputationCard. - Offer negotiation (kind
30403) — offer -> counter -> accept / decline / withdraw; on accept apaymentpointer may be attached; then in-app pay. - Close the deal — seller "Mark as sold" ->
MarkSoldDialog(buyer, method, amount) ->markSold(kind30402status=sold) +publishTrade(kind31871). - Mutual reputation — buyer "Confirm this trade" -> second
31871with optional payment proof ->useTradeReputation. - Shipment (kind
34967) — seller "shipped", buyer "received"/"issue". - 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.
respondinListingOffersPanel.tsx(~line 95) attaches the accepting user'slud16as thepaymentpointer with no role check. When the buyer accepts the seller's counter, the buyer's address is published as the payee;PayWithSatsis passedpointer = latest.paymentandsellerPubkey = 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]
advertiseEscrowKeycould wipe the profile. It published kind 0 fromauthor.data?.metadata ?? {}(src/hooks/wallet/useCashuEscrow.ts:134); if the profile query had not resolved, it wrote a profile containing onlycashu_escrow, erasing name/picture/about/lud16/nip05. Fixed this pass (refuses to publish untilauthor.datahas loaded). - [reported] Escrow funding can strand funds.
EscrowRequestDialog.tsx:64-87mints and P2PK-locks the funds, then publishes the31872; 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-284never persists the proofs. Native-only and unverified (seeESCROW.md), but this is the worst-case path. - [reported] Buyer refund is unreachable on web.
ListingTradeActions.tsx:196-208gates refund onescrow.available(native), so a web user holding an escrowed token can see theEscrowPanelbut cannot reclaim after locktime. - [reported] A sold listing can be left with no attestation.
MarkSoldDialog.tsx:70-77runsmarkSoldthenpublishTradenon-atomically; if the second fails (or is queued offline, which resolves —useNostrPublish.ts:77-89), the listing is sold but no31871exists, so no reputation and no confirmation path. - [verified] Escrow mint is chosen by the buyer, not the payee.
useCashuEscrowresolves the mint asprefs.preferredMintUrl || mints[0].url(the buyer's first trusted mint,useCashuEscrow.ts:40,103), andEscrowRequestDialogdid 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_minton 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.
GearSwapItemCardcalled onlymarkSold, so no31871/buyer/reputation came from the grid, unlike the detail and dashboard. Fixed this pass (opensMarkSoldDialog). - [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 = requireddoes 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 stalevalidcopy from one relay can outlive a newerrevoked;iConfirmedcan stay true. - [reported]
useTradeReputationskipsonlyVerified(:216-222), unlikeuseTradeIntegrity/useShipmentStatus, so a hostile relay can inject forged reputation events. - [reported] Request back-reference is wrong.
publishTradewrites the listingacoord as therequesttag (useTradeAttestations.ts:141), but NIP.md documents31872:<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), andListingOffersPanelnever 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);pointerKindis trusted verbatim (:55-63); no BOLT11 expiry check (bolt11.ts:37-67); a non-SATSamountset by another client is displayed as one currency and paid as sats (ListingOffersPanel.tsx:52vsusePayPaymentPointer.ts:70). - [reported] Wallet reliability: a normal NWC failure aborts with no WebLN/Cashu
fallback (
usePayPaymentPointer.ts:85-111), unlikeuseZaps; 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.
publishMessagedid 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
SATSwith no currency selector (OfferDialog.tsx:54,useMarketplaceOffers.ts:264); the counter rejects note-only while the offer allows it (ListingOffersPanel.tsx:141vsOfferDialog.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->TradeReputationCardreturns null,TradeReputationPanelshows the empty state,TradesPageshows "No trades".usePendingTradeConfirmations/useIncomingTradeRequests-> "Nothing waiting".useListingTrade/useShipmentStatus->ShippingStatusCardshows nothing shipped.useSellerSalescatches to 0 -> every seller is "new seller"; with an active trust filter,useBatchTrustScoreszeros hide every listing.useSellerInfo-> null (drops account-age/verified signals);useListingReports-> 0 (hides report warnings);SellerDashboard-> "no listings yet".useListingOffersnow passesrequireAnyandListingOffersPanelshows 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 andEscrowRequestDialognative-only warning are unreachable because the dialog mounts only whencanEscrow(ListingTradeActions.tsx:388);EscrowRequestDialog.tsx:134is dead code; refund gated onescrow.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):
- Grid "Mark as sold" now opens
MarkSoldDialog(records the31871). - Offers accept: only the listing owner attaches a payment pointer (payee fix).
advertiseEscrowKeyrefuses to publish kind 0 before the profile has loaded.useListingOfferspassesrequireAny; the offers panel error state now fires.- Offer messages invalidate the offer queries (no more stale Pay button).
(Plus, earlier today:
useMarketplaceItemmoderation 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.)
7. Recommended order
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.P0 payee/pointer hardening + BOLT11 expiry.Partly done (items 2, 6, 7); still open: cross-checkpayment_hashand reject non-SATS amounts paid as sats.- 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 therequesttag back-reference. - P1 state: NWC fallback (WebLN/Cashu on a normal failure) and empty-preimage handling; outage states for trade/shipment/seller/reputation surfaces.
- P2: native-only explanation on web, terminology consolidation, and the untranslated values.
Items tagged [reported] should be re-confirmed in source before a fix.