Remove markets
This commit is contained in:
@@ -53,7 +53,7 @@ export function calibrationReport() {
|
||||
.map(name=>[name,simulate(name)]));
|
||||
const a=scenarios.steady.at(-1)!,b=scenarios.rapid.at(-1)!;
|
||||
return { schema:'wikideck-consensus-analysis-v1',chainId:CHAIN_ID,consensusChanged:false,
|
||||
scope:'Deterministic header-math scenarios only; no valid blocks, mining, payments or launch.',
|
||||
scope:'Deterministic header-math scenarios only; no valid blocks, mining or launch.',
|
||||
initialExpectedHashes:expectedHashes(MAX_TARGET),targetSeconds:CONSENSUS.targetSeconds,
|
||||
assumptions:'Spacing is externally imposed, not stochastic mining at constant hashrate. Work comparisons do not imply an attacker can produce that work.',
|
||||
scenarios,
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { chmod, mkdir, lstat, readFile, readdir, writeFile, rm, open, realpath } from 'node:fs/promises';
|
||||
import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Pool } from 'pg';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { z } from 'zod';
|
||||
import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts';
|
||||
import { CHAIN_ID } from './testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './market/postgres-schema.ts';
|
||||
import { postgresConfig } from './market/postgres-config.ts';
|
||||
import { withStoppedNodeLock, createNodeSnapshotUnlocked, verifyBackup } from './recovery.ts';
|
||||
import { auditPostgres } from './market/postgres-custody.ts';
|
||||
|
||||
const pairManifestSchema = z.object({ format: z.literal('wikideck-coordinated-backup'), version: z.literal(1),
|
||||
network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), profile: z.literal(PROFILE),
|
||||
createdAt: z.string().datetime(), custodianId: z.string().uuid(), database: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
||||
postgresServerVersion: z.string().min(1).max(64), postgresServerVersionNum: z.string().regex(/^\d{5,6}$/),
|
||||
pgDumpVersion: z.string().min(1).max(128), pgRestoreVersion: z.string().min(1).max(128),
|
||||
sqliteCutoverSourceDigest: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
||||
nodeManifestSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
postgresDump: z.object({ name: z.literal('custody.dump'), bytes: z.number().int().positive().max(256 * 1024 ** 3),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict() }).strict();
|
||||
export type CoordinatedBackupManifest = z.infer<typeof pairManifestSchema>;
|
||||
const bindingSchema = z.object({ version: z.literal(1), custodianId: z.string().uuid() }).strict();
|
||||
function digest(data: Buffer | string): string { return createHash('sha256').update(data).digest('hex'); }
|
||||
async function fileDigest(path: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
async function flush(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r+'); try { await handle.sync(); } finally { await handle.close(); }
|
||||
}
|
||||
async function flushDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return;
|
||||
const handle = await open(path, 'r'); try { await handle.sync(); } finally { await handle.close(); }
|
||||
}
|
||||
async function regularFile(path: string): Promise<{ size: number }> {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Coordinated backup file is not a regular non-symlink file');
|
||||
return info;
|
||||
}
|
||||
function childEnvironment(config: Awaited<ReturnType<typeof postgresConfig>>, caFile: string | undefined,
|
||||
serviceFile: string, passFile: string): NodeJS.ProcessEnv {
|
||||
if (typeof config.host !== 'string' || !config.host || typeof config.database !== 'string' || !config.database ||
|
||||
typeof config.user !== 'string' || !config.user) throw new Error('Incomplete PostgreSQL backup connection settings');
|
||||
const ssl = config.ssl;
|
||||
const tls = ssl === false ? 'disable' : 'verify-full';
|
||||
const ca = typeof ssl === 'object' && ssl && 'ca' in ssl ? ssl.ca : undefined;
|
||||
if (ca && !caFile) throw new Error('Private PostgreSQL CA must be staged for the backup client');
|
||||
// Whitelist child environment. Inherited PGHOSTADDR/SSL* variables could redirect libpq
|
||||
// to a different endpoint than the node-pg advisory-lock connection we just audited.
|
||||
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '',
|
||||
...(process.platform === 'win32' ? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}),
|
||||
PGHOST: config.host, PGPORT: String(config.port), PGDATABASE: config.database,
|
||||
PGUSER: config.user, PGPASSWORD: String(config.password ?? ''), PGPASSFILE: passFile, PGCONNECT_TIMEOUT: '10',
|
||||
PGSERVICEFILE: serviceFile, PGSSLMODE: tls, ...(caFile ? { PGSSLROOTCERT: caFile } : {}) };
|
||||
delete env.PGSERVICE; delete env.PGSSLCERT; delete env.PGSSLKEY; delete env.PGOPTIONS;
|
||||
return env;
|
||||
}
|
||||
async function dumpDatabase(config: Awaited<ReturnType<typeof postgresConfig>>, output: string, binary: string): Promise<void> {
|
||||
const temp = await import('node:fs/promises').then(m => m.mkdtemp(join(tmpdir(), 'wikideck-pg-backup-')));
|
||||
let caFile: string | undefined;
|
||||
try {
|
||||
const ssl = config.ssl;
|
||||
const ca = typeof ssl === 'object' && ssl && 'ca' in ssl ? ssl.ca : undefined;
|
||||
if (typeof ca === 'string') { caFile = join(temp, 'root-ca.pem'); await writeFile(caFile, ca, { flag: 'wx', mode: 0o600 }); }
|
||||
const serviceFile = join(temp, 'pg_service.conf');
|
||||
await writeFile(serviceFile, '', { flag: 'wx', mode: 0o600 });
|
||||
const passFile = join(temp, 'pgpass');
|
||||
await writeFile(passFile, '', { flag: 'wx', mode: 0o600 });
|
||||
const env = childEnvironment(config, caFile, serviceFile, passFile);
|
||||
await new Promise<void>((resolveDump, reject) => {
|
||||
let diagnostic = '';
|
||||
const child: ChildProcess = spawn(binary, ['--no-password', '--format=custom', '--compress=6',
|
||||
'--file', output, '--dbname', config.database!], { shell: false, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env });
|
||||
child.stderr?.on('data', chunk => { diagnostic = (diagnostic + chunk.toString('utf8')).slice(-1024); });
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true; child.kill();
|
||||
const forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); forceKill.unref();
|
||||
child.once('exit', () => clearTimeout(forceKill));
|
||||
}, 30 * 60_000);
|
||||
timeout.unref();
|
||||
child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL dump utility failed to start')); });
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) reject(new Error('PostgreSQL dump exceeded its time budget'));
|
||||
else code === 0 ? resolveDump() : reject(new Error(`PostgreSQL dump failed (${code ?? 'signal'}): ${diagnostic.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 500)}`));
|
||||
});
|
||||
});
|
||||
} finally { await rm(temp, { recursive: true, force: true }); }
|
||||
}
|
||||
async function toolVersion(binary: string): Promise<string> {
|
||||
return new Promise<string>((resolveVersion, reject) => {
|
||||
let stdout = '';
|
||||
const child: ChildProcess = spawn(binary, ['--version'], { shell: false, windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'], env: { PATH: process.env.PATH ?? '', ...(process.platform === 'win32'
|
||||
? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}) } });
|
||||
child.stdout?.on('data', chunk => {
|
||||
if (stdout.length + chunk.length > 1024) { child.kill(); return; }
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill(), 10_000); timeout.unref();
|
||||
child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL client utility failed to start')); });
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout);
|
||||
const version = stdout.trim();
|
||||
if (code !== 0 || !/^pg_(?:dump|restore) \(PostgreSQL\) \d+(?:\.\d+){1,2}$/.test(version)) {
|
||||
reject(new Error('PostgreSQL client utility returned an invalid version response'));
|
||||
} else resolveVersion(version);
|
||||
});
|
||||
});
|
||||
}
|
||||
function pgMajor(version: string): number {
|
||||
const match = /\(PostgreSQL\) (\d+)/.exec(version);
|
||||
if (!match) throw new Error('Invalid PostgreSQL client version');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** Stop-node + PostgreSQL writer-fenced snapshot. This sandbox tool never restores or resets a database. */
|
||||
export async function createCoordinatedCustodyBackup(dataDirectory: string, destination: string,
|
||||
env: NodeJS.ProcessEnv = process.env): Promise<CoordinatedBackupManifest> {
|
||||
if (IS_MAINNET) throw new Error('Coordinated custody backup utility is not approved for mainnet');
|
||||
const backupUrlFile = env.WIKIDECK_CUSTODY_PG_BACKUP_URL_FILE;
|
||||
if (!backupUrlFile) throw new Error('A separate PostgreSQL backup-role URL file is required');
|
||||
const runtimeConfig = await postgresConfig(env);
|
||||
const backupConfig = await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_URL_FILE: backupUrlFile });
|
||||
const dumpBinary = env.WIKIDECK_PG_DUMP_BINARY ?? 'pg_dump';
|
||||
const restoreBinary = env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore';
|
||||
if (dumpBinary !== 'pg_dump' && !isAbsolute(dumpBinary)) throw new Error('Custom PostgreSQL dump binary must use an absolute path');
|
||||
if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
const [pgDumpVersion, pgRestoreVersion] = await Promise.all([toolVersion(dumpBinary), toolVersion(restoreBinary)]);
|
||||
if (pgMajor(pgRestoreVersion) < pgMajor(pgDumpVersion)) throw new Error('PostgreSQL restore utility is older than the dump utility');
|
||||
if (backupConfig.host !== runtimeConfig.host || backupConfig.port !== runtimeConfig.port ||
|
||||
backupConfig.database !== runtimeConfig.database || backupConfig.user === runtimeConfig.user) {
|
||||
throw new Error('Backup database target must match custody, using a separate backup role');
|
||||
}
|
||||
const source = await realpath(dataDirectory);
|
||||
const target = resolve(destination), rel = relative(source, target);
|
||||
if (!rel || (!rel.startsWith('..') && !isAbsolute(rel))) throw new Error('Backup destination must be outside node data directory');
|
||||
const parentPath = dirname(target), parent = await realpath(parentPath);
|
||||
if (process.platform === 'win32' ? parent.toLowerCase() !== parentPath.toLowerCase() : parent !== parentPath) {
|
||||
throw new Error('Backup destination parent must not be symlinked');
|
||||
}
|
||||
try { await lstat(target); throw new Error('Backup destination already exists'); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
|
||||
return withStoppedNodeLock(source, async lockedSource => {
|
||||
// Reserve the final name exclusively. The pair manifest is written last; partial folders never verify.
|
||||
await mkdir(target, { mode: 0o700 });
|
||||
const bindingPath = join(lockedSource, PROFILE, 'custody-postgres.json');
|
||||
const bindingInfo = await regularFile(bindingPath);
|
||||
if (bindingInfo.size > 4096) throw new Error('PostgreSQL data-directory binding exceeds limit');
|
||||
const binding = bindingSchema.parse(JSON.parse(await readFile(bindingPath, 'utf8')));
|
||||
const pool = new Pool({ ...backupConfig, max: 1, application_name: 'wikideck-coordinated-backup' });
|
||||
let client;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
const actualDb = await client.query('SELECT current_database() AS name');
|
||||
if (actualDb.rows[0].name !== backupConfig.database) throw new Error('Backup connected to an unexpected database');
|
||||
const serverVersion = await client.query(`SELECT current_setting('server_version') AS version,
|
||||
current_setting('server_version_num')::text AS version_num`);
|
||||
await client.query('SELECT pg_advisory_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (!identity.rowCount) throw new Error('Custody PostgreSQL identity missing');
|
||||
const record = JSON.parse(identity.rows[0].value);
|
||||
if (record.id !== binding.custodianId || record.version !== 1 || record.network !== NETWORK ||
|
||||
record.chainId !== CHAIN_ID || record.mode !== 'sandbox') throw new Error('PostgreSQL and node custody identities differ');
|
||||
const imported = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'");
|
||||
const frozenSource = join(lockedSource, PROFILE, 'custody-sandbox.sqlite');
|
||||
try {
|
||||
const sourceInfo = await lstat(frozenSource);
|
||||
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || !imported.rowCount) throw new Error('Frozen SQLite cutover source is not bound to this PostgreSQL database');
|
||||
const sourceDb = new DatabaseSync(frozenSource, { readOnly: true });
|
||||
try {
|
||||
const marker = sourceDb.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined;
|
||||
if (marker?.value !== imported.rows[0].value) throw new Error('Frozen SQLite source digest differs from PostgreSQL import binding');
|
||||
} finally { sourceDb.close(); }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
if (imported.rowCount) throw new Error('Imported PostgreSQL custody is missing its frozen SQLite source; operator review required');
|
||||
}
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
try { await auditPostgres(client); await client.query('COMMIT'); }
|
||||
catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
|
||||
const nodeManifest = await createNodeSnapshotUnlocked(lockedSource, join(target, 'node'), true);
|
||||
const dumpPath = join(target, 'custody.dump');
|
||||
await dumpDatabase(backupConfig, dumpPath, dumpBinary);
|
||||
const dumpInfo = await regularFile(dumpPath);
|
||||
if (dumpInfo.size < 1 || dumpInfo.size > 256 * 1024 ** 3) throw new Error('PostgreSQL dump size outside supported bounds');
|
||||
if (process.platform !== 'win32') await chmod(dumpPath, 0o600);
|
||||
await flush(dumpPath);
|
||||
const manifest = pairManifestSchema.parse({ format: 'wikideck-coordinated-backup', version: 1, network: NETWORK,
|
||||
chainId: CHAIN_ID, profile: PROFILE, createdAt: new Date().toISOString(), custodianId: binding.custodianId,
|
||||
database: backupConfig.database, postgresServerVersion: serverVersion.rows[0].version,
|
||||
postgresServerVersionNum: serverVersion.rows[0].version_num, pgDumpVersion, pgRestoreVersion,
|
||||
sqliteCutoverSourceDigest: imported.rows[0]?.value ?? null,
|
||||
nodeManifestSha256: digest(JSON.stringify(nodeManifest)),
|
||||
postgresDump: { name: 'custody.dump', bytes: dumpInfo.size, sha256: await fileDigest(dumpPath) } });
|
||||
await writeFile(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2), { flag: 'wx', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await chmod(join(target, 'manifest.json'), 0o600);
|
||||
await flush(join(target, 'manifest.json')); await flushDirectory(target); await flushDirectory(parent);
|
||||
return manifest;
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.query('SELECT pg_advisory_unlock($1::bigint)', [CUSTODY_WRITE_LOCK]).catch(() => {});
|
||||
client.release();
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyDumpArchive(path: string, binary: string): Promise<void> {
|
||||
if (binary !== 'pg_restore' && !isAbsolute(binary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
await new Promise<void>((resolveArchive, reject) => {
|
||||
let diagnostic = '', listing = '', listingTooLarge = false;
|
||||
const child: ChildProcess = spawn(binary, ['--list', path], {
|
||||
shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { PATH: process.env.PATH ?? '', SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' },
|
||||
});
|
||||
child.stdout?.on('data', chunk => {
|
||||
if (listing.length + chunk.length > 2 * 1024 * 1024) {
|
||||
listingTooLarge = true; child.kill(); return;
|
||||
}
|
||||
listing += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.on('data', chunk => { diagnostic = (diagnostic + chunk.toString('utf8')).slice(-1024); });
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true; child.kill();
|
||||
const forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); forceKill.unref();
|
||||
child.once('exit', () => clearTimeout(forceKill));
|
||||
}, 60_000);
|
||||
timeout.unref();
|
||||
child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL restore utility failed to start')); });
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) reject(new Error('PostgreSQL archive verification exceeded its time budget'));
|
||||
else if (listingTooLarge) reject(new Error('PostgreSQL dump archive listing exceeds verification budget'));
|
||||
else if (code !== 0) reject(new Error(`PostgreSQL dump archive is invalid (${code ?? 'signal'}): ${diagnostic.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 500)}`));
|
||||
else if (!['schema_versions', 'custody_meta', 'accounts', 'journals', 'entries'].every(name =>
|
||||
new RegExp(`\\bTABLE\\s+wk_custody\\s+${name}(?:\\s|$)`).test(listing))) {
|
||||
reject(new Error('PostgreSQL dump archive is missing required custody accounting tables'));
|
||||
}
|
||||
else resolveArchive();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCoordinatedCustodyBackup(directory: string,
|
||||
restoreBinary = process.env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore'): Promise<CoordinatedBackupManifest> {
|
||||
if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
const root = await realpath(directory);
|
||||
const manifestPath = join(root, 'manifest.json'), info = await regularFile(manifestPath);
|
||||
if (info.size > 16 * 1024) throw new Error('Coordinated backup manifest exceeds limit');
|
||||
const manifest = pairManifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')));
|
||||
const restoreVersion = await toolVersion(restoreBinary);
|
||||
if (pgMajor(restoreVersion) < pgMajor(manifest.pgDumpVersion)) throw new Error('Installed pg_restore is older than archive pg_dump; use a compatible maintenance image');
|
||||
const nodePath = join(root, 'node'), nodeInfo = await lstat(nodePath);
|
||||
if (!nodeInfo.isDirectory() || nodeInfo.isSymbolicLink()) throw new Error('Node backup package must be a regular directory');
|
||||
const nodeManifest = await verifyBackup(nodePath);
|
||||
if (digest(JSON.stringify(nodeManifest)) !== manifest.nodeManifestSha256 || nodeManifest.network !== manifest.network ||
|
||||
nodeManifest.chainId !== manifest.chainId || nodeManifest.profile !== manifest.profile) throw new Error('Node snapshot does not match pair manifest');
|
||||
const expectedNodeEntries = [...nodeManifest.files.map(file => file.name), 'manifest.json'].sort();
|
||||
if ((await readdir(nodePath)).sort().join('|') !== expectedNodeEntries.join('|')) {
|
||||
throw new Error('Unexpected coordinated node backup contents');
|
||||
}
|
||||
const dumpPath = join(root, manifest.postgresDump.name), dumpInfo = await regularFile(dumpPath);
|
||||
if (dumpInfo.size !== manifest.postgresDump.bytes || await fileDigest(dumpPath) !== manifest.postgresDump.sha256) {
|
||||
throw new Error('PostgreSQL dump hash/size mismatch');
|
||||
}
|
||||
await verifyDumpArchive(dumpPath, restoreBinary);
|
||||
const entries = (await readdir(root)).sort();
|
||||
if (entries.join('|') !== ['custody.dump', 'manifest.json', 'node'].sort().join('|')) throw new Error('Unexpected coordinated backup contents');
|
||||
return manifest;
|
||||
}
|
||||
+2
-13
@@ -1,22 +1,12 @@
|
||||
import { IS_MAINNET, IS_PILOT, NETWORK, PROFILE } from '../shared/network.ts';
|
||||
import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './testnet/consensus.ts';
|
||||
|
||||
export function launchManifest() {
|
||||
return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
|
||||
genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : IS_PILOT ? 'private-pilot-mock-only' : 'testnet',
|
||||
paymentMode: 'mock', audited: false };
|
||||
genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet', audited: false };
|
||||
}
|
||||
/** Operator attestation is an accident-prevention gate, NOT proof of a security audit. */
|
||||
export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
|
||||
if (IS_PILOT) {
|
||||
if (env.WIKIDECK_PILOT_MODE !== 'private-mock-only') {
|
||||
throw new Error('Private pilot requires WIKIDECK_PILOT_MODE=private-mock-only');
|
||||
}
|
||||
if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') {
|
||||
throw new Error('Private pilot supports mock payments only; live XMR remains disabled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!IS_MAINNET) return;
|
||||
if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_REVIEW_ACK !== 'independent-review-complete') {
|
||||
throw new Error(`Mainnet candidate is launch-gated. Review the launch checklist; explicit chain approval required: ${CHAIN_ID}`);
|
||||
@@ -31,5 +21,4 @@ export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now =
|
||||
if (peer.protocol !== 'https:' || peer.origin === origin.origin || peer.username || peer.password || peer.pathname !== '/' || peer.search || peer.hash) throw new Error('Invalid mainnet peer origin');
|
||||
}
|
||||
if (new Set(peers.map(p => new URL(p).origin)).size < 2 || env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1') throw new Error('Mainnet requires distinct public peer origins; private peer bypass is disabled');
|
||||
if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') throw new Error('Live XMR payments remain disabled pending a separate settlement review');
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { GENESIS_HASH, sha256 } from '../testnet/consensus.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const MAX_REHEARSAL_EVENTS = 4096;
|
||||
const eventSchema = z.object({ sequence: z.number().int().positive().safe(), type: z.enum(['connect', 'disconnect']),
|
||||
height: z.number().int().positive().safe(), hash: hex, previousHash: hex,
|
||||
transactionIds: z.array(hex).max(64), eventHash: hex }).strict();
|
||||
const pageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: hex, anchor: hex,
|
||||
events: z.array(eventSchema).max(64), cursor: z.number().int().nonnegative().safe(),
|
||||
head: z.number().int().nonnegative().safe(), caughtUp: z.boolean() }).strict();
|
||||
export type CanonicalInboxPage = z.infer<typeof pageSchema>;
|
||||
export type CanonicalInboxResult = { streamId: string; cursor: number; head: number; height: number; tip: string;
|
||||
replayed: boolean; appendedEvents: z.infer<typeof eventSchema>[]; reviewRequired?: boolean };
|
||||
|
||||
export class CanonicalInboxError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string) { super(`Canonical event inbox rejected input: ${code}`); this.code = code; }
|
||||
}
|
||||
const streamKey = 'canonical-inbox:v1:stream';
|
||||
const eventKey = (sequence: number) => `canonical-inbox:v1:event:${sequence.toString().padStart(16, '0')}`;
|
||||
function storedEvent(text: string): z.infer<typeof eventSchema> {
|
||||
let decoded: unknown;
|
||||
try { decoded = JSON.parse(text); } catch { throw new CanonicalInboxError('stored-event-json-invalid'); }
|
||||
const parsed = eventSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new CanonicalInboxError('stored-event-invalid');
|
||||
return parsed.data;
|
||||
}
|
||||
async function anchorAt(client: PoolClient, sequence: number): Promise<string | undefined> {
|
||||
if (sequence === 0) return GENESIS_HASH;
|
||||
const row = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(sequence)]);
|
||||
return row.rows[0] ? storedEvent(row.rows[0].value).eventHash : undefined;
|
||||
}
|
||||
function eventDigest(streamId: string, e: z.infer<typeof eventSchema>, previousAnchor: string): string {
|
||||
return sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, e.sequence, e.type,
|
||||
e.height, e.hash, e.previousHash, e.transactionIds, previousAnchor]));
|
||||
}
|
||||
function applyTip(stack: string[], e: z.infer<typeof eventSchema>): void {
|
||||
if (e.type === 'connect') {
|
||||
if (e.height !== stack.length + 1 || e.previousHash !== (stack.at(-1) ?? GENESIS_HASH)) throw new CanonicalInboxError('connect-parent-mismatch');
|
||||
stack.push(e.hash); return;
|
||||
}
|
||||
if (e.height !== stack.length || e.hash !== stack.at(-1) || e.height < 1 ||
|
||||
e.previousHash !== (stack.at(-2) ?? GENESIS_HASH)) throw new CanonicalInboxError('disconnect-ancestry-mismatch');
|
||||
stack.pop();
|
||||
}
|
||||
async function readCanonicalHistory(client: PoolClient) {
|
||||
const rows = await client.query(`SELECT key,value FROM wk_custody.custody_meta
|
||||
WHERE key=$1 OR key LIKE 'canonical-inbox:v1:event:%' ORDER BY key LIMIT $2`, [streamKey, MAX_REHEARSAL_EVENTS + 2]);
|
||||
if (rows.rows.length > MAX_REHEARSAL_EVENTS + 1) throw new CanonicalInboxError('rehearsal-event-budget-exceeded');
|
||||
const stream = rows.rows.find(r => r.key === streamKey)?.value as string | undefined;
|
||||
if (rows.rows.length && (!stream || !z.string().uuid().safeParse(stream).success)) throw new CanonicalInboxError('stream-record-invalid');
|
||||
const stack: string[] = [];
|
||||
let cursor = 0, anchor = GENESIS_HASH;
|
||||
for (const row of rows.rows) {
|
||||
if (row.key === streamKey) continue;
|
||||
const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.key);
|
||||
const e = storedEvent(row.value);
|
||||
if (!match || Number(match[1]) !== cursor + 1 || e.sequence !== cursor + 1) throw new CanonicalInboxError('history-gap-or-invalid-record');
|
||||
if (e.eventHash !== eventDigest(stream!, e, anchor)) throw new CanonicalInboxError('event-anchor-invalid');
|
||||
applyTip(stack, e); cursor++; anchor = e.eventHash;
|
||||
}
|
||||
return { streamId: stream, cursor, anchor, stack };
|
||||
}
|
||||
function assertPageShape(page: CanonicalInboxPage): void {
|
||||
const finalCursor = page.events.at(-1)?.sequence ?? page.cursor;
|
||||
if ((page.events.length && page.events[0].sequence < 1) || page.cursor !== finalCursor ||
|
||||
page.head < page.cursor || page.caughtUp !== (page.cursor === page.head) ||
|
||||
(!page.events.length && !page.caughtUp) ||
|
||||
page.anchor !== (page.events.at(-1)?.eventHash ?? page.previousAnchor) || page.events.some((e, i) =>
|
||||
i > 0 && e.sequence !== page.events[i - 1].sequence + 1)) throw new CanonicalInboxError('page-shape-invalid');
|
||||
}
|
||||
|
||||
/** Persist only events returned by the local ledger's validated canonical-event API. This verifies
|
||||
* continuity/integrity, not PoW or provenance; callers must not pass peer-supplied, unvalidated data.
|
||||
*/
|
||||
export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Promise<CanonicalInboxResult> {
|
||||
const parsed = pageSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new CanonicalInboxError('page-schema-invalid');
|
||||
const page = parsed.data;
|
||||
assertPageShape(page);
|
||||
if (page.head > MAX_REHEARSAL_EVENTS || page.cursor > MAX_REHEARSAL_EVENTS) throw new CanonicalInboxError('rehearsal-event-budget-exceeded');
|
||||
const history = await readCanonicalHistory(client);
|
||||
const storedStream = history.streamId;
|
||||
if (storedStream && storedStream !== page.streamId) throw new CanonicalInboxError('stream-changed');
|
||||
let { cursor, anchor } = history;
|
||||
const stack = history.stack;
|
||||
if (!storedStream) {
|
||||
if (cursor !== 0 || page.previousAnchor !== GENESIS_HASH ||
|
||||
(page.events.length ? page.events[0].sequence !== 1 : page.cursor !== 0)) {
|
||||
throw new CanonicalInboxError('initial-history-required');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [streamKey, page.streamId]);
|
||||
}
|
||||
if (!page.events.length) {
|
||||
const savedAnchor = await anchorAt(client, page.cursor);
|
||||
if (page.cursor > cursor || savedAnchor !== page.anchor || page.previousAnchor !== page.anchor) throw new CanonicalInboxError('empty-cursor-mismatch');
|
||||
return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length,
|
||||
tip: stack.at(-1) ?? GENESIS_HASH, replayed: true, appendedEvents: [] };
|
||||
}
|
||||
const firstSequence = page.events[0].sequence;
|
||||
if (firstSequence > cursor + 1) throw new CanonicalInboxError('cursor-gap');
|
||||
const predecessor = await anchorAt(client, firstSequence - 1);
|
||||
if (!predecessor || page.previousAnchor !== predecessor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
let priorAnchor = predecessor;
|
||||
let appended = false;
|
||||
const appendedEvents: z.infer<typeof eventSchema>[] = [];
|
||||
for (const e of page.events) {
|
||||
if (e.eventHash !== eventDigest(page.streamId, e, priorAnchor)) throw new CanonicalInboxError('event-hash-invalid');
|
||||
if (e.sequence <= cursor) {
|
||||
const saved = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(e.sequence)]);
|
||||
if (!saved.rowCount || JSON.stringify(storedEvent(saved.rows[0].value)) !== JSON.stringify(e)) throw new CanonicalInboxError('replayed-event-mismatch');
|
||||
} else {
|
||||
if (e.sequence !== cursor + 1 || priorAnchor !== anchor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
applyTip(stack, e);
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [eventKey(e.sequence), JSON.stringify(e)]);
|
||||
cursor = e.sequence; anchor = e.eventHash; appended = true; appendedEvents.push(e);
|
||||
}
|
||||
priorAnchor = e.eventHash;
|
||||
}
|
||||
if (page.cursor !== page.events.at(-1)!.sequence || page.anchor !== priorAnchor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length,
|
||||
tip: stack.at(-1) ?? GENESIS_HASH, replayed: !appended, appendedEvents };
|
||||
}
|
||||
|
||||
/** Full append-only inbox verification. Expected to run under the caller's coherent audit snapshot. */
|
||||
export async function auditCanonicalInbox(client: PoolClient): Promise<void> {
|
||||
await readCanonicalHistory(client);
|
||||
const reviews = await client.query(`SELECT key,value FROM wk_custody.custody_meta
|
||||
WHERE key LIKE 'sale-reorg-review:%' ORDER BY key`);
|
||||
for (const row of reviews.rows) {
|
||||
let record: unknown;
|
||||
try { record = JSON.parse(row.value); } catch { throw new CanonicalInboxError('sale-review-record-invalid'); }
|
||||
const schema = z.object({ version: z.literal(1), streamId: z.string().uuid(), orderId: z.string().uuid(),
|
||||
terminalTransactionId: hex, stateRoot: hex, eventSequence: z.number().int().positive().safe(), eventHash: hex }).strict();
|
||||
const parsed = schema.safeParse(record), keyMatch = /^sale-reorg-review:([a-f0-9-]{36}):(\d+)$/.exec(row.key);
|
||||
if (!parsed.success || !keyMatch || keyMatch[1] !== parsed.data.orderId || Number(keyMatch[2]) !== parsed.data.eventSequence) {
|
||||
throw new CanonicalInboxError('sale-review-record-invalid');
|
||||
}
|
||||
const event = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1',
|
||||
[eventKey(parsed.data.eventSequence)]);
|
||||
if (!event.rowCount) throw new CanonicalInboxError('sale-review-event-missing');
|
||||
const disconnected = storedEvent(event.rows[0].value);
|
||||
if (disconnected.type !== 'disconnect' || disconnected.eventHash !== parsed.data.eventHash ||
|
||||
!disconnected.transactionIds.includes(parsed.data.terminalTransactionId)) {
|
||||
throw new CanonicalInboxError('sale-review-event-mismatch');
|
||||
}
|
||||
}
|
||||
if (reviews.rowCount) throw new CanonicalInboxError('posted-sale-terminal-disconnected');
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Ledger } from '../testnet/ledger.ts';
|
||||
import type { PostgresCustody } from './postgres-custody.ts';
|
||||
|
||||
export type CanonicalObserverState = 'stopped' | 'running' | 'failed' | 'review-required';
|
||||
export type CanonicalObserverStatus = { state: CanonicalObserverState; cursor: number; head: number; height: number;
|
||||
tip: string; caughtUp: boolean; failureCode: string | null };
|
||||
|
||||
/** Bounded polling loop for the explicitly disposable PostgreSQL rehearsal only.
|
||||
* It reads local Ledger events; it does not verify candidate consensus or trigger custody payouts.
|
||||
*/
|
||||
export class CanonicalObserver {
|
||||
private readonly custody: PostgresCustody;
|
||||
private readonly ledger: Ledger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly pagesPerCycle: number;
|
||||
private active = false;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private cycle: Promise<void> | undefined;
|
||||
private current: CanonicalObserverStatus = { state: 'stopped', cursor: 0, head: 0, height: 0,
|
||||
tip: '', caughtUp: false, failureCode: null };
|
||||
|
||||
constructor(custody: PostgresCustody, ledger: Ledger, options: { intervalMs?: number; pagesPerCycle?: number } = {}) {
|
||||
if (!custody.rehearsalObserverEnabled) throw new Error('Canonical observer requires disposable rehearsal custody');
|
||||
if (process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL !== '1' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1' ||
|
||||
process.env.NODE_ENV === 'production') throw new Error('Canonical observer requires explicit non-production disposable runtime opt-in');
|
||||
if (!(ledger instanceof Ledger)) throw new Error('Canonical observer requires a local Ledger instance');
|
||||
this.custody = custody; this.ledger = ledger;
|
||||
this.intervalMs = options.intervalMs ?? 1000; this.pagesPerCycle = options.pagesPerCycle ?? 4;
|
||||
if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 25 || this.intervalMs > 60000 ||
|
||||
!Number.isSafeInteger(this.pagesPerCycle) || this.pagesPerCycle < 1 || this.pagesPerCycle > 16) {
|
||||
throw new Error('Invalid canonical observer bounds');
|
||||
}
|
||||
}
|
||||
|
||||
status(): CanonicalObserverStatus { return { ...this.current }; }
|
||||
|
||||
async start(): Promise<CanonicalObserverStatus> {
|
||||
if (this.active) { await this.cycle; return this.status(); }
|
||||
if (this.current.state === 'failed' || this.current.state === 'review-required') return this.status();
|
||||
this.active = true; this.current = { ...this.current, state: 'running', failureCode: null };
|
||||
await this.runCycle();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Deterministic wake-up hook; the scheduled poll and manual wake are serialized. */
|
||||
async pollNow(): Promise<CanonicalObserverStatus> {
|
||||
if (!this.active) throw new Error('Canonical observer is not running');
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = undefined; }
|
||||
await this.runCycle();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.active = false;
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = undefined; }
|
||||
await this.cycle;
|
||||
if (this.current.state === 'running') this.current = { ...this.current, state: 'stopped' };
|
||||
}
|
||||
|
||||
private runCycle(): Promise<void> {
|
||||
if (this.cycle) return this.cycle;
|
||||
const work = (async () => {
|
||||
for (let page = 0; page < this.pagesPerCycle && this.active; page++) {
|
||||
try {
|
||||
const result = await this.custody.observeLedgerCanonicalEvents(this.ledger);
|
||||
this.current = { state: result.reviewRequired ? 'review-required' : 'running', cursor: result.cursor,
|
||||
head: result.head, height: result.height, tip: result.tip, caughtUp: result.cursor >= result.head,
|
||||
failureCode: result.reviewRequired ? 'posted-sale-terminal-disconnected' : null };
|
||||
if (result.reviewRequired) { this.active = false; break; }
|
||||
if (result.cursor >= result.head) break;
|
||||
} catch {
|
||||
this.current = { ...this.current, state: 'failed', failureCode: 'canonical-observer-failed' };
|
||||
this.active = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
this.cycle = work.finally(() => {
|
||||
this.cycle = undefined;
|
||||
if (this.active && !this.timer) {
|
||||
this.timer = setTimeout(() => { this.timer = undefined; void this.runCycle(); }, this.intervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
});
|
||||
return this.cycle;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
migrate: 0,
|
||||
'migrate-mainnet-schema': 0,
|
||||
'initialize-mainnet-custody-identity': 1,
|
||||
'migrate-pilot-schema': 0,
|
||||
'initialize-pilot-custody-identity': 1,
|
||||
'grant-pilot-deposit-reservation-runtime': 1,
|
||||
'grant-pilot-deposit-address-runtime': 1,
|
||||
'grant-pilot-monero-observer-runtime': 1,
|
||||
'grant-pilot-monero-review-runtime': 1,
|
||||
'pilot-deposit-prepare-review': 2,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
'grant-monero-withdrawal-runtime': 1,
|
||||
'grant-monero-credit-runtime': 1,
|
||||
'import-sqlite': 1,
|
||||
audit: 0,
|
||||
'resolve-integrity': 1,
|
||||
'deposit-prepare-credit': 2,
|
||||
'deposit-stage-credit': 1,
|
||||
'deposit-prepare-release': 1,
|
||||
'deposit-release': 1,
|
||||
'deposit-reconcile-holds': 1,
|
||||
'deposit-prepare-resolve-hold': 2,
|
||||
'deposit-resolve-hold': 1,
|
||||
} as const;
|
||||
|
||||
export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS;
|
||||
export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string };
|
||||
|
||||
export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, isMainnet: boolean): void {
|
||||
if ((action === 'migrate-mainnet-schema' || action === 'initialize-mainnet-custody-identity') && !isMainnet) {
|
||||
throw new Error('Mainnet custody operation requires the mainnet profile');
|
||||
}
|
||||
const mainnetAllowed = ['grant-monero-observer-runtime', 'grant-monero-deposit-allocator-runtime',
|
||||
'grant-monero-withdrawal-runtime',
|
||||
'grant-monero-credit-runtime', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity',
|
||||
'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
|
||||
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
|
||||
if (isMainnet && !mainnetAllowed.includes(action)) {
|
||||
throw new Error('Only scoped Monero infrastructure grants, identity initialization, and reviewed credit/withdrawal-intent operations are allowed on mainnet');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMainnetSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'migrate-mainnet-schema' && acknowledgement !== 'explicit-schema-migration-only') {
|
||||
throw new Error('Mainnet schema migration requires explicit schema-migration acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
const requiresReviewAck = action === 'initialize-mainnet-custody-identity' || action === 'grant-monero-credit-runtime' ||
|
||||
action.startsWith('deposit-');
|
||||
if (requiresReviewAck && acknowledgement !== 'reviewed-credit-only') {
|
||||
throw new Error('Mainnet deposit operations require WIKIDECK_MAINNET_CUSTODY_OPERATIONS=reviewed-credit-only');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
|
||||
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity',
|
||||
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime',
|
||||
'grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review'];
|
||||
if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) {
|
||||
throw new Error(isPilot
|
||||
? 'Pilot profile permits only pilot-specific schema, identity, scoped-role, and review-preparation operations through this CLI'
|
||||
: 'Pilot custody operation requires the pilot profile');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotReservationGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-deposit-reservation-runtime' && acknowledgement !== 'reservation-only-no-addresses') {
|
||||
throw new Error('Pilot reservation role grant requires explicit reservation-only/no-addresses acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotAddressGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-deposit-address-runtime' && acknowledgement !== 'operator-triggered-addresses-only') {
|
||||
throw new Error('Pilot address role grant requires explicit operator-triggered/address-only acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotObserverGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-monero-observer-runtime' && acknowledgement !== 'pilot-observation-only-no-credit') {
|
||||
throw new Error('Pilot observer role grant requires explicit observation-only/no-credit acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotDepositReviewAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-monero-review-runtime' && acknowledgement !== 'read-only-pilot-review-role-only') {
|
||||
throw new Error('Pilot review role grant requires the exact read-only-role acknowledgement');
|
||||
}
|
||||
if (action === 'pilot-deposit-prepare-review' && acknowledgement !== 'pilot-deposit-review-preparation-only') {
|
||||
throw new Error('Pilot deposit review preparation requires the exact preparation-only acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'migrate-pilot-schema' && acknowledgement !== 'explicit-pilot-schema-migration-only') {
|
||||
throw new Error('Pilot schema migration requires explicit pilot schema-migration acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotCustodyIdentityAcknowledged(action: CustodyDbAction, identityAcknowledgement: string | undefined,
|
||||
operationAcknowledgement: string | undefined): void {
|
||||
if (action === 'initialize-pilot-custody-identity' &&
|
||||
(identityAcknowledgement !== 'initialize-pilot-custody-v1' || operationAcknowledgement !== 'identity-only-no-funds')) {
|
||||
throw new Error('Pilot identity initialization requires exact identity-only/no-funds acknowledgements');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
|
||||
const [action, ...values] = args;
|
||||
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
|
||||
throw new Error(`Expected one of: ${Object.keys(CUSTODY_DB_ACTION_ARGUMENT_COUNTS).join(', ')}`);
|
||||
}
|
||||
const count = CUSTODY_DB_ACTION_ARGUMENT_COUNTS[action as CustodyDbAction];
|
||||
if (values.length !== count || values.some(value => value.length === 0)) {
|
||||
throw new Error(`Invalid argument count for custody operation: ${action}`);
|
||||
}
|
||||
return { action: action as CustodyDbAction, argument: values[0], extraArgument: values[1] };
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat } from 'node:fs/promises';
|
||||
import type { Pool } from 'pg';
|
||||
import { CUSTODY_POLICY } from '../../shared/custody.ts';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { auditPostgres } from './postgres-custody.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
/** Explicit stopped-node SANDBOX import. Source freezes BEFORE target commit; failure requires resume, not dual writes. */
|
||||
export async function importSqliteCustody(file: string, pool: Pool): Promise<{ imported: true; sourceDigest: string; custodianId: string }> {
|
||||
const stat = await lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024 * 1024) throw new Error('Invalid sandbox source database');
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec('PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000');
|
||||
const names = ['accounts', 'journals', 'entries', 'listings', 'orders', 'withdrawals', 'receipts', 'outbox', 'credits'] as const;
|
||||
type Row = Record<string, string | number>;
|
||||
let snapshot: Record<typeof names[number], Row[]>;
|
||||
let identityText: string;
|
||||
let sourceDigest: string;
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (Object.values(db.prepare('PRAGMA quick_check').get()!)[0] !== 'ok') throw new Error('Invalid SQLite integrity');
|
||||
identityText = (db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as { value: string }).value;
|
||||
const identity = JSON.parse(identityText);
|
||||
if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY) {
|
||||
throw new Error('Source custody identity mismatch');
|
||||
}
|
||||
snapshot = Object.fromEntries(names.map(name => [name, db.prepare(`SELECT rowid AS import_sequence,* FROM ${name} ORDER BY rowid`).all()])) as typeof snapshot;
|
||||
sourceDigest = createHash('sha256').update(JSON.stringify([identityText, snapshot])).digest('hex');
|
||||
const existing = db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined;
|
||||
if (existing && existing.value !== sourceDigest) throw new Error('Frozen SQLite source changed; operator review required');
|
||||
db.prepare('INSERT OR IGNORE INTO custody_meta VALUES(?,?)').run('postgres_cutover', sourceDigest);
|
||||
db.exec('COMMIT'); // Source is now permanently ineligible for SQLite startup. Preserve for audit, NEVER delete/reset.
|
||||
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
||||
} finally { db.close(); }
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN');
|
||||
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(c);
|
||||
const old = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'");
|
||||
if (old.rowCount) {
|
||||
if (old.rows[0].value !== sourceDigest) throw new Error('Target already imported a different source');
|
||||
await auditPostgres(c); await c.query('COMMIT');
|
||||
return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id };
|
||||
}
|
||||
for (const table of ['custody_meta', ...names]) {
|
||||
if ((await c.query(`SELECT 1 FROM wk_custody.${table} LIMIT 1`)).rowCount) throw new Error('Import requires a migrated EMPTY PostgreSQL custody schema');
|
||||
}
|
||||
for (const a of snapshot.accounts) {
|
||||
if (!/^(0|[1-9]\d{0,29})$/.test(String(a.balance))) throw new Error('Invalid source atomic balance');
|
||||
await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2)', [a.id, a.normal]);
|
||||
}
|
||||
// Rebuild balances from journal entries, never trust mutable projections as opening balances.
|
||||
const byJournal = new Map<string, Row[]>();
|
||||
for (const e of snapshot.entries) {
|
||||
const rows = byJournal.get(String(e.journal)) ?? []; rows.push(e); byJournal.set(String(e.journal), rows);
|
||||
}
|
||||
for (const j of snapshot.journals) {
|
||||
await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [j.id, j.kind, j.created]);
|
||||
const lines = (byJournal.get(String(j.id)) ?? []).sort((a, b) => Number(a.position) - Number(b.position));
|
||||
for (const e of lines) {
|
||||
if (!/^(0|[1-9]\d{0,29})$/.test(String(e.debit)) || !/^(0|[1-9]\d{0,29})$/.test(String(e.credit))) throw new Error('Invalid source entry encoding');
|
||||
await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', [e.journal, e.position, e.account, e.debit, e.credit]);
|
||||
}
|
||||
}
|
||||
const journalIds = new Set(snapshot.journals.map(j => j.id));
|
||||
if (snapshot.entries.some(e => !journalIds.has(e.journal))) throw new Error('Orphan source entry');
|
||||
for (const a of snapshot.accounts) {
|
||||
const rebuilt = await c.query('SELECT balance::text AS balance FROM wk_custody.accounts WHERE id=$1', [a.id]);
|
||||
if (rebuilt.rows[0].balance !== a.balance) throw new Error('Source balance projection mismatch');
|
||||
}
|
||||
for (const l of snapshot.listings) await c.query('INSERT INTO wk_custody.listings(sequence,id,card,status,data) VALUES($1,$2,$3,$4,$5)', [l.import_sequence, l.id, l.card, l.status, l.data]);
|
||||
for (const o of snapshot.orders) await c.query('INSERT INTO wk_custody.orders(sequence,id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5,$6)', [o.import_sequence, o.id, o.buyer, o.seller, o.status, o.data]);
|
||||
for (const w of snapshot.withdrawals) await c.query('INSERT INTO wk_custody.withdrawals(sequence,id,actor,status,data) VALUES($1,$2,$3,$4,$5)', [w.import_sequence, w.id, w.actor, w.status, w.data]);
|
||||
for (const r of snapshot.receipts) await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [r.id, r.digest, r.data]);
|
||||
for (const o of snapshot.outbox) await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, o.kind, o.status, o.data]);
|
||||
for (const a of snapshot.credits) await c.query('INSERT INTO wk_custody.credits VALUES($1)', [a.actor]);
|
||||
for (const table of ['listings', 'orders', 'withdrawals']) await c.query(`SELECT setval(pg_get_serial_sequence('wk_custody.${table}','sequence'),COALESCE((SELECT max(sequence) FROM wk_custody.${table}),0)+1,false)`);
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2),($3,$4)', ['identity', identityText, 'import_source', sourceDigest]);
|
||||
await auditPostgres(c);
|
||||
await c.query('COMMIT');
|
||||
return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id };
|
||||
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
import { auditPostgres } from './postgres-custody.ts';
|
||||
|
||||
/** Operator-only acknowledgement AFTER externally reviewed repair/restore; never repairs or resets a journal. */
|
||||
export async function resolveCustodyIncident(pool: Pool, evidenceId: string): Promise<{ resolutionId: string }> {
|
||||
z.string().uuid().parse(evidenceId);
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN');
|
||||
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const allowed = await c.query("SELECT pg_has_role(current_user,nspowner,'MEMBER') AS owns FROM pg_namespace WHERE nspname='wk_custody'");
|
||||
if (allowed.rows[0]?.owns !== true) throw new Error('Schema-owner approval required for integrity resolution');
|
||||
const incident = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='integrity_incident_v1' FOR UPDATE");
|
||||
if (!incident.rowCount) throw new Error('No unresolved custody integrity incident');
|
||||
// Delete is UNCOMMITTED and exclusively locked. Any audit failure rolls it back, leaving the hold intact.
|
||||
await c.query("DELETE FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
await auditPostgres(c);
|
||||
const resolutionId = randomUUID();
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`integrity_resolution:${resolutionId}`,
|
||||
JSON.stringify({ version: 1, evidenceId, resolvedAt: Date.now(),
|
||||
incidentDigest: createHash('sha256').update(incident.rows[0].value).digest('hex') })]);
|
||||
await c.query('COMMIT'); return { resolutionId };
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { PoolClient, QueryResult } from 'pg';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
||||
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
||||
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
||||
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage } from '../../shared/monero-withdrawal.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string) { super(`Custody integrity check failed: ${code}`); this.code = code; }
|
||||
}
|
||||
function fail(code: string): never { throw new CustodyIntegrityError(code); }
|
||||
const uuid = z.string().uuid(), hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const integer = z.number().int().nonnegative().safe();
|
||||
const atomic = z.string().max(22).refine(s => { try { parseAtomicXmr(s); return true; } catch { return false; } });
|
||||
const positive = atomic.refine(s => { try { return parseAtomicXmr(s) > 0n; } catch { return false; } });
|
||||
const wallet = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/);
|
||||
const listingSchema = z.object({ id: uuid, seller: hex, cardHash: hex, sequence: integer,
|
||||
title: z.string().min(1).max(200), priceAtomicXmr: positive, feeBps: z.number().int().min(0).max(5000),
|
||||
hostFeeAddress: wallet.optional(), status: z.enum(['open', 'reserved', 'cancelled', 'sold']), createdAt: integer }).strict();
|
||||
const orderSchema = z.object({ id: uuid, listingId: uuid, seller: hex, buyer: hex, cardHash: hex, sequence: integer,
|
||||
grossAtomicXmr: positive, sellerAtomicXmr: atomic, authorAtomicXmr: atomic, hostAtomicXmr: atomic,
|
||||
authorAddress: wallet, hostFeeAddress: wallet.optional(), status: z.enum(['awaiting-lock', 'cancelled', 'settled']), createdAt: integer }).strict();
|
||||
const withdrawalSchema = z.object({ id: uuid, actor: hex, destination: z.literal('mock-external'), amountAtomicXmr: positive,
|
||||
maxFeeAtomicXmr: atomic, status: z.enum(['reserved', 'prepared', 'complete', 'cancelled']),
|
||||
attemptId: uuid.optional(), transactionId: z.string().regex(/^fixture:[a-f0-9]{64}$/).optional(),
|
||||
feeAtomicXmr: atomic.optional(), createdAt: integer }).strict();
|
||||
type Entry = { account: string; debit: string; credit: string };
|
||||
type Journal = { id: string; kind: string; lines: Entry[] };
|
||||
function parse<T>(schema: z.ZodType<T>, data: unknown, code: string): T {
|
||||
const result = schema.safeParse(data); return result.success ? result.data : fail(code);
|
||||
}
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
// Compare parsed records without depending on JSONB object key ordering.
|
||||
if (a === b) return true;
|
||||
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
||||
const x = a as Record<string, unknown>, y = b as Record<string, unknown>;
|
||||
const keys = Object.keys(x).filter(k => x[k] !== undefined).sort();
|
||||
return keys.join('\0') === Object.keys(y).filter(k => y[k] !== undefined).sort().join('\0') && keys.every(k => same(x[k], y[k]));
|
||||
}
|
||||
function json(text: string | null): unknown { try { return JSON.parse(text ?? ''); } catch { return fail('outbox-encoding'); } }
|
||||
const d = (account: string, amount: string): Entry => ({ account, debit: amount, credit: '0' });
|
||||
const c = (account: string, amount: string): Entry => ({ account, debit: '0', credit: amount });
|
||||
async function journals(client: PoolClient, ids: string[]): Promise<Map<string, Journal>> {
|
||||
const result = await client.query(`SELECT j.id,j.kind,jsonb_agg(jsonb_build_object('account',e.account,
|
||||
'debit',e.debit::text,'credit',e.credit::text) ORDER BY e.position) FILTER(WHERE e.journal IS NOT NULL) AS lines
|
||||
FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
|
||||
WHERE j.id=ANY($1::text[]) GROUP BY j.id`, [ids]);
|
||||
return new Map(result.rows.map(r => [r.id, r as Journal]));
|
||||
}
|
||||
function expectJournal(all: Map<string, Journal>, id: string, kind: string, expected: Entry[] | null) {
|
||||
const got = all.get(id);
|
||||
if (expected === null) { if (got) fail('unexpected-terminal-posting'); return; }
|
||||
if (!got || got.kind !== kind || !Array.isArray(got.lines)) fail('missing-lifecycle-posting');
|
||||
const normalized = (lines: Entry[]) => lines.filter(l => BigInt(l.debit) !== 0n || BigInt(l.credit) !== 0n)
|
||||
.map(l => JSON.stringify([l.account, l.debit, l.credit])).sort();
|
||||
if (!same(normalized(got.lines), normalized(expected))) fail('lifecycle-posting-mismatch');
|
||||
}
|
||||
async function validateWallets(values: (string | undefined)[]) {
|
||||
// Bounded per-batch cache, not a growing process-wide map of customer data.
|
||||
for (const address of new Set(values.filter((v): v is string => !!v))) {
|
||||
try { await validateXmrAddress(address); } catch { fail('fee-recipient-invalid'); }
|
||||
}
|
||||
}
|
||||
|
||||
type OrderRow = { id: string; buyer: string; seller: string; status: string; data: unknown;
|
||||
listing_data: unknown; listing_status: string; listing_card: string; balance: string | null;
|
||||
normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null };
|
||||
async function checkOrders(client: PoolClient, rows: OrderRow[]) {
|
||||
const posting = await journals(client, rows.flatMap(r => [`reserve:${r.id}`, `cancel:${r.id}`, `settle:${r.id}`]));
|
||||
const wallets: string[] = [];
|
||||
for (const row of rows) {
|
||||
const o = parse(orderSchema, row.data, 'order-record-invalid'), l = parse(listingSchema, row.listing_data, 'order-listing-invalid');
|
||||
if (o.id !== row.id || o.buyer !== row.buyer || o.seller !== row.seller || o.status !== row.status ||
|
||||
l.status !== row.listing_status || l.cardHash !== row.listing_card || o.listingId !== l.id || o.seller !== l.seller ||
|
||||
o.cardHash !== l.cardHash || o.sequence !== l.sequence || o.grossAtomicXmr !== l.priceAtomicXmr || o.seller === o.buyer ||
|
||||
(o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== (l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address)) fail('order-terms-mismatch');
|
||||
const split = allocateXmrSale(BigInt(l.priceAtomicXmr), l.feeBps);
|
||||
if (o.sellerAtomicXmr !== split.sellerBeforeNetworkCosts.toString() || o.hostAtomicXmr !== split.hosting.toString() ||
|
||||
o.authorAtomicXmr !== split.author.toString() || o.authorAddress !== ADDITIONAL_MARKET_FEE.address) fail('order-allocation-mismatch');
|
||||
wallets.push(o.authorAddress, o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address);
|
||||
const account = `customer:${o.buyer}:purchase:${o.id}`, available = `customer:${o.buyer}:available`;
|
||||
if (row.normal !== 'credit' || row.balance !== (o.status === 'awaiting-lock' ? o.grossAtomicXmr : '0')) fail('order-reservation-mismatch');
|
||||
const expectedStatus = o.status === 'awaiting-lock' ? 'reserved' : o.status === 'settled' ? 'sold' : 'cancelled';
|
||||
if (l.status !== expectedStatus || row.box_kind !== 'nft-lock') fail('order-state-mismatch');
|
||||
if (o.status === 'settled') {
|
||||
if (row.box_status !== 'fixture-complete' || !/^fixture:[a-f0-9]{64}$/.test(row.box_data ?? '')) fail('settlement-evidence-mismatch');
|
||||
} else {
|
||||
const original = parse(orderSchema, json(row.box_data), 'order-outbox-invalid');
|
||||
if (row.box_status !== (o.status === 'cancelled' ? 'cancelled' : 'blocked') ||
|
||||
!same(original, { ...o, status: 'awaiting-lock' })) fail('order-outbox-mismatch');
|
||||
}
|
||||
expectJournal(posting, `reserve:${o.id}`, 'purchase-reservation', [d(available, o.grossAtomicXmr), c(account, o.grossAtomicXmr)]);
|
||||
expectJournal(posting, `cancel:${o.id}`, 'purchase-cancellation', o.status === 'cancelled' ? [d(account, o.grossAtomicXmr), c(available, o.grossAtomicXmr)] : null);
|
||||
expectJournal(posting, `settle:${o.id}`, 'mock-sale-settlement', o.status === 'settled' ? [d(account, o.grossAtomicXmr),
|
||||
c(`customer:${o.seller}:available`, o.sellerAtomicXmr), c(`author:${o.authorAddress}`, o.authorAtomicXmr),
|
||||
c(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', o.hostAtomicXmr)] : null);
|
||||
const evidenceRows = await client.query('SELECT key,value FROM wk_custody.custody_meta WHERE key=ANY($1::text[])',
|
||||
[[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`,
|
||||
`sale-rehearsal-terminal-index:${o.id}`]]);
|
||||
if (evidenceRows.rowCount) {
|
||||
try {
|
||||
const records = new Map<string, string>(evidenceRows.rows.map(r => [r.key, r.value]));
|
||||
const binding = records.get(`sale-rehearsal-binding:${o.id}`);
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
const verify = (key: string) => {
|
||||
const evidence = JSON.parse(records.get(key) ?? '');
|
||||
const replay = replayReservedSale({ ...o, status: 'awaiting-lock' }, JSON.parse(identity.rows[0].value).id, evidence.input, l.feeBps);
|
||||
if (evidence.version !== 1 || evidence.binding !== binding || replay.binding !== binding ||
|
||||
replay.stateRoot !== evidence.stateRoot) fail('sale-rehearsal-evidence-mismatch');
|
||||
return replay;
|
||||
};
|
||||
verify(`sale-rehearsal-observation:${o.id}`);
|
||||
if (o.status === 'awaiting-lock') {
|
||||
if (records.has(`sale-rehearsal-terminal:${o.id}`) || records.has(`sale-rehearsal-terminal-index:${o.id}`)) {
|
||||
fail('sale-rehearsal-evidence-mismatch');
|
||||
}
|
||||
} else {
|
||||
const replay = verify(`sale-rehearsal-terminal:${o.id}`);
|
||||
if (replay.decision.action !== (o.status === 'settled' ? 'settle' : 'refund') ||
|
||||
(o.status === 'settled' && row.box_data !== `fixture:${replay.stateRoot}`)) fail('sale-rehearsal-evidence-mismatch');
|
||||
const index = JSON.parse(records.get(`sale-rehearsal-terminal-index:${o.id}`) ?? '');
|
||||
if (index.version !== 1 || index.stateRoot !== replay.stateRoot ||
|
||||
index.terminalTransactionId !== (replay.terminalTransactionId ?? null)) fail('sale-rehearsal-evidence-mismatch');
|
||||
if (replay.terminalTransactionId) {
|
||||
const link = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1',
|
||||
[`sale-rehearsal-tx:${replay.terminalTransactionId}`]);
|
||||
const parsedLink = JSON.parse(link.rows[0]?.value ?? '');
|
||||
if (parsedLink.version !== 1 || parsedLink.orderId !== o.id || parsedLink.stateRoot !== replay.stateRoot) {
|
||||
fail('sale-rehearsal-terminal-index-mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { fail('sale-rehearsal-evidence-mismatch'); }
|
||||
}
|
||||
}
|
||||
await validateWallets(wallets);
|
||||
}
|
||||
const orderSelect = `SELECT o.id::text,o.buyer,o.seller,o.status,o.data,l.data AS listing_data,l.status AS listing_status,
|
||||
l.card AS listing_card,a.balance::text,a.normal,b.kind AS box_kind,b.status AS box_status,b.data AS box_data
|
||||
FROM wk_custody.orders o LEFT JOIN wk_custody.listings l ON l.id::text=o.data->>'listingId'
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||o.buyer||':purchase:'||o.id::text
|
||||
LEFT JOIN wk_custody.outbox b ON b.id=o.id`;
|
||||
|
||||
type WithdrawalRow = { id: string; actor: string; status: string; data: unknown; balance: string | null;
|
||||
normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null };
|
||||
async function checkWithdrawals(client: PoolClient, rows: WithdrawalRow[]) {
|
||||
const posting = await journals(client, rows.flatMap(r => [`withdraw:${r.id}`, `withdraw-cancel:${r.id}`, `withdraw-confirm:${r.id}`]));
|
||||
for (const row of rows) {
|
||||
const w = parse(withdrawalSchema, row.data, 'withdrawal-record-invalid');
|
||||
if (w.id !== row.id || w.actor !== row.actor || w.status !== row.status) fail('withdrawal-identity-mismatch');
|
||||
const total = (BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr)).toString();
|
||||
const active = w.status === 'reserved' || w.status === 'prepared';
|
||||
if (row.normal !== 'credit' || row.balance !== (active ? total : '0')) fail('withdrawal-reservation-mismatch');
|
||||
const prepared = w.status === 'prepared' || w.status === 'complete';
|
||||
if (prepared ? !w.attemptId || !w.transactionId || w.feeAtomicXmr === undefined || BigInt(w.feeAtomicXmr) > BigInt(w.maxFeeAtomicXmr)
|
||||
: w.attemptId !== undefined || w.transactionId !== undefined || w.feeAtomicXmr !== undefined) fail('withdrawal-attempt-invalid');
|
||||
const status = w.status === 'reserved' ? 'blocked' : w.status === 'cancelled' ? 'cancelled'
|
||||
: w.status === 'prepared' ? 'fixture-prepared' : 'fixture-complete';
|
||||
const box = parse(withdrawalSchema, json(row.box_data), 'withdrawal-outbox-invalid');
|
||||
if (row.box_kind !== 'withdrawal' || row.box_status !== status || !same(box, { ...w, status: prepared ? 'prepared' : 'reserved' })) fail('withdrawal-outbox-mismatch');
|
||||
const account = `customer:${w.actor}:withdrawal:${w.id}`, available = `customer:${w.actor}:available`;
|
||||
expectJournal(posting, `withdraw:${w.id}`, 'withdrawal-reservation', [d(available, total), c(account, total)]);
|
||||
expectJournal(posting, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', w.status === 'cancelled' ? [d(account, total), c(available, total)] : null);
|
||||
expectJournal(posting, `withdraw-confirm:${w.id}`, 'mock-withdrawal', w.status === 'complete' ? [d(account, total),
|
||||
c('treasury:mock', (BigInt(w.amountAtomicXmr) + BigInt(w.feeAtomicXmr!)).toString()),
|
||||
c(available, (BigInt(w.maxFeeAtomicXmr) - BigInt(w.feeAtomicXmr!)).toString())] : null);
|
||||
}
|
||||
}
|
||||
|
||||
type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observation_id: string; allocation_id: string;
|
||||
beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string;
|
||||
observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string;
|
||||
review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string;
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null;
|
||||
release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null;
|
||||
release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null;
|
||||
release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null;
|
||||
release_review_digest: string | null; released_at: string | null };
|
||||
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<DepositCreditRow> = await client.query<DepositCreditRow>(`SELECT c.credit_id::text,c.wallet_fingerprint,c.observation_id,
|
||||
c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text,
|
||||
c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text,
|
||||
c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations,
|
||||
a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner,
|
||||
r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce,
|
||||
r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature,
|
||||
r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text
|
||||
FROM wk_custody.monero_deposit_credit_events c
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id
|
||||
WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])]));
|
||||
for (const row of result.rows) {
|
||||
let approvalIsValid = false;
|
||||
let subjectIsValid = false;
|
||||
try {
|
||||
const message = depositCreditApprovalMessage(row.review_subject, row.reviewer_owner,
|
||||
row.review_nonce, Number(row.review_expires_at));
|
||||
approvalIsValid = address(row.reviewer_public_key) === row.reviewer_owner &&
|
||||
createHash('sha256').update(message).digest('hex') === row.review_digest &&
|
||||
Number(row.review_expires_at) >= Number(row.credited_at) &&
|
||||
verifySignature(row.reviewer_public_key, row.reviewer_owner, message, row.review_signature);
|
||||
subjectIsValid = row.observation_id === `${row.review_subject.observationId.split(':')[0]}:${row.review_subject.outputPublicKey}` &&
|
||||
row.review_subject.walletFingerprint === row.wallet_fingerprint &&
|
||||
row.review_subject.observationDigest === row.observation_digest &&
|
||||
row.review_subject.observationId === row.observation_id &&
|
||||
row.review_subject.amountAtomicXmr === row.amount_atomic_xmr && row.review_subject.beneficiary === row.beneficiary &&
|
||||
row.review_subject.allocationId === row.allocation_id &&
|
||||
row.review_subject.observationEventSequence === Number(row.last_observation_event_sequence) &&
|
||||
row.review_subject.minimumConfirmations === row.minimum_confirmations;
|
||||
} catch { approvalIsValid = false; }
|
||||
if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch');
|
||||
const released = row.release_id !== null;
|
||||
if (row.normal !== 'credit' || row.balance !== (released ? '0' : row.amount_atomic_xmr)) fail('deposit-pending-liability-mismatch');
|
||||
expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [
|
||||
d('treasury:monero:pending', row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
]);
|
||||
if (released) {
|
||||
let releaseValid = false;
|
||||
try {
|
||||
const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!,
|
||||
row.release_review_nonce!, Number(row.release_review_expires_at));
|
||||
const releaseSubject = row.release_review_subject!;
|
||||
releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner &&
|
||||
createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest &&
|
||||
Number(row.release_review_expires_at) >= Number(row.released_at) &&
|
||||
verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) &&
|
||||
releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint &&
|
||||
releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary &&
|
||||
releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id;
|
||||
} catch { releaseValid = false; }
|
||||
if (!releaseValid) fail('deposit-release-review-invalid');
|
||||
expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [
|
||||
d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr),
|
||||
]);
|
||||
const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text,
|
||||
r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text,
|
||||
r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence,
|
||||
h.event_type AS hold_event_type
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]);
|
||||
const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort();
|
||||
const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review');
|
||||
const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r
|
||||
ON r.hold_id=h.hold_id AND r.resolution_kind='release-review'
|
||||
WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]);
|
||||
const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])]
|
||||
.map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort();
|
||||
const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([
|
||||
hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort();
|
||||
if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner ||
|
||||
item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce ||
|
||||
item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature ||
|
||||
item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') ||
|
||||
releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') ||
|
||||
releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) {
|
||||
let resolutionValid = false;
|
||||
try {
|
||||
const subject = resolution.review_subject as DepositReorgResolutionSubject;
|
||||
const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by,
|
||||
resolution.review_nonce, Number(resolution.review_expires_at));
|
||||
resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' &&
|
||||
subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id &&
|
||||
subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type &&
|
||||
subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id &&
|
||||
subject.currentObservation.beneficiary === row.beneficiary &&
|
||||
subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr &&
|
||||
address(resolution.reviewer_public_key) === resolution.resolved_by &&
|
||||
createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest &&
|
||||
Number(resolution.review_expires_at) >= Number(resolution.resolved_at) &&
|
||||
verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature);
|
||||
} catch { resolutionValid = false; }
|
||||
if (!resolutionValid) fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.credit_id;
|
||||
}
|
||||
const orphanAccount = await client.query(`SELECT 1 FROM wk_custody.accounts a
|
||||
WHERE a.id LIKE 'customer:%:deposit-pending:%' AND NOT EXISTS (
|
||||
SELECT 1 FROM wk_custody.monero_deposit_credit_events c
|
||||
WHERE a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text) LIMIT 1`);
|
||||
if (orphanAccount.rowCount) fail('orphan-deposit-pending-liability');
|
||||
const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal');
|
||||
const orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanRelease.rowCount) fail('orphan-deposit-release-journal');
|
||||
const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id
|
||||
WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`);
|
||||
if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution');
|
||||
const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR
|
||||
h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`);
|
||||
if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch');
|
||||
}
|
||||
|
||||
async function checkMainnetWithdrawalIntents(client: PoolClient, budget: () => void): Promise<void> {
|
||||
type WithdrawalIntentAuditRow = { request_id: string; custodian_id: string; market_network: string; market_chain_id: string;
|
||||
beneficiary: string; destination: string; amount_atomic_xmr: string; max_fee_atomic_xmr: string; public_key: string;
|
||||
expires_at: string; request_signature: string; request_digest: string; status: string; created_at: string;
|
||||
cancel_request_id: string | null; cancel_public_key: string | null; cancel_expires_at: string | null;
|
||||
cancel_signature: string | null; cancelled_at: string | null };
|
||||
let after: string | null = null;
|
||||
const identityResult = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (!identityResult.rowCount) {
|
||||
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-missing');
|
||||
return;
|
||||
}
|
||||
const identity = JSON.parse(identityResult.rows[0].value) as Record<string, unknown>;
|
||||
if (identity.mode !== 'mainnet-custody' || identity.network !== 'wikideck-randomx-mainnet-1' || identity.policy !== 'custody-mainnet-v1') {
|
||||
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-mismatch');
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<WithdrawalIntentAuditRow> = await client.query<WithdrawalIntentAuditRow>(`SELECT request_id::text,custodian_id::text,market_network,market_chain_id,beneficiary,
|
||||
destination,amount_atomic_xmr::text,max_fee_atomic_xmr::text,public_key,expires_at::text,request_signature,
|
||||
request_digest,status,created_at::text,cancel_request_id::text,cancel_public_key,cancel_expires_at::text,
|
||||
cancel_signature,cancelled_at::text FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE ($1::uuid IS NULL OR request_id>$1::uuid) ORDER BY request_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.flatMap(row => [
|
||||
`monero-withdrawal-reserve:${row.request_id}`, `monero-withdrawal-cancel:${row.request_id}`]));
|
||||
for (const row of result.rows) {
|
||||
let destination: string;
|
||||
try { destination = await validateXmrAddress(row.destination); }
|
||||
catch { fail('mainnet-withdrawal-destination-invalid'); }
|
||||
const total = (BigInt(row.amount_atomic_xmr) + BigInt(row.max_fee_atomic_xmr)).toString();
|
||||
const aggregate = await client.query(`SELECT
|
||||
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved,
|
||||
COALESCE((SELECT sum(c.amount_atomic_xmr) FROM wk_custody.monero_deposit_credit_events c
|
||||
JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id WHERE c.beneficiary=$1),0)::text AS credited`,
|
||||
[row.beneficiary]);
|
||||
if (BigInt(aggregate.rows[0].reserved) > BigInt(aggregate.rows[0].credited)) {
|
||||
fail('mainnet-withdrawal-exceeds-reviewed-credit');
|
||||
}
|
||||
const holdAccount = `customer:${row.beneficiary}:monero-withdrawal:${row.request_id}`;
|
||||
const availableAccount = `customer:${row.beneficiary}:available`;
|
||||
const hold = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', [holdAccount]);
|
||||
const active = row.status === 'reserved';
|
||||
if (!['reserved','cancelled'].includes(row.status) || (active && (hold.rowCount !== 1 || hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== total)) ||
|
||||
(!active && hold.rowCount === 1 && (hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== '0'))) fail('mainnet-withdrawal-reservation-mismatch');
|
||||
try {
|
||||
const request = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
||||
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
publicKey: row.public_key, requestId: row.request_id, destination, amountAtomicXmr: row.amount_atomic_xmr,
|
||||
maxFeeAtomicXmr: row.max_fee_atomic_xmr, expiresAt: Number(row.expires_at), signature: row.request_signature };
|
||||
const { signature: _signature, ...unsigned } = request;
|
||||
const message = mainnetWithdrawalMessage(unsigned);
|
||||
if (row.market_network !== 'wikideck-randomx-mainnet-1' || row.custodian_id !== identity.id ||
|
||||
address(row.public_key) !== row.beneficiary || createHash('sha256').update(message).digest('hex') !== row.request_digest ||
|
||||
!verifySignature(row.public_key, row.beneficiary, message, row.request_signature)) fail('mainnet-withdrawal-authorization-invalid');
|
||||
} catch { fail('mainnet-withdrawal-authorization-invalid'); }
|
||||
expectJournal(postings, `monero-withdrawal-reserve:${row.request_id}`, 'monero-withdrawal-reservation', [
|
||||
d(availableAccount, total), c(holdAccount, total)]);
|
||||
if (active) {
|
||||
if (row.cancel_request_id || row.cancel_signature || row.cancelled_at) fail('mainnet-withdrawal-cancellation-invalid');
|
||||
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', null);
|
||||
} else {
|
||||
try {
|
||||
const cancellation = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
||||
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
publicKey: row.cancel_public_key!, cancellationId: row.cancel_request_id!,
|
||||
withdrawalRequestId: row.request_id, expiresAt: Number(row.cancel_expires_at), signature: row.cancel_signature! };
|
||||
const { signature: cancelSignature, ...unsigned } = cancellation;
|
||||
const message = mainnetWithdrawalCancellationMessage(unsigned);
|
||||
if (address(cancellation.publicKey) !== row.beneficiary ||
|
||||
!verifySignature(cancellation.publicKey, row.beneficiary, message, cancelSignature)) fail('mainnet-withdrawal-cancellation-invalid');
|
||||
} catch { fail('mainnet-withdrawal-cancellation-invalid'); }
|
||||
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', [
|
||||
d(holdAccount, total), c(availableAccount, total)]);
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.request_id;
|
||||
}
|
||||
}
|
||||
|
||||
const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal,
|
||||
b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text
|
||||
LEFT JOIN wk_custody.outbox b ON b.id=w.id`;
|
||||
|
||||
/** Targeted mutation checks share the same transaction as postings/receipts. No external side effects. */
|
||||
export async function auditOrder(client: PoolClient, id: string) {
|
||||
const result = await client.query(`${orderSelect} WHERE o.id=$1`, [id]);
|
||||
if (result.rowCount !== 1) fail('order-record-missing'); await checkOrders(client, result.rows);
|
||||
}
|
||||
export async function auditWithdrawal(client: PoolClient, id: string) {
|
||||
const result = await client.query(`${withdrawalSelect} WHERE w.id=$1`, [id]);
|
||||
if (result.rowCount !== 1) fail('withdrawal-record-missing'); await checkWithdrawals(client, result.rows);
|
||||
}
|
||||
/** Caller supplies a coherent transaction. Keyset pages bound application memory, not total audit duration. */
|
||||
export async function auditBusinessLiabilities(client: PoolClient): Promise<void> {
|
||||
const deadline = performance.now() + 15000;
|
||||
const budget = () => { if (performance.now() > deadline) throw new Error('Custody business audit budget exceeded'); };
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<OrderRow> = await client.query(`${orderSelect} WHERE ($1::uuid IS NULL OR o.id>$1::uuid) ORDER BY o.id LIMIT 128`, [after]);
|
||||
await checkOrders(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
||||
}
|
||||
after = null;
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<WithdrawalRow> = await client.query(`${withdrawalSelect} WHERE ($1::uuid IS NULL OR w.id>$1::uuid) ORDER BY w.id LIMIT 128`, [after]);
|
||||
await checkWithdrawals(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
||||
}
|
||||
after = null;
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<{ id: string; card: string; status: string; data: unknown }> = await client.query('SELECT id::text,card,status,data FROM wk_custody.listings WHERE ($1::uuid IS NULL OR id>$1::uuid) ORDER BY id LIMIT 128', [after]);
|
||||
const wallets: string[] = [];
|
||||
for (const row of result.rows) {
|
||||
const l = parse(listingSchema, row.data, 'listing-record-invalid');
|
||||
if (row.id !== l.id || row.card !== l.cardHash || row.status !== l.status) fail('listing-identity-mismatch');
|
||||
wallets.push(l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address);
|
||||
}
|
||||
await validateWallets(wallets);
|
||||
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
||||
}
|
||||
await checkDepositCredits(client, budget);
|
||||
await checkMainnetWithdrawalIntents(client, budget);
|
||||
// Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings.
|
||||
const checks: [string, string][] = [
|
||||
['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE a.id='customer:'||o.buyer||':purchase:'||o.id::text) LIMIT 1`],
|
||||
['orphan-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:withdrawal:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE a.id='customer:'||w.actor||':withdrawal:'||w.id::text) LIMIT 1`],
|
||||
['orphan-monero-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:monero-withdrawal:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents w
|
||||
WHERE a.id='customer:'||w.beneficiary||':monero-withdrawal:'||w.request_id::text) LIMIT 1`],
|
||||
['orphan-outbox', `SELECT 1 FROM wk_custody.outbox b WHERE NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE o.id=b.id AND b.kind='nft-lock')
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE w.id=b.id AND b.kind='withdrawal') LIMIT 1`],
|
||||
['listing-order-mismatch', `SELECT 1 FROM wk_custody.listings l LEFT JOIN wk_custody.orders o ON o.data->>'listingId'=l.id::text
|
||||
WHERE (l.status IN ('reserved','sold') AND o.id IS NULL) OR l.id::text IS DISTINCT FROM l.data->>'id'
|
||||
OR l.status IS DISTINCT FROM l.data->>'status' OR l.card IS DISTINCT FROM l.data->>'cardHash' LIMIT 1`],
|
||||
['duplicate-listing-orders', `SELECT 1 FROM wk_custody.orders GROUP BY data->>'listingId' HAVING count(*)<>1 LIMIT 1`],
|
||||
['orphan-lifecycle-posting', `SELECT 1 FROM wk_custody.journals j WHERE
|
||||
(j.kind IN ('purchase-reservation','purchase-cancellation','mock-sale-settlement') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.orders o WHERE j.id IN ('reserve:'||o.id::text,'cancel:'||o.id::text,'settle:'||o.id::text))) OR
|
||||
(j.kind IN ('withdrawal-reservation','withdrawal-cancellation','mock-withdrawal') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) OR
|
||||
(j.kind IN ('monero-withdrawal-reservation','monero-withdrawal-cancellation') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.monero_withdrawal_intents w WHERE j.id IN
|
||||
('monero-withdrawal-reserve:'||w.request_id::text,'monero-withdrawal-cancel:'||w.request_id::text))) LIMIT 1`],
|
||||
['duplicate-prepared-attempt', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
||||
GROUP BY data->>'attemptId' HAVING count(*)>1 LIMIT 1`],
|
||||
['duplicate-prepared-transaction', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
||||
GROUP BY data->>'transactionId' HAVING count(*)>1 LIMIT 1`],
|
||||
];
|
||||
for (const [code, sql] of checks) { budget(); if ((await client.query(sql)).rowCount) fail(code); }
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFile, open, lstat, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { CustodyMarket } from './custody.ts';
|
||||
import { PostgresCustody, type CustodyOptions } from './postgres-custody.ts';
|
||||
import { custodyStorage, postgresConfig } from './postgres-config.ts';
|
||||
import { PROFILE } from '../../shared/network.ts';
|
||||
|
||||
/** No automatic SQLite reinterpretation or fallback after a database connection/configuration failure. */
|
||||
export async function openCustodyStorage(directory: string, options: CustodyOptions, env = process.env): Promise<CustodyMarket | PostgresCustody> {
|
||||
const profileDirectory = join(directory, PROFILE);
|
||||
const storage = custodyStorage(env), marker = join(profileDirectory, 'custody-postgres.json');
|
||||
const observerMode = env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
if (observerMode !== undefined && observerMode !== '0' && observerMode !== '1') {
|
||||
throw new Error('WIKIDECK_CUSTODY_CANONICAL_REHEARSAL must be exactly 0 or 1');
|
||||
}
|
||||
if (observerMode === '1' && (env.NODE_ENV === 'production' || storage !== 'postgres' || env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
||||
throw new Error('Canonical observer rehearsal requires PostgreSQL and the disposable database test guard');
|
||||
}
|
||||
const custodyOptions = observerMode === '1' ? { ...options, disposableSaleRehearsal: true } : options;
|
||||
await mkdir(profileDirectory, { recursive: true, mode: 0o700 });
|
||||
let existing: { version: number; custodianId: string } | undefined;
|
||||
try { existing = JSON.parse(await readFile(marker, 'utf8')); }
|
||||
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
||||
if (storage === 'sqlite') {
|
||||
if (existing) throw new Error('Custody data directory is bound to PostgreSQL; SQLite fallback prohibited');
|
||||
return CustodyMarket.open(directory, custodyOptions);
|
||||
}
|
||||
const source = join(profileDirectory, 'custody-sandbox.sqlite');
|
||||
let frozen: string | undefined;
|
||||
try {
|
||||
const info = await lstat(source);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid custody source file');
|
||||
const db = new DatabaseSync(source, { readOnly: true });
|
||||
try {
|
||||
frozen = (db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined)?.value;
|
||||
if (!frozen) throw new Error('Existing SQLite custody requires explicit stopped-node import before PostgreSQL activation');
|
||||
} finally { db.close(); }
|
||||
} catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
||||
const market = await PostgresCustody.open(await postgresConfig(env), custodyOptions);
|
||||
try {
|
||||
if (frozen && await market.importSource() !== frozen) throw new Error('PostgreSQL import does not match local frozen custody source');
|
||||
if (existing && (existing.version !== 1 || existing.custodianId !== market.id)) throw new Error('PostgreSQL custodian differs from data-directory binding');
|
||||
if (!existing) {
|
||||
const handle = await open(marker, 'wx', 0o600);
|
||||
try { await handle.writeFile(JSON.stringify({ version: 1, custodianId: market.id })); await handle.sync(); }
|
||||
finally { await handle.close(); }
|
||||
if (process.platform !== 'win32') {
|
||||
const parent = await open(profileDirectory, 'r');
|
||||
try { await parent.sync(); } finally { await parent.close(); }
|
||||
}
|
||||
}
|
||||
return market;
|
||||
} catch (e) { await market.close(); throw e; }
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
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() };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
export type MainnetCustodyIdentity = {
|
||||
version: 1;
|
||||
mode: 'mainnet-custody';
|
||||
policy: 'custody-mainnet-v1';
|
||||
network: 'wikideck-randomx-mainnet-1';
|
||||
chainId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
function uuid(value: string): boolean {
|
||||
return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
/** One-time, schema-owner-only identity binding for the separately operated mainnet custody ledger. */
|
||||
export async function initializeMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
|
||||
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
|
||||
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
|
||||
}
|
||||
const identity: MainnetCustodyIdentity = { version: 1, mode: 'mainnet-custody', policy: 'custody-mainnet-v1',
|
||||
network: NETWORK, chainId: CHAIN_ID, id: custodianId };
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
const current = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity' FOR UPDATE");
|
||||
if (current.rowCount) {
|
||||
const parsed = JSON.parse(current.rows[0].value) as Partial<MainnetCustodyIdentity>;
|
||||
if (parsed.version !== identity.version || parsed.mode !== identity.mode || parsed.policy !== identity.policy ||
|
||||
parsed.network !== identity.network || parsed.chainId !== identity.chainId || parsed.id !== identity.id) {
|
||||
throw new Error('Custody database is already bound to a different network or custodian identity');
|
||||
}
|
||||
} else {
|
||||
const populated = await client.query(`SELECT
|
||||
EXISTS(SELECT 1 FROM wk_custody.journals) AS has_journals,
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) AS has_allocations`);
|
||||
if (populated.rows[0].has_journals || populated.rows[0].has_allocations) {
|
||||
throw new Error('Cannot initialize mainnet custody identity after financial or deposit records exist');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['identity', JSON.stringify(identity)]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return identity;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export async function verifyMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
|
||||
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
|
||||
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
|
||||
}
|
||||
const result = await pool.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
|
||||
const identity = JSON.parse(result.rows[0].value) as Partial<MainnetCustodyIdentity>;
|
||||
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
|
||||
identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.id !== custodianId) {
|
||||
throw new Error('Mainnet custody identity does not match configured custodian');
|
||||
}
|
||||
return identity as MainnetCustodyIdentity;
|
||||
}
|
||||
|
||||
export function parseMainnetCustodianId(value: string): string {
|
||||
if (!uuid(value)) throw new Error('Invalid mainnet custodian UUID');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { assertXmrRpcAmount } from '../../shared/xmr-amount.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalCancellationSchema,
|
||||
mainnetCustodyBalanceMessage, mainnetCustodyBalanceRequestSchema, mainnetWithdrawalMessage,
|
||||
mainnetWithdrawalRequestSchema } from '../../shared/monero-withdrawal.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
const digest = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
const availableAccount = (beneficiary: string) => `customer:${beneficiary}:available`;
|
||||
const withdrawalAccount = (beneficiary: string, requestId: string) => `customer:${beneficiary}:monero-withdrawal:${requestId}`;
|
||||
|
||||
export type MainnetWithdrawalIntent = {
|
||||
requestId: string;
|
||||
custodianId: string;
|
||||
beneficiary: string;
|
||||
destination: string;
|
||||
amountAtomicXmr: string;
|
||||
maxFeeAtomicXmr: string;
|
||||
status: 'reserved' | 'cancelled';
|
||||
createdAt: number;
|
||||
cancelledAt: number | null;
|
||||
};
|
||||
export type MainnetCustodyBalance = {
|
||||
availableAtomicXmr: string;
|
||||
depositPendingAtomicXmr: string;
|
||||
withdrawalReservedAtomicXmr: string;
|
||||
};
|
||||
|
||||
async function insertJournal(client: PoolClient, journalId: string, kind: string, createdAt: number,
|
||||
debitAccount: string, creditAccount: string, amount: string): Promise<void> {
|
||||
for (const [accountId, normal] of [[debitAccount, 'credit'], [creditAccount, 'credit']] as const) {
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]);
|
||||
const row = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]);
|
||||
if (row.rowCount !== 1 || row.rows[0].normal !== normal) throw new Error('Mainnet withdrawal ledger account mismatch');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [journalId, kind, createdAt]);
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
|
||||
[journalId, debitAccount, amount, creditAccount]);
|
||||
}
|
||||
|
||||
async function mainnetIdentity(client: PoolClient, custodianId: string, chainId: string): Promise<void> {
|
||||
const result = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
|
||||
const identity = JSON.parse(result.rows[0].value) as Record<string, unknown>;
|
||||
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
|
||||
identity.network !== 'wikideck-randomx-mainnet-1' || identity.id !== custodianId || identity.chainId !== chainId) {
|
||||
throw new Error('Withdrawal request does not match the initialized mainnet custody identity');
|
||||
}
|
||||
}
|
||||
|
||||
function expiryIsValid(expiresAt: number, now: number): boolean {
|
||||
return Number.isSafeInteger(now) && now >= 0 && expiresAt >= now && expiresAt <= now + MAX_REQUEST_LIFETIME_MS;
|
||||
}
|
||||
|
||||
/** Customer-signed request: atomically reserve available liability, but do not prepare or send XMR. */
|
||||
export async function reserveMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
|
||||
const request = mainnetWithdrawalRequestSchema.parse(rawRequest);
|
||||
const destination = await validateXmrAddress(request.destination);
|
||||
if (address(request.publicKey) !== request.beneficiary ||
|
||||
!verifySignature(request.publicKey, request.beneficiary, mainnetWithdrawalMessage(request), request.signature)) {
|
||||
throw new Error('Mainnet withdrawal signature is invalid');
|
||||
}
|
||||
const requestDigest = digest(mainnetWithdrawalMessage(request));
|
||||
const amount = BigInt(request.amountAtomicXmr), fee = BigInt(request.maxFeeAtomicXmr), total = amount + fee;
|
||||
assertXmrRpcAmount(amount); assertXmrRpcAmount(fee);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await mainnetIdentity(client, request.custodianId, request.chainId);
|
||||
const prior = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE', [request.requestId]);
|
||||
if (prior.rowCount) {
|
||||
const row = prior.rows[0];
|
||||
if (row.request_digest !== requestDigest || row.request_signature !== request.signature) throw new Error('Withdrawal request ID was reused with different terms');
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: row.status, createdAt: Number(row.created_at), cancelledAt: row.cancelled_at === null ? null : Number(row.cancelled_at) };
|
||||
}
|
||||
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet withdrawal request is expired or outside the allowed window');
|
||||
const hold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
|
||||
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [request.beneficiary]);
|
||||
if (hold.rowCount) throw new Error('Customer deposit is under reorg review; new withdrawals are held');
|
||||
const creditTotal = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr),0)::text AS credited
|
||||
FROM wk_custody.monero_deposit_release_events r
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=r.credit_id
|
||||
WHERE c.beneficiary=$1`, [request.beneficiary]);
|
||||
const activeTotals = await client.query(`SELECT
|
||||
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved`, [request.beneficiary]);
|
||||
const available = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1',
|
||||
[availableAccount(request.beneficiary)]);
|
||||
if (available.rowCount !== 1 || available.rows[0].normal !== 'credit' || BigInt(available.rows[0].balance) < total) {
|
||||
throw new Error('Insufficient available mainnet custody balance');
|
||||
}
|
||||
const alreadyReserved = BigInt(activeTotals.rows[0].reserved);
|
||||
const backed = BigInt(creditTotal.rows[0].credited);
|
||||
if (alreadyReserved + total > backed) throw new Error('Mainnet customer withdrawals exceed reviewed released deposit backing');
|
||||
const createdAt = now, reserveJournal = `monero-withdrawal-reserve:${request.requestId}`;
|
||||
await insertJournal(client, reserveJournal, 'monero-withdrawal-reservation', now,
|
||||
availableAccount(request.beneficiary), withdrawalAccount(request.beneficiary, request.requestId), total.toString());
|
||||
await client.query(`INSERT INTO wk_custody.monero_withdrawal_intents(
|
||||
request_id,custodian_id,market_network,market_chain_id,beneficiary,destination,amount_atomic_xmr,max_fee_atomic_xmr,
|
||||
public_key,expires_at,request_signature,request_digest,status,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'reserved',$13)`,
|
||||
[request.requestId, request.custodianId, request.network, request.chainId, request.beneficiary, destination,
|
||||
request.amountAtomicXmr, request.maxFeeAtomicXmr, request.publicKey, request.expiresAt, request.signature,
|
||||
requestDigest, createdAt]);
|
||||
await client.query('COMMIT');
|
||||
return { requestId: request.requestId, custodianId: request.custodianId, beneficiary: request.beneficiary,
|
||||
destination, amountAtomicXmr: request.amountAtomicXmr, maxFeeAtomicXmr: request.maxFeeAtomicXmr,
|
||||
status: 'reserved', createdAt, cancelledAt: null };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Customer may release a reservation only before any separate treasury preparation has occurred. */
|
||||
export async function cancelMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
|
||||
const cancellation = mainnetWithdrawalCancellationSchema.parse(rawRequest);
|
||||
if (address(cancellation.publicKey) !== cancellation.beneficiary ||
|
||||
!verifySignature(cancellation.publicKey, cancellation.beneficiary,
|
||||
mainnetWithdrawalCancellationMessage(cancellation), cancellation.signature)) {
|
||||
throw new Error('Mainnet withdrawal cancellation signature is invalid');
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await mainnetIdentity(client, cancellation.custodianId, cancellation.chainId);
|
||||
const result = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE',
|
||||
[cancellation.withdrawalRequestId]);
|
||||
if (result.rowCount !== 1 || result.rows[0].beneficiary !== cancellation.beneficiary) {
|
||||
throw new Error('Mainnet withdrawal request not found for this beneficiary');
|
||||
}
|
||||
const row = result.rows[0];
|
||||
if (row.status === 'cancelled') {
|
||||
if (row.cancel_request_id !== cancellation.cancellationId || row.cancel_signature !== cancellation.signature) {
|
||||
throw new Error('Withdrawal was cancelled under different signed terms');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: Number(row.cancelled_at) };
|
||||
}
|
||||
if (!expiryIsValid(cancellation.expiresAt, now)) throw new Error('Mainnet withdrawal cancellation is expired or outside the allowed window');
|
||||
const amount = BigInt(row.amount_atomic_xmr), maxFee = BigInt(row.max_fee_atomic_xmr), total = amount + maxFee;
|
||||
const cancelJournal = `monero-withdrawal-cancel:${row.request_id}`;
|
||||
await insertJournal(client, cancelJournal, 'monero-withdrawal-cancellation', now,
|
||||
withdrawalAccount(row.beneficiary, row.request_id), availableAccount(row.beneficiary), total.toString());
|
||||
await client.query(`UPDATE wk_custody.monero_withdrawal_intents SET status='cancelled',cancel_request_id=$2,
|
||||
cancel_public_key=$3,cancel_expires_at=$4,cancel_signature=$5,cancelled_at=$6 WHERE request_id=$1`,
|
||||
[row.request_id, cancellation.cancellationId, cancellation.publicKey, cancellation.expiresAt, cancellation.signature, now]);
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: now };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Read the caller's own balances using a signed, expiring request; never accepts an actor from query parameters. */
|
||||
export async function readMainnetCustodyBalance(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetCustodyBalance> {
|
||||
const request = mainnetCustodyBalanceRequestSchema.parse(rawRequest);
|
||||
if (address(request.publicKey) !== request.beneficiary ||
|
||||
!verifySignature(request.publicKey, request.beneficiary, mainnetCustodyBalanceMessage(request), request.signature)) {
|
||||
throw new Error('Mainnet custody balance signature is invalid');
|
||||
}
|
||||
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet custody balance request is expired or outside the allowed window');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
await mainnetIdentity(client, request.custodianId, request.chainId);
|
||||
const available = await client.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[availableAccount(request.beneficiary)]);
|
||||
const pending = await client.query(`SELECT COALESCE(sum(balance),0)::text AS balance FROM wk_custody.accounts
|
||||
WHERE id LIKE $1`, [`customer:${request.beneficiary}:deposit-pending:%`]);
|
||||
const withdrawals = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr+max_fee_atomic_xmr),0)::text AS balance
|
||||
FROM wk_custody.monero_withdrawal_intents WHERE beneficiary=$1 AND status='reserved'`, [request.beneficiary]);
|
||||
await client.query('COMMIT');
|
||||
return { availableAtomicXmr: available.rows[0]?.balance ?? '0',
|
||||
depositPendingAtomicXmr: pending.rows[0].balance,
|
||||
withdrawalReservedAtomicXmr: withdrawals.rows[0].balance };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_CONFIG_BYTES = 4096;
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
const RPC_TIMEOUT_MS = 5000;
|
||||
const configSchema = z.object({
|
||||
network: z.enum(['mainnet', 'stagenet']),
|
||||
url: z.string().min(1).max(2048),
|
||||
allowPrivateHttp: z.boolean(),
|
||||
}).strict();
|
||||
const infoSchema = z.object({
|
||||
status: z.literal('OK'),
|
||||
nettype: z.enum(['mainnet', 'stagenet', 'testnet']),
|
||||
mainnet: z.boolean(), stagenet: z.boolean(), testnet: z.boolean(),
|
||||
synchronized: z.boolean(), busy_syncing: z.boolean(), offline: z.boolean(), untrusted: z.boolean(),
|
||||
height: z.number().int().nonnegative().safe(),
|
||||
height_without_bootstrap: z.number().int().nonnegative().safe(),
|
||||
target_height: z.number().int().nonnegative().safe(),
|
||||
version: z.string().min(1).max(80),
|
||||
bootstrap_daemon_address: z.string().max(512),
|
||||
}).passthrough();
|
||||
const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(),
|
||||
result: z.record(z.string(), z.unknown()), error: z.never().optional() }).strict();
|
||||
|
||||
export type MoneroDaemonProbeConfig = z.infer<typeof configSchema>;
|
||||
export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise<Response>;
|
||||
export type MoneroDaemonProbeResult = {
|
||||
network: 'mainnet' | 'stagenet';
|
||||
height: number;
|
||||
version: string;
|
||||
synchronized: true;
|
||||
bootstrapProxyUsed: false;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
function isPrivateIp(host: string): boolean {
|
||||
const version = isIP(host);
|
||||
if (version === 4) {
|
||||
const [a, b] = host.split('.').map(Number);
|
||||
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) || (a === 169 && b === 254);
|
||||
}
|
||||
if (version === 6) {
|
||||
const normalized = host.toLowerCase();
|
||||
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') ||
|
||||
normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateEndpoint(config: MoneroDaemonProbeConfig): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(config.url); } catch { throw new Error('Invalid Monero daemon RPC endpoint'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
|
||||
url.pathname !== '/json_rpc' || !url.port) {
|
||||
throw new Error('Monero daemon endpoint must be a bare /json_rpc URL with an explicit port');
|
||||
}
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
if (!isPrivateIp(host)) throw new Error('Monero daemon endpoint must use a private literal IP address');
|
||||
if (url.protocol === 'http:' && !config.allowPrivateHttp) {
|
||||
throw new Error('Plain HTTP daemon RPC requires explicit private-network acknowledgement');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function loadMoneroDaemonProbeConfig(env: NodeJS.ProcessEnv = process.env): Promise<MoneroDaemonProbeConfig> {
|
||||
const path = env.WIKIDECK_MONERO_DAEMON_CONFIG_FILE;
|
||||
if (!path) throw new Error('Protected Monero daemon probe config file is required');
|
||||
try {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error();
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
||||
const config = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
||||
validateEndpoint(config);
|
||||
return config;
|
||||
} catch {
|
||||
throw new Error('Invalid or inaccessible protected Monero daemon probe configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async function readBounded(response: Response): Promise<string> {
|
||||
if (!response.body) throw new Error('Monero daemon RPC response body is missing');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error('Monero daemon RPC response exceeds size limit');
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/** Calls only monerod's read-only get_info method; no Wallet-RPC or transaction methods exist here. */
|
||||
export async function probeMoneroDaemon(configInput: unknown, transport: MoneroDaemonProbeTransport = fetch,
|
||||
now = Date.now()): Promise<MoneroDaemonProbeResult> {
|
||||
const config = configSchema.parse(configInput);
|
||||
const endpoint = validateEndpoint(config);
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid daemon probe time');
|
||||
const id = randomUUID();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
|
||||
timeout.unref();
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method: 'get_info' }) });
|
||||
} catch {
|
||||
throw new Error('Monero daemon RPC probe failed or timed out');
|
||||
}
|
||||
if (response.status !== 200 || response.redirected ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Monero daemon RPC response rejected');
|
||||
}
|
||||
let envelope: z.infer<typeof envelopeSchema>;
|
||||
try { envelope = envelopeSchema.parse(JSON.parse(await readBounded(response))); }
|
||||
catch { throw new Error('Invalid Monero daemon RPC response'); }
|
||||
if (envelope.id !== id) throw new Error('Monero daemon RPC response ID mismatch');
|
||||
let info: z.infer<typeof infoSchema>;
|
||||
try { info = infoSchema.parse(envelope.result); }
|
||||
catch { throw new Error('Invalid Monero daemon RPC response'); }
|
||||
if (info.nettype !== config.network || info.mainnet !== (config.network === 'mainnet') ||
|
||||
info.stagenet !== (config.network === 'stagenet') || info.testnet ||
|
||||
info.height < 1 || info.height_without_bootstrap !== info.height || info.untrusted ||
|
||||
info.offline || !info.synchronized || info.busy_syncing || info.bootstrap_daemon_address !== '') {
|
||||
throw new Error('Monero daemon network or synchronization state is not acceptable');
|
||||
}
|
||||
return { network: config.network, height: info.height, version: info.version,
|
||||
synchronized: true, bootstrapProxyUsed: false, checkedAt: new Date(now).toISOString() };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
|
||||
import { validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const hex64 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const index = z.number().int().min(0).max(0xffff_ffff).safe();
|
||||
const reserveInputSchema = z.object({
|
||||
requestId: uuid,
|
||||
custodianId: uuid,
|
||||
marketNetwork: z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']),
|
||||
marketChainId: hex64,
|
||||
beneficiary: hex64,
|
||||
walletGenerationId: uuid,
|
||||
walletFingerprint: hex64,
|
||||
accountIndex: index,
|
||||
reservedAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
const registerInputSchema = z.object({
|
||||
allocationId: uuid,
|
||||
walletGenerationId: uuid,
|
||||
moneroAddress: z.string().length(95),
|
||||
registeredAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
|
||||
export type MoneroDepositAllocation = {
|
||||
allocationId: string;
|
||||
requestId: string;
|
||||
custodianId: string;
|
||||
marketNetwork: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6';
|
||||
marketChainId: string;
|
||||
beneficiary: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
moneroNetwork: 'mainnet';
|
||||
accountIndex: number;
|
||||
addressIndex: number;
|
||||
status: 'reserved' | 'registered';
|
||||
moneroAddress: string | null;
|
||||
reservedAt: number;
|
||||
registeredAt: number | null;
|
||||
};
|
||||
|
||||
type AllocationRow = {
|
||||
allocation_id: string;
|
||||
request_id: string;
|
||||
custodian_id: string;
|
||||
market_network: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6';
|
||||
market_chain_id: string;
|
||||
beneficiary: string;
|
||||
wallet_generation_id: string;
|
||||
wallet_fingerprint: string;
|
||||
monero_network: 'mainnet';
|
||||
account_index: string;
|
||||
address_index: string;
|
||||
status: 'reserved' | 'registered';
|
||||
monero_address: string | null;
|
||||
reserved_at: string;
|
||||
registered_at: string | null;
|
||||
request_digest: string;
|
||||
};
|
||||
|
||||
function toAllocation(row: AllocationRow): MoneroDepositAllocation {
|
||||
return {
|
||||
allocationId: row.allocation_id,
|
||||
requestId: row.request_id,
|
||||
custodianId: row.custodian_id,
|
||||
marketNetwork: row.market_network,
|
||||
marketChainId: row.market_chain_id,
|
||||
beneficiary: row.beneficiary,
|
||||
walletGenerationId: row.wallet_generation_id,
|
||||
walletFingerprint: row.wallet_fingerprint,
|
||||
moneroNetwork: row.monero_network,
|
||||
accountIndex: Number(row.account_index),
|
||||
addressIndex: Number(row.address_index),
|
||||
status: row.status,
|
||||
moneroAddress: row.monero_address,
|
||||
reservedAt: Number(row.reserved_at),
|
||||
registeredAt: row.registered_at === null ? null : Number(row.registered_at),
|
||||
};
|
||||
}
|
||||
|
||||
function requestDigest(input: z.infer<typeof reserveInputSchema>): string {
|
||||
return createHash('sha256').update(JSON.stringify([
|
||||
'wikideck-monero-deposit-allocation-v1', input.custodianId, input.marketNetwork,
|
||||
input.marketChainId, input.beneficiary, input.walletGenerationId,
|
||||
input.walletFingerprint, input.accountIndex,
|
||||
])).digest('hex');
|
||||
}
|
||||
|
||||
async function finishTransaction(client: PoolClient, commit: boolean): Promise<void> {
|
||||
if (commit) await client.query('COMMIT');
|
||||
else await client.query('ROLLBACK').catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Durably reserve a never-reused wallet subaddress index and bind it to a beneficiary before
|
||||
* any wallet RPC address-generation side effect. This module is deliberately not wired to
|
||||
* startup, HTTP, a wallet RPC, or financial posting. A reserved row is never reclaimed.
|
||||
*/
|
||||
export async function reserveMoneroDepositAllocation(
|
||||
pool: Pool,
|
||||
input: unknown,
|
||||
): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> {
|
||||
const parsed = reserveInputSchema.parse(input);
|
||||
const digest = requestDigest(parsed);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
// Per-wallet/account serialization makes MAX(index)+1 safe across independent processes.
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',
|
||||
[`wikideck-monero-deposit-allocation-v1:${parsed.walletGenerationId}:${parsed.accountIndex}`]);
|
||||
|
||||
const byRequest = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE request_id=$1 FOR UPDATE`, [parsed.requestId]);
|
||||
if (byRequest.rowCount) {
|
||||
if (byRequest.rows[0].request_digest !== digest) throw new Error('Deposit allocation request ID was reused with different terms');
|
||||
await finishTransaction(client, true);
|
||||
return { allocation: toAllocation(byRequest.rows[0]), replayed: true };
|
||||
}
|
||||
|
||||
const byBeneficiary = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4
|
||||
AND wallet_generation_id=$5 FOR UPDATE`, [parsed.custodianId, parsed.marketNetwork, parsed.marketChainId,
|
||||
parsed.beneficiary, parsed.walletGenerationId]);
|
||||
if (byBeneficiary.rowCount) {
|
||||
if (byBeneficiary.rows[0].request_digest !== digest) throw new Error('Beneficiary already has a different deposit allocation for this wallet generation');
|
||||
await finishTransaction(client, true);
|
||||
return { allocation: toAllocation(byBeneficiary.rows[0]), replayed: true };
|
||||
}
|
||||
|
||||
const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index
|
||||
FROM wk_custody.monero_deposit_allocations WHERE wallet_generation_id=$1 AND account_index=$2`,
|
||||
[parsed.walletGenerationId, parsed.accountIndex]);
|
||||
const addressIndex = BigInt(next.rows[0].next_index);
|
||||
if (addressIndex > 0xffff_ffffn) throw new Error('Monero subaddress index space is exhausted for this account');
|
||||
|
||||
const inserted = await client.query<AllocationRow>(`INSERT INTO wk_custody.monero_deposit_allocations(
|
||||
allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id,
|
||||
wallet_fingerprint,monero_network,account_index,address_index,status,request_digest,reserved_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',$9,$10,'reserved',$11,$12) RETURNING *`,
|
||||
[randomUUID(), parsed.requestId, parsed.custodianId, parsed.marketNetwork, parsed.marketChainId,
|
||||
parsed.beneficiary, parsed.walletGenerationId, parsed.walletFingerprint, parsed.accountIndex,
|
||||
addressIndex.toString(), digest, parsed.reservedAt]);
|
||||
await finishTransaction(client, true);
|
||||
return { allocation: toAllocation(inserted.rows[0]), replayed: false };
|
||||
} catch (error) {
|
||||
await finishTransaction(client, false);
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
|
||||
throw new Error('Deposit allocation conflicts with an existing beneficiary, wallet index, or address');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the address returned by a separately managed wallet operation exactly once. */
|
||||
export async function registerMoneroDepositSubaddress(
|
||||
pool: Pool,
|
||||
input: unknown,
|
||||
): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> {
|
||||
const parsed = registerInputSchema.parse(input);
|
||||
const address = await validateXmrSubaddress(parsed.moneroAddress);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const found = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2 FOR UPDATE`, [parsed.allocationId, parsed.walletGenerationId]);
|
||||
if (!found.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation');
|
||||
const row = found.rows[0];
|
||||
if (row.status === 'registered') {
|
||||
if (row.monero_address !== address) throw new Error('Registered deposit address cannot be replaced');
|
||||
await finishTransaction(client, true);
|
||||
return { allocation: toAllocation(row), replayed: true };
|
||||
}
|
||||
if (parsed.registeredAt < Number(row.reserved_at)) throw new Error('Deposit address registration predates its durable reservation');
|
||||
const registered = await client.query<AllocationRow>(`UPDATE wk_custody.monero_deposit_allocations
|
||||
SET status='registered',monero_address=$3,registered_at=$4
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2 RETURNING *`,
|
||||
[parsed.allocationId, parsed.walletGenerationId, address, parsed.registeredAt]);
|
||||
await finishTransaction(client, true);
|
||||
return { allocation: toAllocation(registered.rows[0]), replayed: false };
|
||||
} catch (error) {
|
||||
await finishTransaction(client, false);
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
|
||||
throw new Error('Monero deposit address is already registered to an allocation');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or recover the wallet subaddress for a previously committed reservation. A stable
|
||||
* wallet label makes a lost create_address response recoverable via get_address at the exact
|
||||
* reserved index. The wallet RPC must be dedicated to this allocator: external address
|
||||
* creation can consume indexes and is treated as a hard mismatch, never silently reassigned.
|
||||
*/
|
||||
export async function createOrRecoverMoneroDepositSubaddress(
|
||||
pool: Pool,
|
||||
rpc: MoneroDepositAddressRpc,
|
||||
allocationId: string,
|
||||
walletGenerationId: string,
|
||||
registeredAt: number,
|
||||
): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> {
|
||||
const parsedAllocationId = uuid.parse(allocationId);
|
||||
const parsedGenerationId = uuid.parse(walletGenerationId);
|
||||
if (!Number.isSafeInteger(registeredAt) || registeredAt < 0) throw new Error('Invalid deposit address registration time');
|
||||
|
||||
const client = await pool.connect();
|
||||
let address: string | undefined;
|
||||
let label: string | undefined;
|
||||
let lockKey: string | undefined;
|
||||
let locked = false;
|
||||
try {
|
||||
const initial = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
|
||||
if (!initial.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation');
|
||||
if (initial.rows[0].status === 'registered') {
|
||||
return { allocation: toAllocation(initial.rows[0]), replayed: true };
|
||||
}
|
||||
const row = initial.rows[0];
|
||||
lockKey = `wikideck-monero-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
|
||||
await client.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
|
||||
locked = true;
|
||||
|
||||
// Re-read after obtaining the per-wallet lock in case another process finished first.
|
||||
const current = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
|
||||
if (!current.rowCount) throw new Error('Deposit allocation reservation disappeared');
|
||||
if (current.rows[0].status === 'registered') {
|
||||
return { allocation: toAllocation(current.rows[0]), replayed: true };
|
||||
}
|
||||
const allocation = current.rows[0];
|
||||
const liveWalletFingerprint = await rpc.verifyWallet();
|
||||
if (liveWalletFingerprint !== allocation.wallet_fingerprint) {
|
||||
throw new Error('Configured Monero wallet identity differs from the durable allocation generation');
|
||||
}
|
||||
const expectedLabel = `wikideck-deposit-v1:${allocation.allocation_id}`;
|
||||
const expectedIndex = Number(allocation.address_index);
|
||||
const existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (existing) {
|
||||
if (existing.label !== expectedLabel) throw new Error('Reserved Monero subaddress index is occupied by a different wallet label');
|
||||
address = await validateXmrSubaddress(existing.address);
|
||||
label = existing.label;
|
||||
} else {
|
||||
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
|
||||
if (created.addressIndex !== expectedIndex) {
|
||||
throw new Error('Monero wallet created an unexpected subaddress index; allocation requires operator reconciliation');
|
||||
}
|
||||
const verified = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (!verified || verified.label !== expectedLabel || verified.address !== created.address) {
|
||||
throw new Error('Monero wallet subaddress could not be verified at its reserved index');
|
||||
}
|
||||
address = await validateXmrSubaddress(verified.address);
|
||||
label = verified.label;
|
||||
}
|
||||
} finally {
|
||||
if (locked && lockKey) await client.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
|
||||
client.release();
|
||||
}
|
||||
if (!address || !label) throw new Error('Monero deposit address allocation was not verified');
|
||||
return registerMoneroDepositSubaddress(pool, { allocationId: parsedAllocationId,
|
||||
walletGenerationId: parsedGenerationId, moneroAddress: address, registeredAt });
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { assertXmrRpcAmount, parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const fingerprint = hex;
|
||||
const publicKey = z.string().min(1).max(256);
|
||||
const signature = z.string().min(1).max(256);
|
||||
const uuid = z.string().uuid();
|
||||
const POLICY_VERSION = 'monero-pending-credit-v1';
|
||||
const MAX_EVENT_HISTORY = 256;
|
||||
|
||||
export type DepositCreditPolicy = {
|
||||
/** Reviewed key-owner hashes; the signing key remains operator-held and is never stored here. */
|
||||
approvedReviewerOwners: readonly string[];
|
||||
/** Provisional floor; a production value still requires explicit approval. */
|
||||
minimumConfirmations: number;
|
||||
/** Refuse credit review when observation scans have gone stale. */
|
||||
maximumObservationAgeMs: number;
|
||||
};
|
||||
export type DepositCreditReviewSubject = {
|
||||
policyVersion: typeof POLICY_VERSION;
|
||||
walletFingerprint: string;
|
||||
observationId: string;
|
||||
outputPublicKey: string;
|
||||
amountAtomicXmr: string;
|
||||
blockHeight: number;
|
||||
confirmations: number;
|
||||
minimumConfirmations: number;
|
||||
locked: boolean;
|
||||
doubleSpendSeen: boolean;
|
||||
allocationId: string;
|
||||
beneficiary: string;
|
||||
observationDigest: string;
|
||||
observationEventSequence: number;
|
||||
observationEventHistoryDigest: string;
|
||||
observerUpdatedAt: number;
|
||||
};
|
||||
export type DepositCreditApproval = {
|
||||
subject: DepositCreditReviewSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
|
||||
export type DepositCreditReleaseSubject = DepositCreditReviewSubject & {
|
||||
creditId: string;
|
||||
pendingJournalId: string;
|
||||
unresolvedHolds: { holdId: string; eventSequence: number; eventType: 'changed' | 'not-seen' | 'reappeared' }[];
|
||||
};
|
||||
export type DepositCreditReleaseApproval = {
|
||||
subject: DepositCreditReleaseSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositCreditReleaseResult = { creditId: string; releaseId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
|
||||
export type DepositReorgResolutionSubject = {
|
||||
policyVersion: 'monero-reorg-resolution-v1';
|
||||
creditId: string;
|
||||
releaseId: string;
|
||||
holdId: string;
|
||||
walletFingerprint: string;
|
||||
observationId: string;
|
||||
eventSequence: number;
|
||||
eventType: 'changed' | 'not-seen' | 'reappeared';
|
||||
currentObservation: DepositCreditReviewSubject;
|
||||
};
|
||||
export type DepositReorgResolutionApproval = {
|
||||
subject: DepositReorgResolutionSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositReorgResolutionResult = { resolutionId: string; holdId: string; replayed: boolean };
|
||||
|
||||
const subjectSchema = z.object({
|
||||
policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
|
||||
outputPublicKey: hex, amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
blockHeight: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000), locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
allocationId: uuid, beneficiary: hex, observationDigest: hex,
|
||||
observationEventSequence: z.number().int().nonnegative().safe(), observationEventHistoryDigest: hex,
|
||||
observerUpdatedAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
const approvalSchema = z.object({ subject: subjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
|
||||
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
const releaseSubjectSchema = subjectSchema.extend({ creditId: uuid, pendingJournalId: z.string().min(1).max(256),
|
||||
unresolvedHolds: z.array(z.object({ holdId: uuid, eventSequence: z.number().int().positive().safe(),
|
||||
eventType: z.enum(['changed', 'not-seen', 'reappeared']) }).strict()).max(1024) }).strict();
|
||||
const releaseApprovalSchema = z.object({ subject: releaseSubjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
|
||||
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
const resolutionSubjectSchema = z.object({ policyVersion: z.literal('monero-reorg-resolution-v1'), creditId: uuid,
|
||||
releaseId: uuid, holdId: uuid, walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
|
||||
eventSequence: z.number().int().positive().safe(), eventType: z.enum(['changed','not-seen','reappeared']),
|
||||
currentObservation: subjectSchema }).strict();
|
||||
const resolutionApprovalSchema = z.object({ subject: resolutionSubjectSchema, reviewerOwner: hex,
|
||||
reviewerPublicKey: publicKey, nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
|
||||
function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); }
|
||||
function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
return JSON.stringify(['wikideck-monero-pending-credit-review-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.walletFingerprint, subject.observationId, subject.outputPublicKey,
|
||||
subject.amountAtomicXmr, subject.blockHeight, subject.confirmations, subject.minimumConfirmations,
|
||||
subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary,
|
||||
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
|
||||
subject.observerUpdatedAt]);
|
||||
}
|
||||
function canonicalReleaseMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
return JSON.stringify(['wikideck-monero-pending-release-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.creditId, subject.pendingJournalId, subject.walletFingerprint, subject.observationId,
|
||||
subject.outputPublicKey, subject.amountAtomicXmr, subject.blockHeight, subject.confirmations,
|
||||
subject.minimumConfirmations, subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary,
|
||||
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
|
||||
subject.observerUpdatedAt, subject.unresolvedHolds.map(hold => [hold.holdId, hold.eventSequence, hold.eventType])]);
|
||||
}
|
||||
function canonicalResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
const observation = subject.currentObservation;
|
||||
return JSON.stringify(['wikideck-monero-reorg-resolution-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.creditId, subject.releaseId, subject.holdId, subject.walletFingerprint,
|
||||
subject.observationId, subject.eventSequence, subject.eventType, observation.outputPublicKey,
|
||||
observation.amountAtomicXmr, observation.blockHeight, observation.confirmations, observation.minimumConfirmations,
|
||||
observation.locked, observation.doubleSpendSeen, observation.allocationId, observation.beneficiary,
|
||||
observation.observationDigest, observation.observationEventSequence, observation.observationEventHistoryDigest,
|
||||
observation.observerUpdatedAt]);
|
||||
}
|
||||
|
||||
function validatePolicy(policy: DepositCreditPolicy): void {
|
||||
if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length ||
|
||||
policy.approvedReviewerOwners.length > 8 || policy.approvedReviewerOwners.some(owner => !hex.safeParse(owner).success) ||
|
||||
!Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 || policy.minimumConfirmations > 100_000 ||
|
||||
!Number.isSafeInteger(policy.maximumObservationAgeMs) || policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) {
|
||||
throw new Error('Invalid private Monero deposit-credit policy');
|
||||
}
|
||||
}
|
||||
|
||||
async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string,
|
||||
policy: DepositCreditPolicy, now: number, allowReorgHistory = false): Promise<DepositCreditReviewSubject> {
|
||||
const observation = await client.query(`SELECT o.wallet_fingerprint,o.observation_id,o.txid,o.monero_address,o.account_index,
|
||||
o.address_index,o.output_public_key,o.amount_atomic_xmr::text,o.block_height,o.confirmations,o.locked,
|
||||
o.double_spend_seen,o.payload_digest,o.currently_present,s.updated_at AS observer_updated_at
|
||||
FROM wk_custody.monero_transfer_observations o
|
||||
JOIN wk_custody.monero_observer_state s USING(wallet_fingerprint)
|
||||
WHERE o.wallet_fingerprint=$1 AND o.observation_id=$2`, [walletFingerprint, observationId]);
|
||||
if (observation.rowCount !== 1) throw new Error('Deposit observation not found');
|
||||
const row = observation.rows[0];
|
||||
if (!row.output_public_key || row.observation_id !== `${row.txid}:${row.output_public_key}` || !row.currently_present ||
|
||||
row.double_spend_seen || row.locked || Number(row.confirmations) < policy.minimumConfirmations) {
|
||||
throw new Error('Deposit observation is not eligible for reviewed pending credit');
|
||||
}
|
||||
if (now - Number(row.observer_updated_at) < 0 || now - Number(row.observer_updated_at) > policy.maximumObservationAgeMs) {
|
||||
throw new Error('Monero observer state is stale');
|
||||
}
|
||||
const amount = BigInt(row.amount_atomic_xmr);
|
||||
assertXmrRpcAmount(amount);
|
||||
parseAtomicXmr(amount.toString());
|
||||
|
||||
const allocation = await client.query(`SELECT allocation_id,beneficiary,custodian_id,market_network,market_chain_id
|
||||
FROM wk_custody.monero_deposit_allocations
|
||||
WHERE wallet_fingerprint=$1 AND account_index=$2 AND address_index=$3 AND monero_address=$4 AND status='registered'`,
|
||||
[walletFingerprint, row.account_index, row.address_index, row.monero_address]);
|
||||
if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation');
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable');
|
||||
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string; policy?: string };
|
||||
const mapped = allocation.rows[0];
|
||||
const validIdentity = (custodian.mode === 'sandbox' && custodian.network === 'wikideck-randomx-testnet-6' &&
|
||||
custodian.policy === 'custody-sandbox-v1' && mapped.market_network === 'wikideck-randomx-testnet-6') ||
|
||||
(custodian.mode === 'mainnet-custody' && custodian.network === 'wikideck-randomx-mainnet-1' &&
|
||||
custodian.policy === 'custody-mainnet-v1' && mapped.market_network === 'wikideck-randomx-mainnet-1');
|
||||
if (!validIdentity || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
|
||||
mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity');
|
||||
|
||||
const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at
|
||||
FROM wk_custody.monero_observation_events WHERE wallet_fingerprint=$1 AND observation_id=$2
|
||||
ORDER BY sequence LIMIT $3`, [walletFingerprint, observationId, MAX_EVENT_HISTORY + 1]);
|
||||
if ((events.rowCount ?? events.rows.length) > MAX_EVENT_HISTORY) throw new Error('Deposit observation event history exceeds review bound');
|
||||
if (!allowReorgHistory && events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
|
||||
throw new Error('Deposit observation has prior disappearance/reappearance and requires separate review');
|
||||
}
|
||||
const sequence = events.rows.length ? Number(events.rows.at(-1)!.sequence) : 0;
|
||||
const eventDigest = digest(JSON.stringify(events.rows.map(event => [Number(event.sequence), event.event_type,
|
||||
event.payload_digest, Number(event.wallet_height), Number(event.observed_at)])));
|
||||
return {
|
||||
policyVersion: POLICY_VERSION, walletFingerprint, observationId, outputPublicKey: row.output_public_key,
|
||||
amountAtomicXmr: amount.toString(), blockHeight: Number(row.block_height), confirmations: Number(row.confirmations),
|
||||
minimumConfirmations: policy.minimumConfirmations, locked: row.locked, doubleSpendSeen: row.double_spend_seen,
|
||||
allocationId: mapped.allocation_id, beneficiary: mapped.beneficiary, observationDigest: row.payload_digest,
|
||||
observationEventSequence: sequence, observationEventHistoryDigest: eventDigest,
|
||||
observerUpdatedAt: Number(row.observer_updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the exact current snapshot an authorized operator must review and sign. */
|
||||
export async function prepareDepositCreditReview(pool: Pool, walletFingerprint: string, observationId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReviewSubject> {
|
||||
validatePolicy(policy); fingerprint.parse(walletFingerprint);
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReviewSubject(c, walletFingerprint, observationId, policy, now);
|
||||
await c.query('COMMIT'); return subject;
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
/** Canonical message an allow-listed reviewer signs after examining the exact output/allocation snapshot. */
|
||||
export function depositCreditApprovalMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string,
|
||||
expiresAt: number): string {
|
||||
return canonicalReviewMessage(subjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts an operator-reviewed credit into a non-spendable pending-liability account. This service is not
|
||||
* wired to HTTP/startup; release to available balance is a separate, not-yet-implemented approval step.
|
||||
*/
|
||||
export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval: DepositCreditApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = approvalSchema.parse(rawApproval);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner,
|
||||
canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt), approval.signature)) {
|
||||
throw new Error('Deposit credit review signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt));
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT credit_id,journal_id,beneficiary,amount_atomic_xmr::text,reviewer_owner,
|
||||
review_nonce,observation_digest,allocation_id::text,reviewer_public_key,review_expires_at,review_signature,
|
||||
minimum_confirmations,review_digest FROM wk_custody.monero_deposit_credit_events
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`,
|
||||
[approval.subject.walletFingerprint, approval.subject.observationId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce ||
|
||||
saved.observation_digest !== approval.subject.observationDigest || saved.amount_atomic_xmr !== approval.subject.amountAtomicXmr ||
|
||||
saved.allocation_id !== approval.subject.allocationId || saved.minimum_confirmations !== approval.subject.minimumConfirmations ||
|
||||
saved.reviewer_public_key !== approval.reviewerPublicKey || Number(saved.review_expires_at) !== approval.expiresAt ||
|
||||
saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit output was already staged under different reviewed terms');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { creditId: saved.credit_id, journalId: saved.journal_id, beneficiary: saved.beneficiary,
|
||||
amountAtomicXmr: saved.amount_atomic_xmr, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit credit review signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReviewSubject(client, approval.subject.walletFingerprint, approval.subject.observationId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit observation changed after reviewer approval');
|
||||
const creditId = randomUUID();
|
||||
const journalId = `monero-deposit-pending:${digest(`${current.walletFingerprint}:${current.observationId}`)}`;
|
||||
const assetAccount = 'treasury:monero:pending';
|
||||
const liabilityAccount = `customer:${current.beneficiary}:deposit-pending:${creditId}`;
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)',
|
||||
[journalId, 'monero-deposit-pending-credit', now]);
|
||||
const lines = [[0, assetAccount, 'debit'], [1, liabilityAccount, 'credit']] as const;
|
||||
for (const [position, accountId, normal] of lines) {
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]);
|
||||
const account = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]);
|
||||
if (account.rows[0]?.normal !== normal) throw new Error('Deposit pending account normal mismatch');
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)',
|
||||
[journalId, position, accountId, normal === 'debit' ? current.amountAtomicXmr : '0',
|
||||
normal === 'credit' ? current.amountAtomicXmr : '0']);
|
||||
}
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_credit_events(
|
||||
credit_id,wallet_fingerprint,observation_id,allocation_id,beneficiary,amount_atomic_xmr,journal_id,
|
||||
observation_digest,last_observation_event_sequence,reviewer_owner,reviewer_public_key,review_nonce,
|
||||
review_expires_at,review_signature,review_subject,review_digest,credited_at,minimum_confirmations)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
||||
[creditId, current.walletFingerprint, current.observationId, current.allocationId, current.beneficiary,
|
||||
current.amountAtomicXmr, journalId, current.observationDigest, current.observationEventSequence,
|
||||
approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature,
|
||||
JSON.stringify(current), reviewDigest, now, current.minimumConfirmations]);
|
||||
await client.query('COMMIT');
|
||||
return { creditId, journalId, beneficiary: current.beneficiary, amountAtomicXmr: current.amountAtomicXmr, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
async function readReleaseSubject(client: PoolClient, creditId: string, policy: DepositCreditPolicy,
|
||||
now: number): Promise<DepositCreditReleaseSubject> {
|
||||
const credit = await client.query(`SELECT credit_id::text,wallet_fingerprint,observation_id,beneficiary,
|
||||
amount_atomic_xmr::text,journal_id FROM wk_custody.monero_deposit_credit_events WHERE credit_id=$1`, [creditId]);
|
||||
if (credit.rowCount !== 1) throw new Error('Pending deposit credit not found');
|
||||
const row = credit.rows[0];
|
||||
if (await client.query('SELECT 1 FROM wk_custody.monero_deposit_release_events WHERE credit_id=$1', [creditId]).then(r => r.rowCount)) {
|
||||
throw new Error('Pending deposit credit was already released');
|
||||
}
|
||||
const observation = await readReviewSubject(client, row.wallet_fingerprint, row.observation_id, policy, now, true);
|
||||
if (observation.beneficiary !== row.beneficiary || observation.amountAtomicXmr !== row.amount_atomic_xmr) {
|
||||
throw new Error('Pending credit no longer matches the current observed output');
|
||||
}
|
||||
const holds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=$1 AND NOT EXISTS (
|
||||
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)
|
||||
ORDER BY h.observation_event_sequence LIMIT 1025`, [creditId]);
|
||||
if (holds.rows.length > 1024) throw new Error('Deposit reorg hold set exceeds review bound');
|
||||
return { ...observation, creditId, pendingJournalId: row.journal_id,
|
||||
unresolvedHolds: holds.rows.map(hold => ({ holdId: hold.hold_id,
|
||||
eventSequence: Number(hold.observation_event_sequence), eventType: hold.event_type })) };
|
||||
}
|
||||
|
||||
/** Return the current pending liability and unresolved reorg evidence for explicit reviewer approval. */
|
||||
export async function prepareDepositCreditReleaseReview(pool: Pool, creditId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseSubject> {
|
||||
validatePolicy(policy); uuid.parse(creditId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReleaseSubject(client, creditId, policy, now);
|
||||
await client.query('COMMIT'); return subject;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Canonical reviewer message for releasing a pending liability and resolving its included scoped holds. */
|
||||
export function depositCreditReleaseApprovalMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string,
|
||||
nonce: string, expiresAt: number): string {
|
||||
return canonicalReleaseMessage(releaseSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/** Explicit reviewer-authorized, idempotent move from pending to spendable available balance. Not HTTP-wired. */
|
||||
export async function releaseReviewedDepositCredit(pool: Pool, rawApproval: DepositCreditReleaseApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = releaseApprovalSchema.parse(rawApproval);
|
||||
const message = canonicalReleaseMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
|
||||
throw new Error('Deposit release review signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(message);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT release_id,credit_id::text,journal_id,reviewer_owner,review_nonce,
|
||||
review_signature,review_digest,review_subject,released_at FROM wk_custody.monero_deposit_release_events
|
||||
WHERE credit_id=$1`, [approval.subject.creditId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce ||
|
||||
saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit release was already committed under different reviewed terms');
|
||||
}
|
||||
const credit = await client.query(`SELECT beneficiary,amount_atomic_xmr::text FROM wk_custody.monero_deposit_credit_events
|
||||
WHERE credit_id=$1`, [saved.credit_id]);
|
||||
await client.query('COMMIT');
|
||||
return { creditId: saved.credit_id, releaseId: saved.release_id, journalId: saved.journal_id,
|
||||
beneficiary: credit.rows[0].beneficiary, amountAtomicXmr: credit.rows[0].amount_atomic_xmr, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit release review signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReleaseSubject(client, approval.subject.creditId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit release evidence changed after reviewer approval');
|
||||
const pendingAccount = `customer:${current.beneficiary}:deposit-pending:${current.creditId}`;
|
||||
const availableAccount = `customer:${current.beneficiary}:available`;
|
||||
const pending = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [pendingAccount]);
|
||||
if (pending.rowCount !== 1 || pending.rows[0].normal !== 'credit') {
|
||||
throw new Error('Pending deposit liability does not match reviewed release amount');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,\'credit\') ON CONFLICT(id) DO NOTHING', [availableAccount]);
|
||||
const available = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [availableAccount]);
|
||||
if (available.rows[0]?.normal !== 'credit') throw new Error('Available customer account normal mismatch');
|
||||
|
||||
const releaseId = randomUUID(), journalId = `monero-deposit-release:${current.creditId}`;
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)',
|
||||
[journalId, 'monero-deposit-release', now]);
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
|
||||
[journalId, pendingAccount, current.amountAtomicXmr, availableAccount]);
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_release_events(
|
||||
release_id,credit_id,journal_id,reviewer_owner,reviewer_public_key,review_nonce,review_expires_at,
|
||||
review_signature,review_subject,review_digest,released_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[releaseId, current.creditId, journalId, approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce,
|
||||
approval.expiresAt, approval.signature, JSON.stringify(current), reviewDigest, now]);
|
||||
for (const hold of current.unresolvedHolds) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
|
||||
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
|
||||
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','release-review',$7,$8,$9,$10,$11,$12)`,
|
||||
[randomUUID(), hold.holdId, current.creditId, releaseId, approval.reviewerOwner, now,
|
||||
approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature,
|
||||
JSON.stringify({ subject: current, holdId: hold.holdId }), reviewDigest]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { creditId: current.creditId, releaseId, journalId, beneficiary: current.beneficiary,
|
||||
amountAtomicXmr: current.amountAtomicXmr, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
async function readReorgResolutionSubject(client: PoolClient, creditId: string, holdId: string,
|
||||
policy: DepositCreditPolicy, now: number): Promise<DepositReorgResolutionSubject> {
|
||||
const row = await client.query(`SELECT h.hold_id::text,h.credit_id::text,h.wallet_fingerprint,h.observation_id,
|
||||
h.observation_event_sequence::text,h.event_type,c.release_id::text
|
||||
FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_release_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.hold_id=$1 AND h.credit_id=$2 AND NOT EXISTS(
|
||||
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`, [holdId, creditId]);
|
||||
if (row.rowCount !== 1) throw new Error('Unresolved released-deposit reorg hold not found');
|
||||
const currentObservation = await readReviewSubject(client, row.rows[0].wallet_fingerprint,
|
||||
row.rows[0].observation_id, policy, now, true);
|
||||
return resolutionSubjectSchema.parse({ policyVersion: 'monero-reorg-resolution-v1', creditId,
|
||||
releaseId: row.rows[0].release_id, holdId, walletFingerprint: row.rows[0].wallet_fingerprint,
|
||||
observationId: row.rows[0].observation_id, eventSequence: Number(row.rows[0].observation_event_sequence),
|
||||
eventType: row.rows[0].event_type, currentObservation });
|
||||
}
|
||||
|
||||
/** Prepare an exact current snapshot to resolve one scoped reorg hold after the output reappears. */
|
||||
export async function prepareDepositReorgResolution(pool: Pool, creditId: string, holdId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionSubject> {
|
||||
validatePolicy(policy); uuid.parse(creditId); uuid.parse(holdId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReorgResolutionSubject(client, creditId, holdId, policy, now);
|
||||
await client.query('COMMIT'); return subject;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export function depositReorgResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string,
|
||||
nonce: string, expiresAt: number): string {
|
||||
return canonicalResolutionMessage(resolutionSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/** Resolve a reorg hold only after the output is again present, unlocked, sufficiently confirmed, and signed off. */
|
||||
export async function resolveReleasedDepositReorgHold(pool: Pool, rawApproval: DepositReorgResolutionApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = resolutionApprovalSchema.parse(rawApproval);
|
||||
const message = canonicalResolutionMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
|
||||
throw new Error('Deposit reorg resolution signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(message), client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT resolution_id::text,review_nonce,review_signature,review_digest
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions WHERE hold_id=$1`, [approval.subject.holdId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.review_nonce !== approval.nonce || saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit reorg hold was already resolved under different reviewed terms');
|
||||
}
|
||||
await client.query('COMMIT'); return { resolutionId: saved.resolution_id, holdId: approval.subject.holdId, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit reorg resolution signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReorgResolutionSubject(client, approval.subject.creditId, approval.subject.holdId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit reorg evidence changed after review');
|
||||
const resolutionId = randomUUID();
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
|
||||
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
|
||||
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','post-release-review',$7,$8,$9,$10,$11,$12)`,
|
||||
[resolutionId, approval.subject.holdId, approval.subject.creditId, approval.subject.releaseId,
|
||||
approval.reviewerOwner, now, approval.reviewerPublicKey, approval.nonce, approval.expiresAt,
|
||||
approval.signature, JSON.stringify(approval.subject), reviewDigest]);
|
||||
await client.query('COMMIT'); return { resolutionId, holdId: approval.subject.holdId, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Append review holds for post-credit Monero reorg signals; this never reverses a journal or releases pending funds. */
|
||||
export async function reconcileDepositCreditReorgHolds(pool: Pool, walletFingerprint: string, now = Date.now()): Promise<number> {
|
||||
fingerprint.parse(walletFingerprint);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const reorgs = await client.query(`SELECT c.credit_id,c.observation_id,e.sequence,e.event_type
|
||||
FROM wk_custody.monero_deposit_credit_events c
|
||||
JOIN wk_custody.monero_observation_events e ON e.wallet_fingerprint=c.wallet_fingerprint
|
||||
AND e.observation_id=c.observation_id AND e.sequence>c.last_observation_event_sequence
|
||||
WHERE c.wallet_fingerprint=$1 AND e.event_type IN ('changed','not-seen','reappeared')
|
||||
AND NOT EXISTS (SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=c.credit_id AND h.observation_event_sequence=e.sequence)
|
||||
ORDER BY e.sequence LIMIT 1024`, [walletFingerprint]);
|
||||
for (const row of reorgs.rows) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_holds(
|
||||
hold_id,credit_id,wallet_fingerprint,observation_id,observation_event_sequence,event_type,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(credit_id,observation_event_sequence) DO NOTHING`,
|
||||
[randomUUID(), row.credit_id, walletFingerprint, row.observation_id, row.sequence, row.event_type, now]);
|
||||
}
|
||||
await client.query('COMMIT'); return reorgs.rowCount ?? 0;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { moneroDepositAddressRequestSchema, moneroDepositAddressMessage } from '../../shared/monero-deposit.ts';
|
||||
import { createOrRecoverMoneroDepositSubaddress, reserveMoneroDepositAllocation } from './monero-deposit-allocation-store.ts';
|
||||
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
export type MainnetEnrollmentContext = {
|
||||
custodianId: string;
|
||||
chainId: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
accountIndex: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type MoneroDepositAddressResult = {
|
||||
network: 'mainnet';
|
||||
allocationId: string;
|
||||
walletGenerationId: string;
|
||||
addressIndex: number;
|
||||
address: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify an expiring customer-wallet signature and durably allocate that customer's one mainnet
|
||||
* subaddress. This only enrolls an address; it never credits a balance, starts a sale, or moves XMR.
|
||||
* Do not expose a public route until the matching credit, withdrawal, and recovery paths are ready.
|
||||
*/
|
||||
export async function enrollMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
|
||||
context: MainnetEnrollmentContext, rawRequest: unknown): Promise<MoneroDepositAddressResult> {
|
||||
const request = moneroDepositAddressRequestSchema.parse(rawRequest);
|
||||
if (request.network !== 'wikideck-randomx-mainnet-1' || request.custodianId !== context.custodianId ||
|
||||
request.chainId !== context.chainId || request.walletGenerationId !== context.walletGenerationId ||
|
||||
request.walletFingerprint !== context.walletFingerprint || request.accountIndex !== context.accountIndex) {
|
||||
throw new Error('Deposit address request does not match this mainnet custodian and wallet generation');
|
||||
}
|
||||
if (address(request.publicKey) !== request.actor) throw new Error('Deposit address signer does not own the requested beneficiary');
|
||||
const { signature, ...unsigned } = request;
|
||||
const message = moneroDepositAddressMessage(unsigned);
|
||||
if (!verifySignature(request.publicKey, request.actor, message, signature)) {
|
||||
throw new Error('Deposit address request signature is invalid');
|
||||
}
|
||||
const now = context.now?.() ?? Date.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now ||
|
||||
request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
|
||||
throw new Error('Deposit address request is expired or outside the allowed window');
|
||||
}
|
||||
|
||||
const { allocation } = await reserveMoneroDepositAllocation(pool, {
|
||||
requestId: request.requestId,
|
||||
custodianId: context.custodianId,
|
||||
marketNetwork: 'wikideck-randomx-mainnet-1',
|
||||
marketChainId: context.chainId,
|
||||
beneficiary: request.actor,
|
||||
walletGenerationId: context.walletGenerationId,
|
||||
walletFingerprint: context.walletFingerprint,
|
||||
accountIndex: context.accountIndex,
|
||||
reservedAt: now,
|
||||
});
|
||||
const registered = await createOrRecoverMoneroDepositSubaddress(pool, rpc, allocation.allocationId,
|
||||
context.walletGenerationId, now);
|
||||
if (registered.allocation.beneficiary !== request.actor || registered.allocation.custodianId !== context.custodianId ||
|
||||
registered.allocation.marketChainId !== context.chainId || registered.allocation.moneroNetwork !== 'mainnet' ||
|
||||
registered.allocation.status !== 'registered' || !registered.allocation.moneroAddress) {
|
||||
throw new Error('Registered Monero deposit address does not match the signed beneficiary allocation');
|
||||
}
|
||||
return { network: 'mainnet', allocationId: registered.allocation.allocationId,
|
||||
walletGenerationId: registered.allocation.walletGenerationId,
|
||||
addressIndex: registered.allocation.addressIndex, address: registered.allocation.moneroAddress,
|
||||
replayed: registered.replayed };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { DepositCreditPolicy } from './monero-deposit-credit.ts';
|
||||
|
||||
const MAX_REVIEW_CONFIG_BYTES = 4096;
|
||||
const MAX_APPROVAL_BYTES = 64 * 1024;
|
||||
const reviewerPolicySchema = z.object({
|
||||
approvedReviewerOwners: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(8),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000),
|
||||
maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (new Set(value.approvedReviewerOwners).size !== value.approvedReviewerOwners.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Reviewer owner allowlist must not contain duplicates' });
|
||||
}
|
||||
});
|
||||
|
||||
async function readPrivateJson(path: string, maximumBytes: number): Promise<unknown> {
|
||||
if (process.platform === 'win32') throw new Error('Owner-only ACL verification is unavailable on Windows');
|
||||
if (!isAbsolute(path)) throw new Error('Review files require an absolute path');
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.size < 1 || info.size > maximumBytes) {
|
||||
throw new Error('Review file must be a bounded regular file');
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error('Review file permissions must be owner-only');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0, position = 0;
|
||||
while (total <= maximumBytes) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(4096, maximumBytes + 1 - total));
|
||||
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
|
||||
if (bytesRead === 0) break;
|
||||
total += bytesRead; position += bytesRead;
|
||||
if (total > maximumBytes) throw new Error('Review file exceeds byte limit');
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
||||
return JSON.parse(text) as unknown;
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
/** Public keys/policy only; no reviewer private key is loaded by the application. */
|
||||
export async function loadMoneroDepositReviewPolicy(env: NodeJS.ProcessEnv = process.env): Promise<DepositCreditPolicy> {
|
||||
const path = env.WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE;
|
||||
if (!path) throw new Error('Monero deposit review policy file is required');
|
||||
try { return reviewerPolicySchema.parse(await readPrivateJson(path, MAX_REVIEW_CONFIG_BYTES)); }
|
||||
catch { throw new Error('Invalid or inaccessible protected Monero deposit review policy'); }
|
||||
}
|
||||
|
||||
/** Signed approvals are bearer authorizations, so read only owner-protected regular files. */
|
||||
export async function loadMoneroDepositReviewApproval(path: string): Promise<unknown> {
|
||||
try { return await readPrivateJson(path, MAX_APPROVAL_BYTES); }
|
||||
catch { throw new Error('Invalid or inaccessible protected Monero deposit review approval'); }
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { z } from 'zod';
|
||||
import type { MoneroFixtureTransport } from './monero-observer.ts';
|
||||
|
||||
const MAX_TLS_FILE_BYTES = 64 * 1024;
|
||||
const MAX_RPC_REQUEST_BYTES = 64 * 1024;
|
||||
const MAX_RPC_RESPONSE_BYTES = 1024 * 1024;
|
||||
const transportConfigSchema = z.object({
|
||||
url: z.string().url().max(2048),
|
||||
clientCertificateFile: z.string().min(1).max(4096),
|
||||
clientPrivateKeyFile: z.string().min(1).max(4096),
|
||||
serverCaFile: z.string().min(1).max(4096),
|
||||
}).strict();
|
||||
|
||||
export async function loadMoneroMtlsTransportConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
const configPath = env.WIKIDECK_MONERO_MTLS_CONFIG_FILE;
|
||||
if (!configPath) throw new Error('Monero mTLS transport config file is required');
|
||||
try {
|
||||
const info = await lstat(configPath);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) throw new Error();
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
||||
const parsed = transportConfigSchema.parse(JSON.parse(await readFile(configPath, 'utf8')));
|
||||
const url = new URL(parsed.url);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') throw new Error();
|
||||
for (const path of [parsed.clientCertificateFile, parsed.clientPrivateKeyFile, parsed.serverCaFile]) {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) throw new Error();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error('Invalid or inaccessible Monero mTLS configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async function readProtectedPem(path: string): Promise<Buffer> {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) {
|
||||
throw new Error('Monero RPC TLS material must be a bounded regular file');
|
||||
}
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
|
||||
throw new Error('Monero RPC TLS material permissions are too broad');
|
||||
}
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTPS mutual-TLS transport for a private Wallet-RPC gateway. This replaces
|
||||
* Monero v0.18.5.4's legacy HTTP Digest authentication without implementing MD5 in the app.
|
||||
* The gateway is responsible for certificate authorization and a strict Wallet-RPC method
|
||||
* allowlist; this transport never adds Basic/Digest Authorization headers or follows redirects.
|
||||
*/
|
||||
export async function loadMoneroMtlsTransport(configInput: unknown): Promise<MoneroFixtureTransport> {
|
||||
const config = transportConfigSchema.parse(configInput);
|
||||
const url = new URL(config.url);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') {
|
||||
throw new Error('Monero mTLS gateway must be a bare HTTPS /json_rpc endpoint');
|
||||
}
|
||||
const [cert, key, ca] = await Promise.all([
|
||||
readProtectedPem(config.clientCertificateFile),
|
||||
readProtectedPem(config.clientPrivateKeyFile),
|
||||
readProtectedPem(config.serverCaFile),
|
||||
]);
|
||||
|
||||
return async (endpoint, init) => {
|
||||
const target = new URL(endpoint);
|
||||
if (target.href !== url.href || init.method !== 'POST' || init.redirect !== 'error' || typeof init.body !== 'string') {
|
||||
throw new Error('Monero mTLS request does not match the pinned RPC endpoint contract');
|
||||
}
|
||||
const body = Buffer.from(init.body, 'utf8');
|
||||
if (body.byteLength > MAX_RPC_REQUEST_BYTES) throw new Error('Monero RPC request exceeds size limit');
|
||||
if (new Headers(init.headers).has('authorization')) throw new Error('HTTP Authorization headers are not supported; use mTLS');
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const request = httpsRequest({
|
||||
protocol: 'https:', hostname: url.hostname, port: url.port || 443, path: url.pathname,
|
||||
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json',
|
||||
'content-length': body.byteLength }, cert, key, ca, rejectUnauthorized: true,
|
||||
...(isIP(url.hostname) ? {} : { servername: url.hostname }), minVersion: 'TLSv1.3',
|
||||
}, response => {
|
||||
if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300 ||
|
||||
(response.headers.location !== undefined) ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '')) {
|
||||
response.destroy(); reject(new Error('Monero mTLS gateway response rejected')); return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
size += chunk.byteLength;
|
||||
if (size > MAX_RPC_RESPONSE_BYTES) {
|
||||
response.destroy(new Error('Monero mTLS gateway response exceeds size limit'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on('error', () => reject(new Error('Monero mTLS gateway response failed')));
|
||||
response.on('end', () => resolve(new Response(Buffer.concat(chunks), {
|
||||
status: response.statusCode,
|
||||
headers: { 'content-type': response.headers['content-type'] ?? 'application/json' },
|
||||
})));
|
||||
});
|
||||
request.setTimeout(5000, () => request.destroy(new Error('Monero mTLS request deadline exceeded')));
|
||||
const signal = init.signal;
|
||||
if (signal?.aborted) { request.destroy(new Error('Monero mTLS request aborted')); return; }
|
||||
signal?.addEventListener('abort', () => request.destroy(new Error('Monero mTLS request aborted')), { once: true });
|
||||
request.on('error', () => reject(new Error('Monero mTLS request failed')));
|
||||
request.end(body);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts';
|
||||
|
||||
const walletFingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const scanInputSchema = z.object({
|
||||
scanId: z.string().uuid(),
|
||||
walletFingerprint: walletFingerprintSchema,
|
||||
expectedScanHeight: z.number().int().nonnegative().safe(),
|
||||
scannedAt: z.number().int().nonnegative().safe(),
|
||||
page: z.object({
|
||||
network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(),
|
||||
fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(),
|
||||
primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
const observationSchema = z.object({
|
||||
observationId: z.string().min(1).max(160), transactionId: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
address: z.string().length(95),
|
||||
amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
|
||||
accountIndex: z.number().int().nonnegative().safe(), addressIndex: z.number().int().nonnegative().safe(),
|
||||
outputPublicKey: z.string().regex(/^[a-f0-9]{64}$/), globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
}).strict();
|
||||
const MAX_SCAN_RANGE = 100_000;
|
||||
const MAX_OVERLAP = 10_000;
|
||||
|
||||
export type PersistObservationScanInput = {
|
||||
scanId: string;
|
||||
walletFingerprint: string;
|
||||
expectedScanHeight: number;
|
||||
scannedAt: number;
|
||||
page: MoneroObservationPage;
|
||||
};
|
||||
export type PersistObservationScanResult = {
|
||||
scanId: string;
|
||||
scanHeight: number;
|
||||
observed: number;
|
||||
changed: number;
|
||||
reappeared: number;
|
||||
notSeen: number;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
function rowDigest(observation: MoneroIncomingObservation): string {
|
||||
return digest([observation.observationId, observation.transactionId, observation.amountAtomicXmr,
|
||||
observation.height, observation.timestamp, observation.unlockTime,
|
||||
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.locked, observation.doubleSpendSeen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one complete wallet-RPC scan page as observation-only state. This function does not
|
||||
* create customer credits or financial journals. A missing transfer produces `not-seen`, not
|
||||
* an automatic debit/reversal; operators must reconcile Monero reorgs before any later credit design.
|
||||
*/
|
||||
export async function persistMoneroObservationScan(pool: Pool, input: PersistObservationScanInput): Promise<PersistObservationScanResult> {
|
||||
const parsed = scanInputSchema.parse(input);
|
||||
const page = parsed.page;
|
||||
if (page.fromHeight > page.throughHeight || page.throughHeight > page.walletHeight ||
|
||||
page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP ||
|
||||
page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) {
|
||||
throw new Error('Monero observation scan range is not contiguous with its durable cursor');
|
||||
}
|
||||
await validateXmrAddress(page.primaryAddress);
|
||||
const observations = page.incoming.map(value => observationSchema.parse(value));
|
||||
const ids = new Set<string>();
|
||||
for (const observation of observations) {
|
||||
await validateXmrAddress(observation.address);
|
||||
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
|
||||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
|
||||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
|
||||
(observation.locked && observation.confirmations <= 0)) throw new Error('Monero observation is inconsistent with its scan page');
|
||||
if (ids.has(observation.observationId)) throw new Error('Duplicate Monero observation identity');
|
||||
ids.add(observation.observationId);
|
||||
}
|
||||
const pageDigest = digest([parsed.walletFingerprint, page.fromHeight, page.throughHeight,
|
||||
observations.map(row => [row.observationId, rowDigest(row)]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('SET LOCAL statement_timeout=\'10s\'; SET LOCAL lock_timeout=\'3s\'; SET LOCAL idle_in_transaction_session_timeout=\'10s\'' );
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const priorScan = await client.query(`SELECT payload_digest,through_height FROM wk_custody.monero_observation_scans
|
||||
WHERE scan_id=$1`, [parsed.scanId]);
|
||||
if (priorScan.rowCount) {
|
||||
if (priorScan.rows[0].payload_digest !== pageDigest) throw new Error('Monero scan ID was reused with different observations');
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Number(priorScan.rows[0].through_height), observed: observations.length,
|
||||
changed: 0, reappeared: 0, notSeen: 0, replayed: true };
|
||||
}
|
||||
let state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state
|
||||
WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]);
|
||||
if (!state.rowCount) {
|
||||
if (parsed.expectedScanHeight !== 0) throw new Error('First Monero observation scan must start from wallet history height zero');
|
||||
await client.query(`INSERT INTO wk_custody.monero_observer_state(wallet_fingerprint,network,scan_height,updated_at)
|
||||
VALUES($1,'mainnet',0,$2)`, [parsed.walletFingerprint, parsed.scannedAt]);
|
||||
state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state
|
||||
WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]);
|
||||
}
|
||||
if (state.rows[0].network !== 'mainnet' || Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) {
|
||||
throw new Error('Monero observer cursor changed; reread durable state before retry');
|
||||
}
|
||||
|
||||
let changed = 0, reappeared = 0;
|
||||
for (const observation of observations) {
|
||||
const current = await client.query(`SELECT payload_digest,currently_present,first_observed_at
|
||||
FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletFingerprint, observation.observationId]);
|
||||
const payloadDigest = rowDigest(observation);
|
||||
const eventType = !current.rowCount ? 'seen' : !current.rows[0].currently_present ? 'reappeared'
|
||||
: current.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
|
||||
await client.query(`INSERT INTO wk_custody.monero_transfer_observations(
|
||||
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,output_public_key,global_output_index,
|
||||
amount_atomic_xmr,block_height,
|
||||
wallet_height,confirmations,timestamp,unlock_time,locked,double_spend_seen,payload_digest,
|
||||
currently_present,last_seen_scan_id,first_observed_at,last_observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$19,$19)
|
||||
ON CONFLICT(wallet_fingerprint,observation_id) DO UPDATE SET
|
||||
monero_address=excluded.monero_address,
|
||||
output_public_key=excluded.output_public_key,global_output_index=excluded.global_output_index,
|
||||
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
|
||||
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
|
||||
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
|
||||
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
|
||||
[parsed.walletFingerprint, observation.observationId, observation.transactionId, observation.address,
|
||||
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
|
||||
observation.timestamp, observation.unlockTime, observation.locked, observation.doubleSpendSeen, payloadDigest, parsed.scanId,
|
||||
current.rowCount ? Number(current.rows[0].first_observed_at) : parsed.scannedAt]);
|
||||
if (eventType) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_observation_events
|
||||
(wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletFingerprint, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]);
|
||||
if (eventType === 'changed') changed++;
|
||||
if (eventType === 'reappeared') reappeared++;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3
|
||||
AND last_seen_scan_id<>$4 FOR UPDATE`, [parsed.walletFingerprint, page.fromHeight, page.throughHeight, parsed.scanId]);
|
||||
for (const row of missing.rows) {
|
||||
await client.query(`UPDATE wk_custody.monero_transfer_observations SET currently_present=false,last_seen_scan_id=$3
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [parsed.walletFingerprint, row.observation_id, parsed.scanId]);
|
||||
await client.query(`INSERT INTO wk_custody.monero_observation_events
|
||||
(wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletFingerprint, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]);
|
||||
}
|
||||
await client.query(`INSERT INTO wk_custody.monero_observation_scans
|
||||
(scan_id,wallet_fingerprint,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`,
|
||||
[parsed.scanId, parsed.walletFingerprint, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]);
|
||||
await client.query(`UPDATE wk_custody.monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3
|
||||
WHERE wallet_fingerprint=$1`, [parsed.walletFingerprint, page.throughHeight, parsed.scannedAt]);
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight),
|
||||
observed: observations.length, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { MONERO_OBSERVER_WORKER_LOCK } from './postgres-schema.ts';
|
||||
import { persistMoneroObservationScan, type PersistObservationScanResult } from './monero-observation-store.ts';
|
||||
import type { MoneroObservationScanner } from './monero-observer.ts';
|
||||
|
||||
const fingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
export const MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE = 100_000;
|
||||
export const MONERO_OBSERVER_OVERLAP_BLOCKS = 1_000;
|
||||
export const MONERO_OBSERVER_MAX_PAGES_PER_BATCH = 4;
|
||||
|
||||
export type MoneroObserverBatchResult = {
|
||||
walletFingerprint: string;
|
||||
pages: PersistObservationScanResult[];
|
||||
scanHeight: number;
|
||||
walletHeight: number;
|
||||
caughtUp: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform a small bounded catch-up batch. The database observation role supplies the durable cursor;
|
||||
* the scanner never receives a spend or address-allocation capability.
|
||||
*/
|
||||
export async function runMoneroObserverBatch(pool: Pool, walletFingerprint: string, scanner: MoneroObservationScanner,
|
||||
options: { now?: () => number; maxPages?: number } = {}): Promise<MoneroObserverBatchResult> {
|
||||
fingerprintSchema.parse(walletFingerprint);
|
||||
const maxPages = options.maxPages ?? MONERO_OBSERVER_MAX_PAGES_PER_BATCH;
|
||||
if (!Number.isSafeInteger(maxPages) || maxPages < 1 || maxPages > MONERO_OBSERVER_MAX_PAGES_PER_BATCH) {
|
||||
throw new Error('Invalid Monero observer page budget');
|
||||
}
|
||||
const pages: PersistObservationScanResult[] = [];
|
||||
let walletHeight = 0, scanHeight = 0;
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber++) {
|
||||
const state = await pool.query<{ scan_height: string }>(
|
||||
'SELECT scan_height::text FROM wk_custody.monero_observer_state WHERE wallet_fingerprint=$1', [walletFingerprint]);
|
||||
const expectedScanHeight = state.rowCount ? Number(state.rows[0].scan_height) : 0;
|
||||
if (!Number.isSafeInteger(expectedScanHeight) || expectedScanHeight < 0) throw new Error('Invalid durable Monero observer cursor');
|
||||
const fromHeight = Math.max(0, expectedScanHeight - MONERO_OBSERVER_OVERLAP_BLOCKS);
|
||||
const page = await scanner.scanIncoming(fromHeight, MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE);
|
||||
if (page.network !== 'mainnet' || page.fromHeight !== fromHeight || page.throughHeight < expectedScanHeight ||
|
||||
page.throughHeight - page.fromHeight >= MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE) {
|
||||
throw new Error('Monero observer returned a page outside the worker’s bounded cursor window');
|
||||
}
|
||||
const result = await persistMoneroObservationScan(pool, { scanId: randomUUID(), walletFingerprint,
|
||||
expectedScanHeight, scannedAt: (options.now ?? Date.now)(), page });
|
||||
pages.push(result); scanHeight = result.scanHeight; walletHeight = page.walletHeight;
|
||||
if (page.throughHeight >= page.walletHeight) break;
|
||||
}
|
||||
return { walletFingerprint, pages, scanHeight, walletHeight, caughtUp: scanHeight >= walletHeight };
|
||||
}
|
||||
|
||||
export type ObserverWorkerOptions = {
|
||||
pollIntervalMs?: number;
|
||||
onBatch?: (result: MoneroObserverBatchResult) => void;
|
||||
onFailure?: () => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.resolve();
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(done, ms); timer.unref();
|
||||
function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit opt-in worker loop. A PostgreSQL session advisory lock prevents duplicate scanner processes;
|
||||
* scan failures are redacted and retried with bounded backoff. No journal/credit/release APIs are called.
|
||||
*/
|
||||
export async function runMoneroObserverWorker(pool: Pool, walletFingerprint: string, scanner: MoneroObservationScanner,
|
||||
options: ObserverWorkerOptions = {}): Promise<void> {
|
||||
fingerprintSchema.parse(walletFingerprint);
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 30_000;
|
||||
if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1_000 || pollIntervalMs > 15 * 60_000) {
|
||||
throw new Error('Invalid Monero observer polling interval');
|
||||
}
|
||||
const leader = await pool.connect();
|
||||
let acquired = false;
|
||||
let retryDelay = pollIntervalMs;
|
||||
try {
|
||||
const lock = await leader.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1::bigint) AS locked', [MONERO_OBSERVER_WORKER_LOCK]);
|
||||
acquired = lock.rows[0]?.locked === true;
|
||||
if (!acquired) throw new Error('A Monero observer worker already holds the database lock');
|
||||
while (!options.signal?.aborted) {
|
||||
try {
|
||||
const result = await runMoneroObserverBatch(pool, walletFingerprint, scanner);
|
||||
options.onBatch?.(result);
|
||||
retryDelay = pollIntervalMs;
|
||||
if (!result.caughtUp) continue;
|
||||
} catch {
|
||||
options.onFailure?.();
|
||||
retryDelay = Math.min(Math.max(pollIntervalMs, retryDelay * 2), 5 * 60_000);
|
||||
}
|
||||
await sleep(retryDelay, options.signal);
|
||||
}
|
||||
} finally {
|
||||
if (acquired) await leader.query('SELECT pg_advisory_unlock($1::bigint)', [MONERO_OBSERVER_WORKER_LOCK]).catch(() => {});
|
||||
leader.release();
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import { validateXmrAddress, validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const MAX_CONFIG_BYTES = 4096;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const RPC_TIMEOUT_MS = 5000;
|
||||
const MAX_TRANSFERS = 4096;
|
||||
const MAX_SCAN_RANGE = 100_000;
|
||||
const configSchema = z.object({
|
||||
network: z.literal('mainnet'),
|
||||
url: z.string().min(1).max(2048),
|
||||
}).strict();
|
||||
const rpcEnvelopeSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()).optional(),
|
||||
error: z.object({ code: z.string().regex(/^-?(0|[1-9]\d*)$/).transform(Number)
|
||||
.refine(Number.isSafeInteger), message: z.string().max(1024) }).passthrough().optional(),
|
||||
}).strict().refine(value => (value.result === undefined) !== (value.error === undefined));
|
||||
const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/);
|
||||
const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough();
|
||||
const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough();
|
||||
const validateAddressResultSchema = z.object({ valid: z.literal(true), integrated: z.literal(false),
|
||||
nettype: z.literal('mainnet'), subaddress: z.boolean(), openalias_address: z.literal('') }).passthrough();
|
||||
const subaddressEntrySchema = z.object({ address: z.string().length(95), label: z.string().max(128),
|
||||
address_index: integerString, used: z.boolean() }).passthrough();
|
||||
const subaddressReadResultSchema = z.object({ address: z.string().length(95),
|
||||
addresses: z.array(subaddressEntrySchema).max(1), status: z.literal('OK') }).passthrough();
|
||||
const subaddressCreateResultSchema = z.object({ address: z.string().length(95), address_index: integerString,
|
||||
address_indices: z.array(integerString).max(1).optional(), addresses: z.array(z.string().length(95)).max(1).optional(),
|
||||
status: z.literal('OK') }).passthrough();
|
||||
const incomingTransferSchema = z.object({
|
||||
txid: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
address: z.string().length(95),
|
||||
amount: integerString,
|
||||
height: integerString,
|
||||
timestamp: integerString,
|
||||
unlock_time: integerString,
|
||||
locked: z.boolean(),
|
||||
double_spend_seen: z.boolean(),
|
||||
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
|
||||
}).passthrough();
|
||||
const transfersResultSchema = z.object({
|
||||
in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]),
|
||||
status: z.literal('OK'),
|
||||
}).passthrough();
|
||||
const incomingOutputSchema = z.object({
|
||||
amount: integerString,
|
||||
global_index: integerString,
|
||||
tx_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
|
||||
unlocked: z.boolean(),
|
||||
block_height: integerString,
|
||||
pubkey: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
}).passthrough();
|
||||
const incomingOutputsResultSchema = z.object({
|
||||
// v0.18.5.4 omits default-valued empty arrays/status from JSON-RPC serialization.
|
||||
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS).default([]),
|
||||
status: z.literal('OK').optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type MoneroIncomingObservation = {
|
||||
observationId: string;
|
||||
transactionId: string;
|
||||
address: string;
|
||||
amountAtomicXmr: string;
|
||||
height: number;
|
||||
confirmations: number;
|
||||
timestamp: number;
|
||||
unlockTime: string;
|
||||
accountIndex: number;
|
||||
addressIndex: number;
|
||||
outputPublicKey: string;
|
||||
globalOutputIndex: number;
|
||||
locked: boolean;
|
||||
doubleSpendSeen: boolean;
|
||||
};
|
||||
export type MoneroObservationPage = {
|
||||
network: 'mainnet';
|
||||
walletHeight: number;
|
||||
fromHeight: number;
|
||||
throughHeight: number;
|
||||
primaryAddress: string;
|
||||
incoming: MoneroIncomingObservation[];
|
||||
};
|
||||
export type MoneroObservationScanner = {
|
||||
scanIncoming(fromHeight: number, maxBlocks?: number): Promise<MoneroObservationPage>;
|
||||
};
|
||||
export type MoneroFixtureTransport = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
class MoneroWalletRpcError extends Error {
|
||||
readonly rpcCode: number;
|
||||
readonly rpcMessage: string;
|
||||
constructor(rpcCode: number, rpcMessage: string) {
|
||||
super(`Monero wallet-RPC method failed (${rpcCode})`);
|
||||
this.name = 'MoneroWalletRpcError';
|
||||
this.rpcCode = rpcCode;
|
||||
this.rpcMessage = rpcMessage;
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: string, maximum: bigint, name: string): number {
|
||||
const parsed = BigInt(value);
|
||||
if (parsed > maximum || parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Invalid Monero ${name}`);
|
||||
return Number(parsed);
|
||||
}
|
||||
|
||||
/** JSON.parse rounds uint64 amounts. Convert JSON numeric tokens to strings before parsing,
|
||||
* preserving exact wallet-RPC integer text so all monetary values can be checked as bigint. */
|
||||
function parseLosslessJson(text: string): unknown {
|
||||
let output = '', inString = false, escaped = false;
|
||||
for (let i = 0; i < text.length;) {
|
||||
const char = text[i];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === '"') inString = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') { inString = true; output += char; i++; continue; }
|
||||
if (char === '-' || (char >= '0' && char <= '9')) {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
||||
if (!match) throw new Error('Invalid JSON numeric token');
|
||||
output += JSON.stringify(match[0]); i += match[0].length; continue;
|
||||
}
|
||||
output += char; i++;
|
||||
}
|
||||
return JSON.parse(output) as unknown;
|
||||
}
|
||||
|
||||
function validateEndpoint(value: string): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { throw new Error('Invalid Monero wallet-RPC endpoint'); }
|
||||
if (url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') {
|
||||
throw new Error('Monero wallet-RPC endpoint must be a bare /json_rpc URL');
|
||||
}
|
||||
const loopback = url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
||||
throw new Error('Wallet RPC requires HTTPS, except loopback-only HTTP');
|
||||
}
|
||||
if (!url.port && !['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid Monero wallet-RPC port');
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
if (env.WIKIDECK_MONERO_OBSERVER !== '1') throw new Error('Monero observer is disabled');
|
||||
const path = env.WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE;
|
||||
if (!path) throw new Error('Monero wallet-RPC config file is required');
|
||||
try {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error();
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
||||
const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
||||
validateEndpoint(parsed.url);
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error('Invalid or inaccessible protected Monero wallet-RPC configuration');
|
||||
}
|
||||
}
|
||||
|
||||
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
|
||||
/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */
|
||||
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address')[]) {
|
||||
const endpoint = validateEndpoint(config.url);
|
||||
// Removed Basic auth header
|
||||
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
|
||||
if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client');
|
||||
const id = randomUUID(), controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
|
||||
const abort = new Promise<never>((_, reject) => controller.signal.addEventListener('abort',
|
||||
() => reject(new Error('Monero wallet-RPC deadline exceeded')), { once: true }));
|
||||
const operation = (async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
|
||||
} catch { throw new Error('Monero wallet-RPC request failed'); }
|
||||
if (!response.ok || response.redirected || !response.body ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
||||
await response.body?.cancel(); throw new Error('Monero wallet-RPC response rejected');
|
||||
}
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) {
|
||||
await response.body.cancel(); throw new Error('Monero wallet-RPC response exceeds size limit');
|
||||
}
|
||||
const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read(); if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error('Monero wallet-RPC response exceeds size limit');
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
let raw: unknown;
|
||||
try { raw = parseLosslessJson(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
|
||||
catch { throw new Error('Monero wallet-RPC returned malformed JSON'); }
|
||||
const envelope = rpcEnvelopeSchema.parse(raw);
|
||||
if (envelope.id !== id) throw new Error('Monero wallet-RPC response ID mismatch');
|
||||
if (envelope.error) throw new MoneroWalletRpcError(envelope.error.code, envelope.error.message);
|
||||
return envelope.result!;
|
||||
})();
|
||||
try { return await Promise.race([operation, abort]); }
|
||||
finally { clearTimeout(timeout); }
|
||||
}
|
||||
return rpc;
|
||||
}
|
||||
|
||||
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
|
||||
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
|
||||
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers', 'incoming_transfers']);
|
||||
return {
|
||||
async verifyMainnetWallet(): Promise<string> {
|
||||
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
||||
return validateXmrAddress(result.address);
|
||||
},
|
||||
async scanIncoming(fromHeight: number, maxBlocks = MAX_SCAN_RANGE): Promise<MoneroObservationPage> {
|
||||
if (!Number.isSafeInteger(fromHeight) || fromHeight < 0 || !Number.isSafeInteger(maxBlocks) ||
|
||||
maxBlocks < 1 || maxBlocks > MAX_SCAN_RANGE) throw new Error('Invalid Monero scan range');
|
||||
const [heightRaw, primaryAddress] = await Promise.all([
|
||||
rpc('get_height'), this.verifyMainnetWallet(),
|
||||
]);
|
||||
const walletHeight = integer(heightResultSchema.parse(heightRaw).height, BigInt(Number.MAX_SAFE_INTEGER), 'height');
|
||||
if (fromHeight > walletHeight) throw new Error('Monero observer cursor is ahead of wallet height');
|
||||
const throughHeight = Math.min(walletHeight, fromHeight + maxBlocks - 1);
|
||||
const transferRaw = transfersResultSchema.parse(await rpc('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: fromHeight, max_height: throughHeight,
|
||||
}));
|
||||
if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor');
|
||||
const transferMetadata = new Map<string, z.infer<typeof incomingTransferSchema>>();
|
||||
const keyForIndex = (txid: string, major: number, minor: number) => `${txid}:${major}:${minor}`;
|
||||
for (const item of transferRaw.in) {
|
||||
const key = keyForIndex(item.txid, integer(item.subaddr_index.major, 0xffffffffn, 'account index'),
|
||||
integer(item.subaddr_index.minor, 0xffffffffn, 'address index'));
|
||||
if (transferMetadata.has(key)) throw new Error('Duplicate incoming Monero transaction metadata');
|
||||
transferMetadata.set(key, item);
|
||||
}
|
||||
|
||||
// get_transfers groups amounts by transaction/subaddress. incoming_transfers exposes each
|
||||
// owned output and its public key, which is the stable per-output identity needed for credit idempotency.
|
||||
const requestedIndices = new Map<number, Set<number>>();
|
||||
for (const item of transferRaw.in) {
|
||||
const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index');
|
||||
const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index');
|
||||
const indices = requestedIndices.get(major) ?? new Set<number>();
|
||||
indices.add(minor); requestedIndices.set(major, indices);
|
||||
}
|
||||
const incoming: MoneroIncomingObservation[] = [];
|
||||
const seenOutputKeys = new Set<string>();
|
||||
for (const [major, indices] of requestedIndices) {
|
||||
const minors = [...indices];
|
||||
for (let offset = 0; offset < minors.length; offset += 64) {
|
||||
const selected = minors.slice(offset, offset + 64);
|
||||
const outputRaw = incomingOutputsResultSchema.parse(await rpc('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: major, subaddr_indices: selected,
|
||||
}));
|
||||
if (outputRaw.transfers.length >= MAX_TRANSFERS) {
|
||||
throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
||||
}
|
||||
for (const output of outputRaw.transfers) {
|
||||
const outputMajor = integer(output.subaddr_index.major, 0xffffffffn, 'account index');
|
||||
const outputMinor = integer(output.subaddr_index.minor, 0xffffffffn, 'address index');
|
||||
if (outputMajor !== major || !indices.has(outputMinor)) throw new Error('Wallet returned an output outside requested subaddresses');
|
||||
const metadata = transferMetadata.get(keyForIndex(output.tx_hash, outputMajor, outputMinor));
|
||||
if (!metadata) continue;
|
||||
const height = integer(output.block_height, BigInt(Number.MAX_SAFE_INTEGER), 'output height');
|
||||
const expectedHeight = integer(metadata.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
|
||||
if (height !== expectedHeight || height < fromHeight || height > throughHeight) continue;
|
||||
const amount = BigInt(output.amount);
|
||||
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero output amount');
|
||||
const globalOutputIndex = integer(output.global_index, BigInt(Number.MAX_SAFE_INTEGER), 'global output index');
|
||||
const outputPublicKey = output.pubkey;
|
||||
if (seenOutputKeys.has(outputPublicKey)) throw new Error('Duplicate Monero wallet output public key');
|
||||
if (incoming.length >= MAX_TRANSFERS) throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
||||
seenOutputKeys.add(outputPublicKey);
|
||||
incoming.push({ observationId: `${output.tx_hash}:${outputPublicKey}`, transactionId: output.tx_hash,
|
||||
address: metadata.address, amountAtomicXmr: amount.toString(), height,
|
||||
confirmations: walletHeight - height + 1,
|
||||
timestamp: integer(metadata.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
|
||||
unlockTime: metadata.unlock_time, accountIndex: outputMajor, addressIndex: outputMinor,
|
||||
outputPublicKey, globalOutputIndex, locked: !output.unlocked, doubleSpendSeen: metadata.double_spend_seen });
|
||||
}
|
||||
}
|
||||
}
|
||||
const totals = new Map<string, bigint>();
|
||||
for (const output of incoming) {
|
||||
const key = keyForIndex(output.transactionId, output.accountIndex, output.addressIndex);
|
||||
totals.set(key, (totals.get(key) ?? 0n) + BigInt(output.amountAtomicXmr));
|
||||
}
|
||||
for (const [key, metadata] of transferMetadata) {
|
||||
if (totals.get(key) !== BigInt(metadata.amount)) throw new Error('Wallet transaction amount does not match its owned output set');
|
||||
await validateXmrAddress(metadata.address);
|
||||
}
|
||||
return { network: 'mainnet', walletHeight, fromHeight, throughHeight, primaryAddress, incoming };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MoneroDepositAddressRpc = {
|
||||
verifyWallet(): Promise<string>;
|
||||
getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>;
|
||||
createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>;
|
||||
};
|
||||
|
||||
/** Stable, non-secret identity of the wallet generation, bound to its verified mainnet primary address. */
|
||||
export async function moneroWalletFingerprint(primaryAddress: string): Promise<string> {
|
||||
await validateXmrAddress(primaryAddress);
|
||||
return createHash('sha256').update(JSON.stringify(['wikideck-monero-wallet-generation-v1', primaryAddress])).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow address-allocation capability; it verifies the mainnet primary address and can only
|
||||
* inspect or create one subaddress at a time. Watch-only status must be enforced by wallet
|
||||
* provisioning because the pinned Wallet RPC does not expose a supported wallet-type query.
|
||||
* Keep this credential/service separate
|
||||
* from API and withdrawal capabilities. This
|
||||
* client is intentionally not attached to startup, routes, UI, or automatic crediting.
|
||||
*/
|
||||
export function createMoneroDepositAddressRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
|
||||
const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']);
|
||||
return {
|
||||
async verifyWallet(): Promise<string> {
|
||||
const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0, address_index: [0] }));
|
||||
await validateXmrAddress(address.address);
|
||||
validateAddressResultSchema.parse(await rpc('validate_address', { address: address.address }));
|
||||
return moneroWalletFingerprint(address.address);
|
||||
},
|
||||
async getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null> {
|
||||
if (![accountIndex, addressIndex].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff)) {
|
||||
throw new Error('Invalid Monero subaddress index');
|
||||
}
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = await rpc('get_address', { account_index: accountIndex, address_index: [addressIndex] });
|
||||
} catch (error) {
|
||||
// Monero v0.18.5.4 reports an unused, not-yet-created index as RPC error -15.
|
||||
// Do not treat unrelated account, wallet, or transport failures as an empty address.
|
||||
if (error instanceof MoneroWalletRpcError && error.rpcCode === -15 &&
|
||||
/address index is out of bound/i.test(error.rpcMessage)) return null;
|
||||
throw error;
|
||||
}
|
||||
const result = subaddressReadResultSchema.parse(raw);
|
||||
const entry = result.addresses[0];
|
||||
if (!entry) return null;
|
||||
if (integer(entry.address_index, 0xffff_ffffn, 'address index') !== addressIndex) {
|
||||
throw new Error('Monero wallet returned a different subaddress index');
|
||||
}
|
||||
await validateXmrAddress(entry.address);
|
||||
return { address: entry.address, label: entry.label };
|
||||
},
|
||||
async createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }> {
|
||||
if (!Number.isSafeInteger(accountIndex) || accountIndex < 0 || accountIndex > 0xffff_ffff ||
|
||||
typeof label !== 'string' || label.length < 1 || label.length > 128 || /[\r\n\x00]/.test(label)) {
|
||||
throw new Error('Invalid Monero subaddress creation parameters');
|
||||
}
|
||||
const result = subaddressCreateResultSchema.parse(await rpc('create_address', {
|
||||
account_index: accountIndex, label, count: 1,
|
||||
}));
|
||||
const addressIndex = integer(result.address_index, 0xffff_ffffn, 'address index');
|
||||
if ((result.address_indices && (result.address_indices.length !== 1 || integer(result.address_indices[0], 0xffff_ffffn, 'address index') !== addressIndex)) ||
|
||||
(result.addresses && (result.addresses.length !== 1 || result.addresses[0] !== result.address))) {
|
||||
throw new Error('Monero wallet returned inconsistent subaddress creation data');
|
||||
}
|
||||
await validateXmrSubaddress(result.address);
|
||||
return { address: result.address, addressIndex };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { createServer, type Server } from 'node:https';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { isIP } from 'node:net';
|
||||
import type { TLSSocket } from 'node:tls';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const tlsFilesSchema = z.object({ certificateFile: z.string().min(1).max(4096), privateKeyFile: z.string().min(1).max(4096),
|
||||
clientCaFile: z.string().min(1).max(4096) }).strict();
|
||||
const gatewayClientPolicySchema = z.object({
|
||||
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
roles: z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2),
|
||||
}).strict();
|
||||
const gatewayConfigSchema = z.object({
|
||||
listenHost: z.string().min(1).max(64),
|
||||
listenPort: z.number().int().min(1024).max(65535),
|
||||
walletRpcUrl: z.string().url().max(2048),
|
||||
server: tlsFilesSchema,
|
||||
clientPolicies: z.array(gatewayClientPolicySchema).min(1).max(32),
|
||||
}).strict().superRefine((config, context) => {
|
||||
const fingerprints = config.clientPolicies.map(client => client.fingerprint);
|
||||
if (new Set(fingerprints).size !== fingerprints.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Gateway client fingerprints must be unique', path: ['clientPolicies'] });
|
||||
}
|
||||
for (const client of config.clientPolicies) {
|
||||
if (new Set(client.roles).size !== client.roles.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Gateway client roles must be unique', path: ['clientPolicies'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string().max(128), z.number().int().safe()]),
|
||||
method: z.string().max(64), params: z.record(z.string(), z.unknown()).optional() }).strict();
|
||||
const uint32 = z.number().int().min(0).max(0xffff_ffff).safe();
|
||||
const height = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).safe();
|
||||
|
||||
export type MoneroGatewayConfig = z.infer<typeof gatewayConfigSchema>;
|
||||
export type MoneroGatewayRole = 'observer' | 'address-allocator';
|
||||
type GatewayMethod = 'get_height' | 'get_address' | 'validate_address' | 'create_address' | 'get_transfers' | 'incoming_transfers';
|
||||
const METHODS_BY_ROLE: Record<MoneroGatewayRole, readonly GatewayMethod[]> = {
|
||||
observer: ['get_height', 'get_address', 'get_transfers', 'incoming_transfers'],
|
||||
'address-allocator': ['get_address', 'validate_address', 'create_address'],
|
||||
};
|
||||
|
||||
function isPrivateBindAddress(value: string): boolean {
|
||||
if (isIP(value) === 4) {
|
||||
const octets = value.split('.').map(Number);
|
||||
return octets[0] === 127 || octets[0] === 10 ||
|
||||
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
||||
(octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
if (isIP(value) === 6) {
|
||||
const normalized = value.toLowerCase();
|
||||
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function loadMoneroRpcGatewayConfig(path: string): Promise<MoneroGatewayConfig> {
|
||||
try {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 16 * 1024) throw new Error();
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
||||
return gatewayConfigSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
||||
} catch {
|
||||
throw new Error('Invalid or inaccessible protected Monero RPC gateway configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async function readPrivateFile(path: string): Promise<Buffer> {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 64 * 1024) {
|
||||
throw new Error('Monero gateway TLS material must be a bounded regular file');
|
||||
}
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
|
||||
throw new Error('Monero gateway TLS material permissions are too broad');
|
||||
}
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
export function validateMoneroRpcGatewayRequest(value: unknown, roles: readonly MoneroGatewayRole[]): string {
|
||||
const request = envelopeSchema.parse(value);
|
||||
const parsedRoles = z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2).parse(roles);
|
||||
if (new Set(parsedRoles).size !== parsedRoles.length) throw new Error('Monero RPC gateway client roles must be unique');
|
||||
const allowed = new Set(parsedRoles.flatMap(role => METHODS_BY_ROLE[role]));
|
||||
if (!allowed.has(request.method as GatewayMethod)) throw new Error('Wallet RPC method is not allowed for this client certificate');
|
||||
const params = request.params ?? {};
|
||||
switch (request.method) {
|
||||
case 'get_height':
|
||||
z.object({}).strict().parse(params);
|
||||
break;
|
||||
case 'get_address': {
|
||||
z.object({ account_index: uint32, address_index: z.array(uint32).length(1) }).strict().parse(params);
|
||||
break;
|
||||
}
|
||||
case 'validate_address':
|
||||
z.object({ address: z.string().length(95) }).strict().parse(params);
|
||||
break;
|
||||
case 'create_address': {
|
||||
const parsed = z.object({ account_index: uint32, label: z.string().min(1).max(128), count: z.literal(1) }).strict().parse(params);
|
||||
if (/[\r\n\x00]/.test(parsed.label)) throw new Error('Invalid subaddress label');
|
||||
break;
|
||||
}
|
||||
case 'get_transfers': {
|
||||
const parsed = z.object({ in: z.literal(true), filter_by_height: z.literal(true),
|
||||
min_height: height, max_height: height }).strict().parse(params);
|
||||
if (parsed.max_height < parsed.min_height || parsed.max_height - parsed.min_height > 100_000) {
|
||||
throw new Error('Wallet transfer scan range is invalid or too large');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'incoming_transfers':
|
||||
z.object({ transfer_type: z.literal('all'), account_index: uint32,
|
||||
subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Wallet RPC method is not allow-listed');
|
||||
}
|
||||
const body = JSON.stringify(request);
|
||||
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('Wallet RPC request exceeds size limit');
|
||||
return body;
|
||||
}
|
||||
|
||||
async function forwardToLocalWallet(url: URL, body: string): Promise<{ status: number; contentType: string | undefined; body: Buffer }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000); timeout.unref();
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', redirect: 'error', signal: controller.signal,
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' }, body });
|
||||
if (response.status !== 200 || response.redirected ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) {
|
||||
await response.body?.cancel(); throw new Error('Local Wallet RPC response rejected');
|
||||
}
|
||||
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await reader.read(); if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error('Local Wallet RPC response exceeds size limit');
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
return { status: response.status, contentType: response.headers.get('content-type') ?? undefined,
|
||||
body: Buffer.concat(chunks) };
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Private mTLS authorization gateway for Monero v0.18.x Wallet RPC. Bind it to loopback;
|
||||
* run wallet-rpc with --disable-rpc-login only behind this process and a host firewall.
|
||||
* Only allow-listed, schema-bounded methods are forwarded to a literal loopback upstream.
|
||||
*/
|
||||
export async function createMoneroRpcGateway(configInput: unknown): Promise<Server> {
|
||||
const config = gatewayConfigSchema.parse(configInput);
|
||||
if (!isPrivateBindAddress(config.listenHost)) throw new Error('Monero RPC gateway must bind to an explicit private interface IP');
|
||||
let upstream: URL;
|
||||
try { upstream = new URL(config.walletRpcUrl); } catch { throw new Error('Invalid local Wallet RPC URL'); }
|
||||
if (upstream.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(upstream.hostname) ||
|
||||
upstream.pathname !== '/json_rpc' || upstream.username || upstream.password || upstream.search || upstream.hash) {
|
||||
throw new Error('Wallet RPC gateway upstream must be fixed loopback-only HTTP');
|
||||
}
|
||||
const clientPolicies = new Map(config.clientPolicies.map(policy => [policy.fingerprint, policy.roles]));
|
||||
const [cert, key, ca] = await Promise.all([readPrivateFile(config.server.certificateFile),
|
||||
readPrivateFile(config.server.privateKeyFile), readPrivateFile(config.server.clientCaFile)]);
|
||||
const server = createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3' }, (request, response) => {
|
||||
void (async () => {
|
||||
if (request.method !== 'POST' || request.url !== '/json_rpc' ||
|
||||
!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) {
|
||||
response.writeHead(404).end(); return;
|
||||
}
|
||||
const peer = (request.socket as TLSSocket).getPeerCertificate();
|
||||
const roles = peer.raw ? clientPolicies.get(createHash('sha256').update(peer.raw).digest('hex')) : undefined;
|
||||
if (!roles) {
|
||||
response.writeHead(403).end(); return;
|
||||
}
|
||||
const chunks: Buffer[] = []; let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_REQUEST_BYTES) { response.writeHead(413).end(); return; }
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
let body: string;
|
||||
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles); }
|
||||
catch { response.writeHead(400).end(); return; }
|
||||
let result: Awaited<ReturnType<typeof forwardToLocalWallet>>;
|
||||
try { result = await forwardToLocalWallet(upstream, body); }
|
||||
catch { response.writeHead(502).end(); return; }
|
||||
response.writeHead(result.status, { 'content-type': result.contentType ?? 'application/json',
|
||||
'cache-control': 'no-store', 'content-length': result.body.byteLength }).end(result.body);
|
||||
})().catch(() => { if (!response.headersSent) response.writeHead(500).end(); else response.destroy(); });
|
||||
});
|
||||
server.maxConnections = 32;
|
||||
server.headersTimeout = 5000;
|
||||
server.requestTimeout = 10_000;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(config.listenPort, config.listenHost, () => { server.off('error', reject); resolve(); });
|
||||
});
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address || address.address !== config.listenHost || address.port !== config.listenPort) {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
throw new Error('Monero RPC gateway did not bind to the configured loopback address');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { formatXmr, parseAtomicXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
|
||||
/** No live payment adapter is reachable in this release. Never return a payable mock address. */
|
||||
export function paymentMode(env: NodeJS.ProcessEnv = process.env): 'mock' {
|
||||
const mode = env.WIKIDECK_PAYMENT_MODE ?? 'mock';
|
||||
if (mode !== 'mock') throw new Error('Real payments are launch-gated. Only WIKIDECK_PAYMENT_MODE=mock is supported until production settlement review.');
|
||||
return mode;
|
||||
}
|
||||
export function xmrString(atomic: bigint): string {
|
||||
return formatXmr(atomic);
|
||||
}
|
||||
/** Deterministic simulation ONLY. Round gross up and both independent seller fees down. */
|
||||
export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: number) {
|
||||
const rate = BigInt(usdCentsPerXmr);
|
||||
const gross = (BigInt(priceCents) * XMR_ATOMIC_UNITS + rate - 1n) / rate;
|
||||
const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps);
|
||||
return { invoice: { provider: 'mock' as const, amountXmr: xmrString(gross), address: null,
|
||||
label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' },
|
||||
feeXmr: xmrString(fee), sellerNetXmr: xmrString(sellerBeforeNetworkCosts),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } };
|
||||
}
|
||||
|
||||
/** Native-XMR sandbox quote: exact listed gross, with no exchange-rate conversion. */
|
||||
export function mockXmrQuote(priceAtomicXmr: string, feeBps: number) {
|
||||
const gross = parseAtomicXmr(priceAtomicXmr);
|
||||
if (gross === 0n) throw new Error('Sale price must be positive');
|
||||
const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps);
|
||||
return { invoice: { provider: 'mock' as const, amountXmr: formatXmr(gross), address: null,
|
||||
label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' },
|
||||
feeXmr: formatXmr(fee), sellerNetXmr: formatXmr(sellerBeforeNetworkCosts),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: formatXmr(additionalFee), payout: 'not-started' as const } };
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
export type PilotCustodyIdentity = {
|
||||
version: 1;
|
||||
mode: 'pilot-custody';
|
||||
policy: 'pilot-custody-v1';
|
||||
marketNetwork: 'wikideck-randomx-pilot-1';
|
||||
marketChainId: string;
|
||||
moneroNetwork: 'mainnet';
|
||||
custodianId: string;
|
||||
activationStatus: 'identity-only';
|
||||
initializedAt: number;
|
||||
};
|
||||
|
||||
function validUuid(value: string): boolean {
|
||||
return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function requirePilot(custodianId: string, initializedAt: number): void {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || !validUuid(custodianId)) {
|
||||
throw new Error('Pilot custody identity requires the pilot profile and a valid custodian UUID');
|
||||
}
|
||||
if (!Number.isSafeInteger(initializedAt) || initializedAt < 0) throw new Error('Invalid pilot identity initialization time');
|
||||
}
|
||||
|
||||
export function pilotCustodyIdentityFromRow(row: Record<string, unknown>): PilotCustodyIdentity {
|
||||
const identity = {
|
||||
version: Number(row.version), mode: row.mode, policy: row.policy,
|
||||
marketNetwork: row.market_network, marketChainId: row.market_chain_id,
|
||||
moneroNetwork: row.monero_network, custodianId: row.custodian_id,
|
||||
activationStatus: row.activation_status, initializedAt: Number(row.initialized_at),
|
||||
} as PilotCustodyIdentity;
|
||||
if (identity.version !== 1 || identity.mode !== 'pilot-custody' || identity.policy !== 'pilot-custody-v1' ||
|
||||
identity.marketNetwork !== 'wikideck-randomx-pilot-1' || identity.marketChainId !== CHAIN_ID ||
|
||||
identity.moneroNetwork !== 'mainnet' || !validUuid(identity.custodianId) ||
|
||||
identity.activationStatus !== 'identity-only' || !Number.isSafeInteger(identity.initializedAt) || identity.initializedAt < 0) {
|
||||
throw new Error('Stored pilot custody identity is invalid or inconsistent');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function requireEmptyFinancialDatabase(client: PoolClient, allowPilotIdentity = false): Promise<void> {
|
||||
const result = await client.query(`SELECT NOT wk_custody.pilot_reservation_database_isolated() AS populated,
|
||||
(SELECT count(*) FROM wk_custody.pilot_custody_identity) AS pilot_identities`);
|
||||
if (result.rows[0].populated) {
|
||||
throw new Error('Pilot identity requires a separate empty custody database; existing mock or financial data cannot be reused');
|
||||
}
|
||||
const identities = Number(result.rows[0].pilot_identities);
|
||||
if ((!allowPilotIdentity && identities !== 0) || (allowPilotIdentity && identities !== 1)) {
|
||||
throw new Error('Pilot custody database identity state is inconsistent');
|
||||
}
|
||||
if (!allowPilotIdentity) {
|
||||
const reservations = await client.query('SELECT EXISTS(SELECT 1 FROM wk_custody.pilot_monero_deposit_reservations) AS populated');
|
||||
if (reservations.rows[0].populated) throw new Error('Pilot identity requires an empty pilot reservation table');
|
||||
}
|
||||
}
|
||||
|
||||
/** Recheck the dedicated database boundary before each reservation; only its single identity row is allowed. */
|
||||
export async function assertPilotReservationDatabaseEmpty(client: PoolClient): Promise<void> {
|
||||
await requireEmptyFinancialDatabase(client, true);
|
||||
}
|
||||
|
||||
/** One-time identity binding only. It does not enable deposit allocation, observation, credit, or withdrawal roles. */
|
||||
export async function initializePilotCustodyIdentity(pool: Pool, custodianId: string,
|
||||
initializedAt = Date.now()): Promise<PilotCustodyIdentity> {
|
||||
requirePilot(custodianId, initializedAt);
|
||||
const expected: PilotCustodyIdentity = { version: 1, mode: 'pilot-custody', policy: 'pilot-custody-v1',
|
||||
marketNetwork: 'wikideck-randomx-pilot-1', marketChainId: CHAIN_ID, moneroNetwork: 'mainnet',
|
||||
custodianId: custodianId.toLowerCase(), activationStatus: 'identity-only', initializedAt };
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
const current = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1 FOR UPDATE');
|
||||
if (current.rowCount) {
|
||||
const identity = pilotCustodyIdentityFromRow(current.rows[0]);
|
||||
if (identity.version !== expected.version || identity.mode !== expected.mode || identity.policy !== expected.policy ||
|
||||
identity.marketNetwork !== expected.marketNetwork || identity.marketChainId !== expected.marketChainId ||
|
||||
identity.moneroNetwork !== expected.moneroNetwork || identity.custodianId !== expected.custodianId) {
|
||||
throw new Error('Pilot custody database is already bound to a different market, Monero network, or custodian');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return identity;
|
||||
}
|
||||
await requireEmptyFinancialDatabase(client);
|
||||
await client.query(`INSERT INTO wk_custody.pilot_custody_identity
|
||||
(singleton,version,mode,policy,market_network,market_chain_id,monero_network,custodian_id,activation_status,initialized_at)
|
||||
VALUES(1,$1,$2,$3,$4,$5,$6,$7,$8,$9)`, [expected.version, expected.mode, expected.policy,
|
||||
expected.marketNetwork, expected.marketChainId, expected.moneroNetwork, expected.custodianId,
|
||||
expected.activationStatus, expected.initializedAt]);
|
||||
await client.query('COMMIT');
|
||||
return expected;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export async function verifyPilotCustodyIdentity(pool: Pool, custodianId: string): Promise<PilotCustodyIdentity> {
|
||||
requirePilot(custodianId, 0);
|
||||
const result = await pool.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (result.rowCount !== 1) throw new Error('Pilot custody identity is not initialized');
|
||||
const identity = pilotCustodyIdentityFromRow(result.rows[0]);
|
||||
if (identity.custodianId !== custodianId.toLowerCase()) throw new Error('Pilot custody identity does not match configured custodian');
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function parsePilotCustodianId(value: string): string {
|
||||
if (!validUuid(value)) throw new Error('Invalid pilot custodian UUID');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
|
||||
import { validateXmrSubaddress } from './xmr.ts';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { assertPilotReservationDatabaseEmpty, verifyPilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { validatePilotMoneroDepositRequest, type PilotDepositRequestContext } from './pilot-monero-deposit-request.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
const MAX_INDEX = 0xffff_ffff;
|
||||
const uuidPattern = /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
||||
const hexPattern = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type PilotDepositReservation = {
|
||||
allocationId: string;
|
||||
requestId: string;
|
||||
custodianId: string;
|
||||
marketNetwork: 'wikideck-randomx-pilot-1';
|
||||
marketChainId: string;
|
||||
moneroNetwork: 'mainnet';
|
||||
beneficiary: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
accountIndex: number;
|
||||
addressIndex: number;
|
||||
requestDigest: string;
|
||||
allocationDigest: string;
|
||||
reservedAt: number;
|
||||
};
|
||||
|
||||
type ReservationRow = {
|
||||
allocation_id: string; request_id: string; custodian_id: string;
|
||||
market_network: 'wikideck-randomx-pilot-1'; market_chain_id: string; monero_network: 'mainnet';
|
||||
beneficiary: string; wallet_generation_id: string; wallet_fingerprint: string;
|
||||
account_index: string; address_index: string; request_digest: string; allocation_digest: string; reserved_at: string;
|
||||
};
|
||||
|
||||
export type PilotDepositAllocationIntent = {
|
||||
requestId: string; custodianId: string; marketNetwork: 'wikideck-randomx-pilot-1'; marketChainId: string;
|
||||
moneroNetwork: 'mainnet'; beneficiary: string; walletGenerationId: string; walletFingerprint: string;
|
||||
accountIndex: number; requestDigest: string;
|
||||
};
|
||||
|
||||
type ReservationAddressRow = ReservationRow & {
|
||||
status: 'reserved' | 'creating' | 'registered';
|
||||
create_started_at: string | null;
|
||||
monero_address: string | null;
|
||||
registered_at: string | null;
|
||||
};
|
||||
|
||||
function toReservation(row: ReservationRow): PilotDepositReservation {
|
||||
return { allocationId: row.allocation_id, requestId: row.request_id, custodianId: row.custodian_id,
|
||||
marketNetwork: row.market_network, marketChainId: row.market_chain_id, moneroNetwork: row.monero_network,
|
||||
beneficiary: row.beneficiary, walletGenerationId: row.wallet_generation_id, walletFingerprint: row.wallet_fingerprint,
|
||||
accountIndex: Number(row.account_index), addressIndex: Number(row.address_index), requestDigest: row.request_digest,
|
||||
allocationDigest: row.allocation_digest, reservedAt: Number(row.reserved_at) };
|
||||
}
|
||||
|
||||
function validateIdentity(identity: PilotCustodyIdentity, intent: PilotDepositAllocationIntent, reservedAt: number): void {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || identity.version !== 1 || identity.mode !== 'pilot-custody' ||
|
||||
identity.policy !== 'pilot-custody-v1' || identity.marketNetwork !== NETWORK || identity.marketChainId !== CHAIN_ID ||
|
||||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') {
|
||||
throw new Error('Pilot deposit reservation requires the configured identity-only pilot custody profile');
|
||||
}
|
||||
if (!Number.isSafeInteger(reservedAt) || reservedAt < 0 || !uuidPattern.test(intent.requestId) ||
|
||||
!uuidPattern.test(intent.custodianId) || !uuidPattern.test(intent.walletGenerationId) ||
|
||||
intent.custodianId.toLowerCase() !== identity.custodianId.toLowerCase() ||
|
||||
intent.marketNetwork !== identity.marketNetwork || intent.marketChainId !== identity.marketChainId ||
|
||||
intent.moneroNetwork !== identity.moneroNetwork || !hexPattern.test(intent.beneficiary) ||
|
||||
!hexPattern.test(intent.walletFingerprint) || !hexPattern.test(intent.requestDigest) ||
|
||||
!Number.isSafeInteger(intent.accountIndex) || intent.accountIndex < 0 || intent.accountIndex > MAX_INDEX) {
|
||||
throw new Error('Invalid pilot deposit allocation intent');
|
||||
}
|
||||
}
|
||||
|
||||
function allocationDigest(intent: PilotDepositAllocationIntent): string {
|
||||
return createHash('sha256').update(JSON.stringify([
|
||||
'wikideck-pilot-monero-deposit-allocation-v1', intent.custodianId, intent.marketNetwork,
|
||||
intent.marketChainId, intent.moneroNetwork, intent.beneficiary, intent.walletGenerationId,
|
||||
intent.walletFingerprint, intent.accountIndex,
|
||||
])).digest('hex');
|
||||
}
|
||||
|
||||
async function assertIdentityRecord(client: PoolClient, identity: PilotCustodyIdentity): Promise<void> {
|
||||
const result = await client.query(`SELECT version,mode,policy,market_network,market_chain_id,monero_network,
|
||||
custodian_id,activation_status FROM wk_custody.pilot_custody_identity WHERE singleton=1`);
|
||||
if (!result.rowCount) throw new Error('Pilot custody identity is not initialized');
|
||||
const row = result.rows[0];
|
||||
if (Number(row.version) !== identity.version || row.mode !== identity.mode || row.policy !== identity.policy ||
|
||||
row.market_network !== identity.marketNetwork || row.market_chain_id !== identity.marketChainId ||
|
||||
row.monero_network !== identity.moneroNetwork || row.custodian_id !== identity.custodianId ||
|
||||
row.activation_status !== identity.activationStatus) {
|
||||
throw new Error('Pilot custody identity changed or does not match the database binding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a never-reused Monero address index for a signed, validated pilot request.
|
||||
* This does not call Wallet-RPC, create/register an address, or permit crediting funds.
|
||||
*/
|
||||
export async function reservePilotMoneroDepositIndex(pool: Pool, identity: PilotCustodyIdentity,
|
||||
intentInput: PilotDepositAllocationIntent, reservedAt = Date.now()): Promise<{ reservation: PilotDepositReservation; replayed: boolean }> {
|
||||
const intent = { ...intentInput, custodianId: intentInput.custodianId.toLowerCase() };
|
||||
validateIdentity(identity, intent, reservedAt);
|
||||
const allocationHash = allocationDigest(intent);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
await assertPilotReservationDatabaseEmpty(client);
|
||||
await assertIdentityRecord(client, identity);
|
||||
// Serialize index allocation across app processes using the same watch-only wallet/account.
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [
|
||||
`wikideck-pilot-monero-reservation-v1:${intent.walletGenerationId}:${intent.accountIndex}`,
|
||||
]);
|
||||
|
||||
const byRequest = await client.query<ReservationRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE request_id=$1`, [intent.requestId]);
|
||||
if (byRequest.rowCount) {
|
||||
const row = byRequest.rows[0];
|
||||
if (row.request_digest !== intent.requestDigest || row.allocation_digest !== allocationHash) {
|
||||
throw new Error('Pilot deposit request ID was reused with different signed terms');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(row), replayed: true };
|
||||
}
|
||||
|
||||
const byBeneficiary = await client.query<ReservationRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4
|
||||
AND wallet_generation_id=$5`, [intent.custodianId, intent.marketNetwork, intent.marketChainId,
|
||||
intent.beneficiary, intent.walletGenerationId]);
|
||||
if (byBeneficiary.rowCount) {
|
||||
const row = byBeneficiary.rows[0];
|
||||
if (row.allocation_digest !== allocationHash) {
|
||||
throw new Error('Beneficiary already has a different pilot reservation for this wallet generation');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(row), replayed: true };
|
||||
}
|
||||
|
||||
const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index
|
||||
FROM wk_custody.pilot_monero_deposit_reservations WHERE wallet_generation_id=$1 AND account_index=$2`,
|
||||
[intent.walletGenerationId, intent.accountIndex]);
|
||||
const index = BigInt(next.rows[0].next_index);
|
||||
if (index > BigInt(MAX_INDEX)) throw new Error('Pilot Monero subaddress index space is exhausted for this account');
|
||||
const inserted = await client.query<ReservationRow>(`INSERT INTO wk_custody.pilot_monero_deposit_reservations(
|
||||
allocation_id,request_id,custodian_id,market_network,market_chain_id,monero_network,beneficiary,
|
||||
wallet_generation_id,wallet_fingerprint,account_index,address_index,request_digest,allocation_digest,reserved_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [randomUUID(), intent.requestId,
|
||||
intent.custodianId, intent.marketNetwork, intent.marketChainId, intent.moneroNetwork, intent.beneficiary,
|
||||
intent.walletGenerationId, intent.walletFingerprint, intent.accountIndex, index.toString(), intent.requestDigest,
|
||||
allocationHash, reservedAt]);
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(inserted.rows[0]), replayed: false };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
|
||||
throw new Error('Pilot deposit reservation conflicts with an existing request, beneficiary, or wallet index');
|
||||
}
|
||||
throw error;
|
||||
} finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Full signed-request path: verify durable identity, cryptographically validate, then reserve an index. */
|
||||
export async function reservePilotMoneroDepositRequest(pool: Pool,
|
||||
context: Omit<PilotDepositRequestContext, 'identity'>,
|
||||
rawRequest: unknown, reservedAt = Date.now()) {
|
||||
const custodianId = z.object({ custodianId: z.string().uuid() }).passthrough().parse(rawRequest).custodianId;
|
||||
const identity = await verifyPilotCustodyIdentity(pool, custodianId);
|
||||
const intent = validatePilotMoneroDepositRequest({ ...context, identity }, rawRequest);
|
||||
return reservePilotMoneroDepositIndex(pool, identity, intent, reservedAt);
|
||||
}
|
||||
|
||||
async function registerPilotAddress(pool: Pool, reservation: PilotDepositReservation, moneroAddress: string,
|
||||
registeredAt: number): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
|
||||
const checkedAddress = await validateXmrSubaddress(moneroAddress);
|
||||
if (!Number.isSafeInteger(registeredAt) || registeredAt < reservation.reservedAt) {
|
||||
throw new Error('Pilot address registration predates its durable index reservation');
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
const result = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reservation.allocationId, reservation.walletGenerationId]);
|
||||
if (!result.rowCount) throw new Error('Pilot deposit reservation was not found for this wallet generation');
|
||||
const row = result.rows[0];
|
||||
if (row.status === 'registered') {
|
||||
if (row.monero_address !== checkedAddress) throw new Error('Registered pilot deposit address cannot be replaced');
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(row), address: checkedAddress, replayed: true };
|
||||
}
|
||||
if (row.status !== 'creating' || row.create_started_at === null || row.monero_address !== null ||
|
||||
row.registered_at !== null || registeredAt < Number(row.create_started_at)) {
|
||||
throw new Error('Pilot deposit reservation registration state is inconsistent');
|
||||
}
|
||||
const updated = await client.query<ReservationAddressRow>(`UPDATE wk_custody.pilot_monero_deposit_reservations
|
||||
SET status='registered',monero_address=$2,registered_at=$3
|
||||
WHERE allocation_id=$1 AND status='creating' AND monero_address IS NULL RETURNING *`, [reservation.allocationId, checkedAddress, registeredAt]);
|
||||
if (!updated.rowCount) {
|
||||
const already = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE allocation_id=$1 AND status='registered'`, [reservation.allocationId]);
|
||||
if (!already.rowCount || already.rows[0].monero_address !== checkedAddress) {
|
||||
throw new Error('Pilot deposit reservation changed during address registration');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(already.rows[0]), address: checkedAddress, replayed: true };
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { reservation: toReservation(updated.rows[0]), address: checkedAddress, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator/library-only address creation for a durable reservation. A lost create_address
|
||||
* response is recovered by the stable label at the exact reserved index on the next call.
|
||||
* No HTTP route invokes this function and the result is not a credit or payment authorization.
|
||||
*/
|
||||
export async function createOrRecoverPilotMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
|
||||
context: Omit<PilotDepositRequestContext, 'identity'>, rawRequest: unknown,
|
||||
now = Date.now()): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
|
||||
const reserved = await reservePilotMoneroDepositRequest(pool, context, rawRequest, now);
|
||||
const initial = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reserved.reservation.allocationId, reserved.reservation.walletGenerationId]);
|
||||
if (!initial.rowCount) throw new Error('Pilot deposit reservation was not found for its wallet generation');
|
||||
if (initial.rows[0].status === 'registered') {
|
||||
const address = await validateXmrSubaddress(initial.rows[0].monero_address ?? '');
|
||||
return { reservation: toReservation(initial.rows[0]), address, replayed: true };
|
||||
}
|
||||
|
||||
const row = initial.rows[0];
|
||||
const lockKey = `wikideck-pilot-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
|
||||
const lockClient = await pool.connect();
|
||||
let locked = false;
|
||||
try {
|
||||
await lockClient.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
|
||||
locked = true;
|
||||
// Another worker may have completed while this call waited for the per-wallet fence.
|
||||
const current = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [row.allocation_id, row.wallet_generation_id]);
|
||||
if (!current.rowCount) throw new Error('Pilot deposit reservation disappeared');
|
||||
if (current.rows[0].status === 'registered') {
|
||||
const address = await validateXmrSubaddress(current.rows[0].monero_address ?? '');
|
||||
return { reservation: toReservation(current.rows[0]), address, replayed: true };
|
||||
}
|
||||
const allocation = current.rows[0];
|
||||
if (await rpc.verifyWallet() !== allocation.wallet_fingerprint) {
|
||||
throw new Error('Configured Monero wallet identity differs from the durable pilot reservation');
|
||||
}
|
||||
const expectedIndex = Number(allocation.address_index);
|
||||
const expectedLabel = `wikideck-pilot-deposit-v1:${allocation.allocation_id}`;
|
||||
const createWasAlreadyStarted = allocation.status === 'creating';
|
||||
let existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (!createWasAlreadyStarted) {
|
||||
// Persist the side-effect intent first. If the process or transport fails afterwards,
|
||||
// retry is reconciliation-only: it may recover an existing labeled index but never create twice.
|
||||
const createStartedAt = Math.max(now, Number(allocation.reserved_at));
|
||||
const started = await pool.query(`UPDATE wk_custody.pilot_monero_deposit_reservations
|
||||
SET status='creating',create_started_at=$2 WHERE allocation_id=$1 AND status='reserved'
|
||||
RETURNING allocation_id`, [allocation.allocation_id, createStartedAt]);
|
||||
if (started.rowCount !== 1) throw new Error('Pilot address create intent could not be committed before Wallet-RPC');
|
||||
}
|
||||
if (!existing) {
|
||||
if (createWasAlreadyStarted) {
|
||||
throw new Error('Pilot Wallet-RPC create outcome is uncertain; reconcile the reserved index and wallet before retrying');
|
||||
}
|
||||
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
|
||||
if (created.addressIndex !== expectedIndex) {
|
||||
throw new Error('Monero wallet created an unexpected pilot subaddress index; reservation requires operator reconciliation');
|
||||
}
|
||||
// Never trust the create response alone; query the exact reserved index and stable label.
|
||||
existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (!existing || existing.address !== created.address) {
|
||||
throw new Error('Monero wallet subaddress could not be recovered at the reserved pilot index');
|
||||
}
|
||||
}
|
||||
if (existing.label !== expectedLabel) {
|
||||
throw new Error('Reserved pilot Monero subaddress index is occupied by a different wallet label');
|
||||
}
|
||||
const address = await validateXmrSubaddress(existing.address);
|
||||
const reservation = toReservation(allocation);
|
||||
return await registerPilotAddress(pool, reservation, address, now);
|
||||
} finally {
|
||||
if (locked) await lockClient.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
|
||||
lockClient.release();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
|
||||
/** Read a signed wallet request only from a bounded owner-only regular file. */
|
||||
export async function loadPilotMoneroDepositRequestFile(path: string): Promise<unknown> {
|
||||
if (process.platform === 'win32') throw new Error('Pilot address command requires POSIX protected-file permission checks');
|
||||
if (!isAbsolute(path)) throw new Error('Pilot address request file path must be absolute');
|
||||
let file;
|
||||
try { file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
||||
catch { throw new Error('Pilot address request file is inaccessible or is a symbolic link'); }
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.size < 1 || info.size > MAX_REQUEST_BYTES || (info.mode & 0o077) !== 0) {
|
||||
throw new Error('Pilot address request file must be bounded and owner-only');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(4096, MAX_REQUEST_BYTES + 1 - total));
|
||||
const { bytesRead } = await file.read(chunk);
|
||||
if (bytesRead === 0) break;
|
||||
total += bytesRead;
|
||||
if (total > MAX_REQUEST_BYTES) throw new Error('Pilot address request file exceeds size limit');
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
||||
try { return JSON.parse(text) as unknown; }
|
||||
catch { throw new Error('Pilot address request file does not contain valid JSON'); }
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { pilotMoneroDepositRequestMessage, pilotMoneroDepositRequestSchema } from '../../shared/pilot-monero-deposit.ts';
|
||||
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { address, CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
export type PilotDepositRequestContext = {
|
||||
identity: PilotCustodyIdentity;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
accountIndex: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a signed pilot deposit intent without allocating an address or touching PostgreSQL/Wallet-RPC.
|
||||
* This boundary is intentionally identity-only until credit, withdrawal, and recovery are connected.
|
||||
*/
|
||||
export function validatePilotMoneroDepositRequest(context: PilotDepositRequestContext, rawRequest: unknown) {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') {
|
||||
throw new Error('Pilot Monero deposit requests require the pilot market profile');
|
||||
}
|
||||
const request = pilotMoneroDepositRequestSchema.parse(rawRequest);
|
||||
const { identity } = context;
|
||||
if (identity.version !== 1 || identity.mode !== 'pilot-custody' || identity.policy !== 'pilot-custody-v1' ||
|
||||
identity.marketNetwork !== NETWORK || identity.marketChainId !== CHAIN_ID ||
|
||||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only' ||
|
||||
request.marketNetwork !== identity.marketNetwork || request.marketChainId !== identity.marketChainId ||
|
||||
request.moneroNetwork !== identity.moneroNetwork || request.custodianId !== identity.custodianId ||
|
||||
request.walletGenerationId !== context.walletGenerationId || request.walletFingerprint !== context.walletFingerprint ||
|
||||
request.accountIndex !== context.accountIndex) {
|
||||
throw new Error('Pilot deposit request does not match the identity, Monero network, or wallet generation');
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(context.walletFingerprint) ||
|
||||
!Number.isSafeInteger(context.accountIndex) || context.accountIndex < 0 || context.accountIndex > 0xffff_ffff) {
|
||||
throw new Error('Invalid configured pilot watch-only wallet identity');
|
||||
}
|
||||
if (address(request.publicKey) !== request.beneficiary) {
|
||||
throw new Error('Pilot deposit request signer does not own the requested beneficiary');
|
||||
}
|
||||
const { signature, ...unsigned } = request;
|
||||
const message = pilotMoneroDepositRequestMessage(unsigned);
|
||||
if (!verifySignature(request.publicKey, request.beneficiary, message, signature)) {
|
||||
throw new Error('Pilot deposit request signature is invalid');
|
||||
}
|
||||
const now = context.now?.() ?? Date.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now || request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
|
||||
throw new Error('Pilot deposit request is expired or outside the allowed window');
|
||||
}
|
||||
const requestDigest = createHash('sha256').update(message).digest('hex');
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
custodianId: identity.custodianId,
|
||||
marketNetwork: identity.marketNetwork,
|
||||
marketChainId: identity.marketChainId,
|
||||
moneroNetwork: identity.moneroNetwork,
|
||||
beneficiary: request.beneficiary,
|
||||
walletGenerationId: request.walletGenerationId,
|
||||
walletFingerprint: request.walletFingerprint,
|
||||
accountIndex: request.accountIndex,
|
||||
expiresAt: request.expiresAt,
|
||||
requestDigest,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { pilotCustodyIdentityFromRow } from './pilot-custody-identity.ts';
|
||||
import { validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const HEX64 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const UUID = z.string().uuid();
|
||||
const OBSERVATION_ID = /^[a-f0-9]{64}:[a-f0-9]{64}$/;
|
||||
const UINT64 = /^(0|[1-9]\d{0,19})$/;
|
||||
const MAX_EVENT_HISTORY = 256;
|
||||
const MAX_BLOCK_UNLOCK = 500_000_000n;
|
||||
|
||||
export type PilotDepositReviewPolicy = {
|
||||
minimumConfirmations: number;
|
||||
maximumObservationAgeMs: number;
|
||||
};
|
||||
|
||||
const subjectWithoutDigestSchema = z.object({
|
||||
policyVersion: z.literal('pilot-monero-deposit-review-v1'),
|
||||
preparationOnly: z.literal(true),
|
||||
crediting: z.literal('disabled'),
|
||||
marketNetwork: z.literal('wikideck-randomx-pilot-1'),
|
||||
marketChainId: HEX64,
|
||||
moneroNetwork: z.literal('mainnet'),
|
||||
custodianId: UUID,
|
||||
activationStatus: z.literal('identity-only'),
|
||||
walletGenerationId: UUID,
|
||||
walletFingerprint: HEX64,
|
||||
allocationId: UUID,
|
||||
beneficiary: HEX64,
|
||||
accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
moneroAddress: z.string().length(95),
|
||||
observationId: z.string().regex(OBSERVATION_ID),
|
||||
transactionId: HEX64,
|
||||
outputPublicKey: HEX64,
|
||||
amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
blockHeight: z.number().int().positive().safe(),
|
||||
walletHeight: z.number().int().positive().safe(),
|
||||
confirmations: z.number().int().positive().safe(),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000).safe(),
|
||||
unlockTime: z.string().regex(UINT64),
|
||||
locked: z.literal(false),
|
||||
doubleSpendSeen: z.literal(false),
|
||||
currentlyPresent: z.literal(true),
|
||||
globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
observationDigest: HEX64,
|
||||
scanId: UUID,
|
||||
scanFromHeight: z.number().int().nonnegative().safe(),
|
||||
scanThroughHeight: z.number().int().nonnegative().safe(),
|
||||
observerScanHeight: z.number().int().nonnegative().safe(),
|
||||
scanCompletedAt: z.number().int().nonnegative().safe(),
|
||||
observerUpdatedAt: z.number().int().nonnegative().safe(),
|
||||
lastObservedAt: z.number().int().nonnegative().safe(),
|
||||
scanFreshnessMs: z.number().int().nonnegative().safe(),
|
||||
maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000).safe(),
|
||||
eventHistorySequence: z.number().int().nonnegative().safe(),
|
||||
eventHistoryCount: z.number().int().positive().max(MAX_EVENT_HISTORY).safe(),
|
||||
eventHistoryDigest: HEX64,
|
||||
preparedAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
|
||||
export type PilotDepositReviewSubject = z.infer<typeof subjectWithoutDigestSchema> & { subjectDigest: string };
|
||||
|
||||
const reviewSubjectSchema = subjectWithoutDigestSchema.extend({ subjectDigest: HEX64 }).strict();
|
||||
const inputSchema = z.object({
|
||||
walletGenerationId: UUID,
|
||||
observationId: z.string().regex(OBSERVATION_ID),
|
||||
}).strict();
|
||||
|
||||
function digest(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, name: string, minimum = 0): number {
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') {
|
||||
throw new Error(`Invalid pilot review ${name}`);
|
||||
}
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`Invalid pilot review ${name}`);
|
||||
return number;
|
||||
}
|
||||
|
||||
function validatePolicy(policy: PilotDepositReviewPolicy): void {
|
||||
if (!Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 ||
|
||||
policy.minimumConfirmations > 100_000 || !Number.isSafeInteger(policy.maximumObservationAgeMs) ||
|
||||
policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) {
|
||||
throw new Error('Invalid pilot deposit review policy');
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnlockMature(unlockTime: string, walletHeight: number, scanCompletedAt: number): void {
|
||||
if (!UINT64.test(unlockTime)) throw new Error('Pilot deposit unlock time is malformed');
|
||||
const unlock = BigInt(unlockTime);
|
||||
if (unlock === 0n) return;
|
||||
const mature = unlock < MAX_BLOCK_UNLOCK
|
||||
? BigInt(walletHeight) >= unlock
|
||||
: BigInt(Math.floor(scanCompletedAt / 1000)) >= unlock;
|
||||
if (!mature) throw new Error('Pilot deposit output has a future unlock time');
|
||||
}
|
||||
|
||||
function validateEvents(rows: Record<string, unknown>[], observationDigest: string) {
|
||||
if (!rows.length || rows.length > MAX_EVENT_HISTORY) {
|
||||
throw new Error('Pilot observation event history is missing or exceeds the review bound');
|
||||
}
|
||||
const events = rows.map(row => {
|
||||
const sequence = safeInteger(row.sequence, 'event sequence');
|
||||
const observedAt = safeInteger(row.observed_at, 'event timestamp');
|
||||
const walletHeight = safeInteger(row.wallet_height, 'event wallet height');
|
||||
const payloadDigest = HEX64.parse(row.payload_digest);
|
||||
const eventType = z.enum(['seen', 'changed', 'reappeared', 'not-seen']).parse(row.event_type);
|
||||
const scanId = UUID.parse(row.scan_id);
|
||||
return { sequence, observedAt, walletHeight, payloadDigest, eventType, scanId };
|
||||
});
|
||||
if (events.some(event => event.eventType !== 'seen' || event.payloadDigest !== observationDigest)) {
|
||||
throw new Error('Pilot observation history contains change, disappearance, or reappearance evidence and needs separate review');
|
||||
}
|
||||
const last = events.at(-1)!;
|
||||
const historyDigest = digest(JSON.stringify(events.map(event => [event.sequence, event.eventType,
|
||||
event.payloadDigest, event.walletHeight, event.observedAt, event.scanId])));
|
||||
return { sequence: last.sequence, count: events.length, digest: historyDigest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an operator-facing pilot deposit review snapshot from persisted observations only.
|
||||
* This function is read-only and does not approve, stage, post, release, or credit funds.
|
||||
*/
|
||||
export async function preparePilotMoneroDepositReview(pool: Pool, inputValue: unknown,
|
||||
policy: PilotDepositReviewPolicy, now = Date.now()): Promise<PilotDepositReviewSubject> {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') {
|
||||
throw new Error('Pilot deposit review preparation requires the pilot profile');
|
||||
}
|
||||
const input = inputSchema.parse(inputValue);
|
||||
validatePolicy(policy);
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid pilot review time');
|
||||
|
||||
const client: PoolClient = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
await verifyCustodySchema(client);
|
||||
|
||||
const identityResult = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (identityResult.rowCount !== 1) throw new Error('Pilot custody identity is not initialized');
|
||||
const identity = pilotCustodyIdentityFromRow(identityResult.rows[0]);
|
||||
if (identity.marketChainId !== CHAIN_ID || identity.marketNetwork !== NETWORK ||
|
||||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') {
|
||||
throw new Error('Pilot custody identity does not permit review preparation');
|
||||
}
|
||||
|
||||
const observations = await client.query(`SELECT
|
||||
o.wallet_generation_id::text,o.observation_id,o.allocation_id::text,o.classification,o.txid,o.monero_address,
|
||||
o.amount_atomic_xmr::text AS amount_atomic_xmr,o.block_height::text AS block_height,
|
||||
o.wallet_height::text AS wallet_height,o.confirmations::text AS confirmations,o.unlock_time,
|
||||
o.account_index::text AS account_index,o.address_index::text AS address_index,o.output_public_key,
|
||||
o.global_output_index::text AS global_output_index,o.locked,o.double_spend_seen,o.payload_digest,
|
||||
o.currently_present,o.last_seen_scan_id::text AS scan_id,o.last_observed_at::text AS last_observed_at,
|
||||
s.wallet_fingerprint,s.network,s.scan_height::text AS observer_scan_height,s.updated_at::text AS observer_updated_at,
|
||||
sc.from_height::text AS scan_from_height,sc.through_height::text AS scan_through_height,
|
||||
sc.completed_at::text AS scan_completed_at
|
||||
FROM wk_custody.pilot_monero_observations o
|
||||
JOIN wk_custody.pilot_monero_observer_state s USING(wallet_generation_id)
|
||||
JOIN wk_custody.pilot_monero_observation_scans sc
|
||||
ON sc.scan_id=o.last_seen_scan_id AND sc.wallet_generation_id=o.wallet_generation_id
|
||||
WHERE o.wallet_generation_id=$1 AND o.observation_id=$2`, [input.walletGenerationId, input.observationId]);
|
||||
if (observations.rowCount !== 1) throw new Error('Pilot deposit observation was not found for this wallet generation');
|
||||
const row = observations.rows[0];
|
||||
if (row.wallet_generation_id !== input.walletGenerationId || row.observation_id !== input.observationId ||
|
||||
row.network !== identity.moneroNetwork || row.classification !== 'assigned' || row.allocation_id === null ||
|
||||
row.currently_present !== true || row.double_spend_seen !== false || row.locked !== false) {
|
||||
throw new Error('Pilot deposit observation is not an assigned, present, unlocked, non-double-spent output');
|
||||
}
|
||||
|
||||
const txid = HEX64.parse(row.txid);
|
||||
const outputPublicKey = HEX64.parse(row.output_public_key);
|
||||
if (row.observation_id !== `${txid}:${outputPublicKey}`) throw new Error('Pilot observation ID does not match its output identity');
|
||||
const amountAtomicXmr = z.string().regex(/^[1-9]\d{0,19}$/).parse(row.amount_atomic_xmr);
|
||||
const amount = BigInt(amountAtomicXmr);
|
||||
if (amount > XMR_RPC_UINT64_MAX) throw new Error('Pilot deposit amount exceeds the Monero RPC uint64 range');
|
||||
|
||||
const blockHeight = safeInteger(row.block_height, 'block height', 1);
|
||||
const walletHeight = safeInteger(row.wallet_height, 'wallet height', 1);
|
||||
const confirmations = safeInteger(row.confirmations, 'confirmation count', 1);
|
||||
const observerScanHeight = safeInteger(row.observer_scan_height, 'observer scan height');
|
||||
const scanFromHeight = safeInteger(row.scan_from_height, 'scan start height');
|
||||
const scanThroughHeight = safeInteger(row.scan_through_height, 'scan end height');
|
||||
const scanCompletedAt = safeInteger(row.scan_completed_at, 'scan completion time');
|
||||
const observerUpdatedAt = safeInteger(row.observer_updated_at, 'observer update time');
|
||||
const lastObservedAt = safeInteger(row.last_observed_at, 'last observation time');
|
||||
const accountIndex = safeInteger(row.account_index, 'account index');
|
||||
const addressIndex = safeInteger(row.address_index, 'address index');
|
||||
const globalOutputIndex = safeInteger(row.global_output_index, 'global output index');
|
||||
if (confirmations !== walletHeight - blockHeight + 1 || confirmations < policy.minimumConfirmations) {
|
||||
throw new Error('Pilot deposit output is below the configured confirmation floor');
|
||||
}
|
||||
if (observerScanHeight < walletHeight || scanThroughHeight < walletHeight ||
|
||||
scanFromHeight > blockHeight || scanThroughHeight < blockHeight || lastObservedAt !== scanCompletedAt ||
|
||||
observerUpdatedAt < scanCompletedAt) {
|
||||
throw new Error('Pilot observation scan is not caught up or its persisted scan context is inconsistent');
|
||||
}
|
||||
const scanFreshnessMs = now - observerUpdatedAt;
|
||||
if (scanFreshnessMs < 0 || now - scanCompletedAt < 0 || now - lastObservedAt < 0 ||
|
||||
scanFreshnessMs > policy.maximumObservationAgeMs || now - scanCompletedAt > policy.maximumObservationAgeMs ||
|
||||
now - lastObservedAt > policy.maximumObservationAgeMs) {
|
||||
throw new Error('Pilot Monero observation scan is stale or has a future timestamp');
|
||||
}
|
||||
assertUnlockMature(row.unlock_time, walletHeight, scanCompletedAt);
|
||||
|
||||
const allocations = await client.query(`SELECT allocation_id::text,custodian_id::text,market_network,market_chain_id,
|
||||
monero_network,beneficiary,wallet_generation_id::text,wallet_fingerprint,account_index::text AS account_index,
|
||||
address_index::text AS address_index,status,monero_address
|
||||
FROM wk_custody.pilot_monero_deposit_reservations WHERE allocation_id=$1`, [row.allocation_id]);
|
||||
if (allocations.rowCount !== 1) throw new Error('Pilot assigned output has no unique durable reservation');
|
||||
const allocation = allocations.rows[0];
|
||||
const beneficiary = HEX64.parse(allocation.beneficiary);
|
||||
if (allocation.custodian_id !== identity.custodianId || allocation.market_network !== identity.marketNetwork ||
|
||||
allocation.market_chain_id !== identity.marketChainId || allocation.monero_network !== identity.moneroNetwork ||
|
||||
allocation.wallet_generation_id !== input.walletGenerationId || allocation.wallet_fingerprint !== row.wallet_fingerprint ||
|
||||
safeInteger(allocation.account_index, 'reserved account index') !== accountIndex ||
|
||||
safeInteger(allocation.address_index, 'reserved address index') !== addressIndex ||
|
||||
allocation.status !== 'registered' || allocation.monero_address !== row.monero_address) {
|
||||
throw new Error('Pilot output does not exactly match its immutable registered reservation and identity');
|
||||
}
|
||||
const moneroAddress = await validateXmrSubaddress(z.string().length(95).parse(row.monero_address));
|
||||
const walletFingerprint = HEX64.parse(row.wallet_fingerprint);
|
||||
const observationDigest = HEX64.parse(row.payload_digest);
|
||||
const scanId = UUID.parse(row.scan_id);
|
||||
|
||||
const history = await client.query(`SELECT sequence::text,event_type,payload_digest,wallet_height::text AS wallet_height,
|
||||
observed_at::text AS observed_at,scan_id::text
|
||||
FROM wk_custody.pilot_monero_observation_events
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2 ORDER BY sequence LIMIT $3`,
|
||||
[input.walletGenerationId, input.observationId, MAX_EVENT_HISTORY + 1]);
|
||||
if (history.rows.length > MAX_EVENT_HISTORY) throw new Error('Pilot observation event history exceeds the review bound');
|
||||
const eventHistory = validateEvents(history.rows, observationDigest);
|
||||
|
||||
const unsigned = subjectWithoutDigestSchema.parse({
|
||||
policyVersion: 'pilot-monero-deposit-review-v1', preparationOnly: true, crediting: 'disabled',
|
||||
marketNetwork: identity.marketNetwork, marketChainId: identity.marketChainId, moneroNetwork: identity.moneroNetwork,
|
||||
custodianId: identity.custodianId, activationStatus: identity.activationStatus,
|
||||
walletGenerationId: input.walletGenerationId, walletFingerprint, allocationId: allocation.allocation_id,
|
||||
beneficiary, accountIndex, addressIndex, moneroAddress, observationId: input.observationId,
|
||||
transactionId: txid, outputPublicKey, amountAtomicXmr, blockHeight, walletHeight, confirmations,
|
||||
minimumConfirmations: policy.minimumConfirmations, unlockTime: z.string().regex(UINT64).parse(row.unlock_time),
|
||||
locked: false, doubleSpendSeen: false, currentlyPresent: true, globalOutputIndex, observationDigest,
|
||||
scanId, scanFromHeight, scanThroughHeight, observerScanHeight, scanCompletedAt, observerUpdatedAt,
|
||||
lastObservedAt, scanFreshnessMs, maximumObservationAgeMs: policy.maximumObservationAgeMs,
|
||||
eventHistorySequence: eventHistory.sequence, eventHistoryCount: eventHistory.count,
|
||||
eventHistoryDigest: eventHistory.digest, preparedAt: now,
|
||||
});
|
||||
const subject = reviewSubjectSchema.parse({ ...unsigned, subjectDigest: digest(JSON.stringify(unsigned)) });
|
||||
await client.query('COMMIT');
|
||||
return subject;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { verifyPilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
|
||||
const MAX_SCAN_RANGE = 100_000;
|
||||
const MAX_OVERLAP = 10_000;
|
||||
const fingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const scanInputSchema = z.object({
|
||||
scanId: z.string().uuid(),
|
||||
walletGenerationId: z.string().uuid(),
|
||||
walletFingerprint: fingerprintSchema,
|
||||
expectedScanHeight: z.number().int().nonnegative().safe(),
|
||||
scannedAt: z.number().int().nonnegative().safe(),
|
||||
page: z.object({
|
||||
network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(),
|
||||
fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(),
|
||||
primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
const observationSchema = z.object({
|
||||
observationId: z.string().min(1).max(160), transactionId: fingerprintSchema,
|
||||
address: z.string().length(95), amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
|
||||
accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
outputPublicKey: fingerprintSchema, globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
type PersistInput = {
|
||||
scanId: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
expectedScanHeight: number;
|
||||
scannedAt: number;
|
||||
page: MoneroObservationPage;
|
||||
};
|
||||
export type PilotObservationClassification = 'assigned' | 'unallocated';
|
||||
export type PilotObservationScanResult = {
|
||||
scanId: string; scanHeight: number; observed: number; assigned: number; unallocated: number;
|
||||
changed: number; reappeared: number; notSeen: number; replayed: boolean;
|
||||
};
|
||||
|
||||
export async function readPilotMoneroObservationCursor(pool: Pool, walletGenerationId: string,
|
||||
walletFingerprint: string): Promise<number> {
|
||||
const generation = z.string().uuid().parse(walletGenerationId);
|
||||
const fingerprint = fingerprintSchema.parse(walletFingerprint);
|
||||
const identity = await verifyPilotCustodyIdentity(pool, (await pool.query(
|
||||
'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? '');
|
||||
const result = await pool.query(`SELECT wallet_fingerprint,network,scan_height
|
||||
FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1`, [generation]);
|
||||
if (!result.rowCount) return 0;
|
||||
if (result.rows[0].wallet_fingerprint !== fingerprint || result.rows[0].network !== identity.moneroNetwork) {
|
||||
throw new Error('Pilot observer cursor is bound to a different wallet or Monero network');
|
||||
}
|
||||
return Number(result.rows[0].scan_height);
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
function observationDigest(observation: MoneroIncomingObservation, classification: PilotObservationClassification): string {
|
||||
return digest([classification, observation.observationId, observation.transactionId, observation.amountAtomicXmr,
|
||||
observation.height, observation.timestamp, observation.unlockTime, observation.accountIndex,
|
||||
observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.locked, observation.doubleSpendSeen]);
|
||||
}
|
||||
|
||||
/** Persist pilot-wallet observations separately from mainnet custody observations. Never posts money or credits. */
|
||||
export async function persistPilotMoneroObservationScan(pool: Pool, input: PersistInput): Promise<PilotObservationScanResult> {
|
||||
const parsed = scanInputSchema.parse(input), page = parsed.page;
|
||||
if (page.fromHeight > page.throughHeight || page.throughHeight > page.walletHeight ||
|
||||
page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP ||
|
||||
page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) {
|
||||
throw new Error('Pilot Monero observation range is not contiguous with its durable cursor');
|
||||
}
|
||||
await validateXmrAddress(page.primaryAddress);
|
||||
const observations = page.incoming.map(row => observationSchema.parse(row));
|
||||
const seen = new Set<string>();
|
||||
for (const observation of observations) {
|
||||
await validateXmrAddress(observation.address);
|
||||
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
|
||||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
|
||||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
|
||||
seen.has(observation.observationId)) throw new Error('Pilot Monero observation is inconsistent with its scan page');
|
||||
seen.add(observation.observationId);
|
||||
}
|
||||
const pageDigest = digest([parsed.walletGenerationId, parsed.walletFingerprint, page.fromHeight, page.throughHeight,
|
||||
observations.map(row => [row.observationId, row]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
const identity = await verifyPilotCustodyIdentity(pool, (await client.query(
|
||||
'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? '');
|
||||
const state = await client.query('SELECT wallet_fingerprint,network,scan_height FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1 FOR UPDATE',
|
||||
[parsed.walletGenerationId]);
|
||||
const prior = await client.query('SELECT payload_digest,through_height FROM wk_custody.pilot_monero_observation_scans WHERE scan_id=$1', [parsed.scanId]);
|
||||
if (prior.rowCount) {
|
||||
if (prior.rows[0].payload_digest !== pageDigest) throw new Error('Pilot observation scan ID was reused with different data');
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Number(prior.rows[0].through_height), observed: observations.length,
|
||||
assigned: 0, unallocated: 0, changed: 0, reappeared: 0, notSeen: 0, replayed: true };
|
||||
}
|
||||
if (!state.rowCount) {
|
||||
if (parsed.expectedScanHeight !== 0) throw new Error('First pilot observation scan must start at wallet history height zero');
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observer_state(wallet_generation_id,wallet_fingerprint,network,scan_height,updated_at)
|
||||
VALUES($1,$2,'mainnet',0,$3)`, [parsed.walletGenerationId, parsed.walletFingerprint, parsed.scannedAt]);
|
||||
} else if (state.rows[0].wallet_fingerprint !== parsed.walletFingerprint || state.rows[0].network !== 'mainnet' ||
|
||||
Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) {
|
||||
throw new Error('Pilot observer wallet identity or cursor changed; reread durable state before retry');
|
||||
}
|
||||
const scanState = await client.query('SELECT wallet_fingerprint FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1',
|
||||
[parsed.walletGenerationId]);
|
||||
if (scanState.rows[0].wallet_fingerprint !== parsed.walletFingerprint) throw new Error('Pilot observer wallet fingerprint mismatch');
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_scans
|
||||
(scan_id,wallet_generation_id,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`,
|
||||
[parsed.scanId, parsed.walletGenerationId, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]);
|
||||
|
||||
let assigned = 0, unallocated = 0, changed = 0, reappeared = 0;
|
||||
for (const observation of observations) {
|
||||
const allocation = await client.query<{ allocation_id: string }>(`SELECT allocation_id
|
||||
FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE wallet_generation_id=$1 AND wallet_fingerprint=$2 AND account_index=$3 AND address_index=$4
|
||||
AND monero_network='mainnet' AND status='registered' AND monero_address=$5`,
|
||||
[parsed.walletGenerationId, parsed.walletFingerprint, observation.accountIndex, observation.addressIndex, observation.address]);
|
||||
const classification: PilotObservationClassification = allocation.rowCount ? 'assigned' : 'unallocated';
|
||||
const allocationId: string | null = allocation.rowCount ? allocation.rows[0].allocation_id : null;
|
||||
const payloadDigest = observationDigest(observation, classification);
|
||||
const existing = await client.query(`SELECT payload_digest,currently_present,first_observed_at FROM wk_custody.pilot_monero_observations
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletGenerationId, observation.observationId]);
|
||||
const eventType = !existing.rowCount ? 'seen' : !existing.rows[0].currently_present ? 'reappeared'
|
||||
: existing.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observations(
|
||||
wallet_generation_id,observation_id,allocation_id,classification,txid,monero_address,amount_atomic_xmr,
|
||||
block_height,wallet_height,confirmations,timestamp,unlock_time,account_index,address_index,
|
||||
output_public_key,global_output_index,locked,double_spend_seen,payload_digest,currently_present,
|
||||
last_seen_scan_id,first_observed_at,last_observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,true,$20,$21,$21)
|
||||
ON CONFLICT(wallet_generation_id,observation_id) DO UPDATE SET
|
||||
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
|
||||
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
|
||||
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
|
||||
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
|
||||
[parsed.walletGenerationId, observation.observationId, allocationId, classification, observation.transactionId,
|
||||
observation.address, observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
|
||||
observation.timestamp, observation.unlockTime, observation.accountIndex, observation.addressIndex,
|
||||
observation.outputPublicKey, observation.globalOutputIndex, observation.locked, observation.doubleSpendSeen,
|
||||
payloadDigest, parsed.scanId, existing.rowCount ? Number(existing.rows[0].first_observed_at) : parsed.scannedAt]);
|
||||
if (classification === 'assigned') assigned++; else unallocated++;
|
||||
if (eventType) {
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events
|
||||
(wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletGenerationId, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]);
|
||||
if (eventType === 'changed') changed++;
|
||||
if (eventType === 'reappeared') reappeared++;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.pilot_monero_observations
|
||||
WHERE wallet_generation_id=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3 AND last_seen_scan_id<>$4 FOR UPDATE`,
|
||||
[parsed.walletGenerationId, page.fromHeight, page.throughHeight, parsed.scanId]);
|
||||
for (const row of missing.rows) {
|
||||
await client.query(`UPDATE wk_custody.pilot_monero_observations SET currently_present=false,last_seen_scan_id=$3
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2`, [parsed.walletGenerationId, row.observation_id, parsed.scanId]);
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events
|
||||
(wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletGenerationId, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]);
|
||||
}
|
||||
await client.query(`UPDATE wk_custody.pilot_monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3
|
||||
WHERE wallet_generation_id=$1`, [parsed.walletGenerationId, page.throughHeight, parsed.scannedAt]);
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight), observed: observations.length,
|
||||
assigned, unallocated, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
|
||||
export type MarketPolicy = { marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string };
|
||||
export function operatorMarketPolicy(env: NodeJS.ProcessEnv = process.env): Required<MarketPolicy> {
|
||||
const flag = (name: string, fallback: boolean) => {
|
||||
const value = env[name] ?? (fallback ? '1' : '0');
|
||||
if (value !== '0' && value !== '1') throw new Error(`${name} must be 0 or 1`);
|
||||
return value === '1';
|
||||
};
|
||||
const hostFeeAddress = env.WIKIDECK_MARKET_HOST_FEE_ADDRESS ?? ADDITIONAL_MARKET_FEE.address;
|
||||
if (!/^[1-9A-HJ-NP-Za-km-z]{95}$/.test(hostFeeAddress)) throw new Error('Invalid WIKIDECK_MARKET_HOST_FEE_ADDRESS');
|
||||
return { marketEnabled: flag('WIKIDECK_MARKET_ENABLED', true),
|
||||
tradingEnabled: flag('WIKIDECK_MARKET_TRADING_ENABLED', true), hostFeeAddress };
|
||||
}
|
||||
export function resolvedMarketPolicy(policy: MarketPolicy): Required<MarketPolicy> {
|
||||
return { marketEnabled: policy.marketEnabled ?? true, tradingEnabled: policy.tradingEnabled ?? true,
|
||||
hostFeeAddress: policy.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address };
|
||||
}
|
||||
export async function validateMarketPolicy(policy: MarketPolicy): Promise<void> {
|
||||
await validateXmrAddress(resolvedMarketPolicy(policy).hostFeeAddress);
|
||||
}
|
||||
/** Pausing new business never hides liabilities or blocks safe resolution/withdrawal requests. */
|
||||
export function permitsMarketAction(policy: MarketPolicy, action: string): boolean {
|
||||
return !['list', 'bid', 'buy', 'reserve', 'mock-credit'].includes(action) ||
|
||||
(resolvedMarketPolicy(policy).marketEnabled && resolvedMarketPolicy(policy).tradingEnabled);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import type { PoolConfig } from 'pg';
|
||||
|
||||
export function custodyStorage(env: NodeJS.ProcessEnv = process.env): 'sqlite' | 'postgres' {
|
||||
const value = env.WIKIDECK_CUSTODY_STORAGE ?? 'sqlite';
|
||||
if (value !== 'sqlite' && value !== 'postgres') throw new Error('Invalid custody storage backend');
|
||||
return value;
|
||||
}
|
||||
async function secretFile(path: string | undefined): Promise<string> {
|
||||
if (!path) throw new Error('Custody PostgreSQL secret file is required');
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 16384 || info.size === 0) throw new Error('Invalid PostgreSQL secret file');
|
||||
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('PostgreSQL secret file permissions are too broad');
|
||||
return (await readFile(path, 'utf8')).trim();
|
||||
}
|
||||
function derLength(bytes: Buffer, offset: number): { length: number; next: number } {
|
||||
if (offset >= bytes.length) throw new Error('Invalid certificate DER length');
|
||||
const first = bytes[offset];
|
||||
if (first < 0x80) return { length: first, next: offset + 1 };
|
||||
const count = first & 0x7f;
|
||||
if (count === 0 || count > 4 || offset + count >= bytes.length) throw new Error('Invalid certificate DER length');
|
||||
let length = 0;
|
||||
for (let i = 1; i <= count; i++) length = length * 256 + bytes[offset + i];
|
||||
return { length, next: offset + count + 1 };
|
||||
}
|
||||
function derElement(bytes: Buffer, offset: number): { tag: number; start: number; end: number; next: number } {
|
||||
if (offset >= bytes.length) throw new Error('Invalid certificate DER');
|
||||
const tag = bytes[offset], size = derLength(bytes, offset + 1), end = size.next + size.length;
|
||||
if (end > bytes.length) throw new Error('Invalid certificate DER');
|
||||
return { tag, start: size.next, end, next: end };
|
||||
}
|
||||
function validateCaCertificate(pem: Buffer): string {
|
||||
const certificate = new X509Certificate(pem);
|
||||
const now = Date.now();
|
||||
if (!Number.isFinite(Date.parse(certificate.validFrom)) || !Number.isFinite(Date.parse(certificate.validTo)) ||
|
||||
now < Date.parse(certificate.validFrom) || now > Date.parse(certificate.validTo)) {
|
||||
throw new Error('PostgreSQL CA certificate is not currently valid');
|
||||
}
|
||||
if (certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) {
|
||||
throw new Error('PostgreSQL CA certificate must be a valid self-signed trust anchor');
|
||||
}
|
||||
const key = certificate.publicKey;
|
||||
if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
|
||||
throw new Error('PostgreSQL CA certificate key must be RSA-2048 or stronger');
|
||||
}
|
||||
const der = certificate.raw;
|
||||
const outer = derElement(der, 0);
|
||||
if (outer.tag !== 0x30) throw new Error('Invalid PostgreSQL CA certificate DER');
|
||||
const tbs = derElement(der, outer.start);
|
||||
const signatureAlgorithm = derElement(der, tbs.next);
|
||||
if (signatureAlgorithm.tag !== 0x30) throw new Error('Invalid PostgreSQL CA signature algorithm');
|
||||
const oid = derElement(der, signatureAlgorithm.start);
|
||||
if (oid.tag !== 0x06) throw new Error('Invalid PostgreSQL CA signature algorithm');
|
||||
const signatureOid = der.subarray(oid.start, oid.end).toString('hex');
|
||||
const weakSignatureOids = new Set(['2a864886f70d010104', '2b0e03021a', '2b0e03021b']);
|
||||
if (weakSignatureOids.has(signatureOid)) throw new Error('PostgreSQL CA certificate uses a weak signature algorithm');
|
||||
return pem.toString('utf8');
|
||||
}
|
||||
async function publicCaFile(path: string | undefined): Promise<string | undefined> {
|
||||
if (!path) return undefined;
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.size < 1 || info.size > 64 * 1024) throw new Error('Invalid PostgreSQL CA file');
|
||||
// A CA certificate is public trust material; mode 0644 is expected. It is not a database credential.
|
||||
const bytes = await file.readFile();
|
||||
return validateCaCertificate(bytes);
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
/** No URL logging, no TLS options in a connection string overriding verified transport. */
|
||||
export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Promise<PoolConfig> {
|
||||
try {
|
||||
const url = new URL(await secretFile(env.WIKIDECK_CUSTODY_PG_URL_FILE));
|
||||
if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || url.search || url.hash ||
|
||||
!/^\/[a-zA-Z0-9_-]+$/.test(url.pathname)) throw new Error('Invalid PostgreSQL URL');
|
||||
const insecure = env.WIKIDECK_CUSTODY_PG_LOCAL_TEST === '1';
|
||||
const transport = env.WIKIDECK_CUSTODY_PG_TRANSPORT ?? 'tls';
|
||||
if (transport !== 'tls' && transport !== 'infrastructure') throw new Error('Invalid PostgreSQL transport');
|
||||
const infrastructure = transport === 'infrastructure';
|
||||
const acknowledgement = env.WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK;
|
||||
if (infrastructure) {
|
||||
if (acknowledgement !== 'encrypted-authenticated-path-enforced' || insecure || env.WIKIDECK_CUSTODY_PG_CA_FILE) {
|
||||
throw new Error('Infrastructure transport requires explicit acknowledgement without conflicting TLS/test settings');
|
||||
}
|
||||
} else if (acknowledgement !== undefined) throw new Error('Infrastructure acknowledgement without infrastructure transport');
|
||||
if (env.WIKIDECK_CUSTODY_PG_LOCAL_TEST !== undefined && !insecure) throw new Error('Invalid local-test switch');
|
||||
if (insecure && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('Non-TLS test connections must be loopback');
|
||||
if (!insecure && (!url.username || !url.password)) throw new Error('PostgreSQL credentials required');
|
||||
if (url.port && (!/^\d+$/.test(url.port) || Number(url.port) < 1 || Number(url.port) > 65535)) throw new Error('Invalid PostgreSQL port');
|
||||
const ca = await publicCaFile(env.WIKIDECK_CUSTODY_PG_CA_FILE);
|
||||
return { host: url.hostname.replace(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 5432,
|
||||
database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password),
|
||||
// Infrastructure mode is explicit plaintext on the driver's hop. It does NOT configure or
|
||||
// verify a service mesh, peer identity, network policy, or encryption outside this process.
|
||||
ssl: insecure || infrastructure ? false : { rejectUnauthorized: true, ...(ca ? { ca } : {}) },
|
||||
max: 4, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000,
|
||||
statement_timeout: 10000, lock_timeout: 3000, idle_in_transaction_session_timeout: 10000,
|
||||
application_name: 'wikideck-custody', options: '-c search_path=wk_custody,pg_catalog' };
|
||||
} catch { throw new Error('Invalid or inaccessible custody PostgreSQL configuration; check secret files and explicit transport policy'); }
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import { Pool, type PoolClient, type PoolConfig } from 'pg';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { IS_MAINNET, NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
import { Ledger } from '../testnet/ledger.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
|
||||
type CustodyActivity, type CustodyCommand, type CustodyInfo, type CustodyListing,
|
||||
type CustodyOrder, type CustodyPage, type CustodyWithdrawal } from '../../shared/custody.ts';
|
||||
import { CustodyError } from './custody.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
|
||||
import { CustodyIntegrityError, auditBusinessLiabilities, auditOrder, auditWithdrawal } from './custody-integrity.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { CanonicalInboxError, auditCanonicalInbox, ingestCanonicalPage } from './canonical-inbox.ts';
|
||||
|
||||
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined;
|
||||
now?: () => number; disposableSaleRehearsal?: boolean } & MarketPolicy;
|
||||
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
||||
export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string };
|
||||
type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
|
||||
const available = (actor: string) => `customer:${actor}:available`;
|
||||
const reserved = (o: CustodyOrder) => `customer:${o.buyer}:purchase:${o.id}`;
|
||||
const held = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`;
|
||||
function check(ok: unknown, message: string): asserts ok { if (!ok) throw new CustodyError(message); }
|
||||
|
||||
/** PostgreSQL-backed SAME sandbox protocol. No real XMR, NFT witness, or automatic schema migration. */
|
||||
export class PostgresCustody {
|
||||
readonly id: string;
|
||||
private readonly pool: Pool;
|
||||
private readonly options: CustodyOptions;
|
||||
private active = 0;
|
||||
private readonly auditTimer: ReturnType<typeof setInterval>;
|
||||
private auditInFlight: Promise<CustodyAudit> | undefined;
|
||||
private integrityHold = false;
|
||||
private auditState: { status: 'ok' | 'failed'; lastAttemptAt: number; lastSuccessAt: number; failureCode: string | null };
|
||||
private constructor(pool: Pool, options: CustodyOptions, id: string) {
|
||||
this.pool = pool; this.options = options; this.id = id;
|
||||
this.auditState = { status: 'ok', lastAttemptAt: Date.now(), lastSuccessAt: Date.now(), failureCode: null };
|
||||
this.auditTimer = setInterval(() => { void this.audit().catch(() => {
|
||||
console.error('Custody financial audit unavailable or inconsistent; mutations held pending operator review.');
|
||||
}); }, 60000);
|
||||
this.auditTimer.unref();
|
||||
}
|
||||
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
|
||||
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
||||
if (options.disposableSaleRehearsal && (config.database !== 'custody_test' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
||||
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
|
||||
}
|
||||
await validateMarketPolicy(options);
|
||||
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
||||
const pool = new Pool(config);
|
||||
// Never log connection details or let idle-client socket errors become uncaught process exceptions.
|
||||
pool.on('error', () => { console.error('Custody PostgreSQL connection unavailable; requests will fail closed.'); });
|
||||
try {
|
||||
const c = await pool.connect();
|
||||
let id: string;
|
||||
try {
|
||||
if (options.disposableSaleRehearsal && (await c.query('SELECT current_database() AS name')).rows[0].name !== 'custody_test') {
|
||||
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
|
||||
}
|
||||
await c.query('BEGIN');
|
||||
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(c);
|
||||
const privilege = await c.query(`SELECT r.rolsuper,r.rolcreatedb,r.rolcreaterole,r.rolreplication,r.rolbypassrls,
|
||||
pg_has_role(current_user,n.nspowner,'MEMBER') AS owns_schema
|
||||
FROM pg_roles r CROSS JOIN pg_namespace n WHERE r.rolname=current_user AND n.nspname='wk_custody'`);
|
||||
if (!privilege.rowCount || Object.values(privilege.rows[0]).some(Boolean)) {
|
||||
throw new Error('Custody runtime requires a non-administrative role separate from the schema owner');
|
||||
}
|
||||
const existing = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (existing.rowCount) {
|
||||
const v = JSON.parse(existing.rows[0].value);
|
||||
if (v.version !== 1 || v.network !== NETWORK || v.chainId !== CHAIN_ID || v.mode !== 'sandbox' ||
|
||||
v.policy !== CUSTODY_POLICY || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v.id)) throw new Error('Custody PostgreSQL identity mismatch');
|
||||
id = v.id;
|
||||
} else {
|
||||
id = randomUUID();
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['identity', JSON.stringify({ version: 1,
|
||||
network: NETWORK, chainId: CHAIN_ID, mode: 'sandbox', policy: CUSTODY_POLICY, id })]);
|
||||
}
|
||||
await auditPostgres(c);
|
||||
await c.query('COMMIT');
|
||||
} catch (e) {
|
||||
await c.query('ROLLBACK').catch(() => {});
|
||||
if (e instanceof CustodyIntegrityError) await recordIntegrityIncident(c, e.code).catch(() => {
|
||||
console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.');
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
finally { c.release(); }
|
||||
return new PostgresCustody(pool, options, id);
|
||||
} catch (e) { await pool.end(); throw e; }
|
||||
}
|
||||
async close(): Promise<void> { clearInterval(this.auditTimer); await this.auditInFlight?.catch(() => {}); await this.pool.end(); }
|
||||
diagnostics() {
|
||||
const stale = Date.now() - this.auditState.lastSuccessAt > 120000;
|
||||
return { backend: 'postgres', mutationHold: this.integrityHold || this.auditState.status !== 'ok' || stale,
|
||||
integrityHold: this.integrityHold, audit: { ...this.auditState, stale } };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
get rehearsalObserverEnabled(): boolean { return this.options.disposableSaleRehearsal === true; }
|
||||
private async transaction<T>(write: boolean, fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
||||
check(this.active < 16, 'Custody database busy; retry the original command shortly');
|
||||
this.active++;
|
||||
let c: PoolClient | undefined;
|
||||
let mutationSavepoint = false;
|
||||
try {
|
||||
c = await this.pool.connect();
|
||||
await c.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
// One writer per financial domain initially; other readers remain nonblocking. Lock is database-scoped,
|
||||
// shared by every node/process, migrations and import, not an in-process promise queue.
|
||||
if (write) {
|
||||
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await c.query('SAVEPOINT custody_mutation'); mutationSavepoint = true;
|
||||
}
|
||||
const result = await fn(c);
|
||||
await c.query('COMMIT');
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof CustodyIntegrityError) {
|
||||
this.integrityHold = true;
|
||||
this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: e.code };
|
||||
// Roll back business changes but retain the cross-process writer lock until the incident is committed.
|
||||
// Read-only/background audit failures require a separate marker transaction; DB/process loss can still
|
||||
// prevent persistence, so external writer fencing remains an incident-response requirement.
|
||||
if (c) {
|
||||
try {
|
||||
if (mutationSavepoint) await c.query('ROLLBACK TO SAVEPOINT custody_mutation');
|
||||
else await c.query('ROLLBACK');
|
||||
await recordIntegrityIncident(c, e.code);
|
||||
if (mutationSavepoint) await c.query('COMMIT');
|
||||
} catch {
|
||||
await c.query('ROLLBACK').catch(() => {});
|
||||
console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.');
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await c?.query('ROLLBACK').catch(() => {});
|
||||
// Never retry after an ambiguous COMMIT. Signed receipts resolve the SAME nonce on a client retry.
|
||||
if (e instanceof CustodyError) throw e;
|
||||
if ((e as { code?: string }).code === '23514') throw new CustodyError('Insufficient balance or custody amount constraint');
|
||||
throw new Error('Custody PostgreSQL operation failed; refresh or retry the original signed command');
|
||||
} finally { c?.release(); this.active--; }
|
||||
}
|
||||
async browse(offset = 0): Promise<CustodyPage> {
|
||||
check(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'Invalid custody page');
|
||||
if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null };
|
||||
return this.transaction(false, async c => {
|
||||
const { rows } = await c.query("SELECT data FROM wk_custody.listings WHERE status IN ('open','reserved') ORDER BY sequence DESC LIMIT 25 OFFSET $1", [offset]);
|
||||
return { info: this.info(), listings: rows.slice(0, 24).map(r => r.data), next: rows.length > 24 ? offset + 24 : null };
|
||||
});
|
||||
}
|
||||
private async activity(c: PoolClient, actor: string, offset: number): Promise<CustodyActivity> {
|
||||
const orders = await c.query('SELECT data FROM wk_custody.orders WHERE buyer=$1 OR seller=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
|
||||
const withdrawals = await c.query('SELECT data FROM wk_custody.withdrawals WHERE actor=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]);
|
||||
const sums = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE id=$1),0)::text AS available,
|
||||
COALESCE(sum(balance) FILTER(WHERE id LIKE $2),0)::text AS purchase,
|
||||
COALESCE(sum(balance) FILTER(WHERE id LIKE $3),0)::text AS withdrawal FROM wk_custody.accounts WHERE id LIKE $4`,
|
||||
[available(actor), `customer:${actor}:purchase:%`, `customer:${actor}:withdrawal:%`, `customer:${actor}:%`]);
|
||||
const claimed = await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor]);
|
||||
return { availableAtomicXmr: sums.rows[0].available, purchaseReservedAtomicXmr: sums.rows[0].purchase,
|
||||
withdrawalReservedAtomicXmr: sums.rows[0].withdrawal, mockCreditClaimed: !!claimed.rowCount,
|
||||
orders: orders.rows.slice(0, 24).map(r => r.data), withdrawals: withdrawals.rows.slice(0, 24).map(r => r.data),
|
||||
next: orders.rows.length > 24 || withdrawals.rows.length > 24 ? offset + 24 : null };
|
||||
}
|
||||
private async post(c: PoolClient, id: string, kind: string, lines: Line[]) {
|
||||
const nonzero = lines.filter(l => (l.debit ?? 0n) !== 0n || (l.credit ?? 0n) !== 0n);
|
||||
if (!nonzero.length || nonzero.reduce((s, l) => s + (l.debit ?? 0n) - (l.credit ?? 0n), 0n) !== 0n) throw new Error('Unbalanced custody posting');
|
||||
await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [id, kind, (this.options.now ?? Date.now)()]);
|
||||
for (const [position, line] of nonzero.entries()) {
|
||||
const normal = line.normal ?? 'credit';
|
||||
await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [line.account, normal]);
|
||||
// Runtime intentionally has no UPDATE privilege on balances. The SECURITY DEFINER projection
|
||||
// trigger takes the account row lock; global writer admission protects the preceding normal check.
|
||||
const account = await c.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [line.account]);
|
||||
if (account.rows[0].normal !== normal) throw new Error('Custody account normal mismatch');
|
||||
// DB trigger updates projection; deferred trigger rejects an empty/unbalanced journal at COMMIT.
|
||||
await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)',
|
||||
[id, position, line.account, (line.debit ?? 0n).toString(), (line.credit ?? 0n).toString()]);
|
||||
}
|
||||
}
|
||||
private async record<T>(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', id: string): Promise<T> {
|
||||
const r = await c.query(`SELECT data FROM wk_custody.${table} WHERE id=$1 FOR UPDATE`, [id]);
|
||||
check(r.rowCount, 'Custody record not found'); return r.rows[0].data as T;
|
||||
}
|
||||
private async update(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', record: { id: string; status: string }) {
|
||||
await c.query(`UPDATE wk_custody.${table} SET status=$1,data=$2 WHERE id=$3`, [record.status, JSON.stringify(record), record.id]);
|
||||
}
|
||||
async execute(raw: unknown): Promise<Result> {
|
||||
const cmd = custodyCommandSchema.parse(raw), message = custodyMessage(cmd), now = (this.options.now ?? Date.now)();
|
||||
check(cmd.custodianId === this.id && cmd.chainId === CHAIN_ID && cmd.expiresAt >= now && cmd.expiresAt <= now + 300000 &&
|
||||
verifySignature(cmd.publicKey, cmd.actor, message, cmd.signature), 'Invalid or expired custody signature');
|
||||
const a = cmd.action;
|
||||
if (a.type === 'activity') return this.transaction(false, c => this.activity(c, cmd.actor, a.offset));
|
||||
return this.transaction(true, async c => {
|
||||
const key = `${cmd.actor}:${cmd.nonce}`, hash = createHash('sha256').update(message).digest('hex');
|
||||
const old = await c.query('SELECT digest,data FROM wk_custody.receipts WHERE id=$1', [key]);
|
||||
if (old.rowCount) { check(old.rows[0].digest === hash, 'Nonce already used for different custody terms'); return old.rows[0].data as Result; }
|
||||
const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
check(!incident.rowCount, 'Custody integrity incident is unresolved; operator review required');
|
||||
check(!this.diagnostics().mutationHold, 'Custody financial reconciliation requires operator review; balances remain readable and original receipts can be replayed');
|
||||
check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
if (a.type === 'reserve' || a.type === 'withdraw') {
|
||||
const depositHold = await c.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
|
||||
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [cmd.actor]);
|
||||
check(!depositHold.rowCount, 'Customer deposit is under reorg review; new spending is held');
|
||||
}
|
||||
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
|
||||
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
|
||||
check(count.rows[0].n < 10000, 'Custody sandbox command budget reached');
|
||||
}
|
||||
// Verify existing liabilities BEFORE resolving them, and validate the new business state before COMMIT.
|
||||
if (a.type === 'cancel-reservation') await auditOrder(c, a.orderId);
|
||||
if (a.type === 'cancel-withdrawal') await auditWithdrawal(c, a.withdrawalId);
|
||||
const result = await this.mutate(c, cmd);
|
||||
if (a.type === 'reserve' || a.type === 'cancel-reservation') await auditOrder(c, (result as CustodyOrder).id);
|
||||
if (a.type === 'withdraw' || a.type === 'cancel-withdrawal') await auditWithdrawal(c, (result as CustodyWithdrawal).id);
|
||||
await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [key, hash, JSON.stringify(result)]);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
private async mutate(c: PoolClient, cmd: CustodyCommand): Promise<Result> {
|
||||
const a = cmd.action, actor = cmd.actor, createdAt = (this.options.now ?? Date.now)();
|
||||
if (a.type === 'mock-credit') {
|
||||
check(!(await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor])).rowCount, 'Sandbox credit already claimed');
|
||||
await this.post(c, `credit:${actor}`, 'mock-deposit', [
|
||||
{ account: 'treasury:mock', normal: 'debit', debit: BigInt(MOCK_CREDIT_ATOMIC) },
|
||||
{ account: available(actor), credit: BigInt(MOCK_CREDIT_ATOMIC) }]);
|
||||
await c.query('INSERT INTO wk_custody.credits VALUES($1)', [actor]); return this.activity(c, actor, 0);
|
||||
}
|
||||
if (a.type === 'list') {
|
||||
check(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
|
||||
const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress;
|
||||
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again');
|
||||
const card = this.options.card(a.cardHash, actor, a.sequence);
|
||||
check(card, 'Card is missing, stale, immature, or pending');
|
||||
check(!(await c.query("SELECT 1 FROM wk_custody.listings WHERE card=$1 AND status IN ('open','reserved')", [a.cardHash])).rowCount, 'Card already has a custody listing');
|
||||
const l: CustodyListing = { id: randomUUID(), seller: actor, cardHash: a.cardHash, sequence: a.sequence,
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt };
|
||||
await c.query('INSERT INTO wk_custody.listings(id,card,status,data) VALUES($1,$2,$3,$4)', [l.id, l.cardHash, l.status, JSON.stringify(l)]); return l;
|
||||
}
|
||||
if (a.type === 'reserve' || a.type === 'cancel-listing') {
|
||||
const l = await this.record<CustodyListing>(c, 'listings', a.listingId);
|
||||
check(l.status === 'open', 'Custody listing is no longer available');
|
||||
if (a.type === 'cancel-listing') {
|
||||
check(l.seller === actor, 'Only the seller can cancel this listing'); l.status = 'cancelled'; await this.update(c, 'listings', l); return l;
|
||||
}
|
||||
check(actor !== l.seller, 'Cannot buy your own card');
|
||||
check(a.priceAtomicXmr === l.priceAtomicXmr && a.feeBps === l.feeBps, 'Custody sale terms changed');
|
||||
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
||||
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms');
|
||||
check(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
|
||||
const o: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: actor, cardHash: l.cardHash,
|
||||
sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(),
|
||||
authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress,
|
||||
status: 'awaiting-lock', createdAt };
|
||||
await this.post(c, `reserve:${o.id}`, 'purchase-reservation', [{ account: available(actor), debit: allocation.gross }, { account: reserved(o), credit: allocation.gross }]);
|
||||
l.status = 'reserved'; await this.update(c, 'listings', l);
|
||||
await c.query('INSERT INTO wk_custody.orders(id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5)', [o.id, actor, o.seller, o.status, JSON.stringify(o)]);
|
||||
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, 'nft-lock', 'blocked', JSON.stringify(o)]); return o;
|
||||
}
|
||||
if (a.type === 'cancel-reservation') {
|
||||
const o = await this.record<CustodyOrder>(c, 'orders', a.orderId);
|
||||
check(!(await c.query('SELECT 1 FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-binding:${o.id}`])).rowCount,
|
||||
'Rehearsal sale authorization exists; cancellation requires replayed terminal evidence');
|
||||
const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]);
|
||||
check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer');
|
||||
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]);
|
||||
o.status = 'cancelled'; await this.update(c, 'orders', o);
|
||||
const l = await this.record<CustodyListing>(c, 'listings', o.listingId); l.status = 'cancelled'; await this.update(c, 'listings', l);
|
||||
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); return o;
|
||||
}
|
||||
if (a.type === 'withdraw') {
|
||||
const w: CustodyWithdrawal = { id: randomUUID(), actor, destination: a.destination, amountAtomicXmr: a.amountAtomicXmr,
|
||||
maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt };
|
||||
const total = BigInt(a.amountAtomicXmr) + BigInt(a.maxFeeAtomicXmr);
|
||||
await this.post(c, `withdraw:${w.id}`, 'withdrawal-reservation', [{ account: available(actor), debit: total }, { account: held(w), credit: total }]);
|
||||
await c.query('INSERT INTO wk_custody.withdrawals(id,actor,status,data) VALUES($1,$2,$3,$4)', [w.id, actor, w.status, JSON.stringify(w)]);
|
||||
await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [w.id, 'withdrawal', 'blocked', JSON.stringify(w)]); return w;
|
||||
}
|
||||
if (a.type === 'cancel-withdrawal') {
|
||||
const w = await this.record<CustodyWithdrawal>(c, 'withdrawals', a.withdrawalId);
|
||||
check(w.actor === actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner');
|
||||
const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr);
|
||||
await this.post(c, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [{ account: held(w), debit: total }, { account: available(actor), credit: total }]);
|
||||
w.status = 'cancelled'; await this.update(c, 'withdrawals', w);
|
||||
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [w.id]); return w;
|
||||
}
|
||||
throw new CustodyError('Unsupported custody action');
|
||||
}
|
||||
/** Disposable database ONLY. Not wired to HTTP, configuration flags, live chain or a signer. */
|
||||
observeSaleRehearsal(orderId: string, transcript: unknown) {
|
||||
check(this.options.disposableSaleRehearsal === true, 'Sale rehearsal is unavailable on this custody instance');
|
||||
return this.transaction(true, async c => {
|
||||
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
|
||||
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
|
||||
const o = await this.record<CustodyOrder>(c, 'orders', orderId);
|
||||
await auditOrder(c, o.id);
|
||||
const l = await this.record<CustodyListing>(c, 'listings', o.listingId);
|
||||
const replay = replayReservedSale(o, this.id, transcript, l.feeBps);
|
||||
const key = `sale-rehearsal-binding:${o.id}`;
|
||||
const old = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [key]);
|
||||
if (old.rowCount) check(old.rows[0].value === replay.binding, 'Rehearsal authorization changed');
|
||||
else {
|
||||
check(o.status === 'awaiting-lock', 'Cannot bind an already resolved order to a rehearsal');
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [key, replay.binding]);
|
||||
}
|
||||
if (replay.decision.action === 'review') throw new CustodyIntegrityError('sale-rehearsal-outcome-reorg');
|
||||
// Retain the first signed observation plus terminal evidence, not a canonical event inbox.
|
||||
// Runtime has INSERT but intentionally no UPDATE privilege on these metadata records.
|
||||
const evidenceKey = `sale-rehearsal-observation:${o.id}`;
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2) ON CONFLICT(key) DO NOTHING', [evidenceKey,
|
||||
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
|
||||
if (replay.decision.action === 'settle' || replay.decision.action === 'refund') {
|
||||
check(o.status === 'awaiting-lock', 'Rehearsal order already resolved');
|
||||
if (replay.decision.action === 'settle') {
|
||||
await this.post(c, `settle:${o.id}`, 'mock-sale-settlement', [
|
||||
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) },
|
||||
{ account: available(o.seller), credit: BigInt(o.sellerAtomicXmr) },
|
||||
{ account: `author:${o.authorAddress}`, credit: BigInt(o.authorAtomicXmr) },
|
||||
{ account: o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', credit: BigInt(o.hostAtomicXmr) }]);
|
||||
o.status = 'settled'; l.status = 'sold';
|
||||
await c.query("UPDATE wk_custody.outbox SET status='fixture-complete',data=$1 WHERE id=$2", [`fixture:${replay.stateRoot}`, o.id]);
|
||||
} else {
|
||||
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [
|
||||
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(o.buyer), credit: BigInt(o.grossAtomicXmr) }]);
|
||||
o.status = 'cancelled'; l.status = 'cancelled';
|
||||
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]);
|
||||
}
|
||||
await this.update(c, 'orders', o); await this.update(c, 'listings', l);
|
||||
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`sale-rehearsal-terminal:${o.id}`,
|
||||
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
|
||||
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
|
||||
[`sale-rehearsal-terminal-index:${o.id}`, JSON.stringify({ version: 1,
|
||||
terminalTransactionId: replay.terminalTransactionId ?? null, stateRoot: replay.stateRoot })]);
|
||||
if (replay.terminalTransactionId) {
|
||||
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
|
||||
[`sale-rehearsal-tx:${replay.terminalTransactionId}`,
|
||||
JSON.stringify({ version: 1, orderId: o.id, stateRoot: replay.stateRoot })]);
|
||||
}
|
||||
await auditOrder(c, o.id);
|
||||
}
|
||||
return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot };
|
||||
});
|
||||
}
|
||||
/** Disposable observer rehearsal only; input must come from Ledger.canonicalEvents(), never peers/HTTP. */
|
||||
async observeCanonicalEvents(page: unknown) {
|
||||
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
||||
const result = await this.transaction(true, async c => {
|
||||
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
|
||||
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
|
||||
try {
|
||||
const result = await ingestCanonicalPage(c, page);
|
||||
const reviews: { orderId: string; terminalTransactionId: string; stateRoot: string; eventSequence: number; eventHash: string }[] = [];
|
||||
for (const event of result.appendedEvents) {
|
||||
if (event.type !== 'disconnect') continue;
|
||||
for (const txid of event.transactionIds) {
|
||||
const terminal = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-tx:${txid}`]);
|
||||
if (terminal.rowCount) {
|
||||
const link = JSON.parse(terminal.rows[0].value);
|
||||
if (link.version !== 1 || typeof link.orderId !== 'string' || typeof link.stateRoot !== 'string') {
|
||||
throw new CustodyIntegrityError('sale-terminal-index-invalid');
|
||||
}
|
||||
reviews.push({ orderId: link.orderId, terminalTransactionId: txid, stateRoot: link.stateRoot,
|
||||
eventSequence: event.sequence, eventHash: event.eventHash });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const review of reviews) {
|
||||
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING',
|
||||
[`sale-reorg-review:${review.orderId}:${review.eventSequence}`, JSON.stringify({ version: 1,
|
||||
streamId: result.streamId, ...review })]);
|
||||
}
|
||||
if (reviews.length) await recordIntegrityIncident(c, 'posted-sale-terminal-disconnected');
|
||||
return { ...result, appendedEvents: [], reviewRequired: reviews.length > 0 };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (result.reviewRequired) {
|
||||
this.integrityHold = true;
|
||||
this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: 'posted-sale-terminal-disconnected' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** Consume one bounded page directly from this process's locally validated ledger; no HTTP/peer feed. */
|
||||
async observeLedgerCanonicalEvents(ledger: Ledger) {
|
||||
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
||||
check(ledger instanceof Ledger, 'Canonical inbox requires a local Ledger instance');
|
||||
const summary = ledger.summary();
|
||||
check(summary.network === NETWORK && summary.chainId === CHAIN_ID, 'Canonical ledger identity mismatch');
|
||||
const cursor = await this.transaction(false, async c => {
|
||||
const row = await c.query(`SELECT key FROM wk_custody.custody_meta
|
||||
WHERE key LIKE 'canonical-inbox:v1:event:%' ORDER BY key DESC LIMIT 1`);
|
||||
if (!row.rowCount) return 0;
|
||||
const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.rows[0].key);
|
||||
if (!match) throw new CustodyIntegrityError('canonical-inbox-cursor-invalid');
|
||||
return Number(match[1]);
|
||||
});
|
||||
let page: unknown;
|
||||
try { page = ledger.canonicalEvents(cursor, 64); }
|
||||
catch { throw new CustodyIntegrityError('canonical-ledger-cursor-mismatch'); }
|
||||
return this.observeCanonicalEvents(page);
|
||||
}
|
||||
audit(): Promise<CustodyAudit> {
|
||||
if (this.auditInFlight) return this.auditInFlight;
|
||||
this.auditState.lastAttemptAt = Date.now();
|
||||
const pending = this.transaction(false, auditPostgres).then(result => {
|
||||
this.auditState = { ...this.auditState, lastSuccessAt: Date.now(),
|
||||
status: this.integrityHold ? 'failed' : 'ok', failureCode: this.integrityHold ? this.auditState.failureCode : null };
|
||||
return result;
|
||||
}).catch(error => {
|
||||
this.auditState = { ...this.auditState, status: 'failed', failureCode: error instanceof CustodyIntegrityError ? error.code : 'audit-unavailable' };
|
||||
throw error;
|
||||
}).finally(() => { this.auditInFlight = undefined; });
|
||||
this.auditInFlight = pending; return pending;
|
||||
}
|
||||
importSource(): Promise<string | undefined> {
|
||||
return this.transaction(false, async c => (await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'")).rows[0]?.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact NUMERIC/text aggregation in a coherent transaction; no Number conversion of money. */
|
||||
export async function auditPostgres(c: PoolClient): Promise<CustodyAudit> {
|
||||
const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
if (incident.rowCount) throw new CustodyIntegrityError('unresolved-integrity-incident');
|
||||
try { await auditCanonicalInbox(c); }
|
||||
catch (error) {
|
||||
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
||||
throw error;
|
||||
}
|
||||
const bad = await c.query(`SELECT 1 FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
|
||||
GROUP BY j.id HAVING count(e.position)=0 OR sum(e.debit-e.credit)<>0 LIMIT 1`);
|
||||
if (bad.rowCount) throw new CustodyIntegrityError('unbalanced-journal');
|
||||
const mismatch = await c.query(`SELECT 1 FROM wk_custody.accounts a LEFT JOIN wk_custody.entries e ON e.account=a.id
|
||||
GROUP BY a.id HAVING a.balance<>COALESCE(sum(CASE WHEN a.normal='debit' THEN e.debit-e.credit ELSE e.credit-e.debit END),0) LIMIT 1`);
|
||||
if (mismatch.rowCount) throw new CustodyIntegrityError('balance-projection-mismatch');
|
||||
const totals = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE normal='debit'),0)::text AS assets,
|
||||
COALESCE(sum(balance) FILTER(WHERE normal='credit'),0)::text AS claims FROM wk_custody.accounts`);
|
||||
if (totals.rows[0].assets !== totals.rows[0].claims) throw new CustodyIntegrityError('accounting-backing-mismatch');
|
||||
await auditBusinessLiabilities(c);
|
||||
const n = await c.query('SELECT count(*)::text AS n FROM wk_custody.journals');
|
||||
if (BigInt(n.rows[0].n) > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Journal count exceeds supported range');
|
||||
return { journals: Number(n.rows[0].n), assetsAtomicXmr: totals.rows[0].assets, claimsAndRevenueAtomicXmr: totals.rows[0].claims };
|
||||
}
|
||||
|
||||
async function recordIntegrityIncident(c: PoolClient, code: string): Promise<void> {
|
||||
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING',
|
||||
['integrity_incident_v1', JSON.stringify({ version: 1, code, detectedAt: Date.now() })]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const db = new DatabaseSync(workerData.marketFile);
|
||||
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL;');
|
||||
const ledger = workerData.ledgerFile ? new DatabaseSync(workerData.ledgerFile, { readOnly: true }) : null;
|
||||
ledger?.exec('PRAGMA busy_timeout=5000; PRAGMA query_only=ON;');
|
||||
const port = parentPort;
|
||||
if (!port) throw new Error('Reconciliation commit worker requires a parent port');
|
||||
|
||||
function atomic(fn) {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try { const result = fn(); db.exec('COMMIT'); return result; }
|
||||
catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
}
|
||||
function assertCurrentJob(expected) {
|
||||
const row = db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get();
|
||||
if (!row || !isDeepStrictEqual(JSON.parse(row.value), expected)) {
|
||||
throw new Error('Reconciliation job changed before worker commit');
|
||||
}
|
||||
}
|
||||
function rows(stage, afterId, limit) {
|
||||
const table = stage === 'orders' ? 'orders' : 'listings';
|
||||
return db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`)
|
||||
.all(afterId, afterId, limit);
|
||||
}
|
||||
function readLedgerFacts(request, selected) {
|
||||
if (!ledger) return request.fallbackFacts;
|
||||
ledger.exec('BEGIN');
|
||||
try {
|
||||
const tip = ledger.prepare('SELECT hash FROM canonical_blocks ORDER BY height DESC LIMIT 1').get()?.hash ?? request.genesisHash;
|
||||
const height = ledger.prepare('SELECT COALESCE(MAX(height),0) AS n FROM canonical_blocks').get().n;
|
||||
const eventHead = ledger.prepare('SELECT COALESCE(MAX(sequence),0) AS n FROM canonical_events').get().n;
|
||||
const pendingTransactions = ledger.prepare('SELECT id,transaction_json FROM pending_transfers ORDER BY position').all()
|
||||
.map(row => {
|
||||
const tx = JSON.parse(row.transaction_json);
|
||||
const cardIds = tx.type === 'trade' ? [...tx.offered, ...tx.requested].map(leg => leg.cardHash) : [tx.cardHash];
|
||||
return { id: row.id, cardIds };
|
||||
});
|
||||
const pendingIds = pendingTransactions.map(tx => tx.id);
|
||||
const deliveryIds = Object.create(null), transactionIds = new Set(), cardIds = new Set();
|
||||
for (const row of selected) {
|
||||
const record = JSON.parse(row.data);
|
||||
if (request.stage === 'orders') {
|
||||
const id = createHash('sha256').update(JSON.stringify([request.network, 'nft-transfer', request.chainId,
|
||||
record.cardHash, record.seller, record.buyer, record.sequence])).digest('hex');
|
||||
deliveryIds[record.id] = id; transactionIds.add(id); cardIds.add(record.cardHash);
|
||||
} else cardIds.add(record.cardHash);
|
||||
}
|
||||
const deliveryTransactions = Object.create(null);
|
||||
const readTx = ledger.prepare('SELECT height,block_hash FROM canonical_transactions WHERE id=?');
|
||||
for (const id of transactionIds) deliveryTransactions[id] = readTx.get(id) ?? null;
|
||||
const nfts = Object.create(null);
|
||||
const readNft = ledger.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n
|
||||
JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`);
|
||||
for (const card of cardIds) {
|
||||
const nft = readNft.get(card);
|
||||
nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: height - nft.minted_height + 1 } : null;
|
||||
}
|
||||
ledger.exec('COMMIT');
|
||||
return { height,
|
||||
pendingTransactions, pendingIds, pendingCardIds: [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))],
|
||||
deliveryIds, deliveryTransactions, nfts,
|
||||
snapshot: { tip, eventHead, pendingDigest: JSON.stringify([...pendingIds].sort()) } };
|
||||
} catch (error) { ledger.exec('ROLLBACK'); throw error; }
|
||||
}
|
||||
function validCard(facts, cardHash, owner, sequence, maturity) {
|
||||
const nft = facts.nfts[cardHash];
|
||||
return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= maturity;
|
||||
}
|
||||
function deliveryId(request, order) { return request.deliveryIds[order.id]; }
|
||||
function processOrders(request, selected) {
|
||||
const disconnected = new Set(request.disconnectedIds);
|
||||
const pendingIds = new Set(request.pendingIds);
|
||||
const orders = [], listings = [];
|
||||
for (const row of selected) {
|
||||
const order = JSON.parse(row.data);
|
||||
const listingRow = db.prepare('SELECT data FROM listings WHERE id=?').get(order.listingId);
|
||||
if (!listingRow) throw new Error('Order listing missing during reconciliation');
|
||||
const listing = JSON.parse(listingRow.data);
|
||||
const id = deliveryId(request, order), delivery = request.deliveryTransactions[id];
|
||||
if (order.status === 'complete' && disconnected.has(id)) {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
}
|
||||
if (order.status === 'refunded' || order.status === 'expired') continue;
|
||||
const confirmations = delivery ? request.height - delivery.height + 1 : 0;
|
||||
order.deliveryConfirmations = confirmations;
|
||||
if (order.status === 'complete' || order.status === 'review') {
|
||||
if (!delivery || confirmations < order.requiredConfirmations) {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
}
|
||||
orders.push(order);
|
||||
continue;
|
||||
}
|
||||
if (delivery && order.status === 'awaiting-payment') {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
} else if (delivery) {
|
||||
order.transferId = id; order.status = 'delivery-pending';
|
||||
if (confirmations >= order.requiredConfirmations) {
|
||||
order.status = 'complete'; order.payout = 'mock-paid'; listing.status = 'sold';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'mock-paid';
|
||||
}
|
||||
} else if (order.status === 'awaiting-payment' && request.expectedJob.observedAt >= order.payBy) {
|
||||
order.status = 'expired'; listing.status = listing.kind === 'fixed' ? 'open' : 'expired';
|
||||
} else if (order.status !== 'awaiting-payment') {
|
||||
const pending = pendingIds.has(id);
|
||||
const conflict = request.pendingTransactions.some(tx => tx.cardIds.includes(order.cardHash) && tx.id !== id);
|
||||
if (!validCard(request, order.cardHash, order.seller, order.sequence, request.maturity) ||
|
||||
conflict || (request.expectedJob.observedAt >= order.deliverBy && !pending)) order.status = 'refund-required';
|
||||
else if (order.status === 'delivery-pending' && !pending) order.status = 'paid';
|
||||
}
|
||||
orders.push(order);
|
||||
listings.push(listing);
|
||||
}
|
||||
return { orders, listings };
|
||||
}
|
||||
function processListings(request, selected) {
|
||||
const pendingCards = new Set(request.pendingCardIds), listings = [];
|
||||
for (const row of selected) {
|
||||
const listing = JSON.parse(row.data);
|
||||
if (listing.status !== 'open') continue;
|
||||
if (listing.additionalFee?.bps !== request.additionalFee.bps || listing.additionalFee?.address !== request.additionalFee.address ||
|
||||
!validCard(request, listing.cardHash, listing.seller, listing.sequence, request.maturity) || pendingCards.has(listing.cardHash)) {
|
||||
listing.status = 'invalid';
|
||||
} else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt + request.paymentWindow) {
|
||||
listing.status = 'expired';
|
||||
} else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt && !listing.highestBid) {
|
||||
listing.status = 'expired';
|
||||
}
|
||||
listings.push(listing);
|
||||
}
|
||||
return { orders: [], listings };
|
||||
}
|
||||
function saveResults(result) {
|
||||
const order = db.prepare(`INSERT INTO orders(id,data) VALUES(?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data`);
|
||||
for (const item of result.orders) order.run(item.id, JSON.stringify(item));
|
||||
const listing = db.prepare(`INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data`);
|
||||
for (const item of result.listings) listing.run(item.id, item.cardHash, item.status, JSON.stringify(item));
|
||||
}
|
||||
|
||||
port.on('message', request => {
|
||||
try {
|
||||
let snapshot, nextJob;
|
||||
if (request.operation === 'reconcile-batch') {
|
||||
atomic(() => {
|
||||
assertCurrentJob(request.expectedJob);
|
||||
const selected = rows(request.stage, request.afterId, request.batchSize);
|
||||
const facts = readLedgerFacts(request, selected);
|
||||
snapshot = facts.snapshot;
|
||||
const prepared = { ...request, ...facts };
|
||||
const result = request.stage === 'orders' ? processOrders(prepared, selected) : processListings(prepared, selected);
|
||||
saveResults(result);
|
||||
nextJob = request.stage === 'orders'
|
||||
? selected.length < request.batchSize ? { ...request.expectedJob, stage: 'listings', afterId: null }
|
||||
: { ...request.expectedJob, afterId: selected.at(-1).id }
|
||||
: selected.length < request.batchSize ? { ...request.expectedJob, stage: 'finalize', afterId: null }
|
||||
: { ...request.expectedJob, afterId: selected.at(-1).id };
|
||||
db.prepare("UPDATE market_meta SET value=? WHERE key='canonical_reconciliation_job'")
|
||||
.run(JSON.stringify(nextJob));
|
||||
});
|
||||
} else if (request.operation === 'finalize') {
|
||||
atomic(() => {
|
||||
assertCurrentJob(request.expectedJob);
|
||||
db.prepare('DELETE FROM receipts WHERE expires < ?').run(request.observedAt - 86400_000);
|
||||
if (request.cursor) {
|
||||
db.prepare(`INSERT INTO market_meta(key,value) VALUES('canonical_event_cursor',?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(request.cursor);
|
||||
}
|
||||
db.prepare("DELETE FROM market_meta WHERE key='canonical_reconciliation_job'").run();
|
||||
});
|
||||
} else throw new Error('Unknown reconciliation worker operation');
|
||||
port.postMessage({ id: request.id, ok: true, snapshot, nextJob });
|
||||
} catch (error) {
|
||||
port.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : 'Worker batch failed' });
|
||||
}
|
||||
});
|
||||
port.on('close', () => { ledger?.close(); db.close(); });
|
||||
@@ -1 +0,0 @@
|
||||
// The runtime worker is reconciliation-commit-worker.js so it is independent of TS loaders.
|
||||
@@ -1,87 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import type { CustodyOrder } from '../../shared/custody.ts';
|
||||
import { saleTransactionSchema, saleTermsMessage } from '../../shared/sale-candidate.ts';
|
||||
import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot,
|
||||
saleCandidateTransactionId, termsForCustodyOrder, type SaleContext } from '../testnet/sale-candidate.ts';
|
||||
import { sha256, verifySignature } from '../testnet/consensus.ts';
|
||||
|
||||
const height = z.number().int().min(0).max(1000000);
|
||||
export const rehearsalSchema = z.object({
|
||||
policy: z.object({ coinbaseMaturity: height.positive(), lockDepth: height.positive(), outcomeDepth: height.positive(),
|
||||
maxLockBlocks: height.positive(), maxTransactions: z.number().int().min(1).max(64) }).strict(),
|
||||
seed: z.object({ height, mintedHeight: height, pageId: height.positive() }).strict(),
|
||||
lock: saleTransactionSchema.options[0],
|
||||
blocks: z.array(z.array(saleTransactionSchema).max(64)).min(1).max(128),
|
||||
}).strict();
|
||||
export type SaleRehearsal = z.infer<typeof rehearsalSchema>;
|
||||
|
||||
// This is an internal JSON-only seam, but still bound traversal before Zod or serialization.
|
||||
function checkEvidenceBudget(raw: unknown): void {
|
||||
let nodes = 0, bytes = 0;
|
||||
const seen = new WeakSet<object>();
|
||||
const visit = (value: unknown, depth: number): void => {
|
||||
if (++nodes > 32768 || depth > 12) throw new Error('Rehearsal evidence exceeds complexity budget');
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
|
||||
bytes += Buffer.byteLength(value) + 2;
|
||||
} else if (value !== null && typeof value === 'object') {
|
||||
if (seen.has(value)) throw new Error('Rehearsal evidence must be acyclic JSON');
|
||||
seen.add(value);
|
||||
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error('Rehearsal evidence must be JSON');
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > (Array.isArray(value) ? 128 : 64)) throw new Error('Rehearsal evidence exceeds collection budget');
|
||||
for (const key of keys) {
|
||||
bytes += Buffer.byteLength(key) + 4;
|
||||
const property = Object.getOwnPropertyDescriptor(value, key)!;
|
||||
if (!('value' in property)) throw new Error('Rehearsal evidence must not contain accessors');
|
||||
visit(property.value, depth + 1);
|
||||
}
|
||||
seen.delete(value);
|
||||
} else if (value !== undefined && value !== null && typeof value !== 'boolean' &&
|
||||
!(typeof value === 'number' && Number.isFinite(value))) throw new Error('Rehearsal evidence must be JSON');
|
||||
if (bytes > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
|
||||
};
|
||||
visit(raw, 0);
|
||||
}
|
||||
|
||||
/** Replays signed synthetic blocks from a caller-supplied seed, NOT a canonical-chain verifier.
|
||||
* No RPC/PoW, Monero, clock, signing key or HTTP route. Limited to one actual sandbox reservation.
|
||||
*/
|
||||
export function replayReservedSale(order: CustodyOrder, custodianId: string, raw: unknown, listingFeeBps: number) {
|
||||
checkEvidenceBudget(raw);
|
||||
const input = rehearsalSchema.parse(raw), context: SaleContext = saleContext(input.policy);
|
||||
if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
|
||||
if (input.lock.type !== 'sale-lock') throw new Error('Rehearsal requires signed lock authorization');
|
||||
const terms = input.lock.terms;
|
||||
if (terms.feeBps !== listingFeeBps) throw new Error('Rehearsal fee rate does not match reserved listing');
|
||||
const expected = termsForCustodyOrder({ ...order, status: 'awaiting-lock' }, context, { custodianId,
|
||||
host: terms.host, feeBps: terms.feeBps, lockUntilHeight: terms.lockUntilHeight, expiresHeight: terms.expiresHeight });
|
||||
if (saleTermsMessage(expected) !== saleTermsMessage(terms)) throw new Error('Rehearsal does not match reserved order terms');
|
||||
if (input.seed.mintedHeight > input.seed.height) throw new Error('Invalid rehearsal issuance height');
|
||||
const message = saleTermsMessage(terms);
|
||||
if (!verifySignature(input.lock.sellerAuthorization.publicKey, order.seller, message, input.lock.sellerAuthorization.signature) ||
|
||||
!verifySignature(input.lock.buyerAuthorization.publicKey, order.buyer, message, input.lock.buyerAuthorization.signature)) {
|
||||
throw new Error('Rehearsal requires trader consent');
|
||||
}
|
||||
const binding = sha256(JSON.stringify(['custody-sale-rehearsal-v1', custodianId, message, input.seed, input.policy]));
|
||||
const reservationDigest = sha256(JSON.stringify(['custody-reservation-rehearsal-v1', custodianId, order.id,
|
||||
order.buyer, order.seller, order.cardHash, order.sequence, order.grossAtomicXmr,
|
||||
order.sellerAtomicXmr, order.authorAtomicXmr, order.hostAtomicXmr, order.authorAddress,
|
||||
order.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address, binding]));
|
||||
let state = initialSaleState({ [order.cardHash]: { owner: order.seller, sequence: order.sequence,
|
||||
mintedHeight: input.seed.mintedHeight, pageId: input.seed.pageId } }, input.seed.height);
|
||||
let lockTransactionId: string | undefined, terminalTransactionId: string | undefined;
|
||||
for (const block of input.blocks) {
|
||||
for (const tx of block) {
|
||||
if (tx.type === 'sale-lock' ? saleId(tx.terms) !== saleId(terms) : tx.saleId !== saleId(terms)) throw new Error('Unrelated sale in rehearsal');
|
||||
if (tx.type === 'sale-complete' && tx.reservationDigest !== reservationDigest) throw new Error('Completion reservation commitment mismatch');
|
||||
if (tx.type === 'sale-lock') lockTransactionId = saleCandidateTransactionId(tx);
|
||||
else terminalTransactionId = saleCandidateTransactionId(tx);
|
||||
}
|
||||
state = applySaleBlock(state, block, context);
|
||||
}
|
||||
const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved';
|
||||
return { input, binding, reservationDigest, lockTransactionId, terminalTransactionId, stateRoot: saleStateRoot(state, context),
|
||||
decision: saleDecision(state, terms, context, previous), context };
|
||||
}
|
||||
@@ -1,664 +0,0 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, chmod } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds,
|
||||
type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts';
|
||||
import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing,
|
||||
type MarketOrder, type MarketInfo } from '../../shared/market.ts';
|
||||
import { isXmrListing, isXmrOrder, marketCommandV3Schema, marketMessageV3,
|
||||
type MarketActivityV3, type MarketCommandV3, type MarketListingRecord, type MarketListingV3,
|
||||
type MarketOrderRecord, type MarketOrderV3 } from '../../shared/market-v3.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
|
||||
import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts';
|
||||
import { RpcError } from '../resources.ts';
|
||||
import type { CanonicalEvents, IndexedNft } from '../testnet/canonical-index.ts';
|
||||
import { z } from 'zod';
|
||||
import { operatorMarketPolicy, resolvedMarketPolicy, permitsMarketAction, validateMarketPolicy, type MarketPolicy } from './policy.ts';
|
||||
import { PROFILE } from '../../shared/network.ts';
|
||||
|
||||
type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number; height?: number };
|
||||
submitTransfer(raw: unknown): Promise<string>; nft?(token: string): IndexedNft | undefined;
|
||||
canonicalTransaction?(id: string): { height: number; blockHash: string } | undefined;
|
||||
canonicalHead?(): number; canonicalEvents?(after: number, limit?: number): CanonicalEvents;
|
||||
pendingTransactions?(): TestnetSnapshot['pendingTransfers']; reconciliationDatabasePath?(): string };
|
||||
type ChainView = { height: number; pendingTransfers: TestnetSnapshot['pendingTransfers'];
|
||||
nft(token: string): IndexedNft | undefined; transaction(id: string): { height: number; blockHash: string } | undefined };
|
||||
type Config = { feeBps: number; confirmations: number } & MarketPolicy;
|
||||
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
paymentMode(env);
|
||||
const integer = (name: string, fallback: number, min: number, max: number) => {
|
||||
const text = env[name] ?? String(fallback);
|
||||
if (!/^\d+$/.test(text)) throw new Error(`Invalid ${name}`);
|
||||
const n = Number(text);
|
||||
if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`);
|
||||
return n;
|
||||
};
|
||||
return { ...operatorMarketPolicy(env), feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000),
|
||||
confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000) };
|
||||
}
|
||||
const PAYMENT_WINDOW = 30 * 60_000;
|
||||
const DELIVERY_WINDOW = 48 * 60 * 60_000;
|
||||
const RECONCILIATION_FRESH_MS = 15_000;
|
||||
const cursorSchema = z.object({ version: z.literal(1), streamId: z.string().uuid(),
|
||||
cursor: z.number().int().nonnegative().safe(), anchor: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
||||
const canonicalEventSchema = z.object({ sequence: z.number().int().positive().safe(), type: z.enum(['connect', 'disconnect']),
|
||||
height: z.number().int().positive().safe(), hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
previousHash: z.string().regex(/^[a-f0-9]{64}$/), transactionIds: z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(64),
|
||||
eventHash: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
||||
const canonicalEventPageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
anchor: z.string().regex(/^[a-f0-9]{64}$/), events: z.array(canonicalEventSchema).max(64),
|
||||
cursor: z.number().int().nonnegative().safe(), head: z.number().int().nonnegative().safe(), caughtUp: z.boolean() }).strict();
|
||||
const reconciliationJobSchema = z.object({ version: z.literal(1), stage: z.enum(['orders', 'listings', 'finalize']),
|
||||
afterId: z.string().nullable(), observedAt: z.number().int().nonnegative().safe(),
|
||||
tip: z.string().regex(/^[a-f0-9]{64}$/).nullable(), revision: z.number().int().nonnegative().safe().nullable(),
|
||||
eventPage: canonicalEventPageSchema.nullable() }).strict();
|
||||
// Keep each synchronous SQLite slice small enough to limit request-loop stalls.
|
||||
export const MARKET_RECONCILIATION_BATCH_SIZE = 32;
|
||||
const RECONCILIATION_JOB_KEY = 'canonical_reconciliation_job';
|
||||
|
||||
/** Node-local marketplace, NOT consensus escrow. Its SQLite journal is backed up with the ledger. */
|
||||
export class Market {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private queued = 0;
|
||||
private reconciled: { tip: string; revision: number; at: number } | undefined;
|
||||
private eventCursor = 0;
|
||||
private eventCheckpoint: z.infer<typeof cursorSchema> | undefined;
|
||||
private eventBacklog = false;
|
||||
private reconciliationJob: z.infer<typeof reconciliationJobSchema> | undefined;
|
||||
private readonly reconciliationWorker?: Worker;
|
||||
private readonly workerReadsLedger: boolean;
|
||||
private workerRequestId = 0;
|
||||
private readonly workerRequests = new Map<number, { resolve(message: Record<string, unknown>): void; reject(error: Error): void }>();
|
||||
private reconciliationWorkerBusy = false;
|
||||
private reconciliationWorkerError: string | undefined;
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly chain: Chain;
|
||||
readonly config: Config;
|
||||
readonly id: string;
|
||||
private readonly now: () => number;
|
||||
private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number, worker?: Worker) {
|
||||
this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now;
|
||||
this.reconciliationWorker = worker;
|
||||
this.workerReadsLedger = !!chain.reconciliationDatabasePath;
|
||||
worker?.on('message', (message: { id: number; ok: boolean; error?: string }) => {
|
||||
const pending = this.workerRequests.get(message.id);
|
||||
if (!pending) return;
|
||||
this.workerRequests.delete(message.id);
|
||||
if (message.ok) pending.resolve(message); else pending.reject(new Error(message.error ?? 'Reconciliation worker failed'));
|
||||
});
|
||||
worker?.on('error', error => {
|
||||
this.reconciliationWorkerError = 'Reconciliation worker failed';
|
||||
for (const pending of this.workerRequests.values()) pending.reject(error);
|
||||
this.workerRequests.clear();
|
||||
});
|
||||
worker?.on('exit', code => {
|
||||
if (!this.workerRequests.size) return;
|
||||
const error = new Error(`Reconciliation worker exited with code ${code}`);
|
||||
this.reconciliationWorkerError = 'Reconciliation worker stopped unexpectedly';
|
||||
for (const pending of this.workerRequests.values()) pending.reject(error);
|
||||
this.workerRequests.clear();
|
||||
});
|
||||
const cursor = db.prepare("SELECT value FROM market_meta WHERE key='canonical_event_cursor'").get() as { value: string } | undefined;
|
||||
if (cursor) {
|
||||
this.eventCheckpoint = cursorSchema.parse(JSON.parse(cursor.value));
|
||||
this.eventCursor = this.eventCheckpoint.cursor;
|
||||
}
|
||||
const job = db.prepare('SELECT value FROM market_meta WHERE key=?').get(RECONCILIATION_JOB_KEY) as { value: string } | undefined;
|
||||
if (job) { this.reconciliationJob = reconciliationJobSchema.parse(JSON.parse(job.value)); this.eventBacklog = true; }
|
||||
else this.eventBacklog = !!chain.canonicalHead && this.eventCursor < chain.canonicalHead();
|
||||
}
|
||||
|
||||
static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now,
|
||||
reconciliation: { worker?: boolean } = {}): Promise<Market> {
|
||||
await validateMarketPolicy(config);
|
||||
const folder = join(directory, PROFILE);
|
||||
await mkdir(folder, { recursive: true, mode: 0o700 });
|
||||
const file = join(folder, 'market.sqlite');
|
||||
const db = new DatabaseSync(file);
|
||||
let worker: Worker | undefined;
|
||||
try {
|
||||
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS market_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
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 one_active_listing ON listings(card) WHERE status IN ('open','reserved');
|
||||
CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS listings_by_status ON listings(status);
|
||||
CREATE INDEX IF NOT EXISTS listings_by_seller ON listings(json_extract(data,'$.seller'));
|
||||
CREATE INDEX IF NOT EXISTS orders_by_seller ON orders(json_extract(data,'$.seller'));
|
||||
CREATE INDEX IF NOT EXISTS orders_by_buyer ON orders(json_extract(data,'$.buyer'));`);
|
||||
const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined;
|
||||
let id: string;
|
||||
if (meta) {
|
||||
const identity = JSON.parse(meta.value);
|
||||
if (identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.version !== 1 ||
|
||||
typeof identity.id !== 'string') throw new Error('Marketplace network/schema mismatch');
|
||||
id = identity.id;
|
||||
} else {
|
||||
id = randomUUID();
|
||||
db.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, id }));
|
||||
}
|
||||
if (process.platform !== 'win32') await chmod(file, 0o600);
|
||||
worker = reconciliation.worker ? new Worker(new URL('./reconciliation-commit-worker.js', import.meta.url),
|
||||
{ workerData: { marketFile: file, ledgerFile: chain.reconciliationDatabasePath?.() ?? null }, execArgv: [] }) : undefined;
|
||||
worker?.unref();
|
||||
return new Market(db, chain, config, id, now, worker);
|
||||
} catch (error) { if (worker) await worker.terminate(); db.close(); throw error; }
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
if (this.reconciliationWorker) await this.reconciliationWorker.terminate();
|
||||
this.db.close();
|
||||
}
|
||||
isReconciliationWorkerBusy(): boolean { return this.reconciliationWorkerBusy; }
|
||||
isReconciliationWorkerFailed(): boolean { return !!this.reconciliationWorkerError; }
|
||||
diagnostics() {
|
||||
const states=this.db.prepare("SELECT json_extract(data,'$.status') AS status,COUNT(*) AS count FROM orders GROUP BY json_extract(data,'$.status')").all();
|
||||
return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states,
|
||||
canonicalEventCursor:this.eventCursor,canonicalEventHead:this.chain.canonicalHead?.()??null,eventBacklog:this.eventBacklog,
|
||||
reconciliationWorkerBusy:this.reconciliationWorkerBusy,reconciliationWorkerFailed:!!this.reconciliationWorkerError };
|
||||
}
|
||||
hasReconciliationBacklog(): boolean { return this.eventBacklog || !!this.reconciliationJob; }
|
||||
info(): MarketInfo {
|
||||
return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', marketVersion: 3, feeBps: this.config.feeBps,
|
||||
...resolvedMarketPolicy(this.config),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
deliveryConfirmations: this.config.confirmations, serverTime: this.now() };
|
||||
}
|
||||
private nextReconciliationRows(table: 'listings' | 'orders', afterId: string | null): { id: string; data: string }[] {
|
||||
return this.db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`)
|
||||
.all(afterId, afterId, MARKET_RECONCILIATION_BATCH_SIZE) as { id: string; data: string }[];
|
||||
}
|
||||
private listing(id: string): MarketListingRecord {
|
||||
const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined;
|
||||
if (!row) throw new Error('Listing not found');
|
||||
return JSON.parse(row.data) as MarketListingRecord;
|
||||
}
|
||||
private order(id: string): MarketOrderRecord {
|
||||
const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined;
|
||||
if (!row) throw new Error('Order not found');
|
||||
return JSON.parse(row.data) as MarketOrderRecord;
|
||||
}
|
||||
private saveListing(l: MarketListingRecord) {
|
||||
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data')
|
||||
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
|
||||
}
|
||||
private saveOrder(o: MarketOrderRecord) {
|
||||
this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o));
|
||||
}
|
||||
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 view(): ChainView {
|
||||
if (this.chain.nft && this.chain.canonicalTransaction && this.chain.pendingTransactions && this.chain.summary) {
|
||||
const height = this.chain.summary().height;
|
||||
if (height === undefined) throw new Error('Indexed chain height missing');
|
||||
return { height, pendingTransfers: this.chain.pendingTransactions(), nft: token => this.chain.nft!(token),
|
||||
transaction: id => this.chain.canonicalTransaction!(id) };
|
||||
}
|
||||
// Compatibility boundary for unit-test/legacy adapters only. The actual node always supplies indexed queries.
|
||||
const s = this.chain.snapshot();
|
||||
const txs = new Map(s.blocks.flatMap(b => b.transactions.map(tx => [transactionId(tx), { height: b.height, blockHash: b.hash }] as const)));
|
||||
return { height: s.blocks.length, pendingTransfers: s.pendingTransfers, transaction: id => txs.get(id), nft: token => {
|
||||
const block = s.blocks.find(b => b.tokenId === token);
|
||||
return block ? { block, owner: testnetOwnerOf(s, token)!, sequence: s.transfers.filter(tx => transactionCardIds(tx).includes(token)).length,
|
||||
confirmations: s.blocks.length + 1 - block.height } : undefined;
|
||||
} };
|
||||
}
|
||||
private card(view: ChainView, hash: string, owner: string, sequence: number): boolean {
|
||||
const nft = view.nft(hash);
|
||||
return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= CONSENSUS.coinbaseMaturity;
|
||||
}
|
||||
private deliveryId(order: MarketOrderRecord): string {
|
||||
// Identity excludes signature bytes. Recognize exact sale delivery through ANY submission route,
|
||||
// including peer gossip or a direct /api/transfer, even before the market records delivery intent.
|
||||
return transactionId({ network: NETWORK, chainId: CHAIN_ID, cardHash: order.cardHash,
|
||||
from: order.seller, to: order.buyer, sequence: order.sequence, publicKey: '', signature: '' });
|
||||
}
|
||||
private workerRequest(request: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const worker = this.reconciliationWorker;
|
||||
if (!worker) return Promise.reject(new Error('Reconciliation worker is unavailable'));
|
||||
const id = ++this.workerRequestId;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.workerRequests.set(id, { resolve, reject });
|
||||
try { worker.postMessage({ ...request, id }); }
|
||||
catch (error) { this.workerRequests.delete(id); reject(error instanceof Error ? error : new Error('Worker request failed')); }
|
||||
});
|
||||
}
|
||||
private scheduleWorkerContinuation(): void {
|
||||
setImmediate(() => { if (!this.reconciliationWorkerBusy && this.hasReconciliationBacklog()) this.reconcile(); }).unref();
|
||||
}
|
||||
private finalizeWorkerJob(job: z.infer<typeof reconciliationJobSchema>): void {
|
||||
const summary = this.chain.summary?.(), eventPage = job.eventPage as CanonicalEvents | null;
|
||||
const currentHead = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0;
|
||||
if ((summary && (job.tip !== summary.tip || job.revision !== summary.revision)) ||
|
||||
(eventPage && currentHead > eventPage.head)) {
|
||||
const events = this.chain.canonicalEvents?.(this.eventCursor, 64);
|
||||
if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId ||
|
||||
events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.');
|
||||
const restarted = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: this.now(),
|
||||
tip: summary?.tip ?? null, revision: summary?.revision ?? null, eventPage: events ?? null });
|
||||
this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?')
|
||||
.run(JSON.stringify(restarted), RECONCILIATION_JOB_KEY));
|
||||
this.reconciliationJob = restarted; this.eventBacklog = true; this.reconciliationWorkerError = undefined;
|
||||
this.scheduleWorkerContinuation();
|
||||
return;
|
||||
}
|
||||
if (this.reconciliationWorkerBusy) return;
|
||||
this.reconciliationWorkerBusy = true;
|
||||
const cursor = eventPage ? JSON.stringify({ version: 1, streamId: eventPage.streamId,
|
||||
cursor: eventPage.cursor, anchor: eventPage.anchor }) : null;
|
||||
void this.workerRequest({ operation: 'finalize', expectedJob: job, cursor, observedAt: job.observedAt })
|
||||
.then(() => {
|
||||
this.reconciliationJob = undefined;
|
||||
if (eventPage) {
|
||||
this.eventCursor = eventPage.cursor;
|
||||
this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor };
|
||||
}
|
||||
const current = this.chain.summary?.();
|
||||
const head = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0;
|
||||
this.eventBacklog = (!!eventPage && (!eventPage.caughtUp || head > eventPage.cursor)) ||
|
||||
(!!current && (current.tip !== job.tip || current.revision !== job.revision));
|
||||
if (current && !this.eventBacklog) this.reconciled = { ...current, at: this.now() };
|
||||
this.reconciliationWorkerError = undefined;
|
||||
})
|
||||
.catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker finalize failed'; this.eventBacklog = true; })
|
||||
.finally(() => {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
if (!this.reconciliationWorkerError && this.hasReconciliationBacklog()) this.scheduleWorkerContinuation();
|
||||
});
|
||||
}
|
||||
private dispatchWorkerBatch(job: z.infer<typeof reconciliationJobSchema>): void {
|
||||
if (!this.reconciliationWorker || this.reconciliationWorkerBusy || job.stage === 'finalize') return;
|
||||
this.reconciliationWorkerBusy = true;
|
||||
try {
|
||||
let fallbackFacts: Record<string, unknown> | null = null;
|
||||
if (!this.workerReadsLedger) {
|
||||
const rows = this.nextReconciliationRows(job.stage, job.afterId), s = this.view();
|
||||
const pendingTransactions = s.pendingTransfers.map(tx => ({ id: transactionId(tx), cardIds: transactionCardIds(tx) }));
|
||||
const pendingIds = pendingTransactions.map(tx => tx.id);
|
||||
const pendingCardIds = [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))];
|
||||
const deliveryIds: Record<string, string> = Object.create(null), targetCards = new Set<string>();
|
||||
if (job.stage === 'orders') for (const row of rows) {
|
||||
const order = JSON.parse(row.data) as MarketOrderRecord;
|
||||
deliveryIds[order.id] = this.deliveryId(order); targetCards.add(order.cardHash);
|
||||
} else for (const row of rows) targetCards.add((JSON.parse(row.data) as MarketListingRecord).cardHash);
|
||||
const deliveryTransactions: Record<string, { height: number; blockHash: string } | null> = Object.create(null);
|
||||
const nfts: Record<string, { owner: string; sequence: number; confirmations: number } | null> = Object.create(null);
|
||||
if (job.stage === 'orders') for (const id of Object.values(deliveryIds)) deliveryTransactions[id] = s.transaction(id) ?? null;
|
||||
for (const card of targetCards) {
|
||||
const nft = s.nft(card); nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: nft.confirmations } : null;
|
||||
}
|
||||
fallbackFacts = { height: s.height, pendingTransactions, pendingIds, pendingCardIds, deliveryIds, deliveryTransactions, nfts };
|
||||
}
|
||||
const disconnectedIds = job.eventPage?.events.filter(event => event.type === 'disconnect').flatMap(event => event.transactionIds) ?? [];
|
||||
void this.workerRequest({ operation: 'reconcile-batch', expectedJob: job, stage: job.stage,
|
||||
afterId: job.afterId, batchSize: MARKET_RECONCILIATION_BATCH_SIZE, fallbackFacts,
|
||||
genesisHash: GENESIS_HASH, network: NETWORK, chainId: CHAIN_ID, disconnectedIds,
|
||||
maturity: CONSENSUS.coinbaseMaturity, additionalFee: ADDITIONAL_MARKET_FEE, paymentWindow: PAYMENT_WINDOW })
|
||||
.then(message => {
|
||||
const nextJob = reconciliationJobSchema.parse(message.nextJob);
|
||||
this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true;
|
||||
if (this.workerReadsLedger) this.refenceWorkerSnapshot(job, message.snapshot as { tip: string; eventHead: number; pendingDigest: string });
|
||||
})
|
||||
.catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation failed'; this.eventBacklog = true; })
|
||||
.finally(() => {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
if (!this.reconciliationWorkerError) this.scheduleWorkerContinuation();
|
||||
});
|
||||
} catch (error) {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation preparation failed';
|
||||
this.eventBacklog = true;
|
||||
}
|
||||
}
|
||||
private refenceWorkerSnapshot(job: z.infer<typeof reconciliationJobSchema>, snapshot: { tip: string; eventHead: number; pendingDigest: string }): void {
|
||||
const summary = this.chain.summary?.();
|
||||
if (!summary) return;
|
||||
const pendingDigest = this.chain.pendingTransactions
|
||||
? JSON.stringify(this.chain.pendingTransactions().map(transactionId).sort()) : '';
|
||||
const pageHead = job.eventPage?.head ?? snapshot.eventHead;
|
||||
if (summary.tip === snapshot.tip && (job.revision === null || summary.revision === job.revision) && snapshot.eventHead <= pageHead &&
|
||||
pendingDigest === snapshot.pendingDigest) return;
|
||||
const events = this.chain.canonicalEvents?.(this.eventCursor, 64);
|
||||
if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId ||
|
||||
events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.');
|
||||
const restarted = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: this.now(),
|
||||
tip: summary.tip, revision: summary.revision, eventPage: events ?? null });
|
||||
this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?')
|
||||
.run(JSON.stringify(restarted), RECONCILIATION_JOB_KEY));
|
||||
this.reconciliationJob = restarted; this.eventBacklog = true;
|
||||
}
|
||||
/** Reconcile one durable keyset batch from canonical transaction identity, never mempool acknowledgement. */
|
||||
private reconcile() {
|
||||
if (this.reconciliationWorkerBusy || this.reconciliationWorkerError) return;
|
||||
const now = this.now();
|
||||
const summary = this.chain.summary?.();
|
||||
if (!this.reconciliationJob) {
|
||||
const events = this.chain.canonicalEvents?.(this.eventCursor, 64);
|
||||
if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId ||
|
||||
events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.');
|
||||
const job = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: now,
|
||||
tip: summary?.tip ?? null, revision: summary?.revision ?? null, eventPage: events ?? null });
|
||||
this.atomic(() => this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?)')
|
||||
.run(RECONCILIATION_JOB_KEY, JSON.stringify(job)));
|
||||
this.reconciliationJob = job; this.eventBacklog = true;
|
||||
}
|
||||
|
||||
let job = this.reconciliationJob;
|
||||
if (summary && (job.tip !== summary.tip || job.revision !== summary.revision)) {
|
||||
// Canonical state changed while a multi-batch scan was running. Rewind the
|
||||
// keyset and bind a fresh event page so every row is checked against one
|
||||
// observed chain revision before the canonical cursor can advance.
|
||||
const events = this.chain.canonicalEvents?.(this.eventCursor, 64);
|
||||
if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId ||
|
||||
events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.');
|
||||
job = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: now,
|
||||
tip: summary.tip, revision: summary.revision, eventPage: events ?? null });
|
||||
this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?')
|
||||
.run(JSON.stringify(job), RECONCILIATION_JOB_KEY));
|
||||
this.reconciliationJob = job;
|
||||
this.eventBacklog = true;
|
||||
}
|
||||
if (job.stage === 'finalize') {
|
||||
if (this.reconciliationWorker) { this.finalizeWorkerJob(job); return; }
|
||||
const eventPage = job.eventPage as CanonicalEvents | null;
|
||||
this.atomic(() => {
|
||||
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000);
|
||||
if (eventPage) this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
||||
.run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId,
|
||||
cursor: eventPage.cursor, anchor: eventPage.anchor }));
|
||||
this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY);
|
||||
});
|
||||
this.reconciliationJob = undefined;
|
||||
if (eventPage) {
|
||||
this.eventCursor = eventPage.cursor;
|
||||
this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor };
|
||||
}
|
||||
const head = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0;
|
||||
this.eventBacklog = !!eventPage && (!eventPage.caughtUp || head > eventPage.cursor);
|
||||
const current = this.chain.summary?.();
|
||||
if (current && !this.eventBacklog) this.reconciled = { ...current, at: now };
|
||||
return;
|
||||
}
|
||||
if (this.reconciliationWorker) { this.dispatchWorkerBatch(job); return; }
|
||||
const s = this.view(), pendingIds = new Set(s.pendingTransfers.map(transactionId));
|
||||
const eventPage = job.eventPage as CanonicalEvents | null;
|
||||
const disconnected = new Set(eventPage?.events.filter(e => e.type === 'disconnect').flatMap(e => e.transactionIds) ?? []);
|
||||
const rows = this.nextReconciliationRows(job.stage, job.afterId);
|
||||
let nextJob: z.infer<typeof reconciliationJobSchema> | undefined;
|
||||
let completed = false;
|
||||
this.atomic(() => {
|
||||
if (job.stage === 'orders' && rows.length) {
|
||||
for (const row of rows) {
|
||||
const o = JSON.parse(row.data) as MarketOrderRecord;
|
||||
const l = this.listing(o.listingId);
|
||||
// A disconnect of completed delivery is sticky even if the same transaction has reappeared.
|
||||
if (o.status === 'complete' && disconnected.has(this.deliveryId(o))) {
|
||||
o.status = 'review'; o.payout = 'review';
|
||||
if (o.additionalFee) o.additionalFee.payout = 'review';
|
||||
this.saveOrder(o);
|
||||
}
|
||||
if (o.status === 'refunded' || o.status === 'expired') continue;
|
||||
const deliveryId = this.deliveryId(o);
|
||||
const delivery = s.transaction(deliveryId);
|
||||
const confirmations = delivery ? s.height - delivery.height + 1 : 0;
|
||||
o.deliveryConfirmations = confirmations;
|
||||
if (o.status === 'complete' || o.status === 'review') {
|
||||
if (!delivery || confirmations < o.requiredConfirmations) {
|
||||
o.status = 'review'; o.payout = 'review';
|
||||
if (o.additionalFee) o.additionalFee.payout = 'review';
|
||||
}
|
||||
this.saveOrder(o); continue; // Never automatically pay a second time after a reorganization.
|
||||
}
|
||||
if (delivery && o.status === 'awaiting-payment') {
|
||||
o.status = 'review'; o.payout = 'review';
|
||||
if (o.additionalFee) o.additionalFee.payout = 'review';
|
||||
} else if (delivery) {
|
||||
o.transferId = deliveryId; o.status = 'delivery-pending';
|
||||
if (confirmations >= o.requiredConfirmations) {
|
||||
o.status = 'complete'; o.payout = 'mock-paid'; l.status = 'sold';
|
||||
if (o.additionalFee) o.additionalFee.payout = 'mock-paid';
|
||||
}
|
||||
} else if (o.status === 'awaiting-payment' && job.observedAt >= o.payBy) {
|
||||
o.status = 'expired'; l.status = l.kind === 'fixed' ? 'open' : 'expired';
|
||||
} else if (o.status !== 'awaiting-payment') {
|
||||
const pending = pendingIds.has(deliveryId);
|
||||
const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== deliveryId);
|
||||
if (!this.card(s, o.cardHash, o.seller, o.sequence) || conflict || (job.observedAt >= o.deliverBy && !pending)) o.status = 'refund-required';
|
||||
else if (o.status === 'delivery-pending' && !pending) o.status = 'paid';
|
||||
}
|
||||
this.saveOrder(o); this.saveListing(l);
|
||||
}
|
||||
} else if (job.stage === 'listings' && rows.length) {
|
||||
for (const row of rows) {
|
||||
const l = JSON.parse(row.data) as MarketListingRecord;
|
||||
if (l.status !== 'open') continue;
|
||||
if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
|
||||
!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid';
|
||||
else if (l.kind === 'auction' && job.observedAt >= l.endsAt! + PAYMENT_WINDOW) l.status = 'expired';
|
||||
else if (l.kind === 'auction' && job.observedAt >= l.endsAt! && !l.highestBid) l.status = 'expired';
|
||||
this.saveListing(l);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.stage === 'orders') {
|
||||
if (rows.length < MARKET_RECONCILIATION_BATCH_SIZE) nextJob = { ...job, stage: 'listings', afterId: null };
|
||||
else nextJob = { ...job, afterId: rows.at(-1)!.id };
|
||||
} else {
|
||||
if (rows.length < MARKET_RECONCILIATION_BATCH_SIZE) {
|
||||
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000);
|
||||
if (eventPage) {
|
||||
this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
||||
.run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId,
|
||||
cursor: eventPage.cursor, anchor: eventPage.anchor }));
|
||||
}
|
||||
this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY);
|
||||
completed = true;
|
||||
} else nextJob = { ...job, afterId: rows.at(-1)!.id };
|
||||
}
|
||||
if (nextJob) this.db.prepare('UPDATE market_meta SET value=? WHERE key=?').run(JSON.stringify(nextJob), RECONCILIATION_JOB_KEY);
|
||||
});
|
||||
|
||||
if (completed) {
|
||||
this.reconciliationJob = undefined;
|
||||
if (eventPage) {
|
||||
this.eventCursor = eventPage.cursor;
|
||||
this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor };
|
||||
// The ledger can append events while large journals are processed over many bounded calls.
|
||||
// Do not mark the market reconciled to a newer tip until those post-page events are consumed.
|
||||
const currentHead = this.chain.canonicalHead?.() ?? eventPage.head;
|
||||
this.eventBacklog = !eventPage.caughtUp || currentHead > eventPage.cursor;
|
||||
} else this.eventBacklog = false;
|
||||
const current = this.chain.summary?.();
|
||||
if (current && !this.eventBacklog) this.reconciled = { ...current, at: now };
|
||||
} else {
|
||||
this.reconciliationJob = nextJob;
|
||||
this.eventBacklog = true;
|
||||
}
|
||||
// Empty/short order pages can advance to the listing stage in the same bounded call. This
|
||||
// avoids rejecting the first command on an empty market while still capping work at 2×128 rows.
|
||||
if (!completed && job.stage === 'orders' && rows.length < MARKET_RECONCILIATION_BATCH_SIZE) this.reconcile();
|
||||
}
|
||||
browse(offset = 0): { listings: MarketListingRecord[]; next: number | null; info: MarketInfo } {
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page');
|
||||
// Reconciliation must not interleave with delivery submission while its chain await is outstanding.
|
||||
const summary = this.chain.summary?.();
|
||||
const last = this.reconciled;
|
||||
if (!this.queued && (this.hasReconciliationBacklog() || !summary || !last || summary.tip !== last.tip ||
|
||||
summary.revision !== last.revision || this.now() - last.at >= RECONCILIATION_FRESH_MS)) this.reconcile();
|
||||
if (!resolvedMarketPolicy(this.config).marketEnabled) return { listings: [], next: null, info: this.info() };
|
||||
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 { listings: rows.slice(0,24).map(r=>JSON.parse(r.data)), next: rows.length>24?offset+24:null, info: this.info() };
|
||||
}
|
||||
execute(raw: unknown): Promise<MarketActivityV3 | MarketListingRecord | MarketOrderRecord> {
|
||||
const current = marketCommandV3Schema.safeParse(raw);
|
||||
const cmd = current.success ? current.data : marketCommandSchema.parse(raw);
|
||||
if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly'));
|
||||
this.queued++;
|
||||
const result = this.queue.then(async () => {
|
||||
try { return await this.run(cmd); }
|
||||
finally { this.queued--; }
|
||||
});
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
private async run(c: MarketCommand | MarketCommandV3): Promise<MarketActivityV3 | MarketListingRecord | MarketOrderRecord> {
|
||||
const now = this.now();
|
||||
const isV3 = 'version' in c;
|
||||
const message = isV3 ? marketMessageV3(c) : marketMessage(c);
|
||||
if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 ||
|
||||
!verifySignature(c.publicKey, c.actor, message, c.signature)) throw new Error('Invalid or expired marketplace signature');
|
||||
const key = `${c.actor}:${c.nonce}`, digest = message;
|
||||
const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
|
||||
if (old) {
|
||||
if (old.digest !== digest) throw new Error('Nonce already used for different terms');
|
||||
return JSON.parse(old.data);
|
||||
}
|
||||
if (!permitsMarketAction(this.config, c.action.type)) throw new Error('New market trading is disabled by this host; existing activity and resolution remain available');
|
||||
const a = c.action;
|
||||
const summary = this.chain.summary?.(), last = this.reconciled;
|
||||
const stale = !summary || !last || summary.tip !== last.tip || summary.revision !== last.revision ||
|
||||
this.now() - last.at >= RECONCILIATION_FRESH_MS;
|
||||
if (this.hasReconciliationBacklog() || stale || (!this.reconciliationWorker && a.type !== 'activity')) this.reconcile();
|
||||
if (this.eventBacklog && a.type !== 'activity') throw new RpcError(503, 'Market canonical reconciliation is catching up; retry shortly.');
|
||||
if (a.type === 'activity') return {
|
||||
orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC")
|
||||
.all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord),
|
||||
listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC")
|
||||
.all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) };
|
||||
if (a.type === 'deliver') {
|
||||
const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer);
|
||||
if (isV3 && !isXmrOrder(o) || !isV3 && isXmrOrder(o) || o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver under the order terms');
|
||||
if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer ||
|
||||
!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale');
|
||||
// Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID.
|
||||
o.transfer = tx; o.transferId = transactionId(tx); o.status = 'delivery-pending'; this.saveOrder(o);
|
||||
await this.chain.submitTransfer(tx);
|
||||
return this.receipt(key, digest, c.expiresAt, o);
|
||||
}
|
||||
return this.atomic(() => {
|
||||
const result = isV3 ? this.mutateV3(c) : this.mutateV2(c);
|
||||
return this.receipt(key, digest, c.expiresAt, result);
|
||||
});
|
||||
}
|
||||
private receipt<T>(key: string, digest: string, expires: number, value: T): T {
|
||||
this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value));
|
||||
return value;
|
||||
}
|
||||
private mutateV2(c: MarketCommand): MarketActivityV3 | MarketListingRecord | MarketOrderRecord {
|
||||
const a = c.action, now = this.now(), s = this.view();
|
||||
if (a.type === 'list') {
|
||||
throw new Error('USD listing terms are retired; create a new native-XMR listing');
|
||||
}
|
||||
if (a.type === 'cancel') {
|
||||
const l = this.listing(a.listingId);
|
||||
if (isXmrListing(l) || l.status !== 'open') throw new Error('USD legacy listing cannot be changed under this command');
|
||||
if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled');
|
||||
l.status = 'cancelled'; this.saveListing(l); return l;
|
||||
}
|
||||
if (a.type === 'mock-pay' || a.type === 'refund') {
|
||||
const o = this.order(a.orderId);
|
||||
if (isXmrOrder(o)) throw new Error('Use native-XMR signed terms for this order');
|
||||
if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action');
|
||||
if (a.type === 'mock-pay') {
|
||||
if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable');
|
||||
if (!this.card(s, o.cardHash, o.seller, o.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash))) throw new Error('Seller card changed; do not pay');
|
||||
o.status = 'paid'; o.deliverBy = now + DELIVERY_WINDOW;
|
||||
} else {
|
||||
if (o.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash)) ||
|
||||
s.transaction(this.deliveryId(o))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer');
|
||||
o.status = 'refunded'; const l = this.listing(o.listingId); l.status = 'cancelled'; this.saveListing(l);
|
||||
}
|
||||
this.saveOrder(o); return o;
|
||||
}
|
||||
if (a.type === 'activity') return {
|
||||
orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC")
|
||||
.all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord),
|
||||
listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC")
|
||||
.all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) };
|
||||
if (a.type === 'deliver') throw new Error('Legacy delivery is handled by the common signed-order delivery path');
|
||||
throw new Error('Legacy USD bids and checkout are retired; only already-created orders may proceed');
|
||||
}
|
||||
|
||||
private mutateV3(c: MarketCommandV3): MarketActivityV3 | MarketListingRecord | MarketOrderRecord {
|
||||
const a = c.action, now = this.now(), s = this.view();
|
||||
if (a.type === 'list') {
|
||||
if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again');
|
||||
const hostFeeAddress = resolvedMarketPolicy(this.config).hostFeeAddress;
|
||||
if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient changed; review and sign again');
|
||||
if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending');
|
||||
if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end');
|
||||
const activeListings = this.db.prepare(`SELECT COUNT(*) AS n FROM listings
|
||||
WHERE json_extract(data,'$.seller')=? AND status IN ('open','reserved')`).get(c.actor) as { n: number };
|
||||
if (activeListings.n >= 100) throw new Error('Limit: 100 active listings per seller');
|
||||
if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required');
|
||||
if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed');
|
||||
const mint = s.nft(a.cardHash)!.block;
|
||||
const l: MarketListingV3 = { id: randomUUID(), termsVersion: 3, cardHash: a.cardHash, sequence: a.sequence,
|
||||
seller: c.actor, title: mint.article.title, image: mint.article.image!, kind: a.kind,
|
||||
priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null };
|
||||
this.saveListing(l); return l;
|
||||
}
|
||||
if (a.type === 'retire-legacy-listing') {
|
||||
const listing = this.listing(a.listingId);
|
||||
if (isXmrListing(listing)) throw new Error('Native-XMR listings use the signed cancel action');
|
||||
if (listing.seller !== c.actor || !['open','invalid'].includes(listing.status)) throw new Error('Only the seller can retire an unreserved legacy listing');
|
||||
if (listing.highestBid) throw new Error('Legacy auction has bids; resolve or wait for its original terms before retirement');
|
||||
if (this.db.prepare("SELECT 1 FROM orders WHERE json_extract(data,'$.listingId')=? AND json_extract(data,'$.status') IN ('awaiting-payment','paid','delivery-pending','refund-required','review') LIMIT 1").get(listing.id)) {
|
||||
throw new Error('Legacy listing has an in-flight order; preserve that order under its original terms');
|
||||
}
|
||||
listing.status = 'cancelled'; this.saveListing(listing); return listing;
|
||||
}
|
||||
if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') {
|
||||
const l = this.listing(a.listingId);
|
||||
if (!isXmrListing(l)) throw new Error('Legacy USD listings are read-only; seller must retire and relist with fresh XMR authorization');
|
||||
if (l.status !== 'open') throw new Error('Listing is no longer available');
|
||||
if (a.type === 'cancel') {
|
||||
if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled');
|
||||
l.status = 'cancelled'; this.saveListing(l); return l;
|
||||
}
|
||||
if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card');
|
||||
if (a.type === 'bid') {
|
||||
if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids');
|
||||
const minimum = l.highestBid ? BigInt(l.highestBid.priceAtomicXmr) + 1n : BigInt(l.priceAtomicXmr);
|
||||
if (BigInt(a.priceAtomicXmr) < minimum) throw new Error('Bid must meet the start price and exceed the current bid by one atomic unit');
|
||||
l.highestBid = { bidder: c.actor, priceAtomicXmr: a.priceAtomicXmr }; this.saveListing(l); return l;
|
||||
}
|
||||
const price = l.kind === 'fixed' ? l.priceAtomicXmr : l.highestBid?.priceAtomicXmr;
|
||||
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
||||
if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient does not match signed listing terms');
|
||||
if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing');
|
||||
if (price !== a.priceAtomicXmr || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again');
|
||||
if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available');
|
||||
if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required');
|
||||
const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW;
|
||||
const order: MarketOrderV3 = { id: randomUUID(), termsVersion: 3, listingId: l.id, seller: l.seller, buyer: c.actor,
|
||||
cardHash: l.cardHash, sequence: l.sequence, priceAtomicXmr: price!, feeBps: l.feeBps, hostFeeAddress, createdAt: now, payBy,
|
||||
deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', ...mockXmrQuote(price!, l.feeBps),
|
||||
payout: 'not-started', deliveryConfirmations: 0, requiredConfirmations: this.config.confirmations };
|
||||
l.status = 'reserved'; this.saveListing(l); this.saveOrder(order); return order;
|
||||
}
|
||||
if (a.type === 'mock-pay' || a.type === 'refund') {
|
||||
const order = this.order(a.orderId);
|
||||
if (!isXmrOrder(order)) throw new Error('Legacy USD orders keep their original terms and cannot be changed through native-XMR actions');
|
||||
if (order.buyer !== c.actor) throw new Error('Only the order buyer can perform this action');
|
||||
if (a.type === 'mock-pay') {
|
||||
if (order.status !== 'awaiting-payment' || now >= order.payBy) throw new Error('Invoice is not payable');
|
||||
if (!this.card(s, order.cardHash, order.seller, order.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash))) throw new Error('Seller card changed; do not pay');
|
||||
order.status = 'paid'; order.deliverBy = now + DELIVERY_WINDOW;
|
||||
} else {
|
||||
if (order.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash)) ||
|
||||
s.transaction(this.deliveryId(order))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer');
|
||||
order.status = 'refunded'; const listing = this.listing(order.listingId); listing.status = 'cancelled'; this.saveListing(listing);
|
||||
}
|
||||
this.saveOrder(order); return order;
|
||||
}
|
||||
throw new Error('Unsupported native-XMR market action');
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { keccak } from 'hash-wasm';
|
||||
import { parseXmr } from '../../shared/xmr-amount.ts';
|
||||
|
||||
const alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const decodedSizes:Record<number,number>={2:1,3:2,5:3,6:4,7:5,9:6,10:7,11:8};
|
||||
/** Support only mainnet standard/subaddresses. Integrated/payment-ID addresses need a separate policy. */
|
||||
async function validateMainnetAddressPrefix(address:string):Promise<number>{
|
||||
if(typeof address!=='string'||address.length!==95)throw new Error('Expected a mainnet standard XMR address or subaddress');
|
||||
const bytes:number[]=[];
|
||||
for(let start=0;start<address.length;start+=11){
|
||||
const chunk=address.slice(start,start+11),size=decodedSizes[chunk.length];
|
||||
if(!size)throw new Error('Invalid XMR Base58 block');
|
||||
let value=0n;
|
||||
for(const c of chunk){const index=alphabet.indexOf(c);if(index<0)throw new Error('Invalid XMR Base58 alphabet');value=value*58n+BigInt(index);}
|
||||
const output=Array<number>(size).fill(0);
|
||||
for(let i=size-1;i>=0;i--){output[i]=Number(value&255n);value>>=8n;}
|
||||
if(value)throw new Error('XMR Base58 block overflow');bytes.push(...output);
|
||||
}
|
||||
if(bytes.length!==69||![18,42].includes(bytes[0]))throw new Error('XMR address network/type is not supported');
|
||||
const decoded=Uint8Array.from(bytes),expected=(await keccak(decoded.slice(0,-4),256)).slice(0,8);
|
||||
if(Buffer.from(decoded.slice(-4)).toString('hex')!==expected)throw new Error('XMR checksum mismatch');
|
||||
return bytes[0];
|
||||
}
|
||||
export async function validateXmrAddress(address:string):Promise<string>{
|
||||
await validateMainnetAddressPrefix(address);
|
||||
return address;
|
||||
}
|
||||
export async function validateXmrSubaddress(address:string):Promise<string>{
|
||||
if(await validateMainnetAddressPrefix(address)!==42)throw new Error('Expected a Monero mainnet subaddress');
|
||||
return address;
|
||||
}
|
||||
export function atomicXmr(value:string):bigint{
|
||||
return parseXmr(value);
|
||||
}
|
||||
export function fiatCents(value:string):number{
|
||||
if(typeof value!=='string'||value.length>10||!/^(0|[1-9]\d{0,6})(\.\d{1,2})?(?![\s\S])/.test(value))throw new Error('Invalid USD decimal');
|
||||
const [whole,fraction='']=value.split('.');return Number(whole)*100+Number(fraction.padEnd(2,'0'));
|
||||
}
|
||||
@@ -4,28 +4,16 @@ const schema=z.object({schema:z.literal('wikideck-operations-v1'),observedAt:z.s
|
||||
native:z.object({available:z.boolean(),queued:nonnegative}),
|
||||
ledger:z.object({height:nonnegative,mutationQueue:nonnegative,tipAgeSeconds:nonnegative.nullable()}),
|
||||
peers:z.object({configured:nonnegative,connected:nonnegative}),
|
||||
eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable(),
|
||||
market:z.object({queued:nonnegative,lastReconciledAt:nonnegative.nullable(),
|
||||
orderStates:z.array(z.object({status:z.string(),count:nonnegative})).max(20)}),
|
||||
custody:z.object({backend:z.string(),mutationHold:z.boolean().optional(),integrityHold:z.boolean().optional(),
|
||||
audit:z.object({status:z.enum(['ok','failed']),lastAttemptAt:nonnegative,lastSuccessAt:nonnegative,
|
||||
failureCode:z.string().nullable(),stale:z.boolean()}).optional()}).optional()});
|
||||
eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable()});
|
||||
export function checkOperations(raw:unknown,now=Date.now()){
|
||||
const s=schema.parse(raw),warnings:string[]=[];
|
||||
const observation=Date.parse(s.observedAt);
|
||||
if(Math.abs(now-observation)>60000)warnings.push('diagnostics-clock-or-freshness');
|
||||
if(!s.native.available)warnings.push('native-engine-unavailable');
|
||||
if(s.native.queued>=48||s.ledger.mutationQueue>=192||s.market.queued>=48)warnings.push('queue-pressure');
|
||||
if(s.native.queued>=48||s.ledger.mutationQueue>=192)warnings.push('queue-pressure');
|
||||
if(!s.peers.configured||!s.peers.connected)warnings.push('peer-isolation');
|
||||
if(s.ledger.height===0||s.ledger.tipAgeSeconds===null||s.ledger.tipAgeSeconds>3600)warnings.push('empty-or-stale-chain');
|
||||
if(s.eventLoop.meanMs>250)warnings.push('event-loop-delay');
|
||||
if(!s.market.lastReconciledAt||now-s.market.lastReconciledAt>60000)warnings.push('market-reconciliation-stale');
|
||||
if(s.market.orderStates.some(r=>(r.status==='review'||r.status==='refund-required')&&r.count>0))warnings.push('market-operator-review');
|
||||
if(s.custody?.backend==='postgres') {
|
||||
const audit=s.custody.audit;
|
||||
if(s.custody.mutationHold||s.custody.integrityHold||audit?.status==='failed')warnings.push('custody-financial-review');
|
||||
if(!audit||audit.stale||now-audit.lastSuccessAt>120000||audit.lastSuccessAt>now+60000)warnings.push('custody-audit-stale');
|
||||
}
|
||||
const disk=z.object({availableBytes:nonnegative}).safeParse(s.disk);
|
||||
if(!disk.success||!s.diskSampledAt||now-s.diskSampledAt>90000)warnings.push('disk-sample-unavailable-or-stale');
|
||||
else if(disk.data.availableBytes<512*1024*1024)warnings.push('disk-low');
|
||||
|
||||
@@ -35,7 +35,7 @@ export function startOperations(port: number | undefined, snapshot: ()=>unknown,
|
||||
if(!directory)return;
|
||||
try {
|
||||
const fs=await statfs(directory);
|
||||
const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal','market.sqlite','market.sqlite-wal'].map(async name=>{
|
||||
const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal'].map(async name=>{
|
||||
try{return {name,bytes:(await stat(join(directory,name))).size};}
|
||||
catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return {name,bytes:0};throw error;}
|
||||
}));
|
||||
|
||||
+9
-22
@@ -1,23 +1,13 @@
|
||||
import { IS_MAINNET, IS_PILOT } from '../shared/network.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { IS_MAINNET } from '../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from './launch.ts';
|
||||
import { marketConfig } from './market/store.ts';
|
||||
|
||||
export type LaunchCheck = { id: string; status: 'pass' | 'blocked' | 'unverified'; detail: string };
|
||||
/** Read-only local assessment. Never opens a ledger, starts PoW, contacts peers, or sets approvals.
|
||||
* Configured approvals cannot resolve findings that require new code or independent evidence. */
|
||||
export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
|
||||
const checks: LaunchCheck[] = [{ id: 'network-selection', status: IS_MAINNET || IS_PILOT ? 'pass' : 'blocked',
|
||||
detail: IS_MAINNET ? 'Separate mainnet candidate selected.' : IS_PILOT
|
||||
? 'Private pilot chain selected; this is not a public or production profile.'
|
||||
: 'Testnet selected; this is not a production profile.' }];
|
||||
try {
|
||||
marketConfig(env);
|
||||
checks.push({ id: 'mock-payment-boundary', status: 'pass', detail: 'Only mock payments are supported; market configuration is valid.' });
|
||||
} catch {
|
||||
checks.push({ id: 'mock-payment-boundary', status: 'blocked', detail: 'Invalid market configuration or unsupported live payment mode. No secret values are included in this report.' });
|
||||
}
|
||||
if (IS_MAINNET || IS_PILOT) {
|
||||
const checks: LaunchCheck[] = [{ id: 'network-selection', status: IS_MAINNET ? 'pass' : 'blocked',
|
||||
detail: IS_MAINNET ? 'Separate mainnet candidate selected.' : 'Testnet selected; this is not a production profile.' }];
|
||||
if (IS_MAINNET) {
|
||||
try {
|
||||
assertLaunchAllowed(env, now);
|
||||
checks.push({ id: 'operator-startup-gates', status: 'pass', detail: 'Local startup gates satisfied; this does not establish production readiness or peer independence.' });
|
||||
@@ -26,15 +16,12 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date
|
||||
}
|
||||
}
|
||||
checks.push(
|
||||
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Stage-3 bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis and independent economic-security review remain missing.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' },
|
||||
{ id: 'live-market-settlement', status: 'blocked', detail: 'No end-to-end live Monero custody path is connected. Production deposit allocation/crediting, treasury withdrawal signing, market ledger settlement, recovery integration and reviewed custody/refund policy remain unavailable. Mock payment is not real payment.' },
|
||||
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Global RPC budgets, bounded pages, and browser history controls exist. In-memory DAG growth and production-scale load/capacity evidence remain unresolved.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis, and independent economic-security review remain missing.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Encrypted vault storage, explicit migration/unlock, and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage, and independent production wallet/recovery review remain unresolved.' },
|
||||
{ id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' },
|
||||
{ id: 'deployment-rehearsal', status: 'unverified', detail: 'Stage-4 explicit proxy trust, private diagnostics/checker and stopped-node paired backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery and on-call procedures remain unverified.' },
|
||||
{ id: 'fee-recipient-control', status: 'unverified', detail: 'Confirm the fixed public XMR fee address, checksum and recipient control out of band before any future live payout. No keys or proof are requested here.' },
|
||||
{ id: 'deployment-rehearsal', status: 'unverified', detail: 'Explicit proxy trust, private diagnostics/checker, and stopped-node ledger backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery, and on-call procedures remain unverified.' },
|
||||
);
|
||||
return { ...launchManifest(), assessedAt: new Date(now).toISOString(), productionReady: false,
|
||||
decision: 'NO-GO', scope: 'Local code/configuration preflight; not an independent audit or launch approval.',
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE }, checks };
|
||||
decision: 'NO-GO', scope: 'Local code/configuration preflight; not an independent audit or launch approval.', checks };
|
||||
}
|
||||
|
||||
+29
-44
@@ -6,14 +6,19 @@ 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','custody-sandbox.sqlite'] as const;
|
||||
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1),
|
||||
const ledgerFile='ledger.sqlite';
|
||||
const legacyFiles=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const;
|
||||
const legacyManifestSchema=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()).min(2).max(3)}).strict()
|
||||
files:z.array(z.object({name:z.enum(legacyFiles),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');
|
||||
m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate legacy backup database');
|
||||
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(2),
|
||||
network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(),
|
||||
files:z.tuple([z.object({name:z.literal(ledgerFile),bytes:z.number().int().positive().max(64*1024**3),
|
||||
sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()])}).strict();
|
||||
const supportedManifestSchema=z.union([manifestSchema,legacyManifestSchema]);
|
||||
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();}}
|
||||
@@ -21,7 +26,7 @@ async function flushDirectory(path:string){
|
||||
if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation.
|
||||
const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();}
|
||||
}
|
||||
function inspect(file:string,name:typeof files[number]) {
|
||||
function inspect(file:string,name:string) {
|
||||
const db=new DatabaseSync(file,{readOnly:true});
|
||||
try {
|
||||
const result=db.prepare('PRAGMA quick_check').all();
|
||||
@@ -29,18 +34,6 @@ 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'){
|
||||
if(db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) {
|
||||
throw new Error('PostgreSQL cutover source cannot be backed up as current custody; coordinated database backup required');
|
||||
}
|
||||
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;
|
||||
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||!z.string().uuid().safeParse(identity.id).success)throw new Error('Market identity mismatch');
|
||||
}
|
||||
}finally{db.close();}
|
||||
}
|
||||
@@ -57,35 +50,27 @@ export async function withStoppedNodeLock<T>(dataDir:string,operation:(source:st
|
||||
return offlineLock(source,()=>operation(source));
|
||||
}
|
||||
/** Snapshot the local SQLite half while the caller holds withStoppedNodeLock(). */
|
||||
export async function createNodeSnapshotUnlocked(source:string,destination:string,allowPostgresBinding=false){
|
||||
export async function createNodeSnapshotUnlocked(source:string,destination:string){
|
||||
const dest=resolve(destination);
|
||||
const rel=relative(source,dest);
|
||||
if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory');
|
||||
// File-only backups cannot represent an external financial database. Fail rather than omit liabilities.
|
||||
try {
|
||||
await lstat(join(source,PROFILE,'custody-postgres.json'));
|
||||
if(!allowPostgresBinding)throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused');
|
||||
} catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
||||
if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked');
|
||||
await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination.
|
||||
const entries=[];
|
||||
for(const name of files){
|
||||
const src=join(source,PROFILE,name),target=join(dest,name);
|
||||
// PostgreSQL is authoritative after cutover. Do not reinterpret or copy the frozen SQLite source as current custody.
|
||||
if(name==='custody-sandbox.sqlite'&&allowPostgresBinding)continue;
|
||||
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();}
|
||||
if(process.platform!=='win32')await chmod(target,0o600);
|
||||
await flush(target);
|
||||
inspect(target,name);const info=await regular(target);
|
||||
entries.push({name,bytes:info.size,sha256:await digest(target)});
|
||||
}
|
||||
const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE,
|
||||
createdAt:new Date().toISOString(),files:entries});
|
||||
// Never create a misleading ledger-only backup when retired financial journals remain.
|
||||
for(const name of ['market.sqlite','custody-sandbox.sqlite','custody-postgres.json']){
|
||||
try { await lstat(join(source,PROFILE,name)); throw new Error(`Legacy financial data (${name}) requires a separately retained pre-retirement backup`); }
|
||||
catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
||||
}
|
||||
if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked');
|
||||
await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination.
|
||||
const src=join(source,PROFILE,ledgerFile),target=join(dest,ledgerFile);
|
||||
await regular(src);inspect(src,ledgerFile);
|
||||
const db=new DatabaseSync(src,{readOnly:true});
|
||||
try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();}
|
||||
if(process.platform!=='win32')await chmod(target,0o600);
|
||||
await flush(target);
|
||||
inspect(target,ledgerFile);const info=await regular(target);
|
||||
const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:2,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE,
|
||||
createdAt:new Date().toISOString(),files:[{name:ledgerFile,bytes:info.size,sha256:await digest(target)}]});
|
||||
// Completion marker written last. Partial output is preserved for operator inspection, never treated as usable.
|
||||
await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600});
|
||||
await flush(join(dest,'manifest.json'));
|
||||
@@ -98,7 +83,7 @@ export async function createBackup(dataDir:string,destination:string){
|
||||
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')));
|
||||
const manifest=supportedManifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8')));
|
||||
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);
|
||||
|
||||
+8
-245
@@ -1,13 +1,11 @@
|
||||
import express from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdir, open, unlink } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS } from '../../shared/testnet.ts';
|
||||
import { transactionCardIds } from '../../shared/testnet.ts';
|
||||
import { NETWORK } from '../../shared/testnet.ts';
|
||||
import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts';
|
||||
import { RandomX } from './randomx.ts';
|
||||
import { Ledger } from './ledger.ts';
|
||||
@@ -15,66 +13,13 @@ import { PeerNetwork } from './peers.ts';
|
||||
import { wikipediaEnrichment } from '../enrichment.ts';
|
||||
import { snapshotStats } from '../../shared/enrichment.ts';
|
||||
import { wikipediaArticles } from '../wiki.ts';
|
||||
import { Market, marketConfig } from '../market/store.ts';
|
||||
import { paymentMode } from '../market/payments.ts';
|
||||
import { marketCommandSchema } from '../../shared/market.ts';
|
||||
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
|
||||
import { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { moneroDepositAddressRequestSchema } from '../../shared/monero-deposit.ts';
|
||||
import { mainnetCustodyBalanceRequestSchema, mainnetWithdrawalRequestSchema,
|
||||
mainnetWithdrawalCancellationSchema } from '../../shared/monero-withdrawal.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { openCustodyStorage } from '../market/custody-storage.ts';
|
||||
import { PostgresCustody } from '../market/postgres-custody.ts';
|
||||
import { CanonicalObserver } from '../market/canonical-observer.ts';
|
||||
import { enrollMoneroDepositAddress, type MainnetEnrollmentContext } from '../market/monero-deposit-enrollment.ts';
|
||||
import { cancelMainnetWithdrawal, readMainnetCustodyBalance, reserveMainnetWithdrawal } from '../market/mainnet-withdrawal.ts';
|
||||
import { verifyMainnetCustodyIdentity } from '../market/mainnet-custody-identity.ts';
|
||||
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../market/postgres-config.ts';
|
||||
import { validateMarketPolicy } from '../market/policy.ts';
|
||||
import { IS_MAINNET, IS_PILOT, PROFILE } from '../../shared/network.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
import { trustedProxies, operationsPort, startOperations } from '../operations.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const marketRuntimeSetting = process.env.WIKIDECK_MARKET_RUNTIME_ENABLED ?? '0';
|
||||
if (marketRuntimeSetting !== '0' && marketRuntimeSetting !== '1') {
|
||||
throw new Error('WIKIDECK_MARKET_RUNTIME_ENABLED must be exactly 0 or 1');
|
||||
}
|
||||
const marketRuntimeEnabled = marketRuntimeSetting === '1';
|
||||
const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources.
|
||||
paymentMode(); // Keep real-payment modes fail-closed even when the optional market runtime is off.
|
||||
let paymentConfig: ReturnType<typeof marketConfig> | undefined;
|
||||
if (marketRuntimeEnabled || custodyConfig === 'sandbox') {
|
||||
paymentConfig = marketConfig();
|
||||
await validateMarketPolicy(paymentConfig); // Reject invalid recipient checksum/network before opening node resources.
|
||||
}
|
||||
const canonicalObserverMode = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
if (canonicalObserverMode !== undefined && canonicalObserverMode !== '0' && canonicalObserverMode !== '1') {
|
||||
throw new Error('WIKIDECK_CUSTODY_CANONICAL_REHEARSAL must be exactly 0 or 1');
|
||||
}
|
||||
const depositEnrollmentMode = process.env.WIKIDECK_MONERO_DEPOSIT_ENROLLMENT ?? '0';
|
||||
if (depositEnrollmentMode !== '0' && depositEnrollmentMode !== '1') {
|
||||
throw new Error('WIKIDECK_MONERO_DEPOSIT_ENROLLMENT must be exactly 0 or 1');
|
||||
}
|
||||
if (depositEnrollmentMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero deposit enrollment requires the mainnet profile');
|
||||
}
|
||||
const withdrawalRequestMode = process.env.WIKIDECK_MONERO_WITHDRAWAL_REQUESTS ?? '0';
|
||||
if (withdrawalRequestMode !== '0' && withdrawalRequestMode !== '1') {
|
||||
throw new Error('WIKIDECK_MONERO_WITHDRAWAL_REQUESTS must be exactly 0 or 1');
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero withdrawal requests require the mainnet profile');
|
||||
}
|
||||
if (canonicalObserverMode === '1' && (process.env.NODE_ENV === 'production' || custodyConfig !== 'sandbox' ||
|
||||
process.env.WIKIDECK_CUSTODY_STORAGE !== 'postgres' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
||||
throw new Error('Canonical observer rehearsal requires sandbox custody, PostgreSQL, and disposable test isolation');
|
||||
}
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
const opsPort=operationsPort();
|
||||
@@ -96,68 +41,6 @@ let peers: PeerNetwork;
|
||||
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
|
||||
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
|
||||
catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let market: Market | undefined;
|
||||
try { if (marketRuntimeEnabled) market = await Market.open(dataDir, ledger, paymentConfig!, Date.now, { worker: true }); }
|
||||
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let custody: CustodyMarket | PostgresCustody | undefined;
|
||||
let custodyObserver: CanonicalObserver | undefined;
|
||||
let moneroDepositPool: Pool | undefined;
|
||||
let moneroDepositRpc: ReturnType<typeof createMoneroDepositAddressRpc> | undefined;
|
||||
let moneroDepositContext: MainnetEnrollmentContext | undefined;
|
||||
let moneroWithdrawalPool: Pool | undefined;
|
||||
let moneroWithdrawalCustodianId: string | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig!,
|
||||
card: (hash, owner, sequence) => {
|
||||
const nft = ledger.nft(hash);
|
||||
if (!nft || nft.owner !== owner || nft.confirmations < CONSENSUS.coinbaseMaturity || nft.sequence !== sequence ||
|
||||
ledger.pendingTransactions().some(tx => transactionCardIds(tx).includes(hash))) return undefined;
|
||||
return { title: nft.block.article.title };
|
||||
} });
|
||||
if (canonicalObserverMode === '1') {
|
||||
if (!(custody instanceof PostgresCustody)) throw new Error('Canonical observer rehearsal requires PostgreSQL custody');
|
||||
custodyObserver = new CanonicalObserver(custody, ledger);
|
||||
if ((await custodyObserver.start()).state !== 'running') throw new Error('Canonical observer rehearsal failed to start; inspect custody diagnostics');
|
||||
}
|
||||
if (depositEnrollmentMode === '1') {
|
||||
const custodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_CUSTODIAN_ID);
|
||||
const walletGenerationId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID);
|
||||
const expectedFingerprint = z.string().regex(/^[a-f0-9]{64}$/).parse(process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT);
|
||||
const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX;
|
||||
if (!accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) {
|
||||
throw new Error('Invalid configured Monero deposit account index');
|
||||
}
|
||||
const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' });
|
||||
const mtlsConfig = await loadMoneroMtlsTransportConfig();
|
||||
if (new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) {
|
||||
throw new Error('Monero observer and mTLS gateway endpoints do not match');
|
||||
}
|
||||
const transport = await loadMoneroMtlsTransport(mtlsConfig);
|
||||
moneroDepositRpc = createMoneroDepositAddressRpc(observerConfig, transport);
|
||||
const actualFingerprint = await moneroDepositRpc.verifyWallet();
|
||||
if (actualFingerprint !== expectedFingerprint) throw new Error('Configured Monero wallet fingerprint does not match the protected wallet');
|
||||
const allocatorUrlFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_URL_FILE;
|
||||
const allocatorCaFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_CA_FILE;
|
||||
if (!allocatorUrlFile || !allocatorCaFile) throw new Error('Monero deposit allocator PostgreSQL URL and CA files are required');
|
||||
moneroDepositPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: allocatorUrlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: allocatorCaFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
moneroDepositPool.on('error', () => console.error('Monero deposit allocator database unavailable.'));
|
||||
await moneroDepositPool.query('SELECT 1 FROM wk_custody.monero_deposit_allocations LIMIT 0');
|
||||
moneroDepositContext = { custodianId, chainId: CHAIN_ID, walletGenerationId, walletFingerprint: actualFingerprint,
|
||||
accountIndex: Number(accountText) };
|
||||
}
|
||||
if (withdrawalRequestMode === '1') {
|
||||
moneroWithdrawalCustodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WITHDRAWAL_CUSTODIAN_ID);
|
||||
const urlFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_URL_FILE;
|
||||
const caFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_CA_FILE;
|
||||
if (!urlFile || !caFile) throw new Error('Monero withdrawal request PostgreSQL URL and CA files are required');
|
||||
moneroWithdrawalPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: caFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
moneroWithdrawalPool.on('error', () => console.error('Monero withdrawal request database unavailable.'));
|
||||
await verifyMainnetCustodyIdentity(moneroWithdrawalPool, moneroWithdrawalCustodianId);
|
||||
await moneroWithdrawalPool.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 0');
|
||||
}
|
||||
} catch(e) {await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await 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);
|
||||
@@ -191,93 +74,6 @@ app.get('/api/canonical',(req,res)=>{
|
||||
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()}));
|
||||
if (market) {
|
||||
app.get('/api/market', (req,res)=>res.json(market!.browse(Number(req.query.offset ?? 0))));
|
||||
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);
|
||||
try {
|
||||
const result = await market!.execute(command);
|
||||
res.json(result);
|
||||
if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{});
|
||||
} catch(error) {
|
||||
if(error instanceof RpcError)throw error;
|
||||
// Marketplace storage/payment internals must never leak through the generic node error handler.
|
||||
if(error instanceof Error && !/SQLITE|constraint|database/i.test(error.message))res.status(400).json({error:error.message.slice(0,200)});
|
||||
else res.status(409).json({error:'Marketplace state conflict. Refresh and retry.'});
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/api/custody', async (req,res)=> {
|
||||
if (!custody) { res.json({ enabled: false }); return; }
|
||||
try { res.json(await 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}), async (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(await 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.' });
|
||||
}
|
||||
});
|
||||
if (depositEnrollmentMode === '1' && moneroDepositContext && moneroDepositRpc && moneroDepositPool) {
|
||||
app.get('/api/monero/deposit-config', (_req,res) => {
|
||||
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: moneroDepositContext!.chainId,
|
||||
custodianId: moneroDepositContext!.custodianId, walletGenerationId: moneroDepositContext!.walletGenerationId,
|
||||
walletFingerprint: moneroDepositContext!.walletFingerprint, accountIndex: moneroDepositContext!.accountIndex,
|
||||
addressEnrollmentEnabled: true, depositCreditEnabled: false, withdrawalsEnabled: false });
|
||||
});
|
||||
app.post('/api/monero/deposit-address', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = moneroDepositAddressRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed Monero deposit-address request.' }); return; }
|
||||
try {
|
||||
const result = await enrollMoneroDepositAddress(moneroDepositPool!, moneroDepositRpc!, moneroDepositContext!, parsed.data);
|
||||
res.json({ ...result, status: 'address-enrolled-not-credited', depositCreditEnabled: false, withdrawalsEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Deposit address enrollment failed; verify signed terms and retry with the same request ID.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && moneroWithdrawalPool && moneroWithdrawalCustodianId) {
|
||||
app.get('/api/monero/withdrawal-config', (_req,res) => {
|
||||
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: CHAIN_ID,
|
||||
custodianId: moneroWithdrawalCustodianId, withdrawalRequestsEnabled: true,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
});
|
||||
app.post('/api/monero/custody-balance', rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetCustodyBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet custody balance request.' }); return; }
|
||||
try { res.json(await readMainnetCustodyBalance(moneroWithdrawalPool!, parsed.data)); }
|
||||
catch { res.status(403).json({ error: 'Mainnet custody balance is unavailable for this signed request.' }); }
|
||||
});
|
||||
app.post('/api/monero/withdrawals', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet withdrawal request.' }); return; }
|
||||
try {
|
||||
const result = await reserveMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.status(202).json({ ...result, status: result.status === 'reserved' ? 'reserved-not-sent' : result.status,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal request could not be reserved; verify signed terms and available balance.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/monero/withdrawals/cancel', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalCancellationSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed withdrawal cancellation.' }); return; }
|
||||
try {
|
||||
const result = await cancelMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.json({ ...result, transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal cancellation could not be applied; it may already be cancelled or under operator processing.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{
|
||||
if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){
|
||||
res.status(400).json({error:'Invalid wallet address'});return;
|
||||
@@ -365,55 +161,22 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,
|
||||
});
|
||||
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
|
||||
const server=app.listen(port,host,()=>{
|
||||
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':IS_PILOT?'private pilot':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
|
||||
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
|
||||
peers.start();
|
||||
});
|
||||
const operations=startOperations(opsPort,()=>({network:NETWORK,paymentMode:'mock',
|
||||
const operations=startOperations(opsPort,()=>({network:NETWORK,
|
||||
ledger:ledger.diagnostics(),native:engine.diagnostics(),
|
||||
peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length},
|
||||
rpc:{api:apiBudget.status(),p2p:peerBudget.status()},market:market?.diagnostics() ?? { enabled:false },
|
||||
canonicalObserver:custodyObserver?.status(),
|
||||
custody: custody && 'diagnostics' in custody ? custody.diagnostics() : { backend: custody ? 'sqlite-sandbox' : 'off' }}),resolve(dataDir,PROFILE));
|
||||
rpc:{api:apiBudget.status(),p2p:peerBudget.status()}}),resolve(dataDir,PROFILE));
|
||||
server.maxConnections=256;
|
||||
server.headersTimeout=10000;
|
||||
server.requestTimeout=15000;
|
||||
server.keepAliveTimeout=5000;
|
||||
server.setTimeout(30000,socket=>socket.destroy());
|
||||
// Advance simulated payout/expiry state even when no browser is open. No real funds are sent.
|
||||
let stopping=false;
|
||||
let catchupPaused=false;
|
||||
let catchupImmediate:NodeJS.Immediate|undefined;
|
||||
let marketTimer:NodeJS.Timeout|undefined;
|
||||
let marketCatchupTimer:NodeJS.Timeout|undefined;
|
||||
// Drain durable catch-up without page-load dependence. Each pass is a smaller synchronous
|
||||
// SQLite slice, then setImmediate yields so HTTP and peer work can run between passes.
|
||||
const advanceMarketCatchup=()=>{
|
||||
catchupImmediate=undefined;
|
||||
if(!market||stopping||catchupPaused||market.isReconciliationWorkerBusy()||!market.hasReconciliationBacklog())return;
|
||||
if(market.isReconciliationWorkerFailed()){
|
||||
catchupPaused=true;console.error('Marketplace reconciliation worker failed; operator review required.');return;
|
||||
}
|
||||
try {market.browse();}
|
||||
catch {catchupPaused=true;console.error('Marketplace canonical catch-up paused; operator review required.');return;}
|
||||
if(market.hasReconciliationBacklog()&&!market.isReconciliationWorkerBusy()){
|
||||
catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref();
|
||||
}
|
||||
};
|
||||
if (market) {
|
||||
marketTimer=setInterval(()=>{try {market!.browse();}catch{console.error('Marketplace reconciliation failed; operator review required.');}},15000);
|
||||
marketTimer.unref();
|
||||
marketCatchupTimer=setInterval(()=>{
|
||||
if(!stopping&&!catchupPaused&&!catchupImmediate&&market!.hasReconciliationBacklog()){
|
||||
catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref();
|
||||
}
|
||||
},250);
|
||||
marketCatchupTimer.unref();
|
||||
}
|
||||
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer);
|
||||
if(catchupImmediate)clearImmediate(catchupImmediate);
|
||||
const observerDrain=custodyObserver?.close()??Promise.resolve();
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{await observerDrain;await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{stopping=true;if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
@@ -257,8 +257,6 @@ export class Ledger {
|
||||
canonicalHead(): number { return this.index.head(); }
|
||||
nft(token: string) { return this.index.nft(token, this.height()); }
|
||||
canonicalTransaction(id: string) { return this.index.transaction(id); }
|
||||
/** Local read-only observer path for the market worker; never exposed through an HTTP response. */
|
||||
reconciliationDatabasePath(): string { return this.file; }
|
||||
legacySnapshot(maxBlocks: number): TestnetSnapshot {
|
||||
// Reject before any full-chain copy/serialization. Never truncate ownership history silently.
|
||||
if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.');
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { nftTransferMessage, nftTradeMessage, type NftState, type NftTransfer, type NftTrade } from '../../shared/testnet.ts';
|
||||
import { SALE_CANDIDATE, saleTransactionSchema, saleTermsSchema, saleTermsMessage,
|
||||
saleCompletionMessage, saleCancellationMessage, type SaleTerms, type SaleTransaction } from '../../shared/sale-candidate.ts';
|
||||
import { CHAIN_ID, sha256, verifySignature, transferSchema, tradeSchema } from './consensus.ts';
|
||||
import type { CustodyOrder } from '../../shared/custody.ts';
|
||||
|
||||
const integer = z.number().int().min(1).max(1000000);
|
||||
const policySchema = z.object({ coinbaseMaturity: integer, lockDepth: integer, outcomeDepth: integer,
|
||||
maxLockBlocks: integer, maxTransactions: z.number().int().min(1).max(64) }).strict();
|
||||
export type SalePolicy = z.infer<typeof policySchema>;
|
||||
export type SaleContext = { chainId: string; policyDigest: string; policy: SalePolicy };
|
||||
export type SaleRecord = { terms: SaleTerms; lockHeight: number; status: 'locked' | 'completed' | 'cancelled' | 'expired';
|
||||
outcomeHeight?: number; reservationDigest?: string };
|
||||
export type SaleState = { height: number; nfts: NftState; sales: Record<string, SaleRecord>;
|
||||
locks: Record<string, string>; orders: Record<string, string> };
|
||||
export type CandidateTransaction = SaleTransaction | { type: 'native-transfer'; transfer: NftTransfer } |
|
||||
{ type: 'native-trade'; trade: NftTrade };
|
||||
const genericChain = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const candidateNativeTransfer = transferSchema.extend({ chainId: genericChain });
|
||||
// Reuse the existing two-party signature/uniqueness checks, but NEVER the active chain ID.
|
||||
const candidateNativeTrade = tradeSchema.safeExtend({ chainId: genericChain });
|
||||
const candidateTransactionSchema = z.union([saleTransactionSchema,
|
||||
z.object({ type: z.literal('native-transfer'), transfer: candidateNativeTransfer }).strict(),
|
||||
z.object({ type: z.literal('native-trade'), trade: candidateNativeTrade }).strict()]);
|
||||
function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
|
||||
const orderKey = (t: SaleTerms) => `${t.custodianId}:${t.orderId}`;
|
||||
|
||||
/** Explicit policy, no invented production thresholds. Digest creates an OFFLINE domain, not a new genesis. */
|
||||
export function saleContext(raw: unknown): SaleContext {
|
||||
const policy = policySchema.parse(raw);
|
||||
check(policy.maxLockBlocks > policy.lockDepth, 'Lock window must exceed lock depth');
|
||||
const policyDigest = sha256(JSON.stringify([SALE_CANDIDATE, policy.coinbaseMaturity, policy.lockDepth,
|
||||
policy.outcomeDepth, policy.maxLockBlocks, policy.maxTransactions, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]));
|
||||
return { policy, policyDigest, chainId: sha256(JSON.stringify([SALE_CANDIDATE, NETWORK, CHAIN_ID, policyDigest])) };
|
||||
}
|
||||
export function saleId(terms: SaleTerms): string { return sha256(saleTermsMessage(saleTermsSchema.parse(terms))); }
|
||||
|
||||
/** Pure bridge from an actual reservation record to candidate terms; signs/submits/debits NOTHING. */
|
||||
export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, options: {
|
||||
custodianId: string; host: string; feeBps: number; lockUntilHeight: number; expiresHeight: number;
|
||||
}): SaleTerms {
|
||||
check(order.status === 'awaiting-lock', 'Only awaiting-lock custody orders can draft candidate terms');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(order.grossAtomicXmr), options.feeBps);
|
||||
check(order.sellerAtomicXmr === allocation.sellerBeforeNetworkCosts.toString() && order.hostAtomicXmr === allocation.hosting.toString() &&
|
||||
order.authorAtomicXmr === allocation.author.toString() && order.authorAddress === ADDITIONAL_MARKET_FEE.address,
|
||||
'Custody order allocations do not match candidate terms');
|
||||
return saleTermsSchema.parse({ protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId,
|
||||
policyDigest: context.policyDigest, custodianId: options.custodianId, orderId: order.id, cardHash: order.cardHash,
|
||||
sequence: order.sequence, seller: order.seller, buyer: order.buyer, host: options.host,
|
||||
priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, hostFeeAddress: order.hostFeeAddress,
|
||||
lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight });
|
||||
}
|
||||
export function initialSaleState(nfts: NftState, height = 0): SaleState {
|
||||
check(Number.isSafeInteger(height) && height >= 0, 'Invalid candidate height');
|
||||
return { height, nfts: Object.assign(Object.create(null), structuredClone(nfts)),
|
||||
sales: Object.create(null), locks: Object.create(null), orders: Object.create(null) };
|
||||
}
|
||||
function advance(sequence: number): number {
|
||||
check(Number.isSafeInteger(sequence) && sequence < Number.MAX_SAFE_INTEGER, 'NFT sequence exhausted'); return sequence + 1;
|
||||
}
|
||||
|
||||
/** Offline deterministic block transition; all changes roll back by discarding the clone on any error.
|
||||
* No HTTP caller, Ledger import, wallet RPC, time, mutable configuration or custody DB access here.
|
||||
*/
|
||||
export function applySaleBlock(parent: SaleState, raw: unknown[], context: SaleContext): SaleState {
|
||||
const canonicalContext = saleContext(context.policy);
|
||||
check(context.chainId === canonicalContext.chainId && context.policyDigest === canonicalContext.policyDigest && context.chainId !== CHAIN_ID,
|
||||
'Invalid candidate context');
|
||||
check(Number.isSafeInteger(parent.height) && parent.height < Number.MAX_SAFE_INTEGER, 'Candidate height exhausted');
|
||||
check(Array.isArray(raw) && raw.length <= context.policy.maxTransactions, 'Candidate transaction budget exceeded');
|
||||
const txs = raw.map(tx => candidateTransactionSchema.parse(tx));
|
||||
const state = structuredClone(parent); state.height++;
|
||||
const seen = new Set<string>();
|
||||
for (const tx of txs) {
|
||||
const identity = candidateIdentity(tx);
|
||||
check(!seen.has(identity), 'Duplicate candidate transaction'); seen.add(identity);
|
||||
if (tx.type === 'native-transfer' || tx.type === 'native-trade') {
|
||||
applyNative(state, tx, context); continue;
|
||||
}
|
||||
if (tx.type === 'sale-lock') {
|
||||
const t = tx.terms, id = saleId(t), nft = state.nfts[t.cardHash];
|
||||
check(t.chainId === context.chainId && t.policyDigest === context.policyDigest, 'Sale context mismatch');
|
||||
check(t.seller !== t.buyer && t.host !== t.seller && t.host !== t.buyer, 'Host and trading identities must be separate');
|
||||
check(nft && nft.owner === t.seller && nft.sequence === t.sequence && !state.locks[t.cardHash], 'Card missing, locked, or stale');
|
||||
check(nft.sequence <= Number.MAX_SAFE_INTEGER - 2, 'NFT sequence exhausted for lock and terminal outcome');
|
||||
check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature sale card');
|
||||
check(state.height <= t.lockUntilHeight && t.lockUntilHeight + context.policy.lockDepth < t.expiresHeight &&
|
||||
Number.isSafeInteger(t.lockUntilHeight + context.policy.lockDepth) &&
|
||||
t.expiresHeight - state.height <= context.policy.maxLockBlocks, 'Invalid lock height window');
|
||||
check(!state.sales[id] && !state.orders[orderKey(t)], 'Sale/order authorization already used');
|
||||
const message = saleTermsMessage(t);
|
||||
check(verifySignature(tx.sellerAuthorization.publicKey, t.seller, message, tx.sellerAuthorization.signature) &&
|
||||
verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Sale lock requires both trader signatures');
|
||||
// Sequence changes at lock AND terminal outcome invalidate previously signed ordinary transfers.
|
||||
state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
state.sales[id] = { terms: t, lockHeight: state.height, status: 'locked' };
|
||||
state.locks[t.cardHash] = id; state.orders[orderKey(t)] = id;
|
||||
continue;
|
||||
}
|
||||
check(tx.chainId === context.chainId, 'Sale context mismatch');
|
||||
const sale = state.sales[tx.saleId];
|
||||
check(sale?.status === 'locked' && state.locks[sale.terms.cardHash] === tx.saleId, 'No active matching sale lock');
|
||||
const t = sale.terms, nft = state.nfts[t.cardHash];
|
||||
check(nft.owner === t.seller && nft.sequence === t.sequence + 1, 'Sale ownership/sequence mismatch');
|
||||
if (tx.type === 'sale-complete') {
|
||||
check(state.height < t.expiresHeight && state.height - sale.lockHeight >= context.policy.lockDepth,
|
||||
'Sale completion outside mature lock window');
|
||||
check(verifySignature(tx.hostAuthorization.publicKey, t.host, saleCompletionMessage(tx), tx.hostAuthorization.signature),
|
||||
'Invalid funding host authorization');
|
||||
sale.status = 'completed'; sale.reservationDigest = tx.reservationDigest;
|
||||
state.nfts[t.cardHash] = { ...nft, owner: t.buyer, sequence: advance(nft.sequence) };
|
||||
} else if (tx.type === 'sale-cancel') {
|
||||
check(state.height < t.expiresHeight, 'Use permissionless expiry at or after expiry height');
|
||||
const message = saleCancellationMessage(tx);
|
||||
check(verifySignature(tx.hostAuthorization.publicKey, t.host, message, tx.hostAuthorization.signature) &&
|
||||
verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Cancellation requires host and buyer authorization');
|
||||
sale.status = 'cancelled'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
} else {
|
||||
check(state.height >= t.expiresHeight, 'Sale has not expired');
|
||||
sale.status = 'expired'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
}
|
||||
sale.outcomeHeight = state.height; delete state.locks[t.cardHash];
|
||||
}
|
||||
return state;
|
||||
}
|
||||
/** Candidate transaction IDs exclude malleable signature bytes, matching the active chain convention.
|
||||
* These IDs are an unactivated candidate format and are NOT accepted by the existing Ledger schema.
|
||||
*/
|
||||
export function saleCandidateTransactionId(tx: SaleTransaction): string {
|
||||
if (tx.type === 'sale-lock') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'lock', saleTermsMessage(tx.terms)]));
|
||||
if (tx.type === 'sale-complete') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'complete', saleCompletionMessage(tx)]));
|
||||
if (tx.type === 'sale-cancel') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'cancel', saleCancellationMessage(tx)]));
|
||||
return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'expire', tx.chainId, tx.saleId]));
|
||||
}
|
||||
function candidateIdentity(tx: CandidateTransaction): string {
|
||||
if (tx.type === 'sale-lock' || tx.type === 'sale-complete' || tx.type === 'sale-cancel' || tx.type === 'sale-expire') {
|
||||
return saleCandidateTransactionId(tx);
|
||||
}
|
||||
return sha256(tx.type === 'native-transfer' ? nftTransferMessage(tx.transfer) : nftTradeMessage(tx.trade));
|
||||
}
|
||||
function applyNative(state: SaleState, tx: Extract<CandidateTransaction, { type: 'native-transfer' | 'native-trade' }>, context: SaleContext) {
|
||||
const native = tx.type === 'native-transfer' ? tx.transfer : tx.trade;
|
||||
check(native.chainId === context.chainId, 'Native candidate context mismatch');
|
||||
const legs = tx.type === 'native-transfer' ? [{ cardHash: tx.transfer.cardHash, sequence: tx.transfer.sequence,
|
||||
from: tx.transfer.from, to: tx.transfer.to }] : [
|
||||
...tx.trade.offered.map(l => ({ ...l, from: tx.trade.proposer, to: tx.trade.counterparty })),
|
||||
...tx.trade.requested.map(l => ({ ...l, from: tx.trade.counterparty, to: tx.trade.proposer }))];
|
||||
for (const l of legs) {
|
||||
const nft = state.nfts[l.cardHash];
|
||||
check(nft && !state.locks[l.cardHash] && nft.owner === l.from && nft.sequence === l.sequence && l.from !== l.to,
|
||||
'Native candidate card locked, missing or stale');
|
||||
check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature native card');
|
||||
}
|
||||
if (tx.type === 'native-transfer') check(verifySignature(tx.transfer.publicKey, tx.transfer.from,
|
||||
nftTransferMessage(tx.transfer), tx.transfer.signature), 'Invalid native transfer signature');
|
||||
// Trade signatures/unique cards have already been checked by candidateNativeTrade.
|
||||
for (const l of legs) state.nfts[l.cardHash] = { ...state.nfts[l.cardHash], owner: l.to, sequence: advance(l.sequence) };
|
||||
}
|
||||
|
||||
export type SaleDecision = { action: 'hold' | 'settle' | 'refund' | 'review' | 'none'; reason: string };
|
||||
/** Pure financial observation policy: recommendation ONLY, not authority to post money or a canonical-chain proof. */
|
||||
export function saleDecision(state: SaleState, terms: SaleTerms, context: SaleContext,
|
||||
previous: 'reserved' | 'settled' | 'refunded' | 'review' = 'reserved'): SaleDecision {
|
||||
check(terms.chainId === context.chainId && terms.policyDigest === context.policyDigest, 'Observation context mismatch');
|
||||
const sale = state.sales[saleId(terms)];
|
||||
const mature = sale?.outcomeHeight !== undefined && state.height - sale.outcomeHeight + 1 >= context.policy.outcomeDepth;
|
||||
if (previous === 'review') return { action: 'review', reason: 'Existing incident requires evidence-backed resolution' };
|
||||
if (previous === 'settled' || previous === 'refunded') {
|
||||
const expected = previous === 'settled' ? sale?.status === 'completed' : sale?.status === 'cancelled' || sale?.status === 'expired' ||
|
||||
(!sale && state.height - terms.lockUntilHeight >= context.policy.outcomeDepth);
|
||||
return expected && (sale ? mature : true) ? { action: 'none', reason: 'Already posted; never duplicate' }
|
||||
: { action: 'review', reason: 'Previously posted outcome lost canonical maturity' };
|
||||
}
|
||||
if (!sale) return state.height - terms.lockUntilHeight >= context.policy.outcomeDepth
|
||||
? { action: 'refund', reason: 'Lock admission deadline is mature without canonical lock' }
|
||||
: { action: 'hold', reason: 'A withheld lock authorization can still be included' };
|
||||
if (!mature) return { action: 'hold', reason: 'Lock or terminal outcome is not mature' };
|
||||
return sale.status === 'completed' ? { action: 'settle', reason: 'Canonical completion reached configured depth' }
|
||||
: { action: 'refund', reason: 'Canonical cancellation/expiry reached configured depth' };
|
||||
}
|
||||
/** Candidate commitment includes locks AND permanent order-use tombstones, not only current NFT ownership. */
|
||||
export function saleStateRoot(state: SaleState, context: SaleContext): string {
|
||||
return sha256(JSON.stringify([SALE_CANDIDATE, 'state', context.chainId, state.height,
|
||||
Object.keys(state.nfts).sort().map(id => { const n = state.nfts[id]; return [id, n.owner, n.sequence, n.mintedHeight, n.pageId]; }),
|
||||
Object.keys(state.sales).sort().map(id => { const s = state.sales[id]; return [id, saleTermsMessage(s.terms), s.lockHeight,
|
||||
s.status, s.outcomeHeight ?? null, s.reservationDigest ?? null]; }),
|
||||
Object.keys(state.locks).sort().map(id => [id, state.locks[id]]),
|
||||
Object.keys(state.orders).sort().map(id => [id, state.orders[id]])]));
|
||||
}
|
||||
Reference in New Issue
Block a user