Seller UX: the offer thread shows a Close deal button on an accepted, unsold listing. It opens MarkSoldDialog prefilled with the buyer, the agreed amount, and the settlement method (lightning when a payment pointer was attached, else cash), so the seller never pastes an npub by hand. MarkSoldDialog gains defaultBuyer/defaultAmountSats/defaultMethod and re-seeds on open. New marketUi.offers.closeDeal (en/es).
15 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, cosmetic] Request back-reference is wrong.
publishTradewrites the listingacoord as therequesttag (useTradeAttestations.ts:141) where NIP.md documents31872:<requestor>:<d>. Confirmed not consumed anywhere in the client (a repo-wide scan finds norequesttag reader), so it is a spec-conformance issue only, not a functional one; deferred rather than risk a writer/reader mismatch. - [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.)
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.
15. Localized trade status: the trade-record state and the listing
pending status are no longer printed raw (marketUi.trades.stateRevoked /
stateVerifying, marketUi.statusPending).
16. Close the deal from an accepted offer (product improvement): the offer
thread now shows a Close deal button to the seller on an accepted,
unsold listing, opening MarkSoldDialog prefilled with the buyer, the
agreed amount, and the settlement method — no more pasting an npub by hand.
New marketUi.offers.closeDeal.
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.