diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index 00093fc..6a85e9b 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -11,21 +11,8 @@ data: NEAR_INTENTS_BRIDGE_RPC_URL: https://bridge.chaindefuser.com/rpc NEAR_INTENTS_VERIFIER_CONTRACT: intents.near NEAR_RPC_URL: https://near.lava.build - NEAR_INTENTS_PAIR_FILTER: nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near NEAR_INTENTS_STATUS_POLL_MS: "60000" NEAR_INTENTS_ACCOUNT_ID: unrip-dev.near - TRADING_BTC_ASSET_ID: nep141:nbtc.bridge.near - TRADING_BTC_TRACKED_ASSET_IDS: nep141:nbtc.bridge.near,nep141:btc.omft.near - TRADING_BTC_SYMBOL: BTC - TRADING_BTC_LABEL: BTC / nBTC reserve - TRADING_BTC_DECIMALS: "8" - TRADING_BTC_CHAIN: btc:mainnet - TRADING_BTC_WITHDRAW_ADDRESS: "" - TRADING_EURE_ASSET_ID: nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near - TRADING_EURE_SYMBOL: EURe - TRADING_EURE_DECIMALS: "18" - TRADING_EURE_CHAIN: "eth:100" - TRADING_EURE_WITHDRAW_ADDRESS: "0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb" NEAR_INTENTS_CONTROL_API_ENABLED: "true" NEAR_INTENTS_CONTROL_HOST: 0.0.0.0 NEAR_INTENTS_CONTROL_PORT: "8081" @@ -53,6 +40,7 @@ data: STRATEGY_ENGINE_CONTROL_BASE_URL: http://strategy-engine.unrip.svc.cluster.local:8086 TRADE_EXECUTOR_CONTROL_BASE_URL: http://trade-executor.unrip.svc.cluster.local:8087 OPS_SENTINEL_CONTROL_BASE_URL: http://ops-sentinel.unrip.svc.cluster.local:8088 + OPERATOR_DASHBOARD_CONTROL_BASE_URL: http://operator-dashboard.unrip.svc.cluster.local:8090 NOTIFICATION_NTFY_BASE_URL: http://ntfy.utility.svc.cluster.local NOTIFICATION_NTFY_TOPIC: unrip NOTIFICATION_NTFY_TIMEOUT_MS: "5000" @@ -86,23 +74,12 @@ data: MARKET_REFERENCE_COINGECKO_URL: https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur INVENTORY_SYNC_REFRESH_MS: "15000" LIQUIDITY_REFRESH_MS: "30000" - STRATEGY_GROSS_THRESHOLD_PCT: "0.49" STRATEGY_INITIAL_ARMED: "false" - STRATEGY_MAX_NOTIONAL_EURE: "150" - STRATEGY_PRICE_MAX_AGE_MS: "30000" - STRATEGY_INVENTORY_MAX_AGE_MS: "30000" EXECUTOR_INITIAL_ARMED: "false" EXECUTOR_RESPONSE_TIMEOUT_MS: "10000" - INTENT_REQUEST_DEFAULT_AMOUNT_EURE: "5" - INTENT_REQUEST_MAX_AMOUNT_EURE: "5" - INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS: "200" - INTENT_REQUEST_MAX_SLIPPAGE_BPS: "200" - INTENT_REQUEST_MIN_DEADLINE_MS: "60000" INTENT_REQUEST_QUOTE_TIMEOUT_MS: "10000" INTENT_REQUEST_PUBLISH_TIMEOUT_MS: "10000" INTENT_REQUEST_STATUS_TIMEOUT_MS: "10000" - INTENT_REQUEST_INVENTORY_MAX_AGE_MS: "30000" - INTENT_REQUEST_PRICE_MAX_AGE_MS: "30000" LIQUIDITY_WITHDRAWALS_FROZEN: "true" BTC_FUNDING_OBSERVER_ENABLED: "true" BTC_FUNDING_OBSERVER_BASE_URL: https://mempool.space/api diff --git a/package.json b/package.json index 2de2610..666d681 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "strategy:engine": "node src/apps/strategy-engine.mjs", "trade:executor": "node src/apps/trade-executor.mjs", "operator:dashboard": "node src/apps/operator-dashboard.mjs", + "assets:import": "node src/apps/supported-token-importer.mjs", "operator-dashboard:build": "vite build --config vite.operator-dashboard.config.mjs", "operator-dashboard:dev": "bash scripts/dev/operator-dashboard-dev.sh", "operator-dashboard:forward": "bash scripts/dev/operator-dashboard-forward.sh", diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index 442c47e..f88a87f 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -18,6 +18,7 @@ import { parseEventMessage } from '../core/event-envelope.mjs'; import { loadConfig } from '../lib/config.mjs'; import { claimNotificationDelivery, + createTradingConfigStore, createPostgresPool, ensureHistorySchema, insertEnvironmentStatusChange, @@ -27,6 +28,7 @@ import { loadPortfolioMetricInputs, refreshIntentRequestOutcomes, refreshQuoteOutcomes, + seedTradingConfig, upsertPortfolioMetric, } from '../lib/postgres.mjs'; @@ -41,6 +43,12 @@ const pool = createPostgresPool({ connectionString: config.postgresUrl, }); await ensureHistorySchema(pool); +await seedTradingConfig(pool); +const tradingConfigStore = createTradingConfigStore({ + pool, + logger: logger.child({ component: 'trading-config' }), +}); +await tradingConfigStore.forceRefresh(); const notificationLogger = logger.child({ component: 'notifications' }); const notificationClient = createNtfyNotificationClient({ @@ -282,16 +290,20 @@ const controlApi = startControlApi({ return { ...state, database_connectivity: connectivity, + trading_config: tradingConfigStore.getState(), }; }, }, healthProvider: { async getHealth() { const connectivity = await pool.query('SELECT 1').then(() => true).catch(() => false); + const tradingConfig = tradingConfigStore.getState(); const lastTruthAt = state.last_write_at || state.last_metrics_at || null; const freshnessAgeMs = lastTruthAt ? Date.now() - new Date(lastTruthAt).getTime() : null; return { - ok: connectivity && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelHistoryWriterStaleMs), + ok: connectivity + && tradingConfig.ok === true + && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelHistoryWriterStaleMs), paused: state.paused, last_write_at: state.last_write_at, last_alert_write_at: state.last_alert_write_at, @@ -302,7 +314,12 @@ const controlApi = startControlApi({ last_metrics_at: state.last_metrics_at, freshness_age_ms: Number.isFinite(freshnessAgeMs) ? Math.max(0, freshnessAgeMs) : null, database_connectivity: connectivity, + trading_config_ok: tradingConfig.ok, + trading_config_block_reason: tradingConfig.block_reason, last_error: state.last_error, + reason: tradingConfig.ok === true + ? null + : tradingConfig.block_reason || 'trading config unavailable', }; }, }, @@ -357,19 +374,20 @@ const controlApi = startControlApi({ }); async function refreshPortfolioMetrics() { + const tradingConfig = await requireTradingConfig(); const inputs = await loadPortfolioMetricInputs(pool, { - btcAsset: config.tradingBtc, - btcAssets: config.tradingBtcAssets, - eureAsset: config.tradingEure, + btcAsset: tradingConfig.tradingBtc, + btcAssets: tradingConfig.tradingBtcAssets, + eureAsset: tradingConfig.tradingEure, }); const payload = computePortfolioMetric({ baseline: inputs.baseline, currentInventory: inputs.currentInventory?.payload, currentPrice: inputs.currentPrice?.payload, externalFlows: inputs.externalFlows || [], - btcAsset: config.tradingBtc, - btcAssets: config.tradingBtcAssets, - eureAsset: config.tradingEure, + btcAsset: tradingConfig.tradingBtc, + btcAssets: tradingConfig.tradingBtcAssets, + eureAsset: tradingConfig.tradingEure, commandCount: inputs.commandCount, resultCount: inputs.resultCount, }); @@ -401,9 +419,10 @@ async function refreshPortfolioMetrics() { } async function refreshIntentRequestOutcomeAttributions() { + const tradingConfig = await requireTradingConfig(); const records = await refreshIntentRequestOutcomes(pool, { - btcAsset: config.tradingBtc, - eureAsset: config.tradingEure, + btcAsset: tradingConfig.tradingBtc, + eureAsset: tradingConfig.tradingEure, }); state.last_intent_request_outcomes_at = new Date().toISOString(); state.latest_intent_request_outcomes = { @@ -417,9 +436,10 @@ async function refreshIntentRequestOutcomeAttributions() { } async function refreshQuoteOutcomeAttributions() { + const tradingConfig = await requireTradingConfig(); const records = await refreshQuoteOutcomes(pool, { - btcAsset: config.tradingBtc, - eureAsset: config.tradingEure, + btcAsset: tradingConfig.tradingBtc, + eureAsset: tradingConfig.tradingEure, }); state.last_quote_outcomes_at = new Date().toISOString(); state.quote_outcomes_error = null; @@ -432,6 +452,14 @@ async function refreshQuoteOutcomeAttributions() { return records; } +async function requireTradingConfig() { + const tradingConfig = await tradingConfigStore.getConfig(); + if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) { + throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`); + } + return tradingConfig; +} + async function publishLiquidityNotification({ topic, event }) { if (topic !== config.kafkaTopicOpsLiquidityAction) return; const notification = buildLiquidityActionNotification({ event, config }); diff --git a/src/apps/inventory-sync.mjs b/src/apps/inventory-sync.mjs index ac7212e..c5f987d 100644 --- a/src/apps/inventory-sync.mjs +++ b/src/apps/inventory-sync.mjs @@ -18,6 +18,12 @@ import { assertLiquidityActionEvent, } from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; +import { + createPostgresPool, + createTradingConfigStore, + ensureHistorySchema, + seedTradingConfig, +} from '../lib/postgres.mjs'; import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs'; import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs'; @@ -48,12 +54,17 @@ const producer = await createProducer({ 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' }), +}); +await tradingConfigStore.forceRefresh(); -const chains = uniqueChainsForAssets(config.trackedAssets); -const fallbackAssetByChain = new Map([ - [config.tradingBtc.chain, config.tradingBtc.assetId], - [config.tradingEure.chain, config.tradingEure.assetId], -]); const consumer = await createConsumer({ groupId: config.kafkaConsumerGroupInventory, brokers: config.kafkaBrokers, @@ -126,9 +137,15 @@ async function refresh() { if (state.paused) return; try { + const tradingConfig = await tradingConfigStore.getConfig(); + if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`); + const chains = uniqueChainsForAssets(tradingConfig.trackedAssets); + const fallbackAssetByChain = new Map( + tradingConfig.trackedAssets.map((asset) => [asset.chain, asset.assetId]), + ); const balances = await verifierClient.mtBatchBalanceOf({ accountId: config.nearIntentsAccountId, - tokenIds: config.trackedAssetIds, + tokenIds: tradingConfig.trackedAssetIds, }); const recentDeposits = []; for (const chain of chains) { @@ -138,7 +155,7 @@ async function refresh() { }); for (const deposit of response?.deposits || []) { const assetId = bridgeDepositAssetId(deposit, { - assetRegistry: config.assetRegistry, + assetRegistry: tradingConfig.assetRegistry, fallbackAssetId: fallbackAssetByChain.get(chain), }); recentDeposits.push({ @@ -163,7 +180,7 @@ async function refresh() { balances, recentDeposits, trackedWithdrawals: Object.values(state.tracked_withdrawals), - assetRegistry: config.assetRegistry, + assetRegistry: tradingConfig.assetRegistry, observedAt: new Date().toISOString(), }); state.last_snapshot = snapshot; @@ -184,7 +201,7 @@ async function refresh() { state.last_error = serializeError(error); logger.error('inventory_refresh_failed', { topic: config.kafkaTopicStateIntentInventory, - pair: config.activePair, + pair: tradingConfig.activePair, details: { error: serializeError(error), }, @@ -206,6 +223,7 @@ const controlApi = startControlApi({ getState() { return { account_id: config.nearIntentsAccountId, + trading_config: tradingConfigStore.getState(), ...state, funding_visibility: buildFundingVisibility( Object.values(state.funding_observations), @@ -248,6 +266,7 @@ async function shutdown() { await controlApi.close().catch(() => {}); await consumer.disconnect(); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } diff --git a/src/apps/liquidity-manager.mjs b/src/apps/liquidity-manager.mjs index 7ebb55b..79403e0 100644 --- a/src/apps/liquidity-manager.mjs +++ b/src/apps/liquidity-manager.mjs @@ -22,6 +22,12 @@ 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'; @@ -48,6 +54,26 @@ const producer = await createProducer({ 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, @@ -89,15 +115,15 @@ const store = createJsonStateStore({ }, }); -const chains = uniqueChainsForAssets(config.trackedAssets); -const trackedAssetsByChain = groupAssetsByChain(config.trackedAssets); +const chains = uniqueChainsForAssets(runtimeConfig.trackedAssets); +const trackedAssetsByChain = groupAssetsByChain(runtimeConfig.trackedAssets); const fallbackAssetByChain = new Map([ - [config.tradingBtc.chain, config.tradingBtc.assetId], - [config.tradingEure.chain, config.tradingEure.assetId], + [runtimeConfig.tradingBtc.chain, runtimeConfig.tradingBtc.assetId], + [runtimeConfig.tradingEure.chain, runtimeConfig.tradingEure.assetId], ]); const fundingObserverByChain = new Map( - btcAddressObserver ? [[config.tradingBtc.chain, btcAddressObserver]] : [], + btcAddressObserver ? [[runtimeConfig.tradingBtc.chain, btcAddressObserver]] : [], ); async function refresh() { @@ -401,7 +427,7 @@ async function estimateWithdrawal({ assetId, amount, destinationAddress, chain = destinationAddress, chain, supportedTokens: state.supported_tokens, - config, + config: runtimeConfig, }); const estimate = await bridgeClient.withdrawalEstimate({ chain: plan.chain, @@ -783,6 +809,7 @@ async function shutdown() { clearInterval(timer); await controlApi.close().catch(() => {}); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } @@ -800,7 +827,7 @@ function mapSupportedTokens(tokens) { function mapDepositAssetId(deposit, chain) { return bridgeDepositAssetId(deposit, { - assetRegistry: config.assetRegistry, + assetRegistry: runtimeConfig.assetRegistry, fallbackAssetId: fallbackAssetByChain.get(chain), }); } @@ -831,9 +858,10 @@ function buildPublicState() { return { account_id: config.nearIntentsAccountId, - tracked_assets: config.trackedAssets, + tracked_assets: runtimeConfig.trackedAssets, + trading_config: tradingConfigStore.getState(), withdrawal_defaults: Object.fromEntries( - config.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]), + runtimeConfig.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]), ), ...state, observer_health: buildObserverHealth(state.observer_health, { diff --git a/src/apps/market-reference-ingest.mjs b/src/apps/market-reference-ingest.mjs index c6fbd70..24d9d71 100644 --- a/src/apps/market-reference-ingest.mjs +++ b/src/apps/market-reference-ingest.mjs @@ -7,6 +7,12 @@ import { createLogger, serializeError } from '../core/log.mjs'; import { assertMarketPriceEvent } from '../core/schemas.mjs'; import { fetchCoinGeckoBtcEur, fetchKrakenBtcEur } from '../lib/market-data.mjs'; import { loadConfig } from '../lib/config.mjs'; +import { + createPostgresPool, + createTradingConfigStore, + ensureHistorySchema, + seedTradingConfig, +} from '../lib/postgres.mjs'; const config = loadConfig(); const logger = createLogger({ @@ -21,6 +27,16 @@ const producer = await createProducer({ 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' }), +}); +await tradingConfigStore.forceRefresh(); const state = { paused: false, @@ -43,7 +59,7 @@ async function refresh() { const now = Date.now(); await refreshKraken(now).catch((error) => { logger.warn('kraken_refresh_failed', { - pair: config.activePair, + pair: tradingConfigStore.getState().active_pair, details: { error: serializeError(error), }, @@ -54,7 +70,9 @@ async function refresh() { coingeckoDueAt = now + config.marketReferenceCoinGeckoRefreshMs; } - const event = buildPriceEvent(now); + const tradingConfig = await tradingConfigStore.getConfig(); + if (!tradingConfig.ok) throw new Error(`trading config unavailable: ${tradingConfig.blockReason}`); + const event = buildPriceEvent(now, { tradingConfig }); assertMarketPriceEvent(event); await producer.sendJson(config.kafkaTopicRefMarketPrice, event, { key: event.payload.price_id }); state.last_published_at = new Date(now).toISOString(); @@ -65,7 +83,7 @@ async function refresh() { state.last_publish_error = serializeError(error); logger.error('reference_refresh_failed', { topic: config.kafkaTopicRefMarketPrice, - pair: config.activePair, + pair: tradingConfigStore.getState().active_pair, details: { error: serializeError(error), }, @@ -110,7 +128,7 @@ async function refreshCoinGecko(now) { error: serializeError(error), }; logger.warn('coingecko_refresh_failed', { - pair: config.activePair, + pair: tradingConfigStore.getState().active_pair, details: { error: serializeError(error), }, @@ -118,9 +136,13 @@ async function refreshCoinGecko(now) { } } -function buildPriceEvent(now) { +function buildPriceEvent(now, { tradingConfig }) { const sourceUsed = chooseSource(now); if (!sourceUsed) throw new Error('No fresh reference price available'); + const referencePair = tradingConfig.pairs.find((pair) => ( + pair.priceRoute?.source === 'btc_eur_reference' && pair.canTrade + )); + if (!referencePair) throw new Error('No DB-enabled BTC/EUR price route available'); const eurPerBtc = sourceUsed === 'kraken' ? state.kraken.price @@ -137,7 +159,11 @@ function buildPriceEvent(now) { observedAt: new Date(now).toISOString(), payload: { price_id: `price-${now}`, - pair: config.activePair, + pair: referencePair.key, + pair_id: referencePair.pairId, + price_route_id: referencePair.priceRoute.routeId, + base_asset_id: referencePair.priceRoute.baseAssetId, + quote_asset_id: referencePair.priceRoute.quoteAssetId, eur_per_btc: eurPerBtc.toFixed(8), eure_per_btc: eurPerBtc.toFixed(8), btc_per_eur: btcPerEur.toFixed(12), @@ -189,7 +215,23 @@ const controlApi = startControlApi({ getState() { return { ...state, - active_pair: config.activePair, + active_pair: tradingConfigStore.getState().active_pair, + trading_config: tradingConfigStore.getState(), + }; + }, + }, + healthProvider: { + getHealth() { + const tradingConfig = tradingConfigStore.getState(); + return { + ok: tradingConfig.ok === true && !state.last_publish_error, + trading_config_ok: tradingConfig.ok, + trading_config_block_reason: tradingConfig.block_reason, + paused: state.paused, + last_published_at: state.last_published_at, + reason: tradingConfig.ok !== true + ? tradingConfig.block_reason || 'trading config unavailable' + : state.last_publish_error?.message || null, }; }, }, @@ -211,7 +253,7 @@ const controlApi = startControlApi({ handler: () => { state.paused = true; logger.warn('polling_paused', { - pair: config.activePair, + pair: tradingConfigStore.getState().active_pair, }); return { ok: true, paused: true }; }, @@ -222,7 +264,7 @@ const controlApi = startControlApi({ handler: async () => { state.paused = false; logger.info('polling_resumed', { - pair: config.activePair, + pair: tradingConfigStore.getState().active_pair, }); await refresh(); return { ok: true, paused: false }; @@ -235,6 +277,7 @@ async function shutdown() { clearInterval(timer); await controlApi.close().catch(() => {}); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } diff --git a/src/apps/near-intents-ingest.mjs b/src/apps/near-intents-ingest.mjs index a9adc4d..efc2462 100644 --- a/src/apps/near-intents-ingest.mjs +++ b/src/apps/near-intents-ingest.mjs @@ -3,8 +3,13 @@ import process from 'node:process'; import { createProducer } from '../bus/kafka/producer.mjs'; import { startControlApi } from '../core/control-api.mjs'; import { createLogger } from '../core/log.mjs'; -import { createPairFilterController } from '../core/pair-filter.mjs'; import { loadConfig } from '../lib/config.mjs'; +import { + createPostgresPool, + createTradingConfigStore, + ensureHistorySchema, + seedTradingConfig, +} from '../lib/postgres.mjs'; import { startNearIntentsWs } from '../venues/near-intents/ws.mjs'; import { ageMs } from '../core/runtime-health.mjs'; @@ -14,17 +19,20 @@ const logger = createLogger({ component: 'ingest', namespace: config.projectNamespace, }); -const pairFilterController = createPairFilterController({ - argv: process.argv.slice(2), - env: process.env, - defaultPairFilter: config.nearIntentsPairFilter, - pairFilterFile: config.nearIntentsPairFilterFile, - reloadEveryMs: config.nearIntentsPairFilterReloadMs, +void process.argv; + +const configPool = createPostgresPool({ + connectionString: config.postgresUrl, +}); +await ensureHistorySchema(configPool); +await seedTradingConfig(configPool); +const tradingConfigStore = createTradingConfigStore({ + pool: configPool, logger: logger.child({ - component: 'filter', - venue: 'near-intents', + component: 'trading-config', }), }); +await tradingConfigStore.forceRefresh(); if (!config.nearIntentsApiKey) { logger.error('missing_api_key', { @@ -45,7 +53,11 @@ const producer = await createProducer({ const wsRuntime = await startNearIntentsWs({ apiKey: config.nearIntentsApiKey, wsUrl: config.nearIntentsWsUrl, - getPairFilter: () => pairFilterController.getPairFilter(), + matchesPair: async (assetIn, assetOut) => { + const tradingConfig = await tradingConfigStore.getConfig(); + if (!tradingConfig.ok) return false; + return tradingConfig.enabledPairKeys.has(`${assetIn}->${assetOut}`); + }, producer, rawTopic: config.kafkaTopicRawNearIntentsQuote, normalizedTopic: config.kafkaTopicNormSwapDemand, @@ -69,7 +81,7 @@ const controlApi = config.nearIntentsControlApiEnabled stateProvider: { getState() { return { - pair_filter: pairFilterController.getState(), + trading_config: tradingConfigStore.getState(), ingest: wsRuntime.getState(), }; }, @@ -77,19 +89,26 @@ const controlApi = config.nearIntentsControlApiEnabled healthProvider: { getHealth() { const ingest = wsRuntime.getState(); + const tradingConfig = tradingConfigStore.getState(); const lastTruthAt = ingest.last_published_at || ingest.last_matching_quote_at || ingest.last_message_at; const freshnessAgeMs = ageMs(lastTruthAt); const staleAfterMs = config.opsSentinelIngestQuoteStaleMs; return { - ok: Boolean(ingest.connected) && (freshnessAgeMs == null || freshnessAgeMs <= staleAfterMs), + ok: Boolean(ingest.connected) + && tradingConfig.ok === true + && (freshnessAgeMs == null || freshnessAgeMs <= staleAfterMs), connected: ingest.connected, + trading_config_ok: tradingConfig.ok, + trading_config_block_reason: tradingConfig.block_reason, last_message_at: ingest.last_message_at, last_matching_quote_at: ingest.last_matching_quote_at, last_published_at: ingest.last_published_at, freshness_age_ms: freshnessAgeMs, stale_after_ms: staleAfterMs, reason: - ingest.connected + tradingConfig.ok !== true + ? tradingConfig.block_reason || 'trading config unavailable' + : ingest.connected ? freshnessAgeMs != null && freshnessAgeMs > staleAfterMs ? 'quote truth stale' : null @@ -108,34 +127,33 @@ const controlApi = config.nearIntentsControlApiEnabled }, { method: 'GET', - path: '/pair-filter', + path: '/pairs', readBody: false, - handler: () => pairFilterController.getState(), - }, - { - method: 'PUT', - path: '/pair-filter', - handler: ({ body }) => { - if (body.disabled === true || body.enabled === false || body.pair == null) { - return pairFilterController.disable(); - } - - if (typeof body.pair !== 'string') { - return { - statusCode: 400, - payload: { - error: 'send JSON like {"pair":"asset_a->asset_b"} or {"pair":null}', - }, - }; - } - - return pairFilterController.setPairFilter(body.pair); + handler: async () => { + const tradingConfig = await tradingConfigStore.forceRefresh(); + return { + ok: tradingConfig.ok, + pairs: tradingConfig.observedPairs.map((pair) => ({ + pair_id: pair.pairId, + pair: pair.key, + mode: pair.mode, + status: pair.status, + can_trade: pair.canTrade, + block_reason: pair.blockReason, + })), + }; }, }, { method: 'POST', - path: '/pair-filter/reset', - handler: () => pairFilterController.reset(), + path: '/config/refresh', + handler: async () => { + const tradingConfig = await tradingConfigStore.forceRefresh(); + return { + ok: tradingConfig.ok, + trading_config: tradingConfigStore.getState(), + }; + }, }, ], }) @@ -144,8 +162,8 @@ const controlApi = config.nearIntentsControlApiEnabled async function shutdown() { controlApi && await controlApi.close().catch(() => {}); wsRuntime.close(); - pairFilterController.close(); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 37afc2d..e207c0c 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -29,12 +29,18 @@ import { readJsonBody, sendJson } from '../core/control-api.mjs'; import { loadConfig } from '../lib/config.mjs'; import { fetchJson } from '../lib/http.mjs'; import { + createPairStrategyConfigVersion, createPostgresPool, + createTradingConfigStore, + enableObserveOnlyPair, ensureHistorySchema, + importSupportedAssets, + loadAssetCatalogSummary, loadCurrentFundingObservations, loadLatestInventorySnapshot, loadLatestMarketPrice, loadLatestPortfolioMetric, + loadPairConfigSummary, loadRecentAlertTransitions, loadRecentDepositStatuses, loadRecentEnvironmentStatuses, @@ -46,6 +52,7 @@ import { loadRecentQuotes, loadSubmissionPage, loadSubmissionSummary, + seedTradingConfig, } from '../lib/postgres.mjs'; const config = loadConfig(); @@ -80,6 +87,13 @@ const pool = createPostgresPool({ connectionString: config.postgresUrl, }); await ensureHistorySchema(pool); +await seedTradingConfig(pool); +const tradingConfigStore = createTradingConfigStore({ + pool, + logger: logger.child({ component: 'trading-config' }), +}); +const initialTradingConfig = await tradingConfigStore.forceRefresh(); +const initialRuntimeConfig = buildRuntimeConfig(initialTradingConfig); const staticAssets = await loadStaticAssets(); const initialServiceSnapshots = await loadServiceSnapshots(); @@ -127,12 +141,12 @@ const initialRecentQuoteOutcomes = await safeSourceLoad( ); const initialNearIntentsStatus = await safeSourceLoad( 'near_intents_status', - () => loadNearIntentsStatus(), + () => loadNearIntentsStatus(initialRuntimeConfig), null, ); const liveState = createDashboardLiveState({ - config, + config: initialRuntimeConfig, recentQuotes: initialRecentQuotes, recentTradeDecisions: initialRecentTradeDecisions, recentExecuteTradeCommands: initialRecentExecuteTradeCommands, @@ -361,7 +375,9 @@ async function handleApiRequest({ req, res, url, auth }) { .find((definition) => definition.service === control.service); try { const result = await invokeControl(control, body || {}); - const serviceSnapshot = await loadServiceSnapshot(serviceDefinition); + const serviceSnapshot = serviceDefinition + ? await loadServiceSnapshot(serviceDefinition) + : null; return sendJson(res, 200, { ok: true, control, @@ -375,13 +391,15 @@ async function handleApiRequest({ req, res, url, auth }) { error: serializeError(error), }, }); - const serviceSnapshot = await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({ - ...serviceDefinition, - reachable: false, - state: null, - health: null, - error: serializeError(snapshotError), - })); + const serviceSnapshot = serviceDefinition + ? await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({ + ...serviceDefinition, + reachable: false, + state: null, + health: null, + error: serializeError(snapshotError), + })) + : null; const failure = buildDashboardControlErrorResponse(error, { control }); return sendJson(res, failure.statusCode, { ...failure.payload, @@ -395,6 +413,8 @@ async function handleApiRequest({ req, res, url, auth }) { async function loadBootstrapPayload({ auth, page, pageSize }) { const sourceErrors = []; + const tradingConfig = await tradingConfigStore.forceRefresh(); + const runtimeConfig = buildRuntimeConfig(tradingConfig); const [ portfolioMetric, inventorySnapshot, @@ -411,6 +431,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentIntentRequests, recentAlertTransitions, recentEnvironmentStatuses, + assetCatalog, + pairConfig, serviceSnapshots, nearIntentsStatus, ] = await Promise.all([ @@ -420,7 +442,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { safeSourceLoad( 'recent_quotes', () => loadRecentQuotes(pool, { - limit: config.operatorDashboardQuoteLimit, + limit: runtimeConfig.operatorDashboardQuoteLimit, }), [], sourceErrors, @@ -481,8 +503,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { 'recent_intent_requests', () => loadRecentIntentRequests(pool, { limit: 20, - btcAsset: config.tradingBtc, - eureAsset: config.tradingEure, + btcAsset: runtimeConfig.tradingBtc, + eureAsset: runtimeConfig.tradingEure, }), [], sourceErrors, @@ -499,12 +521,14 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { [], sourceErrors, ), + safeSourceLoad('asset_catalog', () => loadAssetCatalogSummary(pool, { limit: 80 }), null, sourceErrors), + safeSourceLoad('pair_config', () => loadPairConfigSummary(pool), null, sourceErrors), loadServiceSnapshots(), - safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(), null, sourceErrors), + safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(runtimeConfig), null, sourceErrors), ]); const payload = buildDashboardBootstrap({ - config, + config: runtimeConfig, auth, portfolioMetric, inventorySnapshot, @@ -521,6 +545,8 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentIntentRequests, recentAlertTransitions, recentEnvironmentStatuses, + assetCatalog, + pairConfig, serviceSnapshots, nearIntentsStatus, sourceErrors, @@ -564,7 +590,7 @@ async function fetchUpstreamJson(url) { }); } -async function loadNearIntentsStatus() { +async function loadNearIntentsStatus(runtimeConfig = config) { const [servicesResponse, postsResponse, postEnumsResponse] = await Promise.all([ fetchNearIntentsStatusJson(config.nearIntentsStatusServicesUrl), fetchNearIntentsStatusJson(config.nearIntentsStatusPostsUrl), @@ -576,7 +602,7 @@ async function loadNearIntentsStatus() { postsResponse, postEnumsResponse, observedAt: new Date().toISOString(), - trackedAssets: config.trackedAssets, + trackedAssets: runtimeConfig.trackedAssets, }); } @@ -587,6 +613,34 @@ async function fetchNearIntentsStatusJson(url) { } async function invokeControl(control, body) { + if (control.service === 'operator-dashboard' && control.action === 'import-supported-assets') { + const result = await importSupportedAssets(pool); + await tradingConfigStore.forceRefresh(); + return result; + } + + if (control.service === 'operator-dashboard' && control.action === 'update-pair-edge') { + const result = await createPairStrategyConfigVersion(pool, { + pairId: body.pair_id || body.pair, + edgeBps: Number(body.edge_bps), + changedBy: body.changed_by || 'operator', + reason: body.reason || 'dashboard edge update', + }); + await tradingConfigStore.forceRefresh(); + return result; + } + + if (control.service === 'operator-dashboard' && control.action === 'enable-observe-only-pair') { + const result = await enableObserveOnlyPair(pool, { + assetIn: body.asset_in, + assetOut: body.asset_out, + changedBy: body.changed_by || 'operator', + reason: body.reason || 'dashboard observe-only enable', + }); + await tradingConfigStore.forceRefresh(); + return result; + } + const response = await fetchJson( `${lookupServiceBaseUrl(control.service)}${control.path}`, { @@ -610,6 +664,22 @@ function lookupServiceBaseUrl(serviceName) { return service.base_url; } +function buildRuntimeConfig(tradingConfig) { + return { + ...config, + ...tradingConfig, + assetRegistry: tradingConfig.assetRegistry || config.assetRegistry, + trackedAssets: tradingConfig.trackedAssets || config.trackedAssets, + trackedAssetIds: tradingConfig.trackedAssetIds || config.trackedAssetIds, + tradingBtc: tradingConfig.tradingBtc || config.tradingBtc, + tradingBtcAssets: tradingConfig.tradingBtcAssets?.length + ? tradingConfig.tradingBtcAssets + : config.tradingBtcAssets, + tradingEure: tradingConfig.tradingEure || config.tradingEure, + activePair: tradingConfig.activePair || config.activePair, + }; +} + function broadcast(payload) { const encoded = JSON.stringify(payload); for (const socket of webSockets) { diff --git a/src/apps/ops-sentinel.mjs b/src/apps/ops-sentinel.mjs index 17266fa..6068cef 100644 --- a/src/apps/ops-sentinel.mjs +++ b/src/apps/ops-sentinel.mjs @@ -20,6 +20,7 @@ import { shouldContainExecutorForAlerts, shouldRaiseIngestPublishStale, } from '../core/runtime-health.mjs'; +import { summarizeServiceSnapshotForSentinel } from '../core/service-snapshot-summary.mjs'; import { assertEnvironmentStatusEvent, assertFundingObservationEvent, @@ -31,14 +32,38 @@ import { } from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; import { fetchJson } from '../lib/http.mjs'; +import { + createPostgresPool, + createTradingConfigStore, + ensureHistorySchema, + seedTradingConfig, +} from '../lib/postgres.mjs'; const config = loadConfig(); -const thresholds = createRuntimeHealthThresholds(config); const logger = createLogger({ service: 'ops-sentinel', component: 'alerts', namespace: config.projectNamespace, }); +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(); +Object.assign(config, { + ...initialTradingConfig, + assetRegistry: initialTradingConfig.assetRegistry || config.assetRegistry, + trackedAssets: initialTradingConfig.trackedAssets?.length + ? initialTradingConfig.trackedAssets + : config.trackedAssets, + activePair: initialTradingConfig.activePair || config.activePair, +}); +const thresholds = createRuntimeHealthThresholds(config); const producer = await createProducer({ brokers: config.kafkaBrokers, @@ -183,7 +208,7 @@ const controlApi = startControlApi({ last_runtime_eval_at: state.last_runtime_eval_at, service_snapshots: state.service_snapshots, service_health: state.service_health, - latest_runtime_alerts: [], + latest_runtime_alerts: state.latest_runtime_alerts, near_intents_status: state.near_intents_status, last_environment_status_poll_at: state.last_environment_status_poll_at, last_environment_status_publish_at: state.last_environment_status_publish_at, @@ -193,8 +218,9 @@ const controlApi = startControlApi({ environment_status_publish_count: state.environment_status_publish_count, containment: state.containment, notifier: notifier.getState(), + trading_config: tradingConfigStore.getState(), anomaly_samples: state.anomaly_samples.slice(-thresholds.anomalyWindowSize), - active_alerts: [], + active_alerts: state.latest_runtime_alerts, recent_transitions: [], }; }, @@ -203,13 +229,20 @@ const controlApi = startControlApi({ getHealth() { const staleMs = ageMs(state.last_runtime_eval_at); return { - ok: !state.paused && (staleMs == null || staleMs <= thresholds.sentinelStaleMs), + ok: !state.paused + && tradingConfigStore.getState().ok === true + && (staleMs == null || staleMs <= thresholds.sentinelStaleMs), paused: state.paused, + trading_config_ok: tradingConfigStore.getState().ok, + trading_config_block_reason: tradingConfigStore.getState().block_reason, last_event_at: state.last_event_at, last_runtime_eval_at: state.last_runtime_eval_at, last_error: state.last_error, stale: staleMs != null && staleMs > thresholds.sentinelStaleMs, stale_after_ms: thresholds.sentinelStaleMs, + reason: tradingConfigStore.getState().ok === true + ? null + : tradingConfigStore.getState().block_reason || 'trading config unavailable', }; }, }, @@ -247,7 +280,7 @@ async function evaluateRuntimeHealthLoop() { const now = new Date().toISOString(); const previousRuntimeEvalAt = state.last_runtime_eval_at; const serviceSnapshots = await Promise.all(monitoredServices.map(loadServiceSnapshot)); - state.service_snapshots = serviceSnapshots; + state.service_snapshots = serviceSnapshots.map(summarizeServiceSnapshotForSentinel); state.last_runtime_eval_at = now; const servicesByName = Object.fromEntries(serviceSnapshots.map((snapshot) => [snapshot.service, snapshot])); @@ -257,10 +290,10 @@ async function evaluateRuntimeHealthLoop() { state.service_health = [...evaluateRuntimeHealth({ servicesByName, activePair: config.activePair, - activeAlerts: [], + activeAlerts: desiredRuntimeAlerts, now, }).values()]; - state.latest_runtime_alerts = []; + state.latest_runtime_alerts = desiredRuntimeAlerts; state.containment.executor_auto_disarmed = null; state.containment.last_action_at = now; state.containment.last_action_reason = 'automatic_executor_containment_disabled'; @@ -759,6 +792,7 @@ async function shutdown() { await controlApi.close().catch(() => {}); await consumer.disconnect(); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index e81bbaa..0884ebc 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -9,6 +9,13 @@ import { createLogger, serializeError } from '../core/log.mjs'; import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs'; import { evaluateTradeOpportunity } from '../core/strategy.mjs'; import { loadConfig } from '../lib/config.mjs'; +import { + createPairStrategyConfigVersion, + createPostgresPool, + createTradingConfigStore, + ensureHistorySchema, + seedTradingConfig, +} from '../lib/postgres.mjs'; const config = loadConfig(); const logger = createLogger({ @@ -29,6 +36,16 @@ const producer = await createProducer({ 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' }), +}); +await tradingConfigStore.forceRefresh(); const armedStateStore = createArmedStateStore({ stateDir: config.strategyStateDir, fileName: 'strategy-engine-control.json', @@ -42,8 +59,6 @@ await consumer.subscribe({ topic: config.kafkaTopicStateIntentInventory, fromBeg const state = { armed: armedStateStore.isArmed(), paused: false, - threshold_pct: config.strategyGrossThresholdPct, - max_notional_eure: config.strategyMaxNotionalEure, latest_price_event: null, latest_inventory_event: null, latest_decision: null, @@ -87,18 +102,25 @@ await consumer.run({ async function handleDemand(event) { if (state.paused) return; + const tradingConfig = await tradingConfigStore.getConfig(); if (state.seen_quotes[event.payload.quote_id]) { + const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`); + const strategyConfig = pair?.strategyConfig || null; await publishDecision({ decision_id: `duplicate-${event.payload.quote_id}`, quote_id: event.payload.quote_id, pair: event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`, + pair_id: pair?.pairId || null, + pair_config_id: strategyConfig?.configId || null, + pair_config_version: strategyConfig?.version == null ? null : String(strategyConfig.version), + edge_bps: strategyConfig?.edgeBps == null ? null : String(strategyConfig.edgeBps), direction: 'duplicate', request_kind: event.payload.request_kind, decision: 'rejected', decision_reason: 'duplicate_quote_id', - threshold_pct: String(state.threshold_pct), - max_notional_eure: String(state.max_notional_eure), + threshold_pct: strategyConfig?.edgeBps == null ? null : String(Number(strategyConfig.edgeBps) / 100), + max_notional_eure: strategyConfig?.maxNotional == null ? null : String(strategyConfig.maxNotional), strategy_armed: state.armed, }); return; @@ -110,10 +132,11 @@ async function handleDemand(event) { demandEvent: event, priceEvent: state.latest_price_event, inventoryEvent: state.latest_inventory_event, - config, + config: { + ...config, + ...tradingConfig, + }, armed: state.armed, - thresholdPct: state.threshold_pct, - maxNotionalEure: state.max_notional_eure, }); await publishDecision(evaluation.decision); @@ -161,17 +184,33 @@ const controlApi = startControlApi({ getState() { return { ...state, + trading_config: tradingConfigStore.getState(), durable_control_state: armedStateStore.getState(), }; }, }, + healthProvider: { + getHealth() { + const tradingConfig = tradingConfigStore.getState(); + return { + ok: tradingConfig.ok === true, + trading_config_ok: tradingConfig.ok, + trading_config_block_reason: tradingConfig.block_reason, + paused: state.paused, + armed: state.armed, + reason: tradingConfig.ok === true + ? null + : tradingConfig.block_reason || 'trading config unavailable', + }; + }, + }, routes: [ { method: 'POST', path: '/arm', handler: () => { state.armed = armedStateStore.setArmed(true).armed; - logger.warn('strategy_armed', { pair: config.activePair }); + logger.warn('strategy_armed', { pair: tradingConfigStore.getState().active_pair }); return { ok: true, armed: true }; }, }, @@ -180,7 +219,7 @@ const controlApi = startControlApi({ path: '/disarm', handler: () => { state.armed = armedStateStore.setArmed(false).armed; - logger.warn('strategy_disarmed', { pair: config.activePair }); + logger.warn('strategy_disarmed', { pair: tradingConfigStore.getState().active_pair }); return { ok: true, armed: false }; }, }, @@ -211,27 +250,23 @@ const controlApi = startControlApi({ }, }, { - method: 'PUT', - path: '/threshold', - handler: ({ body }) => { - const next = Number(body.threshold_pct); - if (!Number.isFinite(next) || next <= 0) { - return { statusCode: 400, payload: { error: 'threshold_pct must be > 0' } }; + method: 'POST', + path: '/pair-config/edge', + handler: async ({ body }) => { + const pairId = body.pair_id || body.pair; + const edgeBps = Number(body.edge_bps); + if (!pairId) return { statusCode: 400, payload: { error: 'pair_id is required' } }; + if (!Number.isInteger(edgeBps) || edgeBps <= 0) { + return { statusCode: 400, payload: { error: 'edge_bps must be a positive integer' } }; } - state.threshold_pct = next; - return { ok: true, threshold_pct: next }; - }, - }, - { - method: 'PUT', - path: '/limits', - handler: ({ body }) => { - const next = Number(body.max_notional_eure); - if (!Number.isFinite(next) || next <= 0) { - return { statusCode: 400, payload: { error: 'max_notional_eure must be > 0' } }; - } - state.max_notional_eure = next; - return { ok: true, max_notional_eure: next }; + const nextConfig = await createPairStrategyConfigVersion(configPool, { + pairId, + edgeBps, + changedBy: body.changed_by || 'operator', + reason: body.reason || 'operator edge update', + }); + await tradingConfigStore.forceRefresh(); + return { ok: true, config: nextConfig, trading_config: tradingConfigStore.getState() }; }, }, ], @@ -241,6 +276,7 @@ async function shutdown() { await controlApi.close().catch(() => {}); await consumer.disconnect(); await producer.disconnect(); + await configPool.end().catch(() => {}); process.exit(0); } diff --git a/src/apps/supported-token-importer.mjs b/src/apps/supported-token-importer.mjs new file mode 100644 index 0000000..ee035c3 --- /dev/null +++ b/src/apps/supported-token-importer.mjs @@ -0,0 +1,42 @@ +import process from 'node:process'; + +import { createLogger, serializeError } from '../core/log.mjs'; +import { loadConfig } from '../lib/config.mjs'; +import { + createPostgresPool, + ensureHistorySchema, + importSupportedAssets, + seedTradingConfig, +} from '../lib/postgres.mjs'; + +const config = loadConfig(); +const logger = createLogger({ + service: 'supported-token-importer', + component: 'asset-registry', + namespace: config.projectNamespace, +}); + +const pool = createPostgresPool({ + connectionString: config.postgresUrl, +}); + +try { + await ensureHistorySchema(pool); + await seedTradingConfig(pool); + const result = await importSupportedAssets(pool); + logger.info('supported_token_import_completed', { + details: result, + }); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + logger.error('supported_token_import_failed', { + details: { + error: serializeError(error), + import_run: error.importRun || null, + }, + }); + if (error.importRun) console.error(JSON.stringify(error.importRun, null, 2)); + process.exitCode = 1; +} finally { + await pool.end().catch(() => {}); +} diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index 56c39f8..bfff7ac 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -17,6 +17,7 @@ import { import { loadConfig } from '../lib/config.mjs'; import { createPostgresPool, + createTradingConfigStore, ensureHistorySchema, insertHistoryEvent, loadIntentRequestPreflightByIdOrKey, @@ -25,6 +26,7 @@ import { loadLatestInventorySnapshot, loadLatestMarketPrice, refreshIntentRequestOutcomes, + seedTradingConfig, } from '../lib/postgres.mjs'; import { buildQuoteResponseSubmission } from '../venues/near-intents/signing.mjs'; import { startSolverRelayWs } from '../venues/near-intents/solver-relay-ws.mjs'; @@ -78,6 +80,12 @@ const requestPool = createPostgresPool({ connectionString: config.postgresUrl, }); await ensureHistorySchema(requestPool); +await seedTradingConfig(requestPool); +const tradingConfigStore = createTradingConfigStore({ + pool: requestPool, + logger: logger.child({ component: 'trading-config' }), +}); +await tradingConfigStore.forceRefresh(); const relayClient = await startSolverRelayWs({ apiKey: config.nearIntentsApiKey, wsUrl: config.nearIntentsWsUrl, @@ -125,6 +133,7 @@ const requestController = createIntentRequestController({ signer, isArmed: () => state.armed, isPaused: () => state.paused, + getTradingConfig: () => tradingConfigStore.getConfig(), withMakerSuppressed, logger: logger.child({ component: 'intent-request-controller' }), }); @@ -267,6 +276,12 @@ async function publishResult(command, extraPayload) { execution_key: command.execution_key, quote_id: command.quote_id, pair: command.pair, + pair_id: command.pair_id || null, + pair_config_id: command.pair_config_id || null, + pair_config_version: command.pair_config_version || null, + edge_bps: command.edge_bps || null, + max_notional: command.max_notional || null, + price_route_id: command.price_route_id || null, ...extraPayload, }, }); @@ -290,6 +305,7 @@ const controlApi = startControlApi({ signer_public_key: signer.getPublicKey().toString(), signer_registered: signerRegistered, relay: relayClient.getState(), + trading_config: tradingConfigStore.getState(), ...state, durable_control_state: armedStateStore.getState(), durable_state: stateStore.getState(), @@ -301,14 +317,20 @@ const controlApi = startControlApi({ const relay = relayClient.getState(); const freshnessAgeMs = ageMs(relay.last_message_at); return { - ok: relay.connected && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelExecutorRelayStaleMs), + ok: relay.connected + && tradingConfigStore.getState().ok === true + && (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelExecutorRelayStaleMs), connected: relay.connected, + trading_config_ok: tradingConfigStore.getState().ok, + trading_config_block_reason: tradingConfigStore.getState().block_reason, relay_last_message_at: relay.last_message_at, relay_freshness_age_ms: freshnessAgeMs, paused: state.paused, armed: state.armed, reason: - relay.connected + tradingConfigStore.getState().ok !== true + ? tradingConfigStore.getState().block_reason || 'trading config unavailable' + : relay.connected ? freshnessAgeMs != null && freshnessAgeMs > config.opsSentinelExecutorRelayStaleMs ? 'solver relay stale' : null @@ -433,7 +455,7 @@ function createIntentRequestStore() { event, record: { quote_id: null, - pair: payload.source_asset_id + '->' + payload.destination_asset_id, + pair: payload.pair || payload.source_asset_id + '->' + payload.destination_asset_id, decision_key: payload.request_id, }, }); @@ -453,15 +475,21 @@ function createIntentRequestStore() { event, record: { quote_id: null, - pair: payload.source_asset_id + '->' + payload.destination_asset_id, + pair: payload.pair || payload.source_asset_id + '->' + payload.destination_asset_id, decision_key: payload.request_id, }, }); }, - refreshOutcomes: () => refreshIntentRequestOutcomes(requestPool, { - btcAsset: config.tradingBtc, - eureAsset: config.tradingEure, - }), + refreshOutcomes: async () => { + const tradingConfig = await tradingConfigStore.getConfig(); + if (!tradingConfig.ok || !tradingConfig.tradingBtc || !tradingConfig.tradingEure) { + throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'missing current assets'}`); + } + return refreshIntentRequestOutcomes(requestPool, { + btcAsset: tradingConfig.tradingBtc, + eureAsset: tradingConfig.tradingEure, + }); + }, }; } diff --git a/src/core/intent-request-controller.mjs b/src/core/intent-request-controller.mjs index 31f2476..4d29265 100644 --- a/src/core/intent-request-controller.mjs +++ b/src/core/intent-request-controller.mjs @@ -22,6 +22,7 @@ export function createIntentRequestController({ signer, isArmed = () => false, isPaused = () => false, + getTradingConfig = null, now = () => Date.now(), uuid = () => crypto.randomUUID(), withMakerSuppressed = async (operation) => operation(), @@ -37,15 +38,22 @@ export function createIntentRequestController({ const createdAt = new Date(now()).toISOString(); const requestId = String(body.request_id || uuid()); const idempotencyKey = String(body.idempotency_key || `intent-request:${requestId}`); - const sourceAsset = config.tradingEure; - const destinationAsset = config.tradingBtc; - const amountEure = String(body.amount_eure || config.intentRequestDefaultAmountEure || '5'); - const slippageBps = Number(body.slippage_bps ?? config.intentRequestDefaultSlippageBps ?? 200); - const minDeadlineMs = Number(body.min_deadline_ms || config.intentRequestMinDeadlineMs || 60_000); + const requestPair = await resolveIntentRequestPair({ body, config, getTradingConfig }); + const sourceAsset = requestPair.sourceAsset; + const destinationAsset = requestPair.destinationAsset; + const amountEure = String( + body.amount_eure + || body.amount + || requestPair.requestDefaultNotional + || config.intentRequestDefaultAmountEure + || '5', + ); + const slippageBps = Number(body.slippage_bps ?? requestPair.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200); + const minDeadlineMs = Number(body.min_deadline_ms || requestPair.minDeadlineMs || config.intentRequestMinDeadlineMs || 60_000); const maxAmountUnits = parseDecimalToUnits( - String(config.intentRequestMaxAmountEure || 5), + String(requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5), sourceAsset.decimals, - { field: 'intent_request_max_amount_eure' }, + { field: 'intent_request_max_notional' }, ); let sourceAmountUnits = '0'; @@ -64,23 +72,30 @@ export function createIntentRequestController({ let blockedBeforeQuote = false; try { + if (!requestPair.ok) { + blockedBeforeQuote = true; + throw codedError( + requestPair.reasonCode, + requestPair.reasonText, + ); + } sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' }); if (BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) { blockedBeforeQuote = true; throw codedError( 'amount_exceeds_request_limit', - `Requested ${amountEure} EURe exceeds configured live request limit ${config.intentRequestMaxAmountEure || 5} EURe.`, + `Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5} ${sourceAsset.symbol}.`, ); } if (!Number.isInteger(slippageBps) || slippageBps < 0) { blockedBeforeQuote = true; throw codedError('invalid_slippage', 'Slippage must be a non-negative integer in basis points.'); } - if (slippageBps > Number(config.intentRequestMaxSlippageBps ?? 200)) { + if (slippageBps > Number(requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200)) { blockedBeforeQuote = true; throw codedError( 'slippage_exceeds_request_limit', - `Slippage ${slippageBps} bps exceeds configured limit ${config.intentRequestMaxSlippageBps ?? 200} bps.`, + `Slippage ${slippageBps} bps exceeds configured limit ${requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200} bps.`, ); } @@ -96,7 +111,7 @@ export function createIntentRequestController({ blockedBeforeQuote = true; throw codedError('inventory_unavailable', 'No spendable inventory snapshot is available.'); } - if (!isFresh(inventoryObservedAt, config.intentRequestInventoryMaxAgeMs ?? config.strategyInventoryMaxAgeMs, now())) { + if (!isFresh(inventoryObservedAt, requestPair.inventoryMaxAgeMs ?? config.intentRequestInventoryMaxAgeMs ?? config.strategyInventoryMaxAgeMs, now())) { blockedBeforeQuote = true; throw codedError('stale_inventory', 'Inventory snapshot is too stale for request creation.'); } @@ -104,7 +119,7 @@ export function createIntentRequestController({ blockedBeforeQuote = true; throw codedError('reference_price_unavailable', 'No BTC/EUR reference price is available.'); } - if (!isFresh(priceObservedAt, config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) { + if (!isFresh(priceObservedAt, requestPair.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs ?? config.strategyPriceMaxAgeMs, now())) { blockedBeforeQuote = true; throw codedError('stale_reference_price', 'Reference price is too stale for request creation.'); } @@ -168,6 +183,22 @@ export function createIntentRequestController({ state, reason_code: reasonCode, reason_text: reasonText, + pair: `${sourceAsset.assetId}->${destinationAsset.assetId}`, + pair_id: requestPair.pair?.pairId || null, + pair_config_id: requestPair.strategyConfig?.configId || null, + pair_config_version: requestPair.strategyConfig?.version == null + ? null + : String(requestPair.strategyConfig.version), + edge_bps: requestPair.strategyConfig?.edgeBps == null + ? null + : String(requestPair.strategyConfig.edgeBps), + max_notional: requestPair.strategyConfig?.maxNotional == null + ? null + : String(requestPair.strategyConfig.maxNotional), + request_max_notional: requestPair.requestMaxNotional == null + ? null + : String(requestPair.requestMaxNotional), + price_route_id: requestPair.priceRoute?.routeId || null, source_asset_id: sourceAsset.assetId, source_symbol: sourceAsset.symbol, source_decimals: sourceAsset.decimals, @@ -197,6 +228,7 @@ export function createIntentRequestController({ ingested_at: marketPrice.ingested_at, observed_at: marketPrice.payload?.observed_at || null, eure_per_btc: marketPrice.payload?.eure_per_btc || null, + price_id: marketPrice.payload?.price_id || null, } : null, solver_quote_count: solverQuotes.length, selected_quote: selectedQuote, @@ -410,6 +442,14 @@ export function createIntentRequestController({ result_code, result_text, submitted_at: submittedAt, + pair: preflight.pair || `${preflight.source_asset_id}->${preflight.destination_asset_id}`, + pair_id: preflight.pair_id || null, + pair_config_id: preflight.pair_config_id || null, + pair_config_version: preflight.pair_config_version || null, + edge_bps: preflight.edge_bps || null, + max_notional: preflight.max_notional || null, + request_max_notional: preflight.request_max_notional || null, + price_route_id: preflight.price_route_id || null, source_asset_id: preflight.source_asset_id, destination_asset_id: preflight.destination_asset_id, source_amount_units: preflight.source_amount_units, @@ -434,6 +474,122 @@ export function createIntentRequestController({ }; } +async function resolveIntentRequestPair({ body, config, getTradingConfig }) { + if (typeof getTradingConfig !== 'function') { + return { + ok: true, + sourceAsset: config.tradingEure, + destinationAsset: config.tradingBtc, + pair: null, + strategyConfig: null, + priceRoute: null, + requestDefaultNotional: config.intentRequestDefaultAmountEure, + requestMaxNotional: config.intentRequestMaxAmountEure, + slippageBps: config.intentRequestDefaultSlippageBps, + requestMaxSlippageBps: config.intentRequestMaxSlippageBps, + minDeadlineMs: config.intentRequestMinDeadlineMs, + priceMaxAgeMs: config.intentRequestPriceMaxAgeMs, + inventoryMaxAgeMs: config.intentRequestInventoryMaxAgeMs, + }; + } + + const tradingConfig = await getTradingConfig(); + const fallbackSource = config.tradingEure || tradingConfig.tradingEure; + const fallbackDestination = config.tradingBtc || tradingConfig.tradingBtc; + if (!tradingConfig?.ok) { + return blockedRequestPair({ + reasonCode: 'pair_config_unavailable', + reasonText: `Trading pair config is unavailable: ${tradingConfig?.blockReason || 'unknown'}.`, + sourceAsset: fallbackSource, + destinationAsset: fallbackDestination, + }); + } + + const requestedSource = body.source_asset_id || body.asset_in || null; + const requestedDestination = body.destination_asset_id || body.asset_out || null; + const pair = requestedSource && requestedDestination + ? tradingConfig.pairByKey.get(`${requestedSource}->${requestedDestination}`) + : tradingConfig.defaultTakerPair; + + if (!pair) { + return blockedRequestPair({ + reasonCode: 'taker_pair_missing', + reasonText: 'No DB-enabled taker pair is configured for request creation.', + sourceAsset: fallbackSource || tradingConfig.trackedAssets?.[0], + destinationAsset: fallbackDestination || tradingConfig.trackedAssets?.[1], + }); + } + + if (!pair.takerEnabled) { + return blockedRequestPair({ + reasonCode: 'pair_not_taker_enabled', + reasonText: 'The selected pair is not enabled for taker request creation.', + pair, + sourceAsset: pair.assetIn, + destinationAsset: pair.assetOut, + }); + } + + if (!pair.canTrade) { + return blockedRequestPair({ + reasonCode: pair.blockReason || 'pair_blocked', + reasonText: `The selected pair is blocked: ${pair.blockReason || 'unknown'}.`, + pair, + sourceAsset: pair.assetIn, + destinationAsset: pair.assetOut, + }); + } + + if (pair.priceRoute?.source !== 'btc_eur_reference') { + return blockedRequestPair({ + reasonCode: 'price_route_missing', + reasonText: 'Only the DB-backed BTC/EUR price route is supported for request creation in this turn.', + pair, + sourceAsset: pair.assetIn, + destinationAsset: pair.assetOut, + }); + } + + const strategyConfig = pair.strategyConfig; + return { + ok: true, + sourceAsset: pair.assetIn, + destinationAsset: pair.assetOut, + pair, + strategyConfig, + priceRoute: pair.priceRoute, + requestDefaultNotional: + strategyConfig.requestDefaultNotional || config.intentRequestDefaultAmountEure, + requestMaxNotional: + strategyConfig.requestMaxNotional || config.intentRequestMaxAmountEure, + slippageBps: strategyConfig.slippageBps ?? config.intentRequestDefaultSlippageBps, + requestMaxSlippageBps: + strategyConfig.requestMaxSlippageBps ?? strategyConfig.slippageBps ?? config.intentRequestMaxSlippageBps, + minDeadlineMs: strategyConfig.minDeadlineMs ?? config.intentRequestMinDeadlineMs, + priceMaxAgeMs: strategyConfig.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs, + inventoryMaxAgeMs: strategyConfig.inventoryMaxAgeMs ?? config.intentRequestInventoryMaxAgeMs, + }; +} + +function blockedRequestPair({ + reasonCode, + reasonText, + pair = null, + sourceAsset = null, + destinationAsset = null, +}) { + return { + ok: false, + reasonCode, + reasonText, + pair, + strategyConfig: pair?.strategyConfig || null, + priceRoute: pair?.priceRoute || null, + sourceAsset, + destinationAsset, + }; +} + function isFresh(timestamp, maxAgeMs, nowMs) { const parsed = Date.parse(timestamp || ''); if (!Number.isFinite(parsed)) return false; diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index cedb0f7..f292cca 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -48,6 +48,36 @@ const CONTROL_DEFINITIONS = [ page: 'funds', risk_class: 'safe', }, + { + service: 'operator-dashboard', + action: 'import-supported-assets', + method: 'POST', + path: '/internal/import-supported-assets', + label: 'Import Assets', + description: 'Fetch and store the current 1Click supported token catalog.', + page: 'strategy', + risk_class: 'safe', + }, + { + service: 'operator-dashboard', + action: 'update-pair-edge', + method: 'POST', + path: '/internal/update-pair-edge', + label: 'Update Edge', + description: 'Create a new active strategy config version for a pair.', + page: 'strategy', + risk_class: 'safe', + }, + { + service: 'operator-dashboard', + action: 'enable-observe-only-pair', + method: 'POST', + path: '/internal/enable-observe-only-pair', + label: 'Enable Observe-Only', + description: 'Approve a directed pair for observation without enabling trading.', + page: 'strategy', + risk_class: 'safe', + }, { service: 'trade-executor', action: 'intent-request-preflight', @@ -229,6 +259,7 @@ const SERVICE_DEFINITIONS = [ ['ops-sentinel', 'Ops Sentinel', 'opsSentinelControlBaseUrl'], ['strategy-engine', 'Strategy Engine', 'strategyEngineControlBaseUrl'], ['trade-executor', 'Trade Executor', 'tradeExecutorControlBaseUrl'], + ['operator-dashboard', 'Operator Dashboard', 'operatorDashboardControlBaseUrl'], ]; export function resolveDashboardAuth({ mode = 'stub' } = {}) { @@ -475,6 +506,8 @@ export function buildDashboardBootstrap({ recentIntentRequests = [], recentAlertTransitions, recentEnvironmentStatuses = [], + assetCatalog = null, + pairConfig = null, serviceSnapshots, nearIntentsStatus = null, sourceErrors = [], @@ -555,6 +588,8 @@ export function buildDashboardBootstrap({ config, servicesByName, activeAlerts, + assetCatalog, + pairConfig, recentQuotes, recentTradeDecisions, recentExecuteTradeCommands, @@ -1029,6 +1064,10 @@ export function deriveQuoteLifecycleRows({ quote_id: decision.quote_id, decision_id: decision.decision_id, pair: decision.pair, + pair_id: decision.pair_id, + pair_config_id: decision.pair_config_id, + pair_config_version: decision.pair_config_version, + edge_bps: decision.edge_bps, direction: decision.direction, request_kind: decision.request_kind, gross_edge_pct: decision.gross_edge_pct, @@ -1054,6 +1093,10 @@ export function deriveQuoteLifecycleRows({ decision_id: command.decision_id, command_id: command.command_id, pair: command.pair, + pair_id: command.pair_id, + pair_config_id: command.pair_config_id, + pair_config_version: command.pair_config_version, + edge_bps: command.edge_bps, direction: command.direction, request_kind: command.request_kind, asset_in: command.asset_in || null, @@ -1109,6 +1152,10 @@ function ensureLifecycleRow(rowsByKey, key) { decision_id: null, command_id: null, pair: null, + pair_id: null, + pair_config_id: null, + pair_config_version: null, + edge_bps: null, direction: null, request_kind: null, gross_edge_pct: null, @@ -1336,6 +1383,10 @@ function normalizeCommand(command) { execution_key: command.execution_key || null, quote_id: command.quote_id || null, pair: command.pair || null, + pair_id: command.pair_id || null, + pair_config_id: command.pair_config_id || null, + pair_config_version: command.pair_config_version || null, + edge_bps: command.edge_bps || null, direction: command.direction || null, request_kind: command.request_kind || null, asset_in: command.asset_in || null, @@ -1350,6 +1401,8 @@ function buildStrategySummary({ config, servicesByName, activeAlerts, + assetCatalog = null, + pairConfig = null, recentQuotes = [], recentTradeDecisions = [], recentExecuteTradeCommands = [], @@ -1405,8 +1458,14 @@ function buildStrategySummary({ strategy_state: { armed: strategyState.armed ?? null, paused: strategyState.paused ?? null, - threshold_pct: strategyState.threshold_pct ?? null, - max_notional_eure: strategyState.max_notional_eure ?? null, + threshold_pct: + strategyState.threshold_pct + ?? firstActivePairConfig(pairConfig)?.edge_pct + ?? null, + max_notional_eure: + strategyState.max_notional_eure + ?? firstActivePairConfig(pairConfig)?.max_notional + ?? null, latest_decision: latestDecision?.decision_id ? latestDecision : null, recent_decisions: recentDecisions.length ? recentDecisions @@ -1415,7 +1474,10 @@ function buildStrategySummary({ trade_funnel: tradeFunnel, skipped_counts: strategyState.skipped_counts || {}, durable_control_state: strategyState.durable_control_state || null, + trading_config: strategyState.trading_config || null, }, + asset_catalog: assetCatalog || buildFallbackAssetCatalog(config), + pair_config: pairConfig || buildFallbackPairConfig(config), executor_state: { armed: executorState.armed ?? null, paused: executorState.paused ?? null, @@ -1488,6 +1550,57 @@ function buildTradeFunnelSummary(lifecycleRows = []) { }; } +function firstActivePairConfig(pairConfig) { + const pair = pairConfig?.pairs?.find((entry) => entry.strategyConfig || entry.strategy_config); + const strategyConfig = pair?.strategyConfig || pair?.strategy_config || null; + if (!strategyConfig) return null; + return { + edge_pct: strategyConfig.edge_bps == null ? null : String(Number(strategyConfig.edge_bps) / 100), + max_notional: strategyConfig.max_notional == null ? null : String(strategyConfig.max_notional), + }; +} + +function buildFallbackAssetCatalog(config) { + const items = [...(config.assetRegistry?.values?.() || [])].map((asset) => ({ + asset_id: asset.assetId, + symbol: asset.symbol, + label: asset.label || asset.symbol, + decimals: asset.decimals, + blockchain: asset.blockchain || asset.chain || null, + chain: asset.chain || asset.blockchain || null, + supported: asset.supported ?? null, + retired_at: asset.retiredAt || null, + enabled_for_inventory: asset.enabledForInventory ?? true, + })); + return { + latest_import: null, + counts: { + known: items.length, + supported: items.filter((asset) => asset.supported === true).length, + retired: items.filter((asset) => asset.retired_at).length, + inventory_enabled: items.filter((asset) => asset.enabled_for_inventory !== false).length, + }, + items, + }; +} + +function buildFallbackPairConfig(config) { + return { + ok: false, + block_reason: 'pair_config_unavailable', + loaded_at: null, + pairs: config.activePair ? [{ + pair_id: config.activePair, + key: config.activePair, + mode: 'legacy', + status: 'legacy', + enabled: false, + canTrade: false, + blockReason: 'pair_config_unavailable', + }] : [], + }; +} + function summarizeGrossEdgeEstimate(rows = []) { let total = 0n; let count = 0; @@ -1638,7 +1751,8 @@ function buildServiceSummary(service, state) { return { connected: state.ingest?.connected ?? null, reconnect_count: state.ingest?.reconnect_count ?? null, - pair_filter: state.pair_filter?.pair_filter || null, + active_pair: state.trading_config?.active_pair || null, + enabled_pair_count: state.trading_config?.enabled_pair_count ?? null, last_message_at: state.ingest?.last_message_at || null, last_matching_quote_at: state.ingest?.last_matching_quote_at || null, last_published_at: state.ingest?.last_published_at || null, @@ -1952,6 +2066,10 @@ function normalizeDecision(decision) { decision_at: decision.decision_at || null, quote_id: decision.quote_id || null, pair: decision.pair || null, + pair_id: decision.pair_id || null, + pair_config_id: decision.pair_config_id || null, + pair_config_version: decision.pair_config_version || null, + edge_bps: decision.edge_bps || null, direction: decision.direction || null, request_kind: decision.request_kind || null, decision: normalizeDecisionVerdict(decision.decision), diff --git a/src/core/pair-filter.mjs b/src/core/pair-filter.mjs index 9cbde42..fcbc5e3 100644 --- a/src/core/pair-filter.mjs +++ b/src/core/pair-filter.mjs @@ -1,8 +1,3 @@ -import fs from 'node:fs'; - -export const DEFAULT_NEAR_INTENTS_PAIR_FILTER = - 'nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near'; - export function parsePairFilter(argv) { const idx = argv.indexOf('--pair'); if (idx === -1) return null; @@ -33,8 +28,7 @@ export function formatPairFilter(pairFilter) { export function resolvePairFilter({ argv = [], - env = process.env, - defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER, + defaultPairFilter = null, } = {}) { const cliPairFilter = parsePairFilter(argv); if (cliPairFilter) { @@ -45,124 +39,38 @@ export function resolvePairFilter({ }; } - const envPairFilter = parsePairFilterValue(env.NEAR_INTENTS_PAIR_FILTER, { - fieldName: 'NEAR_INTENTS_PAIR_FILTER', - }); - if (envPairFilter) { - return { - pairFilter: envPairFilter, - pair: formatPairFilter(envPairFilter), - source: 'env', - }; - } - const defaultResolved = parsePairFilterValue(defaultPairFilter, { fieldName: 'default pair filter', }); return { pairFilter: defaultResolved, pair: formatPairFilter(defaultResolved), - source: 'default', + source: defaultResolved ? 'default' : 'disabled', }; } export function createPairFilterController({ argv = [], - env = process.env, logger = null, - defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER, - pairFilterFile = env.NEAR_INTENTS_PAIR_FILTER_FILE, - reloadEveryMs = env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS, + defaultPairFilter = null, } = {}) { function resolveConfiguredState() { - const nextResolved = resolvePairFilter({ argv, env, defaultPairFilter }); - let nextPairFilter = nextResolved.pairFilter; - let nextPair = nextResolved.pair; - let nextSource = nextResolved.source; - - if (normalizedPairFilterFile) { - const fileValue = readPairFilterFile(normalizedPairFilterFile); - if (fileValue != null) { - const filePairFilter = parsePairFilterValue(fileValue, { - fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE', - }); - nextPairFilter = filePairFilter; - nextPair = formatPairFilter(filePairFilter); - nextSource = 'file'; - } - } - - return { - pairFilter: nextPairFilter, - pair: nextPair, - source: nextSource, - }; + return resolvePairFilter({ argv, defaultPairFilter }); } - const normalizedPairFilterFile = String(pairFilterFile || '').trim() || null; - const normalizedReloadEveryMs = parseReloadMs(reloadEveryMs); const resolved = resolveConfiguredState(); let currentPairFilter = resolved.pairFilter; let currentPair = resolved.pair; - let lastLoadedFileValue = null; let source = resolved.source; let overrideSource = null; - if (normalizedPairFilterFile) { - const initialFileValue = readPairFilterFile(normalizedPairFilterFile); - if (initialFileValue != null) { - const initialFilePairFilter = parsePairFilterValue(initialFileValue, { - fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE', - }); - currentPairFilter = initialFilePairFilter; - currentPair = formatPairFilter(initialFilePairFilter); - lastLoadedFileValue = initialFileValue; - source = 'file'; - } - } - logger?.info('pair_filter_configured', { pair: currentPair, details: { source, - pair_filter_file: normalizedPairFilterFile, }, }); - const timer = normalizedPairFilterFile - ? setInterval(() => { - if (overrideSource === 'api') return; - - const nextValue = readPairFilterFile(normalizedPairFilterFile); - if (nextValue == null || nextValue === lastLoadedFileValue) return; - - try { - const nextPairFilter = parsePairFilterValue(nextValue, { - fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE', - }); - currentPairFilter = nextPairFilter; - currentPair = formatPairFilter(nextPairFilter); - lastLoadedFileValue = nextValue; - logger?.info('pair_filter_reloaded', { - pair: currentPair, - details: { - pair_filter_file: normalizedPairFilterFile, - }, - }); - } catch (error) { - logger?.error('pair_filter_reload_failed', { - pair: currentPair, - details: { - pair_filter_file: normalizedPairFilterFile, - error: error.message, - }, - }); - } - }, normalizedReloadEveryMs) - : null; - - if (timer && typeof timer.unref === 'function') timer.unref(); - function setState(nextPairFilter, nextSource) { currentPairFilter = nextPairFilter; currentPair = formatPairFilter(nextPairFilter); @@ -182,8 +90,8 @@ export function createPairFilterController({ pair: currentPair, source, configured: resolveConfiguredState(), - pairFilterFile: normalizedPairFilterFile, - reloadEveryMs: normalizedPairFilterFile ? normalizedReloadEveryMs : null, + pairFilterFile: null, + reloadEveryMs: null, }; }, setPairFilter(raw, { source: nextSource = 'api' } = {}) { @@ -198,7 +106,6 @@ export function createPairFilterController({ pair: currentPair, details: { source: nextSource, - pair_filter_file: normalizedPairFilterFile, }, }); return this.getState(); @@ -210,7 +117,6 @@ export function createPairFilterController({ pair: null, details: { source: nextSource, - pair_filter_file: normalizedPairFilterFile, }, }); return this.getState(); @@ -223,13 +129,11 @@ export function createPairFilterController({ pair: currentPair, details: { source, - pair_filter_file: normalizedPairFilterFile, }, }); return this.getState(); }, close() { - if (timer) clearInterval(timer); }, }; } @@ -240,19 +144,3 @@ export function matchesPairFilter(assetIn, assetOut, pairFilter) { const y = assetOut.toLowerCase(); return (x === pairFilter[0] && y === pairFilter[1]) || (x === pairFilter[1] && y === pairFilter[0]); } - -function readPairFilterFile(filePath) { - if (!fs.existsSync(filePath)) return null; - - const raw = fs.readFileSync(filePath, 'utf8') - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line && !line.startsWith('#')); - - return raw || null; -} - -function parseReloadMs(raw) { - const parsed = Number(raw); - return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 5_000; -} diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 80564e8..470a934 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -4,7 +4,6 @@ import { bigintAmount, classifyPairDirection, formatNumber, - isActivePair, numberToUnits, pairKey, unitsToNumber, @@ -21,30 +20,42 @@ export function evaluateTradeOpportunity({ maxNotionalEure = config.strategyMaxNotionalEure, }) { const payload = demandEvent.payload; + const pairRuntime = resolvePairRuntime({ payload, config, thresholdPct, maxNotionalEure }); + const effectiveThresholdPct = pairRuntime.thresholdPct ?? thresholdPct; + const effectiveMaxNotionalEure = pairRuntime.maxNotionalEure ?? maxNotionalEure; const decisionId = crypto.randomUUID(); const baseDecision = { decision_id: decisionId, quote_id: payload.quote_id, pair: pairKey(payload.asset_in, payload.asset_out), - direction: classifyPairDirection({ - assetIn: payload.asset_in, - assetOut: payload.asset_out, - btcAssetId: config.tradingBtc.assetId, - eureAssetId: config.tradingEure.assetId, - }), + pair_id: pairRuntime.pair?.pairId || null, + pair_config_id: pairRuntime.strategyConfig?.configId || null, + pair_config_version: pairRuntime.strategyConfig?.version == null + ? null + : String(pairRuntime.strategyConfig.version), + edge_bps: pairRuntime.strategyConfig?.edgeBps == null + ? null + : String(pairRuntime.strategyConfig.edgeBps), + max_notional: effectiveMaxNotionalEure == null ? null : String(effectiveMaxNotionalEure), + min_notional: pairRuntime.strategyConfig?.minNotional == null + ? null + : String(pairRuntime.strategyConfig.minNotional), + price_route_id: pairRuntime.priceRoute?.routeId || null, + direction: pairRuntime.direction, request_kind: payload.request_kind, decision: 'rejected', decision_reason: 'unknown', - threshold_pct: String(thresholdPct), - max_notional_eure: String(maxNotionalEure), + threshold_pct: String(effectiveThresholdPct), + max_notional_eure: String(effectiveMaxNotionalEure), strategy_armed: armed, assumptions: { eure_per_eur: '1', + price_route_source: pairRuntime.priceRoute?.source || null, }, }; - if (!isActivePair(payload.asset_in, payload.asset_out, config)) { - return { decision: withReason(baseDecision, 'unsupported_pair') }; + if (!pairRuntime.ok) { + return { decision: withReason(baseDecision, pairRuntime.reason) }; } if (!priceEvent) { @@ -58,7 +69,7 @@ export function evaluateTradeOpportunity({ const priceAgeMs = now - Date.parse(priceEvent.ingested_at || priceEvent.observed_at || 0); const inventoryAgeMs = now - Date.parse(inventoryEvent.ingested_at || inventoryEvent.observed_at || 0); - if (!Number.isFinite(priceAgeMs) || priceAgeMs > config.strategyPriceMaxAgeMs) { + if (!Number.isFinite(priceAgeMs) || priceAgeMs > pairRuntime.priceMaxAgeMs) { return { decision: { ...withReason(baseDecision, 'stale_reference_price'), @@ -67,7 +78,7 @@ export function evaluateTradeOpportunity({ }; } - if (!Number.isFinite(inventoryAgeMs) || inventoryAgeMs > config.strategyInventoryMaxAgeMs) { + if (!Number.isFinite(inventoryAgeMs) || inventoryAgeMs > pairRuntime.inventoryMaxAgeMs) { return { decision: { ...withReason(baseDecision, 'stale_inventory_snapshot'), @@ -83,8 +94,9 @@ export function evaluateTradeOpportunity({ price, inventory, config, - thresholdPct, - maxNotionalEure, + pairRuntime, + thresholdPct: effectiveThresholdPct, + maxNotionalEure: effectiveMaxNotionalEure, }); if (!buildResult.ok) { @@ -119,8 +131,18 @@ export function evaluateTradeOpportunity({ execution_key: payload.quote_id, quote_id: payload.quote_id, pair: decision.pair, + pair_id: decision.pair_id, + pair_config_id: decision.pair_config_id, + pair_config_version: decision.pair_config_version, + edge_bps: decision.edge_bps, + max_notional: decision.max_notional, + max_notional_eure: decision.max_notional_eure, + price_route_id: decision.price_route_id, + reference_price_id: buildResult.details.price_id || null, asset_in: payload.asset_in, asset_out: payload.asset_out, + asset_in_decimals: pairRuntime.assetIn?.decimals ?? null, + asset_out_decimals: pairRuntime.assetOut?.decimals ?? null, amount_in: payload.amount_in ?? null, amount_out: payload.amount_out ?? null, request_kind: payload.request_kind, @@ -137,20 +159,28 @@ function buildQuote({ price, inventory, config, + pairRuntime = null, thresholdPct, maxNotionalEure, }) { - const direction = classifyPairDirection({ + const direction = pairRuntime?.direction || classifyPairDirection({ assetIn: demand.asset_in, assetOut: demand.asset_out, - btcAssetId: config.tradingBtc.assetId, - eureAssetId: config.tradingEure.assetId, + btcAssetId: config.tradingBtc?.assetId, + eureAssetId: config.tradingEure?.assetId, }); if (direction === 'unsupported') { return { ok: false, reason: 'unsupported_pair', details: {} }; } + const assetRegistry = pairRuntime?.assetRegistry || config.assetRegistry; + const assetIn = pairRuntime?.assetIn || assetRegistry.get(demand.asset_in); + const assetOut = pairRuntime?.assetOut || assetRegistry.get(demand.asset_out); + if (!Number.isInteger(assetIn?.decimals) || !Number.isInteger(assetOut?.decimals)) { + return { ok: false, reason: 'asset_decimals_missing', details: {} }; + } + const thresholdFactor = 1 - (thresholdPct / 100); const penaltyFactor = 1 + (thresholdPct / 100); const spendAsset = demand.asset_out; @@ -165,7 +195,7 @@ function buildQuote({ const inputNumber = unitsToNumber( demand.amount_in, - config.assetRegistry.get(demand.asset_in).decimals, + assetIn.decimals, ); const fairOutput = direction === 'btc_to_eure' ? inputNumber * Number(price.eure_per_btc) @@ -173,7 +203,7 @@ function buildQuote({ const proposedOutput = fairOutput * thresholdFactor; const proposedOutputUnits = numberToUnits( proposedOutput, - config.assetRegistry.get(demand.asset_out).decimals, + assetOut.decimals, { mode: 'floor' }, ); const spendRequired = bigintAmount(proposedOutputUnits); @@ -182,7 +212,7 @@ function buildQuote({ : inputNumber; const impliedRate = unitsToNumber( proposedOutputUnits, - config.assetRegistry.get(demand.asset_out).decimals, + assetOut.decimals, ) / inputNumber; const referenceRate = direction === 'btc_to_eure' ? Number(price.eure_per_btc) @@ -201,6 +231,8 @@ function buildQuote({ referenceRate, inventoryId: inventory.inventory_id, priceId: price.price_id, + assetInDecimals: assetIn.decimals, + assetOutDecimals: assetOut.decimals, }); } @@ -212,7 +244,7 @@ function buildQuote({ const outputNumber = unitsToNumber( demand.amount_out, - config.assetRegistry.get(demand.asset_out).decimals, + assetOut.decimals, ); const fairInput = direction === 'btc_to_eure' ? outputNumber * Number(price.btc_per_eure) @@ -220,7 +252,7 @@ function buildQuote({ const proposedInput = fairInput * penaltyFactor; const proposedInputUnits = numberToUnits( proposedInput, - config.assetRegistry.get(demand.asset_in).decimals, + assetIn.decimals, { mode: 'ceil' }, ); const spendRequired = amountOut; @@ -229,7 +261,7 @@ function buildQuote({ : fairInput; const impliedRate = outputNumber / unitsToNumber( proposedInputUnits, - config.assetRegistry.get(demand.asset_in).decimals, + assetIn.decimals, ); const referenceRate = direction === 'btc_to_eure' ? Number(price.eure_per_btc) @@ -249,6 +281,8 @@ function buildQuote({ referenceRate, inventoryId: inventory.inventory_id, priceId: price.price_id, + assetInDecimals: assetIn.decimals, + assetOutDecimals: assetOut.decimals, }); } @@ -269,6 +303,8 @@ function finalizeQuote({ referenceRate, inventoryId, priceId, + assetInDecimals = null, + assetOutDecimals = null, }) { const grossEdgePct = ((referenceRate - impliedRate) / referenceRate) * 100; const reasonBase = { @@ -281,6 +317,9 @@ function finalizeQuote({ inventory_available: available.toString(), inventory_id: inventoryId, price_id: priceId, + reference_price_id: priceId, + asset_in_decimals: assetInDecimals == null ? null : String(assetInDecimals), + asset_out_decimals: assetOutDecimals == null ? null : String(assetOutDecimals), eure_notional: formatNumber(eureNotional, 6), proposed_amount_in: proposedAmountIn, proposed_amount_out: proposedAmountOut, @@ -331,3 +370,136 @@ function withReason(decision, reason) { decision_reason: reason, }; } + +function resolvePairRuntime({ + payload, + config, + thresholdPct, + maxNotionalEure, +}) { + const key = pairKey(payload.asset_in, payload.asset_out); + const requiresDb = config.requireDbTradingConfig === true || config.tradingConfigLoaded === true; + + if (!requiresDb) { + const direction = classifyPairDirection({ + assetIn: payload.asset_in, + assetOut: payload.asset_out, + btcAssetId: config.tradingBtc?.assetId, + eureAssetId: config.tradingEure?.assetId, + }); + const active = ( + direction !== 'unsupported' + && config.assetRegistry?.has(payload.asset_in) + && config.assetRegistry?.has(payload.asset_out) + ); + return { + ok: active, + reason: active ? null : 'unsupported_pair', + direction, + assetRegistry: config.assetRegistry, + assetIn: config.assetRegistry?.get(payload.asset_in) || null, + assetOut: config.assetRegistry?.get(payload.asset_out) || null, + thresholdPct, + maxNotionalEure, + priceMaxAgeMs: config.strategyPriceMaxAgeMs, + inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, + pair: null, + strategyConfig: null, + priceRoute: null, + }; + } + + if (!config.tradingConfigLoaded || config.ok === false) { + return { + ok: false, + reason: 'pair_config_unavailable', + direction: 'unsupported', + pair: null, + strategyConfig: null, + priceRoute: null, + assetRegistry: config.assetRegistry || new Map(), + assetIn: null, + assetOut: null, + thresholdPct, + maxNotionalEure, + priceMaxAgeMs: config.strategyPriceMaxAgeMs, + inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, + }; + } + + const pair = config.pairByKey?.get(key) || null; + if (!pair || !pair.enabled || !pair.observeEnabled) { + return { + ok: false, + reason: pair ? 'pair_disabled' : 'unsupported_pair', + direction: 'unsupported', + pair, + strategyConfig: pair?.strategyConfig || null, + priceRoute: pair?.priceRoute || null, + assetRegistry: config.assetRegistry, + assetIn: pair?.assetIn || config.assetRegistry?.get(payload.asset_in) || null, + assetOut: pair?.assetOut || config.assetRegistry?.get(payload.asset_out) || null, + thresholdPct, + maxNotionalEure, + priceMaxAgeMs: config.strategyPriceMaxAgeMs, + inventoryMaxAgeMs: config.strategyInventoryMaxAgeMs, + }; + } + + if (!pair.makerEnabled) return blockedPairRuntime(pair, config, 'pair_not_maker_enabled'); + if (pair.blockReason) return blockedPairRuntime(pair, config, pair.blockReason); + if (!pair.strategyConfig) return blockedPairRuntime(pair, config, 'pair_strategy_config_missing'); + if (!pair.priceRoute) return blockedPairRuntime(pair, config, 'price_route_missing'); + + const direction = classifyPriceRouteDirection({ payload, priceRoute: pair.priceRoute }); + if (direction === 'unsupported') return blockedPairRuntime(pair, config, 'price_route_missing'); + + return { + ok: true, + reason: null, + direction, + pair, + strategyConfig: pair.strategyConfig, + priceRoute: pair.priceRoute, + assetRegistry: config.assetRegistry, + assetIn: pair.assetIn, + assetOut: pair.assetOut, + thresholdPct: Number(pair.strategyConfig.edgeBps) / 100, + maxNotionalEure: Number(pair.strategyConfig.maxNotional), + priceMaxAgeMs: Number(pair.strategyConfig.priceMaxAgeMs), + inventoryMaxAgeMs: Number(pair.strategyConfig.inventoryMaxAgeMs), + }; +} + +function blockedPairRuntime(pair, config, reason) { + return { + ok: false, + reason, + direction: classifyPriceRouteDirection({ + payload: { asset_in: pair?.asset_in, asset_out: pair?.asset_out }, + priceRoute: pair?.priceRoute, + }), + pair, + strategyConfig: pair?.strategyConfig || null, + priceRoute: pair?.priceRoute || null, + assetRegistry: config.assetRegistry, + assetIn: pair?.assetIn || null, + assetOut: pair?.assetOut || null, + thresholdPct: pair?.strategyConfig ? Number(pair.strategyConfig.edgeBps) / 100 : config.strategyGrossThresholdPct, + maxNotionalEure: pair?.strategyConfig ? Number(pair.strategyConfig.maxNotional) : config.strategyMaxNotionalEure, + priceMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.priceMaxAgeMs) : config.strategyPriceMaxAgeMs, + inventoryMaxAgeMs: pair?.strategyConfig ? Number(pair.strategyConfig.inventoryMaxAgeMs) : config.strategyInventoryMaxAgeMs, + }; +} + +function classifyPriceRouteDirection({ payload, priceRoute }) { + if (!priceRoute) return 'unsupported'; + if (priceRoute.source !== 'btc_eur_reference') return 'unsupported'; + if (payload.asset_in === priceRoute.baseAssetId && payload.asset_out === priceRoute.quoteAssetId) { + return 'btc_to_eure'; + } + if (payload.asset_in === priceRoute.quoteAssetId && payload.asset_out === priceRoute.baseAssetId) { + return 'eure_to_btc'; + } + return 'unsupported'; +} diff --git a/src/core/trading-config.mjs b/src/core/trading-config.mjs new file mode 100644 index 0000000..be91d33 --- /dev/null +++ b/src/core/trading-config.mjs @@ -0,0 +1,238 @@ +import crypto from 'node:crypto'; + +import { pairKey } from './assets.mjs'; + +export const NEAR_INTENTS_VENUE = 'near-intents'; +export const ONE_CLICK_TOKENS_URL = 'https://1click.chaindefuser.com/v0/tokens'; + +export const CURRENT_NBTC_ASSET_ID = 'nep141:nbtc.bridge.near'; +export const LEGACY_OMFT_BTC_ASSET_ID = 'nep141:btc.omft.near'; +export const CURRENT_EURE_ASSET_ID = + 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near'; + +export const CURRENT_PAIR_KEY = pairKey(CURRENT_NBTC_ASSET_ID, CURRENT_EURE_ASSET_ID); +export const CURRENT_REVERSE_PAIR_KEY = pairKey(CURRENT_EURE_ASSET_ID, CURRENT_NBTC_ASSET_ID); +export const CURRENT_EDGE_BPS = 49; +export const CURRENT_STRATEGY_MAX_NOTIONAL = '150'; +export const CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE = '5'; +export const CURRENT_REQUEST_MAX_NOTIONAL_EURE = '5'; +export const CURRENT_SLIPPAGE_BPS = 200; +export const CURRENT_MIN_DEADLINE_MS = 60_000; +export const CURRENT_PRICE_MAX_AGE_MS = 30_000; +export const CURRENT_INVENTORY_MAX_AGE_MS = 30_000; + +export const PAIR_MODES = new Set(['observe_only', 'maker', 'taker', 'both']); +export const PAIR_STATUSES = new Set(['disabled', 'observe_only', 'maker', 'taker', 'both']); + +export function normalizeOneClickToken(token) { + if (!isRecord(token)) throw new Error('token record must be an object'); + + const assetId = stringField(token.assetId ?? token.asset_id ?? token.defuseAssetId, 'assetId'); + const symbol = stringField(token.symbol, 'symbol'); + const decimals = integerField(token.decimals, 'decimals'); + const blockchain = stringField(token.blockchain ?? token.chain, 'blockchain'); + const contractAddress = optionalString(token.contractAddress ?? token.contract_address); + const latestPrice = token.price == null ? null : String(token.price); + const priceUpdatedAt = optionalTimestamp(token.priceUpdatedAt ?? token.price_updated_at); + + return { + assetId, + venue: NEAR_INTENTS_VENUE, + symbol, + label: symbol, + decimals, + blockchain, + chain: blockchain, + contractAddress, + latestPrice, + priceUpdatedAt, + supported: true, + rawPayload: token, + }; +} + +export function normalizeOneClickTokenResponse(response) { + const tokens = Array.isArray(response) + ? response + : Array.isArray(response?.tokens) + ? response.tokens + : Array.isArray(response?.result) + ? response.result + : null; + + if (!tokens) throw new Error('supported token response must be an array'); + return tokens.map((token) => normalizeOneClickToken(token)); +} + +export function hashJson(value) { + return crypto.createHash('sha256') + .update(JSON.stringify(value)) + .digest('hex'); +} + +export function buildSeedAssets() { + return [ + { + assetId: CURRENT_NBTC_ASSET_ID, + venue: NEAR_INTENTS_VENUE, + symbol: 'BTC', + label: 'BTC / nBTC reserve', + decimals: 8, + blockchain: 'near', + chain: 'btc:mainnet', + contractAddress: 'nbtc.bridge.near', + latestPrice: null, + priceUpdatedAt: null, + supported: true, + enabledForInventory: true, + role: 'trading', + rawPayload: { source: 'repo_seed', assetId: CURRENT_NBTC_ASSET_ID }, + }, + { + assetId: LEGACY_OMFT_BTC_ASSET_ID, + venue: NEAR_INTENTS_VENUE, + symbol: 'BTC', + label: 'BTC / legacy OMFT', + decimals: 8, + blockchain: 'btc', + chain: 'btc:mainnet', + contractAddress: null, + latestPrice: null, + priceUpdatedAt: null, + supported: true, + enabledForInventory: true, + role: 'legacy', + rawPayload: { source: 'repo_seed', assetId: LEGACY_OMFT_BTC_ASSET_ID }, + }, + { + assetId: CURRENT_EURE_ASSET_ID, + venue: NEAR_INTENTS_VENUE, + symbol: 'EURe', + label: 'EURe', + decimals: 18, + blockchain: 'gnosis', + chain: 'eth:100', + contractAddress: '0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430', + latestPrice: null, + priceUpdatedAt: null, + supported: true, + enabledForInventory: true, + role: 'trading', + withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb', + rawPayload: { source: 'repo_seed', assetId: CURRENT_EURE_ASSET_ID }, + }, + ]; +} + +export function buildSeedPairs() { + return [ + { + pairId: CURRENT_PAIR_KEY, + venue: NEAR_INTENTS_VENUE, + assetIn: CURRENT_NBTC_ASSET_ID, + assetOut: CURRENT_EURE_ASSET_ID, + mode: 'both', + enabled: true, + status: 'both', + }, + { + pairId: CURRENT_REVERSE_PAIR_KEY, + venue: NEAR_INTENTS_VENUE, + assetIn: CURRENT_EURE_ASSET_ID, + assetOut: CURRENT_NBTC_ASSET_ID, + mode: 'both', + enabled: true, + status: 'both', + }, + ]; +} + +export function buildSeedStrategyConfig(pairId, { + version = 1, + active = true, + createdBy = 'repo_seed', + reason = 'seed current nBTC/EURe production config', +} = {}) { + return { + configId: `${pairId}:v${version}`, + pairId, + version, + active, + edgeBps: CURRENT_EDGE_BPS, + maxNotional: CURRENT_STRATEGY_MAX_NOTIONAL, + minNotional: '0', + slippageBps: CURRENT_SLIPPAGE_BPS, + minDeadlineMs: CURRENT_MIN_DEADLINE_MS, + priceMaxAgeMs: CURRENT_PRICE_MAX_AGE_MS, + inventoryMaxAgeMs: CURRENT_INVENTORY_MAX_AGE_MS, + requestDefaultNotional: CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE, + requestMaxNotional: CURRENT_REQUEST_MAX_NOTIONAL_EURE, + requestMaxSlippageBps: CURRENT_SLIPPAGE_BPS, + createdBy, + reason, + }; +} + +export function buildSeedPriceRoute(pairId) { + return { + routeId: `${pairId}:btc-eur-reference`, + pairId, + source: 'btc_eur_reference', + baseAssetId: CURRENT_NBTC_ASSET_ID, + quoteAssetId: CURRENT_EURE_ASSET_ID, + routeConfig: { + reference_pair: 'BTC/EUR', + eure_per_eur_assumption: '1', + }, + maxAgeMs: CURRENT_PRICE_MAX_AGE_MS, + enabled: true, + }; +} + +export function pairCanObserve(pair) { + return Boolean(pair?.enabled) && PAIR_MODES.has(pair.mode) && pair.status !== 'disabled'; +} + +export function pairCanMake(pair) { + return pairCanObserve(pair) && ['maker', 'both'].includes(pair.mode); +} + +export function pairCanTake(pair) { + return pairCanObserve(pair) && ['taker', 'both'].includes(pair.mode); +} + +export function normalizePairMode(mode) { + const normalized = String(mode || '').trim().toLowerCase(); + if (!PAIR_MODES.has(normalized)) throw new Error(`unsupported pair mode: ${mode}`); + return normalized; +} + +function stringField(value, field) { + const normalized = optionalString(value); + if (!normalized) throw new Error(`${field} is required`); + return normalized; +} + +function optionalString(value) { + const normalized = String(value ?? '').trim(); + return normalized || null; +} + +function integerField(value, field) { + const number = Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new Error(`${field} must be a non-negative integer`); + } + return number; +} + +function optionalTimestamp(value) { + if (value == null || value === '') return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error('priceUpdatedAt must be a timestamp'); + return date.toISOString(); +} + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs index a88fe97..2f8347d 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -1,5 +1,4 @@ import { loadDotenv } from './env.mjs'; -import { DEFAULT_NEAR_INTENTS_PAIR_FILTER } from '../core/pair-filter.mjs'; const DEFAULTS = { nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws', @@ -7,8 +6,6 @@ const DEFAULTS = { nearBridgeRpcUrl: 'https://bridge.chaindefuser.com/rpc', nearRpcUrl: 'https://near.lava.build', nearVerifierContract: 'intents.near', - nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER, - nearIntentsPairFilterReloadMs: 5_000, nearIntentsControlApiEnabled: true, nearIntentsControlHost: '0.0.0.0', nearIntentsControlPort: 8081, @@ -186,21 +183,17 @@ export function loadConfig({ envPath = '.env' } = {}) { loadDotenv(envPath); const tradingBtc = buildAsset({ - assetId: process.env.TRADING_BTC_ASSET_ID || DEFAULTS.tradingBtcAssetId, - symbol: process.env.TRADING_BTC_SYMBOL || DEFAULTS.tradingBtcSymbol, - label: process.env.TRADING_BTC_LABEL || DEFAULTS.tradingBtcLabel, - decimals: parseNumber(process.env.TRADING_BTC_DECIMALS, DEFAULTS.tradingBtcDecimals), - chain: process.env.TRADING_BTC_CHAIN || DEFAULTS.tradingBtcChain, - withdrawAddress: - process.env.TRADING_BTC_WITHDRAW_ADDRESS || DEFAULTS.tradingBtcWithdrawAddress, + assetId: DEFAULTS.tradingBtcAssetId, + symbol: DEFAULTS.tradingBtcSymbol, + label: DEFAULTS.tradingBtcLabel, + decimals: DEFAULTS.tradingBtcDecimals, + chain: DEFAULTS.tradingBtcChain, + withdrawAddress: DEFAULTS.tradingBtcWithdrawAddress, role: 'trading', }); - const configuredTrackedBtcAssetIds = splitCsv(process.env.TRADING_BTC_TRACKED_ASSET_IDS); const trackedBtcAssetIds = unique([ tradingBtc.assetId, - ...(configuredTrackedBtcAssetIds.length - ? configuredTrackedBtcAssetIds - : DEFAULTS.tradingBtcTrackedAssetIds), + ...DEFAULTS.tradingBtcTrackedAssetIds, ]); const tradingBtcAssets = trackedBtcAssetIds.map((assetId) => { if (assetId === tradingBtc.assetId) return tradingBtc; @@ -215,13 +208,12 @@ export function loadConfig({ envPath = '.env' } = {}) { }); }); const tradingEure = buildAsset({ - assetId: process.env.TRADING_EURE_ASSET_ID || DEFAULTS.tradingEureAssetId, - symbol: process.env.TRADING_EURE_SYMBOL || DEFAULTS.tradingEureSymbol, - label: process.env.TRADING_EURE_LABEL || DEFAULTS.tradingEureSymbol, - decimals: parseNumber(process.env.TRADING_EURE_DECIMALS, DEFAULTS.tradingEureDecimals), - chain: process.env.TRADING_EURE_CHAIN || DEFAULTS.tradingEureChain, - withdrawAddress: - process.env.TRADING_EURE_WITHDRAW_ADDRESS || DEFAULTS.tradingEureWithdrawAddress, + assetId: DEFAULTS.tradingEureAssetId, + symbol: DEFAULTS.tradingEureSymbol, + label: DEFAULTS.tradingEureSymbol, + decimals: DEFAULTS.tradingEureDecimals, + chain: DEFAULTS.tradingEureChain, + withdrawAddress: DEFAULTS.tradingEureWithdrawAddress, role: 'trading', }); const trackedAssets = [ @@ -243,13 +235,6 @@ export function loadConfig({ envPath = '.env' } = {}) { nearRpcUrl: process.env.NEAR_RPC_URL || DEFAULTS.nearRpcUrl, nearVerifierContract: process.env.NEAR_INTENTS_VERIFIER_CONTRACT || DEFAULTS.nearVerifierContract, - nearIntentsPairFilter: - process.env.NEAR_INTENTS_PAIR_FILTER || DEFAULTS.nearIntentsPairFilter, - nearIntentsPairFilterFile: process.env.NEAR_INTENTS_PAIR_FILTER_FILE || '', - nearIntentsPairFilterReloadMs: parseNumber( - process.env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS, - DEFAULTS.nearIntentsPairFilterReloadMs, - ), nearIntentsControlApiEnabled: parseBoolean( process.env.NEAR_INTENTS_CONTROL_API_ENABLED, DEFAULTS.nearIntentsControlApiEnabled, @@ -388,6 +373,16 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.OPERATOR_DASHBOARD_CONTROL_PORT, DEFAULTS.operatorDashboardControlPort, ), + operatorDashboardControlBaseUrl: + process.env.OPERATOR_DASHBOARD_CONTROL_BASE_URL + || defaultControlBaseUrl({ + serviceName: 'operator-dashboard', + port: parseNumber( + process.env.OPERATOR_DASHBOARD_CONTROL_PORT, + DEFAULTS.operatorDashboardControlPort, + ), + namespace: projectNamespace, + }), kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length ? splitCsv(process.env.KAFKA_BROKERS) : DEFAULTS.kafkaBrokers, @@ -469,26 +464,14 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.LIQUIDITY_REFRESH_MS, DEFAULTS.liquidityRefreshMs, ), - strategyGrossThresholdPct: parseNumber( - process.env.STRATEGY_GROSS_THRESHOLD_PCT, - DEFAULTS.strategyGrossThresholdPct, - ), + strategyGrossThresholdPct: DEFAULTS.strategyGrossThresholdPct, strategyInitialArmed: parseBoolean( process.env.STRATEGY_INITIAL_ARMED, DEFAULTS.strategyInitialArmed, ), - strategyMaxNotionalEure: parseNumber( - process.env.STRATEGY_MAX_NOTIONAL_EURE, - DEFAULTS.strategyMaxNotionalEure, - ), - strategyPriceMaxAgeMs: parseNumber( - process.env.STRATEGY_PRICE_MAX_AGE_MS, - DEFAULTS.strategyPriceMaxAgeMs, - ), - strategyInventoryMaxAgeMs: parseNumber( - process.env.STRATEGY_INVENTORY_MAX_AGE_MS, - DEFAULTS.strategyInventoryMaxAgeMs, - ), + strategyMaxNotionalEure: DEFAULTS.strategyMaxNotionalEure, + strategyPriceMaxAgeMs: DEFAULTS.strategyPriceMaxAgeMs, + strategyInventoryMaxAgeMs: DEFAULTS.strategyInventoryMaxAgeMs, executorInitialArmed: parseBoolean( process.env.EXECUTOR_INITIAL_ARMED, DEFAULTS.executorInitialArmed, @@ -497,26 +480,11 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.EXECUTOR_RESPONSE_TIMEOUT_MS, DEFAULTS.executorResponseTimeoutMs, ), - intentRequestDefaultAmountEure: parseNumber( - process.env.INTENT_REQUEST_DEFAULT_AMOUNT_EURE, - DEFAULTS.intentRequestDefaultAmountEure, - ), - intentRequestMaxAmountEure: parseNumber( - process.env.INTENT_REQUEST_MAX_AMOUNT_EURE, - DEFAULTS.intentRequestMaxAmountEure, - ), - intentRequestDefaultSlippageBps: parseNumber( - process.env.INTENT_REQUEST_DEFAULT_SLIPPAGE_BPS, - DEFAULTS.intentRequestDefaultSlippageBps, - ), - intentRequestMaxSlippageBps: parseNumber( - process.env.INTENT_REQUEST_MAX_SLIPPAGE_BPS, - DEFAULTS.intentRequestMaxSlippageBps, - ), - intentRequestMinDeadlineMs: parseNumber( - process.env.INTENT_REQUEST_MIN_DEADLINE_MS, - DEFAULTS.intentRequestMinDeadlineMs, - ), + intentRequestDefaultAmountEure: DEFAULTS.intentRequestDefaultAmountEure, + intentRequestMaxAmountEure: DEFAULTS.intentRequestMaxAmountEure, + intentRequestDefaultSlippageBps: DEFAULTS.intentRequestDefaultSlippageBps, + intentRequestMaxSlippageBps: DEFAULTS.intentRequestMaxSlippageBps, + intentRequestMinDeadlineMs: DEFAULTS.intentRequestMinDeadlineMs, intentRequestQuoteTimeoutMs: parseNumber( process.env.INTENT_REQUEST_QUOTE_TIMEOUT_MS, DEFAULTS.intentRequestQuoteTimeoutMs, @@ -529,14 +497,8 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.INTENT_REQUEST_STATUS_TIMEOUT_MS, DEFAULTS.intentRequestStatusTimeoutMs, ), - intentRequestInventoryMaxAgeMs: parseNumber( - process.env.INTENT_REQUEST_INVENTORY_MAX_AGE_MS, - DEFAULTS.intentRequestInventoryMaxAgeMs, - ), - intentRequestPriceMaxAgeMs: parseNumber( - process.env.INTENT_REQUEST_PRICE_MAX_AGE_MS, - DEFAULTS.intentRequestPriceMaxAgeMs, - ), + intentRequestInventoryMaxAgeMs: DEFAULTS.intentRequestInventoryMaxAgeMs, + intentRequestPriceMaxAgeMs: DEFAULTS.intentRequestPriceMaxAgeMs, withdrawalsFrozen: parseBoolean( process.env.LIQUIDITY_WITHDRAWALS_FROZEN, DEFAULTS.withdrawalsFrozen, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 8efa33f..19bc3c4 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -2,6 +2,18 @@ import { Pool } from 'pg'; import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs'; import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs'; +import { + ONE_CLICK_TOKENS_URL, + buildSeedAssets, + buildSeedPairs, + buildSeedPriceRoute, + buildSeedStrategyConfig, + hashJson, + normalizeOneClickTokenResponse, + pairCanMake, + pairCanObserve, + pairCanTake, +} from '../core/trading-config.mjs'; const TABLES = [ 'raw_near_intents_quotes', @@ -22,6 +34,12 @@ const TABLES = [ const PORTFOLIO_METRICS_TABLE = 'portfolio_metrics_snapshots'; const QUOTE_OUTCOMES_TABLE = 'quote_outcome_attributions'; const INTENT_REQUEST_OUTCOMES_TABLE = 'intent_request_outcomes'; +const SUPPORTED_ASSET_IMPORT_RUNS_TABLE = 'supported_asset_import_runs'; +const TRADING_ASSETS_TABLE = 'trading_assets'; +const TRADING_PAIRS_TABLE = 'trading_pairs'; +const PAIR_STRATEGY_CONFIGS_TABLE = 'pair_strategy_configs'; +const PAIR_PRICE_ROUTES_TABLE = 'pair_price_routes'; +const PAIR_CONFIG_AUDIT_LOG_TABLE = 'pair_config_audit_log'; const CREDITED_LIQUIDITY_STATUSES = ['CREDITED', 'COMPLETED', 'FINALIZED', 'SETTLED']; const COMPLETED_WITHDRAWAL_STATUSES = ['COMPLETED', 'FINALIZED', 'SETTLED']; @@ -31,7 +49,30 @@ export function createPostgresPool({ connectionString }) { }); } +async function withTransaction(pool, operation) { + if (typeof pool.connect !== 'function') return operation(pool); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await operation(client); + await client.query('COMMIT'); + return result; + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch { + // Preserve the original transaction failure. + } + throw error; + } finally { + client.release(); + } +} + export async function ensureHistorySchema(pool) { + await ensureTradingConfigSchema(pool); + for (const table of TABLES) { await pool.query(` CREATE TABLE IF NOT EXISTS ${table} ( @@ -237,6 +278,1219 @@ export async function ensureHistorySchema(pool) { `); } +export async function ensureTradingConfigSchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE} ( + run_id TEXT PRIMARY KEY, + source_url TEXT NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, + token_count INTEGER NOT NULL DEFAULT 0, + added_count INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + unchanged_count INTEGER NOT NULL DEFAULT 0, + retired_count INTEGER NOT NULL DEFAULT 0, + raw_response_hash TEXT, + error TEXT, + raw_response JSONB + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE}_fetched_at_idx + ON ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE} (fetched_at DESC) + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${TRADING_ASSETS_TABLE} ( + asset_id TEXT PRIMARY KEY, + venue TEXT NOT NULL, + symbol TEXT NOT NULL, + label TEXT NOT NULL, + decimals INTEGER NOT NULL, + blockchain TEXT, + chain TEXT, + contract_address TEXT, + latest_price TEXT, + price_updated_at TIMESTAMPTZ, + supported BOOLEAN NOT NULL DEFAULT false, + retired_at TIMESTAMPTZ, + enabled_for_inventory BOOLEAN NOT NULL DEFAULT false, + role TEXT, + withdraw_address TEXT, + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_supported_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${TRADING_ASSETS_TABLE}_supported_idx + ON ${TRADING_ASSETS_TABLE} (supported, updated_at DESC) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${TRADING_ASSETS_TABLE}_inventory_idx + ON ${TRADING_ASSETS_TABLE} (enabled_for_inventory, asset_id) + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${TRADING_PAIRS_TABLE} ( + pair_id TEXT PRIMARY KEY, + venue TEXT NOT NULL, + asset_in TEXT NOT NULL REFERENCES ${TRADING_ASSETS_TABLE}(asset_id), + asset_out TEXT NOT NULL REFERENCES ${TRADING_ASSETS_TABLE}(asset_id), + mode TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'disabled', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (venue, asset_in, asset_out) + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${TRADING_PAIRS_TABLE}_enabled_idx + ON ${TRADING_PAIRS_TABLE} (enabled, status, updated_at DESC) + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${PAIR_STRATEGY_CONFIGS_TABLE} ( + config_id TEXT PRIMARY KEY, + pair_id TEXT NOT NULL REFERENCES ${TRADING_PAIRS_TABLE}(pair_id), + version INTEGER NOT NULL, + active BOOLEAN NOT NULL DEFAULT false, + edge_bps INTEGER NOT NULL, + max_notional NUMERIC NOT NULL, + min_notional NUMERIC NOT NULL DEFAULT 0, + slippage_bps INTEGER NOT NULL DEFAULT 0, + min_deadline_ms INTEGER NOT NULL, + price_max_age_ms INTEGER NOT NULL, + inventory_max_age_ms INTEGER NOT NULL, + request_default_notional NUMERIC, + request_max_notional NUMERIC, + request_max_slippage_bps INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT NOT NULL, + reason TEXT, + UNIQUE (pair_id, version) + ) + `); + await pool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ${PAIR_STRATEGY_CONFIGS_TABLE}_active_one_idx + ON ${PAIR_STRATEGY_CONFIGS_TABLE} (pair_id) + WHERE active + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${PAIR_PRICE_ROUTES_TABLE} ( + route_id TEXT PRIMARY KEY, + pair_id TEXT NOT NULL REFERENCES ${TRADING_PAIRS_TABLE}(pair_id), + source TEXT NOT NULL, + base_asset_id TEXT NOT NULL REFERENCES ${TRADING_ASSETS_TABLE}(asset_id), + quote_asset_id TEXT NOT NULL REFERENCES ${TRADING_ASSETS_TABLE}(asset_id), + route_config JSONB NOT NULL DEFAULT '{}'::jsonb, + max_age_ms INTEGER NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${PAIR_PRICE_ROUTES_TABLE}_pair_enabled_idx + ON ${PAIR_PRICE_ROUTES_TABLE} (pair_id, enabled) + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS ${PAIR_CONFIG_AUDIT_LOG_TABLE} ( + audit_id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + action TEXT NOT NULL, + old_value JSONB, + new_value JSONB, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + changed_by TEXT NOT NULL, + reason TEXT + ) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS ${PAIR_CONFIG_AUDIT_LOG_TABLE}_entity_idx + ON ${PAIR_CONFIG_AUDIT_LOG_TABLE} (entity_type, entity_id, changed_at DESC) + `); +} + +export async function seedTradingConfig(pool, { + now = new Date().toISOString(), + changedBy = 'repo_seed', +} = {}) { + await ensureTradingConfigSchema(pool); + + for (const asset of buildSeedAssets()) { + await upsertSeedAsset(pool, { asset, now }); + } + + for (const pair of buildSeedPairs()) { + await upsertSeedPair(pool, { pair, now, preserveRuntimeState: true }); + await upsertSeedStrategyConfig(pool, { + config: buildSeedStrategyConfig(pair.pairId, { createdBy: changedBy }), + }); + await upsertSeedPriceRoute(pool, { + route: buildSeedPriceRoute(pair.pairId), + now, + }); + } + + return loadTradingConfig(pool); +} + +export async function importSupportedAssets(pool, { + sourceUrl = ONE_CLICK_TOKENS_URL, + fetchedAt = new Date().toISOString(), + fetchImpl = globalThis.fetch, + response = null, +} = {}) { + await ensureTradingConfigSchema(pool); + + let rawResponse = response; + let rawResponseHash = null; + let runId = null; + + try { + if (rawResponse == null) { + if (typeof fetchImpl !== 'function') throw new Error('fetch implementation is unavailable'); + const fetchResponse = await fetchImpl(sourceUrl); + const text = await fetchResponse.text(); + if (!fetchResponse.ok) throw new Error(`HTTP ${fetchResponse.status}: ${text.slice(0, 200)}`); + rawResponse = text ? JSON.parse(text) : null; + } + + const normalizedTokens = normalizeOneClickTokenResponse(rawResponse); + rawResponseHash = hashJson(rawResponse); + runId = `asset-import:${Date.parse(fetchedAt)}:${rawResponseHash.slice(0, 16)}`; + + const existing = await loadTradingAssetsById(pool); + let addedCount = 0; + let updatedCount = 0; + let unchangedCount = 0; + + for (const token of normalizedTokens) { + const previous = existing.get(token.assetId) || null; + if (!previous) addedCount += 1; + else if (importedAssetChanged(previous, token)) updatedCount += 1; + else unchangedCount += 1; + + await upsertImportedAsset(pool, { + asset: token, + fetchedAt, + }); + } + + const importedAssetIds = normalizedTokens.map((token) => token.assetId); + const retiredResult = await pool.query( + ` + UPDATE ${TRADING_ASSETS_TABLE} + SET + supported = false, + retired_at = COALESCE(retired_at, $1), + updated_at = $1 + WHERE venue = 'near-intents' + AND supported = true + AND NOT (asset_id = ANY($2::text[])) + RETURNING asset_id + `, + [fetchedAt, importedAssetIds], + ); + + const summary = { + run_id: runId, + source_url: sourceUrl, + fetched_at: fetchedAt, + status: 'success', + token_count: normalizedTokens.length, + added_count: addedCount, + updated_count: updatedCount, + unchanged_count: unchangedCount, + retired_count: retiredResult.rowCount, + raw_response_hash: rawResponseHash, + error: null, + raw_response: rawResponse, + }; + await insertAssetImportRun(pool, summary); + return publicAssetImportRunSummary(summary); + } catch (error) { + const fallbackHash = rawResponse == null ? null : hashJson(rawResponse); + runId ||= `asset-import:${Date.parse(fetchedAt)}:${fallbackHash?.slice(0, 16) || 'failed'}`; + const summary = { + run_id: runId, + source_url: sourceUrl, + fetched_at: fetchedAt, + status: 'failed', + token_count: 0, + added_count: 0, + updated_count: 0, + unchanged_count: 0, + retired_count: 0, + raw_response_hash: fallbackHash, + error: error.message, + raw_response: rawResponse, + }; + await insertAssetImportRun(pool, summary); + throw Object.assign(error, { importRun: publicAssetImportRunSummary(summary) }); + } +} + +export async function loadTradingConfig(pool) { + await ensureTradingConfigSchema(pool); + + const [ + assetResult, + pairResult, + strategyResult, + routeResult, + latestImportResult, + ] = await Promise.all([ + pool.query(` + SELECT * + FROM ${TRADING_ASSETS_TABLE} + ORDER BY symbol ASC, asset_id ASC + `), + pool.query(` + SELECT * + FROM ${TRADING_PAIRS_TABLE} + ORDER BY created_at ASC, pair_id ASC + `), + pool.query(` + SELECT * + FROM ${PAIR_STRATEGY_CONFIGS_TABLE} + WHERE active = true + ORDER BY pair_id ASC, version DESC + `), + pool.query(` + SELECT * + FROM ${PAIR_PRICE_ROUTES_TABLE} + WHERE enabled = true + ORDER BY pair_id ASC, created_at DESC + `), + pool.query(` + SELECT * + FROM ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE} + ORDER BY fetched_at DESC + LIMIT 1 + `), + ]); + + return buildTradingConfigSnapshot({ + assetRows: assetResult.rows, + pairRows: pairResult.rows, + strategyRows: strategyResult.rows, + routeRows: routeResult.rows, + latestImportRun: latestImportResult.rows[0] || null, + }); +} + +export function createTradingConfigStore({ + pool, + cacheTtlMs = 5_000, + logger = null, +} = {}) { + if (!pool) throw new Error('pool is required'); + let cached = null; + let cachedAtMs = 0; + let lastError = null; + + async function refresh({ force = false } = {}) { + const nowMs = Date.now(); + if (!force && cached && nowMs - cachedAtMs <= cacheTtlMs) return cached; + + try { + cached = await loadTradingConfig(pool); + cachedAtMs = nowMs; + lastError = null; + return cached; + } catch (error) { + lastError = error; + logger?.error?.('trading_config_load_failed', { + details: { error: error.message }, + }); + cached = buildFailClosedTradingConfig(error); + cachedAtMs = nowMs; + return cached; + } + } + + return { + getConfig: refresh, + async forceRefresh() { + return refresh({ force: true }); + }, + getCachedConfig() { + return cached || buildFailClosedTradingConfig(lastError || new Error('config not loaded')); + }, + getState() { + return summarizeTradingConfigSnapshot( + cached || buildFailClosedTradingConfig(lastError || new Error('config not loaded')), + ); + }, + }; +} + +export async function loadAssetCatalogSummary(pool, { limit = 50 } = {}) { + await ensureTradingConfigSchema(pool); + const [snapshot, countResult] = await Promise.all([ + loadTradingConfig(pool), + pool.query(` + SELECT + COUNT(*)::INT AS known_count, + COUNT(*) FILTER (WHERE supported)::INT AS supported_count, + COUNT(*) FILTER (WHERE retired_at IS NOT NULL OR supported = false)::INT AS retired_count, + COUNT(*) FILTER (WHERE enabled_for_inventory)::INT AS inventory_enabled_count + FROM ${TRADING_ASSETS_TABLE} + `), + ]); + const counts = countResult.rows[0] || {}; + return { + latest_import: snapshot.latestImportRun, + counts: { + known: Number(counts.known_count || 0), + supported: Number(counts.supported_count || 0), + retired: Number(counts.retired_count || 0), + inventory_enabled: Number(counts.inventory_enabled_count || 0), + }, + items: snapshot.assets.slice(0, Math.max(1, Number(limit) || 50)), + }; +} + +export async function loadPairConfigSummary(pool) { + const snapshot = await loadTradingConfig(pool); + return { + ok: snapshot.ok, + block_reason: snapshot.blockReason, + loaded_at: snapshot.loadedAt, + pairs: snapshot.pairs, + }; +} + +export async function createPairStrategyConfigVersion(pool, { + pairId = null, + pair = null, + edgeBps = null, + maxNotional = null, + minNotional = null, + slippageBps = null, + minDeadlineMs = null, + priceMaxAgeMs = null, + inventoryMaxAgeMs = null, + requestDefaultNotional = null, + requestMaxNotional = null, + requestMaxSlippageBps = null, + changedBy = 'operator', + reason = 'operator config update', +} = {}) { + await ensureTradingConfigSchema(pool); + const resolvedPairId = pairId || pair; + if (!resolvedPairId) throw new Error('pair_id is required'); + + return withTransaction(pool, async (client) => { + const activeResult = await client.query( + ` + SELECT * + FROM ${PAIR_STRATEGY_CONFIGS_TABLE} + WHERE pair_id = $1 AND active = true + ORDER BY version DESC + LIMIT 1 + `, + [resolvedPairId], + ); + const active = activeResult.rows[0]; + if (!active) throw new Error(`active strategy config missing for pair ${resolvedPairId}`); + + const nextVersion = Number(active.version || 0) + 1; + const nextEdgeBps = edgeBps == null ? Number(active.edge_bps) : Number(edgeBps); + if (!Number.isInteger(nextEdgeBps) || nextEdgeBps <= 0) { + throw new Error('edge_bps must be a positive integer'); + } + + const nextConfig = { + configId: `${resolvedPairId}:v${nextVersion}`, + pairId: resolvedPairId, + version: nextVersion, + edgeBps: nextEdgeBps, + maxNotional: maxNotional == null ? String(active.max_notional) : String(maxNotional), + minNotional: minNotional == null ? String(active.min_notional) : String(minNotional), + slippageBps: slippageBps == null ? Number(active.slippage_bps) : Number(slippageBps), + minDeadlineMs: minDeadlineMs == null ? Number(active.min_deadline_ms) : Number(minDeadlineMs), + priceMaxAgeMs: priceMaxAgeMs == null ? Number(active.price_max_age_ms) : Number(priceMaxAgeMs), + inventoryMaxAgeMs: + inventoryMaxAgeMs == null ? Number(active.inventory_max_age_ms) : Number(inventoryMaxAgeMs), + requestDefaultNotional: + requestDefaultNotional == null + ? active.request_default_notional == null ? null : String(active.request_default_notional) + : String(requestDefaultNotional), + requestMaxNotional: + requestMaxNotional == null + ? active.request_max_notional == null ? null : String(active.request_max_notional) + : String(requestMaxNotional), + requestMaxSlippageBps: + requestMaxSlippageBps == null + ? active.request_max_slippage_bps == null ? null : Number(active.request_max_slippage_bps) + : Number(requestMaxSlippageBps), + createdBy: changedBy, + reason, + }; + + await client.query( + `UPDATE ${PAIR_STRATEGY_CONFIGS_TABLE} SET active = false WHERE pair_id = $1 AND active = true`, + [resolvedPairId], + ); + await insertPairStrategyConfig(client, { config: nextConfig, active: true }); + await insertConfigAuditLog(client, { + entityType: 'pair_strategy_config', + entityId: resolvedPairId, + action: 'version_created', + oldValue: normalizeStrategyConfigRow(active), + newValue: nextConfig, + changedBy, + reason, + }); + + return normalizeStrategyConfigRow({ + ...active, + config_id: nextConfig.configId, + pair_id: nextConfig.pairId, + version: nextConfig.version, + active: true, + edge_bps: nextConfig.edgeBps, + max_notional: nextConfig.maxNotional, + min_notional: nextConfig.minNotional, + slippage_bps: nextConfig.slippageBps, + min_deadline_ms: nextConfig.minDeadlineMs, + price_max_age_ms: nextConfig.priceMaxAgeMs, + inventory_max_age_ms: nextConfig.inventoryMaxAgeMs, + request_default_notional: nextConfig.requestDefaultNotional, + request_max_notional: nextConfig.requestMaxNotional, + request_max_slippage_bps: nextConfig.requestMaxSlippageBps, + created_by: changedBy, + reason, + }); + }); +} + +export async function enableObserveOnlyPair(pool, { + venue = 'near-intents', + assetIn, + assetOut, + changedBy = 'operator', + reason = 'operator enabled observe-only pair', +} = {}) { + await ensureTradingConfigSchema(pool); + if (!assetIn || !assetOut) throw new Error('asset_in and asset_out are required'); + const pairId = `${assetIn}->${assetOut}`; + const existingResult = await pool.query( + ` + SELECT * + FROM ${TRADING_PAIRS_TABLE} + WHERE pair_id = $1 + LIMIT 1 + `, + [pairId], + ); + const existingPair = existingResult.rows[0] ? normalizeTradingPairRow(existingResult.rows[0]) : null; + if (existingPair?.enabled && existingPair.status !== 'disabled') { + return existingPair; + } + const pair = { + pairId, + venue, + assetIn, + assetOut, + mode: 'observe_only', + enabled: true, + status: 'observe_only', + }; + await upsertSeedPair(pool, { pair, now: new Date().toISOString() }); + await insertConfigAuditLog(pool, { + entityType: 'trading_pair', + entityId: pairId, + action: 'observe_only_enabled', + oldValue: null, + newValue: pair, + changedBy, + reason, + }); + return pair; +} + +function buildTradingConfigSnapshot({ + assetRows, + pairRows, + strategyRows, + routeRows, + latestImportRun, +}) { + const loadedAt = new Date().toISOString(); + const assets = assetRows.map(normalizeTradingAssetRow); + const assetRegistry = new Map(assets.map((asset) => [asset.assetId, asset])); + const strategyByPairId = new Map( + strategyRows.map((row) => [row.pair_id, normalizeStrategyConfigRow(row)]), + ); + const routeByPairId = new Map(); + for (const row of routeRows) { + if (!routeByPairId.has(row.pair_id)) routeByPairId.set(row.pair_id, normalizePriceRouteRow(row)); + } + + const pairs = pairRows.map((row) => { + const pair = normalizeTradingPairRow(row); + const assetIn = assetRegistry.get(pair.assetIn) || null; + const assetOut = assetRegistry.get(pair.assetOut) || null; + const strategyConfig = strategyByPairId.get(pair.pairId) || null; + const priceRoute = routeByPairId.get(pair.pairId) || null; + const blockReasons = []; + if (!assetIn) blockReasons.push('asset_in_missing'); + if (!assetOut) blockReasons.push('asset_out_missing'); + if (assetIn && !Number.isInteger(assetIn.decimals)) blockReasons.push('asset_in_decimals_missing'); + if (assetOut && !Number.isInteger(assetOut.decimals)) blockReasons.push('asset_out_decimals_missing'); + if ((pairCanMake(pair) || pairCanTake(pair)) && !strategyConfig) { + blockReasons.push('pair_strategy_config_missing'); + } + if ((pairCanMake(pair) || pairCanTake(pair)) && !priceRoute) { + blockReasons.push('price_route_missing'); + } + if (strategyConfig && (!Number.isInteger(strategyConfig.edgeBps) || strategyConfig.edgeBps <= 0)) { + blockReasons.push('edge_bps_invalid'); + } + if (strategyConfig && !(Number(strategyConfig.maxNotional) > 0)) { + blockReasons.push('max_notional_invalid'); + } + + const observeEnabled = pairCanObserve(pair); + const makerEnabled = pairCanMake(pair); + const takerEnabled = pairCanTake(pair); + const canTrade = (makerEnabled || takerEnabled) && blockReasons.length === 0; + return { + ...pair, + key: pair.pairId, + assetIn, + assetOut, + asset_in: pair.assetIn, + asset_out: pair.assetOut, + asset_in_symbol: assetIn?.symbol || pair.assetIn, + asset_out_symbol: assetOut?.symbol || pair.assetOut, + strategyConfig, + priceRoute, + observeEnabled, + makerEnabled, + takerEnabled, + canTrade, + blockReason: blockReasons[0] || null, + blockReasons, + }; + }); + + const observedPairs = pairs.filter((pair) => pair.observeEnabled); + const enabledPairKeys = new Set(observedPairs.map((pair) => pair.key)); + const makerPairKeys = new Set(pairs.filter((pair) => pair.makerEnabled).map((pair) => pair.key)); + const takerPairKeys = new Set(pairs.filter((pair) => pair.takerEnabled).map((pair) => pair.key)); + const pairByKey = new Map(pairs.map((pair) => [pair.key, pair])); + const pairById = new Map(pairs.map((pair) => [pair.pairId, pair])); + const pairStrategyByPairKey = new Map( + pairs + .filter((pair) => pair.strategyConfig) + .map((pair) => [pair.key, pair.strategyConfig]), + ); + const pairPriceRouteByPairKey = new Map( + pairs + .filter((pair) => pair.priceRoute) + .map((pair) => [pair.key, pair.priceRoute]), + ); + const trackedAssets = assets.filter((asset) => asset.enabledForInventory); + const currentBtc = assetRegistry.get('nep141:nbtc.bridge.near') || trackedAssets.find((asset) => asset.symbol === 'BTC') || null; + const legacyBtc = assetRegistry.get('nep141:btc.omft.near') || null; + const currentEure = assetRegistry.get('nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near') + || trackedAssets.find((asset) => asset.symbol === 'EURe') + || null; + const preferredActivePair = pairByKey.get('nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near') + || observedPairs[0] + || null; + const defaultTakerPair = pairByKey.get('nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near->nep141:nbtc.bridge.near') + || pairs.find((pair) => pair.takerEnabled && pair.canTrade) + || null; + const activeAssetIds = preferredActivePair?.assetIn && preferredActivePair?.assetOut + ? [preferredActivePair.assetIn.assetId, preferredActivePair.assetOut.assetId] + : []; + const blockReason = observedPairs.length === 0 + ? 'no_enabled_pairs' + : trackedAssets.length === 0 + ? 'no_inventory_tracked_assets' + : null; + + return { + ok: blockReason == null, + source: 'postgres', + loadedAt, + blockReason, + latestImportRun: normalizeAssetImportRunRow(latestImportRun), + assets, + assetRegistry, + trackedAssets, + trackedAssetIds: trackedAssets.map((asset) => asset.assetId), + tradingBtc: currentBtc, + tradingBtcAssets: [currentBtc, legacyBtc].filter(Boolean), + tradingEure: currentEure, + activePair: preferredActivePair?.key || null, + activeAssetIds, + pairs, + observedPairs, + enabledPairKeys, + makerPairKeys, + takerPairKeys, + pairByKey, + pairById, + pairStrategyByPairKey, + pairPriceRouteByPairKey, + defaultTakerPair, + tradingConfigLoaded: true, + requireDbTradingConfig: true, + }; +} + +function buildFailClosedTradingConfig(error) { + return { + ok: false, + source: 'postgres', + loadedAt: new Date().toISOString(), + blockReason: 'trading_config_unavailable', + error: error?.message || 'trading config unavailable', + latestImportRun: null, + assets: [], + assetRegistry: new Map(), + trackedAssets: [], + trackedAssetIds: [], + tradingBtc: null, + tradingBtcAssets: [], + tradingEure: null, + activePair: null, + activeAssetIds: [], + pairs: [], + observedPairs: [], + enabledPairKeys: new Set(), + makerPairKeys: new Set(), + takerPairKeys: new Set(), + pairByKey: new Map(), + pairById: new Map(), + pairStrategyByPairKey: new Map(), + pairPriceRouteByPairKey: new Map(), + defaultTakerPair: null, + tradingConfigLoaded: false, + requireDbTradingConfig: true, + }; +} + +export function summarizeTradingConfigSnapshot(snapshot) { + return { + ok: snapshot.ok, + source: snapshot.source, + loaded_at: snapshot.loadedAt, + block_reason: snapshot.blockReason || null, + error: snapshot.error || null, + latest_import: snapshot.latestImportRun || null, + asset_count: snapshot.assets?.length || 0, + tracked_asset_count: snapshot.trackedAssets?.length || 0, + enabled_pair_count: snapshot.observedPairs?.length || 0, + active_pair: snapshot.activePair || null, + pairs: (snapshot.pairs || []).map((pair) => ({ + pair_id: pair.pairId, + pair: pair.key, + mode: pair.mode, + status: pair.status, + enabled: pair.enabled, + can_trade: pair.canTrade, + block_reason: pair.blockReason, + strategy_config_id: pair.strategyConfig?.configId || null, + strategy_config_version: pair.strategyConfig?.version || null, + edge_bps: pair.strategyConfig?.edgeBps ?? null, + max_notional: pair.strategyConfig?.maxNotional ?? null, + price_route_id: pair.priceRoute?.routeId || null, + })), + }; +} + +function normalizeTradingAssetRow(row) { + return { + assetId: row.asset_id, + asset_id: row.asset_id, + venue: row.venue, + symbol: row.symbol, + label: row.label || row.symbol, + decimals: Number(row.decimals), + blockchain: row.blockchain || null, + chain: row.chain || row.blockchain || null, + contractAddress: row.contract_address || null, + contract_address: row.contract_address || null, + latestPrice: row.latest_price == null ? null : String(row.latest_price), + latest_price: row.latest_price == null ? null : String(row.latest_price), + priceUpdatedAt: toIsoTimestamp(row.price_updated_at), + price_updated_at: toIsoTimestamp(row.price_updated_at), + supported: row.supported === true, + retiredAt: toIsoTimestamp(row.retired_at), + retired_at: toIsoTimestamp(row.retired_at), + enabledForInventory: row.enabled_for_inventory === true, + enabled_for_inventory: row.enabled_for_inventory === true, + role: row.role || null, + withdrawAddress: row.withdraw_address || '', + withdraw_address: row.withdraw_address || '', + rawPayload: row.raw_payload || {}, + raw_payload: row.raw_payload || {}, + updated_at: toIsoTimestamp(row.updated_at), + }; +} + +function normalizeTradingPairRow(row) { + return { + pairId: row.pair_id, + pair_id: row.pair_id, + venue: row.venue, + assetIn: row.asset_in, + assetOut: row.asset_out, + mode: row.mode, + enabled: row.enabled === true, + status: row.status, + created_at: toIsoTimestamp(row.created_at), + updated_at: toIsoTimestamp(row.updated_at), + }; +} + +function normalizeStrategyConfigRow(row) { + if (!row) return null; + return { + configId: row.config_id, + config_id: row.config_id, + pairId: row.pair_id, + pair_id: row.pair_id, + version: Number(row.version), + active: row.active === true, + edgeBps: Number(row.edge_bps), + edge_bps: Number(row.edge_bps), + maxNotional: String(row.max_notional), + max_notional: String(row.max_notional), + minNotional: String(row.min_notional ?? '0'), + min_notional: String(row.min_notional ?? '0'), + slippageBps: Number(row.slippage_bps ?? 0), + slippage_bps: Number(row.slippage_bps ?? 0), + minDeadlineMs: Number(row.min_deadline_ms), + min_deadline_ms: Number(row.min_deadline_ms), + priceMaxAgeMs: Number(row.price_max_age_ms), + price_max_age_ms: Number(row.price_max_age_ms), + inventoryMaxAgeMs: Number(row.inventory_max_age_ms), + inventory_max_age_ms: Number(row.inventory_max_age_ms), + requestDefaultNotional: + row.request_default_notional == null ? null : String(row.request_default_notional), + request_default_notional: + row.request_default_notional == null ? null : String(row.request_default_notional), + requestMaxNotional: + row.request_max_notional == null ? null : String(row.request_max_notional), + request_max_notional: + row.request_max_notional == null ? null : String(row.request_max_notional), + requestMaxSlippageBps: + row.request_max_slippage_bps == null ? null : Number(row.request_max_slippage_bps), + request_max_slippage_bps: + row.request_max_slippage_bps == null ? null : Number(row.request_max_slippage_bps), + created_at: toIsoTimestamp(row.created_at), + created_by: row.created_by || null, + reason: row.reason || null, + }; +} + +function normalizePriceRouteRow(row) { + if (!row) return null; + return { + routeId: row.route_id, + route_id: row.route_id, + pairId: row.pair_id, + pair_id: row.pair_id, + source: row.source, + baseAssetId: row.base_asset_id, + base_asset_id: row.base_asset_id, + quoteAssetId: row.quote_asset_id, + quote_asset_id: row.quote_asset_id, + routeConfig: row.route_config || {}, + route_config: row.route_config || {}, + maxAgeMs: Number(row.max_age_ms), + max_age_ms: Number(row.max_age_ms), + enabled: row.enabled === true, + }; +} + +function normalizeAssetImportRunRow(row) { + if (!row) return null; + return { + run_id: row.run_id, + source_url: row.source_url, + fetched_at: toIsoTimestamp(row.fetched_at), + status: row.status, + token_count: Number(row.token_count || 0), + added_count: Number(row.added_count || 0), + updated_count: Number(row.updated_count || 0), + unchanged_count: Number(row.unchanged_count || 0), + retired_count: Number(row.retired_count || 0), + raw_response_hash: row.raw_response_hash || null, + error: row.error || null, + }; +} + +function publicAssetImportRunSummary(run) { + const { raw_response: _rawResponse, ...publicRun } = run; + return publicRun; +} + +async function upsertSeedAsset(pool, { asset, now }) { + await pool.query( + ` + INSERT INTO ${TRADING_ASSETS_TABLE} ( + asset_id, + venue, + symbol, + label, + decimals, + blockchain, + chain, + contract_address, + latest_price, + price_updated_at, + supported, + retired_at, + enabled_for_inventory, + role, + withdraw_address, + raw_payload, + last_supported_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NULL,$12,$13,$14,$15::jsonb,$16,$16) + ON CONFLICT (asset_id) DO UPDATE SET + venue = EXCLUDED.venue, + symbol = EXCLUDED.symbol, + label = EXCLUDED.label, + decimals = EXCLUDED.decimals, + blockchain = COALESCE(${TRADING_ASSETS_TABLE}.blockchain, EXCLUDED.blockchain), + chain = COALESCE(${TRADING_ASSETS_TABLE}.chain, EXCLUDED.chain), + contract_address = COALESCE(${TRADING_ASSETS_TABLE}.contract_address, EXCLUDED.contract_address), + supported = ${TRADING_ASSETS_TABLE}.supported OR EXCLUDED.supported, + enabled_for_inventory = true, + role = COALESCE(${TRADING_ASSETS_TABLE}.role, EXCLUDED.role), + withdraw_address = COALESCE(NULLIF(${TRADING_ASSETS_TABLE}.withdraw_address, ''), EXCLUDED.withdraw_address), + raw_payload = CASE + WHEN ${TRADING_ASSETS_TABLE}.raw_payload = '{}'::jsonb THEN EXCLUDED.raw_payload + ELSE ${TRADING_ASSETS_TABLE}.raw_payload + END, + last_supported_at = COALESCE(${TRADING_ASSETS_TABLE}.last_supported_at, EXCLUDED.last_supported_at), + updated_at = EXCLUDED.updated_at + `, + [ + asset.assetId, + asset.venue, + asset.symbol, + asset.label, + asset.decimals, + asset.blockchain, + asset.chain || asset.blockchain, + asset.contractAddress, + asset.latestPrice, + asset.priceUpdatedAt, + asset.supported, + asset.enabledForInventory, + asset.role, + asset.withdrawAddress || '', + JSON.stringify(asset.rawPayload || {}), + now, + ], + ); +} + +async function upsertSeedPair(pool, { pair, now, preserveRuntimeState = false }) { + const conflictUpdate = preserveRuntimeState + ? ` + venue = EXCLUDED.venue, + asset_in = EXCLUDED.asset_in, + asset_out = EXCLUDED.asset_out, + mode = ${TRADING_PAIRS_TABLE}.mode, + enabled = ${TRADING_PAIRS_TABLE}.enabled, + status = ${TRADING_PAIRS_TABLE}.status, + updated_at = EXCLUDED.updated_at + ` + : ` + venue = EXCLUDED.venue, + asset_in = EXCLUDED.asset_in, + asset_out = EXCLUDED.asset_out, + mode = EXCLUDED.mode, + enabled = EXCLUDED.enabled, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at + `; + await pool.query( + ` + INSERT INTO ${TRADING_PAIRS_TABLE} ( + pair_id, + venue, + asset_in, + asset_out, + mode, + enabled, + status, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) + ON CONFLICT (pair_id) DO UPDATE SET + ${conflictUpdate} + `, + [ + pair.pairId, + pair.venue, + pair.assetIn, + pair.assetOut, + pair.mode, + pair.enabled, + pair.status, + now, + ], + ); +} + +async function upsertSeedStrategyConfig(pool, { config }) { + await insertPairStrategyConfig(pool, { config, active: config.active !== false }); +} + +async function insertPairStrategyConfig(pool, { config, active = true }) { + await pool.query( + ` + INSERT INTO ${PAIR_STRATEGY_CONFIGS_TABLE} ( + config_id, + pair_id, + version, + active, + edge_bps, + max_notional, + min_notional, + slippage_bps, + min_deadline_ms, + price_max_age_ms, + inventory_max_age_ms, + request_default_notional, + request_max_notional, + request_max_slippage_bps, + created_by, + reason + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT (config_id) DO NOTHING + `, + [ + config.configId, + config.pairId, + config.version, + active, + config.edgeBps, + config.maxNotional, + config.minNotional, + config.slippageBps, + config.minDeadlineMs, + config.priceMaxAgeMs, + config.inventoryMaxAgeMs, + config.requestDefaultNotional, + config.requestMaxNotional, + config.requestMaxSlippageBps, + config.createdBy, + config.reason, + ], + ); +} + +async function upsertSeedPriceRoute(pool, { route, now }) { + await pool.query( + ` + INSERT INTO ${PAIR_PRICE_ROUTES_TABLE} ( + route_id, + pair_id, + source, + base_asset_id, + quote_asset_id, + route_config, + max_age_ms, + enabled, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$9) + ON CONFLICT (route_id) DO UPDATE SET + source = EXCLUDED.source, + base_asset_id = EXCLUDED.base_asset_id, + quote_asset_id = EXCLUDED.quote_asset_id, + route_config = EXCLUDED.route_config, + max_age_ms = EXCLUDED.max_age_ms, + enabled = ${PAIR_PRICE_ROUTES_TABLE}.enabled, + updated_at = EXCLUDED.updated_at + `, + [ + route.routeId, + route.pairId, + route.source, + route.baseAssetId, + route.quoteAssetId, + JSON.stringify(route.routeConfig || {}), + route.maxAgeMs, + route.enabled, + now, + ], + ); +} + +async function loadTradingAssetsById(pool) { + const result = await pool.query(`SELECT * FROM ${TRADING_ASSETS_TABLE}`); + return new Map(result.rows.map((row) => [row.asset_id, normalizeTradingAssetRow(row)])); +} + +async function upsertImportedAsset(pool, { asset, fetchedAt }) { + await pool.query( + ` + INSERT INTO ${TRADING_ASSETS_TABLE} ( + asset_id, + venue, + symbol, + label, + decimals, + blockchain, + chain, + contract_address, + latest_price, + price_updated_at, + supported, + retired_at, + enabled_for_inventory, + raw_payload, + last_supported_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true,NULL,false,$11::jsonb,$12,$12) + ON CONFLICT (asset_id) DO UPDATE SET + venue = EXCLUDED.venue, + symbol = EXCLUDED.symbol, + label = EXCLUDED.label, + decimals = EXCLUDED.decimals, + blockchain = EXCLUDED.blockchain, + chain = COALESCE(${TRADING_ASSETS_TABLE}.chain, EXCLUDED.chain), + contract_address = EXCLUDED.contract_address, + latest_price = EXCLUDED.latest_price, + price_updated_at = EXCLUDED.price_updated_at, + supported = true, + retired_at = NULL, + enabled_for_inventory = ${TRADING_ASSETS_TABLE}.enabled_for_inventory, + raw_payload = EXCLUDED.raw_payload, + last_supported_at = EXCLUDED.last_supported_at, + updated_at = EXCLUDED.updated_at + `, + [ + asset.assetId, + asset.venue, + asset.symbol, + asset.label, + asset.decimals, + asset.blockchain, + asset.chain || asset.blockchain, + asset.contractAddress, + asset.latestPrice, + asset.priceUpdatedAt, + JSON.stringify(asset.rawPayload || {}), + fetchedAt, + ], + ); +} + +async function insertAssetImportRun(pool, run) { + await pool.query( + ` + INSERT INTO ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE} ( + run_id, + source_url, + fetched_at, + status, + token_count, + added_count, + updated_count, + unchanged_count, + retired_count, + raw_response_hash, + error, + raw_response + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb) + ON CONFLICT (run_id) DO UPDATE SET + status = EXCLUDED.status, + token_count = EXCLUDED.token_count, + added_count = EXCLUDED.added_count, + updated_count = EXCLUDED.updated_count, + unchanged_count = EXCLUDED.unchanged_count, + retired_count = EXCLUDED.retired_count, + raw_response_hash = EXCLUDED.raw_response_hash, + error = EXCLUDED.error, + raw_response = EXCLUDED.raw_response + `, + [ + run.run_id, + run.source_url, + run.fetched_at, + run.status, + run.token_count, + run.added_count, + run.updated_count, + run.unchanged_count, + run.retired_count, + run.raw_response_hash, + run.error, + run.raw_response == null ? null : JSON.stringify(run.raw_response), + ], + ); +} + +async function insertConfigAuditLog(pool, { + entityType, + entityId, + action, + oldValue = null, + newValue = null, + changedBy, + reason, +}) { + await pool.query( + ` + INSERT INTO ${PAIR_CONFIG_AUDIT_LOG_TABLE} ( + audit_id, + entity_type, + entity_id, + action, + old_value, + new_value, + changed_by, + reason + ) VALUES ($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7,$8) + `, + [ + `audit:${Date.now()}:${Math.random().toString(16).slice(2)}`, + entityType, + entityId, + action, + oldValue == null ? null : JSON.stringify(oldValue), + newValue == null ? null : JSON.stringify(newValue), + changedBy, + reason, + ], + ); +} + +function importedAssetChanged(previous, next) { + return ( + previous.symbol !== next.symbol + || previous.label !== next.label + || previous.decimals !== next.decimals + || previous.blockchain !== next.blockchain + || previous.contractAddress !== next.contractAddress + || previous.latestPrice !== next.latestPrice + || previous.priceUpdatedAt !== next.priceUpdatedAt + || previous.supported !== true + || previous.retiredAt != null + ); +} export async function insertHistoryEvent(pool, { table, topic, event, record }) { await pool.query( @@ -1613,6 +2867,18 @@ function normalizeSubmissionRow(row) { execution_key: resultPayload.execution_key || commandPayload.execution_key || null, quote_id: resultPayload.quote_id || commandPayload.quote_id || decisionPayload.quote_id || null, pair: resultPayload.pair || commandPayload.pair || decisionPayload.pair || null, + pair_id: commandPayload.pair_id || decisionPayload.pair_id || resultPayload.pair_id || null, + pair_config_id: + commandPayload.pair_config_id + || decisionPayload.pair_config_id + || resultPayload.pair_config_id + || null, + pair_config_version: + commandPayload.pair_config_version + || decisionPayload.pair_config_version + || resultPayload.pair_config_version + || null, + edge_bps: commandPayload.edge_bps || decisionPayload.edge_bps || resultPayload.edge_bps || null, observed_at: toIsoTimestamp(row.result_observed_at || row.result_ingested_at), ingested_at: toIsoTimestamp(row.result_ingested_at), status: resultPayload.status || null, @@ -1642,6 +2908,10 @@ function normalizeExecuteTradeCommandRow(row) { execution_key: payload.execution_key || null, quote_id: payload.quote_id || null, pair: payload.pair || null, + pair_id: payload.pair_id || null, + pair_config_id: payload.pair_config_id || null, + pair_config_version: payload.pair_config_version || null, + edge_bps: payload.edge_bps || null, direction: payload.direction || null, request_kind: payload.request_kind || null, asset_in: payload.asset_in || null, @@ -1669,6 +2939,18 @@ function normalizeExecutionResultRow(row) { execution_key: resultPayload.execution_key || commandPayload.execution_key || null, quote_id: resultPayload.quote_id || commandPayload.quote_id || decisionPayload.quote_id || null, pair: resultPayload.pair || commandPayload.pair || decisionPayload.pair || null, + pair_id: commandPayload.pair_id || decisionPayload.pair_id || resultPayload.pair_id || null, + pair_config_id: + commandPayload.pair_config_id + || decisionPayload.pair_config_id + || resultPayload.pair_config_id + || null, + pair_config_version: + commandPayload.pair_config_version + || decisionPayload.pair_config_version + || resultPayload.pair_config_version + || null, + edge_bps: commandPayload.edge_bps || decisionPayload.edge_bps || resultPayload.edge_bps || null, command_at: toIsoTimestamp(row.command_ingested_at), result_at: toIsoTimestamp(row.result_observed_at || row.result_ingested_at), status: resultPayload.status || null, diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index 99d9a2c..7e04553 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -155,7 +155,7 @@ export default function App() { /> ) : null} {currentPage === 'strategy' ? ( - + ) : null} {currentPage === 'system' ? ( diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 63fe621..acffe48 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -294,7 +294,160 @@ function SuccessfulTradesTable({ items }) { ); } -export default function StrategyPage({ strategy }) { +function AssetCatalogSection({ assetCatalog, onControl }) { + const latest = assetCatalog?.latest_import || null; + const counts = assetCatalog?.counts || {}; + const items = assetCatalog?.items || []; + + return ( +
+
+
+
Asset registry
+

