Custodial markets
This commit is contained in:
Executable
+340
@@ -0,0 +1,340 @@
|
||||
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() };
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -6,11 +6,14 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, PROFILE } from '../shared/network.ts';
|
||||
import { CHAIN_ID } from './testnet/consensus.ts';
|
||||
import { CUSTODY_POLICY } from '../shared/custody.ts';
|
||||
|
||||
const files=['ledger.sqlite','market.sqlite'] as const;
|
||||
const files=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const;
|
||||
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1),
|
||||
network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(),
|
||||
files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).length(2)}).strict();
|
||||
files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict()
|
||||
.refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length &&
|
||||
m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate backup database');
|
||||
async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;}
|
||||
async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');}
|
||||
async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}}
|
||||
@@ -26,6 +29,11 @@ function inspect(file:string,name:typeof files[number]) {
|
||||
if(name==='ledger.sqlite'){
|
||||
const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value;
|
||||
if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch');
|
||||
}else if(name==='custody-sandbox.sqlite'){
|
||||
const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined;
|
||||
const identity=row?JSON.parse(row.value):undefined;
|
||||
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'||
|
||||
identity.policy!==CUSTODY_POLICY||!z.string().uuid().safeParse(identity.id).success)throw new Error('Custody identity mismatch');
|
||||
}else{
|
||||
const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined;
|
||||
const identity=row?JSON.parse(row.value):undefined;
|
||||
@@ -50,6 +58,9 @@ export async function createBackup(dataDir:string,destination:string){
|
||||
const entries=[];
|
||||
for(const name of files){
|
||||
const src=join(source,PROFILE,name),target=join(dest,name);
|
||||
if(name==='custody-sandbox.sqlite'){
|
||||
try{await lstat(src);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;}
|
||||
}
|
||||
await regular(src);inspect(src,name);
|
||||
const db=new DatabaseSync(src,{readOnly:true});
|
||||
try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();}
|
||||
@@ -71,7 +82,7 @@ export async function verifyBackup(directory:string){
|
||||
const root=await realpath(directory),manifestFile=join(root,'manifest.json');
|
||||
if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large');
|
||||
const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8')));
|
||||
if(new Set(manifest.files.map(f=>f.name)).size!==2)throw new Error('Duplicate backup database');
|
||||
if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database');
|
||||
for(const file of manifest.files){
|
||||
const path=join(root,file.name),info=await regular(path);
|
||||
if(info.size!==file.bytes||await digest(path)!==file.sha256)throw new Error('Backup database hash/size mismatch');
|
||||
|
||||
+35
-3
@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdir, open, unlink } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK } from '../../shared/testnet.ts';
|
||||
import { NETWORK, CONSENSUS } from '../../shared/testnet.ts';
|
||||
import { testnetOwnerOf, transactionCardIds } from '../../shared/testnet.ts';
|
||||
import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts';
|
||||
import { RandomX } from './randomx.ts';
|
||||
@@ -17,6 +17,8 @@ import { wikipediaArticles } from '../wiki.ts';
|
||||
import { Market, marketConfig } from '../market/store.ts';
|
||||
import { marketCommandSchema } from '../../shared/market.ts';
|
||||
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
|
||||
import { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
@@ -25,6 +27,7 @@ import { trustedProxies, operationsPort, startOperations } from '../operations.t
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
|
||||
const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources.
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
const opsPort=operationsPort();
|
||||
@@ -49,6 +52,17 @@ catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath
|
||||
let market: Market;
|
||||
try { market = await Market.open(dataDir, ledger, paymentConfig); }
|
||||
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let custody: CustodyMarket | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await CustodyMarket.open(dataDir, { feeBps: paymentConfig.feeBps,
|
||||
card: (hash, owner, sequence) => {
|
||||
const s = ledger.snapshot(), b = s.blocks.find(block => block.tokenId === hash);
|
||||
if (!b || testnetOwnerOf(s, hash) !== owner || s.blocks.length + 1 - b.height < CONSENSUS.coinbaseMaturity ||
|
||||
s.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length !== sequence ||
|
||||
s.pendingTransfers.some(tx => transactionCardIds(tx).includes(hash))) return undefined;
|
||||
return { title: b.article.title };
|
||||
} });
|
||||
} catch(e) {market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
|
||||
@@ -83,6 +97,24 @@ app.get('/api/network',(_req,res)=>res.json(launchManifest()));
|
||||
app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
|
||||
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
|
||||
app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0))));
|
||||
app.get('/api/custody', (req,res)=> {
|
||||
if (!custody) { res.json({ enabled: false }); return; }
|
||||
try { res.json(custody.browse(Number(req.query.offset ?? 0))); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody sandbox unavailable; operator review required.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=> {
|
||||
if (!custody) { res.status(503).json({ error: 'Custody sandbox is disabled on this host.' }); return; }
|
||||
const parsed = custodyCommandSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid custody command schema.' }); return; }
|
||||
try { res.json(custody.execute(parsed.data)); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody state conflict; refresh activity before retrying. Operator review may be required.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
|
||||
const current = marketCommandV3Schema.safeParse(req.body);
|
||||
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
|
||||
@@ -198,6 +230,6 @@ let stopping=false;
|
||||
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();clearInterval(marketTimer);
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();custody?.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
Reference in New Issue
Block a user