From b48f0e53ee6e4f95c0e87e24f3db35f8601f1494 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 10 Oct 2026 08:57:33 +0700 Subject: [PATCH] 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). --- MARKETPLACE_FLOW_AUDIT.md | 12 ++++++++++-- NIP.md | 1 + src/components/EscrowRequestDialog.tsx | 24 ++++++++++++++++++++---- src/components/wallet/EscrowKeyCard.tsx | 4 +++- src/hooks/wallet/useCashuEscrow.ts | 12 +++++++----- src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/MARKETPLACE_FLOW_AUDIT.md b/MARKETPLACE_FLOW_AUDIT.md index a009997..20ec4d4 100644 --- a/MARKETPLACE_FLOW_AUDIT.md +++ b/MARKETPLACE_FLOW_AUDIT.md @@ -73,8 +73,11 @@ visibility, terminology, translation). 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. Still open: consult a - seller-advertised mint, and seller-side receive from an unfamiliar mint. + 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). --- @@ -210,6 +213,11 @@ Second batch (payment, attestation, escrow): 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 diff --git a/NIP.md b/NIP.md index ec07b9d..50ea6d8 100644 --- a/NIP.md +++ b/NIP.md @@ -16,6 +16,7 @@ other clients, and preserved on edit: | `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | | `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. | | `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. | +| `cashu_mint` | string | Preferred Cashu mint URL for marketplace escrow (`https://…`). Buyers are nudged to lock funds at this mint, since a mint the seller already uses is one they can redeem from. Optional; see `ESCROW.md`. | `lud16` (Lightning address) is standard NIP-01 but is also editable directly in the SkateSpots profile form for convenience. diff --git a/src/components/EscrowRequestDialog.tsx b/src/components/EscrowRequestDialog.tsx index 46435bc..ca62688 100644 --- a/src/components/EscrowRequestDialog.tsx +++ b/src/components/EscrowRequestDialog.tsx @@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs'; +import { useAuthor } from '@/hooks/useAuthor'; import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations'; import { useToast } from '@/hooks/useToast'; import { ESCROW_MIN_LOCK_DAYS, mintSupportsP2PK } from '@/lib/cashuEscrow'; @@ -51,10 +52,20 @@ export function EscrowRequestDialog({ // Buyer-facing mint choice: default to their preference, else the first trusted // mint. Shown explicitly so the buyer knows where the funds will be locked. const [mintChoice, setMintChoice] = useState(''); + const sellerAuthor = useAuthor(sellerPubkey); + const sellerMintRaw = (sellerAuthor.data?.metadata as Record | undefined)?.cashu_mint; + const sellerMint = + typeof sellerMintRaw === 'string' && sellerMintRaw.trim() ? sellerMintRaw.trim() : undefined; const mintOptions = escrow.mints; - const effectiveMint = mintChoice || prefs.preferredMintUrl || mintOptions[0]?.url || ''; + // Include the seller's advertised mint even if it is not in our trust list, and + // prefer it — a mint the seller actually uses is one they can redeem from. + const mintChoices = + sellerMint && !mintOptions.some((m) => m.url === sellerMint) + ? [...mintOptions, { url: sellerMint }] + : mintOptions; + const effectiveMint = mintChoice || sellerMint || prefs.preferredMintUrl || mintChoices[0]?.url || ''; const p2pkBlocked = - mintSupportsP2PK(mintOptions.find((m) => m.url === effectiveMint)?.info) === false; + mintSupportsP2PK(mintChoices.find((m) => m.url === effectiveMint)?.info) === false; const submit = async () => { const amountSats = parseInt(amount, 10); @@ -141,16 +152,21 @@ export function EscrowRequestDialog({ onChange={(e) => setMintChoice(e.target.value)} className="h-11 w-full rounded-md border border-gray-700 bg-gray-900 px-3 text-sm text-white" > - {mintOptions.length === 0 && ( + {mintChoices.length === 0 && ( )} - {mintOptions.map((m) => ( + {mintChoices.map((m) => ( ))}

{t('marketUi.escrowRequest.mintHint')}

