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.
297 lines
9.7 KiB
TypeScript
297 lines
9.7 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';
|
|
|
|
/**
|
|
* 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;
|
|
/** 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 };
|
|
}
|