Files
wikideck/server/market/monero-observation-store.ts
T

173 lines
11 KiB
TypeScript
Executable File

import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
import { validateXmrAddress } from './xmr.ts';
import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts';
const walletFingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/);
const scanInputSchema = z.object({
scanId: z.string().uuid(),
walletFingerprint: walletFingerprintSchema,
expectedScanHeight: z.number().int().nonnegative().safe(),
scannedAt: z.number().int().nonnegative().safe(),
page: z.object({
network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(),
fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(),
primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096),
}).strict(),
}).strict();
const observationSchema = z.object({
observationId: z.string().min(1).max(160), transactionId: z.string().regex(/^[a-f0-9]{64}$/),
address: z.string().length(95),
amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
accountIndex: z.number().int().nonnegative().safe(), addressIndex: z.number().int().nonnegative().safe(),
outputPublicKey: z.string().regex(/^[a-f0-9]{64}$/), globalOutputIndex: z.number().int().nonnegative().safe(),
locked: z.boolean(), doubleSpendSeen: z.boolean(),
}).strict();
const MAX_SCAN_RANGE = 100_000;
const MAX_OVERLAP = 10_000;
export type PersistObservationScanInput = {
scanId: string;
walletFingerprint: string;
expectedScanHeight: number;
scannedAt: number;
page: MoneroObservationPage;
};
export type PersistObservationScanResult = {
scanId: string;
scanHeight: number;
observed: number;
changed: number;
reappeared: number;
notSeen: number;
replayed: boolean;
};
function digest(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function rowDigest(observation: MoneroIncomingObservation): string {
return digest([observation.observationId, observation.transactionId, observation.amountAtomicXmr,
observation.height, observation.timestamp, observation.unlockTime,
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
observation.locked, observation.doubleSpendSeen]);
}
/**
* Persist one complete wallet-RPC scan page as observation-only state. This function does not
* create customer credits or financial journals. A missing transfer produces `not-seen`, not
* an automatic debit/reversal; operators must reconcile Monero reorgs before any later credit design.
*/
export async function persistMoneroObservationScan(pool: Pool, input: PersistObservationScanInput): Promise<PersistObservationScanResult> {
const parsed = scanInputSchema.parse(input);
const page = parsed.page;
if (page.fromHeight > page.throughHeight || page.throughHeight !== page.walletHeight ||
page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP ||
page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) {
throw new Error('Monero observation scan range is not contiguous with its durable cursor');
}
await validateXmrAddress(page.primaryAddress);
const observations = page.incoming.map(value => observationSchema.parse(value));
const ids = new Set<string>();
for (const observation of observations) {
await validateXmrAddress(observation.address);
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
(observation.locked && observation.confirmations <= 0)) throw new Error('Monero observation is inconsistent with its scan page');
if (ids.has(observation.observationId)) throw new Error('Duplicate Monero observation identity');
ids.add(observation.observationId);
}
const pageDigest = digest([parsed.walletFingerprint, page.fromHeight, page.throughHeight,
observations.map(row => [row.observationId, rowDigest(row)]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]);
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\'' );
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const priorScan = await client.query(`SELECT payload_digest,through_height FROM wk_custody.monero_observation_scans
WHERE scan_id=$1`, [parsed.scanId]);
if (priorScan.rowCount) {
if (priorScan.rows[0].payload_digest !== pageDigest) throw new Error('Monero scan ID was reused with different observations');
await client.query('COMMIT');
return { scanId: parsed.scanId, scanHeight: Number(priorScan.rows[0].through_height), observed: observations.length,
changed: 0, reappeared: 0, notSeen: 0, replayed: true };
}
let state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state
WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]);
if (!state.rowCount) {
if (parsed.expectedScanHeight !== 0) throw new Error('First Monero observation scan must start from wallet history height zero');
await client.query(`INSERT INTO wk_custody.monero_observer_state(wallet_fingerprint,network,scan_height,updated_at)
VALUES($1,'mainnet',0,$2)`, [parsed.walletFingerprint, parsed.scannedAt]);
state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state
WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]);
}
if (state.rows[0].network !== 'mainnet' || Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) {
throw new Error('Monero observer cursor changed; reread durable state before retry');
}
let changed = 0, reappeared = 0;
for (const observation of observations) {
const current = await client.query(`SELECT payload_digest,currently_present,first_observed_at
FROM wk_custody.monero_transfer_observations
WHERE wallet_fingerprint=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletFingerprint, observation.observationId]);
const payloadDigest = rowDigest(observation);
const eventType = !current.rowCount ? 'seen' : !current.rows[0].currently_present ? 'reappeared'
: current.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
await client.query(`INSERT INTO wk_custody.monero_transfer_observations(
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,output_public_key,global_output_index,
amount_atomic_xmr,block_height,
wallet_height,confirmations,timestamp,unlock_time,locked,double_spend_seen,payload_digest,
currently_present,last_seen_scan_id,first_observed_at,last_observed_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$19,$19)
ON CONFLICT(wallet_fingerprint,observation_id) DO UPDATE SET
monero_address=excluded.monero_address,
output_public_key=excluded.output_public_key,global_output_index=excluded.global_output_index,
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
[parsed.walletFingerprint, observation.observationId, observation.transactionId, observation.address,
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
observation.timestamp, observation.unlockTime, observation.locked, observation.doubleSpendSeen, payloadDigest, parsed.scanId,
current.rowCount ? Number(current.rows[0].first_observed_at) : parsed.scannedAt]);
if (eventType) {
await client.query(`INSERT INTO wk_custody.monero_observation_events
(wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
[parsed.walletFingerprint, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]);
if (eventType === 'changed') changed++;
if (eventType === 'reappeared') reappeared++;
}
}
const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.monero_transfer_observations
WHERE wallet_fingerprint=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3
AND last_seen_scan_id<>$4 FOR UPDATE`, [parsed.walletFingerprint, page.fromHeight, page.throughHeight, parsed.scanId]);
for (const row of missing.rows) {
await client.query(`UPDATE wk_custody.monero_transfer_observations SET currently_present=false,last_seen_scan_id=$3
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [parsed.walletFingerprint, row.observation_id, parsed.scanId]);
await client.query(`INSERT INTO wk_custody.monero_observation_events
(wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at)
VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`,
[parsed.walletFingerprint, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]);
}
await client.query(`INSERT INTO wk_custody.monero_observation_scans
(scan_id,wallet_fingerprint,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`,
[parsed.scanId, parsed.walletFingerprint, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]);
await client.query(`UPDATE wk_custody.monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3
WHERE wallet_fingerprint=$1`, [parsed.walletFingerprint, page.throughHeight, parsed.scannedAt]);
await client.query('COMMIT');
return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight),
observed: observations.length, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}