+ {sellerMint && ( +

+ {t('marketUi.escrowRequest.sellerMintHint')} +

+ )}
diff --git a/src/components/wallet/EscrowKeyCard.tsx b/src/components/wallet/EscrowKeyCard.tsx index 733e93b..d4f5300 100644 --- a/src/components/wallet/EscrowKeyCard.tsx +++ b/src/components/wallet/EscrowKeyCard.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; +import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; @@ -16,6 +17,7 @@ import { Handshake, Loader2, Check } from 'lucide-react'; export function EscrowKeyCard() { const { t } = useTranslation(); const escrow = useCashuEscrow(); + const { prefs } = useEscrowPrefs(); const { user } = useCurrentUser(); const author = useAuthor(user?.pubkey); const { toast } = useToast(); @@ -29,7 +31,7 @@ export function EscrowKeyCard() { const publish = async () => { setBusy(true); try { - await escrow.advertiseEscrowKey(); + await escrow.advertiseEscrowKey(prefs.preferredMintUrl || escrow.defaultMintUrl || undefined); toast({ title: t('walletUi.escrow.published'), description: t('walletUi.escrow.publishedDesc'), diff --git a/src/hooks/wallet/useCashuEscrow.ts b/src/hooks/wallet/useCashuEscrow.ts index d1e41f9..c0d5896 100644 --- a/src/hooks/wallet/useCashuEscrow.ts +++ b/src/hooks/wallet/useCashuEscrow.ts @@ -126,7 +126,7 @@ export function useCashuEscrow() { * Publish this user's escrow pubkey to their kind 0 profile (`cashu_escrow`) * so buyers can lock funds to them. */ - const advertiseEscrowKey = useCallback(async (): Promise => { + const advertiseEscrowKey = useCallback(async (mintUrl?: string): Promise => { if (!user) throw new Error('Log in first.'); const pubkey = await getEscrowPubkey(); if (!pubkey) throw new Error('Could not derive your escrow key.'); @@ -138,10 +138,12 @@ export function useCashuEscrow() { throw new Error('Your profile has not loaded yet. Try again in a moment.'); } const current = (author.data.metadata ?? {}) as Record; - await publish({ - kind: 0, - content: JSON.stringify({ ...current, cashu_escrow: pubkey }), - }); + const next: Record = { ...current, cashu_escrow: pubkey }; + // Advertise the mint the seller wants escrow funded at, so a buyer can prefer + // one the seller can actually redeem from. + const mint = mintUrl?.trim(); + if (mint) next.cashu_mint = mint; + await publish({ kind: 0, content: JSON.stringify(next) }); return pubkey; }, [user, getEscrowPubkey, author.data, publish]); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5d60e20..06a5712 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2081,6 +2081,7 @@ marketUi: { daysHint: "After this many days you can reclaim the funds if the seller hasn't delivered.", lockedNotSent: 'Funds are locked in escrow and saved on this device, but the request did not send. Reopen the listing to retry. Do not pay again.', mintLabel: 'Mint (where the funds are locked)', + sellerMintHint: 'The seller prefers this mint; funds locked here are the easiest for them to redeem.', mintHint: 'Escrow lives at this mint. The seller redeems here, so pick one you both trust.', noMints: 'No mints available', p2pkUnsupported: 'This mint does not advertise P2PK (NUT-11) support, so escrow cannot be locked here. Choose another mint.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2b85994..88c6892 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2081,6 +2081,7 @@ marketUi: { daysHint: 'Tras estos días puedes recuperar los fondos si el vendedor no ha entregado.', lockedNotSent: 'Los fondos están bloqueados en escrow y guardados en este dispositivo, pero la solicitud no se envió. Reabre el anuncio para reintentar. No pagues otra vez.', mintLabel: 'Mint (donde se bloquean los fondos)', + sellerMintHint: 'El vendedor prefiere este mint; los fondos bloqueados aquí son los más fáciles de canjear para él.', mintHint: 'El escrow vive en este mint. El vendedor lo canjea aquí, así que elige uno en el que confiéis ambos.', noMints: 'No hay mints disponibles', p2pkUnsupported: 'Este mint no anuncia compatibilidad con P2PK (NUT-11), así que el escrow no se puede bloquear aquí. Elige otro mint.',