Separate identity-only pilot custody foundation
This commit is contained in:
@@ -2,6 +2,8 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
migrate: 0,
|
||||
'migrate-mainnet-schema': 0,
|
||||
'initialize-mainnet-custody-identity': 1,
|
||||
'migrate-pilot-schema': 0,
|
||||
'initialize-pilot-custody-identity': 1,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
@@ -50,6 +52,29 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
|
||||
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity'];
|
||||
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'
|
||||
: 'Pilot custody operation requires the pilot profile');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotCustodyIdentityAcknowledged(action: CustodyDbAction, identityAcknowledgement: string | undefined,
|
||||
operationAcknowledgement: string | undefined): void {
|
||||
if (action === 'initialize-pilot-custody-identity' &&
|
||||
(identityAcknowledgement !== 'initialize-pilot-custody-v1' || operationAcknowledgement !== 'identity-only-no-funds')) {
|
||||
throw new Error('Pilot identity initialization requires exact identity-only/no-funds acknowledgements');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
|
||||
const [action, ...values] = args;
|
||||
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
export type PilotCustodyIdentity = {
|
||||
version: 1;
|
||||
mode: 'pilot-custody';
|
||||
policy: 'pilot-custody-v1';
|
||||
marketNetwork: 'wikideck-randomx-pilot-1';
|
||||
marketChainId: string;
|
||||
moneroNetwork: 'mainnet';
|
||||
custodianId: string;
|
||||
activationStatus: 'identity-only';
|
||||
initializedAt: number;
|
||||
};
|
||||
|
||||
function validUuid(value: string): boolean {
|
||||
return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function requirePilot(custodianId: string, initializedAt: number): void {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || !validUuid(custodianId)) {
|
||||
throw new Error('Pilot custody identity requires the pilot profile and a valid custodian UUID');
|
||||
}
|
||||
if (!Number.isSafeInteger(initializedAt) || initializedAt < 0) throw new Error('Invalid pilot identity initialization time');
|
||||
}
|
||||
|
||||
function identityFromRow(row: Record<string, unknown>): PilotCustodyIdentity {
|
||||
const identity = {
|
||||
version: Number(row.version), mode: row.mode, policy: row.policy,
|
||||
marketNetwork: row.market_network, marketChainId: row.market_chain_id,
|
||||
moneroNetwork: row.monero_network, custodianId: row.custodian_id,
|
||||
activationStatus: row.activation_status, initializedAt: Number(row.initialized_at),
|
||||
} as PilotCustodyIdentity;
|
||||
if (identity.version !== 1 || identity.mode !== 'pilot-custody' || identity.policy !== 'pilot-custody-v1' ||
|
||||
identity.marketNetwork !== 'wikideck-randomx-pilot-1' || identity.marketChainId !== CHAIN_ID ||
|
||||
identity.moneroNetwork !== 'mainnet' || !validUuid(identity.custodianId) ||
|
||||
identity.activationStatus !== 'identity-only' || !Number.isSafeInteger(identity.initializedAt) || identity.initializedAt < 0) {
|
||||
throw new Error('Stored pilot custody identity is invalid or inconsistent');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function requireEmptyFinancialDatabase(client: PoolClient): Promise<void> {
|
||||
const result = await client.query(`SELECT
|
||||
EXISTS(SELECT 1 FROM wk_custody.custody_meta) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.accounts) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.journals) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.entries) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.listings) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.orders) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.withdrawals) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.receipts) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.outbox) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.credits) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_observer_state) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_transfer_observations) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_observation_scans) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_holds) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions) OR
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents) AS populated`);
|
||||
if (result.rows[0].populated) {
|
||||
throw new Error('Pilot identity requires a separate empty custody database; existing mock or financial data cannot be reused');
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time identity binding only. It does not enable deposit allocation, observation, credit, or withdrawal roles. */
|
||||
export async function initializePilotCustodyIdentity(pool: Pool, custodianId: string,
|
||||
initializedAt = Date.now()): Promise<PilotCustodyIdentity> {
|
||||
requirePilot(custodianId, initializedAt);
|
||||
const expected: PilotCustodyIdentity = { version: 1, mode: 'pilot-custody', policy: 'pilot-custody-v1',
|
||||
marketNetwork: 'wikideck-randomx-pilot-1', marketChainId: CHAIN_ID, moneroNetwork: 'mainnet',
|
||||
custodianId: custodianId.toLowerCase(), activationStatus: 'identity-only', initializedAt };
|
||||
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");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
const current = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1 FOR UPDATE');
|
||||
if (current.rowCount) {
|
||||
const identity = identityFromRow(current.rows[0]);
|
||||
if (identity.version !== expected.version || identity.mode !== expected.mode || identity.policy !== expected.policy ||
|
||||
identity.marketNetwork !== expected.marketNetwork || identity.marketChainId !== expected.marketChainId ||
|
||||
identity.moneroNetwork !== expected.moneroNetwork || identity.custodianId !== expected.custodianId) {
|
||||
throw new Error('Pilot custody database is already bound to a different market, Monero network, or custodian');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return identity;
|
||||
}
|
||||
await requireEmptyFinancialDatabase(client);
|
||||
await client.query(`INSERT INTO wk_custody.pilot_custody_identity
|
||||
(singleton,version,mode,policy,market_network,market_chain_id,monero_network,custodian_id,activation_status,initialized_at)
|
||||
VALUES(1,$1,$2,$3,$4,$5,$6,$7,$8,$9)`, [expected.version, expected.mode, expected.policy,
|
||||
expected.marketNetwork, expected.marketChainId, expected.moneroNetwork, expected.custodianId,
|
||||
expected.activationStatus, expected.initializedAt]);
|
||||
await client.query('COMMIT');
|
||||
return expected;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export async function verifyPilotCustodyIdentity(pool: Pool, custodianId: string): Promise<PilotCustodyIdentity> {
|
||||
requirePilot(custodianId, 0);
|
||||
const result = await pool.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (result.rowCount !== 1) throw new Error('Pilot custody identity is not initialized');
|
||||
const identity = identityFromRow(result.rows[0]);
|
||||
if (identity.custodianId !== custodianId.toLowerCase()) throw new Error('Pilot custody identity does not match configured custodian');
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function parsePilotCustodianId(value: string): string {
|
||||
if (!validUuid(value)) throw new Error('Invalid pilot custodian UUID');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 9;
|
||||
export const CUSTODY_PG_VERSION = 10;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
@@ -372,9 +372,32 @@ REVOKE ALL ON FUNCTION wk_custody.monero_withdrawal_intents_immutable() FROM PUB
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V9_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}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V10 = `
|
||||
CREATE TABLE wk_custody.pilot_custody_identity(
|
||||
singleton smallint PRIMARY KEY DEFAULT 1 CHECK(singleton=1),
|
||||
version integer NOT NULL CHECK(version=1),
|
||||
mode text NOT NULL CHECK(mode='pilot-custody'),
|
||||
policy text NOT NULL CHECK(policy='pilot-custody-v1'),
|
||||
market_network text NOT NULL CHECK(market_network='wikideck-randomx-pilot-1'),
|
||||
market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'),
|
||||
monero_network text NOT NULL CHECK(monero_network='mainnet'),
|
||||
custodian_id uuid NOT NULL UNIQUE,
|
||||
activation_status text NOT NULL CHECK(activation_status='identity-only'),
|
||||
initialized_at bigint NOT NULL CHECK(initialized_at>=0)
|
||||
);
|
||||
CREATE FUNCTION wk_custody.pilot_custody_identity_immutable() RETURNS trigger LANGUAGE plpgsql
|
||||
SET search_path=wk_custody,pg_catalog AS $$
|
||||
BEGIN RAISE EXCEPTION 'Pilot custody identity is immutable'; END $$;
|
||||
CREATE TRIGGER pilot_custody_identity_immutable BEFORE UPDATE OR DELETE ON wk_custody.pilot_custody_identity
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_custody_identity_immutable();
|
||||
REVOKE ALL ON wk_custody.pilot_custody_identity FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION wk_custody.pilot_custody_identity_immutable() FROM PUBLIC;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V10_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}`).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_V9_DIGEST;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V10_DIGEST;
|
||||
|
||||
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
|
||||
export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
@@ -427,6 +450,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
if (current === 8) {
|
||||
await c.query(CUSTODY_MIGRATION_V9);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(9,$1)', [CUSTODY_SCHEMA_V9_DIGEST]);
|
||||
current = 9;
|
||||
}
|
||||
if (current === 9) {
|
||||
await c.query(CUSTODY_MIGRATION_V10);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(10,$1)', [CUSTODY_SCHEMA_V10_DIGEST]);
|
||||
}
|
||||
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
|
||||
await verifyCustodySchema(c);
|
||||
@@ -442,7 +470,7 @@ 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_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_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');
|
||||
|
||||
Reference in New Issue
Block a user