195 lines
13 KiB
TypeScript
Executable File
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(); }
|
|
}
|