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

87 lines
6.8 KiB
TypeScript
Executable File

import { z } from 'zod';
import { validateXmrAddress, atomicXmr, fiatCents } from './xmr.ts';
const identifier=z.union([z.string().min(1).max(128),z.number().int().nonnegative().safe()]).transform(String);
const decimal=z.string().min(1).max(32);
const external=z.string().uuid();
const transaction=z.object({addr:z.string().length(95),amount:decimal,crypto:z.literal('XMR'),
status:z.enum(['CONFIRMED','UNCONFIRMED']),txid:z.string().regex(/^[a-f0-9]{64}$/)});
export const invoiceObservation=z.object({external_id:external,fiat:z.literal('USD'),amount_fiat:decimal,
balance_fiat:decimal,status:z.enum(['UNPAID','PARTIAL','PAID','OVERPAID']),txs:z.array(transaction).max(256)});
export type InvoiceObservation=z.infer<typeof invoiceObservation>;
export type ProcessorQuote={id:string;address:string;amountXmr:string;exchangeRate:string};
export type PayoutResult={status:'PENDING'|'FAILURE'|'SUCCESS';result:{amount:string;dest:string;status:'success'|'error';txids?:string[]}[]};
/** Explicit injection only. There is deliberately NO default/global fetch or credential-env loader.
* This interface exercises wire contracts with offline fake responses, not a live activation gate. */
export type FixtureTransport=(url:string,init:RequestInit)=>Promise<Response>;
export interface SettlementProcessor {
createInvoice(id:string,priceCents:number):Promise<ProcessorQuote>;
invoice(id:string):Promise<InvoiceObservation>;
payout(amount:string,destination:string):Promise<string>;
task(id:string):Promise<PayoutResult>;
}
export function createLiveProcessor():never{
throw new Error('Live SHKeeper activation is not implemented or approved. Mock mode only.');
}
export function createSHKeeperHarness(transport:FixtureTransport,credentials:{apiKey:string;username:string;password:string}):SettlementProcessor{
// Ephemeral fixtures are supplied by tests. Application startup never imports/constructs this harness.
if(!transport||typeof transport!=='function')throw new Error('Explicit offline fixture transport required');
for(const value of Object.values(credentials))if(!value||value.length>1024||/[\r\n\x00]/.test(value))throw new Error('Invalid fixture credentials');
if(credentials.username.includes(':'))throw new Error('Invalid Basic username');
const origin='https://shkeeper.eom.dev';
async function request(path:string,basic:boolean,body?:unknown):Promise<unknown>{
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),5000);
const aborted=new Promise<never>((_,reject)=>{controller.signal.addEventListener('abort',()=>reject(new Error('Processor request deadline exceeded')),{once:true});});
const operation=(async()=>{
try{
const response=await transport(origin+path,{method:body===undefined?'GET':'POST',redirect:'error',signal:controller.signal,
headers:{Accept:'application/json',...(basic?{Authorization:`Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`}:{'X-Shkeeper-Api-Key':credentials.apiKey}),
...(body===undefined?{}:{'Content-Type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})});
if(!response.ok||response.redirected||!response.body||!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type')??'')){
await response.body?.cancel();throw new Error('Processor response contract rejected');
}
const length=response.headers.get('content-length');
if(length!==null&&(!/^\d+$/.test(length)||Number(length)>256*1024)){await response.body.cancel();throw new Error('Processor response limit');}
const reader=response.body.getReader(),chunks:Uint8Array[]=[];let size=0;
try{for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength;if(size>256*1024)throw new Error('Processor response limit');chunks.push(part.value);}}
finally{await reader.cancel();reader.releaseLock();}
return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks)));
}catch{throw new Error('Processor operation failed or response could not be verified');} // Never include credentials/upstream body.
})();
try{return await Promise.race([operation,aborted]);}finally{clearTimeout(timer);}
}
return {
async createInvoice(id,priceCents){
external.parse(id);if(!Number.isSafeInteger(priceCents)||priceCents<1||priceCents>100_000_000)throw new Error('Invalid invoice price');
const amount=`${Math.floor(priceCents/100)}.${String(priceCents%100).padStart(2,'0')}`;
const raw=await request('/api/v1/XMR/payment_request',false,{external_id:id,fiat:'USD',amount,
callback_url:`https://market.invalid/disabled-callback/${id}`}); // No live callback route or token.
const q=z.object({status:z.literal('success'),id:identifier,wallet:z.string().length(95),amount:decimal,
exchange_rate:decimal,recalculate_after:z.union([z.literal(0),z.literal('0')])}).parse(raw);
await validateXmrAddress(q.wallet);if(atomicXmr(q.amount)<=0n||!/^\d{1,12}(\.\d{1,12})?$/.test(q.exchange_rate)||Number(q.exchange_rate)<=0)throw new Error('Invalid processor quote');
return {id:q.id,address:q.wallet,amountXmr:q.amount,exchangeRate:q.exchange_rate};
},
async invoice(id){
external.parse(id);
const data=z.object({status:z.literal('success'),invoices:z.array(invoiceObservation).max(2)}).parse(await request(`/api/v1/invoices/${encodeURIComponent(id)}`,false));
if(data.invoices.length!==1||data.invoices[0].external_id!==id)throw new Error('Invoice identity is missing or ambiguous');
const invoice=data.invoices[0];fiatCents(invoice.amount_fiat);fiatCents(invoice.balance_fiat);
const seen=new Set<string>();
for(const tx of invoice.txs){await validateXmrAddress(tx.addr);atomicXmr(tx.amount);if(seen.has(tx.txid))throw new Error('Duplicate invoice transaction');seen.add(tx.txid);}
return invoice;
},
async payout(amount,destination){
if(atomicXmr(amount)<=0n)throw new Error('Payout must be positive');await validateXmrAddress(destination);
const result=z.object({task_id:z.string().uuid()}).parse(await request('/api/v1/XMR/payout',true,{amount,destination,fee:'2'}));
return result.task_id;
},
async task(id){
z.string().uuid().parse(id);
const result=z.object({status:z.enum(['PENDING','FAILURE','SUCCESS']),result:z.array(z.object({amount:decimal,dest:z.string().length(95),
status:z.enum(['success','error']),txids:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(64).optional()})).max(2).default([])}).parse(await request(`/api/v1/XMR/task/${id}`,true));
for(const row of result.result){atomicXmr(row.amount);await validateXmrAddress(row.dest);}
return result;
},
};
}