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.
221 lines
6.7 KiB
TypeScript
221 lines
6.7 KiB
TypeScript
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/);
|
|
});
|
|
});
|