SkateSpots/src/lib/cashuEscrow.test.ts

240 lines
7.4 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();
});
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<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/);
});
});