Wallet subaddress creation with retry recovery

This commit is contained in:
2026-10-21 15:12:05 -04:00
parent e70199ecb0
commit c8cf8245fc
6 changed files with 214 additions and 7 deletions
@@ -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 });
}