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:%'
|
||||
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { assertXmrRpcAmount, parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const fingerprint = hex;
|
||||
const publicKey = z.string().min(1).max(256);
|
||||
const signature = z.string().min(1).max(256);
|
||||
const uuid = z.string().uuid();
|
||||
const POLICY_VERSION = 'monero-pending-credit-v1';
|
||||
const MAX_EVENT_HISTORY = 256;
|
||||
|
||||
export type DepositCreditPolicy = {
|
||||
/** Reviewed key-owner hashes; the signing key remains operator-held and is never stored here. */
|
||||
approvedReviewerOwners: readonly string[];
|
||||
/** Provisional floor; a production value still requires explicit approval. */
|
||||
minimumConfirmations: number;
|
||||
/** Refuse credit review when observation scans have gone stale. */
|
||||
maximumObservationAgeMs: number;
|
||||
};
|
||||
export type DepositCreditReviewSubject = {
|
||||
policyVersion: typeof POLICY_VERSION;
|
||||
walletFingerprint: string;
|
||||
observationId: string;
|
||||
outputPublicKey: string;
|
||||
amountAtomicXmr: string;
|
||||
blockHeight: number;
|
||||
confirmations: number;
|
||||
minimumConfirmations: number;
|
||||
locked: boolean;
|
||||
doubleSpendSeen: boolean;
|
||||
allocationId: string;
|
||||
beneficiary: string;
|
||||
observationDigest: string;
|
||||
observationEventSequence: number;
|
||||
observationEventHistoryDigest: string;
|
||||
observerUpdatedAt: number;
|
||||
};
|
||||
export type DepositCreditApproval = {
|
||||
subject: DepositCreditReviewSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
|
||||
|
||||
const subjectSchema = z.object({
|
||||
policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
|
||||
outputPublicKey: hex, amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
blockHeight: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000), locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
allocationId: uuid, beneficiary: hex, observationDigest: hex,
|
||||
observationEventSequence: z.number().int().nonnegative().safe(), observationEventHistoryDigest: hex,
|
||||
observerUpdatedAt: z.number().int().nonnegative().safe(),
|
||||
}).strict();
|
||||
const approvalSchema = z.object({ subject: subjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
|
||||
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
|
||||
function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); }
|
||||
function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
return JSON.stringify(['wikideck-monero-pending-credit-review-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.walletFingerprint, subject.observationId, subject.outputPublicKey,
|
||||
subject.amountAtomicXmr, subject.blockHeight, subject.confirmations, subject.minimumConfirmations,
|
||||
subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary,
|
||||
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
|
||||
subject.observerUpdatedAt]);
|
||||
}
|
||||
|
||||
function validatePolicy(policy: DepositCreditPolicy): void {
|
||||
if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length ||
|
||||
policy.approvedReviewerOwners.length > 8 || policy.approvedReviewerOwners.some(owner => !hex.safeParse(owner).success) ||
|
||||
!Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 || policy.minimumConfirmations > 100_000 ||
|
||||
!Number.isSafeInteger(policy.maximumObservationAgeMs) || policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) {
|
||||
throw new Error('Invalid private Monero deposit-credit policy');
|
||||
}
|
||||
}
|
||||
|
||||
async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string,
|
||||
policy: DepositCreditPolicy, now: number): Promise<DepositCreditReviewSubject> {
|
||||
const observation = await client.query(`SELECT o.wallet_fingerprint,o.observation_id,o.txid,o.monero_address,o.account_index,
|
||||
o.address_index,o.output_public_key,o.amount_atomic_xmr::text,o.block_height,o.confirmations,o.locked,
|
||||
o.double_spend_seen,o.payload_digest,o.currently_present,s.updated_at AS observer_updated_at
|
||||
FROM wk_custody.monero_transfer_observations o
|
||||
JOIN wk_custody.monero_observer_state s USING(wallet_fingerprint)
|
||||
WHERE o.wallet_fingerprint=$1 AND o.observation_id=$2`, [walletFingerprint, observationId]);
|
||||
if (observation.rowCount !== 1) throw new Error('Deposit observation not found');
|
||||
const row = observation.rows[0];
|
||||
if (!row.output_public_key || row.observation_id !== `${row.txid}:${row.output_public_key}` || !row.currently_present ||
|
||||
row.double_spend_seen || row.locked || Number(row.confirmations) < policy.minimumConfirmations) {
|
||||
throw new Error('Deposit observation is not eligible for reviewed pending credit');
|
||||
}
|
||||
if (now - Number(row.observer_updated_at) < 0 || now - Number(row.observer_updated_at) > policy.maximumObservationAgeMs) {
|
||||
throw new Error('Monero observer state is stale');
|
||||
}
|
||||
const amount = BigInt(row.amount_atomic_xmr);
|
||||
assertXmrRpcAmount(amount);
|
||||
parseAtomicXmr(amount.toString());
|
||||
|
||||
const allocation = await client.query(`SELECT allocation_id,beneficiary,custodian_id,market_network,market_chain_id
|
||||
FROM wk_custody.monero_deposit_allocations
|
||||
WHERE wallet_fingerprint=$1 AND account_index=$2 AND address_index=$3 AND monero_address=$4 AND status='registered'`,
|
||||
[walletFingerprint, row.account_index, row.address_index, row.monero_address]);
|
||||
if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation');
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable');
|
||||
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string };
|
||||
const mapped = allocation.rows[0];
|
||||
if (custodian.mode !== 'sandbox' || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
|
||||
mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity');
|
||||
|
||||
const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at
|
||||
FROM wk_custody.monero_observation_events WHERE wallet_fingerprint=$1 AND observation_id=$2
|
||||
ORDER BY sequence LIMIT $3`, [walletFingerprint, observationId, MAX_EVENT_HISTORY + 1]);
|
||||
if ((events.rowCount ?? events.rows.length) > MAX_EVENT_HISTORY) throw new Error('Deposit observation event history exceeds review bound');
|
||||
if (events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
|
||||
throw new Error('Deposit observation has prior disappearance/reappearance and requires separate review');
|
||||
}
|
||||
const sequence = events.rows.length ? Number(events.rows.at(-1)!.sequence) : 0;
|
||||
const eventDigest = digest(JSON.stringify(events.rows.map(event => [Number(event.sequence), event.event_type,
|
||||
event.payload_digest, Number(event.wallet_height), Number(event.observed_at)])));
|
||||
return {
|
||||
policyVersion: POLICY_VERSION, walletFingerprint, observationId, outputPublicKey: row.output_public_key,
|
||||
amountAtomicXmr: amount.toString(), blockHeight: Number(row.block_height), confirmations: Number(row.confirmations),
|
||||
minimumConfirmations: policy.minimumConfirmations, locked: row.locked, doubleSpendSeen: row.double_spend_seen,
|
||||
allocationId: mapped.allocation_id, beneficiary: mapped.beneficiary, observationDigest: row.payload_digest,
|
||||
observationEventSequence: sequence, observationEventHistoryDigest: eventDigest,
|
||||
observerUpdatedAt: Number(row.observer_updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the exact current snapshot an authorized operator must review and sign. */
|
||||
export async function prepareDepositCreditReview(pool: Pool, walletFingerprint: string, observationId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReviewSubject> {
|
||||
validatePolicy(policy); fingerprint.parse(walletFingerprint);
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReviewSubject(c, walletFingerprint, observationId, policy, now);
|
||||
await c.query('COMMIT'); return subject;
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
/** Canonical message an allow-listed reviewer signs after examining the exact output/allocation snapshot. */
|
||||
export function depositCreditApprovalMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string,
|
||||
expiresAt: number): string {
|
||||
return canonicalReviewMessage(subjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts an operator-reviewed credit into a non-spendable pending-liability account. This service is not
|
||||
* wired to HTTP/startup; release to available balance is a separate, not-yet-implemented approval step.
|
||||
*/
|
||||
export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval: DepositCreditApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = approvalSchema.parse(rawApproval);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner,
|
||||
canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt), approval.signature)) {
|
||||
throw new Error('Deposit credit review signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt));
|
||||
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 idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT credit_id,journal_id,beneficiary,amount_atomic_xmr::text,reviewer_owner,
|
||||
review_nonce,observation_digest,allocation_id::text,reviewer_public_key,review_expires_at,review_signature,
|
||||
minimum_confirmations,review_digest FROM wk_custody.monero_deposit_credit_events
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`,
|
||||
[approval.subject.walletFingerprint, approval.subject.observationId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce ||
|
||||
saved.observation_digest !== approval.subject.observationDigest || saved.amount_atomic_xmr !== approval.subject.amountAtomicXmr ||
|
||||
saved.allocation_id !== approval.subject.allocationId || saved.minimum_confirmations !== approval.subject.minimumConfirmations ||
|
||||
saved.reviewer_public_key !== approval.reviewerPublicKey || Number(saved.review_expires_at) !== approval.expiresAt ||
|
||||
saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit output was already staged under different reviewed terms');
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { creditId: saved.credit_id, journalId: saved.journal_id, beneficiary: saved.beneficiary,
|
||||
amountAtomicXmr: saved.amount_atomic_xmr, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit credit review signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReviewSubject(client, approval.subject.walletFingerprint, approval.subject.observationId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit observation changed after reviewer approval');
|
||||
const creditId = randomUUID();
|
||||
const journalId = `monero-deposit-pending:${digest(`${current.walletFingerprint}:${current.observationId}`)}`;
|
||||
const assetAccount = 'treasury:monero:pending';
|
||||
const liabilityAccount = `customer:${current.beneficiary}:deposit-pending:${creditId}`;
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)',
|
||||
[journalId, 'monero-deposit-pending-credit', now]);
|
||||
const lines = [[0, assetAccount, 'debit'], [1, liabilityAccount, 'credit']] as const;
|
||||
for (const [position, accountId, normal] of lines) {
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]);
|
||||
const account = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]);
|
||||
if (account.rows[0]?.normal !== normal) throw new Error('Deposit pending account normal mismatch');
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)',
|
||||
[journalId, position, accountId, normal === 'debit' ? current.amountAtomicXmr : '0',
|
||||
normal === 'credit' ? current.amountAtomicXmr : '0']);
|
||||
}
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_credit_events(
|
||||
credit_id,wallet_fingerprint,observation_id,allocation_id,beneficiary,amount_atomic_xmr,journal_id,
|
||||
observation_digest,last_observation_event_sequence,reviewer_owner,reviewer_public_key,review_nonce,
|
||||
review_expires_at,review_signature,review_subject,review_digest,credited_at,minimum_confirmations)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
||||
[creditId, current.walletFingerprint, current.observationId, current.allocationId, current.beneficiary,
|
||||
current.amountAtomicXmr, journalId, current.observationDigest, current.observationEventSequence,
|
||||
approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature,
|
||||
JSON.stringify(current), reviewDigest, now, current.minimumConfirmations]);
|
||||
await client.query('COMMIT');
|
||||
return { creditId, journalId, beneficiary: current.beneficiary, amountAtomicXmr: current.amountAtomicXmr, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Append review holds for post-credit Monero reorg signals; this never reverses a journal or releases pending funds. */
|
||||
export async function reconcileDepositCreditReorgHolds(pool: Pool, walletFingerprint: string, now = Date.now()): Promise<number> {
|
||||
fingerprint.parse(walletFingerprint);
|
||||
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]);
|
||||
const reorgs = await client.query(`SELECT c.credit_id,c.observation_id,e.sequence,e.event_type
|
||||
FROM wk_custody.monero_deposit_credit_events c
|
||||
JOIN wk_custody.monero_observation_events e ON e.wallet_fingerprint=c.wallet_fingerprint
|
||||
AND e.observation_id=c.observation_id AND e.sequence>c.last_observation_event_sequence
|
||||
WHERE c.wallet_fingerprint=$1 AND e.event_type IN ('changed','not-seen','reappeared')
|
||||
AND NOT EXISTS (SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=c.credit_id AND h.observation_event_sequence=e.sequence)
|
||||
ORDER BY e.sequence LIMIT 1024`, [walletFingerprint]);
|
||||
for (const row of reorgs.rows) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_holds(
|
||||
hold_id,credit_id,wallet_fingerprint,observation_id,observation_event_sequence,event_type,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(credit_id,observation_event_sequence) DO NOTHING`,
|
||||
[randomUUID(), row.credit_id, walletFingerprint, row.observation_id, row.sequence, row.event_type, now]);
|
||||
}
|
||||
await client.query('COMMIT'); return reorgs.rowCount ?? 0;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -54,8 +54,9 @@ const incomingOutputSchema = z.object({
|
||||
pubkey: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
}).passthrough();
|
||||
const incomingOutputsResultSchema = z.object({
|
||||
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS),
|
||||
status: z.literal('OK'),
|
||||
// v0.18.5.4 omits default-valued empty arrays/status from JSON-RPC serialization.
|
||||
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS).default([]),
|
||||
status: z.literal('OK').optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type MoneroIncomingObservation = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 4;
|
||||
export const CUSTODY_PG_VERSION = 5;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
CREATE SCHEMA IF NOT EXISTS wk_custody;
|
||||
@@ -198,9 +198,65 @@ CREATE UNIQUE INDEX monero_observations_output_key
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V4_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V5 = `
|
||||
-- Deferred balance enforcement runs with the schema owner's read access; the
|
||||
-- fixed search_path and static table reference keep the trigger safe for the
|
||||
-- narrowly privileged pending-credit coordinator.
|
||||
ALTER FUNCTION wk_custody.balanced_journal() SECURITY DEFINER;
|
||||
CREATE TABLE wk_custody.monero_deposit_credit_events(
|
||||
credit_id uuid PRIMARY KEY,
|
||||
wallet_fingerprint text NOT NULL,
|
||||
observation_id text NOT NULL,
|
||||
allocation_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_allocations(allocation_id),
|
||||
beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'),
|
||||
amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615),
|
||||
journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id),
|
||||
observation_digest text NOT NULL CHECK(observation_digest ~ '^[a-f0-9]{64}$'),
|
||||
last_observation_event_sequence bigint NOT NULL CHECK(last_observation_event_sequence>=0),
|
||||
reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'),
|
||||
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
|
||||
review_nonce uuid NOT NULL,
|
||||
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
|
||||
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
|
||||
review_subject jsonb NOT NULL,
|
||||
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
|
||||
credited_at bigint NOT NULL CHECK(credited_at>=0),
|
||||
minimum_confirmations integer NOT NULL CHECK(minimum_confirmations>=10),
|
||||
UNIQUE(wallet_fingerprint,observation_id),
|
||||
UNIQUE(reviewer_owner,review_nonce),
|
||||
FOREIGN KEY(wallet_fingerprint,observation_id)
|
||||
REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id)
|
||||
);
|
||||
ALTER TABLE wk_custody.monero_observation_events
|
||||
ADD CONSTRAINT monero_observation_event_identity UNIQUE(sequence,wallet_fingerprint,observation_id,event_type);
|
||||
CREATE TABLE wk_custody.monero_deposit_reorg_holds(
|
||||
hold_id uuid PRIMARY KEY,
|
||||
credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
|
||||
wallet_fingerprint text NOT NULL,
|
||||
observation_id text NOT NULL,
|
||||
observation_event_sequence bigint NOT NULL REFERENCES wk_custody.monero_observation_events(sequence),
|
||||
event_type text NOT NULL CHECK(event_type IN ('changed','not-seen','reappeared')),
|
||||
created_at bigint NOT NULL CHECK(created_at>=0),
|
||||
UNIQUE(credit_id,observation_event_sequence),
|
||||
FOREIGN KEY(observation_event_sequence,wallet_fingerprint,observation_id,event_type)
|
||||
REFERENCES wk_custody.monero_observation_events(sequence,wallet_fingerprint,observation_id,event_type)
|
||||
);
|
||||
CREATE INDEX monero_deposit_holds_credit ON wk_custody.monero_deposit_reorg_holds(credit_id,observation_event_sequence);
|
||||
CREATE FUNCTION wk_custody.monero_deposit_credit_history_immutable() RETURNS trigger LANGUAGE plpgsql
|
||||
SET search_path=wk_custody,pg_catalog AS $$
|
||||
BEGIN RAISE EXCEPTION 'Monero deposit credit/reorg history is append-only'; END $$;
|
||||
CREATE TRIGGER monero_deposit_credit_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_credit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable();
|
||||
CREATE TRIGGER monero_deposit_reorg_holds_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_holds
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable();
|
||||
REVOKE ALL ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM PUBLIC;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V5_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}`).digest('hex');
|
||||
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
|
||||
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V4_DIGEST;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V5_DIGEST;
|
||||
|
||||
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
|
||||
export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
@@ -228,6 +284,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
if (current === 3) {
|
||||
await c.query(CUSTODY_MIGRATION_V4);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]);
|
||||
current = 4;
|
||||
}
|
||||
if (current === 4) {
|
||||
await c.query(CUSTODY_MIGRATION_V5);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(5,$1)', [CUSTODY_SCHEMA_V5_DIGEST]);
|
||||
}
|
||||
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
|
||||
await verifyCustodySchema(c);
|
||||
@@ -241,7 +302,8 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
|
||||
}
|
||||
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
|
||||
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
|
||||
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, CUSTODY_SCHEMA_V4_DIGEST];
|
||||
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
|
||||
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST];
|
||||
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
|
||||
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
|
||||
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
|
||||
@@ -301,3 +363,35 @@ export async function grantMoneroObserverRuntime(pool: Pool, role: string, custo
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */
|
||||
export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
|
||||
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer'): Promise<void> {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier');
|
||||
if (role === custodyRuntimeRole || role === observerRole) {
|
||||
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime and observer');
|
||||
}
|
||||
const name = `"${role}"`;
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
await c.query('BEGIN'); await verifyCustodySchema(c);
|
||||
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
|
||||
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Deposit-credit role must exist without administrative privileges');
|
||||
const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]);
|
||||
if (memberships.rowCount) throw new Error('Deposit-credit role must not inherit privileges through role membership');
|
||||
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
|
||||
if (owner.rows[0].owner === role) throw new Error('Deposit-credit role must not own the custody schema');
|
||||
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name};
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
|
||||
GRANT SELECT ON wk_custody.custody_meta,wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
|
||||
wk_custody.monero_observation_events,wk_custody.monero_deposit_allocations,
|
||||
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};
|
||||
GRANT SELECT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
|
||||
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};`);
|
||||
await c.query('COMMIT');
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user