Wallet subaddress creation with retry recovery
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
|
||||
import { validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
@@ -194,3 +195,73 @@ export async function registerMoneroDepositSubaddress(
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or recover the wallet subaddress for a previously committed reservation. A stable
|
||||
* wallet label makes a lost create_address response recoverable via get_address at the exact
|
||||
* reserved index. The wallet RPC must be dedicated to this allocator: external address
|
||||
* creation can consume indexes and is treated as a hard mismatch, never silently reassigned.
|
||||
*/
|
||||
export async function createOrRecoverMoneroDepositSubaddress(
|
||||
pool: Pool,
|
||||
rpc: MoneroDepositAddressRpc,
|
||||
allocationId: string,
|
||||
walletGenerationId: string,
|
||||
registeredAt: number,
|
||||
): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> {
|
||||
const parsedAllocationId = uuid.parse(allocationId);
|
||||
const parsedGenerationId = uuid.parse(walletGenerationId);
|
||||
if (!Number.isSafeInteger(registeredAt) || registeredAt < 0) throw new Error('Invalid deposit address registration time');
|
||||
|
||||
const client = await pool.connect();
|
||||
let address: string | undefined;
|
||||
let label: string | undefined;
|
||||
let lockKey: string | undefined;
|
||||
let locked = false;
|
||||
try {
|
||||
const initial = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
|
||||
if (!initial.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation');
|
||||
if (initial.rows[0].status === 'registered') {
|
||||
return { allocation: toAllocation(initial.rows[0]), replayed: true };
|
||||
}
|
||||
const row = initial.rows[0];
|
||||
lockKey = `wikideck-monero-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
|
||||
await client.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
|
||||
locked = true;
|
||||
|
||||
// Re-read after obtaining the per-wallet lock in case another process finished first.
|
||||
const current = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
|
||||
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
|
||||
if (!current.rowCount) throw new Error('Deposit allocation reservation disappeared');
|
||||
if (current.rows[0].status === 'registered') {
|
||||
return { allocation: toAllocation(current.rows[0]), replayed: true };
|
||||
}
|
||||
const allocation = current.rows[0];
|
||||
const expectedLabel = `wikideck-deposit-v1:${allocation.allocation_id}`;
|
||||
const expectedIndex = Number(allocation.address_index);
|
||||
const existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (existing) {
|
||||
if (existing.label !== expectedLabel) throw new Error('Reserved Monero subaddress index is occupied by a different wallet label');
|
||||
address = await validateXmrSubaddress(existing.address);
|
||||
label = existing.label;
|
||||
} else {
|
||||
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
|
||||
if (created.addressIndex !== expectedIndex) {
|
||||
throw new Error('Monero wallet created an unexpected subaddress index; allocation requires operator reconciliation');
|
||||
}
|
||||
const verified = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
if (!verified || verified.label !== expectedLabel || verified.address !== created.address) {
|
||||
throw new Error('Monero wallet subaddress could not be verified at its reserved index');
|
||||
}
|
||||
address = await validateXmrSubaddress(verified.address);
|
||||
label = verified.label;
|
||||
}
|
||||
} finally {
|
||||
if (locked && lockKey) await client.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
|
||||
client.release();
|
||||
}
|
||||
if (!address || !label) throw new Error('Monero deposit address allocation was not verified');
|
||||
return registerMoneroDepositSubaddress(pool, { allocationId: parsedAllocationId,
|
||||
walletGenerationId: parsedGenerationId, moneroAddress: address, registeredAt });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { 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 } from './xmr.ts';
|
||||
import { validateXmrAddress, validateXmrSubaddress } from './xmr.ts';
|
||||
|
||||
const MAX_CONFIG_BYTES = 4096;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
@@ -20,6 +20,13 @@ const rpcEnvelopeSchema = z.object({
|
||||
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 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),
|
||||
@@ -123,10 +130,12 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
|
||||
}
|
||||
|
||||
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
|
||||
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
|
||||
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | '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', params: Record<string, unknown> = {}) {
|
||||
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | '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',
|
||||
@@ -165,6 +174,12 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
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 = fetch) {
|
||||
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']);
|
||||
return {
|
||||
async verifyMainnetWallet(): Promise<string> {
|
||||
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
||||
@@ -202,3 +217,49 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MoneroDepositAddressRpc = {
|
||||
getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>;
|
||||
createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow address-allocation capability; it can only inspect or create one subaddress at a
|
||||
* time. 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', 'create_address']);
|
||||
return {
|
||||
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] });
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user