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).
236 lines
14 KiB
Markdown
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.
|