Stagenet wallet rehearsal passed
This commit is contained in:
Executable
+170
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user