Implemented PostgreSQL v3 allocation-registry groundwork

This commit is contained in:
2026-10-21 14:59:15 -04:00
parent ee5dc35330
commit e70199ecb0
6 changed files with 360 additions and 9 deletions
+196
View File
@@ -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();
}
}
+56 -2
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 2;
export const CUSTODY_PG_VERSION = 3;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA_V1 = `
CREATE SCHEMA IF NOT EXISTS wk_custody;
@@ -127,6 +127,54 @@ CREATE TRIGGER monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_
`;
export const CUSTODY_SCHEMA_V2_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}`).digest('hex');
export const CUSTODY_MIGRATION_V3 = `
CREATE TABLE wk_custody.monero_deposit_allocations(
allocation_id uuid PRIMARY KEY,
request_id uuid NOT NULL UNIQUE,
custodian_id uuid NOT NULL,
market_network text NOT NULL CHECK(market_network IN ('wikideck-randomx-mainnet-1','wikideck-randomx-testnet-6')),
market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'),
beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'),
wallet_generation_id uuid NOT NULL,
wallet_fingerprint text NOT NULL CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'),
monero_network text NOT NULL CHECK(monero_network='mainnet'),
account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295),
address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295),
status text NOT NULL CHECK(status IN ('reserved','registered')),
monero_address text UNIQUE CHECK(monero_address IS NULL OR length(monero_address)=95),
request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'),
reserved_at bigint NOT NULL CHECK(reserved_at>=0),
registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at),
CHECK((status='reserved' AND monero_address IS NULL AND registered_at IS NULL) OR
(status='registered' AND monero_address IS NOT NULL AND registered_at IS NOT NULL)),
UNIQUE(wallet_generation_id,account_index,address_index),
UNIQUE(custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id)
);
CREATE INDEX monero_deposit_allocations_beneficiary
ON wk_custody.monero_deposit_allocations(custodian_id,market_network,market_chain_id,beneficiary);
CREATE FUNCTION wk_custody.monero_deposit_allocations_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero deposit allocation history cannot be deleted'; END IF;
IF OLD.status<>'reserved' OR NEW.status<>'registered' OR OLD.monero_address IS NOT NULL OR NEW.monero_address IS NULL OR
NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR NEW.request_id IS DISTINCT FROM OLD.request_id OR
NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR
NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR
NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR
NEW.monero_network IS DISTINCT FROM OLD.monero_network OR NEW.account_index IS DISTINCT FROM OLD.account_index OR
NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.request_digest IS DISTINCT FROM OLD.request_digest OR
NEW.reserved_at IS DISTINCT FROM OLD.reserved_at OR NEW.registered_at IS NULL OR NEW.registered_at<OLD.reserved_at THEN
RAISE EXCEPTION 'Monero deposit allocation identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER monero_deposit_allocations_immutable BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_allocations
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_allocations_immutable();
REVOKE ALL ON wk_custody.monero_deposit_allocations FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocations_immutable() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V3_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}`).digest('hex');
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V2_DIGEST;
@@ -147,6 +195,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 1) {
await c.query(CUSTODY_MIGRATION_V2);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]);
current = 2;
}
if (current === 2) {
await c.query(CUSTODY_MIGRATION_V3);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(3,$1)', [CUSTODY_SCHEMA_V3_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -160,7 +213,7 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
}
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST];
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST];
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
@@ -189,6 +242,7 @@ export async function grantCustodyRuntime(pool: Pool, role: string): Promise<voi
GRANT INSERT,UPDATE ON wk_custody.listings,wk_custody.orders,wk_custody.withdrawals,wk_custody.outbox TO ${name};`);
await c.query(`REVOKE ALL PRIVILEGES ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
wk_custody.monero_observation_events,wk_custody.monero_observation_scans FROM ${name};
REVOKE ALL PRIVILEGES ON wk_custody.monero_deposit_allocations FROM ${name};
REVOKE ALL PRIVILEGES ON SEQUENCE wk_custody.monero_observation_events_sequence_seq FROM ${name};`);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
+9 -1
View File
@@ -4,7 +4,7 @@ import { parseXmr } from '../../shared/xmr-amount.ts';
const alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const decodedSizes:Record<number,number>={2:1,3:2,5:3,6:4,7:5,9:6,10:7,11:8};
/** Support only mainnet standard/subaddresses. Integrated/payment-ID addresses need a separate policy. */
export async function validateXmrAddress(address:string):Promise<string>{
async function validateMainnetAddressPrefix(address:string):Promise<number>{
if(typeof address!=='string'||address.length!==95)throw new Error('Expected a mainnet standard XMR address or subaddress');
const bytes:number[]=[];
for(let start=0;start<address.length;start+=11){
@@ -19,6 +19,14 @@ export async function validateXmrAddress(address:string):Promise<string>{
if(bytes.length!==69||![18,42].includes(bytes[0]))throw new Error('XMR address network/type is not supported');
const decoded=Uint8Array.from(bytes),expected=(await keccak(decoded.slice(0,-4),256)).slice(0,8);
if(Buffer.from(decoded.slice(-4)).toString('hex')!==expected)throw new Error('XMR checksum mismatch');
return bytes[0];
}
export async function validateXmrAddress(address:string):Promise<string>{
await validateMainnetAddressPrefix(address);
return address;
}
export async function validateXmrSubaddress(address:string):Promise<string>{
if(await validateMainnetAddressPrefix(address)!==42)throw new Error('Expected a Monero mainnet subaddress');
return address;
}
export function atomicXmr(value:string):bigint{