import { createHash, randomUUID } from 'node:crypto'; import type { Pool, PoolClient } from 'pg'; import { z } from 'zod'; import { validateXmrSubaddress } from './xmr.ts'; const uuid = z.string().uuid(); const hex64 = z.string().regex(/^[a-f0-9]{64}$/); const index = z.number().int().min(0).max(0xffff_ffff).safe(); const reserveInputSchema = z.object({ requestId: uuid, custodianId: uuid, marketNetwork: z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']), marketChainId: hex64, beneficiary: hex64, walletGenerationId: uuid, walletFingerprint: hex64, accountIndex: index, reservedAt: z.number().int().nonnegative().safe(), }).strict(); const registerInputSchema = z.object({ allocationId: uuid, walletGenerationId: uuid, moneroAddress: z.string().length(95), registeredAt: z.number().int().nonnegative().safe(), }).strict(); export type MoneroDepositAllocation = { allocationId: string; requestId: string; custodianId: string; marketNetwork: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; marketChainId: string; beneficiary: string; walletGenerationId: string; walletFingerprint: string; moneroNetwork: 'mainnet'; accountIndex: number; addressIndex: number; status: 'reserved' | 'registered'; moneroAddress: string | null; reservedAt: number; registeredAt: number | null; }; type AllocationRow = { allocation_id: string; request_id: string; custodian_id: string; market_network: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; market_chain_id: string; beneficiary: string; wallet_generation_id: string; wallet_fingerprint: string; monero_network: 'mainnet'; account_index: string; address_index: string; status: 'reserved' | 'registered'; monero_address: string | null; reserved_at: string; registered_at: string | null; request_digest: string; }; function toAllocation(row: AllocationRow): MoneroDepositAllocation { return { allocationId: row.allocation_id, requestId: row.request_id, custodianId: row.custodian_id, marketNetwork: row.market_network, marketChainId: row.market_chain_id, beneficiary: row.beneficiary, walletGenerationId: row.wallet_generation_id, walletFingerprint: row.wallet_fingerprint, moneroNetwork: row.monero_network, accountIndex: Number(row.account_index), addressIndex: Number(row.address_index), status: row.status, moneroAddress: row.monero_address, reservedAt: Number(row.reserved_at), registeredAt: row.registered_at === null ? null : Number(row.registered_at), }; } function requestDigest(input: z.infer): string { return createHash('sha256').update(JSON.stringify([ 'wikideck-monero-deposit-allocation-v1', input.custodianId, input.marketNetwork, input.marketChainId, input.beneficiary, input.walletGenerationId, input.walletFingerprint, input.accountIndex, ])).digest('hex'); } async function finishTransaction(client: PoolClient, commit: boolean): Promise { if (commit) await client.query('COMMIT'); else await client.query('ROLLBACK').catch(() => {}); } /** * Durably reserve a never-reused wallet subaddress index and bind it to a beneficiary before * any wallet RPC address-generation side effect. This module is deliberately not wired to * startup, HTTP, a wallet RPC, or financial posting. A reserved row is never reclaimed. */ export async function reserveMoneroDepositAllocation( pool: Pool, input: unknown, ): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { const parsed = reserveInputSchema.parse(input); const digest = requestDigest(parsed); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); // Per-wallet/account serialization makes MAX(index)+1 safe across independent processes. await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [`wikideck-monero-deposit-allocation-v1:${parsed.walletGenerationId}:${parsed.accountIndex}`]); const byRequest = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations WHERE request_id=$1 FOR UPDATE`, [parsed.requestId]); if (byRequest.rowCount) { if (byRequest.rows[0].request_digest !== digest) throw new Error('Deposit allocation request ID was reused with different terms'); await finishTransaction(client, true); return { allocation: toAllocation(byRequest.rows[0]), replayed: true }; } const byBeneficiary = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4 AND wallet_generation_id=$5 FOR UPDATE`, [parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, parsed.beneficiary, parsed.walletGenerationId]); if (byBeneficiary.rowCount) { if (byBeneficiary.rows[0].request_digest !== digest) throw new Error('Beneficiary already has a different deposit allocation for this wallet generation'); await finishTransaction(client, true); return { allocation: toAllocation(byBeneficiary.rows[0]), replayed: true }; } const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index FROM wk_custody.monero_deposit_allocations WHERE wallet_generation_id=$1 AND account_index=$2`, [parsed.walletGenerationId, parsed.accountIndex]); const addressIndex = BigInt(next.rows[0].next_index); if (addressIndex > 0xffff_ffffn) throw new Error('Monero subaddress index space is exhausted for this account'); const inserted = await client.query(`INSERT INTO wk_custody.monero_deposit_allocations( allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id, wallet_fingerprint,monero_network,account_index,address_index,status,request_digest,reserved_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',$9,$10,'reserved',$11,$12) RETURNING *`, [randomUUID(), parsed.requestId, parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, parsed.beneficiary, parsed.walletGenerationId, parsed.walletFingerprint, parsed.accountIndex, addressIndex.toString(), digest, parsed.reservedAt]); await finishTransaction(client, true); return { allocation: toAllocation(inserted.rows[0]), replayed: false }; } catch (error) { await finishTransaction(client, false); if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { throw new Error('Deposit allocation conflicts with an existing beneficiary, wallet index, or address'); } throw error; } finally { client.release(); } } /** Register the address returned by a separately managed wallet operation exactly once. */ export async function registerMoneroDepositSubaddress( pool: Pool, input: unknown, ): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { const parsed = registerInputSchema.parse(input); const address = await validateXmrSubaddress(parsed.moneroAddress); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); const found = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1 AND wallet_generation_id=$2 FOR UPDATE`, [parsed.allocationId, parsed.walletGenerationId]); if (!found.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation'); const row = found.rows[0]; if (row.status === 'registered') { if (row.monero_address !== address) throw new Error('Registered deposit address cannot be replaced'); await finishTransaction(client, true); return { allocation: toAllocation(row), replayed: true }; } if (parsed.registeredAt < Number(row.reserved_at)) throw new Error('Deposit address registration predates its durable reservation'); const registered = await client.query(`UPDATE wk_custody.monero_deposit_allocations SET status='registered',monero_address=$3,registered_at=$4 WHERE allocation_id=$1 AND wallet_generation_id=$2 RETURNING *`, [parsed.allocationId, parsed.walletGenerationId, address, parsed.registeredAt]); await finishTransaction(client, true); return { allocation: toAllocation(registered.rows[0]), replayed: false }; } catch (error) { await finishTransaction(client, false); if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { throw new Error('Monero deposit address is already registered to an allocation'); } throw error; } finally { client.release(); } }