458 lines
33 KiB
TypeScript
Executable File
458 lines
33 KiB
TypeScript
Executable File
import { Pool, type PoolClient, type PoolConfig } from 'pg';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { IS_MAINNET, NETWORK } from '../../shared/network.ts';
|
|
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
|
import { Ledger } from '../testnet/ledger.ts';
|
|
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
|
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
|
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
|
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
|
|
type CustodyActivity, type CustodyCommand, type CustodyInfo, type CustodyListing,
|
|
type CustodyOrder, type CustodyPage, type CustodyWithdrawal } from '../../shared/custody.ts';
|
|
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';
|
|
import { replayReservedSale } from './sale-rehearsal.ts';
|
|
import { CanonicalInboxError, auditCanonicalInbox, ingestCanonicalPage } from './canonical-inbox.ts';
|
|
|
|
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined;
|
|
now?: () => number; disposableSaleRehearsal?: boolean } & MarketPolicy;
|
|
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
|
export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string };
|
|
type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
|
|
const available = (actor: string) => `customer:${actor}:available`;
|
|
const reserved = (o: CustodyOrder) => `customer:${o.buyer}:purchase:${o.id}`;
|
|
const held = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`;
|
|
function check(ok: unknown, message: string): asserts ok { if (!ok) throw new CustodyError(message); }
|
|
|
|
/** PostgreSQL-backed SAME sandbox protocol. No real XMR, NFT witness, or automatic schema migration. */
|
|
export class PostgresCustody {
|
|
readonly id: string;
|
|
private readonly pool: Pool;
|
|
private readonly options: CustodyOptions;
|
|
private active = 0;
|
|
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');
|
|
if (options.disposableSaleRehearsal && (config.database !== 'custody_test' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
|
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
|
|
}
|
|
await validateMarketPolicy(options);
|
|
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
|
const pool = new Pool(config);
|
|
// Never log connection details or let idle-client socket errors become uncaught process exceptions.
|
|
pool.on('error', () => { console.error('Custody PostgreSQL connection unavailable; requests will fail closed.'); });
|
|
try {
|
|
const c = await pool.connect();
|
|
let id: string;
|
|
try {
|
|
if (options.disposableSaleRehearsal && (await c.query('SELECT current_database() AS name')).rows[0].name !== 'custody_test') {
|
|
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
|
|
}
|
|
await c.query('BEGIN');
|
|
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
|
await verifyCustodySchema(c);
|
|
const privilege = await c.query(`SELECT r.rolsuper,r.rolcreatedb,r.rolcreaterole,r.rolreplication,r.rolbypassrls,
|
|
pg_has_role(current_user,n.nspowner,'MEMBER') AS owns_schema
|
|
FROM pg_roles r CROSS JOIN pg_namespace n WHERE r.rolname=current_user AND n.nspname='wk_custody'`);
|
|
if (!privilege.rowCount || Object.values(privilege.rows[0]).some(Boolean)) {
|
|
throw new Error('Custody runtime requires a non-administrative role separate from the schema owner');
|
|
}
|
|
const existing = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
|
if (existing.rowCount) {
|
|
const v = JSON.parse(existing.rows[0].value);
|
|
if (v.version !== 1 || v.network !== NETWORK || v.chainId !== CHAIN_ID || v.mode !== 'sandbox' ||
|
|
v.policy !== CUSTODY_POLICY || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v.id)) throw new Error('Custody PostgreSQL identity mismatch');
|
|
id = v.id;
|
|
} else {
|
|
id = randomUUID();
|
|
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['identity', JSON.stringify({ version: 1,
|
|
network: NETWORK, chainId: CHAIN_ID, mode: 'sandbox', policy: CUSTODY_POLICY, id })]);
|
|
}
|
|
await auditPostgres(c);
|
|
await c.query('COMMIT');
|
|
} 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; }
|
|
}
|
|
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 };
|
|
}
|
|
get rehearsalObserverEnabled(): boolean { return this.options.disposableSaleRehearsal === true; }
|
|
private async transaction<T>(write: boolean, fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
|
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]);
|
|
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;
|
|
if ((e as { code?: string }).code === '23514') throw new CustodyError('Insufficient balance or custody amount constraint');
|
|
throw new Error('Custody PostgreSQL operation failed; refresh or retry the original signed command');
|
|
} finally { c?.release(); this.active--; }
|
|
}
|
|
async browse(offset = 0): Promise<CustodyPage> {
|
|
check(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'Invalid custody page');
|
|
if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null };
|
|
return this.transaction(false, async c => {
|
|
const { rows } = await c.query("SELECT data FROM wk_custody.listings WHERE status IN ('open','reserved') ORDER BY sequence DESC LIMIT 25 OFFSET $1", [offset]);
|
|
return { info: this.info(), listings: rows.slice(0, 24).map(r => r.data), next: rows.length > 24 ? offset + 24 : null };
|
|
});
|
|
}
|
|
private async activity(c: PoolClient, actor: string, offset: number): Promise<CustodyActivity> {
|
|
const orders = await c.query('SELECT data FROM wk_custody.orders WHERE buyer=$1 OR seller=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
|
|
const withdrawals = await c.query('SELECT data FROM wk_custody.withdrawals WHERE actor=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
|
|
const sums = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE id=$1),0)::text AS available,
|
|
COALESCE(sum(balance) FILTER(WHERE id LIKE $2),0)::text AS purchase,
|
|
COALESCE(sum(balance) FILTER(WHERE id LIKE $3),0)::text AS withdrawal FROM wk_custody.accounts WHERE id LIKE $4`,
|
|
[available(actor), `customer:${actor}:purchase:%`, `customer:${actor}:withdrawal:%`, `customer:${actor}:%`]);
|
|
const claimed = await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor]);
|
|
return { availableAtomicXmr: sums.rows[0].available, purchaseReservedAtomicXmr: sums.rows[0].purchase,
|
|
withdrawalReservedAtomicXmr: sums.rows[0].withdrawal, mockCreditClaimed: !!claimed.rowCount,
|
|
orders: orders.rows.slice(0, 24).map(r => r.data), withdrawals: withdrawals.rows.slice(0, 24).map(r => r.data),
|
|
next: orders.rows.length > 24 || withdrawals.rows.length > 24 ? offset + 24 : null };
|
|
}
|
|
private async post(c: PoolClient, id: string, kind: string, lines: Line[]) {
|
|
const nonzero = lines.filter(l => (l.debit ?? 0n) !== 0n || (l.credit ?? 0n) !== 0n);
|
|
if (!nonzero.length || nonzero.reduce((s, l) => s + (l.debit ?? 0n) - (l.credit ?? 0n), 0n) !== 0n) throw new Error('Unbalanced custody posting');
|
|
await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [id, kind, (this.options.now ?? Date.now)()]);
|
|
for (const [position, line] of nonzero.entries()) {
|
|
const normal = line.normal ?? 'credit';
|
|
await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [line.account, normal]);
|
|
// Runtime intentionally has no UPDATE privilege on balances. The SECURITY DEFINER projection
|
|
// trigger takes the account row lock; global writer admission protects the preceding normal check.
|
|
const account = await c.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [line.account]);
|
|
if (account.rows[0].normal !== normal) throw new Error('Custody account normal mismatch');
|
|
// DB trigger updates projection; deferred trigger rejects an empty/unbalanced journal at COMMIT.
|
|
await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)',
|
|
[id, position, line.account, (line.debit ?? 0n).toString(), (line.credit ?? 0n).toString()]);
|
|
}
|
|
}
|
|
private async record<T>(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', id: string): Promise<T> {
|
|
const r = await c.query(`SELECT data FROM wk_custody.${table} WHERE id=$1 FOR UPDATE`, [id]);
|
|
check(r.rowCount, 'Custody record not found'); return r.rows[0].data as T;
|
|
}
|
|
private async update(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', record: { id: string; status: string }) {
|
|
await c.query(`UPDATE wk_custody.${table} SET status=$1,data=$2 WHERE id=$3`, [record.status, JSON.stringify(record), record.id]);
|
|
}
|
|
async execute(raw: unknown): Promise<Result> {
|
|
const cmd = custodyCommandSchema.parse(raw), message = custodyMessage(cmd), now = (this.options.now ?? Date.now)();
|
|
check(cmd.custodianId === this.id && cmd.chainId === CHAIN_ID && cmd.expiresAt >= now && cmd.expiresAt <= now + 300000 &&
|
|
verifySignature(cmd.publicKey, cmd.actor, message, cmd.signature), 'Invalid or expired custody signature');
|
|
const a = cmd.action;
|
|
if (a.type === 'activity') return this.transaction(false, c => this.activity(c, cmd.actor, a.offset));
|
|
return this.transaction(true, async c => {
|
|
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;
|
|
});
|
|
}
|
|
private async mutate(c: PoolClient, cmd: CustodyCommand): Promise<Result> {
|
|
const a = cmd.action, actor = cmd.actor, createdAt = (this.options.now ?? Date.now)();
|
|
if (a.type === 'mock-credit') {
|
|
check(!(await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor])).rowCount, 'Sandbox credit already claimed');
|
|
await this.post(c, `credit:${actor}`, 'mock-deposit', [
|
|
{ account: 'treasury:mock', normal: 'debit', debit: BigInt(MOCK_CREDIT_ATOMIC) },
|
|
{ account: available(actor), credit: BigInt(MOCK_CREDIT_ATOMIC) }]);
|
|
await c.query('INSERT INTO wk_custody.credits VALUES($1)', [actor]); return this.activity(c, actor, 0);
|
|
}
|
|
if (a.type === 'list') {
|
|
check(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
|
|
const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress;
|
|
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again');
|
|
const card = this.options.card(a.cardHash, actor, a.sequence);
|
|
check(card, 'Card is missing, stale, immature, or pending');
|
|
check(!(await c.query("SELECT 1 FROM wk_custody.listings WHERE card=$1 AND status IN ('open','reserved')", [a.cardHash])).rowCount, 'Card already has a custody listing');
|
|
const l: CustodyListing = { id: randomUUID(), seller: actor, cardHash: a.cardHash, sequence: a.sequence,
|
|
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt };
|
|
await c.query('INSERT INTO wk_custody.listings(id,card,status,data) VALUES($1,$2,$3,$4)', [l.id, l.cardHash, l.status, JSON.stringify(l)]); return l;
|
|
}
|
|
if (a.type === 'reserve' || a.type === 'cancel-listing') {
|
|
const l = await this.record<CustodyListing>(c, 'listings', a.listingId);
|
|
check(l.status === 'open', 'Custody listing is no longer available');
|
|
if (a.type === 'cancel-listing') {
|
|
check(l.seller === actor, 'Only the seller can cancel this listing'); l.status = 'cancelled'; await this.update(c, 'listings', l); return l;
|
|
}
|
|
check(actor !== l.seller, 'Cannot buy your own card');
|
|
check(a.priceAtomicXmr === l.priceAtomicXmr && a.feeBps === l.feeBps, 'Custody sale terms changed');
|
|
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
|
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms');
|
|
check(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
|
|
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
|
|
const o: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: actor, cardHash: l.cardHash,
|
|
sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(),
|
|
authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress,
|
|
status: 'awaiting-lock', createdAt };
|
|
await this.post(c, `reserve:${o.id}`, 'purchase-reservation', [{ account: available(actor), debit: allocation.gross }, { account: reserved(o), credit: allocation.gross }]);
|
|
l.status = 'reserved'; await this.update(c, 'listings', l);
|
|
await c.query('INSERT INTO wk_custody.orders(id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5)', [o.id, actor, o.seller, o.status, JSON.stringify(o)]);
|
|
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, 'nft-lock', 'blocked', JSON.stringify(o)]); return o;
|
|
}
|
|
if (a.type === 'cancel-reservation') {
|
|
const o = await this.record<CustodyOrder>(c, 'orders', a.orderId);
|
|
check(!(await c.query('SELECT 1 FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-binding:${o.id}`])).rowCount,
|
|
'Rehearsal sale authorization exists; cancellation requires replayed terminal evidence');
|
|
const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]);
|
|
check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer');
|
|
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]);
|
|
o.status = 'cancelled'; await this.update(c, 'orders', o);
|
|
const l = await this.record<CustodyListing>(c, 'listings', o.listingId); l.status = 'cancelled'; await this.update(c, 'listings', l);
|
|
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); return o;
|
|
}
|
|
if (a.type === 'withdraw') {
|
|
const w: CustodyWithdrawal = { id: randomUUID(), actor, destination: a.destination, amountAtomicXmr: a.amountAtomicXmr,
|
|
maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt };
|
|
const total = BigInt(a.amountAtomicXmr) + BigInt(a.maxFeeAtomicXmr);
|
|
await this.post(c, `withdraw:${w.id}`, 'withdrawal-reservation', [{ account: available(actor), debit: total }, { account: held(w), credit: total }]);
|
|
await c.query('INSERT INTO wk_custody.withdrawals(id,actor,status,data) VALUES($1,$2,$3,$4)', [w.id, actor, w.status, JSON.stringify(w)]);
|
|
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [w.id, 'withdrawal', 'blocked', JSON.stringify(w)]); return w;
|
|
}
|
|
if (a.type === 'cancel-withdrawal') {
|
|
const w = await this.record<CustodyWithdrawal>(c, 'withdrawals', a.withdrawalId);
|
|
check(w.actor === actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner');
|
|
const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr);
|
|
await this.post(c, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [{ account: held(w), debit: total }, { account: available(actor), credit: total }]);
|
|
w.status = 'cancelled'; await this.update(c, 'withdrawals', w);
|
|
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [w.id]); return w;
|
|
}
|
|
throw new CustodyError('Unsupported custody action');
|
|
}
|
|
/** Disposable database ONLY. Not wired to HTTP, configuration flags, live chain or a signer. */
|
|
observeSaleRehearsal(orderId: string, transcript: unknown) {
|
|
check(this.options.disposableSaleRehearsal === true, 'Sale rehearsal is unavailable on this custody instance');
|
|
return this.transaction(true, async c => {
|
|
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
|
|
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
|
|
const o = await this.record<CustodyOrder>(c, 'orders', orderId);
|
|
await auditOrder(c, o.id);
|
|
const l = await this.record<CustodyListing>(c, 'listings', o.listingId);
|
|
const replay = replayReservedSale(o, this.id, transcript, l.feeBps);
|
|
const key = `sale-rehearsal-binding:${o.id}`;
|
|
const old = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [key]);
|
|
if (old.rowCount) check(old.rows[0].value === replay.binding, 'Rehearsal authorization changed');
|
|
else {
|
|
check(o.status === 'awaiting-lock', 'Cannot bind an already resolved order to a rehearsal');
|
|
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [key, replay.binding]);
|
|
}
|
|
if (replay.decision.action === 'review') throw new CustodyIntegrityError('sale-rehearsal-outcome-reorg');
|
|
// Retain the first signed observation plus terminal evidence, not a canonical event inbox.
|
|
// Runtime has INSERT but intentionally no UPDATE privilege on these metadata records.
|
|
const evidenceKey = `sale-rehearsal-observation:${o.id}`;
|
|
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2) ON CONFLICT(key) DO NOTHING', [evidenceKey,
|
|
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
|
|
if (replay.decision.action === 'settle' || replay.decision.action === 'refund') {
|
|
check(o.status === 'awaiting-lock', 'Rehearsal order already resolved');
|
|
if (replay.decision.action === 'settle') {
|
|
await this.post(c, `settle:${o.id}`, 'mock-sale-settlement', [
|
|
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) },
|
|
{ account: available(o.seller), credit: BigInt(o.sellerAtomicXmr) },
|
|
{ account: `author:${o.authorAddress}`, credit: BigInt(o.authorAtomicXmr) },
|
|
{ account: o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', credit: BigInt(o.hostAtomicXmr) }]);
|
|
o.status = 'settled'; l.status = 'sold';
|
|
await c.query("UPDATE wk_custody.outbox SET status='fixture-complete',data=$1 WHERE id=$2", [`fixture:${replay.stateRoot}`, o.id]);
|
|
} else {
|
|
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [
|
|
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(o.buyer), credit: BigInt(o.grossAtomicXmr) }]);
|
|
o.status = 'cancelled'; l.status = 'cancelled';
|
|
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]);
|
|
}
|
|
await this.update(c, 'orders', o); await this.update(c, 'listings', l);
|
|
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`sale-rehearsal-terminal:${o.id}`,
|
|
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
|
|
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
|
|
[`sale-rehearsal-terminal-index:${o.id}`, JSON.stringify({ version: 1,
|
|
terminalTransactionId: replay.terminalTransactionId ?? null, stateRoot: replay.stateRoot })]);
|
|
if (replay.terminalTransactionId) {
|
|
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
|
|
[`sale-rehearsal-tx:${replay.terminalTransactionId}`,
|
|
JSON.stringify({ version: 1, orderId: o.id, stateRoot: replay.stateRoot })]);
|
|
}
|
|
await auditOrder(c, o.id);
|
|
}
|
|
return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot };
|
|
});
|
|
}
|
|
/** Disposable observer rehearsal only; input must come from Ledger.canonicalEvents(), never peers/HTTP. */
|
|
async observeCanonicalEvents(page: unknown) {
|
|
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
|
const result = await this.transaction(true, async c => {
|
|
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
|
|
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
|
|
try {
|
|
const result = await ingestCanonicalPage(c, page);
|
|
const reviews: { orderId: string; terminalTransactionId: string; stateRoot: string; eventSequence: number; eventHash: string }[] = [];
|
|
for (const event of result.appendedEvents) {
|
|
if (event.type !== 'disconnect') continue;
|
|
for (const txid of event.transactionIds) {
|
|
const terminal = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-tx:${txid}`]);
|
|
if (terminal.rowCount) {
|
|
const link = JSON.parse(terminal.rows[0].value);
|
|
if (link.version !== 1 || typeof link.orderId !== 'string' || typeof link.stateRoot !== 'string') {
|
|
throw new CustodyIntegrityError('sale-terminal-index-invalid');
|
|
}
|
|
reviews.push({ orderId: link.orderId, terminalTransactionId: txid, stateRoot: link.stateRoot,
|
|
eventSequence: event.sequence, eventHash: event.eventHash });
|
|
}
|
|
}
|
|
}
|
|
for (const review of reviews) {
|
|
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING',
|
|
[`sale-reorg-review:${review.orderId}:${review.eventSequence}`, JSON.stringify({ version: 1,
|
|
streamId: result.streamId, ...review })]);
|
|
}
|
|
if (reviews.length) await recordIntegrityIncident(c, 'posted-sale-terminal-disconnected');
|
|
return { ...result, appendedEvents: [], reviewRequired: reviews.length > 0 };
|
|
}
|
|
catch (error) {
|
|
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
|
throw error;
|
|
}
|
|
});
|
|
if (result.reviewRequired) {
|
|
this.integrityHold = true;
|
|
this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: 'posted-sale-terminal-disconnected' };
|
|
}
|
|
return result;
|
|
}
|
|
/** Consume one bounded page directly from this process's locally validated ledger; no HTTP/peer feed. */
|
|
async observeLedgerCanonicalEvents(ledger: Ledger) {
|
|
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
|
check(ledger instanceof Ledger, 'Canonical inbox requires a local Ledger instance');
|
|
const summary = ledger.summary();
|
|
check(summary.network === NETWORK && summary.chainId === CHAIN_ID, 'Canonical ledger identity mismatch');
|
|
const cursor = await this.transaction(false, async c => {
|
|
const row = await c.query(`SELECT key FROM wk_custody.custody_meta
|
|
WHERE key LIKE 'canonical-inbox:v1:event:%' ORDER BY key DESC LIMIT 1`);
|
|
if (!row.rowCount) return 0;
|
|
const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.rows[0].key);
|
|
if (!match) throw new CustodyIntegrityError('canonical-inbox-cursor-invalid');
|
|
return Number(match[1]);
|
|
});
|
|
let page: unknown;
|
|
try { page = ledger.canonicalEvents(cursor, 64); }
|
|
catch { throw new CustodyIntegrityError('canonical-ledger-cursor-mismatch'); }
|
|
return this.observeCanonicalEvents(page);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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');
|
|
try { await auditCanonicalInbox(c); }
|
|
catch (error) {
|
|
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
|
throw error;
|
|
}
|
|
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 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 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 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() })]);
|
|
}
|