Files

195 lines
13 KiB
TypeScript
Executable File

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(); }
}