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(); }); it('builds an optional seller+arbiter / buyer+arbiter 2-of-3 policy', () => { const arbiter = '02' + 'c'.repeat(64); const opts = escrowP2PKOptions({ sellerPubkey: SELLER_XONLY, buyerPubkey: BUYER, arbiterPubkey: arbiter, }); expect(opts.pubkey).toEqual([SELLER, arbiter]); expect(opts.refundKeys).toEqual([BUYER, arbiter]); expect(opts.requiredSignatures).toBe(2); expect(opts.requiredRefundSignatures).toBe(2); }); it('rejects an arbiter that is also the buyer or seller', () => { expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: BUYER, arbiterPubkey: SELLER }), ).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/); }); });