Durable, beneficiary-bound subaddress index reservation

This commit is contained in:
2026-10-22 17:26:13 -04:00
parent 84e6e08209
commit 2ecb42c019
9 changed files with 416 additions and 42 deletions
+8 -1
View File
@@ -4,6 +4,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
'initialize-mainnet-custody-identity': 1,
'migrate-pilot-schema': 0,
'initialize-pilot-custody-identity': 1,
'grant-pilot-deposit-reservation-runtime': 1,
'grant-runtime': 1,
'grant-monero-observer-runtime': 1,
'grant-monero-deposit-allocator-runtime': 1,
@@ -53,7 +54,7 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
}
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity'];
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-pilot-deposit-reservation-runtime'];
if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) {
throw new Error(isPilot
? 'Pilot profile permits only pilot schema migration and identity-only initialization through this CLI'
@@ -61,6 +62,12 @@ export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot
}
}
export function assertPilotReservationGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
if (action === 'grant-pilot-deposit-reservation-runtime' && acknowledgement !== 'reservation-only-no-addresses') {
throw new Error('Pilot reservation role grant requires explicit reservation-only/no-addresses acknowledgement');
}
}
export function assertPilotSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
if (action === 'migrate-pilot-schema' && acknowledgement !== 'explicit-pilot-schema-migration-only') {
throw new Error('Pilot schema migration requires explicit pilot schema-migration acknowledgement');
+16 -21
View File
@@ -42,30 +42,25 @@ function identityFromRow(row: Record<string, unknown>): PilotCustodyIdentity {
return identity;
}
async function requireEmptyFinancialDatabase(client: PoolClient): Promise<void> {
const result = await client.query(`SELECT
EXISTS(SELECT 1 FROM wk_custody.custody_meta) OR
EXISTS(SELECT 1 FROM wk_custody.accounts) OR
EXISTS(SELECT 1 FROM wk_custody.journals) OR
EXISTS(SELECT 1 FROM wk_custody.entries) OR
EXISTS(SELECT 1 FROM wk_custody.listings) OR
EXISTS(SELECT 1 FROM wk_custody.orders) OR
EXISTS(SELECT 1 FROM wk_custody.withdrawals) OR
EXISTS(SELECT 1 FROM wk_custody.receipts) OR
EXISTS(SELECT 1 FROM wk_custody.outbox) OR
EXISTS(SELECT 1 FROM wk_custody.credits) OR
EXISTS(SELECT 1 FROM wk_custody.monero_observer_state) OR
EXISTS(SELECT 1 FROM wk_custody.monero_transfer_observations) OR
EXISTS(SELECT 1 FROM wk_custody.monero_observation_scans) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_holds) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions) OR
EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents) AS populated`);
async function requireEmptyFinancialDatabase(client: PoolClient, allowPilotIdentity = false): Promise<void> {
const result = await client.query(`SELECT NOT wk_custody.pilot_reservation_database_isolated() AS populated,
(SELECT count(*) FROM wk_custody.pilot_custody_identity) AS pilot_identities`);
if (result.rows[0].populated) {
throw new Error('Pilot identity requires a separate empty custody database; existing mock or financial data cannot be reused');
}
const identities = Number(result.rows[0].pilot_identities);
if ((!allowPilotIdentity && identities !== 0) || (allowPilotIdentity && identities !== 1)) {
throw new Error('Pilot custody database identity state is inconsistent');
}
if (!allowPilotIdentity) {
const reservations = await client.query('SELECT EXISTS(SELECT 1 FROM wk_custody.pilot_monero_deposit_reservations) AS populated');
if (reservations.rows[0].populated) throw new Error('Pilot identity requires an empty pilot reservation table');
}
}
/** Recheck the dedicated database boundary before each reservation; only its single identity row is allowed. */
export async function assertPilotReservationDatabaseEmpty(client: PoolClient): Promise<void> {
await requireEmptyFinancialDatabase(client, true);
}
/** One-time identity binding only. It does not enable deposit allocation, observation, credit, or withdrawal roles. */
+168
View File
@@ -0,0 +1,168 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
import { CHAIN_ID } from '../testnet/consensus.ts';
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
import { assertPilotReservationDatabaseEmpty, verifyPilotCustodyIdentity } from './pilot-custody-identity.ts';
import { validatePilotMoneroDepositRequest, type PilotDepositRequestContext } from './pilot-monero-deposit-request.ts';
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
const MAX_INDEX = 0xffff_ffff;
const uuidPattern = /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
const hexPattern = /^[a-f0-9]{64}$/;
export type PilotDepositReservation = {
allocationId: string;
requestId: string;
custodianId: string;
marketNetwork: 'wikideck-randomx-pilot-1';
marketChainId: string;
moneroNetwork: 'mainnet';
beneficiary: string;
walletGenerationId: string;
walletFingerprint: string;
accountIndex: number;
addressIndex: number;
requestDigest: string;
allocationDigest: string;
reservedAt: number;
};
type ReservationRow = {
allocation_id: string; request_id: string; custodian_id: string;
market_network: 'wikideck-randomx-pilot-1'; market_chain_id: string; monero_network: 'mainnet';
beneficiary: string; wallet_generation_id: string; wallet_fingerprint: string;
account_index: string; address_index: string; request_digest: string; allocation_digest: string; reserved_at: string;
};
export type PilotDepositAllocationIntent = {
requestId: string; custodianId: string; marketNetwork: 'wikideck-randomx-pilot-1'; marketChainId: string;
moneroNetwork: 'mainnet'; beneficiary: string; walletGenerationId: string; walletFingerprint: string;
accountIndex: number; requestDigest: string;
};
function toReservation(row: ReservationRow): PilotDepositReservation {
return { allocationId: row.allocation_id, requestId: row.request_id, custodianId: row.custodian_id,
marketNetwork: row.market_network, marketChainId: row.market_chain_id, moneroNetwork: row.monero_network,
beneficiary: row.beneficiary, walletGenerationId: row.wallet_generation_id, walletFingerprint: row.wallet_fingerprint,
accountIndex: Number(row.account_index), addressIndex: Number(row.address_index), requestDigest: row.request_digest,
allocationDigest: row.allocation_digest, reservedAt: Number(row.reserved_at) };
}
function validateIdentity(identity: PilotCustodyIdentity, intent: PilotDepositAllocationIntent, reservedAt: number): void {
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || identity.version !== 1 || identity.mode !== 'pilot-custody' ||
identity.policy !== 'pilot-custody-v1' || identity.marketNetwork !== NETWORK || identity.marketChainId !== CHAIN_ID ||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') {
throw new Error('Pilot deposit reservation requires the configured identity-only pilot custody profile');
}
if (!Number.isSafeInteger(reservedAt) || reservedAt < 0 || !uuidPattern.test(intent.requestId) ||
!uuidPattern.test(intent.custodianId) || !uuidPattern.test(intent.walletGenerationId) ||
intent.custodianId.toLowerCase() !== identity.custodianId.toLowerCase() ||
intent.marketNetwork !== identity.marketNetwork || intent.marketChainId !== identity.marketChainId ||
intent.moneroNetwork !== identity.moneroNetwork || !hexPattern.test(intent.beneficiary) ||
!hexPattern.test(intent.walletFingerprint) || !hexPattern.test(intent.requestDigest) ||
!Number.isSafeInteger(intent.accountIndex) || intent.accountIndex < 0 || intent.accountIndex > MAX_INDEX) {
throw new Error('Invalid pilot deposit allocation intent');
}
}
function allocationDigest(intent: PilotDepositAllocationIntent): string {
return createHash('sha256').update(JSON.stringify([
'wikideck-pilot-monero-deposit-allocation-v1', intent.custodianId, intent.marketNetwork,
intent.marketChainId, intent.moneroNetwork, intent.beneficiary, intent.walletGenerationId,
intent.walletFingerprint, intent.accountIndex,
])).digest('hex');
}
async function assertIdentityRecord(client: PoolClient, identity: PilotCustodyIdentity): Promise<void> {
const result = await client.query(`SELECT version,mode,policy,market_network,market_chain_id,monero_network,
custodian_id,activation_status FROM wk_custody.pilot_custody_identity WHERE singleton=1`);
if (!result.rowCount) throw new Error('Pilot custody identity is not initialized');
const row = result.rows[0];
if (Number(row.version) !== identity.version || row.mode !== identity.mode || row.policy !== identity.policy ||
row.market_network !== identity.marketNetwork || row.market_chain_id !== identity.marketChainId ||
row.monero_network !== identity.moneroNetwork || row.custodian_id !== identity.custodianId ||
row.activation_status !== identity.activationStatus) {
throw new Error('Pilot custody identity changed or does not match the database binding');
}
}
/**
* Persist a never-reused Monero address index for a signed, validated pilot request.
* This does not call Wallet-RPC, create/register an address, or permit crediting funds.
*/
export async function reservePilotMoneroDepositIndex(pool: Pool, identity: PilotCustodyIdentity,
intentInput: PilotDepositAllocationIntent, reservedAt = Date.now()): Promise<{ reservation: PilotDepositReservation; replayed: boolean }> {
const intent = { ...intentInput, custodianId: intentInput.custodianId.toLowerCase() };
validateIdentity(identity, intent, reservedAt);
const allocationHash = allocationDigest(intent);
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'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await verifyCustodySchema(client);
await assertPilotReservationDatabaseEmpty(client);
await assertIdentityRecord(client, identity);
// Serialize index allocation across app processes using the same watch-only wallet/account.
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [
`wikideck-pilot-monero-reservation-v1:${intent.walletGenerationId}:${intent.accountIndex}`,
]);
const byRequest = await client.query<ReservationRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE request_id=$1`, [intent.requestId]);
if (byRequest.rowCount) {
const row = byRequest.rows[0];
if (row.request_digest !== intent.requestDigest || row.allocation_digest !== allocationHash) {
throw new Error('Pilot deposit request ID was reused with different signed terms');
}
await client.query('COMMIT');
return { reservation: toReservation(row), replayed: true };
}
const byBeneficiary = await client.query<ReservationRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4
AND wallet_generation_id=$5`, [intent.custodianId, intent.marketNetwork, intent.marketChainId,
intent.beneficiary, intent.walletGenerationId]);
if (byBeneficiary.rowCount) {
const row = byBeneficiary.rows[0];
if (row.allocation_digest !== allocationHash) {
throw new Error('Beneficiary already has a different pilot reservation for this wallet generation');
}
await client.query('COMMIT');
return { reservation: toReservation(row), replayed: true };
}
const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index
FROM wk_custody.pilot_monero_deposit_reservations WHERE wallet_generation_id=$1 AND account_index=$2`,
[intent.walletGenerationId, intent.accountIndex]);
const index = BigInt(next.rows[0].next_index);
if (index > BigInt(MAX_INDEX)) throw new Error('Pilot Monero subaddress index space is exhausted for this account');
const inserted = await client.query<ReservationRow>(`INSERT INTO wk_custody.pilot_monero_deposit_reservations(
allocation_id,request_id,custodian_id,market_network,market_chain_id,monero_network,beneficiary,
wallet_generation_id,wallet_fingerprint,account_index,address_index,request_digest,allocation_digest,reserved_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [randomUUID(), intent.requestId,
intent.custodianId, intent.marketNetwork, intent.marketChainId, intent.moneroNetwork, intent.beneficiary,
intent.walletGenerationId, intent.walletFingerprint, intent.accountIndex, index.toString(), intent.requestDigest,
allocationHash, reservedAt]);
await client.query('COMMIT');
return { reservation: toReservation(inserted.rows[0]), replayed: false };
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
throw new Error('Pilot deposit reservation conflicts with an existing request, beneficiary, or wallet index');
}
throw error;
} finally { client.release(); }
}
/** Full signed-request path: verify durable identity, cryptographically validate, then reserve an index. */
export async function reservePilotMoneroDepositRequest(pool: Pool,
context: Omit<PilotDepositRequestContext, 'identity'>,
rawRequest: unknown, reservedAt = Date.now()) {
const custodianId = z.object({ custodianId: z.string().uuid() }).passthrough().parse(rawRequest).custodianId;
const identity = await verifyPilotCustodyIdentity(pool, custodianId);
const intent = validatePilotMoneroDepositRequest({ ...context, identity }, rawRequest);
return reservePilotMoneroDepositIndex(pool, identity, intent, reservedAt);
}
+98 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 10;
export const CUSTODY_PG_VERSION = 11;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -383,7 +383,8 @@ CREATE TABLE wk_custody.pilot_custody_identity(
monero_network text NOT NULL CHECK(monero_network='mainnet'),
custodian_id uuid NOT NULL UNIQUE,
activation_status text NOT NULL CHECK(activation_status='identity-only'),
initialized_at bigint NOT NULL CHECK(initialized_at>=0)
initialized_at bigint NOT NULL CHECK(initialized_at>=0),
UNIQUE(custodian_id,market_network,market_chain_id,monero_network)
);
CREATE FUNCTION wk_custody.pilot_custody_identity_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
@@ -395,6 +396,65 @@ REVOKE ALL ON FUNCTION wk_custody.pilot_custody_identity_immutable() FROM PUBLIC
`;
export const CUSTODY_SCHEMA_V10_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}`).digest('hex');
export const CUSTODY_MIGRATION_V11 = `
CREATE TABLE wk_custody.pilot_monero_deposit_reservations(
allocation_id uuid PRIMARY KEY,
request_id uuid NOT NULL UNIQUE,
custodian_id uuid NOT NULL,
market_network text NOT NULL CHECK(market_network='wikideck-randomx-pilot-1'),
market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'),
monero_network text NOT NULL CHECK(monero_network='mainnet'),
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}$'),
account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295),
address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295),
request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'),
allocation_digest text NOT NULL CHECK(allocation_digest ~ '^[a-f0-9]{64}$'),
reserved_at bigint NOT NULL CHECK(reserved_at>=0),
UNIQUE(wallet_generation_id,account_index,address_index),
UNIQUE(custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id),
FOREIGN KEY(custodian_id,market_network,market_chain_id,monero_network)
REFERENCES wk_custody.pilot_custody_identity(custodian_id,market_network,market_chain_id,monero_network)
);
CREATE INDEX pilot_deposit_reservations_wallet_index
ON wk_custody.pilot_monero_deposit_reservations(wallet_generation_id,account_index,address_index);
CREATE FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Pilot Monero deposit reservation history is immutable'; END $$;
CREATE TRIGGER pilot_monero_deposit_reservations_immutable
BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_deposit_reservations
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable();
REVOKE ALL ON wk_custody.pilot_monero_deposit_reservations FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable() FROM PUBLIC;
CREATE FUNCTION wk_custody.pilot_reservation_database_isolated() RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
SET search_path=wk_custody,pg_catalog AS $$
SELECT NOT (
EXISTS(SELECT 1 FROM wk_custody.custody_meta) OR
EXISTS(SELECT 1 FROM wk_custody.accounts) OR
EXISTS(SELECT 1 FROM wk_custody.journals) OR
EXISTS(SELECT 1 FROM wk_custody.entries) OR
EXISTS(SELECT 1 FROM wk_custody.listings) OR
EXISTS(SELECT 1 FROM wk_custody.orders) OR
EXISTS(SELECT 1 FROM wk_custody.withdrawals) OR
EXISTS(SELECT 1 FROM wk_custody.receipts) OR
EXISTS(SELECT 1 FROM wk_custody.outbox) OR
EXISTS(SELECT 1 FROM wk_custody.credits) OR
EXISTS(SELECT 1 FROM wk_custody.monero_observer_state) OR
EXISTS(SELECT 1 FROM wk_custody.monero_transfer_observations) OR
EXISTS(SELECT 1 FROM wk_custody.monero_observation_scans) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_holds) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events) OR
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions) OR
EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents)
)
$$;
REVOKE ALL ON FUNCTION wk_custody.pilot_reservation_database_isolated() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V11_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}\n-- custody migration v11 --\n${CUSTODY_MIGRATION_V11}`).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_V10_DIGEST;
@@ -455,6 +515,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 9) {
await c.query(CUSTODY_MIGRATION_V10);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(10,$1)', [CUSTODY_SCHEMA_V10_DIGEST]);
current = 10;
}
if (current === 10) {
await c.query(CUSTODY_MIGRATION_V11);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(11,$1)', [CUSTODY_SCHEMA_V11_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -470,7 +535,7 @@ 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, CUSTODY_SCHEMA_V3_DIGEST,
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST,
CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST];
CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST, CUSTODY_SCHEMA_V11_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');
@@ -598,6 +663,36 @@ export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: strin
finally { c.release(); }
}
/** Pilot reservation-only role. It can allocate durable indexes but cannot register addresses or access financial tables. */
export async function grantPilotDepositReservationRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
creditRole = 'monero_deposit_credit', mainnetAllocatorRole = 'monero_deposit_allocator'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit reservation role identifier');
if ([custodyRuntimeRole, observerRole, creditRole, mainnetAllocatorRole].includes(role)) {
throw new Error('Pilot deposit reservation role must be separate from other custody roles');
}
const name = `"${role}"`;
const c = await pool.connect();
try {
await c.query('BEGIN'); await verifyCustodySchema(c);
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Pilot reservation role must exist without administrative privileges');
const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]);
if (memberships.rowCount) throw new Error('Pilot reservation role must not inherit privileges through role membership');
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
if (owner.rows[0].owner === role) throw new Error('Pilot reservation role must not own the custody schema');
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name};
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
GRANT SELECT ON wk_custody.schema_versions TO ${name};
GRANT SELECT ON wk_custody.pilot_custody_identity TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_deposit_reservations TO ${name};
GRANT EXECUTE ON FUNCTION wk_custody.pilot_reservation_database_isolated() TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Mainnet customer withdrawal-intent role. It can reserve/cancel liabilities but has no signing or relay authority. */
export async function grantMoneroWithdrawalRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',