Implemented and validated the separate Monero observation-worker path

This commit is contained in:
2026-10-22 09:38:20 -04:00
parent 12f80fdc53
commit 158981e965
12 changed files with 253 additions and 12 deletions
+12 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
import { assertCustodyDbActionAllowedOnNetwork, parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
test('custody database CLI parses two-argument deposit operations without shifting arguments', () => {
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), {
@@ -9,6 +9,17 @@ test('custody database CLI parses two-argument deposit operations without shifti
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-resolve-hold', 'credit-id', 'hold-id']), {
action: 'deposit-prepare-resolve-hold', argument: 'credit-id', extraArgument: 'hold-id',
});
assert.deepEqual(parseCustodyDbArguments(['grant-monero-observer-runtime', 'monero_observer']), {
action: 'grant-monero-observer-runtime', argument: 'monero_observer', extraArgument: undefined,
});
});
test('mainnet custody CLI permits only the observation-role grant', () => {
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true));
for (const action of ['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'audit', 'deposit-release'] as const) {
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only the separately scoped/i);
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false));
}
});
test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => {
+1 -1
View File
@@ -234,7 +234,7 @@ test('observer refuses non-loopback plaintext, URL credentials and malformed hei
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }, transport), /HTTPS/);
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }, transport), /HTTPS/);
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }, transport), /bare/);
await assert.rejects(observer.scanIncoming(-1), /height/);
await assert.rejects(observer.scanIncoming(-1), /scan range/);
await assert.rejects(observer.scanIncoming(101), /ahead/);
});
+46
View File
@@ -18,6 +18,7 @@ import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTOD
grantMoneroDepositCreditRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
import { moneroWalletFingerprint } from '../../server/market/monero-observer.ts';
import { runMoneroObserverBatch, runMoneroObserverWorker } from '../../server/market/monero-observer-worker.ts';
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, depositReorgResolutionMessage,
postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, prepareDepositCreditReview, prepareDepositReorgResolution,
reconcileDepositCreditReorgHolds, releaseReviewedDepositCredit, resolveReleasedDepositReorgHold,
@@ -413,6 +414,51 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo
} finally { await custodyRuntime.end(); await runtime.end(); }
});
test('Monero observer worker catches up through bounded durable windows, resumes, and admits one leader', async t => {
const f = await fixture(t);
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_worker_test') THEN CREATE ROLE monero_worker_test LOGIN; END IF; END $$");
await grantMoneroObserverRuntime(f.admin, 'monero_worker_test');
const workerPool = new Pool({ ...config, user: 'monero_worker_test' });
const secondWorkerPool = new Pool({ ...config, user: 'monero_worker_test' });
const wallet = '7'.repeat(64), walletHeight = 250_005, now = Date.now();
const requested: number[] = [];
const scanner = {
async scanIncoming(fromHeight: number, maxBlocks = 100_000) {
requested.push(fromHeight);
const throughHeight = Math.min(walletHeight, fromHeight + maxBlocks - 1);
return { network: 'mainnet' as const, walletHeight, fromHeight, throughHeight,
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [] };
},
};
try {
const failedScanner = { scanIncoming: async () => { throw new Error('fixture RPC failure'); } };
await assert.rejects(runMoneroObserverBatch(workerPool, wallet, failedScanner));
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_observer_state WHERE wallet_fingerprint=$1',
[wallet])).rows[0].n, 0, 'failed RPC must not create or advance durable cursor state');
const batch = await runMoneroObserverBatch(workerPool, wallet, scanner, { now: () => now });
assert.equal(batch.pages.length, 3, 'catch-up is split into a bounded number of pages');
assert.equal(batch.caughtUp, true);
assert.equal(batch.scanHeight, walletHeight);
assert.deepEqual(requested, [0, 98_999, 197_998]);
const restarted = await runMoneroObserverBatch(secondWorkerPool, wallet, scanner, { now: () => now + 1, maxPages: 1 });
assert.equal(restarted.pages.length, 1);
assert.equal(restarted.scanHeight, walletHeight);
assert.equal(requested.at(-1), walletHeight - 1_000, 'restart resumes at a bounded reorg overlap');
const controller = new AbortController();
let signalReady!: () => void;
const batchObserved = new Promise<void>(resolve => { signalReady = resolve; });
const firstWorker = runMoneroObserverWorker(workerPool, wallet, scanner, { pollIntervalMs: 60_000,
signal: controller.signal, onBatch: () => signalReady() });
await batchObserved;
await assert.rejects(runMoneroObserverWorker(secondWorkerPool, wallet, scanner, { pollIntervalMs: 60_000 }), /already holds/);
controller.abort(); await firstWorker;
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0,
'observer worker only persists chain observations and never posts money');
} finally { await secondWorkerPool.end(); await workerPool.end(); }
});
test('reviewed Monero outputs post once to pending liability and reorgs create holds without reversal', async t => {
const f = await fixture(t);
await f.admin.query(`DO $$ BEGIN