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
+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',