Files
wikideck/tests/shkeeper.test.ts
T
2026-10-20 10:06:38 -04:00

232 lines
19 KiB
TypeScript
Executable File

import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID,generateKeyPairSync,sign } from 'node:crypto';
import { mkdtemp,rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { keccak } from 'hash-wasm';
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
import { NETWORK } from '../shared/network.ts';
import { CHAIN_ID,address,sha256,transactionId } from '../server/testnet/consensus.ts';
import { atomicXmr,validateXmrAddress } from '../server/market/xmr.ts';
import { createLiveProcessor,createSHKeeperHarness,type InvoiceObservation,type PayoutResult,type FixtureTransport } from '../server/market/shkeeper.ts';
import { SettlementLab,sellerSettlementMessage,buyerInvoiceMessage,type SettlementTerms,type DeliveryEvidence } from '../server/market/settlement-lab.ts';
const credentials={apiKey:'ephemeral-fixture-key',username:'fixture-user',password:'fixture-password-not-live'};
const merchant=ADDITIONAL_MARKET_FEE.address;
async function fixtureAddress(prefix=18){
const bytes=Uint8Array.from([prefix,...Array(64).fill(2)]),checksum=Buffer.from((await keccak(bytes,256)).slice(0,8),'hex');
const data=Buffer.concat([bytes,checksum]),alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',sizes:Record<number,number>={1:2,2:3,3:5,4:6,5:7,6:9,7:10,8:11};
let result='';
for(let i=0;i<data.length;i+=8){const chunk=data.subarray(i,i+8);let n=BigInt(`0x${chunk.toString('hex')}`),text='';while(n){text=alphabet[Number(n%58n)]+text;n/=58n;}result+=text.padStart(sizes[chunk.length],'1');}
return result; // Checksum fixture only; not a funded wallet or curve/ownership proof.
}
function identity(){
const keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),publicKey=keys.publicKey.export({format:'der',type:'spki'}).toString('base64');
return {publicKey,address:address(publicKey),sign:(s:string)=>sign('sha256',Buffer.from(s),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')};
}
const seller=identity(),buyer=identity();
function fakeProcessor(){
const invoices=new Map<string,InvoiceObservation>(),tasks=new Map<string,{amount:string;destination:string}>();
let payoutMode:'success'|'pending'|'failure'|'mismatch'|'lost-response'='success';
let duplicateTx=false,duplicateTask=false,recalculate=false,failSecond=false,loseInvoice=false;
const posts:{path:string;body:Record<string,string>}[]=[];
const transport:FixtureTransport=async(url,init)=>{
assert.ok(url.startsWith('https://shkeeper.eom.dev/api/v1/'));assert.equal(init.redirect,'error');assert.ok(init.signal);
const headers=init.headers as Record<string,string>,path=new URL(url).pathname;
const basic=path.includes('/payout')||path.includes('/task/');
assert.equal(headers.Accept,'application/json');
if(basic){assert.equal(headers.Authorization,`Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`);assert.equal(headers['X-Shkeeper-Api-Key'],undefined);}
else{assert.equal(headers['X-Shkeeper-Api-Key'],credentials.apiKey);assert.equal(headers.Authorization,undefined);}
if(init.method==='POST'){
const body=JSON.parse(init.body as string);posts.push({path,body});
if(path.endsWith('/payment_request')){
assert.equal(body.fiat,'USD');assert.equal(body.amount,'15.00');
invoices.set(body.external_id,{external_id:body.external_id,fiat:'USD',amount_fiat:'15.00',balance_fiat:'0.00',status:'UNPAID',txs:[]});
if(loseInvoice)throw new Error('created invoice, response lost');
return Response.json({status:'success',id:randomUUID(),wallet:merchant,amount:'0.100000000000',exchange_rate:'150',recalculate_after:recalculate?60:0});
}
assert.equal(body.fee,'2');const id=duplicateTask&&tasks.size?[...tasks.keys()][0]:randomUUID();tasks.set(id,{amount:body.amount,destination:body.destination});
if(payoutMode==='lost-response')throw new Error(`Sensitive upstream detail ${credentials.password}`);
return Response.json({task_id:id});
}
if(path.includes('/invoices/'))return Response.json({status:'success',invoices:[invoices.get(path.split('/').at(-1)!)]});
const id=path.split('/').at(-1)!,task=tasks.get(id);assert.ok(task);
const failure=payoutMode==='failure'||(failSecond&&tasks.size===2&&id===[...tasks.keys()][1]);
const result:PayoutResult=payoutMode==='pending'?{status:'PENDING',result:[]}:{status:failure?'FAILURE':'SUCCESS',result:[{
amount:payoutMode==='mismatch'?'0.2':task.amount,dest:task.destination,status:failure?'error':'success',txids:[duplicateTx?'f'.repeat(64):sha256(id)]}]};
return Response.json(result);
};
return {client:createSHKeeperHarness(transport,credentials),transport,invoices,tasks,posts,
mode:(value:typeof payoutMode)=>{payoutMode=value;},duplicateTx:()=>{duplicateTx=true;},duplicateTask:()=>{duplicateTask=true;},recalculate:()=>{recalculate=true;},
failFee:()=>{failSecond=true;},loseInvoice:()=>{loseInvoice=true;}};
}
async function fixture(t:{after(fn:()=>Promise<void>):void}){
const dir=await mkdtemp(join(tmpdir(),'wikideck-settlement-lab-')),processor=fakeProcessor();
let now=1_800_000_000_000;
let evidence:DeliveryEvidence={transferId:null,confirmations:0,tip:'a'.repeat(64)};
let journal=await SettlementLab.open(dir,processor.client,async()=>structuredClone(evidence),()=>now);
t.after(async()=>{await journal.close();await rm(dir,{recursive:true,force:true});});
const terms:SettlementTerms={id:randomUUID(),network:NETWORK,chainId:CHAIN_ID,cardHash:'b'.repeat(64),sequence:0,
seller:seller.address,buyer:buyer.address,sellerPublicKey:seller.publicKey,buyerPublicKey:buyer.publicKey,
sellerXmr:await fixtureAddress(),priceCents:1500,feeBps:250,expiresAt:now+30*60000,deliveryConfirmations:10};
const create=()=>journal.create(terms,seller.sign(sellerSettlementMessage(terms)));
const quote=async()=>{await create();return journal.issueInvoice(terms.id);};
const approved=async()=>{const q=await quote();await journal.approveQuote(terms.id,buyer.sign(buyerInvoiceMessage(q)));return q;};
const markPaid=()=>{processor.invoices.set(terms.id,{external_id:terms.id,fiat:'USD',amount_fiat:'15.00',balance_fiat:'15.00',status:'PAID',
txs:[{addr:merchant,amount:'0.1',crypto:'XMR',status:'CONFIRMED',txid:'c'.repeat(64)}]});};
const deliver=(confirmations=10)=>{evidence={transferId:transactionId({network:NETWORK,chainId:CHAIN_ID,cardHash:terms.cardHash,sequence:0,from:seller.address,to:buyer.address,publicKey:'',signature:''}),confirmations,tip:'a'.repeat(64)};};
return {dir,processor,terms,create,quote,approved,markPaid,deliver,get journal(){return journal;},
advanceTime:(ms:number)=>{now+=ms;},loseDelivery:()=>{evidence={...evidence,transferId:null,confirmations:0};},
restart:async()=>{await journal.close();journal=await SettlementLab.open(dir,processor.client,async()=>structuredClone(evidence),()=>now);}};
}
test('XMR checksum/network and amount validation reject malformed destinations and floating-point syntax',async()=>{
assert.equal(await validateXmrAddress(merchant),merchant);
assert.equal(await validateXmrAddress(await fixtureAddress(42)),await fixtureAddress(42));
await assert.rejects(validateXmrAddress(merchant.slice(0,-1)+'1'),/checksum/);
await assert.rejects(validateXmrAddress(await fixtureAddress(53)),/network/);
await assert.rejects(validateXmrAddress('https://merchant.invalid'));
for(const amount of ['-1','1e-3','0.0000000000001','Infinity',' 1','01'])assert.throws(()=>atomicXmr(amount));
assert.equal(atomicXmr('0.000000000001'),1n);
assert.throws(()=>createLiveProcessor(),/not implemented or approved/);
});
test('SHKeeper harness authenticates each documented endpoint and rejects unlocked quote rates',async()=>{
const f=fakeProcessor(),id=randomUUID();const q=await f.client.createInvoice(id,1500);
assert.equal(q.amountXmr,'0.100000000000');assert.equal((await f.client.invoice(id)).status,'UNPAID');
const task=await f.client.payout('0.001',merchant);assert.equal((await f.client.task(task)).status,'SUCCESS');
f.recalculate();await assert.rejects(f.client.createInvoice(randomUUID(),1500));
assert.equal(f.posts[0].body.callback_url.startsWith('https://market.invalid/'),true);
});
test('processor responses are bounded, redirects/types/errors are refused without credential leakage',async()=>{
for(const response of [new Response('{}',{status:302,headers:{'Content-Type':'application/json'}}),new Response('<html>'),
new Response('{}',{headers:{'Content-Type':'application/json','Content-Length':'9999999'}}),
new Response('x'.repeat(256*1024+1),{headers:{'Content-Type':'application/json'}})]){
const client=createSHKeeperHarness(async()=>response,credentials);
await assert.rejects(client.invoice(randomUUID()),error=>error instanceof Error&&!error.message.includes(credentials.password));
}
const failing=createSHKeeperHarness(async()=>{throw new Error(credentials.apiKey+credentials.password);},credentials);
await assert.rejects(failing.payout('0.1',merchant),error=>error instanceof Error&&!error.message.includes(credentials.password)&&!error.message.includes(credentials.apiKey));
});
test('seller destination and buyer exact invoice quote require independent signatures',async t=>{
const f=await fixture(t);
await assert.rejects(f.journal.create({...f.terms,sellerXmr:merchant},seller.sign(sellerSettlementMessage(f.terms))),/seller terms/);
const q=await f.quote();assert.equal(q.seller?.amount,'0.096500000000');assert.equal(q.fee?.amount,'0.001000000000');
const altered=structuredClone(q);altered.quote!.amountXmr='0.2';
await assert.rejects(f.journal.approveQuote(f.terms.id,buyer.sign(buyerInvoiceMessage(altered))),/buyer invoice/);
await assert.rejects(f.journal.approveQuote(f.terms.id,seller.sign(buyerInvoiceMessage(q))),/buyer invoice/);
assert.equal((await f.journal.approveQuote(f.terms.id,buyer.sign(buyerInvoiceMessage(q)))).status,'awaiting-payment');
assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,0);
});
test('confirmed exact payment plus canonical depth settles seller and fixed fee separately once',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);
f.deliver(9);assert.equal((await f.journal.advance(f.terms.id)).status,'paid');assert.equal(f.processor.tasks.size,0);
f.deliver();const done=await f.journal.advance(f.terms.id);
assert.equal(done.status,'complete');assert.equal(done.seller?.state,'complete');assert.equal(done.fee?.state,'complete');
const payouts=f.processor.posts.filter(p=>p.path.endsWith('/payout'));
assert.deepEqual(payouts.map(p=>[p.body.destination,p.body.amount]),[[f.terms.sellerXmr,'0.096500000000'],[merchant,'0.001000000000']]);
await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2);
});
test('partial/unconfirmed payments wait; overpayments, identity mismatch and late receipt are held',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();
const o=f.processor.invoices.get(f.terms.id)!;o.txs[0].status='UNCONFIRMED';
assert.equal((await f.journal.pollPayment(f.terms.id)).status,'awaiting-payment');
o.status='PARTIAL';o.txs[0].amount='0.05';o.balance_fiat='7.50';
assert.equal((await f.journal.pollPayment(f.terms.id)).status,'awaiting-payment');
f.advanceTime(30*60000);
assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0);
});
test('authoritative lookup rejects duplicate/mismatched transactions even with PAID status',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();
const o=f.processor.invoices.get(f.terms.id)!;o.txs.push({...o.txs[0]});
assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0);
});
test('processor PAID with overpayment or changed fiat does not authorize delivery settlement',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();
f.processor.invoices.get(f.terms.id)!.txs[0].amount='0.11';
assert.equal((await f.journal.pollPayment(f.terms.id)).status,'review');
});
test('payout response loss remains ambiguous across retries/restart; no blind second POST',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('lost-response');
const failed=await f.journal.advance(f.terms.id);assert.equal(failed.status,'review');assert.equal(failed.seller?.state,'ambiguous');assert.equal(f.processor.tasks.size,1);
await f.journal.advance(f.terms.id);await f.restart();await f.journal.advance(f.terms.id);
assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,1);
});
test('known pending task is polled after restart without recreating payout',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('pending');
assert.equal((await f.journal.advance(f.terms.id)).seller?.state,'pending');await f.restart();
await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,1);
f.processor.mode('success');assert.equal((await f.journal.advance(f.terms.id)).status,'complete');assert.equal(f.processor.tasks.size,2);
});
test('duplicate payout transaction evidence is held after seller success, never allocated twice',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();
// First call: seller task remains pending. Next call confirms seller then submits fee in pending mode.
f.processor.mode('pending');await f.journal.advance(f.terms.id);f.processor.mode('success');
f.processor.duplicateTx();const result=await f.journal.advance(f.terms.id);
assert.equal(result.status,'review');assert.equal(result.seller?.state,'complete');assert.equal(result.fee?.state,'review');
await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2);
});
test('reorg after payout pauses settlement permanently instead of paying again',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();
assert.equal((await f.journal.advance(f.terms.id)).status,'complete');f.loseDelivery();
assert.equal((await f.journal.advance(f.terms.id)).status,'review');f.deliver();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2);
});
test('persisted submitting intent is quarantined at restart before any transport call',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);
const s=f.journal.get(f.terms.id);s.status='paying';s.seller!.state='submitting';
const db=new DatabaseSync(join(f.dir,'settlement-lab.sqlite'));
try{db.prepare('UPDATE lab_orders SET data=? WHERE id=?').run(JSON.stringify(s),f.terms.id);}finally{db.close();}
await f.restart();f.deliver();assert.equal((await f.journal.advance(f.terms.id)).seller?.state,'ambiguous');assert.equal(f.processor.tasks.size,0);
});
test('seller success followed by fee task failure preserves partial success and refuses retry',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.failFee();
const result=await f.journal.advance(f.terms.id);
assert.equal(result.status,'review');assert.equal(result.seller?.state,'complete');assert.equal(result.fee?.state,'review');
await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.tasks.size,2);
});
test('mismatched payout task amount and reused task IDs require review without replay',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.mode('mismatch');
assert.equal((await f.journal.advance(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,1);
});
test('reused processor task ID across seller and fee legs is quarantined',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();f.processor.duplicateTask();
const s=await f.journal.advance(f.terms.id);assert.equal(s.status,'review');assert.equal(s.seller?.state,'complete');assert.equal(s.fee?.state,'ambiguous');
const calls=f.processor.posts.length;await f.restart();await f.journal.advance(f.terms.id);assert.equal(f.processor.posts.length,calls);
});
test('invoice creation response loss is held and never repeated automatically',async t=>{
const f=await fixture(t);await f.create();f.processor.loseInvoice();
assert.equal((await f.journal.issueInvoice(f.terms.id)).status,'review');
await f.restart();await f.journal.issueInvoice(f.terms.id);
assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payment_request')).length,1);
});
test('changing authoritative paid transaction identity suspends payouts',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();
f.processor.invoices.get(f.terms.id)!.txs[0].txid='e'.repeat(64);
assert.equal((await f.journal.advance(f.terms.id)).status,'review');assert.equal(f.processor.tasks.size,0);
});
test('concurrent settlement advances serialize and cannot duplicate payout POSTs',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);f.deliver();
const results=await Promise.all(Array.from({length:8},()=>f.journal.advance(f.terms.id)));
assert.ok(results.every(s=>s.status==='complete'));
assert.equal(f.processor.posts.filter(p=>p.path.endsWith('/payout')).length,2);
});
test('same incoming transaction cannot fund two separate invoice orders',async t=>{
const f=await fixture(t);await f.approved();f.markPaid();await f.journal.pollPayment(f.terms.id);
const second={...f.terms,id:randomUUID()};
await f.journal.create(second,seller.sign(sellerSettlementMessage(second)));
const quote=await f.journal.issueInvoice(second.id);await f.journal.approveQuote(second.id,buyer.sign(buyerInvoiceMessage(quote)));
const observation=structuredClone(f.processor.invoices.get(f.terms.id)!);observation.external_id=second.id;
f.processor.invoices.set(second.id,observation);
assert.equal((await f.journal.pollPayment(second.id)).status,'review');assert.equal(f.processor.tasks.size,0);
});
test('processor deadline aborts hung fixture transport without returning secret detail', {timeout:10000},async()=>{
let aborted=false;
const transport:FixtureTransport=async(_url,init)=>new Promise<Response>((_resolve,reject)=>{
init.signal!.addEventListener('abort',()=>{aborted=true;reject(new Error(credentials.password));},{once:true});
});
const processor=createSHKeeperHarness(transport,credentials);
await assert.rejects(processor.invoice(randomUUID()),error=>error instanceof Error&&!error.message.includes(credentials.password));
assert.equal(aborted,true);
});