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
+3 -1
View File
@@ -19,7 +19,7 @@ The operator has authorized proceeding with implementation instead of the previo
**Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked.
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability requires `get_wallet_info` to identify a Mainnet, Watch only wallet; it checks `get_wallet_info` against `get_address` and derives a domain-separated fingerprint from the verified primary address. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the deterministic allocation label before retrying creation. Address registration is immutable and performed only after the wallet returns the expected index/address/label. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. The configured wallet-generation UUID still requires controlled operator enrollment and backup; the fingerprint/identity checks are implemented but only fixture-tested. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **184/184** offline tests and **35/35** disposable PostgreSQL tests passed, including rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without duplicate address creation or journal posting. No live wallet RPC, wallet credential or funds were used. This is not a public deposit allocator or live deposit processor; mainnet payments remain disabled.
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. Actual testing against the verified Monero v0.18.5.4 Wallet RPC found that `get_wallet_info` returns JSON-RPC “Method not found” despite appearing in current docs; the implementation therefore does not depend on it. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the deterministic allocation label before retrying creation. Actual v0.18.5.4 testing also confirmed that an unused address index returns RPC error `-15` (“address index is out of bound”), rather than an empty `addresses` array; the client recognizes only that exact error as “not created” and fails closed for other errors. Address registration is immutable and performed only after the wallet returns the expected index/address/label. The installed v0.18.5.4 interface does not provide a supported `get_wallet_info` query to attest the wallet type, so watch-only provisioning is an operator/deployment requirement, not a property this RPC client can prove. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. The configured wallet-generation UUID still requires controlled operator enrollment and backup. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **185/185** offline tests and **35/35** disposable PostgreSQL tests passed, including rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without duplicate address creation or journal posting. An actual wallet-RPC rehearsal on the authenticated official v0.18.5.4 archive passed in Docker `--network none`, `--stagenet`, `--offline`, loopback-only mode: it created a disposable watch-only wallet, allocated/labeled one subaddress, recovered by index after discarding the create response, and verified the same address/label after RPC restart. No daemon/network peers, funds, or production credentials were used; all generated wallet files were deleted with the temporary container. This remains test groundwork, not a public deposit allocator or live processor; mainnet payments remain disabled.
Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness.
@@ -153,3 +153,5 @@ Required failure outcomes: duplicate deposit credits are impossible; insufficien
- [Transfer history](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_transfer_by_txid) and [rescan](https://docs.getmonero.org/rpc-library/wallet-rpc/#rescan_blockchain): wallet restore can lose outgoing destinations, transaction keys and notes. Blockchain replay is not a backup for customer accounting.
Source review: 2026-10-20; documentation is capability evidence, not an audited implementation of this design. No wallet/RPC credentials or funds were accessed.
**Actual RPC integration blocker:** The v0.18.5.4 process rehearsal confirmed a login-protected wallet RPC returns HTTP 401 with a Digest challenge; sending Basic auth still returns 401. The current client therefore cannot authenticate to that configuration. Keep authenticated wallet RPC disabled until a reviewed compatible transport is implemented; do not disable authentication outside a strictly isolated test.
+5 -1
View File
@@ -20,7 +20,11 @@ Status: active engineering plan; **not launch or payment authorization**. Update
**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits.
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability now requires a Mainnet, Watch only wallet and compares `get_wallet_info` with the primary `get_address`; it derives the wallet fingerprint from that validated address. The internal allocator checks that fingerprint against the durable generation before reserving/creating, reserves the beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It refuses mismatched wallet identity/index instead of silently binding. This is fixture-tested only, not verified against an actual wallet binary or wallet generation. It is not wired to startup/routes/UI/scans/financial posting, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated allocator wallet RPC, controlled generation enrollment/backups and wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **184/184** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, independent-pool allocator concurrency, replay, immutable registration, least-privilege isolation, zero journal postings, mismatched-wallet rejection and simulated response-loss recovery without duplicate address creation. No live RPC, wallet credential or funds were used. Remaining deposit milestone: rehearse against a disposable local Monero wallet on stagenet; persist and verify wallet-generation enrollment and restart recovery; establish stable per-output identity and canonical inclusion/lock/reorg semantics; then build exact idempotent journal crediting plus audited reversal/hold handling and independent accounting/security review before any real deposits.
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability verifies a mainnet primary address using `get_address` and `validate_address` and derives a wallet fingerprint; it does not claim to attest wallet type. Actual testing found `get_wallet_info` is unavailable as JSON-RPC in v0.18.5.4, and Monero’s `--rpc-login` requires HTTP Digest while the current client sends Basic auth. Therefore authenticated RPC integration is **not yet compatible**; do not enable the client against a login-protected RPC. Watch-only wallet provisioning and isolated network placement must be enforced by deployment until a reviewed compatible auth/provisioning design is implemented. The internal allocator checks the primary-address fingerprint against the durable generation before creating, reserves beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It recognizes only the observed “address index is out of bound” error as an unused slot and fails closed on other RPC errors. Actual RPC verification is limited to the offline disposable rehearsal below; no application startup/routes/UI/scans or financial posting are wired, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated wallet RPC, controlled generation enrollment/backups, and wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **185/185** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, allocator concurrency, replay, immutable registration, role isolation, zero journal postings, wallet mismatch rejection, the official -15 index response, and simulated response-loss recovery. No live RPC credentials, funds or mainnet wallet were used. Remaining deposit milestone: resolve authenticated RPC transport and watch-only provisioning, establish durable wallet-generation enrollment and recovery, pin per-output identity and canonical inclusion/lock/reorg semantics, then implement idempotent credit journals and audited reversal/hold handling before stagenet deposit-credit rehearsal or any real deposits.
**Initial actual-wallet rehearsal blocker (2026-10-21, superseded below):** no Monero wallet binary or pinned image was present locally, so the RPC contract could not yet be exercised against a real process. This was resolved by obtaining and verifying the official v0.18.5.4 archive and running the disposable offline Stagenet rehearsal recorded below. The recorded evidence does not authorize mainnet access, deposits, credits, or payments.
**Actual Wallet RPC rehearsal completed (2026-10-21):** obtained Monero CLI v0.18.5.4 from the official downloads host. Verified the `binaryFate` GPG key fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, the GPG-signed canonical hashes file, and the Linux x64 archive SHA-256 `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10`. Ran only the official `monero-wallet-rpc` executable in a disposable Docker container with `--network none --stagenet --offline`, loopback bind, and RPC login disabled only inside that network-isolated container. The rehearsal generated a temporary Stagenet wallet, derived a watch-only wallet from its view key (no key material or addresses logged), confirmed `validate_address` reports Stagenet, and verified watch-only behavior because `query_key(spend_key)` failed. It reserved the next index, observed v0.18.5.4 return error `-15` (“address index is out of bound”) for that unused index, created exactly one labeled subaddress, recovered by index after discarding the create response, stopped/restarted wallet RPC with the same wallet data, and verified the same address and label persisted. `get_wallet_info` returned Method not found. The release archive has no `monerod`, so there was no daemon, peer connection or chain sync. The run logic is captured by [scripts/monero-stagenet-rpc-rehearsal.mjs](../scripts/monero-stagenet-rpc-rehearsal.mjs). It does **not** test authenticated application transport (the fixture deliberately disables RPC login inside the isolated container), concurrent external wallet clients, hard power-loss durability, deposits, credits, withdrawals, mainnet, or live payment. Temporary wallet files and the verification/download directory are removed after preserving the signed hash/digest evidence; no production credentials or funds were used.
## 1A. MVP release boundary — proposed safe first production-hosted release
+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;
}
+39 -17
View File
@@ -15,13 +15,15 @@ const configSchema = z.object({
password: z.string().min(1).max(1024),
}).strict();
const rpcEnvelopeSchema = z.object({
jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()),
}).strict();
jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()).optional(),
error: z.object({ code: z.string().regex(/^-?(0|[1-9]\d*)$/).transform(Number)
.refine(Number.isSafeInteger), message: z.string().max(1024) }).passthrough().optional(),
}).strict().refine(value => (value.result === undefined) !== (value.error === undefined));
const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/);
const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough();
const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough();
const walletInfoResultSchema = z.object({ address: z.string().length(95), network_type: z.literal('Mainnet'),
wallet_type: z.literal('Watch only') }).passthrough();
const validateAddressResultSchema = z.object({ valid: z.literal(true), integrated: z.literal(false),
nettype: z.literal('mainnet'), subaddress: z.boolean(), openalias_address: z.literal('') }).passthrough();
const subaddressEntrySchema = z.object({ address: z.string().length(95), label: z.string().max(128),
address_index: integerString, used: z.boolean() }).passthrough();
const subaddressReadResultSchema = z.object({ address: z.string().length(95),
@@ -69,6 +71,17 @@ export type MoneroObservationPage = {
};
export type MoneroFixtureTransport = (url: string, init: RequestInit) => Promise<Response>;
class MoneroWalletRpcError extends Error {
readonly rpcCode: number;
readonly rpcMessage: string;
constructor(rpcCode: number, rpcMessage: string) {
super(`Monero wallet-RPC method failed (${rpcCode})`);
this.name = 'MoneroWalletRpcError';
this.rpcCode = rpcCode;
this.rpcMessage = rpcMessage;
}
}
function integer(value: string, maximum: bigint, name: string): number {
const parsed = BigInt(value);
if (parsed > maximum || parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Invalid Monero ${name}`);
@@ -133,10 +146,10 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'get_wallet_info' | 'create_address')[]) {
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address')[]) {
const endpoint = validateEndpoint(config.url);
const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'get_wallet_info' | 'create_address', params: Record<string, unknown> = {}) {
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client');
const id = randomUUID(), controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
@@ -171,7 +184,8 @@ function createWalletRpc(config: z.infer<typeof configSchema>, transport: Monero
catch { throw new Error('Monero wallet-RPC returned malformed JSON'); }
const envelope = rpcEnvelopeSchema.parse(raw);
if (envelope.id !== id) throw new Error('Monero wallet-RPC response ID mismatch');
return envelope.result;
if (envelope.error) throw new MoneroWalletRpcError(envelope.error.code, envelope.error.message);
return envelope.result!;
})();
try { return await Promise.race([operation, abort]); }
finally { clearTimeout(timeout); }
@@ -233,28 +247,36 @@ export async function moneroWalletFingerprint(primaryAddress: string): Promise<s
}
/**
* Narrow address-allocation capability; it requires a verified watch-only mainnet wallet and
* can only inspect or create one subaddress at a time. Keep this credential/service separate
* Narrow address-allocation capability; it verifies the mainnet primary address and can only
* inspect or create one subaddress at a time. Watch-only status must be enforced by wallet
* provisioning because the pinned Wallet RPC does not expose a supported wallet-type query.
* Keep this credential/service separate
* from API and withdrawal capabilities. This
* client is intentionally not attached to startup, routes, UI, or automatic crediting.
*/
export function createMoneroDepositAddressRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
const rpc = createWalletRpc(config, transport, ['get_address', 'get_wallet_info', 'create_address']);
const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']);
return {
async verifyWallet(): Promise<string> {
const [infoRaw, addressRaw] = await Promise.all([rpc('get_wallet_info'), rpc('get_address', { account_index: 0 })]);
const info = walletInfoResultSchema.parse(infoRaw);
const address = addressResultSchema.parse(addressRaw);
await validateXmrAddress(info.address);
const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
await validateXmrAddress(address.address);
if (info.address !== address.address) throw new Error('Monero wallet primary address identity mismatch');
return moneroWalletFingerprint(info.address);
validateAddressResultSchema.parse(await rpc('validate_address', { address: address.address }));
return moneroWalletFingerprint(address.address);
},
async getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null> {
if (![accountIndex, addressIndex].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff)) {
throw new Error('Invalid Monero subaddress index');
}
const raw = await rpc('get_address', { account_index: accountIndex, address_index: [addressIndex] });
let raw: Record<string, unknown>;
try {
raw = await rpc('get_address', { account_index: accountIndex, address_index: [addressIndex] });
} catch (error) {
// Monero v0.18.5.4 reports an unused, not-yet-created index as RPC error -15.
// Do not treat unrelated account, wallet, or transport failures as an empty address.
if (error instanceof MoneroWalletRpcError && error.rpcCode === -15 &&
/address index is out of bound/i.test(error.rpcMessage)) return null;
throw error;
}
const result = subaddressReadResultSchema.parse(raw);
const entry = result.addresses[0];
if (!entry) return null;
+26 -7
View File
@@ -9,6 +9,10 @@ const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } });
}
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 }[] } {
const calls: { method: string; params: unknown; auth: string | null }[] = [];
const transport: MoneroFixtureTransport = async (url, init) => {
@@ -63,8 +67,8 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v
const transport: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown };
calls.push({ method: body.method, params: body.params });
if (body.method === 'get_wallet_info') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
network_type: 'Mainnet', wallet_type: 'Watch only' });
if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false,
nettype: 'mainnet', subaddress: false, openalias_address: '' });
if (body.method === 'get_address') {
const params = body.params as { address_index?: number[] };
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
@@ -80,8 +84,8 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v
assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' });
assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 });
assert.deepEqual(calls, [
{ method: 'get_wallet_info', params: {} },
{ method: 'get_address', params: { account_index: 0 } },
{ method: 'validate_address', params: { address: ADDITIONAL_MARKET_FEE.address } },
{ method: 'get_address', params: { account_index: 0, address_index: [7] } },
{ method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } },
]);
@@ -99,11 +103,26 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v
const spendCapableWallet: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string; method: string };
if (body.method === 'get_wallet_info') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
network_type: 'Mainnet', wallet_type: 'Normal' });
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false,
nettype: 'stagenet', subaddress: false, openalias_address: '' });
throw new Error(`unexpected method ${body.method}`);
};
await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet(), /expected/);
await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet());
});
test('official wallet not-yet-created address-index error is recoverable but unrelated RPC errors fail closed', async () => {
const notFound: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string };
return rpcErrorResponse(body.id, -15, 'address index is out of bound');
};
assert.equal(await createMoneroDepositAddressRpc(config, notFound).getSubaddress(0, 1), null);
const unrelated: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string };
return rpcErrorResponse(body.id, -15, 'account index is out of bound');
};
await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/);
});
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {