Ledger-side credit and withdrawal-intent paths

This commit is contained in:
2026-10-22 13:28:55 -04:00
parent 4fea176877
commit 52559f65d7
17 changed files with 842 additions and 40 deletions
+96 -6
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 8;
export const CUSTODY_PG_VERSION = 9;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -323,9 +323,58 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocation_insert_guard() FROM
`;
export const CUSTODY_SCHEMA_V8_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}`).digest('hex');
export const CUSTODY_MIGRATION_V9 = `
CREATE TABLE wk_custody.monero_withdrawal_intents(
request_id uuid PRIMARY KEY,
custodian_id uuid NOT NULL,
market_network text NOT NULL CHECK(market_network='wikideck-randomx-mainnet-1'),
market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'),
beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'),
destination text NOT NULL CHECK(length(destination)=95),
amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615),
max_fee_atomic_xmr numeric(20,0) NOT NULL CHECK(max_fee_atomic_xmr>=0 AND max_fee_atomic_xmr<=18446744073709551615),
public_key text NOT NULL CHECK(length(public_key) BETWEEN 1 AND 256),
expires_at bigint NOT NULL CHECK(expires_at>=0),
request_signature text NOT NULL CHECK(length(request_signature) BETWEEN 1 AND 256),
request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'),
status text NOT NULL CHECK(status IN ('reserved','cancelled')),
created_at bigint NOT NULL CHECK(created_at>=0),
cancel_request_id uuid UNIQUE,
cancel_public_key text CHECK(cancel_public_key IS NULL OR length(cancel_public_key) BETWEEN 1 AND 256),
cancel_expires_at bigint CHECK(cancel_expires_at IS NULL OR cancel_expires_at>=0),
cancel_signature text CHECK(cancel_signature IS NULL OR length(cancel_signature) BETWEEN 1 AND 256),
cancelled_at bigint CHECK(cancelled_at IS NULL OR cancelled_at>=created_at),
CHECK((status='reserved' AND cancel_request_id IS NULL AND cancel_signature IS NULL AND cancelled_at IS NULL) OR
(status='cancelled' AND cancel_request_id IS NOT NULL AND cancel_public_key IS NOT NULL AND
cancel_expires_at IS NOT NULL AND cancel_signature IS NOT NULL AND cancelled_at IS NOT NULL))
);
CREATE FUNCTION wk_custody.monero_withdrawal_intents_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero withdrawal intent history cannot be deleted'; END IF;
IF OLD.status<>'reserved' OR NEW.status<>'cancelled' 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.beneficiary IS DISTINCT FROM OLD.beneficiary OR
NEW.destination IS DISTINCT FROM OLD.destination OR NEW.amount_atomic_xmr IS DISTINCT FROM OLD.amount_atomic_xmr OR
NEW.max_fee_atomic_xmr IS DISTINCT FROM OLD.max_fee_atomic_xmr OR NEW.public_key IS DISTINCT FROM OLD.public_key OR
NEW.expires_at IS DISTINCT FROM OLD.expires_at OR NEW.request_signature IS DISTINCT FROM OLD.request_signature OR
NEW.request_digest IS DISTINCT FROM OLD.request_digest OR NEW.created_at IS DISTINCT FROM OLD.created_at OR
NEW.cancel_request_id IS NULL OR NEW.cancel_public_key IS NULL OR NEW.cancel_expires_at IS NULL OR
NEW.cancel_signature IS NULL OR NEW.cancelled_at IS NULL THEN
RAISE EXCEPTION 'Monero withdrawal intent identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER monero_withdrawal_intents_immutable BEFORE UPDATE OR DELETE ON wk_custody.monero_withdrawal_intents
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_withdrawal_intents_immutable();
REVOKE ALL ON wk_custody.monero_withdrawal_intents FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.monero_withdrawal_intents_immutable() FROM PUBLIC;
`;
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');
/** 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_V8_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V9_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -373,6 +422,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 7) {
await c.query(CUSTODY_MIGRATION_V8);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(8,$1)', [CUSTODY_SCHEMA_V8_DIGEST]);
current = 8;
}
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]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -388,7 +442,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_V8_DIGEST, CUSTODY_SCHEMA_V9_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');
@@ -455,10 +509,11 @@ export async function grantMoneroObserverRuntime(pool: Pool, role: string, custo
/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */
export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer'): Promise<void> {
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
allocatorRole = 'monero_deposit_allocator'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier');
if (role === custodyRuntimeRole || role === observerRole) {
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime and observer');
if ([custodyRuntimeRole, observerRole, allocatorRole].includes(role)) {
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime, observer, and allocator');
}
const name = `"${role}"`;
const c = await pool.connect();
@@ -514,3 +569,38 @@ export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: strin
} 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',
creditRole = 'monero_deposit_credit', allocatorRole = 'monero_deposit_allocator'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid Monero withdrawal role identifier');
if ([custodyRuntimeRole, observerRole, creditRole, allocatorRole].includes(role)) {
throw new Error('Monero withdrawal role must be separate from custody, observer, credit, and allocator 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('Monero withdrawal 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('Monero withdrawal 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('Monero withdrawal 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.custody_meta,wk_custody.accounts,wk_custody.monero_deposit_credit_events,
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_holds,
wk_custody.monero_deposit_reorg_resolutions TO ${name};
GRANT SELECT,INSERT ON wk_custody.monero_withdrawal_intents TO ${name};
GRANT UPDATE(status,cancel_request_id,cancel_public_key,cancel_expires_at,cancel_signature,cancelled_at)
ON wk_custody.monero_withdrawal_intents TO ${name};
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
GRANT INSERT ON wk_custody.entries TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}