Implemented the next deposit-observation step without activating live deposits

This commit is contained in:
2026-10-21 14:09:55 -04:00
parent 79abaa78f2
commit ee5dc35330
8 changed files with 395 additions and 24 deletions
+116 -9
View File
@@ -1,9 +1,9 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 1;
export const CUSTODY_PG_VERSION = 2;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA = `
export const CUSTODY_SCHEMA_V1 = `
CREATE SCHEMA IF NOT EXISTS wk_custody;
REVOKE ALL ON SCHEMA wk_custody FROM PUBLIC;
SET LOCAL search_path = wk_custody, pg_catalog;
@@ -64,7 +64,72 @@ REVOKE ALL ON ALL TABLES IN SCHEMA wk_custody FROM PUBLIC;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA wk_custody FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA wk_custody FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_DIGEST = createHash('sha256').update(CUSTODY_SCHEMA).digest('hex');
export const CUSTODY_SCHEMA_V1_DIGEST = createHash('sha256').update(CUSTODY_SCHEMA_V1).digest('hex');
export const CUSTODY_MIGRATION_V2 = `
CREATE TABLE wk_custody.monero_observer_state(
wallet_fingerprint text PRIMARY KEY 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.monero_transfer_observations(
wallet_fingerprint text NOT NULL REFERENCES wk_custody.monero_observer_state(wallet_fingerprint),
observation_id text NOT NULL,
txid text NOT NULL CHECK(txid ~ '^[a-f0-9]{64}$'),
monero_address text NOT NULL CHECK(length(monero_address)=95),
account_index bigint NOT NULL CHECK(account_index>=0),
address_index bigint NOT NULL CHECK(address_index>=0),
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})$'),
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,
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_fingerprint,observation_id),
UNIQUE(wallet_fingerprint,txid,account_index,address_index)
);
CREATE INDEX monero_observations_height ON wk_custody.monero_transfer_observations(wallet_fingerprint,block_height,observation_id);
CREATE TABLE wk_custody.monero_observation_events(
sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
wallet_fingerprint text NOT NULL,
observation_id text NOT NULL,
scan_id uuid NOT NULL,
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_fingerprint,observation_id,scan_id,event_type),
FOREIGN KEY(wallet_fingerprint,observation_id)
REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id)
);
CREATE TABLE wk_custody.monero_observation_scans(
scan_id uuid PRIMARY KEY,
wallet_fingerprint text NOT NULL REFERENCES wk_custody.monero_observer_state(wallet_fingerprint),
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 monero_scans_wallet_height ON wk_custody.monero_observation_scans(wallet_fingerprint,through_height DESC);
CREATE FUNCTION wk_custody.monero_observation_events_immutable() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Monero observation event history is append-only'; END $$;
CREATE TRIGGER monero_observation_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_observation_events
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_observation_events_immutable();
CREATE TRIGGER monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_observation_scans
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_observation_events_immutable();
`;
export const CUSTODY_SCHEMA_V2_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}`).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_V2_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -73,20 +138,34 @@ export async function migrateCustody(pool: Pool): Promise<void> {
await c.query('BEGIN');
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const exists = await c.query("SELECT to_regclass('wk_custody.schema_versions') AS name");
if (exists.rows[0].name) await verifyCustodySchema(c);
else {
await c.query(CUSTODY_SCHEMA);
await c.query('INSERT INTO wk_custody.schema_versions VALUES($1,$2)', [CUSTODY_PG_VERSION, CUSTODY_SCHEMA_DIGEST]);
let current = 0;
if (!exists.rows[0].name) {
await c.query(CUSTODY_SCHEMA_V1);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]);
current = 1;
} else current = await verifyCustodySchemaHistory(c);
if (current === 1) {
await c.query(CUSTODY_MIGRATION_V2);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
}
export async function verifyCustodySchema(c: PoolClient): Promise<void> {
const version = await verifyCustodySchemaHistory(c);
if (version !== CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema migration required');
}
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
if (rows.rowCount !== 1 || rows.rows[0].version !== CUSTODY_PG_VERSION || rows.rows[0].digest !== CUSTODY_SCHEMA_DIGEST) {
throw new Error('Custody PostgreSQL schema version/digest mismatch; explicit migration required');
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_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');
}
return Number(rows.rows.at(-1).version);
}
/** Role must already exist (operator creates credentials out-of-band); never accept SQL identifiers from HTTP. */
@@ -108,7 +187,35 @@ export async function grantCustodyRuntime(pool: Pool, role: string): Promise<voi
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
GRANT INSERT ON wk_custody.entries,wk_custody.receipts,wk_custody.credits,wk_custody.custody_meta TO ${name};
GRANT INSERT,UPDATE ON wk_custody.listings,wk_custody.orders,wk_custody.withdrawals,wk_custody.outbox TO ${name};`);
await c.query(`REVOKE ALL PRIVILEGES ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
wk_custody.monero_observation_events,wk_custody.monero_observation_scans FROM ${name};
REVOKE ALL PRIVILEGES ON SEQUENCE wk_custody.monero_observation_events_sequence_seq FROM ${name};`);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; }
finally { c.release(); }
}
/** Separate least-privilege role for private wallet observations; it cannot read balances/journals or post credits. */
export async function grantMoneroObserverRuntime(pool: Pool, role: string, custodyRuntimeRole = 'custody_runtime'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid observer role identifier');
if (role === custodyRuntimeRole) throw new Error('Monero observer requires a separate role from custody runtime');
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('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('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('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,INSERT,UPDATE ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations TO ${name};
GRANT SELECT,INSERT ON wk_custody.monero_observation_events,wk_custody.monero_observation_scans TO ${name};
GRANT USAGE,SELECT ON SEQUENCE wk_custody.monero_observation_events_sequence_seq TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}