Reviewed release and reorg-hold resolution

This commit is contained in:
2026-10-21 22:24:42 -04:00
parent 9f82f050b4
commit 05aead39ae
8 changed files with 504 additions and 21 deletions
+91 -5
View File
@@ -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