operator/library-only Wallet-RPC address creation and recovery
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user