import process from 'node:process'; import { createProducer } from '../bus/kafka/producer.mjs'; import { assetsByChain as groupAssetsByChain, bridgeDepositAssetId, bridgeDepositObservedAt, uniqueChainsForAssets, } from '../core/bridge-assets.mjs'; import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope } from '../core/event-envelope.mjs'; import { buildFundingObservationKey, correlateFundingObservation, hasFundingObservationChanged, matchBridgeDeposit, summarizeFundingObservations, } from '../core/funding-observations.mjs'; import { createJsonStateStore } from '../core/json-state-store.mjs'; import { normalizeLiquidityState } from '../core/liquidity-state.mjs'; import { buildBridgeWithdrawalPlan } from '../core/liquidity-withdrawals.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; import { assertFundingObservationEvent, assertLiquidityActionEvent } from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createPostgresPool, createTradingConfigStore, ensureHistorySchema, seedTradingConfig, } from '../lib/postgres.mjs'; import { createBtcAddressObserver } from '../observers/btc-address-observer.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: 'liquidity-manager', component: 'funding', namespace: config.projectNamespace, venue: 'near-intents', }); if (!config.nearIntentsAccountId) { logger.error('missing_account_id', { details: { variable: 'NEAR_INTENTS_ACCOUNT_ID', }, }); process.exit(1); } const producer = await createProducer({ brokers: config.kafkaBrokers, clientId: config.kafkaClientId, logger, }); const configPool = createPostgresPool({ connectionString: config.postgresUrl, }); await ensureHistorySchema(configPool); await seedTradingConfig(configPool); const tradingConfigStore = createTradingConfigStore({ pool: configPool, logger: logger.child({ component: 'trading-config' }), }); const initialTradingConfig = await tradingConfigStore.forceRefresh(); const runtimeConfig = { ...config, ...initialTradingConfig, assetRegistry: initialTradingConfig.assetRegistry || config.assetRegistry, trackedAssets: initialTradingConfig.trackedAssets?.length ? initialTradingConfig.trackedAssets : config.trackedAssets, tradingBtc: initialTradingConfig.tradingBtc || config.tradingBtc, tradingEure: initialTradingConfig.tradingEure || config.tradingEure, }; const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl }); const verifierClient = createVerifierClient({ nearRpcUrl: config.nearRpcUrl, verifierContract: config.nearVerifierContract, accountId: config.nearIntentsAccountId, signerPrivateKey: config.nearIntentsSignerPrivateKey, }); const btcAddressObserver = config.btcFundingObserverEnabled ? createBtcAddressObserver({ baseUrl: config.btcFundingObserverBaseUrl, }) : null; const store = createJsonStateStore({ stateDir: config.liquidityStateDir, fileName: 'liquidity.json', initialState: { paused: false, funding_observer_paused: false, withdrawals_frozen: config.withdrawalsFrozen, deposit_addresses: {}, deposits: {}, tracked_withdrawals: {}, supported_tokens: {}, funding_observations: {}, funding_observations_by_handle: {}, funding_visibility_by_asset: {}, uncredited_funding_total_by_asset: {}, credit_correlation: {}, observer_health: {}, last_refresh_at: null, last_funding_observation_at: null, funding_observer_last_refresh_at: null, funding_observer_last_error: null, last_error: null, last_withdrawal_request: null, last_withdrawal_result: null, publish_count: 0, funding_publish_count: 0, }, }); const chains = uniqueChainsForAssets(runtimeConfig.trackedAssets); const trackedAssetsByChain = groupAssetsByChain(runtimeConfig.trackedAssets); const fallbackAssetByChain = new Map([ [runtimeConfig.tradingBtc.chain, runtimeConfig.tradingBtc.assetId], [runtimeConfig.tradingEure.chain, runtimeConfig.tradingEure.assetId], ]); const fundingObserverByChain = new Map( btcAddressObserver ? [[runtimeConfig.tradingBtc.chain, btcAddressObserver]] : [], ); async function refresh() { const state = normalizeLiquidityState(store.getState(), { withdrawalsFrozen: config.withdrawalsFrozen, }); if (state.paused) return; try { const supported = await bridgeClient.supportedTokens({ chains }); state.supported_tokens = mapSupportedTokens(supported?.tokens || []); for (const chain of chains) { await refreshChain(chain, state); } for (const tracked of Object.values(state.tracked_withdrawals)) { await refreshWithdrawal(tracked, state); } state.last_refresh_at = new Date().toISOString(); state.last_error = null; applyFundingObservationSummary(state, state.last_refresh_at); store.setState(state); } catch (error) { state.last_error = serializeError(error); store.setState(state); logger.error('liquidity_refresh_failed', { topic: config.kafkaTopicOpsLiquidityAction, details: { error: serializeError(error), }, }); } } async function refreshChain(chain, state) { const depositAddress = await bridgeClient.depositAddress({ accountId: config.nearIntentsAccountId, chain, }); const refreshedAt = new Date().toISOString(); const previousAddress = state.deposit_addresses[chain]?.address || null; state.deposit_addresses[chain] = { ...(state.deposit_addresses[chain] || {}), ...depositAddress, refreshed_at: refreshedAt, }; if (previousAddress !== depositAddress.address) { await publishAction({ action_type: 'deposit_address_refreshed', status: 'READY', chain, asset_id: chainAssetIds(chain).length === 1 ? chainAssetIds(chain)[0] : null, details: { ...depositAddress, asset_ids: chainAssetIds(chain), }, }, state); } const deposits = await bridgeClient.recentDeposits({ accountId: config.nearIntentsAccountId, chain, }); const bridgeDeposits = deposits?.deposits || []; for (const deposit of bridgeDeposits) { const assetId = mapDepositAssetId(deposit, chain); const key = [ chain, deposit.tx_hash || deposit.address, deposit.intents_token_id || deposit.near_token_id || deposit.defuse_asset_identifier, ].join(':'); const normalized = { tx_hash: deposit.tx_hash || null, chain, asset_id: assetId, amount: String(deposit.amount || '0'), account_id: deposit.account_id, address: deposit.address, status: deposit.status, decimals: deposit.decimals, created_at: bridgeDepositObservedAt(deposit), near_token_id: deposit.near_token_id || null, intents_token_id: deposit.intents_token_id || null, defuse_asset_identifier: deposit.defuse_asset_identifier || null, mint_tx_hash: deposit.mint_tx_hash || null, }; const previous = state.deposits[key]; state.deposits[key] = normalized; if (!previous || previous.status !== normalized.status) { await publishAction({ action_type: 'deposit_status_observed', status: normalized.status, chain, asset_id: assetId, details: normalized, }, state, { observedAt: normalized.created_at }); } } await refreshFundingObservations({ chain, state, fundingHandle: depositAddress.address, bridgeDeposits, }); } async function refreshFundingObservations({ chain, state, fundingHandle, bridgeDeposits }) { const refreshedAt = new Date().toISOString(); const observer = fundingObserverByChain.get(chain); if (!fundingHandle) { state.observer_health[chain] = { chain, healthy: false, configured: false, supported: Boolean(observer), paused: state.funding_observer_paused, source: observer ? 'configured' : 'unsupported', refreshed_at: refreshedAt, }; applyFundingObservationSummary(state, refreshedAt); return; } if (!observer) { state.observer_health[chain] = { chain, healthy: false, configured: true, supported: false, paused: false, handle: fundingHandle, source: 'unsupported', refreshed_at: refreshedAt, }; applyFundingObservationSummary(state, refreshedAt); return; } if (state.funding_observer_paused) { state.observer_health[chain] = { ...(state.observer_health[chain] || {}), chain, healthy: true, configured: true, supported: true, paused: true, handle: fundingHandle, refreshed_at: refreshedAt, source: state.observer_health[chain]?.source || 'btc_mempool_space', }; applyFundingObservationSummary(state, refreshedAt); return; } try { const observed = await observer.listTransactions({ address: fundingHandle }); for (const tx of observed.transactions) { const bridgeDeposit = matchBridgeDeposit({ txHash: tx.tx_hash, fundingHandle, bridgeDeposits, }); const key = buildFundingObservationKey({ chain, fundingHandle, txHash: tx.tx_hash, }); const previous = state.funding_observations[key] || null; const next = correlateFundingObservation({ existing: previous, accountId: config.nearIntentsAccountId, assetId: bridgeDeposit ? mapDepositAssetId(bridgeDeposit, chain) : fallbackAssetByChain.get(chain), chain, fundingHandle, source: tx.source || observed.source, txHash: tx.tx_hash, amount: tx.amount, confirmations: tx.confirmations, observedAt: tx.observed_at || observed.observed_at, bridgeDeposit, stuckAfterMs: config.fundingObservationStuckMs, }); state.funding_observations[key] = next; if (hasFundingObservationChanged(previous, next)) { await publishFundingObservation(next, state); } } for (const [key, previous] of Object.entries(state.funding_observations)) { if (previous.chain !== chain || previous.funding_handle !== fundingHandle) continue; const bridgeDeposit = matchBridgeDeposit({ txHash: previous.tx_hash, fundingHandle, bridgeDeposits, }); const next = correlateFundingObservation({ existing: previous, accountId: previous.account_id, assetId: bridgeDeposit ? mapDepositAssetId(bridgeDeposit, chain) : previous.asset_id, chain: previous.chain, fundingHandle: previous.funding_handle, source: previous.source, txHash: previous.tx_hash, amount: previous.amount, confirmations: previous.confirmations, observedAt: refreshedAt, bridgeDeposit, stuckAfterMs: config.fundingObservationStuckMs, }); state.funding_observations[key] = next; if (hasFundingObservationChanged(previous, next)) { await publishFundingObservation(next, state); } } state.funding_observer_last_refresh_at = observed.observed_at || refreshedAt; state.funding_observer_last_error = null; state.observer_health[chain] = { chain, healthy: true, configured: true, supported: true, paused: false, handle: fundingHandle, source: observed.source, observed_count: observed.transactions.length, refreshed_at: observed.observed_at || refreshedAt, }; applyFundingObservationSummary(state, refreshedAt); } catch (error) { state.funding_observer_last_error = serializeError(error); state.observer_health[chain] = { chain, healthy: false, configured: true, supported: true, paused: false, handle: fundingHandle, source: 'btc_mempool_space', refreshed_at: refreshedAt, error: serializeError(error), }; applyFundingObservationSummary(state, refreshedAt); logger.error('funding_observation_refresh_failed', { topic: config.kafkaTopicOpsFundingObservation, details: { chain, funding_handle: fundingHandle, error: serializeError(error), }, }); } } function applyFundingObservationSummary(state, now = new Date().toISOString()) { const summary = summarizeFundingObservations( Object.values(state.funding_observations), { now }, ); state.funding_observations_by_handle = summary.funding_observations_by_handle; state.funding_visibility_by_asset = summary.funding_visibility_by_asset; state.latest_funding_observation_at = summary.latest_funding_observation_at; state.uncredited_funding_total_by_asset = summary.uncredited_funding_total_by_asset; state.credit_correlation = summary.credit_correlation; } async function refreshWithdrawal(tracked, state) { const status = await bridgeClient.withdrawalStatus({ withdrawalHash: tracked.withdrawal_hash, }); const next = { ...tracked, status: status.status, transfer_tx_hash: status.data?.transfer_tx_hash || null, last_checked_at: new Date().toISOString(), }; state.tracked_withdrawals[tracked.withdrawal_hash] = next; if (tracked.status !== next.status) { await publishAction({ action_type: 'withdrawal_status_changed', status: next.status, chain: next.chain, asset_id: next.asset_id, details: next, }, state); } } async function estimateWithdrawal({ assetId, amount, destinationAddress, chain = null, state }) { const plan = buildBridgeWithdrawalPlan({ assetId, amount, destinationAddress, chain, supportedTokens: state.supported_tokens, config: runtimeConfig, }); const estimate = await bridgeClient.withdrawalEstimate({ chain: plan.chain, token: plan.near_token_id, address: plan.destination_address, }); return { ...plan, estimate, }; } async function submitWithdrawal({ assetId, amount, destinationAddress, chain = null }) { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); if (state.paused) throw new Error('liquidity manager is paused'); if (state.withdrawals_frozen) throw new Error('withdrawals are frozen'); await refresh(); const planned = await estimateWithdrawal({ assetId, amount, destinationAddress, chain, state, }); state.last_withdrawal_request = { asset_id: planned.asset_id, amount: planned.amount, chain: planned.chain, destination_address: planned.destination_address, requested_at: new Date().toISOString(), estimate: planned.estimate, }; store.setState(state); const outcome = await verifierClient.ftWithdrawRaw({ token: planned.near_token_id, receiverId: planned.receiver_id, amount: planned.amount, memo: planned.memo, }); const withdrawalHash = outcome.transaction?.hash || outcome.transaction_outcome?.id; if (!withdrawalHash) throw new Error('missing withdrawal hash from ft_withdraw outcome'); const tracked = { withdrawal_hash: withdrawalHash, asset_id: planned.asset_id, chain: planned.chain, amount: planned.amount, address: planned.destination_address, near_token_id: planned.near_token_id, defuse_asset_identifier: planned.defuse_asset_identifier, estimate: planned.estimate, status: 'SUBMITTED', submitted_at: new Date().toISOString(), }; state.tracked_withdrawals[withdrawalHash] = tracked; state.last_withdrawal_result = { withdrawal_hash: withdrawalHash, status: 'SUBMITTED', transaction: outcome.transaction || null, transaction_outcome: outcome.transaction_outcome || null, receipts_outcome: outcome.receipts_outcome || [], }; store.setState(state); await publishAction({ action_type: 'withdrawal_submitted', status: 'SUBMITTED', chain: planned.chain, asset_id: planned.asset_id, details: tracked, }, state); store.setState(state); refreshWithdrawal(tracked, state) .then(() => store.setState(state)) .catch(() => {}); return { plan: planned, tracked, outcome, }; } async function publishAction(payload, state, { observedAt = null } = {}) { const event = buildEventEnvelope({ source: 'liquidity-manager', venue: 'near-intents', eventType: 'liquidity_action', observedAt: observedAt || payload.observed_at || payload.details?.created_at || payload.details?.last_checked_at || payload.details?.submitted_at || null, payload: { liquidity_action_id: `${payload.action_type}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, account_id: config.nearIntentsAccountId, ...payload, }, }); assertLiquidityActionEvent(event); await producer.sendJson(config.kafkaTopicOpsLiquidityAction, event, { key: event.payload.liquidity_action_id }); state.publish_count += 1; } async function publishFundingObservation(payload, state) { const event = buildEventEnvelope({ source: 'liquidity-manager', venue: 'near-intents', eventType: 'funding_observation', observedAt: payload.last_seen_at, payload, }); assertFundingObservationEvent(event); await producer.sendJson(config.kafkaTopicOpsFundingObservation, event, { key: payload.funding_observation_id, }); state.funding_publish_count += 1; } const timer = setInterval(refresh, config.liquidityRefreshMs); timer.unref?.(); await refresh(); const controlApi = startControlApi({ host: config.liquidityManagerControlHost, port: config.liquidityManagerControlPort, logger: logger.child({ component: 'control-api' }), service: 'liquidity-manager', namespace: config.projectNamespace, stateProvider: { getState() { return buildPublicState(); }, }, routes: [ { method: 'POST', path: '/refresh', handler: async () => { await refresh(); return { ok: true, ...buildPublicState(), }; }, }, { method: 'POST', path: '/refresh-funding-observations', handler: async () => { await refresh(); return { ok: true, ...buildPublicState(), }; }, }, { method: 'POST', path: '/pause-funding-observer', handler: () => { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.funding_observer_paused = true; store.setState(state); return { ok: true, funding_observer_paused: true }; }, }, { method: 'POST', path: '/resume-funding-observer', handler: async () => { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.funding_observer_paused = false; store.setState(state); await refresh(); return { ok: true, ...buildPublicState(), }; }, }, { method: 'POST', path: '/pause', handler: () => { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.paused = true; store.setState(state); return { ok: true, paused: true }; }, }, { method: 'POST', path: '/resume', handler: async () => { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.paused = false; store.setState(state); await refresh(); return { ok: true, paused: false }; }, }, { method: 'POST', path: '/freeze-withdrawals', handler: async ({ body }) => { const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.withdrawals_frozen = body.frozen !== false; store.setState(state); await publishAction({ action_type: 'withdrawals_frozen', status: state.withdrawals_frozen ? 'FROZEN' : 'UNFROZEN', chain: null, asset_id: null, details: { frozen: state.withdrawals_frozen, }, }, state); store.setState(state); return { ok: true, withdrawals_frozen: state.withdrawals_frozen }; }, }, { method: 'POST', path: '/withdrawal-estimate', handler: async ({ body }) => { if (!body.asset_id || !body.amount) { return { statusCode: 400, payload: { error: 'asset_id and amount are required' }, }; } const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); if (!Object.keys(state.supported_tokens).length) { await refresh(); } try { const withdrawal = await estimateWithdrawal({ assetId: body.asset_id, amount: body.amount, destinationAddress: body.destination_address, chain: body.chain, state, }); return { ok: true, withdrawal, }; } catch (error) { return { statusCode: 400, payload: { error: error.message, }, }; } }, }, { method: 'POST', path: '/track-withdrawal', handler: async ({ body }) => { if (!body.withdrawal_hash || !body.asset_id || !body.chain) { return { statusCode: 400, payload: { error: 'withdrawal_hash, asset_id, and chain are required' }, }; } const state = store.getState(); normalizeLiquidityState(state, { withdrawalsFrozen: config.withdrawalsFrozen, }); state.tracked_withdrawals[body.withdrawal_hash] = { withdrawal_hash: body.withdrawal_hash, asset_id: body.asset_id, chain: body.chain, amount: String(body.amount || '0'), status: 'TRACKED', address: body.address || null, noted_at: new Date().toISOString(), }; store.setState(state); await publishAction({ action_type: 'withdrawal_tracked', status: 'TRACKED', chain: body.chain, asset_id: body.asset_id, details: state.tracked_withdrawals[body.withdrawal_hash], }, state); store.setState(state); return { ok: true, tracked: state.tracked_withdrawals[body.withdrawal_hash] }; }, }, { method: 'POST', path: '/withdraw', handler: async ({ body }) => { if (!body.asset_id || !body.amount) { return { statusCode: 400, payload: { error: 'asset_id and amount are required' }, }; } try { const submitted = await submitWithdrawal({ assetId: body.asset_id, amount: body.amount, destinationAddress: body.destination_address, chain: body.chain, }); return { ok: true, withdrawal: submitted.plan, tracked: submitted.tracked, }; } catch (error) { return { statusCode: inferWithdrawStatusCode(error), payload: { error: error.message, }, }; } }, }, { method: 'POST', path: '/notify-deposit', handler: async ({ body }) => { if (!body.deposit_address || !body.tx_hash) { return { statusCode: 400, payload: { error: 'deposit_address and tx_hash are required' }, }; } await bridgeClient.notifyDeposit({ depositAddress: body.deposit_address, txHash: body.tx_hash, }); return { ok: true }; }, }, ], }); async function shutdown() { clearInterval(timer); await controlApi.close().catch(() => {}); await producer.disconnect(); await configPool.end().catch(() => {}); process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); function mapSupportedTokens(tokens) { return Object.fromEntries( tokens.map((token) => [ `${token.near_token_id}:${token.defuse_asset_identifier}`, token, ]), ); } function mapDepositAssetId(deposit, chain) { return bridgeDepositAssetId(deposit, { assetRegistry: runtimeConfig.assetRegistry, fallbackAssetId: fallbackAssetByChain.get(chain), }); } function chainAssetIds(chain) { return (trackedAssetsByChain.get(chain) || []).map((asset) => asset.assetId); } function inferWithdrawStatusCode(error) { const message = String(error?.message || ''); if ( message.includes('required') || message.includes('unsupported') || message.includes('amount') || message.includes('chain mismatch') ) { return 400; } return 409; } function buildPublicState() { const now = new Date().toISOString(); const state = normalizeLiquidityState(structuredClone(store.getState()), { withdrawalsFrozen: config.withdrawalsFrozen, }); applyFundingObservationSummary(state, now); return { account_id: config.nearIntentsAccountId, tracked_assets: runtimeConfig.trackedAssets, trading_config: tradingConfigStore.getState(), withdrawal_defaults: Object.fromEntries( runtimeConfig.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]), ), ...state, observer_health: buildObserverHealth(state.observer_health, { now, fundingObserverPaused: state.funding_observer_paused, }), observer_age_ms: ageMs(state.funding_observer_last_refresh_at, now), }; } function buildObserverHealth(observerHealth, { now, fundingObserverPaused }) { return Object.fromEntries( Object.entries(observerHealth || {}).map(([chain, health]) => [ chain, { ...health, paused: fundingObserverPaused || health?.paused || false, age_ms: ageMs(health?.refreshed_at, now), }, ]), ); } function ageMs(from, to) { const left = Date.parse(from || ''); const right = Date.parse(to || ''); if (!Number.isFinite(left) || !Number.isFinite(right)) return null; return Math.max(0, right - left); }