Files
wikideck/server/market/settlement-lab.ts
T
2026-10-20 12:09:58 -04:00

208 lines
14 KiB
TypeScript
Executable File

import { DatabaseSync } from 'node:sqlite';
import { mkdir,open,unlink,chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID,sha256,verifySignature,transactionId } from '../testnet/consensus.ts';
import { atomicXmr,fiatCents,validateXmrAddress } from './xmr.ts';
import { xmrString } from './payments.ts';
import type { SettlementProcessor,ProcessorQuote,InvoiceObservation } from './shkeeper.ts';
const hex=z.string().regex(/^[a-f0-9]{64}$/);
const termsSchema=z.object({id:z.string().uuid(),network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),
cardHash:hex,sequence:z.number().int().nonnegative().safe(),seller:hex,buyer:hex,sellerPublicKey:z.string().max(256),
buyerPublicKey:z.string().max(256),sellerXmr:z.string().length(95),priceCents:z.number().int().min(1).max(100_000_000),
feeBps:z.number().int().min(0).max(5000),expiresAt:z.number().int().nonnegative().safe(),
deliveryConfirmations:z.number().int().min(2).max(1000)}).strict();
export type SettlementTerms=z.infer<typeof termsSchema>;
export function sellerSettlementMessage(t:SettlementTerms){return JSON.stringify([NETWORK,'settlement-lab-terms-v1',
t.chainId,t.id,t.cardHash,t.sequence,t.seller,t.buyer,t.sellerPublicKey,t.buyerPublicKey,t.sellerXmr,t.priceCents,t.feeBps,
t.expiresAt,t.deliveryConfirmations,ADDITIONAL_MARKET_FEE.bps,ADDITIONAL_MARKET_FEE.address]);}
type Leg={amount:string;destination:string;state:'ready'|'submitting'|'pending'|'complete'|'ambiguous'|'review'|'skipped';taskId?:string;txids?:string[]};
export type LabSettlement={terms:SettlementTerms;sellerSignature:string;
status:'draft'|'invoice-submitting'|'quote'|'awaiting-payment'|'paid'|'paying'|'complete'|'review';
quote?:ProcessorQuote;quoteDigest?:string;buyerSignature?:string;paidObservedAt?:number;paidTxids?:string[];
hostingXmr?:string;seller?:Leg;fee?:Leg;reviewReason?:string};
export function buyerInvoiceMessage(s:LabSettlement){
if(!s.quote||!s.seller||!s.fee)throw new Error('No invoice quote to authorize');
return JSON.stringify([NETWORK,'settlement-lab-invoice-v1',s.terms.chainId,s.terms.id,sha256(sellerSettlementMessage(s.terms)),
s.quote.id,'XMR','USD',s.terms.priceCents,s.quote.address,s.quote.amountXmr,s.quote.exchangeRate,s.terms.expiresAt,
s.hostingXmr,s.seller.amount,s.seller.destination,s.fee.amount,s.fee.destination]);
}
export type DeliveryEvidence={transferId:string|null;confirmations:number;tip:string};
/** Trusted consensus-reader boundary, NOT a boolean submitted by an HTTP client. */
export type DeliveryReader=(terms:SettlementTerms)=>Promise<DeliveryEvidence>;
/** Offline settlement research journal. NOT imported by app/Market and NOT part of production
* backup format. No public routes, worker, callback or CLI activation. Test fixtures only. */
export class SettlementLab {
private readonly db:DatabaseSync;
private readonly processor:SettlementProcessor;
private readonly delivery:DeliveryReader;
private readonly now:()=>number;
private readonly release:()=>Promise<void>;
private queue:Promise<unknown>=Promise.resolve();private queued=0;
private constructor(db:DatabaseSync,processor:SettlementProcessor,delivery:DeliveryReader,now:()=>number,release:()=>Promise<void>){
this.db=db;this.processor=processor;this.delivery=delivery;this.now=now;this.release=release;
}
static async open(directory:string,processor:SettlementProcessor,delivery:DeliveryReader,now=Date.now){
await mkdir(directory,{recursive:true,mode:0o700});const file=join(directory,'settlement-lab.sqlite'),lockPath=file+'.lock';
const lock=await open(lockPath,'wx',0o600);let db:DatabaseSync|undefined;
try{
db=new DatabaseSync(file);
db.exec(`PRAGMA journal_mode=WAL;PRAGMA synchronous=FULL;PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS lab_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS lab_orders(id TEXT PRIMARY KEY,data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS lab_tasks(id TEXT PRIMARY KEY,order_id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS lab_txids(id TEXT PRIMARY KEY,order_id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS lab_incoming(id TEXT PRIMARY KEY,order_id TEXT NOT NULL);`);
const identity=JSON.stringify([NETWORK,CHAIN_ID,'offline-settlement-lab-v1']);
const row=db.prepare("SELECT value FROM lab_meta WHERE key='identity'").get() as {value:string}|undefined;
if(row&&row.value!==identity)throw new Error('Settlement lab network/schema mismatch');
if(!row)db.prepare('INSERT INTO lab_meta VALUES(?,?)').run('identity',identity);
if(process.platform!=='win32')await chmod(file,0o600);
// A committed intent with no saved task ID must never be replayed after restart.
const update=db.prepare('UPDATE lab_orders SET data=? WHERE id=?');
db.exec('BEGIN IMMEDIATE');
try{
for(const row of db.prepare('SELECT id,data FROM lab_orders').all() as {id:string;data:string}[]){
const s=JSON.parse(row.data) as LabSettlement;let uncertain=s.status==='invoice-submitting';
for(const leg of [s.seller,s.fee])if(leg?.state==='submitting'){leg.state='ambiguous';uncertain=true;}
if(uncertain){s.status='review';s.reviewReason='restart-after-unresolved-processor-intent';update.run(JSON.stringify(s),row.id);}
}
db.exec('COMMIT');
}catch(error){db.exec('ROLLBACK');throw error;}
return new SettlementLab(db,processor,delivery,now,async()=>{await lock.close();await unlink(lockPath);});
}catch(error){db?.close();await lock.close();await unlink(lockPath);throw error;}
}
async close(){await this.queue;this.db.close();await this.release();}
get(id:string):LabSettlement{
z.string().uuid().parse(id);const row=this.db.prepare('SELECT data FROM lab_orders WHERE id=?').get(id) as {data:string}|undefined;
if(!row)throw new Error('Unknown lab settlement');return JSON.parse(row.data);
}
private save(s:LabSettlement){this.db.prepare('UPDATE lab_orders SET data=? WHERE id=?').run(JSON.stringify(s),s.terms.id);}
private hold(s:LabSettlement,reason:string){s.status='review';s.reviewReason=reason;this.save(s);return s;}
private serialize<T>(fn:()=>Promise<T>):Promise<T>{
if(this.queued>=32)return Promise.reject(new Error('Settlement lab busy'));
this.queued++;const run=this.queue.then(async()=>{try{return await fn();}finally{this.queued--;}});
this.queue=run.catch(()=>undefined);return run;
}
create(raw:unknown,sellerSignature:string){
const terms=termsSchema.parse(raw);
return this.serialize(async()=>{
if(terms.seller===terms.buyer||terms.expiresAt<=this.now()||terms.expiresAt>this.now()+30*60000||
!verifySignature(terms.sellerPublicKey,terms.seller,sellerSettlementMessage(terms),sellerSignature))throw new Error('Invalid seller terms or expiry');
await validateXmrAddress(terms.sellerXmr);await validateXmrAddress(ADDITIONAL_MARKET_FEE.address);
if((this.db.prepare('SELECT COUNT(*) AS n FROM lab_orders').get() as {n:number}).n>=1000)throw new Error('Lab journal capacity reached');
const s:LabSettlement={terms,sellerSignature,status:'draft'};
this.db.prepare('INSERT INTO lab_orders VALUES(?,?)').run(terms.id,JSON.stringify(s));return s;
});
}
issueInvoice(id:string){return this.serialize(async()=>{
const s=this.get(id);if(s.status!=='draft')return s;
if(this.now()>=s.terms.expiresAt)return this.hold(s,'invoice-deadline');
s.status='invoice-submitting';this.save(s); // Durable intent BEFORE network-shaped I/O, no automatic POST retry.
try{
s.quote=await this.processor.createInvoice(id,s.terms.priceCents);
await validateXmrAddress(s.quote.address);const gross=atomicXmr(s.quote.amountXmr);
if(!s.quote.id||gross<=0n)throw new Error('Invalid quote');
const {hosting,author:fee,sellerBeforeNetworkCosts}=allocateXmrSale(gross,s.terms.feeBps);
s.hostingXmr=xmrString(hosting);
s.seller={amount:xmrString(sellerBeforeNetworkCosts),destination:s.terms.sellerXmr,state:'ready'};
s.fee={amount:xmrString(fee),destination:ADDITIONAL_MARKET_FEE.address,state:fee===0n?'skipped':'ready'};
s.quoteDigest=sha256(buyerInvoiceMessage(s));s.status='quote';this.save(s);return s;
}catch{return this.hold(s,'invoice-create-ambiguous-or-invalid');}
});}
approveQuote(id:string,signature:string){return this.serialize(async()=>{
const s=this.get(id);
if(s.status!=='quote'||this.now()>=s.terms.expiresAt||
!verifySignature(s.terms.buyerPublicKey,s.terms.buyer,buyerInvoiceMessage(s),signature))throw new Error('Invalid buyer invoice authorization');
s.buyerSignature=signature;s.status='awaiting-payment';this.save(s);return s;
});}
private async observe(s:LabSettlement,observation?:InvoiceObservation):Promise<'paid'|'waiting'|'review'>{
let o:InvoiceObservation;
try{o=observation??await this.processor.invoice(s.terms.id);}catch{return 'review';}
if(!s.quote||o.external_id!==s.terms.id||o.fiat!=='USD'||fiatCents(o.amount_fiat)!==s.terms.priceCents)return 'review';
let confirmed=0n,all=0n;const seen=new Set<string>();
for(const tx of o.txs){
if(tx.addr!==s.quote.address||tx.crypto!=='XMR'||seen.has(tx.txid))return 'review';
seen.add(tx.txid);const amount=atomicXmr(tx.amount);all+=amount;if(tx.status==='CONFIRMED')confirmed+=amount;
}
const gross=atomicXmr(s.quote.amountXmr);
if(s.paidTxids&&JSON.stringify([...seen].sort())!==JSON.stringify([...s.paidTxids].sort()))return 'review';
if(o.status==='OVERPAID'||all>gross||fiatCents(o.balance_fiat)>s.terms.priceCents)return 'review';
if(o.status==='PAID'&&confirmed===gross&&all===gross&&fiatCents(o.balance_fiat)===s.terms.priceCents)return 'paid';
if(o.status==='UNPAID'&&all===0n&&fiatCents(o.balance_fiat)===0)return 'waiting';
if(o.status==='PARTIAL'&&all<gross)return 'waiting';
if(all===gross&&confirmed<gross)return 'waiting';
return 'review';
}
pollPayment(id:string){return this.serialize(async()=>{
const s=this.get(id);if(s.status!=='awaiting-payment')return s;
if(this.now()>=s.terms.expiresAt)return this.hold(s,'late-or-expired-payment-requires-review');
let observation:InvoiceObservation;
try{observation=await this.processor.invoice(s.terms.id);}catch{return this.hold(s,'invoice-verification-failed');}
const observed=await this.observe(s,observation);
if(observed==='review')return this.hold(s,'invoice-verification-failed');
if(this.now()>=s.terms.expiresAt)return this.hold(s,'payment-observed-after-deadline-requires-review');
if(observed==='paid'){
// Independent observations must not allocate the same Monero transaction to two orders.
this.db.exec('BEGIN IMMEDIATE');
try{
for(const tx of observation.txs)this.db.prepare('INSERT INTO lab_incoming VALUES(?,?)').run(tx.txid,s.terms.id);
s.status='paid';s.paidObservedAt=this.now();s.paidTxids=observation.txs.map(tx=>tx.txid);this.save(s);this.db.exec('COMMIT');
}catch{this.db.exec('ROLLBACK');return this.hold(s,'incoming-transaction-already-allocated-or-storage-conflict');}
}return s;
});}
private expectedDelivery(t:SettlementTerms){return transactionId({network:NETWORK,chainId:CHAIN_ID,cardHash:t.cardHash,
sequence:t.sequence,from:t.seller,to:t.buyer,publicKey:'',signature:''});}
private async ready(s:LabSettlement){
const evidence=await this.delivery(structuredClone(s.terms));
return evidence.transferId===this.expectedDelivery(s.terms)&&Number.isSafeInteger(evidence.confirmations)&&
evidence.confirmations>=s.terms.deliveryConfirmations&&/^[a-f0-9]{64}$/.test(evidence.tip);
}
advance(id:string){return this.serialize(async()=>{
const s=this.get(id);if(!['paid','paying','complete'].includes(s.status))return s;
// Reads before each payout are risk controls, not cross-chain atomicity or finality.
if(await this.observe(s)!=='paid')return this.hold(s,'paid-invoice-no-longer-verifiable');
if(!await this.ready(s)){
if(s.status==='paid')return s; // Payment confirmed; still waiting for adequate NFT depth.
return this.hold(s,'delivery-reorganized-or-insufficient');
}
if(s.status==='complete')return s;
s.status='paying';this.save(s);
for(const leg of [s.seller!,s.fee!]){
if(leg.state==='complete'||leg.state==='skipped')continue;
if(leg.state==='ambiguous'||leg.state==='review'||leg.state==='submitting')return this.hold(s,'payout-intent-requires-operator-reconciliation');
if(leg.state==='ready'){
if(!await this.ready(s)||await this.observe(s)!=='paid')return this.hold(s,'settlement-evidence-changed-before-payout');
leg.state='submitting';this.save(s);
try{
leg.taskId=await this.processor.payout(leg.amount,leg.destination);z.string().uuid().parse(leg.taskId);
this.db.exec('BEGIN IMMEDIATE');
try{this.db.prepare('INSERT INTO lab_tasks VALUES(?,?)').run(leg.taskId,s.terms.id);leg.state='pending';this.save(s);this.db.exec('COMMIT');}
catch(error){this.db.exec('ROLLBACK');throw error;}
}
catch{leg.state='ambiguous';return this.hold(s,'payout-create-outcome-unknown-never-retry-automatically');}
}
let task;
try{task=await this.processor.task(leg.taskId!);}catch{return s;} // Known task GET can be polled, no new payout POST.
if(task.status==='PENDING')return s;
const result=task.result;
if(task.status!=='SUCCESS'||result.length!==1||result[0].status!=='success'||result[0].dest!==leg.destination||
atomicXmr(result[0].amount)!==atomicXmr(leg.amount)||!result[0].txids?.length||
result[0].txids.some(tx=>!(/^[a-f0-9]{64}$/.test(tx)))||new Set(result[0].txids).size!==result[0].txids.length){
leg.state='review';return this.hold(s,'payout-task-failed-partial-or-mismatched');
}
this.db.exec('BEGIN IMMEDIATE');
try{
for(const txid of result[0].txids)this.db.prepare('INSERT INTO lab_txids VALUES(?,?)').run(txid,s.terms.id);
leg.state='complete';leg.txids=result[0].txids;this.save(s);this.db.exec('COMMIT');
}catch{this.db.exec('ROLLBACK');leg.state='review';return this.hold(s,'payout-transaction-already-allocated-or-storage-conflict');}
}
s.status='complete';this.save(s);return s;
});}
}