Move trading config into Postgres
Some checks failed
deploy / deploy (push) Failing after 34s

Proof: npm test passed 159/159; npm run operator-dashboard:build passed; repo-local Postgres importer smoke test imported 163 live 1Click tokens with only 3 inventory-enabled seed assets and nBTC/EURe pairs at 49 bps.

Assumptions: Forgejo main push is the repo deployment path; production has existing repo-managed POSTGRES_URL/POSTGRES_PASSWORD/NEAR_INTENTS_API_KEY secrets; startup seed may create initial current nBTC/EURe config but must preserve DB runtime pair flags after creation.

Still fake: no live funds movement was attempted; imported supported assets remain catalog-only unless explicitly enabled in DB; production rollout evidence still depends on the Forgejo deploy job completing after this push.
This commit is contained in:
philipp 2026-05-12 21:34:58 +02:00
parent 5425152ed9
commit 2ffa4b17f1
26 changed files with 3190 additions and 421 deletions

View file

@ -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

View file

@ -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",

View file

@ -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 });

View file

@ -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);
}

View file

@ -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, {

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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) {

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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(() => {});
}

View file

@ -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,
});
},
};
}

View file

@ -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;

View file

@ -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),

View file

@ -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;
}

View file

@ -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';
}

238
src/core/trading-config.mjs Normal file
View file

@ -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);
}

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -155,7 +155,7 @@ export default function App() {
/>
) : null}
{currentPage === 'strategy' ? (
<StrategyPage strategy={state.dashboard.strategy} />
<StrategyPage onControl={submitControl} strategy={state.dashboard.strategy} />
) : null}
{currentPage === 'system' ? (
<SystemPage onControl={submitControl} system={state.dashboard.system} />

View file

@ -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 (
<section className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Asset registry</div>
<h3>Supported-token import status</h3>
<div className="panel-subtitle">
Last import {latest?.fetched_at ? formatTimestamp(latest.fetched_at) : 'not run'}
</div>
</div>
<div className="pills">
<Pill label={latest?.status || 'not imported'} stateLabel={latest?.status === 'success' ? 'healthy' : 'warning'} />
<button className="button secondary" onClick={() => onControl?.('operator-dashboard', 'import-supported-assets')} type="button">
Import assets
</button>
</div>
</div>
<div className="metric-grid">
<MetricCard label="Known assets" meta={`${counts.inventory_enabled || 0} inventory tracked`} value={String(counts.known || 0)} />
<MetricCard label="Supported now" meta={`${latest?.token_count || 0} tokens in latest run`} value={String(counts.supported || 0)} />
<MetricCard label="Retired" meta="Kept for balances and history" value={String(counts.retired || 0)} />
</div>
<TableFrame>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Decimals</th>
<th>Chain</th>
<th>Price</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{items.length ? items.slice(0, 20).map((asset) => (
<tr key={asset.asset_id || asset.assetId}>
<td>
<div>{asset.label || asset.symbol}</div>
<div className="status-subtle mono">{truncateMiddle(asset.asset_id || asset.assetId, 42)}</div>
</td>
<td>{asset.decimals}</td>
<td>{asset.blockchain || asset.chain || 'Unavailable'}</td>
<td>{asset.latest_price || asset.latestPrice || 'Unavailable'}</td>
<td>
<Pill
label={asset.supported ? 'supported' : 'retired'}
stateLabel={asset.supported ? 'healthy' : 'warning'}
/>
</td>
</tr>
)) : (
<tr><td colSpan={5}>No DB asset registry rows are available.</td></tr>
)}
</tbody>
</table>
</TableFrame>
</section>
);
}
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 (
<section className="panel">
<div className="panel-head">
<div>
<div className="eyebrow">Pair config</div>
<h3>Directed pairs and active strategy versions</h3>
<div className="panel-subtitle">
Loaded {pairConfig?.loaded_at ? formatTimestamp(pairConfig.loaded_at) : 'unavailable'}
</div>
</div>
<div className="pills">
<Pill label={pairConfig?.ok ? 'config loaded' : pairConfig?.block_reason || 'blocked'} stateLabel={pairConfig?.ok ? 'healthy' : 'warning'} />
<button className="button secondary" onClick={enableObserveOnly} type="button">Observe-only pair</button>
</div>
</div>
<TableFrame>
<table>
<thead>
<tr>
<th>Pair</th>
<th>Mode</th>
<th>Edge</th>
<th>Limits</th>
<th>Route</th>
<th>Blocked</th>
<th>Config</th>
</tr>
</thead>
<tbody>
{pairs.length ? pairs.map((pair) => {
const strategyConfig = pair.strategyConfig || pair.strategy_config || {};
const route = pair.priceRoute || pair.price_route || {};
return (
<tr key={pair.pair_id || pair.pairId}>
<td>
<div>{pair.asset_in_symbol || pair.asset_in} {'->'} {pair.asset_out_symbol || pair.asset_out}</div>
<div className="status-subtle mono">{truncateMiddle(pair.pair_id || pair.pairId, 42)}</div>
</td>
<td><Pill label={pair.mode || pair.status} stateLabel={pair.canTrade || pair.can_trade ? 'healthy' : 'warning'} /></td>
<td>{strategyConfig.edge_bps ?? 'Unavailable'} bps</td>
<td>
<div>{strategyConfig.max_notional || 'Unavailable'} max</div>
<div className="status-subtle">{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age</div>
</td>
<td>{route.source || 'Unavailable'}</td>
<td>{pair.blockReason || pair.block_reason || 'No'}</td>
<td>
<div>v{strategyConfig.version || 'Unavailable'}</div>
<button className="button secondary trace-copy-button" onClick={() => updateEdge(pair)} type="button">
Edge
</button>
</td>
</tr>
);
}) : (
<tr><td colSpan={7}>No directed pairs are configured.</td></tr>
)}
</tbody>
</table>
</TableFrame>
</section>
);
}
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 }) {
</div>
</section>
<AssetCatalogSection assetCatalog={strategy.asset_catalog} onControl={onControl} />
<PairConfigSection pairConfig={strategy.pair_config} onControl={onControl} />
<section className="panel">
<div className="panel-head">
<div>

View file

@ -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,

View file

@ -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' });

View file

@ -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/);
});

View file

@ -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/);
});

View file

@ -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 };
}