Strengthen financial reconciliation checks
This commit is contained in:
@@ -11,6 +11,7 @@ import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessag
|
||||
import { CustodyError } from './custody.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
|
||||
import { CustodyIntegrityError, auditBusinessLiabilities, auditOrder, auditWithdrawal } from './custody-integrity.ts';
|
||||
|
||||
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number } & MarketPolicy;
|
||||
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
||||
@@ -27,7 +28,18 @@ export class PostgresCustody {
|
||||
private readonly pool: Pool;
|
||||
private readonly options: CustodyOptions;
|
||||
private active = 0;
|
||||
private constructor(pool: Pool, options: CustodyOptions, id: string) { this.pool = pool; this.options = options; this.id = id; }
|
||||
private readonly auditTimer: ReturnType<typeof setInterval>;
|
||||
private auditInFlight: Promise<CustodyAudit> | undefined;
|
||||
private integrityHold = false;
|
||||
private auditState: { status: 'ok' | 'failed'; lastAttemptAt: number; lastSuccessAt: number; failureCode: string | null };
|
||||
private constructor(pool: Pool, options: CustodyOptions, id: string) {
|
||||
this.pool = pool; this.options = options; this.id = id;
|
||||
this.auditState = { status: 'ok', lastAttemptAt: Date.now(), lastSuccessAt: Date.now(), failureCode: null };
|
||||
this.auditTimer = setInterval(() => { void this.audit().catch(() => {
|
||||
console.error('Custody financial audit unavailable or inconsistent; mutations held pending operator review.');
|
||||
}); }, 60000);
|
||||
this.auditTimer.unref();
|
||||
}
|
||||
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
|
||||
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
||||
await validateMarketPolicy(options);
|
||||
@@ -61,12 +73,23 @@ export class PostgresCustody {
|
||||
}
|
||||
await auditPostgres(c);
|
||||
await c.query('COMMIT');
|
||||
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
|
||||
} catch (e) {
|
||||
await c.query('ROLLBACK').catch(() => {});
|
||||
if (e instanceof CustodyIntegrityError) await recordIntegrityIncident(c, e.code).catch(() => {
|
||||
console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.');
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
finally { c.release(); }
|
||||
return new PostgresCustody(pool, options, id);
|
||||
} catch (e) { await pool.end(); throw e; }
|
||||
}
|
||||
close(): Promise<void> { return this.pool.end(); }
|
||||
async close(): Promise<void> { clearInterval(this.auditTimer); await this.auditInFlight?.catch(() => {}); await this.pool.end(); }
|
||||
diagnostics() {
|
||||
const stale = Date.now() - this.auditState.lastSuccessAt > 120000;
|
||||
return { backend: 'postgres', mutationHold: this.integrityHold || this.auditState.status !== 'ok' || stale,
|
||||
integrityHold: this.integrityHold, audit: { ...this.auditState, stale } };
|
||||
}
|
||||
info(): CustodyInfo {
|
||||
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
|
||||
custodianId: this.id, feeBps: this.options.feeBps, ...resolvedMarketPolicy(this.options), liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
@@ -75,17 +98,40 @@ export class PostgresCustody {
|
||||
check(this.active < 16, 'Custody database busy; retry the original command shortly');
|
||||
this.active++;
|
||||
let c: PoolClient | undefined;
|
||||
let mutationSavepoint = false;
|
||||
try {
|
||||
c = await this.pool.connect();
|
||||
await c.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
// One writer per financial domain initially; other readers remain nonblocking. Lock is database-scoped,
|
||||
// shared by every node/process, migrations and import, not an in-process promise queue.
|
||||
if (write) await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
if (write) {
|
||||
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await c.query('SAVEPOINT custody_mutation'); mutationSavepoint = true;
|
||||
}
|
||||
const result = await fn(c);
|
||||
await c.query('COMMIT');
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof CustodyIntegrityError) {
|
||||
this.integrityHold = true;
|
||||
this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: e.code };
|
||||
// Roll back business changes but retain the cross-process writer lock until the incident is committed.
|
||||
// Read-only/background audit failures require a separate marker transaction; DB/process loss can still
|
||||
// prevent persistence, so external writer fencing remains an incident-response requirement.
|
||||
if (c) {
|
||||
try {
|
||||
if (mutationSavepoint) await c.query('ROLLBACK TO SAVEPOINT custody_mutation');
|
||||
else await c.query('ROLLBACK');
|
||||
await recordIntegrityIncident(c, e.code);
|
||||
if (mutationSavepoint) await c.query('COMMIT');
|
||||
} catch {
|
||||
await c.query('ROLLBACK').catch(() => {});
|
||||
console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.');
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await c?.query('ROLLBACK').catch(() => {});
|
||||
// Never retry after an ambiguous COMMIT. Signed receipts resolve the SAME nonce on a client retry.
|
||||
if (e instanceof CustodyError) throw e;
|
||||
@@ -147,13 +193,21 @@ export class PostgresCustody {
|
||||
const key = `${cmd.actor}:${cmd.nonce}`, hash = createHash('sha256').update(message).digest('hex');
|
||||
const old = await c.query('SELECT digest,data FROM wk_custody.receipts WHERE id=$1', [key]);
|
||||
if (old.rowCount) { check(old.rows[0].digest === hash, 'Nonce already used for different custody terms'); return old.rows[0].data as Result; }
|
||||
const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
check(!incident.rowCount, 'Custody integrity incident is unresolved; operator review required');
|
||||
check(!this.diagnostics().mutationHold, 'Custody financial reconciliation requires operator review; balances remain readable and original receipts can be replayed');
|
||||
check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
|
||||
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
|
||||
check(count.rows[0].n < 10000, 'Custody sandbox command budget reached');
|
||||
}
|
||||
// Verify existing liabilities BEFORE resolving them, and validate the new business state before COMMIT.
|
||||
if (a.type === 'cancel-reservation') await auditOrder(c, a.orderId);
|
||||
if (a.type === 'cancel-withdrawal') await auditWithdrawal(c, a.withdrawalId);
|
||||
const result = await this.mutate(c, cmd);
|
||||
if (a.type === 'reserve' || a.type === 'cancel-reservation') await auditOrder(c, (result as CustodyOrder).id);
|
||||
if (a.type === 'withdraw' || a.type === 'cancel-withdrawal') await auditWithdrawal(c, (result as CustodyWithdrawal).id);
|
||||
await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [key, hash, JSON.stringify(result)]);
|
||||
return result;
|
||||
});
|
||||
@@ -226,7 +280,19 @@ export class PostgresCustody {
|
||||
}
|
||||
throw new CustodyError('Unsupported custody action');
|
||||
}
|
||||
audit(): Promise<CustodyAudit> { return this.transaction(false, auditPostgres); }
|
||||
audit(): Promise<CustodyAudit> {
|
||||
if (this.auditInFlight) return this.auditInFlight;
|
||||
this.auditState.lastAttemptAt = Date.now();
|
||||
const pending = this.transaction(false, auditPostgres).then(result => {
|
||||
this.auditState = { ...this.auditState, lastSuccessAt: Date.now(),
|
||||
status: this.integrityHold ? 'failed' : 'ok', failureCode: this.integrityHold ? this.auditState.failureCode : null };
|
||||
return result;
|
||||
}).catch(error => {
|
||||
this.auditState = { ...this.auditState, status: 'failed', failureCode: error instanceof CustodyIntegrityError ? error.code : 'audit-unavailable' };
|
||||
throw error;
|
||||
}).finally(() => { this.auditInFlight = undefined; });
|
||||
this.auditInFlight = pending; return pending;
|
||||
}
|
||||
importSource(): Promise<string | undefined> {
|
||||
return this.transaction(false, async c => (await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'")).rows[0]?.value);
|
||||
}
|
||||
@@ -234,16 +300,24 @@ export class PostgresCustody {
|
||||
|
||||
/** Exact NUMERIC/text aggregation in a coherent transaction; no Number conversion of money. */
|
||||
export async function auditPostgres(c: PoolClient): Promise<CustodyAudit> {
|
||||
const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
if (incident.rowCount) throw new CustodyIntegrityError('unresolved-integrity-incident');
|
||||
const bad = await c.query(`SELECT 1 FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
|
||||
GROUP BY j.id HAVING count(e.position)=0 OR sum(e.debit-e.credit)<>0 LIMIT 1`);
|
||||
if (bad.rowCount) throw new Error('Unbalanced PostgreSQL custody history');
|
||||
if (bad.rowCount) throw new CustodyIntegrityError('unbalanced-journal');
|
||||
const mismatch = await c.query(`SELECT 1 FROM wk_custody.accounts a LEFT JOIN wk_custody.entries e ON e.account=a.id
|
||||
GROUP BY a.id HAVING a.balance<>COALESCE(sum(CASE WHEN a.normal='debit' THEN e.debit-e.credit ELSE e.credit-e.debit END),0) LIMIT 1`);
|
||||
if (mismatch.rowCount) throw new Error('PostgreSQL custody balance projection mismatch');
|
||||
if (mismatch.rowCount) throw new CustodyIntegrityError('balance-projection-mismatch');
|
||||
const totals = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE normal='debit'),0)::text AS assets,
|
||||
COALESCE(sum(balance) FILTER(WHERE normal='credit'),0)::text AS claims FROM wk_custody.accounts`);
|
||||
if (totals.rows[0].assets !== totals.rows[0].claims) throw new Error('PostgreSQL custody backing mismatch');
|
||||
if (totals.rows[0].assets !== totals.rows[0].claims) throw new CustodyIntegrityError('accounting-backing-mismatch');
|
||||
await auditBusinessLiabilities(c);
|
||||
const n = await c.query('SELECT count(*)::text AS n FROM wk_custody.journals');
|
||||
if (BigInt(n.rows[0].n) > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Journal count exceeds supported range');
|
||||
return { journals: Number(n.rows[0].n), assetsAtomicXmr: totals.rows[0].assets, claimsAndRevenueAtomicXmr: totals.rows[0].claims };
|
||||
}
|
||||
|
||||
async function recordIntegrityIncident(c: PoolClient, code: string): Promise<void> {
|
||||
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING',
|
||||
['integrity_incident_v1', JSON.stringify({ version: 1, code, detectedAt: Date.now() })]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user