Custodial markets

This commit is contained in:
2026-10-20 14:04:59 -04:00
parent 4e753b35fe
commit fc65e497a8
13 changed files with 1027 additions and 18 deletions
+340
View File
@@ -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
View File
@@ -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
View File
@@ -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);});