SkateSpots/src/lib/cashuEscrow.ts
SkateSpots 26e951710e 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.
2026-09-26 21:01:31 +07:00

285 lines
9.2 KiB
TypeScript

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 };
}