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).
255 lines
15 KiB
Markdown
255 lines
15 KiB
Markdown
# 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, cosmetic] Request back-reference is wrong.** `publishTrade` writes
|
|
the listing `a` coord as the `request` tag (`useTradeAttestations.ts:141`) where
|
|
NIP.md documents `31872:<requestor>:<d>`. Confirmed not consumed anywhere in the
|
|
client (a repo-wide scan finds no `request` tag 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`), 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.
|
|
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
|
|
|
|
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.
|