242 lines
19 KiB
TypeScript
Executable File
242 lines
19 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 { 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';
|
|
|
|
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number };
|
|
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 constructor(pool: Pool, options: CustodyOptions, id: string) { this.pool = pool; this.options = options; this.id = id; }
|
|
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
|
|
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
|
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 {
|
|
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(() => {}); 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(); }
|
|
info(): CustodyInfo {
|
|
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
|
|
custodianId: this.id, feeBps: this.options.feeBps, liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
|
}
|
|
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;
|
|
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]);
|
|
const result = await fn(c);
|
|
await c.query('COMMIT');
|
|
return result;
|
|
} catch (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');
|
|
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; }
|
|
// 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');
|
|
}
|
|
const result = await this.mutate(c, cmd);
|
|
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 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, 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');
|
|
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,
|
|
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);
|
|
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');
|
|
}
|
|
audit(): Promise<CustodyAudit> { return this.transaction(false, auditPostgres); }
|
|
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 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');
|
|
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');
|
|
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');
|
|
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 };
|
|
}
|