operator/library-only Wallet-RPC address creation and recovery

This commit is contained in:
2026-10-22 18:55:05 -04:00
parent 2ecb42c019
commit 83d3d104c1
8 changed files with 386 additions and 16 deletions
+9 -1
View File
@@ -5,6 +5,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
'migrate-pilot-schema': 0,
'initialize-pilot-custody-identity': 1,
'grant-pilot-deposit-reservation-runtime': 1,
'grant-pilot-deposit-address-runtime': 1,
'grant-runtime': 1,
'grant-monero-observer-runtime': 1,
'grant-monero-deposit-allocator-runtime': 1,
@@ -54,7 +55,8 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
}
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-pilot-deposit-reservation-runtime'];
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity',
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-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'
@@ -68,6 +70,12 @@ export function assertPilotReservationGrantAcknowledged(action: CustodyDbAction,
}
}
export function assertPilotAddressGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
if (action === 'grant-pilot-deposit-address-runtime' && acknowledgement !== 'operator-triggered-addresses-only') {
throw new Error('Pilot address role grant requires explicit operator-triggered/address-only 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');
@@ -1,6 +1,8 @@
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';
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
import { CHAIN_ID } from '../testnet/consensus.ts';
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
@@ -42,6 +44,13 @@ export type PilotDepositAllocationIntent = {
accountIndex: number; requestDigest: string;
};
type ReservationAddressRow = ReservationRow & {
status: 'reserved' | 'creating' | 'registered';
create_started_at: string | null;
monero_address: string | null;
registered_at: string | null;
};
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,
@@ -166,3 +175,119 @@ export async function reservePilotMoneroDepositRequest(pool: Pool,
const intent = validatePilotMoneroDepositRequest({ ...context, identity }, rawRequest);
return reservePilotMoneroDepositIndex(pool, identity, intent, reservedAt);
}
async function registerPilotAddress(pool: Pool, reservation: PilotDepositReservation, moneroAddress: string,
registeredAt: number): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
const checkedAddress = await validateXmrSubaddress(moneroAddress);
if (!Number.isSafeInteger(registeredAt) || registeredAt < reservation.reservedAt) {
throw new Error('Pilot address registration predates its durable index reservation');
}
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 synchronous_commit=on");
const result = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reservation.allocationId, reservation.walletGenerationId]);
if (!result.rowCount) throw new Error('Pilot deposit reservation was not found for this wallet generation');
const row = result.rows[0];
if (row.status === 'registered') {
if (row.monero_address !== checkedAddress) throw new Error('Registered pilot deposit address cannot be replaced');
await client.query('COMMIT');
return { reservation: toReservation(row), address: checkedAddress, replayed: true };
}
if (row.status !== 'creating' || row.create_started_at === null || row.monero_address !== null ||
row.registered_at !== null || registeredAt < Number(row.create_started_at)) {
throw new Error('Pilot deposit reservation registration state is inconsistent');
}
const updated = await client.query<ReservationAddressRow>(`UPDATE wk_custody.pilot_monero_deposit_reservations
SET status='registered',monero_address=$2,registered_at=$3
WHERE allocation_id=$1 AND status='creating' AND monero_address IS NULL RETURNING *`, [reservation.allocationId, checkedAddress, registeredAt]);
if (!updated.rowCount) {
const already = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND status='registered'`, [reservation.allocationId]);
if (!already.rowCount || already.rows[0].monero_address !== checkedAddress) {
throw new Error('Pilot deposit reservation changed during address registration');
}
await client.query('COMMIT');
return { reservation: toReservation(already.rows[0]), address: checkedAddress, replayed: true };
}
await client.query('COMMIT');
return { reservation: toReservation(updated.rows[0]), address: checkedAddress, replayed: false };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/**
* Operator/library-only address creation for a durable reservation. A lost create_address
* response is recovered by the stable label at the exact reserved index on the next call.
* No HTTP route invokes this function and the result is not a credit or payment authorization.
*/
export async function createOrRecoverPilotMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
context: Omit<PilotDepositRequestContext, 'identity'>, rawRequest: unknown,
now = Date.now()): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
const reserved = await reservePilotMoneroDepositRequest(pool, context, rawRequest, now);
const initial = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reserved.reservation.allocationId, reserved.reservation.walletGenerationId]);
if (!initial.rowCount) throw new Error('Pilot deposit reservation was not found for its wallet generation');
if (initial.rows[0].status === 'registered') {
const address = await validateXmrSubaddress(initial.rows[0].monero_address ?? '');
return { reservation: toReservation(initial.rows[0]), address, replayed: true };
}
const row = initial.rows[0];
const lockKey = `wikideck-pilot-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
const lockClient = await pool.connect();
let locked = false;
try {
await lockClient.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
locked = true;
// Another worker may have completed while this call waited for the per-wallet fence.
const current = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [row.allocation_id, row.wallet_generation_id]);
if (!current.rowCount) throw new Error('Pilot deposit reservation disappeared');
if (current.rows[0].status === 'registered') {
const address = await validateXmrSubaddress(current.rows[0].monero_address ?? '');
return { reservation: toReservation(current.rows[0]), address, replayed: true };
}
const allocation = current.rows[0];
if (await rpc.verifyWallet() !== allocation.wallet_fingerprint) {
throw new Error('Configured Monero wallet identity differs from the durable pilot reservation');
}
const expectedIndex = Number(allocation.address_index);
const expectedLabel = `wikideck-pilot-deposit-v1:${allocation.allocation_id}`;
const createWasAlreadyStarted = allocation.status === 'creating';
let existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (!createWasAlreadyStarted) {
// Persist the side-effect intent first. If the process or transport fails afterwards,
// retry is reconciliation-only: it may recover an existing labeled index but never create twice.
const createStartedAt = Math.max(now, Number(allocation.reserved_at));
const started = await pool.query(`UPDATE wk_custody.pilot_monero_deposit_reservations
SET status='creating',create_started_at=$2 WHERE allocation_id=$1 AND status='reserved'
RETURNING allocation_id`, [allocation.allocation_id, createStartedAt]);
if (started.rowCount !== 1) throw new Error('Pilot address create intent could not be committed before Wallet-RPC');
}
if (!existing) {
if (createWasAlreadyStarted) {
throw new Error('Pilot Wallet-RPC create outcome is uncertain; reconcile the reserved index and wallet before retrying');
}
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
if (created.addressIndex !== expectedIndex) {
throw new Error('Monero wallet created an unexpected pilot subaddress index; reservation requires operator reconciliation');
}
// Never trust the create response alone; query the exact reserved index and stable label.
existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (!existing || existing.address !== created.address) {
throw new Error('Monero wallet subaddress could not be recovered at the reserved pilot index');
}
}
if (existing.label !== expectedLabel) {
throw new Error('Reserved pilot Monero subaddress index is occupied by a different wallet label');
}
const address = await validateXmrSubaddress(existing.address);
const reservation = toReservation(allocation);
return await registerPilotAddress(pool, reservation, address, now);
} finally {
if (locked) await lockClient.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
lockClient.release();
}
}
+123 -2
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 11;
export const CUSTODY_PG_VERSION = 13;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -455,6 +455,86 @@ REVOKE ALL ON FUNCTION wk_custody.pilot_reservation_database_isolated() FROM PUB
`;
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');
export const CUSTODY_MIGRATION_V12 = `
DROP TRIGGER pilot_monero_deposit_reservations_immutable ON wk_custody.pilot_monero_deposit_reservations;
DROP FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable();
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
ADD COLUMN status text NOT NULL DEFAULT 'reserved' CHECK(status IN ('reserved','registered')),
ADD COLUMN monero_address text CHECK(monero_address IS NULL OR length(monero_address)=95),
ADD COLUMN registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at),
ADD CONSTRAINT pilot_deposit_reservation_registration_state 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));
CREATE UNIQUE INDEX pilot_monero_deposit_address_unique
ON wk_custody.pilot_monero_deposit_reservations(monero_address) WHERE monero_address IS NOT NULL;
CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF;
IF OLD.status<>'reserved' OR NEW.status<>'registered' OR NEW.monero_address IS NULL OR NEW.registered_at IS NULL OR
NEW.registered_at<OLD.reserved_at 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.monero_network IS DISTINCT FROM OLD.monero_network 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.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.allocation_digest IS DISTINCT FROM OLD.allocation_digest OR
NEW.reserved_at IS DISTINCT FROM OLD.reserved_at THEN
RAISE EXCEPTION 'Pilot Monero reservation permits only one-time address registration';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER pilot_monero_deposit_registration_guard
BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_deposit_reservations
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_deposit_registration_guard();
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V12_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}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}`).digest('hex');
export const CUSTODY_MIGRATION_V13 = `
DROP TRIGGER pilot_monero_deposit_registration_guard ON wk_custody.pilot_monero_deposit_reservations;
DROP FUNCTION wk_custody.pilot_monero_deposit_registration_guard();
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
ADD COLUMN create_started_at bigint CHECK(create_started_at IS NULL OR create_started_at>=reserved_at);
UPDATE wk_custody.pilot_monero_deposit_reservations
SET create_started_at=registered_at WHERE status='registered';
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
DROP CONSTRAINT pilot_monero_deposit_reservations_status_check,
ADD CONSTRAINT pilot_monero_deposit_reservations_status_check CHECK(status IN ('reserved','creating','registered')),
DROP CONSTRAINT pilot_deposit_reservation_registration_state,
ADD CONSTRAINT pilot_deposit_reservation_registration_state CHECK(
(status='reserved' AND create_started_at IS NULL AND monero_address IS NULL AND registered_at IS NULL) OR
(status='creating' AND create_started_at IS NOT NULL AND monero_address IS NULL AND registered_at IS NULL) OR
(status='registered' AND create_started_at IS NOT NULL AND monero_address IS NOT NULL AND
registered_at IS NOT NULL AND registered_at>=create_started_at));
CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF;
IF 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.monero_network IS DISTINCT FROM OLD.monero_network 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.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.allocation_digest IS DISTINCT FROM OLD.allocation_digest OR NEW.reserved_at IS DISTINCT FROM OLD.reserved_at THEN
RAISE EXCEPTION 'Pilot Monero reservation terms are immutable';
END IF;
IF OLD.status='reserved' AND NEW.status='creating' AND OLD.create_started_at IS NULL AND
NEW.create_started_at IS NOT NULL AND NEW.monero_address IS NULL AND NEW.registered_at IS NULL THEN
RETURN NEW;
END IF;
IF OLD.status='creating' AND NEW.status='registered' AND OLD.create_started_at IS NOT NULL AND
NEW.create_started_at IS NOT DISTINCT FROM OLD.create_started_at AND NEW.monero_address IS NOT NULL AND
OLD.monero_address IS NULL AND NEW.registered_at IS NOT NULL AND NEW.registered_at>=OLD.create_started_at THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Pilot Monero reservation permits only one-time create intent and address registration';
END $$;
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V13_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}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}\n-- custody migration v13 --\n${CUSTODY_MIGRATION_V13}`).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;
@@ -520,6 +600,16 @@ export async function migrateCustody(pool: Pool): Promise<void> {
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]);
current = 11;
}
if (current === 11) {
await c.query(CUSTODY_MIGRATION_V12);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(12,$1)', [CUSTODY_SCHEMA_V12_DIGEST]);
current = 12;
}
if (current === 12) {
await c.query(CUSTODY_MIGRATION_V13);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(13,$1)', [CUSTODY_SCHEMA_V13_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -535,7 +625,8 @@ 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_V11_DIGEST];
CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST, CUSTODY_SCHEMA_V11_DIGEST,
CUSTODY_SCHEMA_V12_DIGEST, CUSTODY_SCHEMA_V13_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');
@@ -693,6 +784,36 @@ export async function grantPilotDepositReservationRuntime(pool: Pool, role: stri
finally { c.release(); }
}
/** Separate pilot address allocator: only the reserved-to-registered column transition is writable. */
export async function grantPilotDepositAddressRuntime(pool: Pool, role: string,
reservationRole = 'pilot_deposit_reservation', custodyRuntimeRole = 'custody_runtime',
observerRole = 'monero_observer', creditRole = 'monero_deposit_credit'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit address role identifier');
if ([reservationRole, custodyRuntimeRole, observerRole, creditRole].includes(role)) {
throw new Error('Pilot address role must be separate from reservation and 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 address 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 address 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 address 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,wk_custody.pilot_custody_identity TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_deposit_reservations TO ${name};
GRANT UPDATE(status,create_started_at,monero_address,registered_at) 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',