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
+19 -5
View File
@@ -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)) {
+92 -1
View File
@@ -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')
+71
View File
@@ -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();
}
+194
View File
@@ -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(); }
}
+6 -2
View File
@@ -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
+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(); }
}
+61 -3
View File
@@ -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);});