Ledger-side credit and withdrawal-intent paths
This commit is contained in:
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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user