import test from 'node:test'; import assert from 'node:assert/strict'; import { keccak } from 'hash-wasm'; import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; import { createMoneroDepositAddressRpc, 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, status = 200, type = 'application/json') { return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } }); } function fakeTransport(incoming: Record[] = []): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } { const calls: { method: string; params: unknown; auth: string | null }[] = []; const transport: MoneroFixtureTransport = async (url, init) => { assert.equal(url, config.url); assert.equal(init.method, 'POST'); assert.equal(init.redirect, 'error'); assert.ok(init.signal); const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown }; calls.push({ method: body.method, params: body.params, auth: new Headers(init.headers).get('authorization') }); 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' }); if (body.method === 'get_transfers') return response(body.id, { in: incoming, status: 'OK' }); throw new Error(`unexpected method ${body.method}`); }; return { transport, calls }; } async function syntheticSubaddress(fill = 3) { const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]); const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; let encoded = ''; for (let i = 0; i < data.length; i += 8) { const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1'); } return encoded; } test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => { 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 } }]); const observer = createMoneroWalletObserver(config, transport); assert.equal('createSubaddress' in observer, false); 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), 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']); assert.ok(calls.every(call => call.auth === `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`)); assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params, { in: true, filter_by_height: true, min_height: 90, max_height: 100 }); assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false); }); test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => { const subaddress = await syntheticSubaddress(); const calls: { method: string; params: unknown }[] = []; const transport: MoneroFixtureTransport = async (_url, init) => { const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown }; calls.push({ method: body.method, params: body.params }); if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, addresses: [{ address: subaddress, label: 'wikideck-deposit-v1:fixture', address_index: 7, used: false }], status: 'OK' }); if (body.method === 'create_address') return response(body.id, { address: subaddress, address_index: 7, address_indices: [7], addresses: [subaddress], status: 'OK' }); return response(body.id, { height: 100, status: 'OK' }); }; const rpc = createMoneroDepositAddressRpc(config, transport); assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' }); assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 }); assert.deepEqual(calls, [ { method: 'get_address', params: { account_index: 0, address_index: [7] } }, { method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } }, ]); await assert.rejects(rpc.getSubaddress(0, 8), /different subaddress index/); await assert.rejects(rpc.createSubaddress(0, 'bad\nlabel'), /parameters/); let unexpectedCalls = 0; const onlyAddressTransport: MoneroFixtureTransport = async (_url, init) => { unexpectedCalls++; const body = JSON.parse(init.body as string) as { id: string; method: string }; return response(body.id, { height: 100, status: 'OK' }); }; await assert.rejects(createMoneroDepositAddressRpc(config, onlyAddressTransport).getSubaddress(0, 0), /expected/); assert.equal(unexpectedCalls, 1); }); 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/); await assert.rejects(observer.scanIncoming(101), /ahead/); }); test('observer rejects unsafe amounts, duplicate observations, malformed transfers and wrong RPC identity', async () => { const unsafeTransport: MoneroFixtureTransport = async (_url, init) => { 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)}","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), 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/); const wrongId: MoneroFixtureTransport = async (_url, init) => { JSON.parse(init.body as string) as { id: string }; return response('00000000-0000-4000-8000-000000000001', { height: 100, status: 'OK' }); }; await assert.rejects(createMoneroWalletObserver(config, wrongId).scanIncoming(90), /ID mismatch/); }); test('observer refuses redirects, wrong content type and oversized RPC response without exposing credentials', async () => { const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }), new Response('', { headers: { 'content-type': 'text/html' } }), new Response('x'.repeat(1024 * 1024 + 1), { headers: { 'content-type': 'application/json' } })]; for (const bad of badResponses) { let call = 0; const transport: MoneroFixtureTransport = async () => { call++; if (call === 1) return response('unused', { height: 100, status: 'OK' }); return bad; }; await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), error => 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/); });