Added pilot-bound Monero observation persistence and the one-shot monero:pilot-observe command
This commit is contained in:
@@ -6,6 +6,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
'initialize-pilot-custody-identity': 1,
|
||||
'grant-pilot-deposit-reservation-runtime': 1,
|
||||
'grant-pilot-deposit-address-runtime': 1,
|
||||
'grant-pilot-monero-observer-runtime': 1,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
@@ -56,7 +57,7 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
|
||||
|
||||
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
|
||||
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity',
|
||||
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime'];
|
||||
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime'];
|
||||
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'
|
||||
@@ -76,6 +77,12 @@ export function assertPilotAddressGrantAcknowledged(action: CustodyDbAction, ack
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPilotObserverGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
if (action === 'grant-pilot-monero-observer-runtime' && acknowledgement !== 'pilot-observation-only-no-credit') {
|
||||
throw new Error('Pilot observer role grant requires explicit observation-only/no-credit acknowledgement');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
|
||||
import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { verifyPilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
|
||||
const MAX_SCAN_RANGE = 100_000;
|
||||
const MAX_OVERLAP = 10_000;
|
||||
const fingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const scanInputSchema = z.object({
|
||||
scanId: z.string().uuid(),
|
||||
walletGenerationId: z.string().uuid(),
|
||||
walletFingerprint: fingerprintSchema,
|
||||
expectedScanHeight: z.number().int().nonnegative().safe(),
|
||||
scannedAt: z.number().int().nonnegative().safe(),
|
||||
page: z.object({
|
||||
network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(),
|
||||
fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(),
|
||||
primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
const observationSchema = z.object({
|
||||
observationId: z.string().min(1).max(160), transactionId: fingerprintSchema,
|
||||
address: z.string().length(95), amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
|
||||
accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(),
|
||||
outputPublicKey: fingerprintSchema, globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
type PersistInput = {
|
||||
scanId: string;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
expectedScanHeight: number;
|
||||
scannedAt: number;
|
||||
page: MoneroObservationPage;
|
||||
};
|
||||
export type PilotObservationClassification = 'assigned' | 'unallocated';
|
||||
export type PilotObservationScanResult = {
|
||||
scanId: string; scanHeight: number; observed: number; assigned: number; unallocated: number;
|
||||
changed: number; reappeared: number; notSeen: number; replayed: boolean;
|
||||
};
|
||||
|
||||
export async function readPilotMoneroObservationCursor(pool: Pool, walletGenerationId: string,
|
||||
walletFingerprint: string): Promise<number> {
|
||||
const generation = z.string().uuid().parse(walletGenerationId);
|
||||
const fingerprint = fingerprintSchema.parse(walletFingerprint);
|
||||
const identity = await verifyPilotCustodyIdentity(pool, (await pool.query(
|
||||
'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? '');
|
||||
const result = await pool.query(`SELECT wallet_fingerprint,network,scan_height
|
||||
FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1`, [generation]);
|
||||
if (!result.rowCount) return 0;
|
||||
if (result.rows[0].wallet_fingerprint !== fingerprint || result.rows[0].network !== identity.moneroNetwork) {
|
||||
throw new Error('Pilot observer cursor is bound to a different wallet or Monero network');
|
||||
}
|
||||
return Number(result.rows[0].scan_height);
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
function observationDigest(observation: MoneroIncomingObservation, classification: PilotObservationClassification): string {
|
||||
return digest([classification, observation.observationId, observation.transactionId, observation.amountAtomicXmr,
|
||||
observation.height, observation.timestamp, observation.unlockTime, observation.accountIndex,
|
||||
observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.locked, observation.doubleSpendSeen]);
|
||||
}
|
||||
|
||||
/** Persist pilot-wallet observations separately from mainnet custody observations. Never posts money or credits. */
|
||||
export async function persistPilotMoneroObservationScan(pool: Pool, input: PersistInput): Promise<PilotObservationScanResult> {
|
||||
const parsed = scanInputSchema.parse(input), page = parsed.page;
|
||||
if (page.fromHeight > page.throughHeight || page.throughHeight > page.walletHeight ||
|
||||
page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP ||
|
||||
page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) {
|
||||
throw new Error('Pilot Monero observation range is not contiguous with its durable cursor');
|
||||
}
|
||||
await validateXmrAddress(page.primaryAddress);
|
||||
const observations = page.incoming.map(row => observationSchema.parse(row));
|
||||
const seen = new Set<string>();
|
||||
for (const observation of observations) {
|
||||
await validateXmrAddress(observation.address);
|
||||
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
|
||||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
|
||||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
|
||||
seen.has(observation.observationId)) throw new Error('Pilot Monero observation is inconsistent with its scan page');
|
||||
seen.add(observation.observationId);
|
||||
}
|
||||
const pageDigest = digest([parsed.walletGenerationId, parsed.walletFingerprint, page.fromHeight, page.throughHeight,
|
||||
observations.map(row => [row.observationId, row]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]);
|
||||
|
||||
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 idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
await verifyCustodySchema(client);
|
||||
const identity = await verifyPilotCustodyIdentity(pool, (await client.query(
|
||||
'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? '');
|
||||
const state = await client.query('SELECT wallet_fingerprint,network,scan_height FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1 FOR UPDATE',
|
||||
[parsed.walletGenerationId]);
|
||||
const prior = await client.query('SELECT payload_digest,through_height FROM wk_custody.pilot_monero_observation_scans WHERE scan_id=$1', [parsed.scanId]);
|
||||
if (prior.rowCount) {
|
||||
if (prior.rows[0].payload_digest !== pageDigest) throw new Error('Pilot observation scan ID was reused with different data');
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Number(prior.rows[0].through_height), observed: observations.length,
|
||||
assigned: 0, unallocated: 0, changed: 0, reappeared: 0, notSeen: 0, replayed: true };
|
||||
}
|
||||
if (!state.rowCount) {
|
||||
if (parsed.expectedScanHeight !== 0) throw new Error('First pilot observation scan must start at wallet history height zero');
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observer_state(wallet_generation_id,wallet_fingerprint,network,scan_height,updated_at)
|
||||
VALUES($1,$2,'mainnet',0,$3)`, [parsed.walletGenerationId, parsed.walletFingerprint, parsed.scannedAt]);
|
||||
} else if (state.rows[0].wallet_fingerprint !== parsed.walletFingerprint || state.rows[0].network !== 'mainnet' ||
|
||||
Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) {
|
||||
throw new Error('Pilot observer wallet identity or cursor changed; reread durable state before retry');
|
||||
}
|
||||
const scanState = await client.query('SELECT wallet_fingerprint FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1',
|
||||
[parsed.walletGenerationId]);
|
||||
if (scanState.rows[0].wallet_fingerprint !== parsed.walletFingerprint) throw new Error('Pilot observer wallet fingerprint mismatch');
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_scans
|
||||
(scan_id,wallet_generation_id,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`,
|
||||
[parsed.scanId, parsed.walletGenerationId, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]);
|
||||
|
||||
let assigned = 0, unallocated = 0, changed = 0, reappeared = 0;
|
||||
for (const observation of observations) {
|
||||
const allocation = await client.query<{ allocation_id: string }>(`SELECT allocation_id
|
||||
FROM wk_custody.pilot_monero_deposit_reservations
|
||||
WHERE wallet_generation_id=$1 AND wallet_fingerprint=$2 AND account_index=$3 AND address_index=$4
|
||||
AND monero_network='mainnet' AND status='registered' AND monero_address=$5`,
|
||||
[parsed.walletGenerationId, parsed.walletFingerprint, observation.accountIndex, observation.addressIndex, observation.address]);
|
||||
const classification: PilotObservationClassification = allocation.rowCount ? 'assigned' : 'unallocated';
|
||||
const allocationId: string | null = allocation.rowCount ? allocation.rows[0].allocation_id : null;
|
||||
const payloadDigest = observationDigest(observation, classification);
|
||||
const existing = await client.query(`SELECT payload_digest,currently_present,first_observed_at FROM wk_custody.pilot_monero_observations
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletGenerationId, observation.observationId]);
|
||||
const eventType = !existing.rowCount ? 'seen' : !existing.rows[0].currently_present ? 'reappeared'
|
||||
: existing.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observations(
|
||||
wallet_generation_id,observation_id,allocation_id,classification,txid,monero_address,amount_atomic_xmr,
|
||||
block_height,wallet_height,confirmations,timestamp,unlock_time,account_index,address_index,
|
||||
output_public_key,global_output_index,locked,double_spend_seen,payload_digest,currently_present,
|
||||
last_seen_scan_id,first_observed_at,last_observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,true,$20,$21,$21)
|
||||
ON CONFLICT(wallet_generation_id,observation_id) DO UPDATE SET
|
||||
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
|
||||
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
|
||||
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
|
||||
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
|
||||
[parsed.walletGenerationId, observation.observationId, allocationId, classification, observation.transactionId,
|
||||
observation.address, observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
|
||||
observation.timestamp, observation.unlockTime, observation.accountIndex, observation.addressIndex,
|
||||
observation.outputPublicKey, observation.globalOutputIndex, observation.locked, observation.doubleSpendSeen,
|
||||
payloadDigest, parsed.scanId, existing.rowCount ? Number(existing.rows[0].first_observed_at) : parsed.scannedAt]);
|
||||
if (classification === 'assigned') assigned++; else unallocated++;
|
||||
if (eventType) {
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events
|
||||
(wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletGenerationId, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]);
|
||||
if (eventType === 'changed') changed++;
|
||||
if (eventType === 'reappeared') reappeared++;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.pilot_monero_observations
|
||||
WHERE wallet_generation_id=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3 AND last_seen_scan_id<>$4 FOR UPDATE`,
|
||||
[parsed.walletGenerationId, page.fromHeight, page.throughHeight, parsed.scanId]);
|
||||
for (const row of missing.rows) {
|
||||
await client.query(`UPDATE wk_custody.pilot_monero_observations SET currently_present=false,last_seen_scan_id=$3
|
||||
WHERE wallet_generation_id=$1 AND observation_id=$2`, [parsed.walletGenerationId, row.observation_id, parsed.scanId]);
|
||||
await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events
|
||||
(wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
|
||||
VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`,
|
||||
[parsed.walletGenerationId, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]);
|
||||
}
|
||||
await client.query(`UPDATE wk_custody.pilot_monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3
|
||||
WHERE wallet_generation_id=$1`, [parsed.walletGenerationId, page.throughHeight, parsed.scannedAt]);
|
||||
await client.query('COMMIT');
|
||||
return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight), observed: observations.length,
|
||||
assigned, unallocated, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user