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.
This commit is contained in:
SkateSpots 2026-09-26 21:01:31 +07:00
parent 8255d9d5e8
commit 26e951710e
5 changed files with 621 additions and 19 deletions

View file

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

93
ESCROW.md Normal file
View file

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

34
NIP.md
View file

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

221
src/lib/cashuEscrow.test.ts Normal file
View file

@ -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, string> = {}): 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<Proof[]> {
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/);
});
});

285
src/lib/cashuEscrow.ts Normal file
View file

@ -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":"<pubkey>","tags":[["sigflag","SIG_INPUTS"],
* ["n_sigs","1"],["locktime","<unix>"],["refund","<pubkey>"]]}
*
* 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<EscrowPolicy> | 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<EscrowPolicy> | 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<typeof getDecodedToken>;
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<EscrowPolicy> | 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<Proof[]>;
}
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<void>;
/** Poll a mint quote until paid. Defaults to `wallet.checkMintQuote`. */
isPaid?: (quote: string) => Promise<boolean>;
/** 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 };
}