import assert from 'node:assert/strict'; import { randomBytes, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const walletRpcBinary = process.argv[2]; if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) { throw new Error('Pass the path to the verified monero-wallet-rpc binary'); } const rpcBinary = resolve(walletRpcBinary); const port = 18083; const endpoint = `http://127.0.0.1:${port}/json_rpc`; const walletPassword = randomBytes(32).toString('hex'); const temp = await mkdtemp(join(tmpdir(), 'wikideck-stagenet-rpc-')); const walletDirectory = join(temp, 'wallets'); const watchOnlyName = 'wikideck-stagenet-watch-only'; let child; function startWalletRpc(walletFile) { const args = [ '--stagenet', '--offline', '--rpc-bind-ip=127.0.0.1', `--rpc-bind-port=${port}`, '--disable-rpc-login', `--wallet-dir=${walletDirectory}`, '--non-interactive', '--no-initial-sync', '--max-concurrency=1', '--log-level=0', '--log-file=' + join(temp, 'wallet-rpc.log'), ]; if (walletFile) { args.splice(args.indexOf(`--wallet-dir=${walletDirectory}`), 1); args.push(`--wallet-file=${walletFile}`, `--password=${walletPassword}`); } child = spawn(rpcBinary, args, { shell: false, windowsHide: true, stdio: 'ignore', cwd: temp }); child.on('error', () => {}); } async function rpc(method, params = {}) { const id = randomUUID(); const response = await fetch(endpoint, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), headers: { accept: 'application/json', 'content-type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), }); assert.equal(response.status, 200, `RPC ${method} status`); const body = await response.json(); assert.equal(body.jsonrpc, '2.0'); assert.equal(body.id, id); if (body.error) { const error = new Error(`RPC ${method} returned an error`); error.rpcError = body.error; throw error; } return body.result; } async function waitUntilReady() { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`wallet-RPC exited during startup (${child.exitCode})`); try { return await rpc('get_version'); } catch { await new Promise(resolveWait => setTimeout(resolveWait, 100)); } } throw new Error('wallet-RPC did not become ready on loopback'); } async function stopWalletRpc() { if (!child || child.exitCode !== null) return; await rpc('store').catch(() => {}); await rpc('stop_wallet').catch(() => {}); await Promise.race([ new Promise(resolveExit => child.once('exit', resolveExit)), new Promise(resolveTimeout => setTimeout(resolveTimeout, 5000)), ]); if (child.exitCode === null) child.kill('SIGKILL'); } let created = false; try { console.log('stage: start offline stagenet wallet RPC'); startWalletRpc(); const version = await waitUntilReady(); console.log('stage: RPC ready'); assert.equal(version.release, true); await rpc('create_wallet', { filename: 'temporary-source', password: walletPassword, language: 'English' }); console.log('stage: disposable source wallet created'); const originalAddress = (await rpc('get_address', { account_index: 0 })).address; const originalValidation = await rpc('validate_address', { address: originalAddress }); assert.equal(originalValidation.valid, true); assert.equal(originalValidation.nettype, 'stagenet'); const viewKey = (await rpc('query_key', { key_type: 'view_key' })).key; assert.match(viewKey, /^[a-f0-9]{64}$/); const generated = await rpc('generate_from_keys', { filename: watchOnlyName, address: originalAddress, viewkey: viewKey, password: walletPassword, restore_height: 0, autosave_current: true, }); console.log('stage: watch-only wallet generated'); assert.match(generated.address, /^.{95}$/); assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress); const watchValidation = await rpc('validate_address', { address: originalAddress }); assert.equal(watchValidation.valid, true); assert.equal(watchValidation.nettype, 'stagenet'); await assert.rejects(rpc('query_key', { key_type: 'spend_key' })); const before = await rpc('get_address', { account_index: 0 }); const reservedIndex = Math.max(...before.addresses.map(address => address.address_index)) + 1; let absent; try { absent = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); } catch (error) { assert.equal(error.rpcError?.code, -15); assert.match(error.rpcError?.message ?? '', /address index is out of bound/i); absent = { addresses: [] }; } assert.equal(absent.addresses.length, 0, 'reserved index should be absent before create_address'); const label = `wikideck-deposit-v1:${randomUUID()}`; const lostResponse = await rpc('create_address', { account_index: 0, label, count: 1 }); console.log('stage: one subaddress created; simulating lost application response'); assert.equal(lostResponse.address_index, reservedIndex); assert.equal(lostResponse.addresses.length, 1); // Simulate the application losing the response: discard its address, then recover by reserved index. const recovered = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); assert.equal(recovered.addresses.length, 1); assert.equal(recovered.addresses[0].address, lostResponse.address); assert.equal(recovered.addresses[0].label, label); await stopWalletRpc(); console.log('stage: wallet RPC stopped after store'); startWalletRpc(join(walletDirectory, watchOnlyName)); await waitUntilReady(); console.log('stage: wallet RPC restarted with watch-only wallet'); assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress); assert.equal((await rpc('validate_address', { address: originalAddress })).nettype, 'stagenet'); await assert.rejects(rpc('query_key', { key_type: 'spend_key' })); const afterRestart = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); assert.equal(afterRestart.addresses.length, 1); assert.equal(afterRestart.addresses[0].address, recovered.addresses[0].address); assert.equal(afterRestart.addresses[0].label, label); created = true; console.log(JSON.stringify({ result: 'passed', moneroWalletRpc: '0.18.5.4', network: 'Stagenet', offline: true, watchOnly: true, rpcBind: '127.0.0.1', subaddressIndex: reservedIndex, lostResponseRecovery: true, restartPersistence: true, realFundsUsed: false, addressOrKeysLogged: false, })); } finally { await stopWalletRpc(); await rm(temp, { recursive: true, force: true }); if (!created) process.exitCode = 1; }