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