87 lines
6.8 KiB
TypeScript
Executable File
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;
|
|
},
|
|
};
|
|
}
|