367 lines
20 KiB
TypeScript
Executable File
367 lines
20 KiB
TypeScript
Executable File
import { createHash, randomUUID } from 'node:crypto';
|
|
import { lstat, readFile } from 'node:fs/promises';
|
|
import { z } from 'zod';
|
|
import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
|
import { validateXmrAddress, validateXmrSubaddress } from './xmr.ts';
|
|
|
|
const MAX_CONFIG_BYTES = 4096;
|
|
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
const RPC_TIMEOUT_MS = 5000;
|
|
const MAX_TRANSFERS = 4096;
|
|
const configSchema = z.object({
|
|
network: z.literal('mainnet'),
|
|
url: z.string().min(1).max(2048),
|
|
}).strict();
|
|
const rpcEnvelopeSchema = z.object({
|
|
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 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),
|
|
addresses: z.array(subaddressEntrySchema).max(1), status: z.literal('OK') }).passthrough();
|
|
const subaddressCreateResultSchema = z.object({ address: z.string().length(95), address_index: integerString,
|
|
address_indices: z.array(integerString).max(1).optional(), addresses: z.array(z.string().length(95)).max(1).optional(),
|
|
status: z.literal('OK') }).passthrough();
|
|
const incomingTransferSchema = z.object({
|
|
txid: z.string().regex(/^[a-f0-9]{64}$/),
|
|
address: z.string().length(95),
|
|
amount: integerString,
|
|
height: integerString,
|
|
timestamp: integerString,
|
|
unlock_time: integerString,
|
|
locked: z.boolean(),
|
|
double_spend_seen: z.boolean(),
|
|
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
|
|
}).passthrough();
|
|
const transfersResultSchema = z.object({
|
|
in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]),
|
|
status: z.literal('OK'),
|
|
}).passthrough();
|
|
const incomingOutputSchema = z.object({
|
|
amount: integerString,
|
|
global_index: integerString,
|
|
tx_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
|
|
unlocked: z.boolean(),
|
|
block_height: integerString,
|
|
pubkey: z.string().regex(/^[a-f0-9]{64}$/),
|
|
}).passthrough();
|
|
const incomingOutputsResultSchema = z.object({
|
|
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS),
|
|
status: z.literal('OK'),
|
|
}).passthrough();
|
|
|
|
export type MoneroIncomingObservation = {
|
|
observationId: string;
|
|
transactionId: string;
|
|
address: string;
|
|
amountAtomicXmr: string;
|
|
height: number;
|
|
confirmations: number;
|
|
timestamp: number;
|
|
unlockTime: string;
|
|
accountIndex: number;
|
|
addressIndex: number;
|
|
outputPublicKey: string;
|
|
globalOutputIndex: number;
|
|
locked: boolean;
|
|
doubleSpendSeen: boolean;
|
|
};
|
|
export type MoneroObservationPage = {
|
|
network: 'mainnet';
|
|
walletHeight: number;
|
|
fromHeight: number;
|
|
throughHeight: number;
|
|
primaryAddress: string;
|
|
incoming: MoneroIncomingObservation[];
|
|
};
|
|
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}`);
|
|
return Number(parsed);
|
|
}
|
|
|
|
/** JSON.parse rounds uint64 amounts. Convert JSON numeric tokens to strings before parsing,
|
|
* preserving exact wallet-RPC integer text so all monetary values can be checked as bigint. */
|
|
function parseLosslessJson(text: string): unknown {
|
|
let output = '', inString = false, escaped = false;
|
|
for (let i = 0; i < text.length;) {
|
|
const char = text[i];
|
|
if (inString) {
|
|
output += char;
|
|
if (escaped) escaped = false;
|
|
else if (char === '\\') escaped = true;
|
|
else if (char === '"') inString = false;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char === '"') { inString = true; output += char; i++; continue; }
|
|
if (char === '-' || (char >= '0' && char <= '9')) {
|
|
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
if (!match) throw new Error('Invalid JSON numeric token');
|
|
output += JSON.stringify(match[0]); i += match[0].length; continue;
|
|
}
|
|
output += char; i++;
|
|
}
|
|
return JSON.parse(output) as unknown;
|
|
}
|
|
|
|
function validateEndpoint(value: string): URL {
|
|
let url: URL;
|
|
try { url = new URL(value); } catch { throw new Error('Invalid Monero wallet-RPC endpoint'); }
|
|
if (url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') {
|
|
throw new Error('Monero wallet-RPC endpoint must be a bare /json_rpc URL');
|
|
}
|
|
const loopback = url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
throw new Error('Wallet RPC requires HTTPS, except loopback-only HTTP');
|
|
}
|
|
if (!url.port && !['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid Monero wallet-RPC port');
|
|
return url;
|
|
}
|
|
|
|
export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.env) {
|
|
if (env.WIKIDECK_MONERO_OBSERVER !== '1') throw new Error('Monero observer is disabled');
|
|
const path = env.WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE;
|
|
if (!path) throw new Error('Monero wallet-RPC config file is required');
|
|
try {
|
|
const info = await lstat(path);
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error();
|
|
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
|
const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
|
validateEndpoint(parsed.url);
|
|
return parsed;
|
|
} catch {
|
|
throw new Error('Invalid or inaccessible protected Monero wallet-RPC configuration');
|
|
}
|
|
}
|
|
|
|
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
|
|
/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */
|
|
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
|
|
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address')[]) {
|
|
const endpoint = validateEndpoint(config.url);
|
|
// Removed Basic auth header
|
|
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'incoming_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();
|
|
const abort = new Promise<never>((_, reject) => controller.signal.addEventListener('abort',
|
|
() => reject(new Error('Monero wallet-RPC deadline exceeded')), { once: true }));
|
|
const operation = (async () => {
|
|
let response: Response;
|
|
try {
|
|
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
|
|
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
|
|
} catch { throw new Error('Monero wallet-RPC request failed'); }
|
|
if (!response.ok || response.redirected || !response.body ||
|
|
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
|
await response.body?.cancel(); throw new Error('Monero wallet-RPC response rejected');
|
|
}
|
|
const contentLength = response.headers.get('content-length');
|
|
if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) {
|
|
await response.body.cancel(); throw new Error('Monero wallet-RPC response exceeds size limit');
|
|
}
|
|
const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0;
|
|
try {
|
|
for (;;) {
|
|
const part = await reader.read(); if (part.done) break;
|
|
size += part.value.byteLength;
|
|
if (size > MAX_RESPONSE_BYTES) throw new Error('Monero wallet-RPC response exceeds size limit');
|
|
chunks.push(part.value);
|
|
}
|
|
} finally { await reader.cancel(); reader.releaseLock(); }
|
|
let raw: unknown;
|
|
try { raw = parseLosslessJson(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
|
|
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');
|
|
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); }
|
|
}
|
|
return rpc;
|
|
}
|
|
|
|
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
|
|
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
|
|
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers', 'incoming_transfers']);
|
|
return {
|
|
async verifyMainnetWallet(): Promise<string> {
|
|
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
|
return validateXmrAddress(result.address);
|
|
},
|
|
async scanIncoming(fromHeight: number): Promise<MoneroObservationPage> {
|
|
if (!Number.isSafeInteger(fromHeight) || fromHeight < 0) throw new Error('Invalid Monero scan height');
|
|
const [heightRaw, primaryAddress] = await Promise.all([
|
|
rpc('get_height'), this.verifyMainnetWallet(),
|
|
]);
|
|
const walletHeight = integer(heightResultSchema.parse(heightRaw).height, BigInt(Number.MAX_SAFE_INTEGER), 'height');
|
|
if (fromHeight > walletHeight) throw new Error('Monero observer cursor is ahead of wallet height');
|
|
const transferRaw = transfersResultSchema.parse(await rpc('get_transfers', {
|
|
in: true, filter_by_height: true, min_height: fromHeight, max_height: walletHeight,
|
|
}));
|
|
if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor');
|
|
const transferMetadata = new Map<string, z.infer<typeof incomingTransferSchema>>();
|
|
const keyForIndex = (txid: string, major: number, minor: number) => `${txid}:${major}:${minor}`;
|
|
for (const item of transferRaw.in) {
|
|
const key = keyForIndex(item.txid, integer(item.subaddr_index.major, 0xffffffffn, 'account index'),
|
|
integer(item.subaddr_index.minor, 0xffffffffn, 'address index'));
|
|
if (transferMetadata.has(key)) throw new Error('Duplicate incoming Monero transaction metadata');
|
|
transferMetadata.set(key, item);
|
|
}
|
|
|
|
// get_transfers groups amounts by transaction/subaddress. incoming_transfers exposes each
|
|
// owned output and its public key, which is the stable per-output identity needed for credit idempotency.
|
|
const requestedIndices = new Map<number, Set<number>>();
|
|
for (const item of transferRaw.in) {
|
|
const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index');
|
|
const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index');
|
|
const indices = requestedIndices.get(major) ?? new Set<number>();
|
|
indices.add(minor); requestedIndices.set(major, indices);
|
|
}
|
|
const incoming: MoneroIncomingObservation[] = [];
|
|
const seenOutputKeys = new Set<string>();
|
|
for (const [major, indices] of requestedIndices) {
|
|
const minors = [...indices];
|
|
for (let offset = 0; offset < minors.length; offset += 64) {
|
|
const selected = minors.slice(offset, offset + 64);
|
|
const outputRaw = incomingOutputsResultSchema.parse(await rpc('incoming_transfers', {
|
|
transfer_type: 'all', account_index: major, subaddr_indices: selected,
|
|
}));
|
|
if (outputRaw.transfers.length >= MAX_TRANSFERS) {
|
|
throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
|
}
|
|
for (const output of outputRaw.transfers) {
|
|
const outputMajor = integer(output.subaddr_index.major, 0xffffffffn, 'account index');
|
|
const outputMinor = integer(output.subaddr_index.minor, 0xffffffffn, 'address index');
|
|
if (outputMajor !== major || !indices.has(outputMinor)) throw new Error('Wallet returned an output outside requested subaddresses');
|
|
const metadata = transferMetadata.get(keyForIndex(output.tx_hash, outputMajor, outputMinor));
|
|
if (!metadata) continue;
|
|
const height = integer(output.block_height, BigInt(Number.MAX_SAFE_INTEGER), 'output height');
|
|
const expectedHeight = integer(metadata.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
|
|
if (height !== expectedHeight || height < fromHeight || height > walletHeight) continue;
|
|
const amount = BigInt(output.amount);
|
|
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero output amount');
|
|
const globalOutputIndex = integer(output.global_index, BigInt(Number.MAX_SAFE_INTEGER), 'global output index');
|
|
const outputPublicKey = output.pubkey;
|
|
if (seenOutputKeys.has(outputPublicKey)) throw new Error('Duplicate Monero wallet output public key');
|
|
if (incoming.length >= MAX_TRANSFERS) throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
|
seenOutputKeys.add(outputPublicKey);
|
|
incoming.push({ observationId: `${output.tx_hash}:${outputPublicKey}`, transactionId: output.tx_hash,
|
|
address: metadata.address, amountAtomicXmr: amount.toString(), height,
|
|
confirmations: walletHeight - height + 1,
|
|
timestamp: integer(metadata.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
|
|
unlockTime: metadata.unlock_time, accountIndex: outputMajor, addressIndex: outputMinor,
|
|
outputPublicKey, globalOutputIndex, locked: !output.unlocked, doubleSpendSeen: metadata.double_spend_seen });
|
|
}
|
|
}
|
|
}
|
|
const totals = new Map<string, bigint>();
|
|
for (const output of incoming) {
|
|
const key = keyForIndex(output.transactionId, output.accountIndex, output.addressIndex);
|
|
totals.set(key, (totals.get(key) ?? 0n) + BigInt(output.amountAtomicXmr));
|
|
}
|
|
for (const [key, metadata] of transferMetadata) {
|
|
if (totals.get(key) !== BigInt(metadata.amount)) throw new Error('Wallet transaction amount does not match its owned output set');
|
|
await validateXmrAddress(metadata.address);
|
|
}
|
|
return { network: 'mainnet', walletHeight, fromHeight, throughHeight: walletHeight, primaryAddress, incoming };
|
|
},
|
|
};
|
|
}
|
|
|
|
export type MoneroDepositAddressRpc = {
|
|
verifyWallet(): Promise<string>;
|
|
getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>;
|
|
createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>;
|
|
};
|
|
|
|
/** Stable, non-secret identity of the wallet generation, bound to its verified mainnet primary address. */
|
|
export async function moneroWalletFingerprint(primaryAddress: string): Promise<string> {
|
|
await validateXmrAddress(primaryAddress);
|
|
return createHash('sha256').update(JSON.stringify(['wikideck-monero-wallet-generation-v1', primaryAddress])).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']);
|
|
return {
|
|
async verifyWallet(): Promise<string> {
|
|
const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0, address_index: [0] }));
|
|
await validateXmrAddress(address.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');
|
|
}
|
|
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;
|
|
if (integer(entry.address_index, 0xffff_ffffn, 'address index') !== addressIndex) {
|
|
throw new Error('Monero wallet returned a different subaddress index');
|
|
}
|
|
await validateXmrAddress(entry.address);
|
|
return { address: entry.address, label: entry.label };
|
|
},
|
|
async createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }> {
|
|
if (!Number.isSafeInteger(accountIndex) || accountIndex < 0 || accountIndex > 0xffff_ffff ||
|
|
typeof label !== 'string' || label.length < 1 || label.length > 128 || /[\r\n\x00]/.test(label)) {
|
|
throw new Error('Invalid Monero subaddress creation parameters');
|
|
}
|
|
const result = subaddressCreateResultSchema.parse(await rpc('create_address', {
|
|
account_index: accountIndex, label, count: 1,
|
|
}));
|
|
const addressIndex = integer(result.address_index, 0xffff_ffffn, 'address index');
|
|
if ((result.address_indices && (result.address_indices.length !== 1 || integer(result.address_indices[0], 0xffff_ffffn, 'address index') !== addressIndex)) ||
|
|
(result.addresses && (result.addresses.length !== 1 || result.addresses[0] !== result.address))) {
|
|
throw new Error('Monero wallet returned inconsistent subaddress creation data');
|
|
}
|
|
await validateXmrSubaddress(result.address);
|
|
return { address: result.address, addressIndex };
|
|
},
|
|
};
|
|
}
|