Verified incoming transfers against Monero Wallet PRC

This commit is contained in:
2026-10-21 21:34:46 -04:00
parent af646dcc32
commit 9f82f050b4
13 changed files with 605 additions and 17 deletions
+254
View File
@@ -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(); }
}