operator/library-only Wallet-RPC address creation and recovery
This commit is contained in:
@@ -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