From 26e951710eeef169482d96648ac4af030a63c413 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 21:01:31 +0700 Subject: [PATCH] feat(marketplace): Cashu escrow core (time-locked P2PK) + design Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key: buyer locks funds to the seller; seller redeems on delivery; the buyer can reclaim after the deadline. Locked (not plain) tokens are what make the cashu_token on a public 31872 safe. - src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK policy builder, offline token inspection (amount/mint/locktime/refund), and an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk}) - 15 unit tests incl. the mint orchestration against a fake wallet - Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides the existing 31872 cashu_token tag - ESCROW.md: design, threat model, status, limitations - AGENTS.md: project map + docs index updated Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still needs on-device verification. --- AGENTS.md | 7 +- ESCROW.md | 93 ++++++++++++ NIP.md | 34 ++--- src/lib/cashuEscrow.test.ts | 221 ++++++++++++++++++++++++++++ src/lib/cashuEscrow.ts | 285 ++++++++++++++++++++++++++++++++++++ 5 files changed, 621 insertions(+), 19 deletions(-) create mode 100644 ESCROW.md create mode 100644 src/lib/cashuEscrow.test.ts create mode 100644 src/lib/cashuEscrow.ts diff --git a/AGENTS.md b/AGENTS.md index 46bf748..4eedd7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,11 @@ session with **no other memory of this project**. So: - **Marketplace**: `GearSwapPage` + `MarketplaceItemCard`. Buyer/seller trade attestations build mutual, WoT-weighted reputation. **No payments are implemented** — "Buy" is intentionally absent; only Contact/Offer/Trade. +- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer + refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` + (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a + `cashu_escrow` pubkey in kind 0. **Native-only**; wallet UI still needs + on-device verification. - **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`). - **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). @@ -166,7 +171,7 @@ Rules: - `/src/contexts/`: React context providers (AppContext, LanguageContext, NWCContext) - `NIP.md`: **source of truth** for every custom/adopted Nostr kind and its schema - `AGENTS.md` (this file): the only cross-session memory — keep it current -- `UX_AUDIT.md`, `GROUP_MESSAGING.md`: feature decision/backlog docs +- `UX_AUDIT.md`, `GROUP_MESSAGING.md`, `ESCROW.md`: feature decision/backlog docs - `/src/test/`: Testing utilities including TestApp component - `/public/`: Static assets - `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application) diff --git a/ESCROW.md b/ESCROW.md new file mode 100644 index 0000000..3c4e62b --- /dev/null +++ b/ESCROW.md @@ -0,0 +1,93 @@ +# Cashu Escrow (marketplace) + +Status: **core implemented & unit-tested; wallet UI is native-only and awaits +on-device verification.** See "Status" at the bottom before trusting it with +real money. + +## Goal + +Let a buyer and seller close a marketplace deal where the buyer's funds are +**committed but recoverable**, so neither side has to trust the other blindly — +without a custodial third party and without an always-online oracle. + +## Model — time-locked P2PK + +Cashu **P2PK** (NUT-11) supports a **`locktime`** and **`refund` keys**. We use +exactly that: + +``` +buyer ──locks funds to──▶ seller pubkey + locktime = now + N days + refund = buyer pubkey +``` + +- **Seller delivered** → redeems with their key (any time). +- **Seller vanished / deal fell through** → after `locktime`, the **buyer** + reclaims with their refund key. + +This is real escrow (both sides protected) with **no witness/oracle** — the only +thing "release on attestation" needs is the locktime, which the mint enforces. + +Contrast with the naive option (lock to the seller, no expiry): that's +**prepayment**, not escrow — the seller can take the money and never deliver — +so we deliberately don't do it. + +## Keys + +Cashu P2PK needs a secp256k1 key the wallet can **sign a witness** with. The +Nostr signer (Amber / NIP-07) can't produce a bare schnorr signature, so we use +the **Cashu wallet's own keyring** (`coco.keyring`), not the Nostr key. + +Each user advertises one escrow pubkey for others to lock to: + +- **kind 0** content field **`cashu_escrow`** — 33-byte compressed hex + (`02…`), derived from the Cashu keyring. See `NIP.md` → Profile Metadata + Extensions. Nostr x-only keys are accepted too (normalised with an `02` + prefix, matching coco's own `getPublicKeyHex`). + +## Flow + +1. Seller advertises `cashu_escrow` (set when they touch their Cashu wallet). +2. Buyer reads the seller's pubkey, then **funds escrow by paying the mint's + Lightning invoice** and mints fresh proofs locked to the seller: + - `createMintQuote(amount)` → pay `request` via NWC/WebLN/Cashu melt + - `mintProofs(amount, quote, { p2pk: { pubkey, locktime, refundKeys:[buyer] } })` + - encode → the `cashu_token` on the `31872` confirmation request. + - Funding by mint (not by spending the coco balance) keeps this independent + of the coco wallet's proof state — no state bridging, no counter desync. +3. Seller sees the escrowed token on the request and redeems (`wallet.receive`). +4. If not delivered by `locktime`, the buyer refunds. + +## Protocol + +The escrow token rides the Attestations NIP's existing optional +`["cashu_token", ""]` tag on the **kind 31872** request (`NIP.md` → Trade +Attestations). The lock is what makes publishing it safe: a plain (unlocked) +token in a public event is spendable by anyone who sees it. + +## Threat model / limitations + +- **Mint trust**: P2PK keeps the mint from spending your funds, but you still + trust the mint to be solvent and redeemable. Use a reputable mint. +- **No delivery proof on-chain**: "delivered" is the seller's word; the buyer's + protection is the locktime refund, and the marketplace reputation system + (`31871`) records the outcome. +- **Locktime is unilateral**: the buyer can always refund after the deadline even + if the seller did deliver but was slow to redeem — so redemption should happen + promptly, and lock windows should be generous. +- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too. + +## Status + +- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation, + `escrowP2PKOptions`, offline token inspection/`parseEscrowToken`, + locktime/refund policy, and the injectable `createEscrowToken` mint flow. +- 🟡 **Wallet UI** (native-only): needs a real mint + Android device to verify; + do not treat as production until exercised end-to-end on-device. + +## Files + +- `src/lib/cashuEscrow.ts` (+ `.test.ts`) — pure escrow policy & inspection. +- `src/hooks/wallet/useCashu.ts` — Cashu wallet (native-only), keyring source. +- `src/hooks/useTradeAttestations.ts` — `31872` request carries `cashu_token`. +- `NIP.md` — profile field + protocol notes. diff --git a/NIP.md b/NIP.md index b9111c6..c361f83 100644 --- a/NIP.md +++ b/NIP.md @@ -15,6 +15,7 @@ other clients, and preserved on edit: | `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. | | `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | | `cordn` | string | A Cordn (cordn.net) contact URL for private, end-to-end encrypted messaging via a self-hosted coordinator. | +| `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`. | `lud16` (Lightning address) is standard NIP-01 but is also editable directly in the SkateSpots profile form for convenience. @@ -3164,27 +3165,24 @@ answered (they have a `31871` for that listing). - Consumers **trust-weight counterparties** with the existing web-of-trust score (as with Scene vouches) so fresh-account rings cannot inflate reputation. -## Escrow (planned — not implemented) +## Escrow (Cashu `cashu_token`) -The NIP allows a `31872` to carry a `["cashu_token", ""]`: a Cashu token -locked to spending conditions (P2PK/HTLC) so funds release when the requested -attestation is observed. **We do not implement this yet**, and the client accepts -an optional `cashu_token` only as a passthrough (no locking). Fully shipping it -needs: +A `31872` may carry `["cashu_token", ""]`. We use Cashu **P2PK with a +locktime and a refund key**, funded by paying the mint's Lightning invoice: -1. **Locked tokens** — `@cashu/cashu-ts` can create P2PK/HTLC outputs, but our - wallet (`coco-cashu-core`) only exposes plain `send()`. Escrow would bypass - coco and use `cashu-ts` directly with the same seed. -2. **Native only** — the bundled Cashu wallet is gated to Capacitor builds - (`useCashu` returns early on web, where mints are CORS-blocked), so an escrow - UI can only run in the app. -3. **A release witness** — Cashu conditions lock to a key or a hash preimage, not - to "a Nostr event was seen". Releasing *on attestation* requires a trusted - witness/oracle that watches for the `31871` and releases. Without one, a token - locked to the seller's key is prepayment, not escrow. +- buyer locks funds to the **seller's** escrow pubkey (`cashu_escrow` on the + seller's profile), with `locktime = now + N days` and the **buyer** as the + refund key; +- seller delivers → redeems; if not, the buyer refunds after the locktime. -Until all three exist, escrow stays off to avoid a payment flow that looks -trustless but isn't. +This is real escrow (no oracle needed) because the mint enforces the locktime — +locking to the seller *without* an expiry would be prepayment, not escrow, so we +don't. A plain (unlocked) token must **never** be put in a public event: anyone +who sees it can spend it; the lock is what makes publishing safe. + +Full design, threat model and status: **`ESCROW.md`**. Core logic lives in +`src/lib/cashuEscrow.ts` (unit-tested). Escrow is **native-only** (the Cashu +wallet is Capacitor-gated). ## Future (from the NIP) diff --git a/src/lib/cashuEscrow.test.ts b/src/lib/cashuEscrow.test.ts new file mode 100644 index 0000000..c6269b3 --- /dev/null +++ b/src/lib/cashuEscrow.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest'; +import { getEncodedToken, type Proof } from '@cashu/cashu-ts'; +import { + normalizeCashuPubkey, + escrowLocktime, + escrowP2PKOptions, + secretPolicy, + parseEscrowToken, + createEscrowToken, + type EscrowP2PKOptions, + SECONDS_PER_DAY, + ESCROW_MIN_LOCK_DAYS, +} from './cashuEscrow'; + +const SELLER_XONLY = 'ab'.repeat(32); +const SELLER = `02${SELLER_XONLY}`; +const BUYER = `02${'cd'.repeat(32)}`; + +function toHex(text: string): string { + return [...new TextEncoder().encode(text)] + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function p2pkSecret(overrides: Record = {}): string { + return toHex( + JSON.stringify({ + nonce: '00'.repeat(8), + data: SELLER, + tags: [ + ['sigflag', 'SIG_INPUTS'], + ['n_sigs', '1'], + ['locktime', String(overrides.locktime ?? '4102444800')], + ['refund', BUYER], + ], + }), + ); +} + +function token(secret: string, amount: number): string { + return getEncodedToken({ + mint: 'https://mint.example', + unit: 'sat', + proofs: [{ id: '009a1f293253e41e', amount, secret, C: `02${'00'.repeat(32)}` }], + }); +} + +function lockedProofs(options: EscrowP2PKOptions, amount = 1000): Proof[] { + const secret = toHex( + JSON.stringify({ + nonce: '00'.repeat(8), + data: options.pubkey, + tags: [ + ['sigflag', 'SIG_INPUTS'], + ['n_sigs', String(options.requiredSignatures)], + ['locktime', String(options.locktime)], + ['refund', ...options.refundKeys], + ], + }), + ); + return [{ id: '009a1f293253e41e', amount, secret, C: `02${'00'.repeat(32)}` }]; +} + +describe('normalizeCashuPubkey', () => { + it('prefixes 32-byte x-only keys with 02', () => { + expect(normalizeCashuPubkey(SELLER_XONLY)).toBe(SELLER); + }); + + it('leaves compressed keys unchanged', () => { + expect(normalizeCashuPubkey(SELLER)).toBe(SELLER); + }); + + it('rejects non-hex and wrong-length input', () => { + expect(normalizeCashuPubkey('nope')).toBeNull(); + expect(normalizeCashuPubkey('1234')).toBeNull(); + expect(normalizeCashuPubkey(undefined)).toBeNull(); + }); +}); + +describe('escrowLocktime', () => { + it('adds the requested number of days', () => { + const from = 1_000_000_000_000; // ms + expect(escrowLocktime(3, from)).toBe(Math.floor(from / 1000) + 3 * SECONDS_PER_DAY); + }); + + it('enforces a minimum lock of one day', () => { + const from = 0; + expect(escrowLocktime(0, from)).toBe((from / 1000) + ESCROW_MIN_LOCK_DAYS * SECONDS_PER_DAY); + }); +}); + +describe('secretPolicy', () => { + it('parses a P2PK secret into a policy', () => { + const policy = secretPolicy(p2pkSecret()); + expect(policy?.pubkey).toBe(SELLER); + expect(policy?.refundKeys).toContain(BUYER); + expect(policy?.locktime).toBe(4102444800); + expect(policy?.requiredSignatures).toBe(1); + }); + + it('returns null for non-locked secrets', () => { + expect(secretPolicy('00'.repeat(32))).toBeNull(); + expect(secretPolicy(toHex('not json'))).toBeNull(); + }); +}); + +describe('escrowP2PKOptions', () => { + it('locks to the seller with the buyer as refund key', () => { + const opts = escrowP2PKOptions({ + sellerPubkey: SELLER_XONLY, + buyerPubkey: BUYER, + lockDays: 3, + from: 1_000_000_000_000, + }); + expect(opts.pubkey).toBe(SELLER); + expect(opts.refundKeys).toEqual([BUYER]); + expect(opts.locktime).toBe(Math.floor(1_000_000_000_000 / 1000) + 3 * SECONDS_PER_DAY); + expect(opts.requiredRefundSignatures).toBe(1); + }); + + it('rejects invalid keys', () => { + expect(() => escrowP2PKOptions({ sellerPubkey: 'bad', buyerPubkey: BUYER })).toThrow(); + expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).toThrow(); + }); +}); + +describe('parseEscrowToken', () => { + it('reads amount, mint and lock policy', () => { + const info = parseEscrowToken(token(p2pkSecret(), 2100)); + expect(info.amount).toBe(2100); + expect(info.mint).toBe('https://mint.example'); + expect(info.locked).toBe(true); + expect(info.policy?.locktime).toBe(4102444800); + expect(info.refundable).toBe(false); + }); + + it('marks past locktimes as refundable', () => { + const info = parseEscrowToken(token(p2pkSecret({ locktime: '1' }), 21)); + expect(info.refundable).toBe(true); + }); + + it('treats a plain token as unlocked', () => { + const plain = getEncodedToken({ + mint: 'https://mint.example', + unit: 'sat', + proofs: [{ id: '009a1f293253e41e', amount: 5, secret: '00'.repeat(32), C: `02${'00'.repeat(32)}` }], + }); + const info = parseEscrowToken(plain); + expect(info.locked).toBe(false); + expect(info.refundable).toBe(false); + }); + + it('throws on a malformed token', () => { + expect(() => parseEscrowToken('not-a-token')).toThrow(); + }); +}); + +describe('createEscrowToken', () => { + it('pays the invoice, mints locked proofs, and returns a parsable token', async () => { + const paid: string[] = []; + let seen: EscrowP2PKOptions | null = null; + + const wallet = { + async createMintQuote(amount: number) { + expect(amount).toBe(1000); + return { quote: 'q1', request: 'lnbc1fake' }; + }, + async checkMintQuote() { + return { state: 'PAID' }; + }, + async mintProofs(amount: number, quote: string, options: { p2pk: EscrowP2PKOptions }) { + expect(amount).toBe(1000); + expect(quote).toBe('q1'); + seen = options.p2pk; + return lockedProofs(options.p2pk); + }, + }; + + const result = await createEscrowToken( + { wallet, payInvoice: async (bolt11) => { paid.push(bolt11); }, pollMs: 0 }, + { + mintUrl: 'https://mint.example', + amountSats: 1000, + sellerPubkey: SELLER_XONLY, + buyerPubkey: BUYER, + lockDays: 2, + }, + ); + + expect(paid).toEqual(['lnbc1fake']); + expect(result.amount).toBe(1000); + expect(seen).toMatchObject({ pubkey: SELLER, refundKeys: [BUYER] }); + + const info = parseEscrowToken(result.token); + expect(info.locked).toBe(true); + expect(info.policy?.pubkey).toBe(SELLER); + expect(info.policy?.refundKeys).toContain(BUYER); + expect(info.refundable).toBe(false); + }); + + it('throws when the invoice is never paid (and never mints)', async () => { + const wallet = { + async createMintQuote() { + return { quote: 'q', request: 'r' }; + }, + async checkMintQuote() { + return { state: 'UNPAID' }; + }, + async mintProofs(): Promise { + throw new Error('should not mint'); + }, + }; + + await expect( + createEscrowToken( + { wallet, payInvoice: async () => {}, maxWaitMs: 5, pollMs: 1 }, + { mintUrl: 'https://mint.example', amountSats: 10, sellerPubkey: SELLER, buyerPubkey: BUYER }, + ), + ).rejects.toThrow(/never confirmed/); + }); +}); diff --git a/src/lib/cashuEscrow.ts b/src/lib/cashuEscrow.ts new file mode 100644 index 0000000..38ae72b --- /dev/null +++ b/src/lib/cashuEscrow.ts @@ -0,0 +1,285 @@ +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 }; +}