Stagenet wallet rehearsal passed
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user