207 lines
14 KiB
TypeScript
Executable File
207 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 { 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=gross*BigInt(s.terms.feeBps)/10000n,fee=gross*100n/10000n;
|
|
s.hostingXmr=xmrString(hosting);
|
|
s.seller={amount:xmrString(gross-hosting-fee),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;
|
|
});}
|
|
}
|