Ledger-side credit and withdrawal-intent paths
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
migrate: 0,
|
||||
'migrate-mainnet-schema': 0,
|
||||
'initialize-mainnet-custody-identity': 1,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-observer-runtime': 1,
|
||||
'grant-monero-deposit-allocator-runtime': 1,
|
||||
'grant-monero-withdrawal-runtime': 1,
|
||||
'grant-monero-credit-runtime': 1,
|
||||
'import-sqlite': 1,
|
||||
audit: 0,
|
||||
@@ -21,12 +23,16 @@ export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS;
|
||||
export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string };
|
||||
|
||||
export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, isMainnet: boolean): void {
|
||||
if (action === 'migrate-mainnet-schema' && !isMainnet) {
|
||||
throw new Error('Mainnet schema migration action requires the mainnet profile');
|
||||
if ((action === 'migrate-mainnet-schema' || action === 'initialize-mainnet-custody-identity') && !isMainnet) {
|
||||
throw new Error('Mainnet custody operation requires the mainnet profile');
|
||||
}
|
||||
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'grant-monero-deposit-allocator-runtime' &&
|
||||
action !== 'migrate-mainnet-schema') {
|
||||
throw new Error('Only explicitly scoped Monero infrastructure-role grants and schema migration are allowed on mainnet');
|
||||
const mainnetAllowed = ['grant-monero-observer-runtime', 'grant-monero-deposit-allocator-runtime',
|
||||
'grant-monero-withdrawal-runtime',
|
||||
'grant-monero-credit-runtime', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity',
|
||||
'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
|
||||
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
|
||||
if (isMainnet && !mainnetAllowed.includes(action)) {
|
||||
throw new Error('Only scoped Monero infrastructure grants, identity initialization, and reviewed credit/withdrawal-intent operations are allowed on mainnet');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +42,14 @@ export function assertMainnetSchemaMigrationAcknowledged(action: CustodyDbAction
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
|
||||
const requiresReviewAck = action === 'initialize-mainnet-custody-identity' || action === 'grant-monero-credit-runtime' ||
|
||||
action.startsWith('deposit-');
|
||||
if (requiresReviewAck && acknowledgement !== 'reviewed-credit-only') {
|
||||
throw new Error('Mainnet deposit operations require WIKIDECK_MAINNET_CUSTODY_OPERATIONS=reviewed-credit-only');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
|
||||
const [action, ...values] = args;
|
||||
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
||||
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
||||
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
||||
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage } from '../../shared/monero-withdrawal.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
@@ -319,6 +320,89 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`);
|
||||
if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch');
|
||||
}
|
||||
|
||||
async function checkMainnetWithdrawalIntents(client: PoolClient, budget: () => void): Promise<void> {
|
||||
type WithdrawalIntentAuditRow = { request_id: string; custodian_id: string; market_network: string; market_chain_id: string;
|
||||
beneficiary: string; destination: string; amount_atomic_xmr: string; max_fee_atomic_xmr: string; public_key: string;
|
||||
expires_at: string; request_signature: string; request_digest: string; status: string; created_at: string;
|
||||
cancel_request_id: string | null; cancel_public_key: string | null; cancel_expires_at: string | null;
|
||||
cancel_signature: string | null; cancelled_at: string | null };
|
||||
let after: string | null = null;
|
||||
const identityResult = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (!identityResult.rowCount) {
|
||||
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-missing');
|
||||
return;
|
||||
}
|
||||
const identity = JSON.parse(identityResult.rows[0].value) as Record<string, unknown>;
|
||||
if (identity.mode !== 'mainnet-custody' || identity.network !== 'wikideck-randomx-mainnet-1' || identity.policy !== 'custody-mainnet-v1') {
|
||||
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-mismatch');
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<WithdrawalIntentAuditRow> = await client.query<WithdrawalIntentAuditRow>(`SELECT request_id::text,custodian_id::text,market_network,market_chain_id,beneficiary,
|
||||
destination,amount_atomic_xmr::text,max_fee_atomic_xmr::text,public_key,expires_at::text,request_signature,
|
||||
request_digest,status,created_at::text,cancel_request_id::text,cancel_public_key,cancel_expires_at::text,
|
||||
cancel_signature,cancelled_at::text FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE ($1::uuid IS NULL OR request_id>$1::uuid) ORDER BY request_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.flatMap(row => [
|
||||
`monero-withdrawal-reserve:${row.request_id}`, `monero-withdrawal-cancel:${row.request_id}`]));
|
||||
for (const row of result.rows) {
|
||||
let destination: string;
|
||||
try { destination = await validateXmrAddress(row.destination); }
|
||||
catch { fail('mainnet-withdrawal-destination-invalid'); }
|
||||
const total = (BigInt(row.amount_atomic_xmr) + BigInt(row.max_fee_atomic_xmr)).toString();
|
||||
const aggregate = await client.query(`SELECT
|
||||
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved,
|
||||
COALESCE((SELECT sum(c.amount_atomic_xmr) FROM wk_custody.monero_deposit_credit_events c
|
||||
JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id WHERE c.beneficiary=$1),0)::text AS credited`,
|
||||
[row.beneficiary]);
|
||||
if (BigInt(aggregate.rows[0].reserved) > BigInt(aggregate.rows[0].credited)) {
|
||||
fail('mainnet-withdrawal-exceeds-reviewed-credit');
|
||||
}
|
||||
const holdAccount = `customer:${row.beneficiary}:monero-withdrawal:${row.request_id}`;
|
||||
const availableAccount = `customer:${row.beneficiary}:available`;
|
||||
const hold = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', [holdAccount]);
|
||||
const active = row.status === 'reserved';
|
||||
if (!['reserved','cancelled'].includes(row.status) || (active && (hold.rowCount !== 1 || hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== total)) ||
|
||||
(!active && hold.rowCount === 1 && (hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== '0'))) fail('mainnet-withdrawal-reservation-mismatch');
|
||||
try {
|
||||
const request = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
||||
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
publicKey: row.public_key, requestId: row.request_id, destination, amountAtomicXmr: row.amount_atomic_xmr,
|
||||
maxFeeAtomicXmr: row.max_fee_atomic_xmr, expiresAt: Number(row.expires_at), signature: row.request_signature };
|
||||
const { signature: _signature, ...unsigned } = request;
|
||||
const message = mainnetWithdrawalMessage(unsigned);
|
||||
if (row.market_network !== 'wikideck-randomx-mainnet-1' || row.custodian_id !== identity.id ||
|
||||
address(row.public_key) !== row.beneficiary || createHash('sha256').update(message).digest('hex') !== row.request_digest ||
|
||||
!verifySignature(row.public_key, row.beneficiary, message, row.request_signature)) fail('mainnet-withdrawal-authorization-invalid');
|
||||
} catch { fail('mainnet-withdrawal-authorization-invalid'); }
|
||||
expectJournal(postings, `monero-withdrawal-reserve:${row.request_id}`, 'monero-withdrawal-reservation', [
|
||||
d(availableAccount, total), c(holdAccount, total)]);
|
||||
if (active) {
|
||||
if (row.cancel_request_id || row.cancel_signature || row.cancelled_at) fail('mainnet-withdrawal-cancellation-invalid');
|
||||
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', null);
|
||||
} else {
|
||||
try {
|
||||
const cancellation = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
|
||||
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
publicKey: row.cancel_public_key!, cancellationId: row.cancel_request_id!,
|
||||
withdrawalRequestId: row.request_id, expiresAt: Number(row.cancel_expires_at), signature: row.cancel_signature! };
|
||||
const { signature: cancelSignature, ...unsigned } = cancellation;
|
||||
const message = mainnetWithdrawalCancellationMessage(unsigned);
|
||||
if (address(cancellation.publicKey) !== row.beneficiary ||
|
||||
!verifySignature(cancellation.publicKey, row.beneficiary, message, cancelSignature)) fail('mainnet-withdrawal-cancellation-invalid');
|
||||
} catch { fail('mainnet-withdrawal-cancellation-invalid'); }
|
||||
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', [
|
||||
d(holdAccount, total), c(availableAccount, total)]);
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.request_id;
|
||||
}
|
||||
}
|
||||
|
||||
const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal,
|
||||
b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text
|
||||
@@ -363,12 +447,16 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise<void
|
||||
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
||||
}
|
||||
await checkDepositCredits(client, budget);
|
||||
await checkMainnetWithdrawalIntents(client, budget);
|
||||
// Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings.
|
||||
const checks: [string, string][] = [
|
||||
['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE a.id='customer:'||o.buyer||':purchase:'||o.id::text) LIMIT 1`],
|
||||
['orphan-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:withdrawal:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE a.id='customer:'||w.actor||':withdrawal:'||w.id::text) LIMIT 1`],
|
||||
['orphan-monero-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:monero-withdrawal:%'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents w
|
||||
WHERE a.id='customer:'||w.beneficiary||':monero-withdrawal:'||w.request_id::text) LIMIT 1`],
|
||||
['orphan-outbox', `SELECT 1 FROM wk_custody.outbox b WHERE NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE o.id=b.id AND b.kind='nft-lock')
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE w.id=b.id AND b.kind='withdrawal') LIMIT 1`],
|
||||
['listing-order-mismatch', `SELECT 1 FROM wk_custody.listings l LEFT JOIN wk_custody.orders o ON o.data->>'listingId'=l.id::text
|
||||
@@ -379,7 +467,10 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise<void
|
||||
(j.kind IN ('purchase-reservation','purchase-cancellation','mock-sale-settlement') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.orders o WHERE j.id IN ('reserve:'||o.id::text,'cancel:'||o.id::text,'settle:'||o.id::text))) OR
|
||||
(j.kind IN ('withdrawal-reservation','withdrawal-cancellation','mock-withdrawal') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) LIMIT 1`],
|
||||
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) OR
|
||||
(j.kind IN ('monero-withdrawal-reservation','monero-withdrawal-cancellation') AND NOT EXISTS
|
||||
(SELECT 1 FROM wk_custody.monero_withdrawal_intents w WHERE j.id IN
|
||||
('monero-withdrawal-reserve:'||w.request_id::text,'monero-withdrawal-cancel:'||w.request_id::text))) LIMIT 1`],
|
||||
['duplicate-prepared-attempt', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
||||
GROUP BY data->>'attemptId' HAVING count(*)>1 LIMIT 1`],
|
||||
['duplicate-prepared-transaction', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
|
||||
export type MainnetCustodyIdentity = {
|
||||
version: 1;
|
||||
mode: 'mainnet-custody';
|
||||
policy: 'custody-mainnet-v1';
|
||||
network: 'wikideck-randomx-mainnet-1';
|
||||
chainId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
function uuid(value: string): boolean {
|
||||
return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
/** One-time, schema-owner-only identity binding for the separately operated mainnet custody ledger. */
|
||||
export async function initializeMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
|
||||
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
|
||||
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
|
||||
}
|
||||
const identity: MainnetCustodyIdentity = { version: 1, mode: 'mainnet-custody', policy: 'custody-mainnet-v1',
|
||||
network: NETWORK, chainId: CHAIN_ID, id: custodianId };
|
||||
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 value FROM wk_custody.custody_meta WHERE key='identity' FOR UPDATE");
|
||||
if (current.rowCount) {
|
||||
const parsed = JSON.parse(current.rows[0].value) as Partial<MainnetCustodyIdentity>;
|
||||
if (parsed.version !== identity.version || parsed.mode !== identity.mode || parsed.policy !== identity.policy ||
|
||||
parsed.network !== identity.network || parsed.chainId !== identity.chainId || parsed.id !== identity.id) {
|
||||
throw new Error('Custody database is already bound to a different network or custodian identity');
|
||||
}
|
||||
} else {
|
||||
const populated = await client.query(`SELECT
|
||||
EXISTS(SELECT 1 FROM wk_custody.journals) AS has_journals,
|
||||
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) AS has_allocations`);
|
||||
if (populated.rows[0].has_journals || populated.rows[0].has_allocations) {
|
||||
throw new Error('Cannot initialize mainnet custody identity after financial or deposit records exist');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['identity', JSON.stringify(identity)]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return identity;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export async function verifyMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
|
||||
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
|
||||
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
|
||||
}
|
||||
const result = await pool.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
|
||||
const identity = JSON.parse(result.rows[0].value) as Partial<MainnetCustodyIdentity>;
|
||||
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
|
||||
identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.id !== custodianId) {
|
||||
throw new Error('Mainnet custody identity does not match configured custodian');
|
||||
}
|
||||
return identity as MainnetCustodyIdentity;
|
||||
}
|
||||
|
||||
export function parseMainnetCustodianId(value: string): string {
|
||||
if (!uuid(value)) throw new Error('Invalid mainnet custodian UUID');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { assertXmrRpcAmount } from '../../shared/xmr-amount.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalCancellationSchema,
|
||||
mainnetCustodyBalanceMessage, mainnetCustodyBalanceRequestSchema, mainnetWithdrawalMessage,
|
||||
mainnetWithdrawalRequestSchema } from '../../shared/monero-withdrawal.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
const digest = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
const availableAccount = (beneficiary: string) => `customer:${beneficiary}:available`;
|
||||
const withdrawalAccount = (beneficiary: string, requestId: string) => `customer:${beneficiary}:monero-withdrawal:${requestId}`;
|
||||
|
||||
export type MainnetWithdrawalIntent = {
|
||||
requestId: string;
|
||||
custodianId: string;
|
||||
beneficiary: string;
|
||||
destination: string;
|
||||
amountAtomicXmr: string;
|
||||
maxFeeAtomicXmr: string;
|
||||
status: 'reserved' | 'cancelled';
|
||||
createdAt: number;
|
||||
cancelledAt: number | null;
|
||||
};
|
||||
export type MainnetCustodyBalance = {
|
||||
availableAtomicXmr: string;
|
||||
depositPendingAtomicXmr: string;
|
||||
withdrawalReservedAtomicXmr: string;
|
||||
};
|
||||
|
||||
async function insertJournal(client: PoolClient, journalId: string, kind: string, createdAt: number,
|
||||
debitAccount: string, creditAccount: string, amount: string): Promise<void> {
|
||||
for (const [accountId, normal] of [[debitAccount, 'credit'], [creditAccount, 'credit']] as const) {
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]);
|
||||
const row = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]);
|
||||
if (row.rowCount !== 1 || row.rows[0].normal !== normal) throw new Error('Mainnet withdrawal ledger account mismatch');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [journalId, kind, createdAt]);
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
|
||||
[journalId, debitAccount, amount, creditAccount]);
|
||||
}
|
||||
|
||||
async function mainnetIdentity(client: PoolClient, custodianId: string, chainId: string): Promise<void> {
|
||||
const result = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
|
||||
const identity = JSON.parse(result.rows[0].value) as Record<string, unknown>;
|
||||
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
|
||||
identity.network !== 'wikideck-randomx-mainnet-1' || identity.id !== custodianId || identity.chainId !== chainId) {
|
||||
throw new Error('Withdrawal request does not match the initialized mainnet custody identity');
|
||||
}
|
||||
}
|
||||
|
||||
function expiryIsValid(expiresAt: number, now: number): boolean {
|
||||
return Number.isSafeInteger(now) && now >= 0 && expiresAt >= now && expiresAt <= now + MAX_REQUEST_LIFETIME_MS;
|
||||
}
|
||||
|
||||
/** Customer-signed request: atomically reserve available liability, but do not prepare or send XMR. */
|
||||
export async function reserveMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
|
||||
const request = mainnetWithdrawalRequestSchema.parse(rawRequest);
|
||||
const destination = await validateXmrAddress(request.destination);
|
||||
if (address(request.publicKey) !== request.beneficiary ||
|
||||
!verifySignature(request.publicKey, request.beneficiary, mainnetWithdrawalMessage(request), request.signature)) {
|
||||
throw new Error('Mainnet withdrawal signature is invalid');
|
||||
}
|
||||
const requestDigest = digest(mainnetWithdrawalMessage(request));
|
||||
const amount = BigInt(request.amountAtomicXmr), fee = BigInt(request.maxFeeAtomicXmr), total = amount + fee;
|
||||
assertXmrRpcAmount(amount); assertXmrRpcAmount(fee);
|
||||
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 mainnetIdentity(client, request.custodianId, request.chainId);
|
||||
const prior = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE', [request.requestId]);
|
||||
if (prior.rowCount) {
|
||||
const row = prior.rows[0];
|
||||
if (row.request_digest !== requestDigest || row.request_signature !== request.signature) throw new Error('Withdrawal request ID was reused with different terms');
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: row.status, createdAt: Number(row.created_at), cancelledAt: row.cancelled_at === null ? null : Number(row.cancelled_at) };
|
||||
}
|
||||
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet withdrawal request is expired or outside the allowed window');
|
||||
const hold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
|
||||
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [request.beneficiary]);
|
||||
if (hold.rowCount) throw new Error('Customer deposit is under reorg review; new withdrawals are held');
|
||||
const creditTotal = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr),0)::text AS credited
|
||||
FROM wk_custody.monero_deposit_release_events r
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=r.credit_id
|
||||
WHERE c.beneficiary=$1`, [request.beneficiary]);
|
||||
const activeTotals = await client.query(`SELECT
|
||||
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
|
||||
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved`, [request.beneficiary]);
|
||||
const available = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1',
|
||||
[availableAccount(request.beneficiary)]);
|
||||
if (available.rowCount !== 1 || available.rows[0].normal !== 'credit' || BigInt(available.rows[0].balance) < total) {
|
||||
throw new Error('Insufficient available mainnet custody balance');
|
||||
}
|
||||
const alreadyReserved = BigInt(activeTotals.rows[0].reserved);
|
||||
const backed = BigInt(creditTotal.rows[0].credited);
|
||||
if (alreadyReserved + total > backed) throw new Error('Mainnet customer withdrawals exceed reviewed released deposit backing');
|
||||
const createdAt = now, reserveJournal = `monero-withdrawal-reserve:${request.requestId}`;
|
||||
await insertJournal(client, reserveJournal, 'monero-withdrawal-reservation', now,
|
||||
availableAccount(request.beneficiary), withdrawalAccount(request.beneficiary, request.requestId), total.toString());
|
||||
await client.query(`INSERT INTO wk_custody.monero_withdrawal_intents(
|
||||
request_id,custodian_id,market_network,market_chain_id,beneficiary,destination,amount_atomic_xmr,max_fee_atomic_xmr,
|
||||
public_key,expires_at,request_signature,request_digest,status,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'reserved',$13)`,
|
||||
[request.requestId, request.custodianId, request.network, request.chainId, request.beneficiary, destination,
|
||||
request.amountAtomicXmr, request.maxFeeAtomicXmr, request.publicKey, request.expiresAt, request.signature,
|
||||
requestDigest, createdAt]);
|
||||
await client.query('COMMIT');
|
||||
return { requestId: request.requestId, custodianId: request.custodianId, beneficiary: request.beneficiary,
|
||||
destination, amountAtomicXmr: request.amountAtomicXmr, maxFeeAtomicXmr: request.maxFeeAtomicXmr,
|
||||
status: 'reserved', createdAt, cancelledAt: null };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Customer may release a reservation only before any separate treasury preparation has occurred. */
|
||||
export async function cancelMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
|
||||
const cancellation = mainnetWithdrawalCancellationSchema.parse(rawRequest);
|
||||
if (address(cancellation.publicKey) !== cancellation.beneficiary ||
|
||||
!verifySignature(cancellation.publicKey, cancellation.beneficiary,
|
||||
mainnetWithdrawalCancellationMessage(cancellation), cancellation.signature)) {
|
||||
throw new Error('Mainnet withdrawal cancellation signature is invalid');
|
||||
}
|
||||
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 mainnetIdentity(client, cancellation.custodianId, cancellation.chainId);
|
||||
const result = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE',
|
||||
[cancellation.withdrawalRequestId]);
|
||||
if (result.rowCount !== 1 || result.rows[0].beneficiary !== cancellation.beneficiary) {
|
||||
throw new Error('Mainnet withdrawal request not found for this beneficiary');
|
||||
}
|
||||
const row = result.rows[0];
|
||||
if (row.status === 'cancelled') {
|
||||
if (row.cancel_request_id !== cancellation.cancellationId || row.cancel_signature !== cancellation.signature) {
|
||||
throw new Error('Withdrawal was cancelled under different signed terms');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: Number(row.cancelled_at) };
|
||||
}
|
||||
if (!expiryIsValid(cancellation.expiresAt, now)) throw new Error('Mainnet withdrawal cancellation is expired or outside the allowed window');
|
||||
const amount = BigInt(row.amount_atomic_xmr), maxFee = BigInt(row.max_fee_atomic_xmr), total = amount + maxFee;
|
||||
const cancelJournal = `monero-withdrawal-cancel:${row.request_id}`;
|
||||
await insertJournal(client, cancelJournal, 'monero-withdrawal-cancellation', now,
|
||||
withdrawalAccount(row.beneficiary, row.request_id), availableAccount(row.beneficiary), total.toString());
|
||||
await client.query(`UPDATE wk_custody.monero_withdrawal_intents SET status='cancelled',cancel_request_id=$2,
|
||||
cancel_public_key=$3,cancel_expires_at=$4,cancel_signature=$5,cancelled_at=$6 WHERE request_id=$1`,
|
||||
[row.request_id, cancellation.cancellationId, cancellation.publicKey, cancellation.expiresAt, cancellation.signature, now]);
|
||||
await client.query('COMMIT');
|
||||
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
|
||||
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
|
||||
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: now };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Read the caller's own balances using a signed, expiring request; never accepts an actor from query parameters. */
|
||||
export async function readMainnetCustodyBalance(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetCustodyBalance> {
|
||||
const request = mainnetCustodyBalanceRequestSchema.parse(rawRequest);
|
||||
if (address(request.publicKey) !== request.beneficiary ||
|
||||
!verifySignature(request.publicKey, request.beneficiary, mainnetCustodyBalanceMessage(request), request.signature)) {
|
||||
throw new Error('Mainnet custody balance signature is invalid');
|
||||
}
|
||||
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet custody balance request is expired or outside the allowed window');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
await mainnetIdentity(client, request.custodianId, request.chainId);
|
||||
const available = await client.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[availableAccount(request.beneficiary)]);
|
||||
const pending = await client.query(`SELECT COALESCE(sum(balance),0)::text AS balance FROM wk_custody.accounts
|
||||
WHERE id LIKE $1`, [`customer:${request.beneficiary}:deposit-pending:%`]);
|
||||
const withdrawals = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr+max_fee_atomic_xmr),0)::text AS balance
|
||||
FROM wk_custody.monero_withdrawal_intents WHERE beneficiary=$1 AND status='reserved'`, [request.beneficiary]);
|
||||
await client.query('COMMIT');
|
||||
return { availableAtomicXmr: available.rows[0]?.balance ?? '0',
|
||||
depositPendingAtomicXmr: pending.rows[0].balance,
|
||||
withdrawalReservedAtomicXmr: withdrawals.rows[0].balance };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -171,9 +171,13 @@ async function readReviewSubject(client: PoolClient, walletFingerprint: string,
|
||||
if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation');
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable');
|
||||
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string };
|
||||
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string; policy?: string };
|
||||
const mapped = allocation.rows[0];
|
||||
if (custodian.mode !== 'sandbox' || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
|
||||
const validIdentity = (custodian.mode === 'sandbox' && custodian.network === 'wikideck-randomx-testnet-6' &&
|
||||
custodian.policy === 'custody-sandbox-v1' && mapped.market_network === 'wikideck-randomx-testnet-6') ||
|
||||
(custodian.mode === 'mainnet-custody' && custodian.network === 'wikideck-randomx-mainnet-1' &&
|
||||
custodian.policy === 'custody-mainnet-v1' && mapped.market_network === 'wikideck-randomx-mainnet-1');
|
||||
if (!validIdentity || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
|
||||
mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity');
|
||||
|
||||
const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
+61
-3
@@ -20,11 +20,15 @@ import { marketCommandSchema } from '../../shared/market.ts';
|
||||
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
|
||||
import { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { moneroDepositAddressRequestSchema } from '../../shared/monero-deposit.ts';
|
||||
import { mainnetCustodyBalanceRequestSchema, mainnetWithdrawalRequestSchema,
|
||||
mainnetWithdrawalCancellationSchema } from '../../shared/monero-withdrawal.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { openCustodyStorage } from '../market/custody-storage.ts';
|
||||
import { PostgresCustody } from '../market/postgres-custody.ts';
|
||||
import { CanonicalObserver } from '../market/canonical-observer.ts';
|
||||
import { enrollMoneroDepositAddress, type MainnetEnrollmentContext } from '../market/monero-deposit-enrollment.ts';
|
||||
import { cancelMainnetWithdrawal, readMainnetCustodyBalance, reserveMainnetWithdrawal } from '../market/mainnet-withdrawal.ts';
|
||||
import { verifyMainnetCustodyIdentity } from '../market/mainnet-custody-identity.ts';
|
||||
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../market/postgres-config.ts';
|
||||
@@ -50,6 +54,13 @@ if (depositEnrollmentMode !== '0' && depositEnrollmentMode !== '1') {
|
||||
if (depositEnrollmentMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero deposit enrollment requires the mainnet profile');
|
||||
}
|
||||
const withdrawalRequestMode = process.env.WIKIDECK_MONERO_WITHDRAWAL_REQUESTS ?? '0';
|
||||
if (withdrawalRequestMode !== '0' && withdrawalRequestMode !== '1') {
|
||||
throw new Error('WIKIDECK_MONERO_WITHDRAWAL_REQUESTS must be exactly 0 or 1');
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero withdrawal requests require the mainnet profile');
|
||||
}
|
||||
if (canonicalObserverMode === '1' && (process.env.NODE_ENV === 'production' || custodyConfig !== 'sandbox' ||
|
||||
process.env.WIKIDECK_CUSTODY_STORAGE !== 'postgres' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
||||
throw new Error('Canonical observer rehearsal requires sandbox custody, PostgreSQL, and disposable test isolation');
|
||||
@@ -83,6 +94,8 @@ let custodyObserver: CanonicalObserver | undefined;
|
||||
let moneroDepositPool: Pool | undefined;
|
||||
let moneroDepositRpc: ReturnType<typeof createMoneroDepositAddressRpc> | undefined;
|
||||
let moneroDepositContext: MainnetEnrollmentContext | undefined;
|
||||
let moneroWithdrawalPool: Pool | undefined;
|
||||
let moneroWithdrawalCustodianId: string | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig,
|
||||
card: (hash, owner, sequence) => {
|
||||
@@ -123,7 +136,18 @@ try {
|
||||
moneroDepositContext = { custodianId, chainId: CHAIN_ID, walletGenerationId, walletFingerprint: actualFingerprint,
|
||||
accountIndex: Number(accountText) };
|
||||
}
|
||||
} catch(e) {await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
if (withdrawalRequestMode === '1') {
|
||||
moneroWithdrawalCustodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WITHDRAWAL_CUSTODIAN_ID);
|
||||
const urlFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_URL_FILE;
|
||||
const caFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_CA_FILE;
|
||||
if (!urlFile || !caFile) throw new Error('Monero withdrawal request PostgreSQL URL and CA files are required');
|
||||
moneroWithdrawalPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: caFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
moneroWithdrawalPool.on('error', () => console.error('Monero withdrawal request database unavailable.'));
|
||||
await verifyMainnetCustodyIdentity(moneroWithdrawalPool, moneroWithdrawalCustodianId);
|
||||
await moneroWithdrawalPool.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 0');
|
||||
}
|
||||
} catch(e) {await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
|
||||
@@ -194,6 +218,40 @@ if (depositEnrollmentMode === '1' && moneroDepositContext && moneroDepositRpc &&
|
||||
}
|
||||
});
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && moneroWithdrawalPool && moneroWithdrawalCustodianId) {
|
||||
app.get('/api/monero/withdrawal-config', (_req,res) => {
|
||||
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: CHAIN_ID,
|
||||
custodianId: moneroWithdrawalCustodianId, withdrawalRequestsEnabled: true,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
});
|
||||
app.post('/api/monero/custody-balance', rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetCustodyBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet custody balance request.' }); return; }
|
||||
try { res.json(await readMainnetCustodyBalance(moneroWithdrawalPool!, parsed.data)); }
|
||||
catch { res.status(403).json({ error: 'Mainnet custody balance is unavailable for this signed request.' }); }
|
||||
});
|
||||
app.post('/api/monero/withdrawals', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet withdrawal request.' }); return; }
|
||||
try {
|
||||
const result = await reserveMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.status(202).json({ ...result, status: result.status === 'reserved' ? 'reserved-not-sent' : result.status,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal request could not be reserved; verify signed terms and available balance.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/monero/withdrawals/cancel', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalCancellationSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed withdrawal cancellation.' }); return; }
|
||||
try {
|
||||
const result = await cancelMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.json({ ...result, transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal cancellation could not be applied; it may already be cancelled or under operator processing.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
|
||||
const current = marketCommandV3Schema.safeParse(req.body);
|
||||
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
|
||||
@@ -340,6 +398,6 @@ for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
||||
if(catchupImmediate)clearImmediate(catchupImmediate);
|
||||
const observerDrain=custodyObserver?.close()??Promise.resolve();
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{await observerDrain;await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{await observerDrain;await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{stopping=true;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{stopping=true;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
Reference in New Issue
Block a user