Supported-token import status

+
+ Last import {latest?.fetched_at ? formatTimestamp(latest.fetched_at) : 'not run'} +
+
+
+ + +
+
+
+ + + +
+ + + + + + + + + + + + + {items.length ? items.slice(0, 20).map((asset) => ( + + + + + + + + )) : ( + + )} + +
AssetDecimalsChainPriceStatus
+
{asset.label || asset.symbol}
+
{truncateMiddle(asset.asset_id || asset.assetId, 42)}
+
{asset.decimals}{asset.blockchain || asset.chain || 'Unavailable'}{asset.latest_price || asset.latestPrice || 'Unavailable'} + +
No DB asset registry rows are available.
+
+
+ ); +} + +function PairConfigSection({ pairConfig, onControl }) { + const pairs = pairConfig?.pairs || []; + + async function updateEdge(pair) { + const current = pair.strategyConfig?.edge_bps ?? pair.strategy_config?.edge_bps ?? pair.edge_bps ?? ''; + const next = window.prompt('edge_bps', current); + if (!next) return; + await onControl?.('operator-dashboard', 'update-pair-edge', { + pair_id: pair.pair_id || pair.pairId, + edge_bps: Number(next), + }); + } + + async function enableObserveOnly() { + const assetIn = window.prompt('asset_in'); + if (!assetIn) return; + const assetOut = window.prompt('asset_out'); + if (!assetOut) return; + await onControl?.('operator-dashboard', 'enable-observe-only-pair', { + asset_in: assetIn, + asset_out: assetOut, + }); + } + + return ( +
+
+
+
Pair config
+

Directed pairs and active strategy versions

+
+ Loaded {pairConfig?.loaded_at ? formatTimestamp(pairConfig.loaded_at) : 'unavailable'} +
+
+
+ + +
+
+ + + + + + + + + + + + + + + {pairs.length ? pairs.map((pair) => { + const strategyConfig = pair.strategyConfig || pair.strategy_config || {}; + const route = pair.priceRoute || pair.price_route || {}; + return ( + + + + + + + + + + ); + }) : ( + + )} + +
PairModeEdgeLimitsRouteBlockedConfig
+
{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}
+
{truncateMiddle(pair.pair_id || pair.pairId, 42)}
+
{strategyConfig.edge_bps ?? 'Unavailable'} bps +
{strategyConfig.max_notional || 'Unavailable'} max
+
{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age
+
{route.source || 'Unavailable'}{pair.blockReason || pair.block_reason || 'No'} +
v{strategyConfig.version || 'Unavailable'}
+ +
No directed pairs are configured.
+
+
+ ); +} + +export default function StrategyPage({ strategy, onControl }) { const funnel = strategy.strategy_state.trade_funnel || {}; const counts = funnel.counts || {}; @@ -326,6 +479,10 @@ export default function StrategyPage({ strategy }) { + + + +
diff --git a/src/venues/near-intents/ws.mjs b/src/venues/near-intents/ws.mjs index 9bb3538..cb41096 100644 --- a/src/venues/near-intents/ws.mjs +++ b/src/venues/near-intents/ws.mjs @@ -12,6 +12,7 @@ export async function startNearIntentsWs({ wsUrl = DEFAULT_WS_URL, pairFilter, getPairFilter = () => pairFilter, + matchesPair = null, producer, rawTopic, normalizedTopic, @@ -25,6 +26,7 @@ export async function startNearIntentsWs({ let quoteSubscriptionId = null; let quoteStatusSubscriptionId = null; let publishedCount = 0; + let rawPublishedCount = 0; let publishLocked = false; let closed = false; let reconnectTimer = null; @@ -101,8 +103,24 @@ export async function startNearIntentsWs({ if (quoteSubscriptionId && subscription && subscription !== quoteSubscriptionId) return; if (publishLocked) return; - const rawEnvelope = buildNearIntentsRawEnvelope(merged); const envelope = buildNearIntentsQuoteEnvelope(merged); + const rawEnvelope = buildNearIntentsRawEnvelope(merged); + + try { + await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id }); + rawPublishedCount += 1; + } catch (error) { + publishErrorCount += 1; + logger?.error('raw_publish_failed', { + namespace, + topic: rawTopic, + details: { + error: serializeError(error), + quote_id: rawEnvelope.payload?.message?.quote_id || rawEnvelope.payload?.message?.quote_hash || null, + }, + }); + } + if (!envelope) return; assertNormalizedSwapDemand(envelope); @@ -110,8 +128,10 @@ export async function startNearIntentsWs({ const assetOut = envelope.payload?.asset_out; if (!assetIn || !assetOut) return; - const activePairFilter = getPairFilter(); - if (!matchesPairFilter(assetIn, assetOut, activePairFilter)) { + const pairAllowed = matchesPair + ? await matchesPair(assetIn, assetOut) + : matchesPairFilter(assetIn, assetOut, getPairFilter()); + if (!pairAllowed) { filteredCount += 1; return; } @@ -119,7 +139,6 @@ export async function startNearIntentsWs({ publishLocked = true; try { - await producer.sendJson(rawTopic, rawEnvelope, { key: rawEnvelope.event_id }); await producer.sendJson(normalizedTopic, envelope, { key: envelope.payload.quote_id }); publishedCount += 1; lastPublishedAt = new Date().toISOString(); @@ -202,6 +221,7 @@ export async function startNearIntentsWs({ frames_received: framesReceived, quote_frames_received: quoteFramesReceived, filtered_count: filteredCount, + raw_published_count: rawPublishedCount, published_count: publishedCount, publish_error_count: publishErrorCount, invalid_json_count: invalidJsonCount, diff --git a/test/config-assets.test.mjs b/test/config-assets.test.mjs index 739d1e6..03f3a2f 100644 --- a/test/config-assets.test.mjs +++ b/test/config-assets.test.mjs @@ -5,7 +5,6 @@ import { loadConfig } from '../src/lib/config.mjs'; const ENV_KEYS = [ 'NEAR_RPC_URL', - 'NEAR_INTENTS_PAIR_FILTER', 'TRADING_BTC_ASSET_ID', 'TRADING_BTC_TRACKED_ASSET_IDS', 'TRADING_BTC_LABEL', @@ -33,10 +32,6 @@ test('default config trades nBTC while still tracking legacy BTC', () => withCle const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' }); assert.equal(config.nearRpcUrl, 'https://near.lava.build'); - assert.equal( - config.nearIntentsPairFilter, - `${NBTC}->${EURE}`, - ); assert.equal(config.tradingBtc.assetId, NBTC); assert.deepEqual(config.activeAssetIds, [NBTC, EURE]); assert.deepEqual(config.trackedAssetIds, [NBTC, LEGACY_BTC, EURE]); @@ -45,8 +40,8 @@ test('default config trades nBTC while still tracking legacy BTC', () => withCle assert.equal(config.assetRegistry.get(LEGACY_BTC).role, 'legacy'); })); -test('tracked BTC ids always include the configured trading BTC reserve', () => withCleanEnv(() => { - process.env.TRADING_BTC_ASSET_ID = NBTC; +test('legacy trading asset env overrides are ignored by runtime config', () => withCleanEnv(() => { + process.env.TRADING_BTC_ASSET_ID = 'nep141:wrong-btc.near'; process.env.TRADING_BTC_TRACKED_ASSET_IDS = LEGACY_BTC; const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' }); diff --git a/test/history-writer-refresh-config.test.mjs b/test/history-writer-refresh-config.test.mjs index abf6231..07839e1 100644 --- a/test/history-writer-refresh-config.test.mjs +++ b/test/history-writer-refresh-config.test.mjs @@ -22,16 +22,16 @@ function withCleanEnv(fn) { } } -test('history writer derived refresh replay cutoff matches request inventory freshness', () => withCleanEnv(() => { +test('history writer derived refresh replay cutoff keeps the approved default', () => withCleanEnv(() => { const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' }); assert.equal(config.historyWriterDerivedRefreshMaxEventAgeMs, 30000); assert.equal(config.historyWriterDerivedRefreshMaxEventAgeMs, config.intentRequestInventoryMaxAgeMs); })); -test('kubernetes history writer replay cutoff matches request inventory freshness', () => { +test('legacy request freshness env no longer ships in kubernetes config', () => { const manifest = readFileSync(new URL('../deploy/k8s/base/unrip.yaml', import.meta.url), 'utf8'); assert.match(manifest, /HISTORY_WRITER_DERIVED_REFRESH_MAX_EVENT_AGE_MS: "30000"/); - assert.match(manifest, /INTENT_REQUEST_INVENTORY_MAX_AGE_MS: "30000"/); + assert.doesNotMatch(manifest, /INTENT_REQUEST_INVENTORY_MAX_AGE_MS/); }); diff --git a/test/strategy-threshold-config.test.mjs b/test/strategy-threshold-config.test.mjs index be31d1a..5a4a952 100644 --- a/test/strategy-threshold-config.test.mjs +++ b/test/strategy-threshold-config.test.mjs @@ -2,24 +2,23 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { loadConfig } from '../src/lib/config.mjs'; +import { + CURRENT_PAIR_KEY, + buildSeedStrategyConfig, +} from '../src/core/trading-config.mjs'; -test('repo default strategy threshold reflects explicitly approved 0.49 percent edge', () => { - const previous = process.env.STRATEGY_GROSS_THRESHOLD_PCT; - delete process.env.STRATEGY_GROSS_THRESHOLD_PCT; - - try { - const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' }); - assert.equal(config.strategyGrossThresholdPct, 0.49); - } finally { - if (previous == null) delete process.env.STRATEGY_GROSS_THRESHOLD_PCT; - else process.env.STRATEGY_GROSS_THRESHOLD_PCT = previous; - } +test('repo DB seed carries the approved 49 bps current-pair edge', () => { + const config = buildSeedStrategyConfig(CURRENT_PAIR_KEY); + assert.equal(config.edgeBps, 49); }); -test('kubernetes strategy threshold deploys the approved 0.49 percent edge', () => { +test('kubernetes production config does not carry pair, asset, or edge env vars', () => { const manifest = readFileSync(new URL('../deploy/k8s/base/unrip.yaml', import.meta.url), 'utf8'); - assert.match(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "0\.49"/); - assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "0\.99"/); - assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT: "1\.49"/); + assert.doesNotMatch(manifest, /NEAR_INTENTS_PAIR_FILTER/); + assert.doesNotMatch(manifest, /TRADING_BTC_/); + assert.doesNotMatch(manifest, /TRADING_EURE_/); + assert.doesNotMatch(manifest, /STRATEGY_GROSS_THRESHOLD_PCT/); + assert.doesNotMatch(manifest, /STRATEGY_MAX_NOTIONAL_EURE/); + assert.doesNotMatch(manifest, /INTENT_REQUEST_DEFAULT_AMOUNT_EURE/); + assert.doesNotMatch(manifest, /INTENT_REQUEST_MAX_AMOUNT_EURE/); }); diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs new file mode 100644 index 0000000..6acf85a --- /dev/null +++ b/test/trading-config.test.mjs @@ -0,0 +1,458 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { evaluateTradeOpportunity } from '../src/core/strategy.mjs'; +import { + CURRENT_EURE_ASSET_ID, + CURRENT_NBTC_ASSET_ID, + LEGACY_OMFT_BTC_ASSET_ID, + normalizeOneClickToken, +} from '../src/core/trading-config.mjs'; +import { + createPairStrategyConfigVersion, + enableObserveOnlyPair, + importSupportedAssets, + loadTradingConfig, + seedTradingConfig, +} from '../src/lib/postgres.mjs'; + +test('1Click token normalizer preserves live asset fields', () => { + const token = normalizeOneClickToken({ + assetId: CURRENT_NBTC_ASSET_ID, + decimals: 8, + blockchain: 'near', + symbol: 'BTC', + price: 80293, + priceUpdatedAt: '2026-05-12T16:25:00.425Z', + contractAddress: 'nbtc.bridge.near', + }); + + assert.equal(token.assetId, CURRENT_NBTC_ASSET_ID); + assert.equal(token.decimals, 8); + assert.equal(token.symbol, 'BTC'); + assert.equal(token.latestPrice, '80293'); + assert.equal(token.priceUpdatedAt, '2026-05-12T16:25:00.425Z'); +}); + +test('supported token import is idempotent, does not enable inventory, and retires missing assets', async () => { + const pool = createMemoryPool(); + const first = await importSupportedAssets(pool, { + fetchedAt: '2026-05-12T16:30:00.000Z', + response: [ + token(CURRENT_NBTC_ASSET_ID, 'BTC', 8), + token(CURRENT_EURE_ASSET_ID, 'EURe', 18), + ], + }); + + assert.equal(first.added_count, 2); + assert.equal(pool.assets.get(CURRENT_NBTC_ASSET_ID).enabled_for_inventory, false); + assert.equal(Object.hasOwn(first, 'raw_response'), false); + + const second = await importSupportedAssets(pool, { + fetchedAt: '2026-05-12T16:31:00.000Z', + response: [ + token(CURRENT_NBTC_ASSET_ID, 'BTC', 8), + token(CURRENT_EURE_ASSET_ID, 'EURe', 18), + ], + }); + assert.equal(second.added_count, 0); + assert.equal(second.unchanged_count, 2); + + const third = await importSupportedAssets(pool, { + fetchedAt: '2026-05-12T16:32:00.000Z', + response: [ + { ...token(CURRENT_NBTC_ASSET_ID, 'BTC', 8), price: 81000 }, + ], + }); + assert.equal(third.updated_count, 1); + assert.equal(third.retired_count, 1); + assert.equal(pool.assets.get(CURRENT_EURE_ASSET_ID).supported, false); + assert.equal(pool.assets.get(CURRENT_EURE_ASSET_ID).retired_at, '2026-05-12T16:32:00.000Z'); +}); + +test('seeded DB config preserves current nBTC/EURe pair, 49 bps edge, and legacy BTC tracking', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool, { now: '2026-05-12T16:35:00.000Z' }); + await seedTradingConfig(pool, { now: '2026-05-12T16:36:00.000Z' }); + + const snapshot = await loadTradingConfig(pool); + + assert.equal(snapshot.ok, true); + assert.equal(snapshot.activePair, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`); + assert.equal(snapshot.pairs.length, 2); + assert.equal(snapshot.pairByKey.get(snapshot.activePair).strategyConfig.edgeBps, 49); + assert.equal(snapshot.trackedAssetIds.includes(LEGACY_OMFT_BTC_ASSET_ID), true); + assert.equal([...snapshot.makerPairKeys].some((pair) => pair.includes(LEGACY_OMFT_BTC_ASSET_ID)), false); +}); + +test('missing DB pair config fails closed', async () => { + const snapshot = await loadTradingConfig(createMemoryPool()); + + assert.equal(snapshot.ok, false); + assert.equal(snapshot.blockReason, 'no_enabled_pairs'); + assert.equal(snapshot.enabledPairKeys.size, 0); +}); + +test('edge update creates a new active strategy version', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + + const next = await createPairStrategyConfigVersion(pool, { + pairId: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, + edgeBps: 75, + changedBy: 'test', + reason: 'test edge update', + }); + const snapshot = await loadTradingConfig(pool); + const versions = [...pool.strategyConfigs.values()] + .filter((row) => row.pair_id === `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`); + + assert.equal(next.version, 2); + assert.equal(snapshot.pairByKey.get(`${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).strategyConfig.edgeBps, 75); + assert.equal(versions.find((row) => row.version === 1).active, false); +}); + +test('observe-only enable does not downgrade an active trading pair', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`; + + const pair = await enableObserveOnlyPair(pool, { + assetIn: CURRENT_NBTC_ASSET_ID, + assetOut: CURRENT_EURE_ASSET_ID, + changedBy: 'test', + reason: 'avoid downgrade', + }); + const snapshot = await loadTradingConfig(pool); + + assert.equal(pair.pairId, pairId); + assert.equal(pair.mode, 'both'); + assert.equal(snapshot.pairByKey.get(pairId).makerEnabled, true); + assert.equal(snapshot.pairByKey.get(pairId).takerEnabled, true); +}); + +test('observe-only enable creates a non-trading tracked pair', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pair = await enableObserveOnlyPair(pool, { + assetIn: LEGACY_OMFT_BTC_ASSET_ID, + assetOut: CURRENT_EURE_ASSET_ID, + changedBy: 'test', + reason: 'watch legacy route', + }); + const snapshot = await loadTradingConfig(pool); + + assert.equal(pair.mode, 'observe_only'); + assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).observeEnabled, true); + assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).makerEnabled, false); + assert.equal(snapshot.pairByKey.get(`${LEGACY_OMFT_BTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`).takerEnabled, false); +}); + +test('repo seed does not re-enable pair runtime flags already stored in DB', async () => { + const pool = createMemoryPool(); + await seedTradingConfig(pool); + const pairId = `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`; + const routeId = `${pairId}:btc-eur-reference`; + Object.assign(pool.pairs.get(pairId), { + mode: 'observe_only', + enabled: false, + status: 'disabled', + }); + Object.assign(pool.routes.get(routeId), { + enabled: false, + }); + + await seedTradingConfig(pool); + const snapshot = await loadTradingConfig(pool); + const pair = snapshot.pairByKey.get(pairId); + + assert.equal(pair.enabled, false); + assert.equal(pair.mode, 'observe_only'); + assert.equal(pair.status, 'disabled'); + assert.equal(pool.routes.get(routeId).enabled, false); + assert.equal(pair.priceRoute, null); + assert.equal(pair.makerEnabled, false); + assert.equal(pair.takerEnabled, false); +}); + +test('strategy uses DB pair config for current pair and persists config version', async () => { + const pool = createMemoryPool(); + const snapshot = await seedTradingConfig(pool); + const result = evaluateTradeOpportunity({ + demandEvent: { + payload: { + quote_id: 'quote-db-1', + pair: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, + asset_in: CURRENT_NBTC_ASSET_ID, + asset_out: CURRENT_EURE_ASSET_ID, + request_kind: 'exact_in', + amount_in: '5000', + min_deadline_ms: '60000', + }, + }, + priceEvent: priceEvent(), + inventoryEvent: inventoryEvent(), + config: snapshot, + armed: true, + now: Date.parse('2026-05-12T16:35:05.000Z'), + }); + + assert.equal(result.decision.decision, 'actionable'); + assert.equal(result.decision.edge_bps, '49'); + assert.equal(result.decision.pair_config_version, '1'); + assert.equal(result.command.quote_output.amount_out, '4975500000000000000'); + assert.equal(result.command.pair_config_id, `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}:v1`); +}); + +function token(assetId, symbol, decimals) { + return { + assetId, + decimals, + blockchain: symbol === 'EURe' ? 'gnosis' : 'near', + symbol, + price: symbol === 'EURe' ? 1.17 : 80293, + priceUpdatedAt: '2026-05-12T16:25:00.425Z', + contractAddress: assetId.replace(/^nep141:/, ''), + }; +} + +function priceEvent() { + return { + ingested_at: '2026-05-12T16:35:00.000Z', + payload: { + price_id: 'price-db-1', + pair: `${CURRENT_NBTC_ASSET_ID}->${CURRENT_EURE_ASSET_ID}`, + eur_per_btc: '100000.00000000', + eure_per_btc: '100000.00000000', + btc_per_eur: '0.000010000000', + btc_per_eure: '0.000010000000', + source_used: 'kraken', + }, + }; +} + +function inventoryEvent() { + return { + ingested_at: '2026-05-12T16:35:00.000Z', + payload: { + inventory_id: 'inventory-db-1', + spendable: { + [CURRENT_NBTC_ASSET_ID]: '1000000', + [CURRENT_EURE_ASSET_ID]: '10000000000000000000', + [LEGACY_OMFT_BTC_ASSET_ID]: '0', + }, + pending_inbound: { + [CURRENT_NBTC_ASSET_ID]: '0', + [CURRENT_EURE_ASSET_ID]: '0', + [LEGACY_OMFT_BTC_ASSET_ID]: '0', + }, + }, + }; +} + +function createMemoryPool() { + return { + assets: new Map(), + pairs: new Map(), + strategyConfigs: new Map(), + routes: new Map(), + importRuns: new Map(), + audit: [], + async query(sql, params = []) { + if (/CREATE TABLE|CREATE (UNIQUE )?INDEX/i.test(sql)) return { rows: [], rowCount: 0 }; + if (/SELECT \* FROM trading_assets\s*$/i.test(sql)) return rows(this.assets); + if (/SELECT \*\s+FROM trading_assets\s+ORDER BY/i.test(sql)) return rows(this.assets); + if (/INSERT INTO trading_assets/i.test(sql)) return insertAsset(this, params); + if (/UPDATE trading_assets/i.test(sql)) return retireAssets(this, params); + if (/INSERT INTO supported_asset_import_runs/i.test(sql)) return insertImportRun(this, params); + if (/SELECT \*\s+FROM supported_asset_import_runs/i.test(sql)) { + return { rows: [...this.importRuns.values()].slice(-1), rowCount: this.importRuns.size ? 1 : 0 }; + } + if (/COUNT\(\*\)::INT AS known_count/i.test(sql)) { + const assets = [...this.assets.values()]; + return { + rows: [{ + known_count: assets.length, + supported_count: assets.filter((asset) => asset.supported).length, + retired_count: assets.filter((asset) => asset.retired_at || !asset.supported).length, + inventory_enabled_count: assets.filter((asset) => asset.enabled_for_inventory).length, + }], + rowCount: 1, + }; + } + if (/INSERT INTO trading_pairs/i.test(sql)) return insertPair(this, params, sql); + if (/SELECT \*\s+FROM trading_pairs\s+WHERE pair_id = \$1/i.test(sql)) { + const row = this.pairs.get(params[0]); + return { rows: row ? [row] : [], rowCount: row ? 1 : 0 }; + } + if (/SELECT \*\s+FROM trading_pairs/i.test(sql)) return rows(this.pairs); + if (/INSERT INTO pair_strategy_configs/i.test(sql)) return insertStrategyConfig(this, params); + if (/SELECT \*\s+FROM pair_strategy_configs\s+WHERE active = true/i.test(sql)) { + return { rows: [...this.strategyConfigs.values()].filter((row) => row.active), rowCount: 0 }; + } + if (/SELECT \*\s+FROM pair_strategy_configs\s+WHERE pair_id = \$1 AND active = true/i.test(sql)) { + const active = [...this.strategyConfigs.values()] + .filter((row) => row.pair_id === params[0] && row.active) + .sort((left, right) => right.version - left.version)[0]; + return { rows: active ? [active] : [], rowCount: active ? 1 : 0 }; + } + if (/UPDATE pair_strategy_configs SET active = false/i.test(sql)) { + let count = 0; + for (const row of this.strategyConfigs.values()) { + if (row.pair_id === params[0] && row.active) { + row.active = false; + count += 1; + } + } + return { rows: [], rowCount: count }; + } + if (/INSERT INTO pair_price_routes/i.test(sql)) return insertRoute(this, params, sql); + if (/SELECT \*\s+FROM pair_price_routes/i.test(sql)) { + return { rows: [...this.routes.values()].filter((row) => row.enabled), rowCount: 0 }; + } + if (/INSERT INTO pair_config_audit_log/i.test(sql)) { + this.audit.push(params); + return { rows: [], rowCount: 1 }; + } + throw new Error(`unhandled SQL in memory pool: ${sql}`); + }, + }; +} + +function rows(map) { + return { rows: [...map.values()], rowCount: map.size }; +} + +function insertAsset(pool, params) { + const seed = params.length === 16; + const [ + assetId, + venue, + symbol, + label, + decimals, + blockchain, + chain, + contractAddress, + latestPrice, + priceUpdatedAt, + ] = params; + const previous = pool.assets.get(assetId); + const row = { + ...(previous || {}), + asset_id: assetId, + venue, + symbol, + label, + decimals, + blockchain, + chain, + contract_address: contractAddress, + latest_price: latestPrice, + price_updated_at: priceUpdatedAt, + supported: seed ? (previous?.supported || params[10]) : true, + retired_at: null, + enabled_for_inventory: seed ? true : previous?.enabled_for_inventory === true, + role: seed ? params[12] : previous?.role || null, + withdraw_address: seed ? params[13] : previous?.withdraw_address || '', + raw_payload: JSON.parse(seed ? params[14] : params[10]), + last_supported_at: seed ? params[15] : params[11], + updated_at: seed ? params[15] : params[11], + }; + pool.assets.set(assetId, row); + return { rows: [], rowCount: previous ? 0 : 1 }; +} + +function retireAssets(pool, params) { + const [retiredAt, importedIds] = params; + let count = 0; + for (const row of pool.assets.values()) { + if (row.venue === 'near-intents' && row.supported && !importedIds.includes(row.asset_id)) { + row.supported = false; + row.retired_at ||= retiredAt; + row.updated_at = retiredAt; + count += 1; + } + } + return { rows: [], rowCount: count }; +} + +function insertImportRun(pool, params) { + const row = { + run_id: params[0], + source_url: params[1], + fetched_at: params[2], + status: params[3], + token_count: params[4], + added_count: params[5], + updated_count: params[6], + unchanged_count: params[7], + retired_count: params[8], + raw_response_hash: params[9], + error: params[10], + raw_response: params[11] == null ? null : JSON.parse(params[11]), + }; + pool.importRuns.set(row.run_id, row); + return { rows: [], rowCount: 1 }; +} + +function insertPair(pool, params, sql = '') { + const previous = pool.pairs.get(params[0]); + const row = { + pair_id: params[0], + venue: params[1], + asset_in: params[2], + asset_out: params[3], + mode: /mode = trading_pairs\.mode/i.test(sql) && previous ? previous.mode : params[4], + enabled: /enabled = trading_pairs\.enabled/i.test(sql) && previous ? previous.enabled : params[5], + status: /status = trading_pairs\.status/i.test(sql) && previous ? previous.status : params[6], + created_at: params[7], + updated_at: params[7], + }; + pool.pairs.set(row.pair_id, row); + return { rows: [], rowCount: 1 }; +} + +function insertStrategyConfig(pool, params) { + const configId = params[0]; + if (pool.strategyConfigs.has(configId)) return { rows: [], rowCount: 0 }; + const row = { + config_id: configId, + pair_id: params[1], + version: params[2], + active: params[3], + edge_bps: params[4], + max_notional: params[5], + min_notional: params[6], + slippage_bps: params[7], + min_deadline_ms: params[8], + price_max_age_ms: params[9], + inventory_max_age_ms: params[10], + request_default_notional: params[11], + request_max_notional: params[12], + request_max_slippage_bps: params[13], + created_by: params[14], + reason: params[15], + created_at: '2026-05-12T16:35:00.000Z', + }; + pool.strategyConfigs.set(configId, row); + return { rows: [], rowCount: 1 }; +} + +function insertRoute(pool, params, sql = '') { + const previous = pool.routes.get(params[0]); + const row = { + route_id: params[0], + pair_id: params[1], + source: params[2], + base_asset_id: params[3], + quote_asset_id: params[4], + route_config: JSON.parse(params[5]), + max_age_ms: params[6], + enabled: /enabled = pair_price_routes\.enabled/i.test(sql) && previous ? previous.enabled : params[7], + created_at: params[8], + updated_at: params[8], + }; + pool.routes.set(row.route_id, row); + return { rows: [], rowCount: 1 }; +}