Reviewed release and reorg-hold resolution
This commit is contained in:
@@ -7,7 +7,9 @@ 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';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
||||
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
||||
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
@@ -177,7 +179,11 @@ type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observa
|
||||
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 };
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null;
|
||||
release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null;
|
||||
release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null;
|
||||
release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null;
|
||||
release_review_digest: string | null; released_at: string | null };
|
||||
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
@@ -186,10 +192,15 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
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
|
||||
a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner,
|
||||
r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce,
|
||||
r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature,
|
||||
r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text
|
||||
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
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id
|
||||
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));
|
||||
const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])]));
|
||||
for (const row of result.rows) {
|
||||
let approvalIsValid = false;
|
||||
let subjectIsValid = false;
|
||||
@@ -210,11 +221,78 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
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');
|
||||
const released = row.release_id !== null;
|
||||
if (row.normal !== 'credit' || row.balance !== (released ? '0' : 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 (released) {
|
||||
let releaseValid = false;
|
||||
try {
|
||||
const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!,
|
||||
row.release_review_nonce!, Number(row.release_review_expires_at));
|
||||
const releaseSubject = row.release_review_subject!;
|
||||
releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner &&
|
||||
createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest &&
|
||||
Number(row.release_review_expires_at) >= Number(row.released_at) &&
|
||||
verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) &&
|
||||
releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint &&
|
||||
releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary &&
|
||||
releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id;
|
||||
} catch { releaseValid = false; }
|
||||
if (!releaseValid) fail('deposit-release-review-invalid');
|
||||
expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [
|
||||
d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr),
|
||||
]);
|
||||
const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text,
|
||||
r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text,
|
||||
r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence,
|
||||
h.event_type AS hold_event_type
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]);
|
||||
const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort();
|
||||
const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review');
|
||||
const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r
|
||||
ON r.hold_id=h.hold_id AND r.resolution_kind='release-review'
|
||||
WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]);
|
||||
const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])]
|
||||
.map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort();
|
||||
const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([
|
||||
hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort();
|
||||
if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner ||
|
||||
item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce ||
|
||||
item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature ||
|
||||
item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') ||
|
||||
releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') ||
|
||||
releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) {
|
||||
let resolutionValid = false;
|
||||
try {
|
||||
const subject = resolution.review_subject as DepositReorgResolutionSubject;
|
||||
const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by,
|
||||
resolution.review_nonce, Number(resolution.review_expires_at));
|
||||
resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' &&
|
||||
subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id &&
|
||||
subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type &&
|
||||
subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id &&
|
||||
subject.currentObservation.beneficiary === row.beneficiary &&
|
||||
subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr &&
|
||||
address(resolution.reviewer_public_key) === resolution.resolved_by &&
|
||||
createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest &&
|
||||
Number(resolution.review_expires_at) >= Number(resolution.resolved_at) &&
|
||||
verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature);
|
||||
} catch { resolutionValid = false; }
|
||||
if (!resolutionValid) fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.credit_id;
|
||||
@@ -227,6 +305,14 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
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 orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanRelease.rowCount) fail('orphan-deposit-release-journal');
|
||||
const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id
|
||||
WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`);
|
||||
if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution');
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user