Stagenet wallet rehearsal passed

This commit is contained in:
2026-10-21 16:53:59 -04:00
parent 0534cde1a9
commit f00ea55dfd
5 changed files with 243 additions and 26 deletions
+170
View File
@@ -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;
}