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; 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; /** 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; private queue:Promise=Promise.resolve();private queued=0; private constructor(db:DatabaseSync,processor:SettlementProcessor,delivery:DeliveryReader,now:()=>number,release:()=>Promise){ 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(fn:()=>Promise):Promise{ 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(); 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{ 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; });} }