Verified incoming transfers against Monero Wallet PRC
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { PoolClient, QueryResult } from 'pg';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { depositCreditApprovalMessage, type DepositCreditReviewSubject } from './monero-deposit-credit.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
@@ -169,6 +172,67 @@ async function checkWithdrawals(client: PoolClient, rows: WithdrawalRow[]) {
|
||||
c(available, (BigInt(w.maxFeeAtomicXmr) - BigInt(w.feeAtomicXmr!)).toString())] : null);
|
||||
}
|
||||
}
|
||||
|
||||
type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observation_id: string; allocation_id: string;
|
||||
beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string;
|
||||
observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string;
|
||||
review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string;
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null };
|
||||
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
budget();
|
||||
const result: QueryResult<DepositCreditRow> = await client.query<DepositCreditRow>(`SELECT c.credit_id::text,c.wallet_fingerprint,c.observation_id,
|
||||
c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text,
|
||||
c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text,
|
||||
c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations,
|
||||
a.balance::text,a.normal FROM wk_custody.monero_deposit_credit_events c
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text
|
||||
WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.map(row => row.journal_id));
|
||||
for (const row of result.rows) {
|
||||
let approvalIsValid = false;
|
||||
let subjectIsValid = false;
|
||||
try {
|
||||
const message = depositCreditApprovalMessage(row.review_subject, row.reviewer_owner,
|
||||
row.review_nonce, Number(row.review_expires_at));
|
||||
approvalIsValid = address(row.reviewer_public_key) === row.reviewer_owner &&
|
||||
createHash('sha256').update(message).digest('hex') === row.review_digest &&
|
||||
Number(row.review_expires_at) >= Number(row.credited_at) &&
|
||||
verifySignature(row.reviewer_public_key, row.reviewer_owner, message, row.review_signature);
|
||||
subjectIsValid = row.observation_id === `${row.review_subject.observationId.split(':')[0]}:${row.review_subject.outputPublicKey}` &&
|
||||
row.review_subject.walletFingerprint === row.wallet_fingerprint &&
|
||||
row.review_subject.observationDigest === row.observation_digest &&
|
||||
row.review_subject.observationId === row.observation_id &&
|
||||
row.review_subject.amountAtomicXmr === row.amount_atomic_xmr && row.review_subject.beneficiary === row.beneficiary &&
|
||||
row.review_subject.allocationId === row.allocation_id &&
|
||||
row.review_subject.observationEventSequence === Number(row.last_observation_event_sequence) &&
|
||||
row.review_subject.minimumConfirmations === row.minimum_confirmations;
|
||||
} catch { approvalIsValid = false; }
|
||||
if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch');
|
||||
if (row.normal !== 'credit' || row.balance !== row.amount_atomic_xmr) fail('deposit-pending-liability-mismatch');
|
||||
expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [
|
||||
d('treasury:monero:pending', row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
]);
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.credit_id;
|
||||
}
|
||||
const orphanAccount = await client.query(`SELECT 1 FROM wk_custody.accounts a
|
||||
WHERE a.id LIKE 'customer:%:deposit-pending:%' AND NOT EXISTS (
|
||||
SELECT 1 FROM wk_custody.monero_deposit_credit_events c
|
||||
WHERE a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text) LIMIT 1`);
|
||||
if (orphanAccount.rowCount) fail('orphan-deposit-pending-liability');
|
||||
const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal');
|
||||
const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR
|
||||
h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`);
|
||||
if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch');
|
||||
}
|
||||
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
|
||||
@@ -212,6 +276,7 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise<void
|
||||
await validateWallets(wallets);
|
||||
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
|
||||
}
|
||||
await checkDepositCredits(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:%'
|
||||
|
||||
Reference in New Issue
Block a user