Files
wikideck/server/market/monero-deposit-allocation-store.ts

272 lines
13 KiB
TypeScript
Executable File

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();
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<typeof reserveInputSchema>): 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<void> {
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<AllocationRow>(`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<AllocationRow>(`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<AllocationRow>(`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<AllocationRow>(`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<AllocationRow>(`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();
}
}
/**
* 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 liveWalletFingerprint = await rpc.verifyWallet();
if (liveWalletFingerprint !== allocation.wallet_fingerprint) {
throw new Error('Configured Monero wallet identity differs from the durable allocation generation');
}
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 });
}