171 lines
7.0 KiB
JavaScript
Executable File
171 lines
7.0 KiB
JavaScript
Executable File
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;
|
|
}
|