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

236 lines
14 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] 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.)
## 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.