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; 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; export interface SettlementProcessor { createInvoice(id:string,priceCents:number):Promise; invoice(id:string):Promise; payout(amount:string,destination:string):Promise; task(id:string):Promise; } 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{ const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),5000); const aborted=new Promise((_,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(); 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; }, }; }