481 lines
38 KiB
TypeScript
Executable File
481 lines
38 KiB
TypeScript
Executable File
import { z } from 'zod';
|
|
import { createHash } from 'node:crypto';
|
|
import type { PoolClient, QueryResult } from 'pg';
|
|
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
|
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
|
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
|
import { validateXmrAddress } from './xmr.ts';
|
|
import { replayReservedSale } from './sale-rehearsal.ts';
|
|
import { address, verifySignature } from '../testnet/consensus.ts';
|
|
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
|
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
|
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
|
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage } from '../../shared/monero-withdrawal.ts';
|
|
|
|
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
|
export class CustodyIntegrityError extends Error {
|
|
readonly code: string;
|
|
constructor(code: string) { super(`Custody integrity check failed: ${code}`); this.code = code; }
|
|
}
|
|
function fail(code: string): never { throw new CustodyIntegrityError(code); }
|
|
const uuid = z.string().uuid(), hex = z.string().regex(/^[a-f0-9]{64}$/);
|
|
const integer = z.number().int().nonnegative().safe();
|
|
const atomic = z.string().max(22).refine(s => { try { parseAtomicXmr(s); return true; } catch { return false; } });
|
|
const positive = atomic.refine(s => { try { return parseAtomicXmr(s) > 0n; } catch { return false; } });
|
|
const wallet = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/);
|
|
const listingSchema = z.object({ id: uuid, seller: hex, cardHash: hex, sequence: integer,
|
|
title: z.string().min(1).max(200), priceAtomicXmr: positive, feeBps: z.number().int().min(0).max(5000),
|
|
hostFeeAddress: wallet.optional(), status: z.enum(['open', 'reserved', 'cancelled', 'sold']), createdAt: integer }).strict();
|
|
const orderSchema = z.object({ id: uuid, listingId: uuid, seller: hex, buyer: hex, cardHash: hex, sequence: integer,
|
|
grossAtomicXmr: positive, sellerAtomicXmr: atomic, authorAtomicXmr: atomic, hostAtomicXmr: atomic,
|
|
authorAddress: wallet, hostFeeAddress: wallet.optional(), status: z.enum(['awaiting-lock', 'cancelled', 'settled']), createdAt: integer }).strict();
|
|
const withdrawalSchema = z.object({ id: uuid, actor: hex, destination: z.literal('mock-external'), amountAtomicXmr: positive,
|
|
maxFeeAtomicXmr: atomic, status: z.enum(['reserved', 'prepared', 'complete', 'cancelled']),
|
|
attemptId: uuid.optional(), transactionId: z.string().regex(/^fixture:[a-f0-9]{64}$/).optional(),
|
|
feeAtomicXmr: atomic.optional(), createdAt: integer }).strict();
|
|
type Entry = { account: string; debit: string; credit: string };
|
|
type Journal = { id: string; kind: string; lines: Entry[] };
|
|
function parse<T>(schema: z.ZodType<T>, data: unknown, code: string): T {
|
|
const result = schema.safeParse(data); return result.success ? result.data : fail(code);
|
|
}
|
|
function same(a: unknown, b: unknown): boolean {
|
|
// Compare parsed records without depending on JSONB object key ordering.
|
|
if (a === b) return true;
|
|
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
const x = a as Record<string, unknown>, y = b as Record<string, unknown>;
|
|
const keys = Object.keys(x).filter(k => x[k] !== undefined).sort();
|
|
return keys.join('\0') === Object.keys(y).filter(k => y[k] !== undefined).sort().join('\0') && keys.every(k => same(x[k], y[k]));
|
|
}
|
|
function json(text: string | null): unknown { try { return JSON.parse(text ?? ''); } catch { return fail('outbox-encoding'); } }
|
|
const d = (account: string, amount: string): Entry => ({ account, debit: amount, credit: '0' });
|
|
const c = (account: string, amount: string): Entry => ({ account, debit: '0', credit: amount });
|
|
async function journals(client: PoolClient, ids: string[]): Promise<Map<string, Journal>> {
|
|
const result = await client.query(`SELECT j.id,j.kind,jsonb_agg(jsonb_build_object('account',e.account,
|
|
'debit',e.debit::text,'credit',e.credit::text) ORDER BY e.position) FILTER(WHERE e.journal IS NOT NULL) AS lines
|
|
FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
|
|
WHERE j.id=ANY($1::text[]) GROUP BY j.id`, [ids]);
|
|
return new Map(result.rows.map(r => [r.id, r as Journal]));
|
|
}
|
|
function expectJournal(all: Map<string, Journal>, id: string, kind: string, expected: Entry[] | null) {
|
|
const got = all.get(id);
|
|
if (expected === null) { if (got) fail('unexpected-terminal-posting'); return; }
|
|
if (!got || got.kind !== kind || !Array.isArray(got.lines)) fail('missing-lifecycle-posting');
|
|
const normalized = (lines: Entry[]) => lines.filter(l => BigInt(l.debit) !== 0n || BigInt(l.credit) !== 0n)
|
|
.map(l => JSON.stringify([l.account, l.debit, l.credit])).sort();
|
|
if (!same(normalized(got.lines), normalized(expected))) fail('lifecycle-posting-mismatch');
|
|
}
|
|
async function validateWallets(values: (string | undefined)[]) {
|
|
// Bounded per-batch cache, not a growing process-wide map of customer data.
|
|
for (const address of new Set(values.filter((v): v is string => !!v))) {
|
|
try { await validateXmrAddress(address); } catch { fail('fee-recipient-invalid'); }
|
|
}
|
|
}
|
|
|
|
type OrderRow = { id: string; buyer: string; seller: string; status: string; data: unknown;
|
|
listing_data: unknown; listing_status: string; listing_card: string; balance: string | null;
|
|
normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null };
|
|
async function checkOrders(client: PoolClient, rows: OrderRow[]) {
|
|
const posting = await journals(client, rows.flatMap(r => [`reserve:${r.id}`, `cancel:${r.id}`, `settle:${r.id}`]));
|
|
const wallets: string[] = [];
|
|
for (const row of rows) {
|
|
const o = parse(orderSchema, row.data, 'order-record-invalid'), l = parse(listingSchema, row.listing_data, 'order-listing-invalid');
|
|
if (o.id !== row.id || o.buyer !== row.buyer || o.seller !== row.seller || o.status !== row.status ||
|
|
l.status !== row.listing_status || l.cardHash !== row.listing_card || o.listingId !== l.id || o.seller !== l.seller ||
|
|
o.cardHash !== l.cardHash || o.sequence !== l.sequence || o.grossAtomicXmr !== l.priceAtomicXmr || o.seller === o.buyer ||
|
|
(o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== (l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address)) fail('order-terms-mismatch');
|
|
const split = allocateXmrSale(BigInt(l.priceAtomicXmr), l.feeBps);
|
|
if (o.sellerAtomicXmr !== split.sellerBeforeNetworkCosts.toString() || o.hostAtomicXmr !== split.hosting.toString() ||
|
|
o.authorAtomicXmr !== split.author.toString() || o.authorAddress !== ADDITIONAL_MARKET_FEE.address) fail('order-allocation-mismatch');
|
|
wallets.push(o.authorAddress, o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address);
|
|
const account = `customer:${o.buyer}:purchase:${o.id}`, available = `customer:${o.buyer}:available`;
|
|
if (row.normal !== 'credit' || row.balance !== (o.status === 'awaiting-lock' ? o.grossAtomicXmr : '0')) fail('order-reservation-mismatch');
|
|
const expectedStatus = o.status === 'awaiting-lock' ? 'reserved' : o.status === 'settled' ? 'sold' : 'cancelled';
|
|
if (l.status !== expectedStatus || row.box_kind !== 'nft-lock') fail('order-state-mismatch');
|
|
if (o.status === 'settled') {
|
|
if (row.box_status !== 'fixture-complete' || !/^fixture:[a-f0-9]{64}$/.test(row.box_data ?? '')) fail('settlement-evidence-mismatch');
|
|
} else {
|
|
const original = parse(orderSchema, json(row.box_data), 'order-outbox-invalid');
|
|
if (row.box_status !== (o.status === 'cancelled' ? 'cancelled' : 'blocked') ||
|
|
!same(original, { ...o, status: 'awaiting-lock' })) fail('order-outbox-mismatch');
|
|
}
|
|
expectJournal(posting, `reserve:${o.id}`, 'purchase-reservation', [d(available, o.grossAtomicXmr), c(account, o.grossAtomicXmr)]);
|
|
expectJournal(posting, `cancel:${o.id}`, 'purchase-cancellation', o.status === 'cancelled' ? [d(account, o.grossAtomicXmr), c(available, o.grossAtomicXmr)] : null);
|
|
expectJournal(posting, `settle:${o.id}`, 'mock-sale-settlement', o.status === 'settled' ? [d(account, o.grossAtomicXmr),
|
|
c(`customer:${o.seller}:available`, o.sellerAtomicXmr), c(`author:${o.authorAddress}`, o.authorAtomicXmr),
|
|
c(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', o.hostAtomicXmr)] : null);
|
|
const evidenceRows = await client.query('SELECT key,value FROM wk_custody.custody_meta WHERE key=ANY($1::text[])',
|
|
[[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`,
|
|
`sale-rehearsal-terminal-index:${o.id}`]]);
|
|
if (evidenceRows.rowCount) {
|
|
try {
|
|
const records = new Map<string, string>(evidenceRows.rows.map(r => [r.key, r.value]));
|
|
const binding = records.get(`sale-rehearsal-binding:${o.id}`);
|
|
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
|
const verify = (key: string) => {
|
|
const evidence = JSON.parse(records.get(key) ?? '');
|
|
const replay = replayReservedSale({ ...o, status: 'awaiting-lock' }, JSON.parse(identity.rows[0].value).id, evidence.input, l.feeBps);
|
|
if (evidence.version !== 1 || evidence.binding !== binding || replay.binding !== binding ||
|
|
replay.stateRoot !== evidence.stateRoot) fail('sale-rehearsal-evidence-mismatch');
|
|
return replay;
|
|
};
|
|
verify(`sale-rehearsal-observation:${o.id}`);
|
|
if (o.status === 'awaiting-lock') {
|
|
if (records.has(`sale-rehearsal-terminal:${o.id}`) || records.has(`sale-rehearsal-terminal-index:${o.id}`)) {
|
|
fail('sale-rehearsal-evidence-mismatch');
|
|
}
|
|
} else {
|
|
const replay = verify(`sale-rehearsal-terminal:${o.id}`);
|
|
if (replay.decision.action !== (o.status === 'settled' ? 'settle' : 'refund') ||
|
|
(o.status === 'settled' && row.box_data !== `fixture:${replay.stateRoot}`)) fail('sale-rehearsal-evidence-mismatch');
|
|
const index = JSON.parse(records.get(`sale-rehearsal-terminal-index:${o.id}`) ?? '');
|
|
if (index.version !== 1 || index.stateRoot !== replay.stateRoot ||
|
|
index.terminalTransactionId !== (replay.terminalTransactionId ?? null)) fail('sale-rehearsal-evidence-mismatch');
|
|
if (replay.terminalTransactionId) {
|
|
const link = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1',
|
|
[`sale-rehearsal-tx:${replay.terminalTransactionId}`]);
|
|
const parsedLink = JSON.parse(link.rows[0]?.value ?? '');
|
|
if (parsedLink.version !== 1 || parsedLink.orderId !== o.id || parsedLink.stateRoot !== replay.stateRoot) {
|
|
fail('sale-rehearsal-terminal-index-mismatch');
|
|
}
|
|
}
|
|
}
|
|
} catch { fail('sale-rehearsal-evidence-mismatch'); }
|
|
}
|
|
}
|
|
await validateWallets(wallets);
|
|
}
|
|
const orderSelect = `SELECT o.id::text,o.buyer,o.seller,o.status,o.data,l.data AS listing_data,l.status AS listing_status,
|
|
l.card AS listing_card,a.balance::text,a.normal,b.kind AS box_kind,b.status AS box_status,b.data AS box_data
|
|
FROM wk_custody.orders o LEFT JOIN wk_custody.listings l ON l.id::text=o.data->>'listingId'
|
|
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||o.buyer||':purchase:'||o.id::text
|
|
LEFT JOIN wk_custody.outbox b ON b.id=o.id`;
|
|
|
|
type WithdrawalRow = { id: string; actor: string; status: string; data: unknown; balance: string | null;
|
|
normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null };
|
|
async function checkWithdrawals(client: PoolClient, rows: WithdrawalRow[]) {
|
|
const posting = await journals(client, rows.flatMap(r => [`withdraw:${r.id}`, `withdraw-cancel:${r.id}`, `withdraw-confirm:${r.id}`]));
|
|
for (const row of rows) {
|
|
const w = parse(withdrawalSchema, row.data, 'withdrawal-record-invalid');
|
|
if (w.id !== row.id || w.actor !== row.actor || w.status !== row.status) fail('withdrawal-identity-mismatch');
|
|
const total = (BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr)).toString();
|
|
const active = w.status === 'reserved' || w.status === 'prepared';
|
|
if (row.normal !== 'credit' || row.balance !== (active ? total : '0')) fail('withdrawal-reservation-mismatch');
|
|
const prepared = w.status === 'prepared' || w.status === 'complete';
|
|
if (prepared ? !w.attemptId || !w.transactionId || w.feeAtomicXmr === undefined || BigInt(w.feeAtomicXmr) > BigInt(w.maxFeeAtomicXmr)
|
|
: w.attemptId !== undefined || w.transactionId !== undefined || w.feeAtomicXmr !== undefined) fail('withdrawal-attempt-invalid');
|
|
const status = w.status === 'reserved' ? 'blocked' : w.status === 'cancelled' ? 'cancelled'
|
|
: w.status === 'prepared' ? 'fixture-prepared' : 'fixture-complete';
|
|
const box = parse(withdrawalSchema, json(row.box_data), 'withdrawal-outbox-invalid');
|
|
if (row.box_kind !== 'withdrawal' || row.box_status !== status || !same(box, { ...w, status: prepared ? 'prepared' : 'reserved' })) fail('withdrawal-outbox-mismatch');
|
|
const account = `customer:${w.actor}:withdrawal:${w.id}`, available = `customer:${w.actor}:available`;
|
|
expectJournal(posting, `withdraw:${w.id}`, 'withdrawal-reservation', [d(available, total), c(account, total)]);
|
|
expectJournal(posting, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', w.status === 'cancelled' ? [d(account, total), c(available, total)] : null);
|
|
expectJournal(posting, `withdraw-confirm:${w.id}`, 'mock-withdrawal', w.status === 'complete' ? [d(account, total),
|
|
c('treasury:mock', (BigInt(w.amountAtomicXmr) + BigInt(w.feeAtomicXmr!)).toString()),
|
|
c(available, (BigInt(w.maxFeeAtomicXmr) - BigInt(w.feeAtomicXmr!)).toString())] : null);
|
|
}
|
|
}
|
|
|
|
type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observation_id: string; allocation_id: string;
|
|
beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string;
|
|
observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string;
|
|
review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string;
|
|
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null;
|
|
release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null;
|
|
release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null;
|
|
release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null;
|
|
release_review_digest: string | null; released_at: string | null };
|
|
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
|
let after: string | null = null;
|
|
for (;;) {
|
|
budget();
|
|
const result: QueryResult<DepositCreditRow> = await client.query<DepositCreditRow>(`SELECT c.credit_id::text,c.wallet_fingerprint,c.observation_id,
|
|
c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text,
|
|
c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text,
|
|
c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations,
|
|
a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner,
|
|
r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce,
|
|
r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature,
|
|
r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text
|
|
FROM wk_custody.monero_deposit_credit_events c
|
|
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text
|
|
LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id
|
|
WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]);
|
|
const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])]));
|
|
for (const row of result.rows) {
|
|
let approvalIsValid = false;
|
|
let subjectIsValid = false;
|
|
try {
|
|
const message = depositCreditApprovalMessage(row.review_subject, row.reviewer_owner,
|
|
row.review_nonce, Number(row.review_expires_at));
|
|
approvalIsValid = address(row.reviewer_public_key) === row.reviewer_owner &&
|
|
createHash('sha256').update(message).digest('hex') === row.review_digest &&
|
|
Number(row.review_expires_at) >= Number(row.credited_at) &&
|
|
verifySignature(row.reviewer_public_key, row.reviewer_owner, message, row.review_signature);
|
|
subjectIsValid = row.observation_id === `${row.review_subject.observationId.split(':')[0]}:${row.review_subject.outputPublicKey}` &&
|
|
row.review_subject.walletFingerprint === row.wallet_fingerprint &&
|
|
row.review_subject.observationDigest === row.observation_digest &&
|
|
row.review_subject.observationId === row.observation_id &&
|
|
row.review_subject.amountAtomicXmr === row.amount_atomic_xmr && row.review_subject.beneficiary === row.beneficiary &&
|
|
row.review_subject.allocationId === row.allocation_id &&
|
|
row.review_subject.observationEventSequence === Number(row.last_observation_event_sequence) &&
|
|
row.review_subject.minimumConfirmations === row.minimum_confirmations;
|
|
} catch { approvalIsValid = false; }
|
|
if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch');
|
|
const released = row.release_id !== null;
|
|
if (row.normal !== 'credit' || row.balance !== (released ? '0' : row.amount_atomic_xmr)) fail('deposit-pending-liability-mismatch');
|
|
expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [
|
|
d('treasury:monero:pending', row.amount_atomic_xmr),
|
|
c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
|
]);
|
|
if (released) {
|
|
let releaseValid = false;
|
|
try {
|
|
const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!,
|
|
row.release_review_nonce!, Number(row.release_review_expires_at));
|
|
const releaseSubject = row.release_review_subject!;
|
|
releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner &&
|
|
createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest &&
|
|
Number(row.release_review_expires_at) >= Number(row.released_at) &&
|
|
verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) &&
|
|
releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint &&
|
|
releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary &&
|
|
releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id;
|
|
} catch { releaseValid = false; }
|
|
if (!releaseValid) fail('deposit-release-review-invalid');
|
|
expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [
|
|
d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
|
c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr),
|
|
]);
|
|
const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text,
|
|
r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text,
|
|
r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence,
|
|
h.event_type AS hold_event_type
|
|
FROM wk_custody.monero_deposit_reorg_resolutions r
|
|
JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]);
|
|
const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort();
|
|
const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review');
|
|
const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
|
FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r
|
|
ON r.hold_id=h.hold_id AND r.resolution_kind='release-review'
|
|
WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]);
|
|
const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])]
|
|
.map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort();
|
|
const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([
|
|
hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort();
|
|
if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner ||
|
|
item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce ||
|
|
item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature ||
|
|
item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') ||
|
|
releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') ||
|
|
releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) {
|
|
fail('deposit-reorg-resolution-invalid');
|
|
}
|
|
for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) {
|
|
let resolutionValid = false;
|
|
try {
|
|
const subject = resolution.review_subject as DepositReorgResolutionSubject;
|
|
const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by,
|
|
resolution.review_nonce, Number(resolution.review_expires_at));
|
|
resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' &&
|
|
subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id &&
|
|
subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type &&
|
|
subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id &&
|
|
subject.currentObservation.beneficiary === row.beneficiary &&
|
|
subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr &&
|
|
address(resolution.reviewer_public_key) === resolution.resolved_by &&
|
|
createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest &&
|
|
Number(resolution.review_expires_at) >= Number(resolution.resolved_at) &&
|
|
verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature);
|
|
} catch { resolutionValid = false; }
|
|
if (!resolutionValid) fail('deposit-reorg-resolution-invalid');
|
|
}
|
|
if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) {
|
|
fail('deposit-reorg-resolution-invalid');
|
|
}
|
|
}
|
|
}
|
|
if (result.rows.length < 128) break;
|
|
after = result.rows.at(-1)!.credit_id;
|
|
}
|
|
const orphanAccount = await client.query(`SELECT 1 FROM wk_custody.accounts a
|
|
WHERE a.id LIKE 'customer:%:deposit-pending:%' AND NOT EXISTS (
|
|
SELECT 1 FROM wk_custody.monero_deposit_credit_events c
|
|
WHERE a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text) LIMIT 1`);
|
|
if (orphanAccount.rowCount) fail('orphan-deposit-pending-liability');
|
|
const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit'
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`);
|
|
if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal');
|
|
const orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release'
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`);
|
|
if (orphanRelease.rowCount) fail('orphan-deposit-release-journal');
|
|
const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r
|
|
LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id
|
|
LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id
|
|
WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`);
|
|
if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution');
|
|
const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
|
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id
|
|
WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR
|
|
h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`);
|
|
if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch');
|
|
}
|
|
|
|
async function checkMainnetWithdrawalIntents(client: PoolClient, budget: () => void): Promise<void> {
|
|
type WithdrawalIntentAuditRow = { request_id: string; custodian_id: string; market_network: string; market_chain_id: string;
|
|
beneficiary: string; destination: string; amount_atomic_xmr: string; max_fee_atomic_xmr: string; public_key: string;
|
|
expires_at: string; request_signature: string; request_digest: string; status: string; created_at: string;
|
|
cancel_request_id: string | null; cancel_public_key: string | null; cancel_expires_at: string | null;
|
|
cancel_signature: string | null; cancelled_at: string | null };
|
|
let after: string | null = null;
|
|
const identityResult = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
|
if (!identityResult.rowCount) {
|
|
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-missing');
|
|
return;
|
|
}
|
|
const identity = JSON.parse(identityResult.rows[0].value) as Record<string, unknown>;
|
|
if (identity.mode !== 'mainnet-custody' || identity.network !== 'wikideck-randomx-mainnet-1' || identity.policy !== 'custody-mainnet-v1') {
|
|
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-mismatch');
|
|
return;
|
|
}
|
|
for (;;) {
|
|
budget();
|
|
const result: QueryResult<WithdrawalIntentAuditRow> = await client.query<WithdrawalIntentAuditRow>(`SELECT request_id::text,custodian_id::text,market_network,market_chain_id,beneficiary,
|
|
destination,amount_atomic_xmr::text,max_fee_atomic_xmr::text,public_key,expires_at::text,request_signature,
|
|
request_digest,status,created_at::text,cancel_request_id::text,cancel_public_key,cancel_expires_at::text,
|
|
cancel_signature,cancelled_at::text FROM wk_custody.monero_withdrawal_intents
|
|
WHERE ($1::uuid IS NULL OR request_id>$1::uuid) ORDER BY request_id LIMIT 128`, [after]);
|
|
const postings = await journals(client, result.rows.flatMap(row => [
|
|
`monero-withdrawal-reserve:${row.request_id}`, `monero-withdrawal-cancel:${row.request_id}`]));
|
|
for (const row of result.rows) {
|
|
let destination: string;
|
|
try { destination = await validateXmrAddress(row.destination); }
|
|
catch { fail('mainnet-withdrawal-destination-invalid'); }
|
|
const total = (BigInt(row.amount_atomic_xmr) + BigInt(row.max_fee_atomic_xmr)).toString();
|
|
const aggregate = await client.query(`SELECT
|
|
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
|
|
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved,
|
|
COALESCE((SELECT sum(c.amount_atomic_xmr) FROM wk_custody.monero_deposit_credit_events c
|
|
JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id WHERE c.beneficiary=$1),0)::text AS credited`,
|
|
[row.beneficiary]);
|
|
if (BigInt(aggregate.rows[0].reserved) > BigInt(aggregate.rows[0].credited)) {
|
|
fail('mainnet-withdrawal-exceeds-reviewed-credit');
|
|
}
|
|
const holdAccount = `customer:${row.beneficiary}:monero-withdrawal:${row.request_id}`;
|
|
const availableAccount = `customer:${row.beneficiary}:available`;
|
|
const hold = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', [holdAccount]);
|
|
const active = row.status === 'reserved';
|
|
if (!['reserved','cancelled'].includes(row.status) || (active && (hold.rowCount !== 1 || hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== total)) ||
|
|
(!active && hold.rowCount === 1 && (hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== '0'))) fail('mainnet-withdrawal-reservation-mismatch');
|
|
try {
|
|
const request = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
|
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
|
publicKey: row.public_key, requestId: row.request_id, destination, amountAtomicXmr: row.amount_atomic_xmr,
|
|
maxFeeAtomicXmr: row.max_fee_atomic_xmr, expiresAt: Number(row.expires_at), signature: row.request_signature };
|
|
const { signature: _signature, ...unsigned } = request;
|
|
const message = mainnetWithdrawalMessage(unsigned);
|
|
if (row.market_network !== 'wikideck-randomx-mainnet-1' || row.custodian_id !== identity.id ||
|
|
address(row.public_key) !== row.beneficiary || createHash('sha256').update(message).digest('hex') !== row.request_digest ||
|
|
!verifySignature(row.public_key, row.beneficiary, message, row.request_signature)) fail('mainnet-withdrawal-authorization-invalid');
|
|
} catch { fail('mainnet-withdrawal-authorization-invalid'); }
|
|
expectJournal(postings, `monero-withdrawal-reserve:${row.request_id}`, 'monero-withdrawal-reservation', [
|
|
d(availableAccount, total), c(holdAccount, total)]);
|
|
if (active) {
|
|
if (row.cancel_request_id || row.cancel_signature || row.cancelled_at) fail('mainnet-withdrawal-cancellation-invalid');
|
|
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', null);
|
|
} else {
|
|
try {
|
|
const cancellation = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
|
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
|
publicKey: row.cancel_public_key!, cancellationId: row.cancel_request_id!,
|
|
withdrawalRequestId: row.request_id, expiresAt: Number(row.cancel_expires_at), signature: row.cancel_signature! };
|
|
const { signature: cancelSignature, ...unsigned } = cancellation;
|
|
const message = mainnetWithdrawalCancellationMessage(unsigned);
|
|
if (address(cancellation.publicKey) !== row.beneficiary ||
|
|
!verifySignature(cancellation.publicKey, row.beneficiary, message, cancelSignature)) fail('mainnet-withdrawal-cancellation-invalid');
|
|
} catch { fail('mainnet-withdrawal-cancellation-invalid'); }
|
|
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', [
|
|
d(holdAccount, total), c(availableAccount, total)]);
|
|
}
|
|
}
|
|
if (result.rows.length < 128) break;
|
|
after = result.rows.at(-1)!.request_id;
|
|
}
|
|
}
|
|
|
|
const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal,
|
|
b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w
|
|
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text
|
|
LEFT JOIN wk_custody.outbox b ON b.id=w.id`;
|
|
|
|
/** Targeted mutation checks share the same transaction as postings/receipts. No external side effects. */
|
|
export async function auditOrder(client: PoolClient, id: string) {
|
|
const result = await client.query(`${orderSelect} WHERE o.id=$1`, [id]);
|
|
if (result.rowCount !== 1) fail('order-record-missing'); await checkOrders(client, result.rows);
|
|
}
|
|
export async function auditWithdrawal(client: PoolClient, id: string) {
|
|
const result = await client.query(`${withdrawalSelect} WHERE w.id=$1`, [id]);
|
|
if (result.rowCount !== 1) fail('withdrawal-record-missing'); await checkWithdrawals(client, result.rows);
|
|
}
|
|
/** Caller supplies a coherent transaction. Keyset pages bound application memory, not total audit duration. */
|
|
export async function auditBusinessLiabilities(client: PoolClient): Promise<void> {
|
|
const deadline = performance.now() + 15000;
|
|
const budget = () => { if (performance.now() > deadline) throw new Error('Custody business audit budget exceeded'); };
|
|
let after: string | null = null;
|
|
for (;;) {
|
|
budget();
|
|
const result: QueryResult<OrderRow> = await client.query(`${orderSelect} WHERE ($1::uuid IS NULL OR o.id>$1::uuid) ORDER BY o.id LIMIT 128`, [after]);
|
|
await checkOrders(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
|
}
|
|
after = null;
|
|
for (;;) {
|
|
budget();
|
|
const result: QueryResult<WithdrawalRow> = await client.query(`${withdrawalSelect} WHERE ($1::uuid IS NULL OR w.id>$1::uuid) ORDER BY w.id LIMIT 128`, [after]);
|
|
await checkWithdrawals(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
|
}
|
|
after = null;
|
|
for (;;) {
|
|
budget();
|
|
const result: QueryResult<{ id: string; card: string; status: string; data: unknown }> = await client.query('SELECT id::text,card,status,data FROM wk_custody.listings WHERE ($1::uuid IS NULL OR id>$1::uuid) ORDER BY id LIMIT 128', [after]);
|
|
const wallets: string[] = [];
|
|
for (const row of result.rows) {
|
|
const l = parse(listingSchema, row.data, 'listing-record-invalid');
|
|
if (row.id !== l.id || row.card !== l.cardHash || row.status !== l.status) fail('listing-identity-mismatch');
|
|
wallets.push(l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address);
|
|
}
|
|
await validateWallets(wallets);
|
|
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
|
}
|
|
await checkDepositCredits(client, budget);
|
|
await checkMainnetWithdrawalIntents(client, budget);
|
|
// Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings.
|
|
const checks: [string, string][] = [
|
|
['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%'
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE a.id='customer:'||o.buyer||':purchase:'||o.id::text) LIMIT 1`],
|
|
['orphan-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:withdrawal:%'
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE a.id='customer:'||w.actor||':withdrawal:'||w.id::text) LIMIT 1`],
|
|
['orphan-monero-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:monero-withdrawal:%'
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents w
|
|
WHERE a.id='customer:'||w.beneficiary||':monero-withdrawal:'||w.request_id::text) LIMIT 1`],
|
|
['orphan-outbox', `SELECT 1 FROM wk_custody.outbox b WHERE NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE o.id=b.id AND b.kind='nft-lock')
|
|
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE w.id=b.id AND b.kind='withdrawal') LIMIT 1`],
|
|
['listing-order-mismatch', `SELECT 1 FROM wk_custody.listings l LEFT JOIN wk_custody.orders o ON o.data->>'listingId'=l.id::text
|
|
WHERE (l.status IN ('reserved','sold') AND o.id IS NULL) OR l.id::text IS DISTINCT FROM l.data->>'id'
|
|
OR l.status IS DISTINCT FROM l.data->>'status' OR l.card IS DISTINCT FROM l.data->>'cardHash' LIMIT 1`],
|
|
['duplicate-listing-orders', `SELECT 1 FROM wk_custody.orders GROUP BY data->>'listingId' HAVING count(*)<>1 LIMIT 1`],
|
|
['orphan-lifecycle-posting', `SELECT 1 FROM wk_custody.journals j WHERE
|
|
(j.kind IN ('purchase-reservation','purchase-cancellation','mock-sale-settlement') AND NOT EXISTS
|
|
(SELECT 1 FROM wk_custody.orders o WHERE j.id IN ('reserve:'||o.id::text,'cancel:'||o.id::text,'settle:'||o.id::text))) OR
|
|
(j.kind IN ('withdrawal-reservation','withdrawal-cancellation','mock-withdrawal') AND NOT EXISTS
|
|
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) OR
|
|
(j.kind IN ('monero-withdrawal-reservation','monero-withdrawal-cancellation') AND NOT EXISTS
|
|
(SELECT 1 FROM wk_custody.monero_withdrawal_intents w WHERE j.id IN
|
|
('monero-withdrawal-reserve:'||w.request_id::text,'monero-withdrawal-cancel:'||w.request_id::text))) LIMIT 1`],
|
|
['duplicate-prepared-attempt', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
|
GROUP BY data->>'attemptId' HAVING count(*)>1 LIMIT 1`],
|
|
['duplicate-prepared-transaction', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
|
GROUP BY data->>'transactionId' HAVING count(*)>1 LIMIT 1`],
|
|
];
|
|
for (const [code, sql] of checks) { budget(); if ((await client.query(sql)).rowCount) fail(code); }
|
|
}
|