Added pilot-bound Monero observation persistence and the one-shot monero:pilot-observe command

This commit is contained in:
2026-10-22 20:04:05 -04:00
parent 36963fed30
commit 176d1927a8
12 changed files with 640 additions and 12 deletions
+152 -2
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 13;
export const CUSTODY_PG_VERSION = 14;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -535,6 +535,114 @@ REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM
`;
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');
export const CUSTODY_MIGRATION_V14 = `
CREATE TABLE wk_custody.pilot_monero_observer_state(
wallet_generation_id uuid PRIMARY KEY,
wallet_fingerprint text NOT NULL CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'),
network text NOT NULL CHECK(network='mainnet'),
scan_height bigint NOT NULL CHECK(scan_height>=0),
updated_at bigint NOT NULL CHECK(updated_at>=0)
);
CREATE TABLE wk_custody.pilot_monero_observation_scans(
scan_id uuid PRIMARY KEY,
wallet_generation_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observer_state(wallet_generation_id),
from_height bigint NOT NULL CHECK(from_height>=0),
through_height bigint NOT NULL CHECK(through_height>=from_height),
payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'),
completed_at bigint NOT NULL CHECK(completed_at>=0)
);
CREATE INDEX pilot_monero_scans_wallet_height
ON wk_custody.pilot_monero_observation_scans(wallet_generation_id,through_height DESC);
CREATE TABLE wk_custody.pilot_monero_observations(
wallet_generation_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observer_state(wallet_generation_id),
observation_id text NOT NULL,
allocation_id uuid REFERENCES wk_custody.pilot_monero_deposit_reservations(allocation_id),
classification text NOT NULL CHECK(classification IN ('assigned','unallocated')),
txid text NOT NULL CHECK(txid ~ '^[a-f0-9]{64}$'),
monero_address text NOT NULL CHECK(length(monero_address)=95),
amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615),
block_height bigint NOT NULL CHECK(block_height>0),
wallet_height bigint NOT NULL CHECK(wallet_height>=block_height),
confirmations bigint NOT NULL CHECK(confirmations=wallet_height-block_height+1),
timestamp bigint NOT NULL CHECK(timestamp>=0),
unlock_time text NOT NULL CHECK(unlock_time ~ '^(0|[1-9][0-9]{0,19})$'),
account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295),
address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295),
output_public_key text NOT NULL CHECK(output_public_key ~ '^[a-f0-9]{64}$'),
global_output_index bigint NOT NULL CHECK(global_output_index>=0),
locked boolean NOT NULL,
double_spend_seen boolean NOT NULL,
payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'),
currently_present boolean NOT NULL DEFAULT true,
last_seen_scan_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observation_scans(scan_id),
first_observed_at bigint NOT NULL CHECK(first_observed_at>=0),
last_observed_at bigint NOT NULL CHECK(last_observed_at>=first_observed_at),
PRIMARY KEY(wallet_generation_id,observation_id),
UNIQUE(wallet_generation_id,output_public_key),
UNIQUE(wallet_generation_id,txid,account_index,address_index,output_public_key),
CHECK((classification='assigned' AND allocation_id IS NOT NULL) OR
(classification='unallocated' AND allocation_id IS NULL)),
CHECK(observation_id=(txid || ':' || output_public_key))
);
CREATE FUNCTION wk_custody.pilot_monero_observation_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
DECLARE primary_fingerprint text;
BEGIN
SELECT wallet_fingerprint INTO STRICT primary_fingerprint FROM wk_custody.pilot_monero_observer_state
WHERE wallet_generation_id=NEW.wallet_generation_id;
IF NEW.classification='assigned' THEN
IF NEW.allocation_id IS NULL OR NOT EXISTS(
SELECT 1 FROM wk_custody.pilot_monero_deposit_reservations r
WHERE r.allocation_id=NEW.allocation_id AND r.wallet_generation_id=NEW.wallet_generation_id AND
r.wallet_fingerprint=primary_fingerprint AND r.status='registered' AND r.monero_address=NEW.monero_address AND
r.account_index=NEW.account_index AND r.address_index=NEW.address_index AND r.monero_network='mainnet') THEN
RAISE EXCEPTION 'Pilot observation does not match a registered wallet reservation';
END IF;
ELSIF NEW.allocation_id IS NOT NULL THEN
RAISE EXCEPTION 'Unallocated pilot observation cannot name a reservation';
END IF;
IF TG_OP='UPDATE' AND (
NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR
NEW.observation_id IS DISTINCT FROM OLD.observation_id OR NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR
NEW.classification IS DISTINCT FROM OLD.classification OR NEW.txid IS DISTINCT FROM OLD.txid OR
NEW.monero_address IS DISTINCT FROM OLD.monero_address OR NEW.account_index IS DISTINCT FROM OLD.account_index OR
NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.output_public_key IS DISTINCT FROM OLD.output_public_key OR
NEW.global_output_index IS DISTINCT FROM OLD.global_output_index OR NEW.first_observed_at IS DISTINCT FROM OLD.first_observed_at) THEN
RAISE EXCEPTION 'Pilot observation identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER pilot_monero_observations_guard BEFORE INSERT OR UPDATE ON wk_custody.pilot_monero_observations
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_guard();
CREATE INDEX pilot_monero_observations_height
ON wk_custody.pilot_monero_observations(wallet_generation_id,block_height,observation_id);
CREATE TABLE wk_custody.pilot_monero_observation_events(
sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
wallet_generation_id uuid NOT NULL,
observation_id text NOT NULL,
scan_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observation_scans(scan_id),
event_type text NOT NULL CHECK(event_type IN ('seen','changed','reappeared','not-seen')),
payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'),
wallet_height bigint NOT NULL CHECK(wallet_height>=0),
observed_at bigint NOT NULL CHECK(observed_at>=0),
UNIQUE(wallet_generation_id,observation_id,scan_id,event_type),
FOREIGN KEY(wallet_generation_id,observation_id)
REFERENCES wk_custody.pilot_monero_observations(wallet_generation_id,observation_id)
);
CREATE FUNCTION wk_custody.pilot_monero_observation_history_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Pilot Monero observation history is append-only'; END $$;
CREATE TRIGGER pilot_monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_observation_scans
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_history_immutable();
CREATE TRIGGER pilot_monero_observation_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_observation_events
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_history_immutable();
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_observation_guard() FROM PUBLIC;
REVOKE ALL ON wk_custody.pilot_monero_observer_state,wk_custody.pilot_monero_observation_scans,
wk_custody.pilot_monero_observations,wk_custody.pilot_monero_observation_events FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_observation_history_immutable() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V14_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}\n-- custody migration v14 --\n${CUSTODY_MIGRATION_V14}`).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;
@@ -610,6 +718,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
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]);
current = 13;
}
if (current === 13) {
await c.query(CUSTODY_MIGRATION_V14);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(14,$1)', [CUSTODY_SCHEMA_V14_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -626,7 +739,7 @@ async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
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_V12_DIGEST, CUSTODY_SCHEMA_V13_DIGEST];
CUSTODY_SCHEMA_V12_DIGEST, CUSTODY_SCHEMA_V13_DIGEST, CUSTODY_SCHEMA_V14_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');
@@ -814,6 +927,43 @@ export async function grantPilotDepositAddressRuntime(pool: Pool, role: string,
finally { c.release(); }
}
/** Pilot observation-only role. It cannot allocate addresses, access mainnet custody, or post financial entries. */
export async function grantPilotMoneroObserverRuntime(pool: Pool, role: string,
addressRole = 'pilot_deposit_address', reservationRole = 'pilot_deposit_reservation',
custodyRuntimeRole = 'custody_runtime', creditRole = 'monero_deposit_credit'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot Monero observer role identifier');
if ([addressRole, reservationRole, custodyRuntimeRole, creditRole].includes(role)) {
throw new Error('Pilot observer role must be separate from address, reservation, custody, and credit 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 observer 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 observer 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 observer 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,
wk_custody.pilot_monero_deposit_reservations TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_observer_state TO ${name};
GRANT UPDATE(scan_height,updated_at) ON wk_custody.pilot_monero_observer_state TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_observation_scans TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_observation_events TO ${name};
GRANT USAGE,SELECT ON SEQUENCE wk_custody.pilot_monero_observation_events_sequence_seq TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_observations TO ${name};
GRANT UPDATE(amount_atomic_xmr,block_height,wallet_height,confirmations,timestamp,unlock_time,locked,
double_spend_seen,payload_digest,currently_present,last_seen_scan_id,last_observed_at)
ON wk_custody.pilot_monero_observations 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',