fix(escrow): show and choose the mint, block non-P2PK mints
The escrow mint was silently the buyer's preferred/first trusted mint (useCashuEscrow: prefs.preferredMintUrl || mints[0].url) with no UI, even though the seller redeems there. Now: - the escrow dialog shows the mint and lets the buyer pick from their trusted mints (defaults to their preference); - a mint whose NUT-06 info advertises no NUT-11 (P2PK) support is blocked with a clear message before minting (cashuEscrow.mintSupportsP2PK); - the create call uses the selected mint. Still open (scoped separately): consult a seller-advertised mint and seller-side receive from an unfamiliar mint. Recorded in the audit.
This commit is contained in:
parent
059e2ae0d3
commit
ac1b22674c
6 changed files with 71 additions and 3 deletions
|
|
@ -65,6 +65,16 @@ visibility, terminology, translation).
|
|||
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. Still open: consult a
|
||||
seller-advertised mint, and seller-side receive from an unfamiliar mint.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -190,6 +200,10 @@ Second batch (payment, attestation, escrow):
|
|||
(`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.
|
||||
|
||||
## 7. Recommended order
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
|||
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
|
||||
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
import { ESCROW_MIN_LOCK_DAYS, mintSupportsP2PK } from '@/lib/cashuEscrow';
|
||||
import { savePendingEscrow, clearPendingEscrow } from '@/lib/pendingEscrow';
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
|
@ -48,6 +48,13 @@ export function EscrowRequestDialog({
|
|||
const [amount, setAmount] = useState('');
|
||||
const [days, setDays] = useState(String(prefs.defaultLockDays));
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 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 mintOptions = escrow.mints;
|
||||
const effectiveMint = mintChoice || prefs.preferredMintUrl || mintOptions[0]?.url || '';
|
||||
const p2pkBlocked =
|
||||
mintSupportsP2PK(mintOptions.find((m) => m.url === effectiveMint)?.info) === false;
|
||||
|
||||
const submit = async () => {
|
||||
const amountSats = parseInt(amount, 10);
|
||||
|
|
@ -65,7 +72,7 @@ export function EscrowRequestDialog({
|
|||
setBusy(true);
|
||||
let mintedToken: string | undefined;
|
||||
try {
|
||||
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined });
|
||||
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: effectiveMint || undefined });
|
||||
mintedToken = token;
|
||||
// Persist BEFORE publishing: if the request fails to send, the P2PK-locked
|
||||
// funds stay recoverable from this device instead of being lost.
|
||||
|
|
@ -124,6 +131,28 @@ export function EscrowRequestDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escrow-mint" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{t('marketUi.escrowRequest.mintLabel')}
|
||||
</Label>
|
||||
<select
|
||||
id="escrow-mint"
|
||||
value={effectiveMint}
|
||||
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 && (
|
||||
<option value="">{t('marketUi.escrowRequest.noMints')}</option>
|
||||
)}
|
||||
{mintOptions.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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{t('marketUi.escrowRequest.daysLabel')}
|
||||
|
|
@ -145,6 +174,9 @@ export function EscrowRequestDialog({
|
|||
{t('marketUi.escrowRequest.nativeOnly')}
|
||||
</p>
|
||||
)}
|
||||
{p2pkBlocked && (
|
||||
<p className="text-xs text-amber-400">{t('marketUi.escrowRequest.p2pkUnsupported')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
||||
|
|
@ -152,7 +184,7 @@ export function EscrowRequestDialog({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={pending || !escrow.available}
|
||||
disabled={pending || !escrow.available || p2pkBlocked}
|
||||
className="flex-1 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export function useCashuEscrow() {
|
|||
return {
|
||||
available,
|
||||
defaultMintUrl,
|
||||
/** The buyer's trusted mints, for the escrow mint picker. */
|
||||
mints,
|
||||
getEscrowPubkey,
|
||||
createEscrow,
|
||||
redeemEscrow,
|
||||
|
|
|
|||
|
|
@ -2079,6 +2079,10 @@ marketUi: {
|
|||
daysLabel: 'Buyer refund after (days)',
|
||||
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)',
|
||||
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.',
|
||||
nativeOnly: 'Cashu escrow is available in the SkateSpots app only (not the web build).',
|
||||
lockAndRequest: 'Lock & request',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2079,6 +2079,10 @@ marketUi: {
|
|||
daysLabel: 'Reembolso del comprador tras (días)',
|
||||
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)',
|
||||
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.',
|
||||
nativeOnly: 'El depósito en garantía Cashu solo está disponible en la app de SkateSpots (no en la versión web).',
|
||||
lockAndRequest: 'Bloquear y solicitar',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -216,6 +216,18 @@ export interface EscrowMintWallet {
|
|||
|
||||
export const MINT_QUOTE_PAID = 'PAID';
|
||||
|
||||
/**
|
||||
* Whether a mint's NUT-06 info advertises NUT-11 (P2PK) support. Returns
|
||||
* `undefined` when the info is unknown, so callers can proceed optimistically
|
||||
* rather than block on missing metadata.
|
||||
*/
|
||||
export function mintSupportsP2PK(info: unknown): boolean | undefined {
|
||||
if (!info || typeof info !== 'object') return undefined;
|
||||
const nuts = (info as { nuts?: unknown }).nuts;
|
||||
if (!nuts || typeof nuts !== 'object') return undefined;
|
||||
return Object.prototype.hasOwnProperty.call(nuts, '11');
|
||||
}
|
||||
|
||||
export interface CreateEscrowDeps {
|
||||
/** A cashu-ts Wallet (or compatible fake in tests) for the chosen mint. */
|
||||
wallet: EscrowMintWallet;
|
||||
|
|
|
|||
Loading…
Reference in a new issue