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).
This commit is contained in:
SkateSpots 2026-10-10 08:57:33 +07:00
parent c71d176226
commit b48f0e53ee
7 changed files with 43 additions and 12 deletions

View file

@ -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

1
NIP.md
View file

@ -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=<nprofile>` 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.

View file

@ -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<string, unknown> | 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 && (
<option value="">{t('marketUi.escrowRequest.noMints')}</option>
)}
{mintOptions.map((m) => (
{mintChoices.map((m) => (
<option key={m.url} value={m.url}>
{m.url.replace(/^https?:\/\//, '')}
</option>
))}
</select>
<p className="text-[11px] text-gray-500">{t('marketUi.escrowRequest.mintHint')}</p>
{sellerMint && (
<p className="text-[11px] text-[#ccff00]">
{t('marketUi.escrowRequest.sellerMintHint')}
</p>
)}
</div>
<div className="space-y-2">

View file

@ -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'),

View file

@ -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<string> => {
const advertiseEscrowKey = useCallback(async (mintUrl?: string): Promise<string> => {
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<string, unknown>;
await publish({
kind: 0,
content: JSON.stringify({ ...current, cashu_escrow: pubkey }),
});
const next: Record<string, unknown> = { ...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]);

View file

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

View file

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