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
+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;