341 lines
24 KiB
TypeScript
Executable File
341 lines
24 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 } 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';
|
|
|
|
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 };
|
|
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');
|
|
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
|
const folder = join(directory, 'testnet');
|
|
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 {
|
|
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, 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');
|
|
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; }
|
|
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 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, 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');
|
|
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,
|
|
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('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() };
|
|
});
|
|
}
|
|
}
|