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.',