import { getDecodedToken, getEncodedToken, type Proof } from '@cashu/cashu-ts'; /** * Cashu escrow helpers (time-locked P2PK). * * Marketplace escrow uses Cashu **P2PK with a locktime and refund key**: * the buyer locks funds to the seller's escrow pubkey, and — after the * locktime — the buyer can reclaim them via the refund key. That gives real, * oracle-free escrow: * * • seller delivered → redeems with their key * • seller vanished → buyer refunds after the deadline * * NUT-11 P2PK secrets are a JSON blob hex-encoded into the proof `secret`: * {"nonce":"…","data":"","tags":[["sigflag","SIG_INPUTS"], * ["n_sigs","1"],["locktime",""],["refund",""]]} * * This module is pure/offline (no mint, no network) so it can be unit-tested * and safely used anywhere. Creating/redeeming lives in the wallet layer. */ export const SECONDS_PER_DAY = 86_400; export const ESCROW_MIN_LOCK_DAYS = 1; export const ESCROW_DEFAULT_LOCK_DAYS = 7; export interface EscrowPolicy { /** Seller pubkey the funds are locked to (compressed hex). */ pubkey: string; /** Unix seconds; at/after this the refund keys may spend. */ locktime: number; /** Buyer pubkey(s) allowed to refund after the locktime. */ refundKeys: string[]; /** Signatures required to spend (seller side). */ requiredSignatures: number; /** Signatures required to refund. */ requiredRefundSignatures: number; } export interface EscrowTokenInfo { mint: string; unit: string; amount: number; /** True when the token carries P2PK/HTLC spending conditions. */ locked: boolean; /** Parsed P2PK policy when present. */ policy: Partial | null; /** True when the locktime has passed (the buyer can reclaim). */ refundable: boolean; } const HEX_RE = /^[0-9a-f]+$/i; function isHex(value: string): boolean { return value.length > 0 && value.length % 2 === 0 && HEX_RE.test(value); } function hexToUtf8(hex: string): string { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i += 1) { bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return new TextDecoder().decode(bytes); } /** * Normalise a Cashu P2PK pubkey to 33-byte compressed hex. * * Nostr pubkeys are 32-byte **x-only**; Cashu expects the 33-byte compressed * form with an `02` prefix (matching coco's own `getPublicKeyHex`). Already * compressed keys are returned unchanged. Returns null for anything else. */ export function normalizeCashuPubkey(value: string | undefined): string | null { if (!value) return null; const v = value.trim().toLowerCase(); if (!HEX_RE.test(v)) return null; if (v.length === 66 && (v.startsWith('02') || v.startsWith('03'))) return v; if (v.length === 64) return `02${v}`; return null; } /** Unix seconds `days` from `from` (defaults to now). */ export function escrowLocktime(days: number, from: number = Date.now()): number { const safeDays = Math.max(ESCROW_MIN_LOCK_DAYS, Math.floor(days)); return Math.floor(from / 1000) + safeDays * SECONDS_PER_DAY; } export interface EscrowP2PKOptions { pubkey: string; locktime: number; refundKeys: string[]; requiredSignatures: number; requiredRefundSignatures: number; } /** * Build the `p2pk` options for a cashu-ts `mintProofs`/`send` call: funds are * locked to the **seller**, and the **buyer** (refund key) may reclaim them once * the locktime passes. Both keys are normalised to compressed hex. */ export function escrowP2PKOptions({ sellerPubkey, buyerPubkey, lockDays = ESCROW_DEFAULT_LOCK_DAYS, from = Date.now(), }: { sellerPubkey: string; buyerPubkey: string; lockDays?: number; from?: number; }): EscrowP2PKOptions { const seller = normalizeCashuPubkey(sellerPubkey); if (!seller) throw new Error('Seller key is not a valid Cashu pubkey.'); const buyer = normalizeCashuPubkey(buyerPubkey); if (!buyer) throw new Error('Your escrow key is not a valid Cashu pubkey.'); return { pubkey: seller, locktime: escrowLocktime(lockDays, from), refundKeys: [buyer], requiredSignatures: 1, requiredRefundSignatures: 1, }; } interface Nuts10Tags { data?: unknown; tags?: unknown; } /** Extract a P2PK policy from a proof secret, or null when it isn't locked. */ export function secretPolicy(secret: string): Partial | null { if (!isHex(secret)) return null; let parsed: unknown; try { parsed = JSON.parse(hexToUtf8(secret)); } catch { return null; } if (!parsed || typeof parsed !== 'object') return null; const obj = parsed as Nuts10Tags; const tags: string[][] = Array.isArray(obj.tags) ? (obj.tags.filter((t) => Array.isArray(t)) as string[][]) : []; const values = (name: string): string[] => tags.find((t) => t[0] === name)?.slice(1) ?? []; const primary = typeof obj.data === 'string' ? [obj.data] : []; const pubkeys = [...primary, ...values('pubkeys')]; const refund = values('refund'); const locktime = Number(values('locktime')[0] ?? 0); if (pubkeys.length === 0 && refund.length === 0 && !locktime) return null; const nSigs = Number(values('n_sigs')[0] ?? 1); const nSigsRefund = Number(values('n_sigs_refund')[0] ?? 1); return { pubkey: pubkeys[0] ? (normalizeCashuPubkey(pubkeys[0]) ?? pubkeys[0]) : undefined, locktime: Number.isFinite(locktime) && locktime > 0 ? locktime : undefined, refundKeys: refund .map((k) => normalizeCashuPubkey(k) ?? k) .filter((k): k is string => !!k), requiredSignatures: Number.isFinite(nSigs) && nSigs > 0 ? nSigs : 1, requiredRefundSignatures: Number.isFinite(nSigsRefund) && nSigsRefund > 0 ? nSigsRefund : 1, }; } /** * Inspect a Cashu token string offline. Throws on malformed input so callers * can surface a friendly error. */ export function parseEscrowToken(token: string): EscrowTokenInfo { let decoded: ReturnType; try { decoded = getDecodedToken(token.trim()); } catch { throw new Error('That is not a valid Cashu token.'); } const proofs = decoded.proofs ?? []; const amount = proofs.reduce((sum, p) => sum + p.amount, 0); let policy: Partial | null = null; for (const proof of proofs) { const found = secretPolicy(proof.secret); if (found) { policy = { ...(policy ?? {}), ...found }; break; } } const locktime = policy?.locktime ?? 0; return { mint: decoded.mint, unit: decoded.unit, amount, locked: !!policy, policy, refundable: !!locktime && Math.floor(Date.now() / 1000) >= locktime, }; } /** The subset of the cashu-ts `Wallet`/`CashuMint` API escrow creation uses. */ export interface EscrowMintWallet { createMintQuote(amount: number): Promise<{ quote: string; request: string }>; checkMintQuote(quote: string): Promise<{ state?: string }>; mintProofs( amount: number, quote: string, options: { p2pk: EscrowP2PKOptions }, ): Promise; } export const MINT_QUOTE_PAID = 'PAID'; export interface CreateEscrowDeps { /** A cashu-ts Wallet (or compatible fake in tests) for the chosen mint. */ wallet: EscrowMintWallet; /** Pay the mint's Lightning invoice (NWC / WebLN / Cashu melt). */ payInvoice: (bolt11: string) => Promise; /** Poll a mint quote until paid. Defaults to `wallet.checkMintQuote`. */ isPaid?: (quote: string) => Promise; /** How long to wait for the invoice to be paid. */ maxWaitMs?: number; /** Override the poll interval (tests pass 0). */ pollMs?: number; } /** * Create an escrowed Cashu token: mint fresh proofs locked to the seller with * the buyer as the refund key, funded by paying the mint's Lightning invoice. * * Funding by mint (rather than spending an existing wallet balance) keeps this * independent of the coco wallet's proof state. Dependencies are injected so * the whole flow can be unit-tested without a mint. */ export async function createEscrowToken( deps: CreateEscrowDeps, params: { mintUrl: string; amountSats: number; sellerPubkey: string; buyerPubkey: string; lockDays?: number; }, ): Promise<{ token: string; amount: number; locktime: number }> { const amountSats = Math.floor(params.amountSats); if (!Number.isFinite(amountSats) || amountSats <= 0) { throw new Error('Enter a positive escrow amount.'); } const p2pk = escrowP2PKOptions({ sellerPubkey: params.sellerPubkey, buyerPubkey: params.buyerPubkey, lockDays: params.lockDays, }); const quote = await deps.wallet.createMintQuote(amountSats); await deps.payInvoice(quote.request); const isPaid = deps.isPaid ?? (async (q: string) => (await deps.wallet.checkMintQuote(q))?.state === MINT_QUOTE_PAID); const pollMs = deps.pollMs ?? 1500; const deadline = Date.now() + (deps.maxWaitMs ?? 120_000); let paid = false; while (Date.now() < deadline) { if (await isPaid(quote.quote)) { paid = true; break; } if (pollMs > 0) await new Promise((resolve) => setTimeout(resolve, pollMs)); } if (!paid) { throw new Error('The mint never confirmed your payment — the mint quote expired.'); } const proofs = await deps.wallet.mintProofs(amountSats, quote.quote, { p2pk }); const token = getEncodedToken({ mint: params.mintUrl, unit: 'sat', proofs }); return { token, amount: amountSats, locktime: p2pk.locktime }; }