Implemented and validated the separate Monero observation-worker path
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
migrate: 0,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-credit-runtime': 1,
|
||||
'import-sqlite': 1,
|
||||
audit: 0,
|
||||
@@ -17,6 +18,12 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
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 (isMainnet && action !== 'grant-monero-observer-runtime') {
|
||||
throw new Error('Only the separately scoped Monero observation-role grant is allowed on mainnet');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
|
||||
const [action, ...values] = args;
|
||||
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
|
||||
|
||||
@@ -66,7 +66,7 @@ function rowDigest(observation: MoneroIncomingObservation): string {
|
||||
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 ||
|
||||
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');
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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),
|
||||
@@ -83,6 +84,9 @@ export type MoneroObservationPage = {
|
||||
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 {
|
||||
@@ -215,15 +219,17 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
||||
return validateXmrAddress(result.address);
|
||||
},
|
||||
async scanIncoming(fromHeight: number): Promise<MoneroObservationPage> {
|
||||
if (!Number.isSafeInteger(fromHeight) || fromHeight < 0) throw new Error('Invalid Monero scan height');
|
||||
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: walletHeight,
|
||||
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>>();
|
||||
@@ -264,7 +270,7 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
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 > walletHeight) continue;
|
||||
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');
|
||||
@@ -290,7 +296,7 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
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: walletHeight, primaryAddress, incoming };
|
||||
return { network: 'mainnet', walletHeight, fromHeight, throughHeight, primaryAddress, incoming };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 7;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
CREATE SCHEMA IF NOT EXISTS wk_custody;
|
||||
REVOKE ALL ON SCHEMA wk_custody FROM PUBLIC;
|
||||
@@ -395,6 +396,10 @@ export async function grantCustodyRuntime(pool: Pool, role: string): Promise<voi
|
||||
await c.query(`REVOKE ALL PRIVILEGES ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
|
||||
wk_custody.monero_observation_events,wk_custody.monero_observation_scans FROM ${name};
|
||||
REVOKE ALL PRIVILEGES ON wk_custody.monero_deposit_allocations FROM ${name};
|
||||
REVOKE ALL PRIVILEGES ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions FROM ${name};
|
||||
GRANT SELECT ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};
|
||||
REVOKE ALL PRIVILEGES ON SEQUENCE wk_custody.monero_observation_events_sequence_seq FROM ${name};`);
|
||||
await c.query('COMMIT');
|
||||
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
|
||||
|
||||
Reference in New Issue
Block a user