Implemented PostgreSQL v3 allocation-registry groundwork
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user