Removed SHKeeper
This commit is contained in:
@@ -1,207 +0,0 @@
|
||||
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<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,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<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;
|
||||
});}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date
|
||||
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Stage-3 bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis and independent economic-security review remain missing.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' },
|
||||
{ id: 'live-market-settlement', status: 'blocked', detail: 'Stage-5 offline SHKeeper wire/settlement prototypes and fake-processor failure tests exist. No live transport, production journal/UI/backup integration, reviewed confirmation/refund/custody policy or payment activation is enabled. Mock payment is not real payment.' },
|
||||
{ id: 'live-market-settlement', status: 'blocked', detail: 'No end-to-end live Monero custody path is connected. Production deposit allocation/crediting, treasury withdrawal signing, market ledger settlement, recovery integration and reviewed custody/refund policy remain unavailable. Mock payment is not real payment.' },
|
||||
{ id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' },
|
||||
{ id: 'deployment-rehearsal', status: 'unverified', detail: 'Stage-4 explicit proxy trust, private diagnostics/checker and stopped-node paired backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery and on-call procedures remain unverified.' },
|
||||
{ id: 'fee-recipient-control', status: 'unverified', detail: 'Confirm the fixed public XMR fee address, checksum and recipient control out of band before any future live payout. No keys or proof are requested here.' },
|
||||
|
||||
Reference in New Issue
Block a user