Files
wikideck/tests/monero-observer.test.ts
T
2026-10-23 10:20:58 -04:00

293 lines
20 KiB
TypeScript
Executable File

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,
moneroWalletFingerprint, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
import { loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
import { createMoneroRpcGateway, loadMoneroRpcGatewayConfig,
validateMoneroRpcGatewayRequest } from '../server/market/monero-rpc-gateway.ts';
const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc' };
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } });
}
function rpcErrorResponse(id: string, code: number, message: string) {
return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }),
{ headers: { 'content-type': 'application/json' } });
}
function fakeTransport(incoming: Record<string, unknown>[] = [], outputs?: Record<string, unknown>[]): { 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' });
if (body.method === 'incoming_transfers') return response(body.id, { transfers: outputs ?? incoming.map((item, index) => ({
amount: item.amount, global_index: index, tx_hash: item.txid, subaddr_index: item.subaddr_index,
unlocked: !(item.locked as boolean), block_height: item.height, pubkey: item.outputPublicKey ?? item.txid,
})), 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)}:${'a'.repeat(64)}`, 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, outputPublicKey: 'a'.repeat(64), globalOutputIndex: 0,
locked: false, doubleSpendSeen: false });
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers', 'incoming_transfers']);
assert.ok(calls.every(call => call.auth === null), 'Wallet RPC credentials must never be sent as Basic/Digest headers');
assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params,
{ in: true, filter_by_height: true, min_height: 90, max_height: 100 });
assert.deepEqual(calls.find(call => call.method === 'incoming_transfers')?.params,
{ transfer_type: 'all', account_index: 0, subaddr_indices: [7] });
assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false);
});
test('observer distinguishes multiple outputs in one transaction by owned output public key', async () => {
const txid = 'a'.repeat(64), firstKey = 'b'.repeat(64), secondKey = 'c'.repeat(64);
const { transport } = fakeTransport([{ txid, address: ADDITIONAL_MARKET_FEE.address, amount: '30', height: 91,
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
subaddr_index: { major: 0, minor: 7 } }], [
{ tx_hash: txid, pubkey: firstKey, amount: '10', global_index: 42, block_height: 91, unlocked: true,
subaddr_index: { major: 0, minor: 7 } },
{ tx_hash: txid, pubkey: secondKey, amount: '20', global_index: 43, block_height: 91, unlocked: true,
subaddr_index: { major: 0, minor: 7 } },
]);
const page = await createMoneroWalletObserver(config, transport).scanIncoming(90);
assert.deepEqual(page.incoming.map(row => [row.observationId, row.amountAtomicXmr, row.globalOutputIndex]), [
[`${txid}:${firstKey}`, '10', 42], [`${txid}:${secondKey}`, '20', 43],
]);
});
test('observer fails closed on missing, duplicated, or misattributed incoming outputs', async () => {
const txid = 'd'.repeat(64), firstKey = 'e'.repeat(64);
const transfer = { txid, address: ADDITIONAL_MARKET_FEE.address, amount: '2', height: 91,
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
subaddr_index: { major: 0, minor: 7 } };
const output = { tx_hash: txid, pubkey: firstKey, amount: '1', global_index: 42, block_height: 91,
unlocked: true, subaddr_index: { major: 0, minor: 7 } };
const incomplete = fakeTransport([transfer], [output]);
await assert.rejects(createMoneroWalletObserver(config, incomplete.transport).scanIncoming(90), /transaction amount/);
const duplicate = fakeTransport([transfer], [output, { ...output, global_index: 43 }]);
await assert.rejects(createMoneroWalletObserver(config, duplicate.transport).scanIncoming(90), /Duplicate Monero wallet output public key/);
const misattributed = fakeTransport([transfer], [{ ...output, subaddr_index: { major: 0, minor: 8 } }]);
await assert.rejects(createMoneroWalletObserver(config, misattributed.transport).scanIncoming(90), /outside requested subaddresses/);
const malformed: 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' });
if (body.method === 'get_transfers') return response(body.id, { in: [transfer], status: 'OK' });
return response(body.id, { transfers: [{ ...output, pubkey: 'not-a-public-key' }], status: 'OK' });
};
await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90));
});
test('observer tolerates v0.18.5.4 omission of empty incoming_transfers result fields', async () => {
const txid = 'd'.repeat(64);
const transport: 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' });
if (body.method === 'get_transfers') return response(body.id, { in: [{ txid, address: ADDITIONAL_MARKET_FEE.address,
amount: '1', height: 91, timestamp: 1, unlock_time: 0, locked: false, double_spend_seen: false,
subaddr_index: { major: 0, minor: 7 } }], status: 'OK' });
return response(body.id, {}); // Actual empty result shape from the pinned Wallet RPC build.
};
await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), /transaction amount/);
});
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 === 'validate_address') return response(body.id, { valid: true, integrated: false,
nettype: 'mainnet', subaddress: false, openalias_address: '' });
if (body.method === 'get_address') {
const params = body.params as { address_index?: number[] };
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
...(params.address_index ? { 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.equal(await rpc.verifyWallet(), await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address));
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: [0] } },
{ method: 'validate_address', params: { address: ADDITIONAL_MARKET_FEE.address } },
{ 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);
const spendCapableWallet: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string; method: string };
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false,
nettype: 'stagenet', subaddress: false, openalias_address: '' });
throw new Error(`unexpected method ${body.method}`);
};
await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet());
});
test('official wallet not-yet-created address-index error is recoverable but unrelated RPC errors fail closed', async () => {
const notFound: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string };
return rpcErrorResponse(body.id, -15, 'address index is out of bound');
};
assert.equal(await createMoneroDepositAddressRpc(config, notFound).getSubaddress(0, 1), null);
const unrelated: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string };
return rpcErrorResponse(body.id, -15, 'account index is out of bound');
};
await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/);
});
test('mTLS gateway enforces bounded per-certificate observer and address-allocator methods', () => {
const request = (method: string, params: Record<string, unknown> = {}) =>
({ jsonrpc: '2.0', id: 'fixture-id', method, params });
const observer = ['observer'] as const, allocator = ['address-allocator'] as const;
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_height'), observer));
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0] }), observer));
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), allocator));
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('validate_address', { address: 'x'.repeat(95) }), allocator));
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
in: true, filter_by_height: true, min_height: 0, max_height: 100,
}), observer));
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
transfer_type: 'all', account_index: 0, subaddr_indices: [2, 3],
}), observer));
assert.throws(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
transfer_type: 'all', account_index: 0, subaddr_indices: [2], key_image: true,
}), observer));
assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), observer), /not allowed for this client certificate/);
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
in: true, filter_by_height: true, min_height: 0, max_height: 100,
}), allocator), /not allowed for this client certificate/);
for (const method of ['transfer', 'transfer_split', 'sweep_all', 'relay_tx', 'get_tx_key', 'query_key', 'open_wallet', 'get_balance']) {
assert.throws(() => validateMoneroRpcGatewayRequest(request(method), ['observer', 'address-allocator']), /not allowed for this client certificate/);
}
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0], all: true }), observer));
assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'x', count: 2 }), allocator));
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
in: true, filter_by_height: true, min_height: 0, max_height: 100_001,
}), observer), /range/);
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_height'), []));
});
test('production mTLS transport config is mandatory and requires a bare HTTPS RPC endpoint', async () => {
await assert.rejects(loadMoneroMtlsTransportConfig({}), /config file is required/);
await assert.rejects(loadMoneroRpcGatewayConfig(''), /protected Monero RPC gateway configuration/);
await assert.rejects(createMoneroRpcGateway({ listenHost: '203.0.113.10', listenPort: 18085,
walletRpcUrl: 'http://127.0.0.1:18083/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing',
clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /private interface IP/);
await assert.rejects(createMoneroRpcGateway({ listenHost: '127.0.0.1', listenPort: 18085,
walletRpcUrl: 'http://wallet.example/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing',
clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /loopback-only/);
});
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport);
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), /scan range/);
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' });
if (body.method === 'get_transfers') 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' } });
return response(body.id, { status: 'OK', transfers: [{ tx_hash: 'b'.repeat(64), pubkey: 'c'.repeat(64),
amount: 18446744073709551616n.toString(), global_index: 1, block_height: 91, unlocked: true,
subaddr_index: { major: 0, minor: 1 } }] });
};
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 secretSentinel = 'fixture-secret-never-log';
const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }),
new Response('<html/>', { 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(secretSentinel));
}
});
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/);
});