First deposit integration slice
This commit is contained in:
@@ -16,7 +16,7 @@ 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>[] = []): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } {
|
||||
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');
|
||||
@@ -26,6 +26,10 @@ function fakeTransport(incoming: Record<string, unknown>[] = []): { transport: M
|
||||
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 };
|
||||
@@ -54,16 +58,62 @@ test('read-only Monero observer preserves uint64 amount text and returns observa
|
||||
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,
|
||||
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, locked: false, doubleSpendSeen: false });
|
||||
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers']);
|
||||
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('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 }[] = [];
|
||||
@@ -138,6 +188,12 @@ test('mTLS gateway allowlists only bounded observation and one-subaddress alloca
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: 0, max_height: 100,
|
||||
})));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2, 3],
|
||||
})));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2], key_image: true,
|
||||
})));
|
||||
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)), /not allow-listed/);
|
||||
}
|
||||
@@ -173,8 +229,11 @@ 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)}","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}}]}}`,
|
||||
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/);
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ test('custody schema v1 upgrades additively through v3 without dropping existing
|
||||
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, 3]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4]);
|
||||
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']) {
|
||||
@@ -229,7 +229,7 @@ test('custody schema v2 upgrades explicitly to v3 address-allocation registry',
|
||||
await client.query('COMMIT');
|
||||
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, 3]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4]);
|
||||
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve');
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); await admin.end(); }
|
||||
@@ -334,11 +334,11 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo
|
||||
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 txid = 'e'.repeat(64), outputPublicKey = 'f'.repeat(64), observationId = `${txid}:${outputPublicKey}`;
|
||||
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 };
|
||||
outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false };
|
||||
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
|
||||
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
|
||||
try {
|
||||
@@ -369,12 +369,29 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo
|
||||
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
|
||||
const observed = await runtime.query(`SELECT amount_atomic_xmr::text AS amount,monero_address,output_public_key,global_output_index,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);
|
||||
assert.equal(observed.rows[0].output_public_key, outputPublicKey); assert.equal(Number(observed.rows[0].global_output_index), 123);
|
||||
|
||||
const reorgMovedOutput = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 120, scannedAt: 1_800_000_325,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121,
|
||||
incoming: [{ ...observation, height: 96, confirmations: 26 }] } });
|
||||
assert.equal(reorgMovedOutput.changed, 1, 'same output re-included at a different height must be visible as changed evidence');
|
||||
const updated = await runtime.query(`SELECT block_height,output_public_key FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]);
|
||||
assert.equal(Number(updated.rows[0].block_height), 96);
|
||||
assert.equal(updated.rows[0].output_public_key, outputPublicKey, 'output identity survives a reorg/re-inclusion');
|
||||
|
||||
await assert.rejects(persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 121, scannedAt: 1_800_000_350,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121,
|
||||
incoming: [{ ...observation, transactionId: 'a'.repeat(64), observationId: `${'a'.repeat(64)}:${outputPublicKey}`, height: 96, confirmations: 26 }] } }),
|
||||
/output key|unique/i, 'one wallet output key cannot be assigned to two transaction identities');
|
||||
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']);
|
||||
assert.deepEqual(eventTypes.rows.map(row => row.event_type), ['seen', 'not-seen', 'reappeared', 'changed']);
|
||||
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/);
|
||||
|
||||
Reference in New Issue
Block a user