Implemented the next deposit-observation step without activating live deposits
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { createMoneroWalletObserver, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
|
||||
import { createMoneroWalletObserver, loadMoneroObserverConfig, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
|
||||
|
||||
const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc', username: 'fixture', password: 'not-a-live-secret' };
|
||||
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
|
||||
@@ -23,15 +23,16 @@ function fakeTransport(incoming: Record<string, unknown>[] = []): { transport: M
|
||||
}
|
||||
|
||||
test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => {
|
||||
const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), amount: '9007199254740993', height: 91,
|
||||
const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
amount: '9007199254740993', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 }, address: ADDITIONAL_MARKET_FEE.address }]);
|
||||
subaddr_index: { major: 0, minor: 7 } }]);
|
||||
const observer = createMoneroWalletObserver(config, transport);
|
||||
const page = await observer.scanIncoming(90);
|
||||
assert.equal(page.network, 'mainnet'); assert.equal(page.walletHeight, 100);
|
||||
assert.equal(page.primaryAddress, ADDITIONAL_MARKET_FEE.address);
|
||||
assert.equal(page.incoming.length, 1);
|
||||
assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:0:7`, transactionId: 'a'.repeat(64),
|
||||
assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:0:7`, transactionId: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
amountAtomicXmr: '9007199254740993', height: 91, confirmations: 10, timestamp: 1_800_000_000,
|
||||
unlockTime: '0', accountIndex: 0, addressIndex: 7, locked: false, doubleSpendSeen: false });
|
||||
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers']);
|
||||
@@ -43,6 +44,7 @@ test('read-only Monero observer preserves uint64 amount text and returns observa
|
||||
|
||||
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }), /HTTPS/);
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }), /HTTPS/);
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }), /bare/);
|
||||
const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport);
|
||||
await assert.rejects(observer.scanIncoming(-1), /height/);
|
||||
@@ -54,12 +56,12 @@ test('observer rejects unsafe amounts, duplicate observations, malformed transfe
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`,
|
||||
return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","address":"${ADDITIONAL_MARKET_FEE.address}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`,
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, unsafeTransport).scanIncoming(90), /amount/);
|
||||
|
||||
const duplicate = { txid: 'c'.repeat(64), amount: '1', height: 91, timestamp: 1, unlock_time: 0,
|
||||
const duplicate = { txid: 'c'.repeat(64), address: ADDITIONAL_MARKET_FEE.address, amount: '1', height: 91, timestamp: 1, unlock_time: 0,
|
||||
locked: true, double_spend_seen: false, subaddr_index: { major: 0, minor: 1 } };
|
||||
const duplicateFixture = fakeTransport([duplicate, duplicate]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, duplicateFixture.transport).scanIncoming(90), /Duplicate/);
|
||||
@@ -86,3 +88,8 @@ test('observer refuses redirects, wrong content type and oversized RPC response
|
||||
error instanceof Error && !error.message.includes(config.password));
|
||||
}
|
||||
});
|
||||
|
||||
test('Monero RPC configuration is opt-in and inaccessible without a protected external config file', async () => {
|
||||
await assert.rejects(loadMoneroObserverConfig({}), /disabled/);
|
||||
await assert.rejects(loadMoneroObserverConfig({ WIKIDECK_MONERO_OBSERVER: '1' }), /config file is required/);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@ import { PostgresCustody, auditPostgres } from '../../server/market/postgres-cus
|
||||
import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts';
|
||||
import { resolveCustodyIncident } from '../../server/market/custody-incident.ts';
|
||||
import { CustodyMarket } from '../../server/market/custody.ts';
|
||||
import { migrateCustody, grantCustodyRuntime } from '../../server/market/postgres-schema.ts';
|
||||
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST } from '../../server/market/postgres-schema.ts';
|
||||
import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
|
||||
import { importSqliteCustody } from '../../server/market/custody-import.ts';
|
||||
import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts';
|
||||
import { openCustodyStorage } from '../../server/market/custody-storage.ts';
|
||||
@@ -22,6 +23,7 @@ import { createBackup, restoreBackup } from '../../server/recovery.ts';
|
||||
import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../../server/coordinated-backup.ts';
|
||||
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
|
||||
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts';
|
||||
import { saleContext, termsForCustodyOrder, saleCandidateTransactionId, saleId } from '../../server/testnet/sale-candidate.ts';
|
||||
@@ -173,6 +175,89 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte
|
||||
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
|
||||
});
|
||||
|
||||
test('custody schema v1 upgrades additively to observation-only v2 without dropping existing metadata', async t => {
|
||||
const admin = new Pool(config);
|
||||
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
|
||||
const client = await admin.connect();
|
||||
try {
|
||||
await client.query('BEGIN'); await client.query(CUSTODY_SCHEMA_V1);
|
||||
await client.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]);
|
||||
await client.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['v1-preserved-marker', 'existing-test-data']);
|
||||
await client.query('COMMIT');
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error; }
|
||||
finally { client.release(); }
|
||||
try {
|
||||
await migrateCustody(admin); await migrateCustody(admin);
|
||||
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2]);
|
||||
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value,
|
||||
'existing-test-data');
|
||||
for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) {
|
||||
assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name);
|
||||
}
|
||||
} finally { await admin.end(); }
|
||||
});
|
||||
|
||||
test('Monero scan persistence is idempotent, reorg-visible and never posts customer credit', async t => {
|
||||
const f = await fixture(t);
|
||||
await f.admin.query('CREATE ROLE monero_observer LOGIN');
|
||||
await grantMoneroObserverRuntime(f.admin, 'monero_observer');
|
||||
await assert.rejects(grantMoneroObserverRuntime(f.admin, 'custody_runtime'), /separate role/);
|
||||
const runtime = new Pool({ ...f.runtime, user: 'monero_observer' });
|
||||
const custodyRuntime = new Pool(f.runtime), wallet = 'd'.repeat(64), scan1 = randomUUID();
|
||||
const txid = 'e'.repeat(64), observationId = `${txid}:0:4`;
|
||||
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 95,
|
||||
address: ADDITIONAL_MARKET_FEE.address,
|
||||
confirmations: 6, timestamp: 1_800_000_000, unlockTime: '0', accountIndex: 0, addressIndex: 4,
|
||||
locked: false, doubleSpendSeen: false };
|
||||
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
|
||||
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
|
||||
try {
|
||||
await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations'));
|
||||
await assert.rejects(runtime.query('SELECT * FROM wk_custody.accounts'));
|
||||
const first = await persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet,
|
||||
expectedScanHeight: 0, scannedAt: 1_800_000_100, page });
|
||||
assert.equal(first.observed, 1); assert.equal(first.scanHeight, 100); assert.equal(first.replayed, false);
|
||||
const replay = await persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet,
|
||||
expectedScanHeight: 0, scannedAt: 1_800_000_100, page });
|
||||
assert.equal(replay.replayed, true);
|
||||
await assert.rejects(persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet,
|
||||
expectedScanHeight: 0, scannedAt: 1_800_000_100, page: { ...page, incoming: [] } }), /reused/);
|
||||
|
||||
const confirmationRefresh = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 100, scannedAt: 1_800_000_150,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 105, throughHeight: 105,
|
||||
incoming: [{ ...observation, confirmations: 11 }] } });
|
||||
assert.equal(confirmationRefresh.changed, 0, 'normal confirmation growth is a projection update, not an immutable event per poll');
|
||||
|
||||
const missing = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 105, scannedAt: 1_800_000_200,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 110, throughHeight: 110, incoming: [] } });
|
||||
assert.equal(missing.notSeen, 1); // Review signal only; never an automatic financial reversal.
|
||||
const reappeared = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 110, scannedAt: 1_800_000_300,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 120, throughHeight: 120,
|
||||
incoming: [{ ...observation, confirmations: 26 }] } });
|
||||
assert.equal(reappeared.reappeared, 1);
|
||||
|
||||
const observed = await runtime.query(`SELECT amount_atomic_xmr::text AS amount,monero_address,currently_present FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]);
|
||||
assert.equal(observed.rows[0].amount, '9007199254740993'); assert.equal(observed.rows[0].currently_present, true);
|
||||
assert.equal(observed.rows[0].monero_address, ADDITIONAL_MARKET_FEE.address);
|
||||
const eventTypes = await runtime.query('SELECT event_type FROM wk_custody.monero_observation_events ORDER BY sequence');
|
||||
assert.deepEqual(eventTypes.rows.map(row => row.event_type), ['seen', 'not-seen', 'reappeared']);
|
||||
await assert.rejects(runtime.query('UPDATE wk_custody.monero_observation_events SET event_type=\'seen\''));
|
||||
await assert.rejects(runtime.query('DELETE FROM wk_custody.monero_observation_scans'));
|
||||
await assert.rejects(runtime.query('SELECT count(*) FROM wk_custody.journals'), /permission denied/);
|
||||
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0,
|
||||
'observation persistence must never create financial postings');
|
||||
await assert.rejects(persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 110, scannedAt: 1_800_000_400,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121,
|
||||
incoming: [{ ...observation, confirmations: 27 }] } }), /cursor changed/);
|
||||
} finally { await custodyRuntime.end(); await runtime.end(); }
|
||||
});
|
||||
|
||||
test('database rejects unbalanced, fractional, negative, historical append and oversized postings at commit', async t => {
|
||||
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
|
||||
const c = await f.admin.connect();
|
||||
|
||||
Reference in New Issue
Block a user