Files
wikideck/server/market/custody.ts
T
2026-10-22 14:40:00 -04:00

352 lines
25 KiB
TypeScript
Executable File

import { DatabaseSync } from 'node:sqlite';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
import { NETWORK } from '../../shared/testnet.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 CustodyCommand, type CustodyActivity, type CustodyInfo, type CustodyListing,
type CustodyOrder, type CustodyWithdrawal, type CustodyPage } from '../../shared/custody.ts';
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
export function custodyMode(env: NodeJS.ProcessEnv = process.env): 'off' | 'sandbox' {
const mode = env.WIKIDECK_CUSTODY_MODE ?? 'off';
if (mode !== 'off' && mode !== 'sandbox') throw new Error('Custody supports off or sandbox only; live custody is disabled');
if (IS_MAINNET && mode !== 'off') throw new Error('Custody sandbox cannot run on mainnet');
return mode;
}
export class CustodyError extends Error {}
type Card = { title: string };
type Options = { feeBps: number; card(cardHash: string, owner: string, sequence: number): Card | undefined; now?: () => number } & MarketPolicy;
type Account = { id: string; normal: 'debit' | 'credit'; balance: string };
type Line = { account: string; normal: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
const AGGREGATE_MAX = 10n ** 30n - 1n;
const LIMIT = 10000; // Explicit disposable-sandbox resource budget; never archive financial history silently.
const available = (actor: string) => `customer:${actor}:available`;
const orderAccount = (order: CustodyOrder) => `customer:${order.buyer}:purchase:${order.id}`;
const withdrawalAccount = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`;
const debit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, debit: amount });
const credit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, credit: amount });
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function requireState(condition: unknown, message: string): asserts condition {
if (!condition) throw new CustodyError(message);
}
/** Isolated, durable OFFLINE/SANDBOX financial model. No wallet RPC, keys, addresses or NFT signer. */
export class CustodyMarket {
private readonly db: DatabaseSync;
private readonly options: Options;
readonly id: string;
private readonly now: () => number;
private constructor(db: DatabaseSync, options: Options, id: string) {
this.db = db; this.options = options; this.id = id; this.now = options.now ?? Date.now;
}
static async open(directory: string, options: Options): Promise<CustodyMarket> {
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
await validateMarketPolicy(options);
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
const folder = join(directory, PROFILE);
await mkdir(folder, { recursive: true, mode: 0o700 });
const file = join(folder, 'custody-sandbox.sqlite');
const db = new DatabaseSync(file);
try {
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS custody_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS accounts(id TEXT PRIMARY KEY,normal TEXT NOT NULL CHECK(normal IN ('debit','credit')),balance TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS journals(id TEXT PRIMARY KEY,kind TEXT NOT NULL,created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS entries(journal TEXT NOT NULL REFERENCES journals(id),position INTEGER NOT NULL,
account TEXT NOT NULL REFERENCES accounts(id),debit TEXT NOT NULL,credit TEXT NOT NULL,PRIMARY KEY(journal,position));
CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY,card TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS custody_one_card ON listings(card) WHERE status IN ('open','reserved');
CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY,buyer TEXT NOT NULL,seller TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS custody_orders_buyer ON orders(buyer);
CREATE INDEX IF NOT EXISTS custody_orders_seller ON orders(seller);
CREATE TABLE IF NOT EXISTS withdrawals(id TEXT PRIMARY KEY,actor TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS custody_withdrawals_actor ON withdrawals(actor);
CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY,digest TEXT NOT NULL,data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS outbox(id TEXT PRIMARY KEY,kind TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS credits(actor TEXT PRIMARY KEY);
CREATE TRIGGER IF NOT EXISTS entries_no_update BEFORE UPDATE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END;
CREATE TRIGGER IF NOT EXISTS entries_no_delete BEFORE DELETE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END;
CREATE TRIGGER IF NOT EXISTS journals_no_update BEFORE UPDATE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END;
CREATE TRIGGER IF NOT EXISTS journals_no_delete BEFORE DELETE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END;`);
db.exec('BEGIN IMMEDIATE');
let id: string;
try {
if (db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) {
throw new Error('SQLite custody was frozen for PostgreSQL cutover; resume the import, never restart SQLite');
}
const row = db.prepare('SELECT value FROM custody_meta WHERE key=?').get('identity') as { value: string } | undefined;
if (row) {
const identity = JSON.parse(row.value);
if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID ||
identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY ||
!/^[a-f0-9-]{36}$/.test(identity.id)) throw new Error('Custody identity/schema mismatch');
id = identity.id;
} else {
id = randomUUID();
db.prepare('INSERT INTO custody_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1,
mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID, id }));
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
if (process.platform !== 'win32') await chmod(file, 0o600);
const market = new CustodyMarket(db, options, id);
market.audit(); // Refuse to start if projections no longer match the immutable journal.
return market;
} catch (error) { db.close(); throw error; }
}
close() { this.db.close(); }
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 };
}
private atomic<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
try { const result = fn(); this.db.exec('COMMIT'); return result; }
catch (error) { this.db.exec('ROLLBACK'); throw error; }
}
private balance(id: string): bigint {
const row = this.db.prepare('SELECT balance FROM accounts WHERE id=?').get(id) as { balance: string } | undefined;
return row ? BigInt(row.balance) : 0n;
}
private post(id: string, kind: string, lines: Line[]) {
if (['mock-deposit', 'purchase-reservation', 'withdrawal-reservation'].includes(kind)) {
requireState((this.db.prepare('SELECT COUNT(*) AS n FROM journals').get() as { n: number }).n < LIMIT,
'Custody sandbox journal budget reached; existing reservations can still be cancelled');
}
const totals = lines.reduce((s, line) => s + (line.debit ?? 0n) - (line.credit ?? 0n), 0n);
if (totals !== 0n || !lines.length) throw new Error('Unbalanced custody journal');
this.db.prepare('INSERT INTO journals VALUES(?,?,?)').run(id, kind, this.now());
for (const [position, line] of lines.entries()) {
const d = line.debit ?? 0n, c = line.credit ?? 0n;
if (d < 0n || c < 0n || (d > 0n && c > 0n)) throw new Error('Invalid journal line');
if (d === 0n && c === 0n) continue;
this.db.prepare('INSERT OR IGNORE INTO accounts VALUES(?,?,?)').run(line.account, line.normal, '0');
const account = this.db.prepare('SELECT * FROM accounts WHERE id=?').get(line.account) as Account;
if (account.normal !== line.normal) throw new Error('Account normal changed');
const balance = BigInt(account.balance) + (line.normal === 'debit' ? d - c : c - d);
requireState(balance >= 0n, 'Insufficient available sandbox balance');
if (balance > AGGREGATE_MAX) throw new CustodyError('Custody aggregate amount limit reached');
this.db.prepare('INSERT INTO entries VALUES(?,?,?,?,?)').run(id, position, line.account, d.toString(), c.toString());
this.db.prepare('UPDATE accounts SET balance=? WHERE id=?').run(balance.toString(), line.account);
}
}
private get<T>(table: 'listings' | 'orders' | 'withdrawals', id: string): T {
const row = this.db.prepare(`SELECT data FROM ${table} WHERE id=?`).get(id) as { data: string } | undefined;
requireState(row, 'Custody record not found');
return JSON.parse(row.data) as T;
}
private saveOrder(order: CustodyOrder) {
this.db.prepare('INSERT INTO orders VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
.run(order.id, order.buyer, order.seller, order.status, JSON.stringify(order));
}
private saveWithdrawal(w: CustodyWithdrawal) {
this.db.prepare('INSERT INTO withdrawals VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
.run(w.id, w.actor, w.status, JSON.stringify(w));
}
private saveListing(l: CustodyListing) {
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
}
browse(offset = 0): CustodyPage {
requireState(Number.isSafeInteger(offset) && offset >= 0 && offset <= LIMIT, 'Invalid custody page');
if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null };
const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?")
.all(offset) as { data: string }[];
return { info: this.info(), listings: rows.slice(0, 24).map(r => JSON.parse(r.data)), next: rows.length > 24 ? offset + 24 : null };
}
private activity(actor: string, offset: number): CustodyActivity {
const orders = this.db.prepare('SELECT data FROM orders WHERE buyer=? OR seller=? ORDER BY rowid DESC LIMIT 25 OFFSET ?')
.all(actor, actor, offset) as { data: string }[];
const withdrawals = this.db.prepare('SELECT data FROM withdrawals WHERE actor=? ORDER BY rowid DESC LIMIT 25 OFFSET ?')
.all(actor, offset) as { data: string }[];
const sum = (prefix: string) => (this.db.prepare('SELECT balance FROM accounts WHERE id GLOB ?').all(prefix) as { balance: string }[])
.reduce((s, r) => s + BigInt(r.balance), 0n).toString();
return { availableAtomicXmr: this.balance(available(actor)).toString(),
purchaseReservedAtomicXmr: sum(`customer:${actor}:purchase:*`), withdrawalReservedAtomicXmr: sum(`customer:${actor}:withdrawal:*`),
mockCreditClaimed: !!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(actor),
orders: orders.slice(0, 24).map(r => JSON.parse(r.data)), withdrawals: withdrawals.slice(0, 24).map(r => JSON.parse(r.data)),
next: orders.length > 24 || withdrawals.length > 24 ? offset + 24 : null };
}
execute(raw: unknown): Result {
const c = custodyCommandSchema.parse(raw), message = custodyMessage(c), now = this.now();
requireState(c.network === NETWORK && c.chainId === CHAIN_ID && c.custodianId === this.id &&
c.expiresAt >= now && c.expiresAt <= now + 5 * 60_000 &&
verifySignature(c.publicKey, c.actor, message, c.signature), 'Invalid or expired custody signature');
if (c.action.type === 'activity') return this.activity(c.actor, c.action.offset);
return this.atomic(() => {
const key = `${c.actor}:${c.nonce}`, hash = digest(message);
const prior = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
if (prior) { requireState(prior.digest === hash, 'Nonce already used for different custody terms'); return JSON.parse(prior.data) as Result; }
requireState(permitsMarketAction(this.options, c.action.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(c.action.type)) {
requireState((this.db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as { n: number }).n < LIMIT,
'Custody sandbox command budget reached; existing reservations can still be cancelled');
}
const result = this.mutate(c);
this.db.prepare('INSERT INTO receipts VALUES(?,?,?)').run(key, hash, JSON.stringify(result));
return result;
});
}
private mutate(c: CustodyCommand): Result {
const a = c.action;
if (a.type === 'mock-credit') {
requireState(!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(c.actor), 'Sandbox credit already claimed');
this.post(`credit:${c.actor}`, 'mock-deposit', [debit('treasury:mock', BigInt(MOCK_CREDIT_ATOMIC), 'debit'), credit(available(c.actor), BigInt(MOCK_CREDIT_ATOMIC))]);
this.db.prepare('INSERT INTO credits VALUES(?)').run(c.actor);
return this.activity(c.actor, 0);
}
if (a.type === 'list') {
requireState(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress;
requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again');
const card = this.options.card(a.cardHash, c.actor, a.sequence);
requireState(card, 'Card is missing, stale, immature, or pending');
requireState(!this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash), 'Card already has a custody listing');
const l: CustodyListing = { id: randomUUID(), seller: c.actor, cardHash: a.cardHash, sequence: a.sequence,
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt: this.now() };
this.saveListing(l); return l;
}
if (a.type === 'cancel-listing' || a.type === 'reserve') {
const l = this.get<CustodyListing>('listings', a.listingId);
requireState(l.status === 'open', 'Custody listing is no longer available');
if (a.type === 'cancel-listing') {
requireState(l.seller === c.actor, 'Only the seller can cancel this listing');
l.status = 'cancelled'; this.saveListing(l); return l;
}
requireState(c.actor !== l.seller, 'Cannot buy your own card');
requireState(l.priceAtomicXmr === a.priceAtomicXmr && l.feeBps === a.feeBps, 'Custody sale terms changed');
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms');
requireState(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
const order: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.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: this.now() };
this.post(`reserve:${order.id}`, 'purchase-reservation', [debit(available(c.actor), allocation.gross), credit(orderAccount(order), allocation.gross)]);
l.status = 'reserved'; this.saveListing(l); this.saveOrder(order);
// Durable intent only. No NFT signing/submission worker is enabled until reviewed consensus rules exist.
this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(order.id, 'nft-lock', 'blocked', JSON.stringify(order));
return order;
}
if (a.type === 'cancel-reservation') {
const o = this.get<CustodyOrder>('orders', a.orderId);
requireState(o.buyer === c.actor && o.status === 'awaiting-lock', 'Only the buyer can cancel an unstarted sandbox reservation');
const intent = this.db.prepare('SELECT status FROM outbox WHERE id=?').get(o.id) as { status: string } | undefined;
requireState(intent?.status === 'blocked', 'NFT intent may have executed; reservation requires review');
// Safe only because this sandbox never emits a custody delivery witness. Do not reuse for live refunds.
this.post(`cancel:${o.id}`, 'purchase-cancellation', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)), credit(available(o.buyer), BigInt(o.grossAtomicXmr))]);
o.status = 'cancelled'; this.saveOrder(o);
const l = this.get<CustodyListing>('listings', o.listingId); l.status = 'cancelled'; this.saveListing(l);
this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(o.id);
return o;
}
if (a.type === 'withdraw') {
const w: CustodyWithdrawal = { id: randomUUID(), actor: c.actor, destination: a.destination,
amountAtomicXmr: a.amountAtomicXmr, maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt: this.now() };
const total = parseAtomicXmr(w.amountAtomicXmr) + parseAtomicXmr(w.maxFeeAtomicXmr);
this.post(`withdraw:${w.id}`, 'withdrawal-reservation', [debit(available(c.actor), total), credit(withdrawalAccount(w), total)]);
this.saveWithdrawal(w);
this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(w.id, 'withdrawal', 'blocked', JSON.stringify(w));
return w;
}
if (a.type === 'cancel-withdrawal') {
const w = this.get<CustodyWithdrawal>('withdrawals', a.withdrawalId);
requireState(w.actor === c.actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner');
const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr);
this.post(`withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [debit(withdrawalAccount(w), total), credit(available(c.actor), total)]);
w.status = 'cancelled'; this.saveWithdrawal(w);
this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(w.id);
return w;
}
throw new CustodyError('Unsupported custody action');
}
/** Offline fixture seam ONLY: records a simulated canonical event, never reachable from HTTP. */
settleFixture(orderId: string, evidenceId: string): CustodyOrder {
requireState(/^fixture:[a-f0-9]{64}$/.test(evidenceId), 'Expected offline fixture evidence');
return this.atomic(() => {
const o = this.get<CustodyOrder>('orders', orderId);
if (o.status === 'settled') {
const event = this.db.prepare('SELECT data FROM outbox WHERE id=?').get(o.id) as { data: string };
requireState(event.data === evidenceId, 'Conflicting fixture settlement evidence'); return o;
}
requireState(o.status === 'awaiting-lock', 'Order is not eligible for fixture settlement');
this.post(`settle:${o.id}`, 'mock-sale-settlement', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)),
credit(available(o.seller), BigInt(o.sellerAtomicXmr)), credit(`author:${o.authorAddress}`, BigInt(o.authorAtomicXmr)),
credit(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', BigInt(o.hostAtomicXmr))]);
o.status = 'settled'; this.saveOrder(o);
const l = this.get<CustodyListing>('listings', o.listingId); l.status = 'sold'; this.saveListing(l);
this.db.prepare("UPDATE outbox SET status='fixture-complete',data=? WHERE id=?").run(evidenceId, o.id);
return o;
});
}
/** Offline mock treasury: persist one transaction identity; a retry must never prepare another spend. */
prepareWithdrawalFixture(id: string, attemptId: string, transactionId: string, feeAtomicXmr: string): CustodyWithdrawal {
requireState(/^fixture:[a-f0-9]{64}$/.test(transactionId) && /^[a-f0-9-]{36}$/.test(attemptId), 'Expected offline withdrawal identity');
const fee = parseAtomicXmr(feeAtomicXmr);
return this.atomic(() => {
const w = this.get<CustodyWithdrawal>('withdrawals', id);
if (w.status === 'prepared' || w.status === 'complete') {
requireState(w.attemptId === attemptId && w.transactionId === transactionId && w.feeAtomicXmr === feeAtomicXmr,
'Withdrawal already prepared with different terms'); return w;
}
requireState(w.status === 'reserved' && fee <= BigInt(w.maxFeeAtomicXmr), 'Withdrawal state or fee cap invalid');
w.status = 'prepared'; w.attemptId = attemptId; w.transactionId = transactionId; w.feeAtomicXmr = feeAtomicXmr;
this.saveWithdrawal(w);
this.db.prepare("UPDATE outbox SET status='fixture-prepared',data=? WHERE id=?").run(JSON.stringify(w), w.id);
return w;
});
}
confirmWithdrawalFixture(id: string, transactionId: string): CustodyWithdrawal {
return this.atomic(() => {
const w = this.get<CustodyWithdrawal>('withdrawals', id);
requireState(w.transactionId === transactionId && ['prepared', 'complete'].includes(w.status), 'Withdrawal identity/state mismatch');
if (w.status === 'complete') return w;
const amount = BigInt(w.amountAtomicXmr), fee = BigInt(w.feeAtomicXmr!), maxFee = BigInt(w.maxFeeAtomicXmr);
this.post(`withdraw-confirm:${id}`, 'mock-withdrawal', [debit(withdrawalAccount(w), amount + maxFee),
credit('treasury:mock', amount + fee, 'debit'), credit(available(w.actor), maxFee - fee)]);
w.status = 'complete'; this.saveWithdrawal(w);
this.db.prepare("UPDATE outbox SET status='fixture-complete' WHERE id=?").run(w.id);
return w;
});
}
/** No floating SQL SUM: replay canonical strings using bigint, independently of balance projections. */
audit(): { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string } {
return this.atomic(() => {
const accounts = this.db.prepare('SELECT * FROM accounts').all() as Account[];
const balances = new Map(accounts.map(a => [a.id, 0n]));
const normals = new Map(accounts.map(a => [a.id, a.normal]));
const journals = this.db.prepare('SELECT id FROM journals').all() as { id: string }[];
for (const journal of journals) {
const lines = this.db.prepare('SELECT account,debit,credit FROM entries WHERE journal=? ORDER BY position').all(journal.id) as { account: string; debit: string; credit: string }[];
let total = 0n;
if (!lines.length) throw new Error('Empty custody journal');
for (const l of lines) {
if (!/^(0|[1-9]\d{0,29})$/.test(l.debit) || !/^(0|[1-9]\d{0,29})$/.test(l.credit)) throw new Error('Invalid custody entry encoding');
const d = BigInt(l.debit), c = BigInt(l.credit);
if (d > 0n && c > 0n || !normals.has(l.account)) throw new Error('Invalid custody entry');
total += d - c;
balances.set(l.account, balances.get(l.account)! + (normals.get(l.account) === 'debit' ? d - c : c - d));
}
if (total !== 0n) throw new Error('Unbalanced custody history');
}
let assets = 0n, claims = 0n;
for (const a of accounts) {
if (!/^(0|[1-9]\d{0,29})$/.test(a.balance) || balances.get(a.id) !== BigInt(a.balance)) throw new Error('Custody balance projection mismatch');
if (a.normal === 'debit') assets += BigInt(a.balance); else claims += BigInt(a.balance);
}
if (assets !== claims) throw new Error('Custody backing mismatch');
return { journals: journals.length, assetsAtomicXmr: assets.toString(), claimsAndRevenueAtomicXmr: claims.toString() };
});
}
}