import process from 'node:process'; import { createConsumer } from '../bus/kafka/consumer.mjs'; import { createProducer } from '../bus/kafka/producer.mjs'; import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { buildFundingVisibility } from '../core/funding-observations.mjs'; import { buildInventorySnapshot } from '../core/inventory.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; import { assertFundingObservationEvent, assertInventorySnapshotEvent, assertLiquidityActionEvent, } from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs'; import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs'; const config = loadConfig(); const logger = createLogger({ service: 'inventory-sync', component: 'inventory', namespace: config.projectNamespace, venue: 'near-intents', }); if (!config.nearIntentsAccountId) { logger.error('missing_account_id', { details: { variable: 'NEAR_INTENTS_ACCOUNT_ID', }, }); process.exit(1); } const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl }); const verifierClient = createVerifierClient({ nearRpcUrl: config.nearRpcUrl, verifierContract: config.nearVerifierContract, }); const producer = await createProducer({ brokers: config.kafkaBrokers, clientId: config.kafkaClientId, logger, }); const consumer = await createConsumer({ groupId: config.kafkaConsumerGroupInventory, brokers: config.kafkaBrokers, clientId: config.kafkaClientId, logger, }); const state = { paused: false, tracked_withdrawals: {}, funding_observations: {}, funding_visibility: { last_observed_at: null, pre_credit_inbound: {}, by_asset: {}, by_handle: {}, }, last_snapshot: null, last_sync_at: null, last_error: null, publish_count: 0, }; await consumer.subscribe({ topic: config.kafkaTopicOpsLiquidityAction, fromBeginning: true }); await consumer.subscribe({ topic: config.kafkaTopicOpsFundingObservation, fromBeginning: true }); await consumer.run({ eachMessage: async ({ topic, message }) => { if (!message.value) return; try { const event = parseEventMessage(message.value.toString()); if (topic === config.kafkaTopicOpsLiquidityAction) { assertLiquidityActionEvent(event); if (event.payload.action_type === 'withdrawal_tracked' || event.payload.action_type === 'withdrawal_status_changed') { const details = event.payload.details || {}; if (details.withdrawal_hash) { state.tracked_withdrawals[details.withdrawal_hash] = { withdrawal_hash: details.withdrawal_hash, asset_id: details.asset_id, chain: details.chain, amount: String(details.amount || '0'), status: details.status || event.payload.status, address: details.address || null, }; } } return; } if (topic === config.kafkaTopicOpsFundingObservation) { assertFundingObservationEvent(event); state.funding_observations[event.payload.funding_observation_id] = event.payload; state.funding_visibility = buildFundingVisibility( Object.values(state.funding_observations), { now: new Date().toISOString() }, ); } } catch (error) { logger.error('inventory_side_input_consume_failed', { topic, details: { error: serializeError(error), }, }); } }, }); async function refresh() { if (state.paused) return; try { const balances = await verifierClient.mtBatchBalanceOf({ accountId: config.nearIntentsAccountId, tokenIds: config.activeAssetIds, }); const recentDeposits = []; for (const chain of [config.tradingBtc.chain, config.tradingEure.chain]) { const response = await bridgeClient.recentDeposits({ accountId: config.nearIntentsAccountId, chain, }); for (const deposit of response?.deposits || []) { recentDeposits.push({ tx_hash: deposit.tx_hash || null, chain, asset_id: chain === config.tradingBtc.chain ? config.tradingBtc.assetId : config.tradingEure.assetId, amount: String(deposit.amount || '0'), address: deposit.address, status: deposit.status, decimals: deposit.decimals, }); } } const snapshot = buildInventorySnapshot({ accountId: config.nearIntentsAccountId, balances, recentDeposits, trackedWithdrawals: Object.values(state.tracked_withdrawals), assetRegistry: config.assetRegistry, observedAt: new Date().toISOString(), }); state.last_snapshot = snapshot; state.last_sync_at = snapshot.synced_at; state.last_error = null; const event = buildEventEnvelope({ source: 'inventory-sync', venue: 'near-intents', eventType: 'intent_inventory', observedAt: snapshot.synced_at, payload: snapshot, }); assertInventorySnapshotEvent(event); await producer.sendJson(config.kafkaTopicStateIntentInventory, event, { key: snapshot.inventory_id }); state.publish_count += 1; } catch (error) { state.last_error = serializeError(error); logger.error('inventory_refresh_failed', { topic: config.kafkaTopicStateIntentInventory, pair: config.activePair, details: { error: serializeError(error), }, }); } } const timer = setInterval(refresh, config.inventorySyncRefreshMs); timer.unref?.(); await refresh(); const controlApi = startControlApi({ host: config.inventorySyncControlHost, port: config.inventorySyncControlPort, logger: logger.child({ component: 'control-api' }), service: 'inventory-sync', namespace: config.projectNamespace, stateProvider: { getState() { return { account_id: config.nearIntentsAccountId, ...state, funding_visibility: buildFundingVisibility( Object.values(state.funding_observations), { now: new Date().toISOString() }, ), }; }, }, routes: [ { method: 'POST', path: '/refresh', handler: async () => { await refresh(); return { ok: true, ...state }; }, }, { method: 'POST', path: '/pause', handler: () => { state.paused = true; return { ok: true, paused: true }; }, }, { method: 'POST', path: '/resume', handler: async () => { state.paused = false; await refresh(); return { ok: true, paused: false }; }, }, ], }); async function shutdown() { clearInterval(timer); await controlApi.close().catch(() => {}); await consumer.disconnect(); await producer.disconnect(); process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);