🌐 Живой сайт / Live Site URL
https://3000-ijep6wg6r625g8dh98oog.e2b.app
← Dashboard/api/status · публичная сводкаCEX Self-TestVerify Freshnessexplain-tx (BEAT)

📦 DEX Parser v3.9.8+ — Полный исходный код

Все 23 файлов проекта. Скопируй их точь-в-точь для воспроизведения.

🤖 ИНСТРУКЦИЯ ДЛЯ AI-АССИСТЕНТОВ (GPT, Claude, Gemini, etc.)

⚠️ ВАЖНО: Скопируй ВСЕ 23 файлов ниже ТОЧЬ-В-ТОЧЬ. Особенно src/lib/parser-engine.ts — это ядро парсера (~3800 строк).

🎯 Что это за проект:

DEX Parser v3.9.8+ — полностью рабочий мониторинг крупных сделок на DEX (Ethereum, BSC, Base, Arbitrum, Polygon, Solana) с уведомлениями в Telegram. Использует GeckoTerminal/CoinGecko API для discovery пулов и CEX-фильтрации, PostgreSQL для персистентности настроек (опционально — работает и без БД).

МОДЕЛЬ АЛЕРТОВ (финальная версия v3.9.8+):
• ⚡ Solo алерт: сделка ≥$30 000 → мгновенная одиночная карточка
• 📊 Серийный алерт: 3+ сделок ($1 000–$29 999) одного трейдера × токен × сторона за 180 сек
• Cooldown (chain,trader,token,side): 10 мин после серии

✅ ТЗ v2: INVENTORY contractExec filter:
• EOA → PASS (всегда)
• Gnosis Safe (getOwners) → PASS (safeAllowed+1)
• Контракт с балансом субъект-токена @block-1 ≥ $100 → CUT (piggy-bank) → wouldSkip=contractExec
• Контракт с балансом ≈0 → PASS (транзитный DEX-роутер) → contractTransient+1
• Нет архивных данных → PASS (fail-open) → contractExecUnknown+1

✅ blockTs из источника (block_timestamp / block_executed_at), никогда now().
✅ Freshness gate: события старше 180с скипаются (stale).
✅ Lazy DB: работает без DATABASE_URL (in-memory режим).

🚀 Быстрый старт:

# 1. Скопируй все файлы по путям ниже
# 2. Добавь в .env:
TELEGRAM_BOT_TOKEN=ваш_токен
TELEGRAM_CHAT_ID=ваш_chat_id
DATABASE_URL=postgresql://...  # опционально

# 3. Запусти:
npm install
npx drizzle-kit push  # опционально (если DATABASE_URL)
npm run build && npm start

# 4. Проверь:
curl http://localhost:3000/api/health     # {"ok":true}
curl http://localhost:3000/api/status    # публичная сводка (public-summary-v1)
# /api/status/details — полная диагностика только после авторизации; не вставляй токен в команду
curl -X POST http://localhost:3000/api/parser/start
curl -X POST http://localhost:3000/api/test-telegram

# 5. Диагностика contractExec (ТЗ v2):
curl "http://localhost:3000/api/debug/explain-tx?chain=bsc&tx=0xb67afff83876373895f5c1dd995f79c6b987f85da42b5b657eeede84939e2b63"
# → wouldSkip="contractExec", preBalanceUsd>100 (BEAT piggy-bank)

curl "http://localhost:3000/api/debug/explain-tx?chain=base&tx=0x62cfc83fcac1d2ac3cdca44cb15518a28dba7746365f71eb15cb08a080258def"
# → wouldSkip=null, preBalanceUsd≈0 (VIRTUAL transit contract)

curl "http://localhost:3000/api/debug/explain-tx?chain=base&tx=0x51a74c4e23188ffa9dcb0d91723f49ab5648b323fb6390b54b27ed606cdb65fd"
# → wouldSkip=null (cbBTC dust → transit)

📋 Файлы для копирования (23 штук):

src/lib/parser-engine.tssrc/app/page.tsxsrc/app/layout.tsxsrc/app/globals.csssrc/app/source/page.tsxsrc/app/api/health/route.tssrc/app/api/status/route.tssrc/app/api/settings/route.tssrc/app/api/parser/start/route.tssrc/app/api/parser/stop/route.tssrc/app/api/test-telegram/route.tssrc/app/api/notifications/route.tssrc/app/api/notifications/clear/route.tssrc/app/api/blacklist/route.tssrc/app/api/debug/cex-selftest/route.tssrc/app/api/debug/sse-sample/route.tssrc/app/api/debug/verify-freshness/route.tssrc/app/api/debug/trade-sample/route.tssrc/app/api/debug/enrich-test/route.tssrc/app/api/debug/explain-tx/route.tssrc/app/api/logs/route.tssrc/db/index.tssrc/db/schema.ts
src/lib/parser-engine.ts
🔥 ЯДРО v3.9.8+: Двухуровневые алерты (solo≥$30K + серии 3×$1K+/3мин), blockTs из источника, freshness gate, inventory contractExec filter (ТЗ v2)
22265 строк · 1186KB
import { finiteEnvNumber } from './env-number';
import { createEvmRuntime } from "./evm/runtime";
import type { EvmMetaEnrichmentDeps } from "./evm/runtime-types";
import type { EvmRuntime, EvmRuntimeDeps, AlchemyCuSourceKey, WsPollMigratedRec, EvmHotUrlHealth } from "./evm/runtime-types";
/**
 * B4.4 composition root for the Solana PubSub runtime (see ./solana/pubsub-runtime.ts).
 * This file stays the compatibility facade: every legacy export below is a delegate binding
 * onto the process-wide runtime carried by the engine kernel.
 */
import { createSolanaPubSubRuntime } from './solana/pubsub-runtime';
import { isSolRpcTxVersionUnsupported, solanaMaxSupportedTxVersion, type SolTxFetchReason } from './solana/tx-version';
import { solTxAttempts, type SolAttemptOutcome } from './solana/tx-attempt-ledger';
import { soloBufferOverflow } from './solo-buffer-overflow';
import type { SolanaPubSubRuntime } from './solana/pubsub-runtime';
import type {
  ExtractedSubject,
  Notification,
  ReserveCacheRow,
  ReserveCacheStore,
  SolPubsubLiveness,
  SolanaPubSubChainStateMap,
  SolanaPubSubFetchResult,
  SolanaPubSubRuntimeDeps,
  SolanaPubSubTransportSocket,
  SolanaPubSubWatchMeta,
} from './solana/pubsub-types';
export type { ExtractedSubject, Notification, ReserveCacheRow, ReserveCacheStore, SolPubsubLiveness } from './solana/pubsub-types';
/**
 * DEX PARSER v3.9.8 — Parser Engine (Next.js version)
 *
 * NEW IN THIS BUILD (Two-Level Alert System):
 * - soloAmountUsd = 30000: instant single-card alert (≥$30K on ALL chains)
 * - seriesTxMinUsd = 1000: series candidate threshold (uniform, all chains incl. Solana)
 * - seriesMinTx = 3: minimum tx count for series alert
 * - seriesWindowSec = 180: rolling window for series detection
 * - seriesCooldownMin = 10: cooldown after series alert per (chain,trader,token,side)
 * - minAmountUsdPerChain removed (Solana no longer has 5000 override for series)
 * - Single alerts $1000–$30000 are SUPPRESSED (only catchable via series)
 * - Series TG card: header + cumulative sum + trader + up to 5 tx rows + liquidity + position
 * - Liquidity: DexPaprika /networks/{net}/tokens/{addr} or GeckoTerminal (60s cache)
 * - Position: balanceOf (EVM eth_call) or Solana token account
 * - kind:"solo"|"series" in notifications
 *
 * ТЗ FIXES (blockTs from source):
 * - Bug 1: blockTs STRICTLY from source data (block_executed_at / block_timestamp)
 * - Events without source blockTs are SKIPPED (skipReasons.noBlockTs), never use now()
 * - Gate: blockTs < now−180s → skipReasons.stale (droppedStaleTotal)
 * - New debug endpoint: /api/debug/verify-freshness
 */

import { createHash } from "crypto";
import { createOracleRuntime } from './oracle/runtime';
import type {
  OracleCoverageState,
  OraclePipelineCoverageState,
  OracleRuntime,
  OracleRuntimeDeps,
  OracleShadowScanEntry,
} from './oracle/runtime-types';
export type { OracleShadowScanEntry as ShadowScanEntry } from './oracle/runtime-types';
export { isDpPlanGate } from './oracle/runtime';
import { mockPricesEnabled, mockPriceUsd, mockTokenSymbol } from "./mock-prices";
import { EVM_WS_ANALYZE_CAP_MAX_DEFAULT } from "./evm/analyze-cap"; // bm2d: посетевой лимит разбора EVM-кадров; b4: дефолты base/bsc
import { createEvmAnalyzeTelemetry, classifyAnalyzeException, classifyAnalyzeResponse, arbDiagnosticAlias, ANALYZE_FAIL_REASONS } from './evm/analyze-telemetry'; // B4.5b follow-up slice A
import type { AnalyzeFailReason, ArbitrumRpcAttemptDiagnostic, ArbitrumTryDecision } from './evm/analyze-telemetry';
import { createEvmRpcRouting } from './evm/rpc-routing'; // B4.5b follow-up slice B
// ТЗ 2026-10-09 §3.1: классификация попыток eth_getLogs и журнал покрытия диапазонов по цепям.
import { classifyGetLogsAttempt, getLogsFailureDetail, type GetLogsOutcome } from './evm/getlogs-outcome';
// ТЗ 2026-10-14 §3-M1: durable append-only журнал обязательств диапазонов (filter_epoch).
import {
  createFilterEpochJournal, createFilterEpochStore, isCompactedRow, filterEpochAppliesTo, provenTtlMs,
  type FilterEpochStore, type FilterEpochRuntime,
} from './evm/filter-epoch-ledger';
import { planFilterEpochRecovery } from './evm/filter-epoch-recovery';
import type { ArbitrumAnalysisDiagnostics } from './evm/analyze-telemetry';
import { parseRetryAfterSeconds } from './oracle/retry-after';
import { evaluateOracleReadiness } from './oracle/sample-completeness';
import { createCoverageLedger } from './evm/coverage-ledger';
import { createPauseLedger, type PauseLedger } from './pause-ledger';
import { evaluateEvmCoverage, type EvmCoverageVerdict } from './health/evm-coverage-rule';
import type { EvmRpcLane } from './evm/rpc-routing';
import { creditSourceFromUrl, type CreditSource } from "./dexpaprika/credit-source"; // bm2e: разбивка расхода кредитов по источникам (пустой словарь — у credit-controller)
// B4.1 (ТЗ decomposition, PR-1): парсинг лимитных сигналов и прогноз тира переехали в
// src/lib/provider/credit-controller.ts. Обратного импорта у того модуля нет и быть не может.
import {
  createCreditController,
  evalTierSmart,
  parseRemainingFromBody,
  readLimitSignalsFromHeaders,
  type CreditController,
  type DexPaprikaCreditTier,
  type DexPaprikaForecastTier,
  type DpLimitSignals,
  type RunwaySource,
} from './provider/credit-controller';
import { anonymousGtUrl, isAnonymousGtUrl, isGeckoTerminalHost } from "./anonymous-gt-url";
// B4.2 (ТЗ decomposition, PR-2): process-wide state анонимного канала api.geckoterminal.com
// (dead-latch, страйки, probe lease, per-path учёт) живёт в src/lib/gecko/anonymous-channel.ts.
// Обратного импорта у того модуля нет и быть не может (у него вообще нет импортов).
import {
  createAnonymousGtChannel,
  isUsefulGtPayload,
  type AnonymousGtChannel,
  type AnonymousGtOutcome,
  type AnonymousGtPath,
  type AnonymousGtResult,
} from './gecko/anonymous-channel';
export {
  PRICE_SANITY_ABS_CAP_USD,
  PRICE_SANITY_MAX_MULT,
  PRICE_SANITY_LIQ_MULT,
  PRICE_SANITY_MIN_LIQ_USD,
  PRICE_SANITY_LOW_LIQ_CAP_USD,
  PRICE_SANITY_CROSS_DIFF,
  PRICE_SANITY_DP_CROSS_ENABLED,
  evaluatePriceSanity,
} from "./price-sanity";
export {
  buildSolanaTokenDeltas,
  computeSolanaUsdDetailed,
  type SolanaUsdSource,
  SOLANA_WSOL_MINT,
  isSolanaWsol,
} from "./solana-valuation";
export {
  type TypedAnchorPool,
  VERIFIED_ANCHOR_POOLS,
  getAnchorPoolsForChain,
  UNISWAP_V4_POOL_MANAGER_ETH,
  UNISWAP_V4_SWAP_TOPIC,
} from "./anchor-manifest";
export { validateAllAnchors, validateSingleAnchor } from "./anchor-validation";
export {
  isClearlySubThresholdSwapPure,
  evaluatePrefilterPure,
  type EvmPrefilterDecision,
  EVM_V2_SWAP_TOPIC,
  EVM_V3_SWAP_TOPIC,
  EVM_PAIR_CREATED_TOPIC,
  EVM_POOL_CREATED_TOPIC,
  type EvmPoolMeta,
  isStableQuoteSymbol,
  isNativeQuoteSymbol,
} from "./evm-swap-prefilter";
export {
  type AnchorUnavailableReason,
  type EvmWsChainState,
  createEvmWsChainState,
  handleEvmWsRpcResponse,
  handleEvmWsSendError,
  sendEvmSubscription,
  pumpDueEvmSubscriptionRetries,
  resetEvmWsChainStateOnReconnect,
  getEvmSubscriptionTargetKey,
  isTargetPending,
  isValidEvmAddress,
  getEvmWsSubscriptionStatus,
} from "./evm-ws-subscriptions";

import {
  PRICE_SANITY_ABS_CAP_USD,
  PRICE_SANITY_MAX_MULT,
  PRICE_SANITY_LIQ_MULT,
  PRICE_SANITY_MIN_LIQ_USD,
  PRICE_SANITY_LOW_LIQ_CAP_USD,
  PRICE_SANITY_CROSS_DIFF,
  PRICE_SANITY_DP_CROSS_ENABLED,
  evaluatePriceSanity,
} from "./price-sanity";
import {
  buildSolanaTokenDeltas,
  computeSolanaUsdDetailed,
  SolanaUsdSource,
  SOLANA_WSOL_MINT,
  isSolanaWsol,
} from "./solana-valuation";
import {
  TypedAnchorPool,
  VERIFIED_ANCHOR_POOLS,
  getAnchorPoolsForChain,
  UNISWAP_V4_POOL_MANAGER_ETH,
  UNISWAP_V4_SWAP_TOPIC,
} from "./anchor-manifest";
import { validateAllAnchors, validateSingleAnchor } from "./anchor-validation";
import {
  getSettlementAddrs, getRouterAddrs, getSubscribedAddrs, getWsSubscribedAddrs, isSettlementAddress, isRouterAddress, getAllowanceHolderAddrs,
  isPoolManager, isPermit2, isSolverHelper, triggersOwnerRecovery, isProtocolInfrastructure, isReactor,
  getContractRole, isUserEntrypoint, getUserEntrypointAddrs, getEnabledByRole, PROTOCOL_DEPLOYMENTS,
  type SupportedChain,
} from "./protocol-registry";
import {
  resolveTrustedNativePrice, getCachedNativePrice, preloadNativePrices, setNativePriceGtLatch,
  NATIVE_PREFILTER_MODE, NATIVE_PREFILTER_MODE_WARNING, CANONICAL_WRAPPED_NATIVE, type NativePriceSnapshot, type NativePrefilterMode,
  incrementNativePriceGeneration,
} from "./native-price-service";
// bm2n: контур валидации выпущенных сделок (Фаза 1). Модуль автономный — импорта
// parser-engine нет, сетевые зависимости инжектятся (см. адаптеры ниже).
import {
  registerDealValidationDeps, isDealValidationDepsRegistered,
  runDealValidationCycle, getDealValidationStatus,
  type DealSample, type EvmTxBare, type SolTxBare, type SolTokenBalanceBare,
} from "./deal-validation";
import {
  decodeSettlementTrades, type SettlementTradeCandidate,
} from "./settlement-adapters";
import {
  isClearlySubThresholdSwapPure,
  evaluatePrefilterPure,
  type EvmPrefilterDecision,
  EVM_V2_SWAP_TOPIC,
  EVM_V3_SWAP_TOPIC,
  EVM_PAIR_CREATED_TOPIC,
  EVM_POOL_CREATED_TOPIC,
  EvmPoolMeta,
  isStableQuoteSymbol,
  isNativeQuoteSymbol,
} from "./evm-swap-prefilter";
import type { AnchorUnavailableReason } from './evm-ws-subscriptions';
import {
  EvmWsChainState,
  createEvmWsChainState,
  handleEvmWsRpcResponse,
  handleEvmWsSendError,
  sendEvmSubscription,
  pumpDueEvmSubscriptionRetries,
  resetEvmWsChainStateOnReconnect,
  getEvmSubscriptionTargetKey,
  isTargetPending,
  isValidEvmAddress,
  getEvmWsSubscriptionStatus,
} from "./evm-ws-subscriptions";
import {
  createWatchlistSnapshotStore,
  buildSnapshot,
  applySnapshot,
  type WatchlistSnapshotStore,
} from "./watchlist-snapshot";
import { getExchangeWalletLabel } from "./exchange-wallets";
import { evaluateIngest, INGEST_LEVEL_RANK, INGEST_DOWN_GRACE_MS_DEFAULT, type IngestVerdict } from "./health/ingest-rule"; // v9.33bm1k Task 4; bm1k-fix2: грейс ingestDown
import { HealthHysteresis } from "./health/hysteresis"; // v9.33bm1k Task 4
import { CuMeter, keyFingerprint } from "./helius/cu-meter"; // v9.33bm1k Task 5/6
import { SpendLedger } from "./helius/spend-ledger"; // bm1k-fix Task 2
import { evaluateBudget, forecastText, type BudgetVerdict } from "./health/budget-rule"; // bm1k-fix Task 3
import { shouldStopReserve, forecastExhaustionMin, canResumeReserve, type ReserveStopReason } from "./helius/reserve-limiter"; // v9.33bm1k Task 6
import { bumpWithEviction, classifySolTxError, extractFailingProgramId, TX_PROGRAM_BUCKET_LIMIT } from "./solana/tx-error"; // bm1n → bm1o → bm1p: классификатор, пределы и извлечение программы — в чистом модуле
// bm3a: резерв оракула переехал с анонимного api.geckoterminal.com на CoinGecko Onchain (Demo-ключ).
import {
  coingeckoTradesUrl, coingeckoDemoAuthHeaders,
} from "./oracle/coingecko-trades"; // B4.3 facade transport adapter for keyed CoinGecko Demo
import { createPacer } from "./solana/subscribe-pacer"; // bm1r: единый темп отправки accountSubscribe + logsSubscribe
import { nextDelayMs, type ReconnectPolicy } from "./solana/reconnect-policy"; // bm1s: политика реконнекта — джиттер, 429-лестница, пол мёртвого сокета
import {
  shouldPersistHotMint,
  upsertHotMint,
  pruneHotMints,
  mergeMentionTargets,
  createHotMintsStore,
  type HotMintRow,
  type HotMintsStore,
} from "./hot-mints";
import {
  createHeliusSpendStore,
  type HeliusSpendStore,
} from "./helius-spend-store";
import {
  createAlchemySpendStore,
  type AlchemySpendStore, // TZ-b2: денежный слой Alchemy (CU = деньги)
} from "./alchemy-spend-store";
import {
  recordRpcCall,
  rpcAccountingSnapshot,
  resetRpcAccounting,
  classifyRpcSource,
} from "./rpc-accounting";
import {
  rankWlMintsByVol,
  isMintYieldDemoted,
  type MintYield,
  mergeMentionTargetsWithWl,
  evmReceiptLooksLikeDexSwap,
} from "./asset-subs";

// ─────────────── Type Definitions ───────────────
// ─────────────── EVM Valuation Result (TZ v9.31 Round 9) ───────────────
export interface EvmValuationResult {
  usd: number;
  source:
    | 'protocol_event'
    | 'trader_stable'
    | 'trader_wrapped_native'
    | 'tx_value'
    | 'native_trace'
    | 'subject_price_estimate'
    | 'linked_route'
    | 'unresolved';
  confidence: 'high' | 'medium' | 'low';
  quoteToken?: string;
  quoteAmount?: number;
  diagnostics: string[];
}

export interface CexTokenEntry {
  id: string;
  symbol: string;
  rank: number;
  addresses: Record<string, string>;
  cexSources: string[];
}

interface CexCacheType {
  byAddress: Record<string, CexTokenEntry>;
  bySymbol: Record<string, CexTokenEntry>;
  updatedAt: number;
  tokenCount: number;
  chainsWithAddresses: number;
}

interface Settings {
  telegramToken: string;
  telegramChatId: string;
  parserEnabled: boolean;
  minAmountUsd: number;
  minImpactPct: number;
  antispamMinutes: number;
  groupEnabled: boolean;
  groupSmallWindowSec: number;
  groupSmallThresholdUsd: number;
  groupLargeWindowSec: number;
  groupLargeThresholdUsd: number;
  cexFilterEnabled: boolean;
  top10FilterEnabled: boolean;
  stableFilterEnabled: boolean;
  excludeContractsEnabled: boolean;
  pollIntervalSec: number;
  maxPoolsPerChain: number;
  discoveryIntervalMin: number;
  sseEnabled: boolean;
  networks: Record<string, boolean>;
  extraInfra: string[];
  maxNotifPerMinGlobal: number;
  maxNotifPerMinPerChain: number;
  soloAmountUsd: number;
  seriesTxMinUsd: number;
  seriesMinTx: number;
  seriesWindowSec: number;
  seriesCooldownMin: number;
  maxEventAgeSec: number;
  dexPaprika: {
    enabled: boolean; baseUrl: string;
    // v9.15 F-AC: adaptive credit management knobs.
    apiEnabled: boolean;                    // master switch for DexPaprika API/SSE usage
    fallbackAtDailyLimitPercent: number;    // daily-credit remaining <= this → tier 'low'
    freezeAtDailyLimitPercent: number;      // daily-credit remaining <= this → tier 'freeze' (EVM SSE off)
    restoreTier: 'auto' | 'manual';         // 'auto' = tier rises when credits recover
    monthlyCreditCap: number;               // 0 = use env DEXPAPRIKA_MONTHLY_CREDIT_CAP (default 300K free key)
    sse: { topN: number; refreshIntervalMs: number };
    budget: { perNetwork: { requestsPerDay: Record<string, number> } }; // optional per-chain REST daily cap
  };
}

interface SseStats {
  framesTotal: number;
  parsedOk: number;
  matchedWatchlist: number;
  dropped: number;
  pingCount: number;
  lastDropReason: string | null;
  lastFrameAt: number;
  lastRawFrame: string | null;
  lastParseSteps: Record<string, unknown> | null;
}

interface ChainState {
  enabled: boolean;
  seenEvents: number;
  notified: number;
  skipped: number;
  skipReasons: Record<string, number>;
  lastEventAt: number;
  lastPollAt: number;
  lastError: string | null;
  errors: number;
  watchPools: string[];                 // single source of truth = organic discovery ∪ anchors
  watchPoolsDiscovery: string[];        // organic (non-anchor) discovered pool addresses
  watchPoolsAnchors: string[];          // unconditional anchor pool addresses
  discoveryTokensTried: number;         // per-token expansion candidates attempted
  discoveryTokensWithPools: number;     // candidates that returned pools
  discovery429s: number;                // GeckoTerminal 429s encountered during discovery
  lastDiscoveryAt: number;              // epoch ms of last discovery run
  lastDiscoveryError: string | null;
  // ТЗ v7: systematic CEX-alt coverage
  discoveryCandidates: number;          // candidate CEX subject tokens for this chain
  discoveryCursorPos: number;           // round-robin cursor position into candidate list
  discoveryDsFallbacks: number;         // how many per-token fetches fell back to DexScreener
  poolPollEmptySubjects: number;        // CEX subjects of chain with 0 watched pools (goal 0)
  priorityTokensQueued: number;         // ТЗ v8: priority CEX subjects (recent events/alerts/anchors)
  // v9.1 F6: per-cycle provider counters
  discoveryGtAttempted: number;
  discoveryGtOk: number;
  discoveryGt429: number;
  discoveryGtBudgetSkip: number;
  discoveryDsAttempted: number;
  discoveryDsOk: number;
  discoveryDsEmpty: number;
  discoveryDsEmptyFresh: number;        // bm2t T1: пустой DS на первом заходе токена
  discoveryDsEmptyRescan: number;       // bm2t T1: пустой DS на повторном заходе
  discoveryCandidatesFromDs: number;    // bm2t T3: кандидаты из profiles/boosts при бане GT
  discoveryCycles: number;
  discoveryStalledCycles: number;       // times discovery was >2 cycles overdue (no silent stall)
  watchStablePools: number;
  hotToken: string | null;
  hotTokenName: string;
  sseConnected: boolean;
  source: string;
  sseLastEvent: number;
  sseFails: number;
  sseLastError: string | null;
  sse: SseStats;
  _pollIdx: number;
  label: string;
  initStatus: string;
  pollTicks1m: number[];
  _seedDone: boolean;
  startupSeeded: number;
  watchlistRetries: number;   // ТЗ v4 Block 4.1: retry count for watchlist init
}


interface CexHitRecord {
  chain: string;
  subjectAddr: string;
  subjectSym: string;
  matchedKey: string;
  source: 'direct' | 'wrapped';
  ts: number;
}

interface PoolInfo {
  address: string;
  baseToken: { address: string; symbol: string; name: string };
  quoteToken: { address: string; symbol: string; name: string };
  liquidityUsd: number;
  fdv: number;
  cachedAt: number;
  dexId?: string; // v9.32bm1h O6: GT relationships.dex.data.id — по нему оракул находит venue политики
}

// ─────────────── Series Buffer Types ───────────────
interface SeriesCandidate {
  ts: number;
  blockTs: number;
  txHash: string;
  usd: number;
  amount: number;
  pool: string | null;
  symbol: string;
  token: string;
  links: Record<string, string>;
  via: string | null;
  // v9.12 F1: a self-swap candidate is never allowed into a series-alert.
  selfSwap?: boolean;
}

interface SeriesBuffer {
  candidates: SeriesCandidate[];
}

const seriesBuffer = new Map<string, SeriesBuffer>();
const seriesCooldown = new Map<string, number>();

interface SeriesStats {
  pending: number;
  fired: number;
  suppressedByCooldown: number;
  noDataCandidates: number;
  expiredWithoutFire: number; // bm2x S3: кандидаты, вычищенные из буфера без fire (осиротели навсегда)
}

const seriesStats: SeriesStats = { pending: 0, fired: 0, suppressedByCooldown: 0, noDataCandidates: 0, expiredWithoutFire: 0 };

// ТЗ v4 Block 5: net-flow guard suppressed counter
let seriesNetSuppressed = 0;

interface SoloStats {
  instant: number;
}

const soloStats: SoloStats = { instant: 0 };

let droppedStaleTotal = 0;
// v9.32bm1g H4: stale по классу источника. ws = evmws/solpubsub/solreserve/gtws/sse (доставка/анализ
// опоздали), poll = poll/dp/catch-up (глубина курсора). Лечатся по-разному — один счётчик не отвечал «кто».
const droppedStaleBySource = { ws: 0, poll: 0 };
const STALE_WS_SOURCES = new Set(['evmws', 'solpubsub', 'solreserve', 'gtws', 'sse']);
function staleSourceClass(source: string): 'ws' | 'poll' { return STALE_WS_SOURCES.has(source) ? 'ws' : 'poll'; }
let droppedNoBlockTs = 0;
// v9.13 F3: cold-start catch-up stale counter. During the startup window, events whose blockTs is
// older than freshnessGate relative to parser start are counted as stale and NOT into seenEvents
// (so a cold-start catch-up flood doesn't pollute the seenEvents denominator / stale_rate).
let startupCatchUpStale = 0;
const COLD_START_WINDOW_MS = Number(process.env.COLD_START_WINDOW_MS || (5 * 60 * 1000));
function isColdStartCatchUpStale(blockTsSec: number): boolean {
  if (!state.startedAt || !(blockTsSec > 0)) return false;
  if (Date.now() - state.startedAt > COLD_START_WINDOW_MS) return false; // window over
  const startedAtSec = Math.floor(state.startedAt / 1000);
  // if the event is older than the freshness gate relative to startup → cold-start catch-up stale
  return (startedAtSec - blockTsSec) > settings.maxEventAgeSec;
}

// ─────────────── contractExec Filter State (ТЗ v2: inventory mode) ───────────────
// CONTRACT_EXEC_FILTER: 'inventory' (default) | 'off'
// 'inventory': cut contracts that HOLD token inventory (balance@block-1 >= $100);
//              pass transit/DEX contracts (balance≈0) and EOAs
const CONTRACT_EXEC_FILTER: 'inventory' | 'off' =
  (process.env.CONTRACT_EXEC_FILTER as 'inventory' | 'off') || 'inventory';
// Minimum USD value of pre-trade balance to classify as "piggy-bank" (cut)
const CONTRACT_INVENTORY_USD = Number(process.env.CONTRACT_INVENTORY_USD || '100');

// ТЗ v4 Block 4.2: pool watchlist thresholds (union of top by reserve & top by 24h volume,
// including USDC/USDT-quoted pairs).
const POOL_MIN_RESERVE_USD = Number(process.env.POOL_MIN_RESERVE_USD || '20000');
const POOL_MIN_24H_VOLUME_USD = Number(process.env.POOL_MIN_24H_VOLUME_USD || '10000');
const WATCH_POOLS_PER_TOKEN = Number(process.env.WATCH_POOLS_PER_TOKEN || '30');

// ТЗ v4 Block 5: Net-flow guard thresholds
const SERIES_MIN_NET_USD = Number(process.env.SERIES_MIN_NET_USD || '3000');
// ТЗ impact thresholds
const SERIES_MIN_TOTAL_IMPACT_PCT = Number(process.env.SERIES_MIN_TOTAL_IMPACT_PCT || '0.15');
const SERIES_MIN_ROW_IMPACT_PCT = Number(process.env.SERIES_MIN_ROW_IMPACT_PCT || '0.05');
const SOLO_MIN_IMPACT_PCT = Number(process.env.SOLO_MIN_IMPACT_PCT || '0.05');
// v9.12 F2 (refined): absolute / liquidity / low-liq caps.
const PRICE_SANITY_ABS_CAP_MULT = Number(process.env.PRICE_SANITY_ABS_CAP_MULT || '1000');   // usd > soloAmountUsd*1000 → invalid ($30M+)

let contractExecSkipped = 0;     // skipReason 'contractExec' global total (piggy-bank contracts)
let safeAllowed = 0;             // Gnosis Safe multisigs allowed through
let contractTransient = 0;       // transit contracts allowed (balance≈0)
let contractExecUnknown = 0;     // receipt unavailable/ambiguous — not filtered

// Cache: tx classification (txHash → 'contractExec' | 'safe' | 'eoa' | 'unknown' | 'transient')
const contractExecCache = new Map<string, 'contractExec' | 'safe' | 'eoa' | 'unknown' | 'transient'>();
// Cache: getOwners result by address (address → boolean isSafe)
const safeCheckCache = new Map<string, boolean>();
// Cache: inventory check by (chain,address,blockNumber) → preBalanceUsd
const inventoryCache = new Map<string, { preBalanceUsd: number; cachedAt: number }>();
const INVENTORY_CACHE_TTL = 30 * 60 * 1000;

const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

// ─────────────── Constants ───────────────
const CHAINS: Record<string, {
  label: string; short: string; explorer: string; rpc: string | null;
  ds: string; gecko: string; dpNetwork: string; wrapped: string;
  nativeContract: string | null; gmgnSlug: string | null;
}> = {
  ethereum: {
    label: 'Ethereum', short: 'ETH', explorer: 'https://etherscan.io',
    rpc: 'https://ethereum-rpc.publicnode.com', ds: 'ethereum', gecko: 'eth',
    dpNetwork: 'ethereum', wrapped: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
    nativeContract: null, gmgnSlug: 'eth'
  },
  bsc: {
    label: 'BNB Chain', short: 'BSC', explorer: 'https://bscscan.com',
    rpc: 'https://bsc-dataseed.binance.org', ds: 'bsc', gecko: 'bsc',
    dpNetwork: 'bsc', wrapped: '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c',
    nativeContract: null, gmgnSlug: 'bsc'
  },
  base: {
    label: 'Base', short: 'BASE', explorer: 'https://basescan.org',
    rpc: 'https://mainnet.base.org', ds: 'base', gecko: 'base',
    dpNetwork: 'base', wrapped: '0x4200000000000000000000000000000000000006',
    nativeContract: null, gmgnSlug: 'base'
  },
  arbitrum: {
    label: 'Arbitrum', short: 'ARB', explorer: 'https://arbiscan.io',
    rpc: 'https://arb1.arbitrum.io/rpc', ds: 'arbitrum', gecko: 'arbitrum',
    dpNetwork: 'arbitrum', wrapped: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1',
    // v9.32bl BL4: GMGN не обслуживает Arbitrum — gmgn.ai/arb/token/… пустая страница (проверено
    // 08.09.2026); слог появился в v9.30 без правки selftest. Карточка получает DexScreener.
    nativeContract: null, gmgnSlug: null
  },
  polygon: {
    label: 'Polygon', short: 'POL', explorer: 'https://polygonscan.com',
    rpc: 'https://polygon-bor-rpc.publicnode.com', ds: 'polygon', gecko: 'polygon_pos',
    dpNetwork: 'polygon', wrapped: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270',
    nativeContract: '0x0000000000000000000000000000000000001010', gmgnSlug: null // v9.32bl BL4: как arbitrum — GMGN без Polygon
  },
  solana: {
    label: 'Solana', short: 'SOL', explorer: 'https://solscan.io',
    rpc: null, ds: 'solana', gecko: 'solana',
    dpNetwork: 'solana', wrapped: 'So11111111111111111111111111111111111111112',
    nativeContract: null, gmgnSlug: 'sol'
  },
};

const GECKO_PLATFORM_TO_CHAIN: Record<string, string> = {
  'ethereum': 'ethereum',
  'binance-smart-chain': 'bsc',
  'base': 'base',
  'arbitrum-one': 'arbitrum',
  'arbitrum': 'arbitrum',
  'polygon-pos': 'polygon',
  'polygon': 'polygon',
  'matic-network': 'polygon',
  'solana': 'solana',
};

const STABLE_SYMS = new Set(['usdt','usdc','dai','busd','tusd','frax','usde','fdusd','pyusd','gusd','usdp','usdd','lusd','mim','crvusd','eurc','usdc.e','usdb','usdbc','usds','usd0','dola','buck','susde','susd','eurs','bidr']);
const STABLE_ADDRS = new Set([
  // Ethereum
  '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
  '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
  '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI
  '0x4fabb145d64652a948d72533023f6e7a623c7c53', // BUSD
  '0x853d955acef822db058eb8505911ed77f175b99e', // FRAX
  '0x5f98805a4e8be255a32880fdec7f6728c6568ba0', // LUSD
  '0x956f47f50a910163d8bf957cf5846d573e7f87ca', // FEI
  '0xdc035d45d973e3ec169d2276ddab16f1e407384f', // USDS (Savings DAI / Sky)
  '0x9d39a5de30e57443bff2a8307a4256c8797a3497', // sUSDe
  '0x4c9edd5852cd905f086c759e8383e09bff1e68b3', // USDe
  '0x0000000000085d4780b73119b644ae5ecd22b376', // TUSD
  '0x8e870d67f660d95d5be530380d0ec0bd388289e1', // USDP
  '0x056fd409e1d7a124bd7017459dfea2f387b6d5cd', // GUSD
  '0xf939e0a03fb07f59a73314e73794be0e57ac1b4e', // crvUSD
  '0x73a15fed60bf67631dc6cd7bc5b6e8da8190acf5', // USD0
  '0x865377367054516e17014ccded1e7d814edc9ce4', // DOLA
  // BSC
  '0x55d398326f99059ff775485246999027b3197955', // USDT BSC
  '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // USDC BSC
  '0xe9e7cea3dedca5984780bafc599bd69add087d56', // BUSD BSC
  '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3', // DAI BSC
  '0x1cd0c718cd64b56e1fe34b00571cce0dc4b59c6e', // FDUSD BSC
  // Base
  '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC Base
  '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca', // USDbC
  '0x50c5725949a6f0c72e6c4a641f24049a917db0cb', // DAI Base
  // Arbitrum
  '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC Arbitrum
  '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', // USDC.e Arbitrum
  '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // USDT Arbitrum
  '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1', // DAI Arbitrum
  // Polygon
  '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC Polygon
  '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', // USDC.e Polygon
  '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT Polygon
  '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063', // DAI Polygon
  // Solana
  'es9vmfrzacermjfrf4h2fyd4kconky11mcce8benwnyb', // USDT Solana
  'epjfwdd5aufqssqem2qn1xzybapc8g4weggkzwytdt1v', // USDC Solana
].map(a => a.toLowerCase()));

const TOP10_HARDCODED = ['btc','eth','usdt','bnb','sol','usdc','xrp','steth','doge','ada','wbtc','weth','wbnb','wsol','tron','trx','pol','matic'];

const STABLE_ADDR_TO_SYM: Record<string, string> = {
  '0xdac17f958d2ee523a2206206994597c13d831ec7': 'USDT',
  '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC',
  '0x6b175474e89094c44da98b954eedeac495271d0f': 'DAI',
  '0x4fabb145d64652a948d72533023f6e7a623c7c53': 'BUSD',
  '0x853d955acef822db058eb8505911ed77f175b99e': 'FRAX',
  '0x5f98805a4e8be255a32880fdec7f6728c6568ba0': 'LUSD',
  '0x55d398326f99059ff775485246999027b3197955': 'USDT',
  '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': 'USDC',
  '0xe9e7cea3dedca5984780bafc599bd69add087d56': 'BUSD',
  '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3': 'DAI',
  '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 'USDC',
  '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca': 'USDbC',
  '0x50c5725949a6f0c72e6c4a641f24049a917db0cb': 'DAI',
  '0xaf88d065e77c8cc2239327c5edb3a432268e5831': 'USDC',
  '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8': 'USDC.e',
  '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': 'USDT',
  '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1': 'DAI',
  '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': 'USDC',
  '0x2791bca1f2de4661ed88a30c99a7a9449aa84174': 'USDC.e',
  '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 'USDT',
  '0x8f3cf7ad23cd3cadbd9735aff958023239c6a063': 'DAI',
  'es9vmfrzacermjfrf4h2fyd4kconky11mcce8benwnyb': 'USDT',
  'epjfwdd5aufqssqem2qn1xzybapc8g4weggkzwytdt1v': 'USDC',
  // Added: USDS and others missing from original
  '0xdc035d45d973e3ec169d2276ddab16f1e407384f': 'USDS',
  '0x9d39a5de30e57443bff2a8307a4256c8797a3497': 'sUSDe',
  '0x4c9edd5852cd905f086c759e8383e09bff1e68b3': 'USDe',
  '0x0000000000085d4780b73119b644ae5ecd22b376': 'TUSD',
  '0x8e870d67f660d95d5be530380d0ec0bd388289e1': 'USDP',
  '0x056fd409e1d7a124bd7017459dfea2f387b6d5cd': 'GUSD',
  '0xf939e0a03fb07f59a73314e73794be0e57ac1b4e': 'crvUSD',
  '0x73a15fed60bf67631dc6cd7bc5b6e8da8190acf5': 'USD0',
  '0x865377367054516e17014ccded1e7d814edc9ce4': 'DOLA',
  '0x1cd0c718cd64b56e1fe34b00571cce0dc4b59c6e': 'FDUSD',
};

function resolveSymFromAddr(addr: string): string {
  return STABLE_ADDR_TO_SYM[lower(addr)] || '?';
}

const WRAPPED_TO_NATIVE: Record<string, string> = {
  '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'eth',
  '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c': 'bnb',
  '0x4200000000000000000000000000000000000006': 'eth',
  '0x82af49447d8a07e3bd95bd0d56f35241523fbab1': 'eth',
  '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270': 'pol',
  '0x0000000000000000000000000000000000001010': 'pol',
  'so11111111111111111111111111111111111111112': 'sol',
};

const NATIVE_OR_WRAPPED = new Set(Object.keys(WRAPPED_TO_NATIVE).map(s => s.toLowerCase()));

// ─────────────── MAJOR_TOKENS Config (externalized, v3.9.9+) ───────────────
// Rule: filter ONLY by SUBJECT address (not quote leg).
// Symbol comparison is FORBIDDEN — only lowercase address matching.
// To add new tokens: add to MAJOR_TOKENS_CONFIG, no code changes needed.
// ТЗ fix: WETH on Arbitrum = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1 (NOT 0x4200...06)
interface MajorTokenEntry {
  address: string;  // lowercase
  symbol: string;   // for display only, NOT used in filter logic
}
const MAJOR_TOKENS_CONFIG: Record<string, MajorTokenEntry[]> = {
  ethereum: [
    // ETH derivatives: WETH + liquid staking tokens
    { address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', symbol: 'WETH' },
    { address: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', symbol: 'stETH' },
    { address: '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0', symbol: 'wstETH' },
    { address: '0xbe9895146f7af43049ca1c1ae358b0541ea49704', symbol: 'cbETH' },
    { address: '0xae78736cd615f374d3085123a210448e74fc6393', symbol: 'rETH' },
    { address: '0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee', symbol: 'weETH' },
    { address: '0xbf5495efe5db9ce00f80364c8b423567e58d2110', symbol: 'ezETH' },
    // BTC derivatives
    { address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', symbol: 'WBTC' },
    { address: '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf', symbol: 'cbBTC' },
    { address: '0x18084fba666a33d37592fa2633fd49a74dd93a88', symbol: 'tBTC' },
    // POL on Ethereum
    { address: '0x455e53cbb86018ac2b8092fdcd39d8444affc3f6', symbol: 'POL(eth)' },
  ],
  base: [
    // ETH derivatives
    { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH' },
    { address: '0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22', symbol: 'cbETH' },
    { address: '0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452', symbol: 'wstETH' },
    // BTC derivatives
    { address: '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf', symbol: 'cbBTC' },
  ],
  arbitrum: [
    // ETH derivatives — ТЗ fix: WETH on Arbitrum = 0x82aF..., NOT 0x4200...06
    { address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', symbol: 'WETH' },
    { address: '0x5979d7b546e38e414f7e9822514be443a4800529', symbol: 'wstETH' },
    // BTC derivatives
    { address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', symbol: 'WBTC' },
    { address: '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf', symbol: 'cbBTC' },
    { address: '0x6c84a8f1c29108f47a79964b5fe888d4f4d0de40', symbol: 'tBTC' },
  ],
  bsc: [
    // BNB derivatives
    { address: '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', symbol: 'WBNB' },
    { address: '0xa2e3356610840701bdf5611a53974510ae27e2e1', symbol: 'WBETH' },
    { address: '0xb0b84d294e0c75a6abe60171b70edeb2efd14a1b', symbol: 'slisBNB' },
    // BTC derivatives
    { address: '0x7130d2a12b9bcbfae4f2634d864a1ee1ce3ead9c', symbol: 'BTCB' },
    // ETH pegged
    { address: '0x2170ed0880ac9a755fd29b2688956bd959f933f8', symbol: 'ETH(bsc)' },
  ],
  polygon: [
    // POL derivatives
    { address: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', symbol: 'WPOL' },
    // BTC derivatives
    { address: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', symbol: 'WBTC' },
    // ETH derivatives
    { address: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619', symbol: 'WETH' },
  ],
  solana: [
    // SOL derivatives
    { address: 'so11111111111111111111111111111111111111112', symbol: 'WSOL' },
    { address: 'j1toso1uck3rlmjorhttrvwy9hj7x8v9yyac6y7kgcpn', symbol: 'jitoSOL' },
    { address: 'msolzycxhdygdzu16g5qsh3i5k3z3kzk7ytfqcjm7so', symbol: 'mSOL' },
    { address: 'bso13r4tkie4kumlgvlstenffd2pseagw81fetmpy5du', symbol: 'bSOL' },
    { address: 'emmwcc4n1rzgvvbt61lixe1ymd5ddu3zq6vccdgbvzy9', symbol: 'INF' },
  ],
};

// Build lookup sets from config (lowercase addresses only — symbol is ignored in filter)
const MAJOR_TOKENS_BY_CHAIN: Record<string, Set<string>> = {};
for (const [chain, entries] of Object.entries(MAJOR_TOKENS_CONFIG)) {
  MAJOR_TOKENS_BY_CHAIN[chain] = new Set(entries.map(e => e.address.toLowerCase()));
}

/** Returns true if the address is a major native/wrapped token on this chain (subject filter) */
function isMajorToken(chainId: string, addr: string): boolean {
  const set = MAJOR_TOKENS_BY_CHAIN[chainId];
  if (!set) return false;
  return set.has(lower(addr));
}

// F5/M3: CoW Protocol GPv2Settlement — a settlement contract (matches trades for many users); the
// real trader must be resolved from the Trade event owner, never the tx sender (sender_eoa).
const COW_SETTLEMENT = '0x9008d19f58aabd9ed0d60971565aa8510560ab41';
// Topic0 of `event Trade(address indexed owner, address indexed sellToken, address indexed buyToken,
// uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)`.
const COW_TRADE_TOPIC = '0xa07a543ab8a018198e99ca0184c93fe9050a79400a0a723441f84de1d972cc17';

// Live settlement/reactor addresses: protocol-registry.getSettlementAddrs()
// (dead SETTLEMENTS map with hallucinated UniswapX/1inch reactor addrs removed in v9.32).

function settlementAddrs(chainId: string): string[] {
  return getSettlementAddrs(chainId);
}
function isSettlementAddr(chainId: string, addr: string): boolean {
  return isSettlementAddress(chainId, addr);
}

// Live router addresses: protocol-registry.getRouterAddrs()
// (dead ROUTERS map with the hallucinated Odos V2 0x4f393218… removed in v9.32b).

function routerAddrs(chainId: string): string[] {
  return getRouterAddrs(chainId);
}
function isRouterAddr(chainId: string, addr: string): boolean {
  return isRouterAddress(chainId, addr);
}

// ─────────── ТЗ bm2n6 (эмиттер ≠ пул): честная атрибуция пула кадра EVM-WS / hotPoll ───────────
// Эмиттер лога (`lg.address` в hotPoll, `result.address` в WS-кадре) — это адрес КОНТРАКТА,
// выдавшего лог, а НЕ «пул по определению». Подписочный набор: вотчпулы (WS top-pools ∪ anchors)
// + settlement/reactor/allowance-holder/solver-helper (OD2); хотлист поллинга: subject-токены и
// топ-пул токена. До этой правки эмиттер целиком уезжал в rawData.pool, и токен/котировка
// попадала в карточку как «пул» (прод-диагностика bm2n3/bm2n4: arb USDC, base AERO; 37 %
// нотификаций несли адрес токена в поле пула). Шаги D/E остаются страховкой.
export type EvmEmitterKind = 'token' | 'settlement' | 'router' | 'pool' | 'unknown';
/** Источник кадра: WS-подписка (push) или hot-poll (`eth_getLogs`). */
export type EvmFrameOrigin = 'ws' | 'hotpoll';

interface EvmEmitterClass {
  kind: EvmEmitterKind;
  inWatchPools: boolean;
  inAnchors: boolean;
}

function emitterInWatchPools(chainId: string, addr: string): boolean {
  const a = lower(addr);
  if (!a) return false;
  return (state.chain[chainId]?.watchPools || []).some(p => lower(p) === a);
}

// Пул по имеющимся данным: watchPools (органический дискавери ∪ anchors) ∪ верифицированные
// якоря (в т.ч. v4-poolId, который приходит из v4-ветки WS вместо адреса) ∪ метаданные
// дискавери GT/DexScreener (данные фабрик) ∪ v4 PoolManager (сам является venue).
function isKnownPoolAddress(chainId: string, addr: string): boolean {
  const a = lower(addr);
  if (!a) return false;
  if (emitterInWatchPools(chainId, a)) return true;
  if (isEvmVerifiedAnchor(chainId, a)) return true;
  if (chainWatchMeta[chainId]?.[a]) return true;
  return isPoolManager(chainId as SupportedChain, a);
}

// Классификация эмиттера имеющимися средствами (ТЗ п.1): evmTokenSubAddrs → token,
// isSettlementAddr → settlement, isRouterAddress → router, иначе pool (по watchPools).
// Приоритет watchPools/anchors сохранён как в шаге E (`watchHit` побеждает роли registry):
// адрес, который движок держит вотчпулом, — пул, даже если реестр помечает его settlement.
// Дополнительно «токеном» считается subject-токен самого кадра и subject-токен вотчлиста
// (котировки вида arb USDC / base AERO — именно они утекали в карточку как пулы).
function classifyEvmEmitter(chainId: string, addr: string, subjectAddr?: string | null): EvmEmitterClass {
  const a = lower(addr);
  const base = { inWatchPools: emitterInWatchPools(chainId, a), inAnchors: isEvmVerifiedAnchor(chainId, a) };
  if (!a) return { kind: 'unknown', ...base };
  if (subjectAddr && a === lower(subjectAddr)) return { kind: 'token', ...base };
  if (evmTokenSubAddrs.has(chainId + ':' + a)) return { kind: 'token', ...base };
  if (base.inWatchPools || base.inAnchors) return { kind: 'pool', ...base }; // приоритет watchPools (шаг E)
  if (isSettlementAddr(chainId, a)) return { kind: 'settlement', ...base };
  if (isRouterAddr(chainId, a)) return { kind: 'router', ...base };
  const meta = chainWatchMeta[chainId] || {};
  for (const e of Object.values(meta)) {
    if (e?.subjectToken && lower(e.subjectToken) === a) return { kind: 'token', ...base };
  }
  if (isKnownPoolAddress(chainId, a)) return { kind: 'pool', ...base };
  return { kind: 'unknown', ...base };
}

// ТЗ п.3: кандидат-пул из Swap-эмиттера чека — честный источник адреса пула там, где сам кадр
// пришёл НЕ от пула (token-кадр hotPoll = Transfer-шум токена; settlement-кадр CoW/Fusion/
// UniswapX исполняется роутингом через обычный AMM-пул). Набор топиков — существующие константы
// движка (V2/V3 Swap из evm-swap-prefilter, V4 Swap из anchor-manifest): SWAP_TOPICS не
// расширяем (стоп-линия ТЗ).
const EVM_SWAP_EMITTER_TOPICS: ReadonlySet<string> = new Set([
  EVM_V2_SWAP_TOPIC.toLowerCase(),
  EVM_V3_SWAP_TOPIC.toLowerCase(),
  UNISWAP_V4_SWAP_TOPIC.toLowerCase(),
]);

function extractSwapEmitterPool(
  chainId: string,
  logs: Array<{ address?: string; topics?: string[] }> | undefined,
  subjectAddr: string | null | undefined,
): string | null {
  const subject = lower(subjectAddr || '');
  const emitters: string[] = [];
  const seen = new Set<string>();
  const subjectLeg = new Set<string>(); // эмиттеры, у которых нога чека — сам subject-токен
  const v4PmToPoolIds = new Map<string, string[]>(); // PoolManager → poolId[] (переводы идут на менеджер)
  const v4Topic = UNISWAP_V4_SWAP_TOPIC.toLowerCase();
  for (const lg of logs || []) {
    const addr = lower(String(lg?.address || ''));
    const topics = lg?.topics || [];
    const t0 = lower(String(topics[0] || ''));
    if (!addr || !t0) continue;
    if (EVM_SWAP_EMITTER_TOPICS.has(t0)) {
      // bm2n7 Фаза B: v4 Swap эмитит синглтон PoolManager; рынок — indexed poolId (topics[1]).
      const poolId = t0 === v4Topic ? lower(String(topics[1] || '')) : '';
      const id = t0 === v4Topic && poolId.length === 66 ? poolId : addr;
      if (t0 === v4Topic && poolId.length === 66) {
        const arr = v4PmToPoolIds.get(addr) || [];
        arr.push(poolId);
        v4PmToPoolIds.set(addr, arr);
      }
      if (!seen.has(id)) { seen.add(id); emitters.push(id); }
      continue;
    }
    if (subject && addr === subject && t0 === lower(TRANSFER_TOPIC) && topics.length >= 3) {
      subjectLeg.add(lower('0x' + String(topics[1]).slice(-40)));
      subjectLeg.add(lower('0x' + String(topics[2]).slice(-40)));
    }
  }
  for (const [pm, ids] of v4PmToPoolIds) {
    if (subjectLeg.has(pm)) for (const id of ids) subjectLeg.add(id);
  }
  // Валидация кандидата (≠ subject, не settlement/router, известный пул/фабричные метаданные,
  // приоритет watchPools) — тем же классификатором, что и эмиттер кадра: одна точка правды.
  const valid = emitters.filter(a => a !== subject && classifyEvmEmitter(chainId, a, subject).kind === 'pool');
  if (!valid.length) return null;
  // Мультихоп: приоритет — эмиттер, у которого нога чека в subject-токене (тот самый пул свопа).
  return valid.find(a => subjectLeg.has(a)) ?? valid[0];
}

const KNOWN_INFRA: Record<string, string> = {
  [COW_SETTLEMENT]: 'CoW Protocol GPv2Settlement',
  '0x0474941aad87433a55eec78109b41c6aa08f8f2c': 'Morpho Bundler',
  '0xcf77a3ba9a5ca399b7c97c74d54e5b1beb874e43': 'Aerodrome Router',
  '0x7a250d5630b4cf539739df2c5dacb4c659f2488d': 'Uniswap V2 Router',
  '0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45': 'Uniswap SwapRouter02',
  '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad': 'Uniswap Universal Router',
  '0x1111111254fb6c44bac0bed2854e76f90643097d': '1inch v5',
  '0x111111125421ca6dc452d289314280a0f8842a65': '1inch v6',
  '0xe592427a0aece92de3edee1f18e0157c05861564': 'Uniswap V3 Router',
  '0x28c6c06298d514db089934071355e5743bf21d60': 'Binance',
  '0x21a31ee1afc51d94c2efccaa2092ad1028285549': 'Binance 14',
  '0x71660c4005ba85c37ccec55d0c4493e66fe775d3': 'Coinbase',
  '0x6cc5f688a315f3dc28a7781717a9a798a59fda7b': 'OKX',
  '0xf89d7b9c864f589bbf53a82105107622b35eaa40': 'Bybit',
};

// Registry roles for transaction plumbing, not a venue/pool. Deliberately excludes
// 'pool-manager': a V4 PoolManager is itself the venue. The Base deployment is marked
// poolManager in the registry because it uses role='settlement' for log subscriptions.
const NON_POOL_ROLES = new Set<string>([
  'settlement', 'router', 'reactor', 'permit2',
  'allowance-holder', 'solver-helper', 'infrastructure',
]);

const KNOWN_CONTRACT_ROUTERS = [
  '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad',
  '0x7a250d5630b4cf539739df2c5dacb4c659f2488d',
  '0xe592427a0aece92de3edee1f18e0157c05861564',
];

const DEFAULT_SETTINGS: Settings = {
  telegramToken: '',
  telegramChatId: '',
  parserEnabled: true,
  minAmountUsd: 1000,
  minImpactPct: 0.3,
  antispamMinutes: 5,
  groupEnabled: true,
  groupSmallWindowSec: 180,
  groupSmallThresholdUsd: 10000,
  groupLargeWindowSec: 60,
  groupLargeThresholdUsd: 30000,
  cexFilterEnabled: true,
  top10FilterEnabled: false,
  stableFilterEnabled: true,
  excludeContractsEnabled: true,
  pollIntervalSec: 10,
  maxPoolsPerChain: 25,
  discoveryIntervalMin: 30,
  sseEnabled: true,
  networks: { ethereum: true, bsc: true, base: true, arbitrum: true, polygon: true, solana: true },
  extraInfra: [],
  maxNotifPerMinGlobal: 4,
  maxNotifPerMinPerChain: 2,
  soloAmountUsd: 30000,
  seriesTxMinUsd: 1000,
  seriesMinTx: 3,
  seriesWindowSec: 180,
  seriesCooldownMin: 10,
  maxEventAgeSec: 180,
  // v9.7 I1: DexPaprika REST as a free (no-API-key, 30 req/min/IP) fallback transaction source for
  // EVM/Solana pools. Enabled by default; baseUrl is the documented public REST API.
  dexPaprika: {
    enabled: true, baseUrl: 'https://api.dexpaprika.com',
    apiEnabled: true,
    fallbackAtDailyLimitPercent: 20,
    freezeAtDailyLimitPercent: 5,
    restoreTier: 'auto',
    monthlyCreditCap: 0,
    sse: { topN: 25, refreshIntervalMs: 6 * 60 * 60 * 1000 },
    budget: { perNetwork: { requestsPerDay: {} } },
  },
};

// ─────────────── Helpers ───────────────
const DP_SYMBOL_ENABLED = process.env.DP_SYMBOL_ENABLED === '1';
const DP_PRICE_ENABLED = process.env.DP_PRICE_ENABLED === '1';

const lower = (s: string | null | undefined) => String(s || '').toLowerCase();

// ТЗ v4 Block 1: Solana addresses are base58 and CASE-SENSITIVE.
// We MUST NOT lowercase them. EVM addresses keep case-insensitive handling.
const isEVMChain = (chainId: string): boolean => chainId !== 'solana';
// Normalise an address for a chain: EVM -> lowercase, Solana -> preserve original case.
function normAddr(chainId: string, addr: string | null | undefined): string {
  const s = String(addr || '');
  return isEVMChain(chainId) ? s.toLowerCase() : s;
}

const shortAddr = (a: string) => (a && a.length > 10) ? a.slice(0, 6) + '…' + a.slice(-4) : (a || '?');
// v9.10 F3: display impact is capped at 100% — a single swap can't move more than the pool's total
// liquidity, so anything above is a liquidity-ratio artifact (tiny pool) and showing e.g. 5000% is
// misleading/"запредельный". NaN/invalid → omitted entirely.
const fmtImpact = (v: number): string | null => {
  const n = Number(v);
  if (!isFinite(n) || !(n > 0)) return null;
  const capped = Math.min(n, 100);
  return capped >= 100 ? '≥100%' : `~${capped.toFixed(2)}%`;
};
// ── v9.32ao-RC1 BP1: engine-kernel на globalThis ──
// Next 16 бандлит instrumentation.ts и API-роуты отдельными бандлями — у каждого свой
// инстанс ЭТОГО модуля. Парсер, стартованный autostart'ом (инструментационный инстанс A),
// был невидим роутам (инстанс B): pauseParser() выходил по !running и возвращал false,
// алерты шли, pausedDropped врал нулями, а POST /api/parser/start поднимал ВТОРОЙ движок
// (двойные подписки/CU). Ядро на globalThis — единые running/paused/notifs/логи/страж
// для всех бандлей процесса.
// v9.32au AU1: why aggregator attribution still has no legs (one reason per tx).
export type AggregatorRejectReason =
  | 'no_candidates'
  | 'usd_zero'
  | 'below_min'
  | 'is_contract'
  | 'getcode_unresolved'
  | 'non_wallet';
// v9.32au AU3: why the CEX-gate dropped a tx (observability only — pass/fail unchanged).
export type CexRejectReason = 'known_cex_address' | 'heuristic' | 'threshold';
function emptyAggregatorRejects(): Record<AggregatorRejectReason, number> {
  return { no_candidates: 0, usd_zero: 0, below_min: 0, is_contract: 0, getcode_unresolved: 0, non_wallet: 0 };
}
function emptyCexRejectBucket(): Record<CexRejectReason, number> {
  return { known_cex_address: 0, heuristic: 0, threshold: 0 };
}
function emptyCexRejects(): Record<string, Record<CexRejectReason, number>> {
  const o: Record<string, Record<CexRejectReason, number>> = {};
  for (const c of Object.keys(CHAINS)) o[c] = emptyCexRejectBucket();
  return o;
}
interface NotifSkippedCounts {
  phantom_tx: number;
  self_swap: number;
  price_oracle_invalid: number;
  no_swap_legs: number;
  aggregatorNoLegs: number;
  aggregatorAttributed: number;
  aggregatorStillNoLegs: number;
  aggregatorSkippedBelowMin: number;
  aggregatorRejects: Record<AggregatorRejectReason, number>;
  blacklistSubjectSwapped: number;
  impact_anomaly: number;
  exchange_transfer: number;
  usd_unresolved: number;
  invalid_tx_format: number;
  invalid_tx_format_synthetic: number;
  invalid_tx_format_other: number;
  invalid_tx_format_regex_evm: number;
  invalid_tx_format_regex_solana: number;
  invalid_tx_format_non_hex: number;
}
// bm2w: cumulative diagnostic stages; not a partition of unique transactions.
type SolAnalysisOrigin = 'diagnostic' | 'pubsubFallback' | 'reserveEnrichment' | 'pollEnrichment';
type SolAnalysisCounts = { attempts: number; found: number; rejected: Record<string, number> };
function emptySolNotificationFlow() {
  return {
    logsNotifications: 0, accountNotifications: 0,
    reserveStages: { notifications: 0, singleLegs: 0, paired: 0, sigRecovered: 0, sigFailed: 0, emitted: 0, pairExpiredTtl: 0 },
    analysis: Object.fromEntries(['diagnostic', 'pubsubFallback', 'reserveEnrichment', 'pollEnrichment']
      .map(origin => [origin, { attempts: 0, found: 0, rejected: {} }])) as Record<SolAnalysisOrigin, SolAnalysisCounts>,
  };
}
interface EngineMetrics {
  solNotificationFlow: ReturnType<typeof emptySolNotificationFlow>;
  solanaValuationSourceCounts: Record<SolanaUsdSource, number>;
  notifSkipped: NotifSkippedCounts;
  cexRejects: Record<string, Record<CexRejectReason, number>>;
}
function emptyNotifSkipped(): NotifSkippedCounts {
  return {
    phantom_tx: 0, self_swap: 0, price_oracle_invalid: 0, no_swap_legs: 0,
    aggregatorNoLegs: 0, aggregatorAttributed: 0, aggregatorStillNoLegs: 0, aggregatorSkippedBelowMin: 0,
    aggregatorRejects: emptyAggregatorRejects(),
    blacklistSubjectSwapped: 0,
    impact_anomaly: 0, exchange_transfer: 0, usd_unresolved: 0,
    invalid_tx_format: 0, invalid_tx_format_synthetic: 0, invalid_tx_format_other: 0,
    invalid_tx_format_regex_evm: 0, invalid_tx_format_regex_solana: 0, invalid_tx_format_non_hex: 0,
  };
}
function emptySolanaValuationSourceCounts(): Record<SolanaUsdSource, number> {
  return {
    trader_stable: 0, trader_wsol: 0, trader_native: 0, trader_other_priced: 0,
    linked_route: 0, subject_price: 0, lastknown: 0, pool_quote_legs: 0, unresolved: 0,
  };
}
function emptyEngineMetrics(): EngineMetrics {
  return { solNotificationFlow: emptySolNotificationFlow(), solanaValuationSourceCounts: emptySolanaValuationSourceCounts(), notifSkipped: emptyNotifSkipped(), cexRejects: emptyCexRejects() };
}
interface EngineKernel {
  state: any; // создаётся первым инстансом модуля (createEngineState ниже)
  pausedStats: { dropped: number; hotPollSkipped: number };
  /** W2: журнал интервалов паузы — общий для бандлей (как pausedStats): pause из route и resume из движка пишут в один. */
  pauseLedger?: PauseLedger;
  /** W2 T3: последнее состояние hot-poll курсора по цепи — пишет бегун, читают pause/resume из любого бандла. */
  evmHotPauseInfo?: Record<string, { cursor: number | null; empty: boolean }>;
  /** W2 T3: курсор на момент паузы (снимок evmHotPauseInfo в pauseParser). */
  pauseCursors?: Record<string, { cursor: number | null; empty: boolean }>;
  /** W2 T3: head, увиденный ПЕРВЫМ hot-poll-циклом после resume, по ключу `<intervalId>:<chainId>`. */
  pauseHeadsAtResume?: Record<string, number>;
  stoppedManually: boolean;
  autostartWatchdogTimer: ReturnType<typeof setInterval> | null;
  logs: string[];
  statusSnapshot: any; // снапшот getStatus() работающего инстанса — чужие роуты не врут нулями
  statusFreshAt: number;
  // v9.32ao-RC2 BP1: транспорт (сокеты/таймеры) живёт в модульном стейте БЕГУНА (бандл A),
  // роуты — другой бандл B со своими пустыми переменными. Бегун регистрирует сюда хуки,
  // чтобы pause/resume из любого бандла реально закрыл/поднял сокеты инстанса A.
  pauseTransport: (() => void) | null;
  resumeTransport: (() => void) | null;
  // v9.32as SD1: настройки — в ядре. До фикса модульная `let settings` давала каждому бандлу
  // (instrumentation / роуты) свою копию: патч POST /api/settings уходил в инстанс роутов,
  // живому движку не виден, а в БД персистился устаревший объект инстанса роутов.
  settings: Settings | null;
  settingsInstanceId: string;
  // B4.1: единственный на процесс контроллер учёта кредитов DexPaprika (состояние, переходы
  // тиров, снапшот для статуса). Носитель — ядро, а не module-scope: Next бандлит parser-engine
  // (инструментация vs API-роуты), и второй экземпляр модуля дал бы второй независимый учёт —
  // ровно тот класс бага, из-за которого в ядро уже переехали settings (SD1) и метрики (AT5).
  // Инициализация — ровно одна, через `??=` в creditController() ниже.
  credit?: CreditController | null;
  // B4.2: единственный на процесс анонимный GT-канал (dead-latch/strikes/probe/per-path —
  // см. src/lib/gecko/anonymous-channel.ts). Тот же класс бага, что SD1/AT5/B4.1: без носителя
  // в ядре второй бандл Next получил бы вторую независимую защёлку. Инициализация — ровно
  // одна, через `??=` в anonymousGtChannel() ниже.
  anonymousGt?: AnonymousGtChannel | null;
  // B4.3: the full Oracle runtime has the same kernel identity as credit and anonymous-GT owners.
  oracleRuntime?: OracleRuntime | null;
  // B4.4: the single Solana PubSub runtime (transport, generations, subscriptions,
  // tombstones, fallback admission) — same kernel identity as credit/anonymousGt/oracle.
  solanaPubsub?: SolanaPubSubRuntime | null;
  evmRuntime?: EvmRuntime | null;
  // v9.32at AT5: счётчики метрик в ядре. SD1 вынес только settings; инкремент
  // `solanaValuationSourceCounts` / `notifSkipped` в бандле роутов (why-tx) был
  // невидим в getStatus() бандла движка (poolQuoteLegsValuations: 0 при живых логах).
  metrics: EngineMetrics | null;
}
type GlobalThisWithKernel = typeof globalThis & { __huitaEngineKernel?: EngineKernel };
function getEngineKernel(): EngineKernel {
  const g = globalThis as GlobalThisWithKernel;
  if (!g.__huitaEngineKernel) {
    g.__huitaEngineKernel = {
      state: null,
      pausedStats: { dropped: 0, hotPollSkipped: 0 },
      stoppedManually: false,
      autostartWatchdogTimer: null,
      logs: [],
      statusSnapshot: null,
      statusFreshAt: 0,
      pauseTransport: null,
      resumeTransport: null,
      settings: null,
      credit: null,
      anonymousGt: null,
      oracleRuntime: null,
      solanaPubsub: null,
      settingsInstanceId: 'kernel-' + (((g as any).__huitaKernelSeq = ((g as any).__huitaKernelSeq || 0) + 1)).toString(36),
      metrics: emptyEngineMetrics(),
    };
  }
  // AT5: старый процесс без metrics (hot-reload / частичный kernel) — донавесить, не теряя settings.
  if (!g.__huitaEngineKernel.metrics) g.__huitaEngineKernel.metrics = emptyEngineMetrics();
  if (g.__huitaEngineKernel.metrics.notifSkipped.aggregatorSkippedBelowMin === undefined) {
    g.__huitaEngineKernel.metrics.notifSkipped.aggregatorSkippedBelowMin = 0;
  }
  g.__huitaEngineKernel.metrics.solNotificationFlow ??= emptySolNotificationFlow();
  const ns = g.__huitaEngineKernel.metrics.notifSkipped;
  if (ns.impact_anomaly === undefined) ns.impact_anomaly = 0;
  if (ns.exchange_transfer === undefined) ns.exchange_transfer = 0;
  if (ns.usd_unresolved === undefined) ns.usd_unresolved = 0;
  return g.__huitaEngineKernel;
}
const engineKernel = getEngineKernel();
function getEvmRuntime(): EvmRuntime {
  return (engineKernel.evmRuntime ??= createEvmRuntime(makeEvmRuntimeDeps()));
}
function getOracleRuntime(): OracleRuntime {
  return (engineKernel.oracleRuntime ??= createOracleRuntime(makeOracleRuntimeDeps()));
}
// B4.4: one Solana PubSub runtime per process/engine kernel; created lazily via `??=`.
function getSolanaPubsub(): SolanaPubSubRuntime {
  return (engineKernel.solanaPubsub ??= createSolanaPubSubRuntime(makeSolanaPubSubDeps()));
}
// ЭТОТ инстанс модуля запускал движок? (нет → getStatus отдаёт свежий снапшот бегуна)
let localEngineStarted = false;

const fmtUsd = (v: number) => {
  v = Number(v) || 0;
  if (v >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B';
  if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M';
  if (v >= 1e3) return '$' + (v / 1e3).toFixed(2) + 'K';
  return '$' + v.toFixed(0);
};

function log(msg: string) {
  const line = '[' + new Date().toISOString() + '] ' + msg;
  console.log(line);
  logs.push(line);
  const cap = LOG_MAX_LINES();
  if (logs.length > cap) logs.splice(0, logs.length - cap);
}
const LOG_MAX_LINES = () => {
  // v9.32as Task5: 2000 → 20000 — постинцидентная форензика (потеря 8 tx 03.09) требует глубьже буфер;
  // env-override сохраняется.
  const n = Number(process.env.LOG_MAX_LINES ?? '20000');
  return n > 0 ? n : 300;
};
export function __fillLogsForTest(n: number): void {
  for (let i = 0; i < n; i++) log('fill-'+i);
}
export function __engineLogsForTest(): string[] {
  return logs;
}

const logs: string[] = engineKernel.logs; // v9.32ao-RC1 BP1: логи в ядре — /api/logs видит движок

// ─────────────── v9.14 F1: process-level error containment ───────────────
// After the v9.13 deploy the service became unresponsive to HTTP (/api/status, /api/notifications)
// under sustained load. Two hardening layers: (1) never let an unhandled promise/exception crash the
// Node process (a stray rejection during a burst of RPC enrichments would take the whole service
// down); (2) a bounded concurrency gate (withEnrichGate) so the heavy detection/enrichment path
// cannot fan out unbounded RPC work and starve the single-threaded event loop. Both are isolated
// behind env toggles so they can be switched independently (see Isolation notes).
// Register process-level crash containment ONLY in a real server runtime (not under node:test, where
// the test runner owns these events — NODE_TEST_CONTEXT is set by node:test).
const __isTestRunner = !!process.env.NODE_TEST_CONTEXT;
if (!__isTestRunner) {
  process.on('unhandledRejection', (reason: unknown) => {
    const msg = reason instanceof Error ? `${reason.message} @ ${reason.stack}` : String(reason);
    try { console.error('[v9.14] unhandledRejection:', msg); } catch { /* ignore */ }
    addErrorSafe(`unhandledRejection: ${(reason instanceof Error ? reason.message : String(reason)).slice(0, 300)}`);
  });
  process.on('uncaughtException', (err: Error) => {
    try { console.error('[v9.14] uncaughtException:', err?.message); } catch { /* ignore */ }
    addErrorSafe(`uncaughtException: ${(err?.message || String(err)).slice(0, 300)}`);
  });
}
// addErrorSafe avoids a re-entrancy loop (addError itself may throw inside these handlers).
function addErrorSafe(msg: string): void {
  try {
    if (typeof state !== 'undefined' && state?.errors) {
      state.errors.push({ t: Date.now(), msg: String(msg).slice(0, 300) });
      if (state.errors.length > 50) state.errors.shift();
    }
    if (typeof log === 'function') log('ERROR: ' + msg);
  } catch { /* never throw from a crash handler */ }
}

// v9.14 F1: bounded concurrency gate for the heavy enrichment path. In v9.13 the SSE streams and the
// parallel poll workers each call processExtractedEvent (trader resolve → eth_getCode → contractExec
// receipt/archive balanceOf → liquidity → position → phantom-tx → price-sanity → wallet balances),
// all sequential RPC awaits. With 6 SSE streams × 25 pools + 6 poll workers there was NO bound on how
// many of these ran concurrently, so the event loop was flooded with in-flight RPC continuations and
// the HTTP handler (/api/status) never got scheduled. This semaphore caps concurrent enrichments and
// queues the rest; if the queue itself saturates, excess work is dropped (counted) — graceful
// degradation instead of process death. Only 2 env knobs, both default to safe values.
// v9.32ae ED1: the single-lane gate proved non-deterministic under prod load (~200 events/min,
// enrichQueued 100-150): solo candidates died together with anchor noise (AE1-AE4 — CARDS sell
// $11 031 resolved fine at 01:55:00 and then silently died in the enrich queue). Two lanes:
//   • high-lane (solo candidates, `high: true`): own UNBOUNDED queue — NEVER dropped; reserved
//     slots (ENRICH_HIGH_LANE_RATIO of the pool, min 2) admit high first; spills into common
//     slots only when no low task is waiting (low cannot be starved by a high-only stream).
//   • low-lane (everything else): legacy bounded queue, honest drop + counter when full.
// Concurrency default 4 → 12: the bottleneck was the pipeline slots, not RPC calls per task.
const ENRICH_CONCURRENCY = () => Number(process.env.ENRICH_CONCURRENCY || '12');
const ENRICH_QUEUE_MAX = () => Number(process.env.ENRICH_QUEUE_MAX || '150');
const ENRICH_HIGH_LANE_RATIO = () => Number(process.env.ENRICH_HIGH_LANE_RATIO ?? '0.5');
function computeEnrichHighSlots(): number {
  const r = ENRICH_HIGH_LANE_RATIO();
  if (!(r > 0)) return 0; // legacy single-lane mode (ENRICH_HIGH_LANE_RATIO=0)
  const c = ENRICH_CONCURRENCY();
  return Math.min(c, Math.max(2, Math.floor(c * r)));
}
let enrichHighSlots = computeEnrichHighSlots();
let enrichActive = 0;       // total running tasks (both lanes) — cap ENRICH_CONCURRENCY
let enrichHighActive = 0;   // running high tasks (reserved ≤ enrichHighSlots, may spill)
let enrichHighQueued = 0;   // high-lane queue depth (never dropped, so only grows under load)
let enrichHighDropped = 0;  // INVARIANT: always 0 — high lane never drops; exported for status
let enrichLowQueued = 0;
let enrichLowDropped = 0;
let lastResourceLogAt = 0;
let lastSseSummaryAt = 0;
const enrichHighWaiters: Array<() => void> = [];
const enrichLowWaiters: Array<() => void> = [];
const enrichLowActive = () => enrichActive - enrichHighActive;
const enrichQueuedTotal = () => enrichLowQueued + enrichHighQueued;
const enrichDroppedTotal = () => enrichLowDropped + enrichHighDropped;
function enrichPump(): void {
  for (;;) {
    // (1) reserved high slots: high is admitted first while it holds its reservation
    if (enrichHighWaiters.length > 0 && enrichHighActive < enrichHighSlots && enrichActive < ENRICH_CONCURRENCY()) {
      const w = enrichHighWaiters.shift()!;
      enrichHighQueued = Math.max(0, enrichHighQueued - 1);
      enrichActive++; enrichHighActive++;
      w(); continue;
    }
    // (2) common slots prefer low: low keeps ENRICH_CONCURRENCY - enrichHighSlots guaranteed
    //     slots (never starved by a high-only stream) and may borrow reserved slots when no
    //     high task is waiting.
    if (enrichLowWaiters.length > 0 && enrichActive < ENRICH_CONCURRENCY()
      && (enrichLowActive() < ENRICH_CONCURRENCY() - enrichHighSlots || enrichHighWaiters.length === 0)) {
      const w = enrichLowWaiters.shift()!;
      enrichLowQueued = Math.max(0, enrichLowQueued - 1);
      enrichActive++;
      w(); continue;
    }
    // (3) high spill: leftover slots go to high when no low is waiting
    if (enrichHighWaiters.length > 0 && enrichActive < ENRICH_CONCURRENCY()) {
      const w = enrichHighWaiters.shift()!;
      enrichHighQueued = Math.max(0, enrichHighQueued - 1);
      enrichActive++; enrichHighActive++;
      w(); continue;
    }
    break;
  }
}
// Run `fn` under the enrichment gate. Returns `undefined` when the LOW lane is saturated
// (dropped) — callers must treat that as "skip" and NOT rely on side effects of `fn`.
// `opts.high` routes the task to the high lane: it is NEVER dropped (waits however long
// needed). With ENRICH_HIGH_LANE_RATIO=0 the flag is ignored (legacy single queue).
// v9.32ao: сброс enrich-очереди между сьютами — протёкшие waiters прошлых тестов не должны
// пожизненно занимать слоты (AO1 в полном прогоне ждал слот за чужими висячими промисами).
export function __resetEnrichGateForTest(): void {
  enrichActive = 0; enrichHighActive = 0; enrichLowQueued = 0;
  enrichHighQueued = 0; enrichHighWaiters.length = 0; enrichLowWaiters.length = 0;
}

// v9.32ba BA3: давление очереди обогащения
const enrichWaitMs: number[] = [];
const staleByAgeBucket = { s0_60: 0, s60_180: 0, s180_420: 0, s420p: 0 };
let priceFromLastKnown = 0;
function noteEnrichWait(ms: number): void {
  enrichWaitMs.push(ms);
  if (enrichWaitMs.length > 500) enrichWaitMs.shift();
}
function percentileMs(arr: number[], p: number): number {
  if (!arr.length) return 0;
  const sorted = [...arr].sort((a, b) => a - b);
  const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
  return sorted[idx];
}
function noteStaleAge(ageSec: number): void {
  if (ageSec < 60) staleByAgeBucket.s0_60++;
  else if (ageSec < 180) staleByAgeBucket.s60_180++;
  else if (ageSec < 420) staleByAgeBucket.s180_420++;
  else staleByAgeBucket.s420p++;
}

// B4.5: acquisition never invokes business work. The single shared pump still owns
// lane priority and queued-slot accounting. A lease is released once, after readiness.
interface EnrichAdmission {
  ready: Promise<void> | null;
  release(): void;
}
function acquireEnrichAdmission(opts?: { high?: boolean }): EnrichAdmission | undefined {
  let ready: Promise<void> | null = null;
  const high = !!(opts?.high && enrichHighSlots > 0);
  if (high) {
    if (enrichActive < ENRICH_CONCURRENCY() && (enrichHighActive < enrichHighSlots || (enrichLowWaiters.length === 0 && enrichHighWaiters.length === 0))) {
      enrichActive++; enrichHighActive++;
    } else {
      // high lane: unbounded queue, no drop — the AE2-class fix ($11 031 CARDS died here)
      enrichHighQueued++;
      ready = new Promise<void>((resolve) => enrichHighWaiters.push(resolve));
      // NOTE: enrichPump() already incremented enrichActive/enrichHighActive for this waiter.
    }
  } else {
    const canStart = enrichActive < ENRICH_CONCURRENCY()
      && (enrichLowActive() < ENRICH_CONCURRENCY() - enrichHighSlots
        || (enrichHighWaiters.length === 0 && enrichLowWaiters.length === 0));
    if (canStart) {
      enrichActive++;
    } else {
      if (enrichLowQueued >= ENRICH_QUEUE_MAX()) { enrichLowDropped++; return undefined; }
      enrichLowQueued++;
      ready = new Promise<void>((resolve) => enrichLowWaiters.push(resolve));
      // NOTE: enrichPump() already incremented enrichActive for this waiter — do NOT increment here.
    }
  }
  return {
    ready,
    release() {
      enrichActive--;
      if (high) enrichHighActive--;
      enrichPump();
    },
  };
}
async function withEnrichGate<T>(fn: () => Promise<T>, opts?: { high?: boolean }): Promise<T | undefined> {
  const tEnter = Date.now();
  const admission = acquireEnrichAdmission(opts);
  if (!admission) return undefined;
  // Do not await a ready lease: the legacy fast path invokes fn synchronously.
  if (admission.ready) await admission.ready;
  noteEnrichWait(Date.now() - tEnter);
  try {
    return await fn();
  } finally {
    admission.release();
  }
}

// ─────────────── Rate Limiting ───────────────
const rateMap = new Map<string, number[]>();

// v9.32ae ED3: how many allowance slots `key` consumed in the window (for remainder budgets).
function rateCount(key: string, windowMs: number): number {
  const t = Date.now();
  const arr = rateMap.get(key) || [];
  let n = 0;
  for (const x of arr) if (x > t - windowMs) n++;
  return n;
}
function rateAllow(key: string, limit: number, windowMs: number): boolean {
  const t = Date.now();
  const arr = (rateMap.get(key) || []).filter(x => x > t - windowMs);
  if (arr.length < limit) {
    arr.push(t);
    rateMap.set(key, arr);
    return true;
  }
  return false;
}

// ─────────────── Fetch helpers ───────────────
const nativeFetchAtEngineLoad = globalThis.fetch;
function isLiveDsOrGtUrl(url: string): boolean {
  try {
    const host = new URL(url).hostname.toLowerCase();
    return host === 'api.dexscreener.com' || host.endsWith('.dexscreener.com')
      || isGeckoTerminalHost(url)
      // bm3a: CoinGecko Onchain (резерв оракула) — тоже живой платный тариф (Demo 100/мин, 10 000/мес),
      // поэтому юнит-тест без явно установленного мока fetch не имеет права тратить его бюджет.
      || host === 'api.coingecko.com' || host.endsWith('.coingecko.com');
  } catch { return false; }
}
async function fetchWithTimeout(url: string, opts: RequestInit = {}, timeoutMs = 12000): Promise<Response> {
  if (process.env.NODE_TEST_CONTEXT && globalThis.fetch === nativeFetchAtEngineLoad && isLiveDsOrGtUrl(url)) {
    throw new Error(`Live DexScreener/GeckoTerminal network access is forbidden in NODE_TEST_CONTEXT: ${new URL(url).hostname}`);
  }
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...opts, signal: controller.signal });
  } finally {
    clearTimeout(timeout);
  }
}
export async function __fetchWithTimeoutForTest(url: string): Promise<Response> {
  return fetchWithTimeout(url);
}

// ТЗ (Oracle DexPaprika auth): test seam — выполняет РЕАЛЬНЫЙ запрос основного источника оракула
// к DexPaprika через тот же fetchWithTimeout и ту же функцию решения заголовка, что fetchPrimaryTrades
// (после GREEN). Позволяет mock-ом глобального fetch захватить заголовки, которые РЕАЛЬНО уходят в
// сеть, и доказать контракт (сырой ключ без Bearer). Сам ключ никогда не логируется.
export function __oraclePrimaryFetchForTest(url: string): Promise<Response> {
  return fetchWithTimeout(url, { headers: oraclePrimaryDpAuthHeaders() }, 4000);
}

interface FetchResult<T> {
  ok: boolean;
  data?: T;
  error?: string;
  status: number;
  retryAfterMs?: number;   // bm2r: Retry-After (мс) — пол паузы для бэкоффа DS-шлюза
}

// ─────────────── v9.11 F2: phantom-tx validation ───────────────
// DexPaprika SSE can push a txHash that does NOT exist on-chain (phantom tx, e.g. a fabricated
// hash that resolves to null via eth_getTransactionByHash). Before sending ANY Telegram alert we
// re-verify the tx exists on-chain. Cached 60s per (chain,txHash) to avoid RPC flooding on series.
const phantomTxCache = new Map<string, { exists: boolean; cachedAt: number }>();
const PHANTOM_TX_CACHE_TTL = 60 * 1000;
// v9.32at AT5: alias на объект ядра — инкремент из любого бандла виден в getStatus().
const notifSkipped: NotifSkippedCounts = engineKernel.metrics!.notifSkipped;
const cexRejects: Record<string, Record<CexRejectReason, number>> = engineKernel.metrics!.cexRejects;
// v9.16 F-TX: DexPaprika SSE-reserve frames carry NO real on-chain tx hash — extractSubjectFromDpReserve
// synthesizes an internal id (`dp-r-<chain>-<pool>-<block>-<side>`) for dedup. That is NOT a chain hash,
// so getTransactionByHash never resolves it (→ phantom alert). Detect it here and reject as
// invalid_tx_format before it can reach a notification.
const DP_SYNTHETIC_HASH_MARKER = 'dp-r-';
function isSyntheticDpReserveHash(txHash: string): boolean {
  return typeof txHash === 'string' && txHash.includes(DP_SYNTHETIC_HASH_MARKER);
}
// v9.16 F-TX: EVM chain hashes must be `0x` + exactly 64 hex chars (no separators). Solana hashes are
// base58 (no 0x prefix) — accept any non-empty, non-synthetic base58 token. Pure + unit-testable.
export function isValidChainTxHash(chainId: string, txHash: string): boolean {
  if (!txHash) return false;
  // Synthetic DexPaprika reserve ids are NEVER a valid chain hash on any chain.
  if (isSyntheticDpReserveHash(txHash)) return false;
  if (chainId === 'solana') {
    // Real Solana signatures are base58; reject anything carrying 0x / separators / whitespace.
    if (/^[A-HJ-NP-Za-km-z1-9]+$/.test(txHash)) return true;
    return false;
  }
  // EVM: strictly 0x + 64 hex chars.
  return /^0x[0-9a-fA-F]{64}$/.test(txHash);
}

// v9.20 F2: classify WHY an invalid_tx_format rejection happened, so we can log a precise reason and
// increment a specific metric. Synthetic DexPaprika reserve ids (`dp-r-…`) are a separate, EXPECTED
// class (reserve frames carry no real hash) and are NOT counted against the real-alert counters.
// EVM failures split into regex-shape (wrong length / missing 0x) vs non-hex characters; Solana
// failures are base58 regex mismatches. Pure + unit-testable.
export type InvalidTxFormatReason = 'synthetic' | 'regex_evm' | 'regex_solana' | 'non_hex';
export function classifyInvalidTxHash(chainId: string, txHash: string): InvalidTxFormatReason {
  if (isSyntheticDpReserveHash(txHash)) return 'synthetic';
  if (chainId === 'solana') return 'regex_solana';
  // EVM: if it has non-hex chars → non_hex; otherwise it's a regex/shape mismatch (length/prefix).
  const hexOnly = /^0x[0-9a-fA-F]*$/.test(txHash);
  return hexOnly ? 'regex_evm' : 'non_hex';
}

// Test hook: override phantom-tx detection (unit tests can't hit a real RPC). null/undefined = real RPC.
let phantomTxMock: ((chainId: string, txHash: string) => boolean | undefined) | null = null;
export function __setPhantomTxMock(fn: ((chainId: string, txHash: string) => boolean | undefined) | null): void {
  phantomTxMock = fn;
}
async function isPhantomTx(chainId: string, txHash: string): Promise<boolean> {
  if (phantomTxMock) {
    const r = phantomTxMock(chainId, txHash);
    if (r !== undefined) {
      if (r) {
        notifSkipped.phantom_tx++;
        log(`[phantom_tx] skip: ${chainId} ${txHash.slice(0, 10)}… (tx not found on chain)`);
      }
      return r;
    }
  }
  if (chainId === 'solana' || !txHash || !txHash.startsWith('0x')) return false; // solana/RPCless skip
  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return false;
  const key = `${chainId}:${txHash}`;
  const cached = phantomTxCache.get(key);
  if (cached && Date.now() - cached.cachedAt < PHANTOM_TX_CACHE_TTL) return !cached.exists;
  try {
    recordRpcCall('eth_getTransactionByHash', classifyRpcSource(rpc), 'phantom');
    const res = await fetchWithTimeout(rpc, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionByHash', params: [txHash] }),
    }, 6000);
    const data = await res.json();
    const exists = !!(data?.result);
    phantomTxCache.set(key, { exists, cachedAt: Date.now() });
    if (!exists) {
      notifSkipped.phantom_tx++;
      log(`[phantom_tx] skip: ${chainId} ${txHash.slice(0, 10)}… (tx not found on chain)`);
    }
    return !exists;
  } catch {
    // RPC error → fail-open (do not block the alert on a transient RPC issue)
    return false;
  }
}

// B4.1: имена, которые parser-engine экспортировал до извлечения, остаются доступны ОТТУДА ЖЕ —
// старый экспорт не переименован и не удалён (контракт 13, манифест B4.0 проверяет).
export { evalTierSmart };
export type { DexPaprikaCreditTier, DexPaprikaForecastTier, DpLimitSignals, RunwaySource };

// ─────────────── v9.15 F-AC: Adaptive Credit Management → B4.1 credit-controller ───────────────
// Состояние учёта кредитов DexPaprika (дневные/месячные бакеты, кольца burn-rate, сигналы
// заголовков, переходы тира) живёт в src/lib/provider/credit-controller.ts. Сюда попадают только
// HTTP-адаптеры: getJSON/стартовая проба/SSE зовут fetch и ОТДАЮТ полученный ответ контроллеру —
// порядок «запрос → ответ → учёт → трактовка 402/429» остаётся у вызывающего кода.
//
// Один экземпляр на процесс: carrier в engineKernel + `??=` (повторный import модуля другим бандлем
// Next переиспользует тот же контроллер). Deps читают настройки/ENV/часы В МОМЕНТ ВЫЗОВА, поэтому
// патч настрое и DEXPAPRIKA_*_CREDIT_CAP видны живому движку без перезагрузки.
function creditController(): CreditController {
  return (engineKernel.credit ??= createCreditController({
    now: () => Date.now(),
    env: (name: string) => process.env[name],
    getSettings: () => settings,          // живой прокси поверх ядра, не копия
    getProcessStartedAt: () => processStartedAt,
    log,
  }));
}

// B4.2: единственный на процесс анонимный GT-канал: carrier в engineKernel + `??=`
// (повторный import модуля другим бандлем Next переиспользует тот же канал).
// Deps читают часы/ENV/лог В МОМЕНТ ВЫЗОВА: Date.now мокается тестами, ENV — динамический.
function anonymousGtChannel(): AnonymousGtChannel {
  return (engineKernel.anonymousGt ??= createAnonymousGtChannel({
    now: () => Date.now(),
    env: (name: string) => process.env[name],
    log,
  }));
}

// b12 R5: прогноз «на сколько дней хватит» — см. estimatedRunway в credit-controller; facade берёт
// дни и ИСТОЧНИК одним снапшотом, чтобы число и его оговорка не разъехались между чтениями.

// Адаптеры composition root: точки учёта остаются ВЫЗВАНЫМИ ПО СТАРЫМ ИМЕНАМ В ТЕХ ЖЕ МЕСТАХ
// (проводку сверяет test/dp-credit-source.test.ts текстом вызова, а поведение — соседние тесты).
// Это именно делегаты: состояния и логики здесь нет, вся она — в контроллере.
function recordCreditUse(kind: 'rest' | 'sse', count = 1, source: CreditSource = 'other'): void {
  creditController().recordUse(kind, count, source);
}
function recordUncountedCreditUse(count = 1, source: CreditSource = 'other'): void {
  creditController().recordUncounted(count, source);
}
function applyDpLimitSignals(h: Headers | null | undefined, bodyText = ''): DpLimitSignals {
  return creditController().applyLimitSignals(h, bodyText);
}
function onCreditHardError(status: number, confirmedRemaining?: number): void {
  creditController().hardError(status, confirmedRemaining);
}
function evaluateCreditTier(force = false): void {
  creditController().evaluate(force);
}


// bm2e: `source` — явное указание источника для вызовов, чей URL неоднозначен. Поллинг и оракул
// ходят по одному пути /pools/{pool}/transactions, по URL их не различить. Параметр, а не глобальный
// флаг: глобал протёк бы на соседние запросы, стартовавшие пока этот ждёт ответа.
async function getJSON<T>(url: string, opts: RequestInit = {}, source?: CreditSource): Promise<FetchResult<T>> {
  const creditSource: CreditSource = source ?? creditSourceFromUrl(url);
  try {
    const res = await fetchWithTimeout(url, opts);
    const text = await res.text();
    // v9.15 F-AC + v9.17 F1 + bm4a: REST-запросы DexPaprika стоят кредитов; авторитетный остаток —
    // x-credits-remaining (legacy x-ratelimit-remaining принимается как фолбэк), разрешение месяца —
    // x-credits-limit. На 402/429 дополнительно читаем тело (credits.remaining/credits.limit): на
    // ошибках заголовков лимита может не быть. Отсутствие сигнала НЕ считается нулём.
    if (typeof url === 'string' && (url.includes('api.dexpaprika.com') || url.includes('streaming.dexpaprika.com'))) {
      // B4.1: учёт и сигналы — у контроллера; ПОСЛЕ получения ответа и ДО трактовки 402/429.
      recordCreditUse('rest', 1, creditSource); // bm2e: явный источник, иначе разбор по пути URL
      const sig = applyDpLimitSignals(res.headers, (res.status === 402 || res.status === 429) ? text : '');
      if (res.status === 402 || res.status === 429) {
        const confirmed = sig.creditsRemaining >= 0
          ? sig.creditsRemaining
          : parseRemainingFromBody(text, -1);
        onCreditHardError(res.status, confirmed);
      }
    }
    // bm2r: Retry-After читаем для 429/503 — бэкофф шлюза обязан уважать просьбу провайдера.
    const raSec = Number(res.headers.get('retry-after') ?? NaN);
    const retryAfterMs = Number.isFinite(raSec) && raSec > 0 ? Math.round(raSec * 1000) : undefined;
    if (res.status === 200) {
      try {
        return { ok: true, data: JSON.parse(text), status: 200 };
      } catch {
        return { ok: false, error: 'bad json', status: res.status, retryAfterMs };
      }
    }
    return { ok: false, error: text?.slice(0, 100) || '', status: res.status, retryAfterMs };
  } catch (e: unknown) {
    return { ok: false, error: (e as Error).message, status: 0 };
  }
}

// Startup tier diagnosis: try the keyless user/me endpoint; on 200 + header < 20% → mid; on 402/429 →
// immediate downgrade. Never blocks startup (returns immediately). 402 → freeze.
// bm4a (2026-10-04): маршрут пробы заменён. /v1/generic/users/me у DexPaprika не существует —
// замер 2026-10-04 дал 404 route_not_found (перебрано 9 кандидатов вида /users/me, /v1/credits,
// /v1/generic/usage: все 404), а в документации такого пути нет. Значит ветка «200 + remaining» была
// мертва, авторитетный остаток не сеялся до первого рабочего цикла, и при этом каждый boot списывал
// кредит на заведомо несуществующий запрос. Проба идёт на документированный GET /networks/{net}/
// tokens/{mint} (доступен на Free, 1 кредит, отвечает x-credits-* на 200 — замерено). Форма
// Authorization здесь не меняется: контракт заголовка основного источника закреплён отдельно
// (oracle-dp-auth.test.ts), для этого маршрута Bearer замерен как рабочий.
const DP_STARTUP_PROBE_TOKEN = 'So11111111111111111111111111111111111111112'; // wrapped SOL, заведомо индексируется
async function diagnoseCreditTierAtStartup(): Promise<void> {
  try {
    const key = getDexPaprikaApiKey();
    const headers: Record<string, string> = { 'Accept': 'application/json' };
    if (key) headers['Authorization'] = `Bearer ${key}`;
    const res = await fetchWithTimeout(
      `https://api.dexpaprika.com/networks/solana/tokens/${DP_STARTUP_PROBE_TOKEN}`, { headers }, 8000);
    const text = await res.text();
    // bm2e: стартовая проверка идёт мимо getJSON и не списывала кредит — в отдельный словарь.
    // bm4a: списываем ТОЛЬКО когда HTTP-ответ получен; на сетевой ошибке/таймауте расхода нет.
    const credits = creditController();
    recordUncountedCreditUse(1, 'auth');
    const sig = applyDpLimitSignals(res.headers, (res.status === 402 || res.status === 429) ? text : '');
    const rem = sig.creditsRemaining >= 0
      ? sig.creditsRemaining
      : (parseRemainingFromBody(text, -1) ?? -1);
    // v9.17 F1: probe response is the AUTHORITATIVE source for credits when it carries a real remaining.
    if (rem >= 0 && sig.creditsRemaining < 0) credits.noteRemaining(rem, sig.creditsResetAt > 0 ? sig.creditsResetAt : undefined, 'body402');
    if (res.status === 200) {
      credits.noteDiagnosisOk();
      if (rem >= 0) {
        // We know the real remaining: evaluate the tier from it (a fresh header → mostly high).
        credits.assumeHighTier();
        evaluateCreditTier(true);
        log(`[credit] startup diagnosis 200, remaining=${rem} (${sig.remainingFrom ?? 'body402'}), plan=${sig.plan ?? 'unknown'}, cap=${credits.snapshot().monthlyCreditCap}`);
      } else {
        credits.assumeHighTier();
        log('[credit] startup diagnosis 200 but no remaining in response — assuming high, lazy detection stays active');
      }
    } else if (res.status === 402 || res.status === 429) {
      // Do NOT blindly freeze: only freeze if the response confirms remaining < 100.
      onCreditHardError(res.status, rem >= 0 ? rem : undefined);
      log(`[credit] startup diagnosis HTTP ${res.status} (remaining=${rem})`);
    } else {
      log(`[credit] startup diagnosis HTTP ${res.status} (ignored, remaining=${rem})`);
    }
  } catch {
    // endpoint unavailable — rely on lazy 402/429 detection at runtime
    log('[credit] startup diagnosis unavailable (lazy 402/429 detection remains active)');
  }
}

// ─────────────── Database Persistence (lazy - no top-level imports) ───────────────

async function loadSettingsFromDb(): Promise<Partial<Settings> | null> {
  if (!process.env.DATABASE_URL) return null;
  try {
    const { db } = await import('@/db');
    const { appSettings } = await import('@/db/schema');
    const { eq } = await import('drizzle-orm');
    const rows = await db.select().from(appSettings).where(eq(appSettings.key, 'parser_settings'));
    if (rows.length > 0 && rows[0].value) {
      return rows[0].value as Partial<Settings>;
    }
    return null;
  } catch (e) {
    log(`[settings] DB load error: ${(e as Error).message}`);
    return null;
  }
}

async function saveSettingsToDb(s: Settings): Promise<boolean> {
  if (!process.env.DATABASE_URL) return false;
  try {
    const { db } = await import('@/db');
    const { appSettings } = await import('@/db/schema');
    await db.insert(appSettings)
      .values({ key: 'parser_settings', value: s as unknown as Record<string, unknown>, updatedAt: new Date() })
      .onConflictDoUpdate({
        target: appSettings.key,
        set: { value: s as unknown as Record<string, unknown>, updatedAt: new Date() }
      });
    log('[settings] Saved to DB');
    return true;
  } catch (e) {
    log(`[settings] DB save error: ${(e as Error).message}`);
    return false;
  }
}

// ТЗ v8 Defect 1: pure merge of a settings source — defaults only fill MISSING keys, never
// overwrite existing saved values. Testable without DB/network.
function mergeSettingsSource(dbSettings: Partial<Settings> | null): { settings: Settings; source: 'persisted' | 'env' | 'defaults' } {
  if (dbSettings) {
    const s: Settings = { ...DEFAULT_SETTINGS, ...dbSettings };
    // env TG creds are an overlay ONLY when not already set (never clobber saved values)
    if (!s.telegramToken && process.env.TELEGRAM_BOT_TOKEN) s.telegramToken = process.env.TELEGRAM_BOT_TOKEN;
    if (!s.telegramChatId && process.env.TELEGRAM_CHAT_ID) s.telegramChatId = process.env.TELEGRAM_CHAT_ID;
    return { settings: s, source: 'persisted' };
  }
  if (process.env.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_CHAT_ID) {
    return { settings: { ...DEFAULT_SETTINGS, telegramToken: process.env.TELEGRAM_BOT_TOKEN || '', telegramChatId: process.env.TELEGRAM_CHAT_ID || '' }, source: 'env' };
  }
  return { settings: { ...DEFAULT_SETTINGS }, source: 'defaults' };
}

// Test hook: inject a fake DB settings row so persistence can be tested without a DB.
let testDbSettings: Partial<Settings> | null | undefined = undefined;
export function __setSettingsDbForTest(v: Partial<Settings> | null): void { testDbSettings = v; }

async function initSettings(): Promise<void> {
  // ТЗ v8 Defect 1: ALWAYS read saved values from DB; defaults only if no row exists.
  let dbSettings: Partial<Settings> | null = null;
  if (testDbSettings !== undefined) {
    dbSettings = testDbSettings;
  } else {
    dbSettings = await loadSettingsFromDb();
  }
  const merged = mergeSettingsSource(dbSettings);
  setSettings(merged.settings); // v9.32as SD1: запись в ядро (globalThis), не в модульную копию
  settingsSource = merged.source;
  log(`[settings] loaded from ${merged.source}, tg configured: ${!!(settings.telegramToken && settings.telegramChatId)}, soloAmountUsd=${settings.soloAmountUsd}`);
}

// ─────────────── Singleton State ───────────────
// v9.32as SD1: settings — аксессор поверх engine-kernel. Next 16 бандлит instrumentation и
// API-роуты отдельными бандлями ЭТОГО модуля: модульная `let settings` давала каждому бандлу
// свою копию, патч из роутов не доходил до движка, а персист в БД писал устаревший объект.
// Прокси перенаправляет ЧТЕНИЕ в engineKernel.settings ?? DEFAULT_SETTINGS, ЗАПИСЬ — в ядро;
// все бандли процесса делят один объект. `const�ъект. `const` — точечные переприсваивания заменены на
// setSettings() (initSettings / updateSettings / dexPaprika-апдейты).
const settings: Settings = new Proxy({} as Settings, {
  get: (_t, prop: string | symbol) =>
    ((engineKernel.settings ?? DEFAULT_SETTINGS) as unknown as Record<PropertyKey, unknown>)[prop],
  set: (_t, prop: string | symbol, value: unknown) => {
    const cur = (engineKernel.settings ??= { ...DEFAULT_SETTINGS }) as unknown as Record<PropertyKey, unknown>;
    cur[prop] = value;
    return true;
  },
  has: (_t, prop: string | symbol) =>
    prop in (engineKernel.settings ?? DEFAULT_SETTINGS),
  ownKeys: () => Object.keys(engineKernel.settings ?? DEFAULT_SETTINGS),
  getOwnPropertyDescriptor: (_t, prop: string | symbol) => {
    const cur = (engineKernel.settings ?? DEFAULT_SETTINGS) as unknown as Record<PropertyKey, unknown>;
    if (prop in cur) return { enumerable: true, configurable: true, value: cur[prop] };
    return undefined;
  },
});
// Полная замена объекта в ядре (единственный путь «settings = …»).
function setSettings(next: Settings): void {
  engineKernel.settings = { ...next };
}
// Глубокая копия (вкл. networks/extraInfra/dexPaprika): наружные мутации результата
// getSettings()/updateSettings() не должны попадать в ядро (и в DEFAULT_SETTINGS через алиас).
function cloneSettings(s: Settings): Settings {
  return {
    ...s,
    networks: { ...s.networks },
    extraInfra: [...s.extraInfra],
    dexPaprika: {
      ...s.dexPaprika,
      sse: { ...s.dexPaprika.sse },
      budget: { perNetwork: { requestsPerDay: { ...(s.dexPaprika.budget?.perNetwork?.requestsPerDay || {}) } } },
    },
  };
}
// ТЗ v9.32as: идентификатор носителя settings — доказательство единственности из разных бандлей.
export function __getSettingsInstanceIdForTest(): string {
  return engineKernel.settingsInstanceId;
}
let blacklist: string[] = [];
const cexCache: CexCacheType = { byAddress: {}, bySymbol: {}, updatedAt: 0, tokenCount: 0, chainsWithAddresses: 0 };

// ─────────────── Token Blacklist Engine (ТЗ v4 Block 3 + ТЗ v5 Defect 1) ───────────────
interface BlacklistRule {
  id: string;
  type: 'address' | 'symbol-regex';
  chain: string;   // chainId for address rules; 'all' or chainId for symbol-regex scope
  value: string;
  note?: string;
  enabled: boolean;
  hits: number;
}

const CHAIN_IDS = Object.keys(CHAINS);

// Seed rules — created on start/migration, togglable via UI/API.
const defaultBlacklistSeed: Array<{ type: 'address' | 'symbol-regex'; chain: string; value: string; note?: string }> = [
  { type: 'symbol-regex', chain: 'all', value: '^[A-Z0-9]{1,6}on$', note: 'Ondo GM (AAPLon, SPYon…)' },
  { type: 'symbol-regex', chain: 'all', value: '^[A-Z0-9]{1,6}x$', note: 'xStocks (TSLAx, AAPLx…)' },
  { type: 'address', chain: 'ethereum', value: '0x45804880de22913DAfE09f4980848ECE6EcbAf78', note: 'PAXG' },
  { type: 'address', chain: 'bsc', value: '0xbe9D156892E55e7154BcD3cB0FEA677F9D3103E1', note: 'SPCXB (SpaceX / PreStocks)' },
  { type: 'address', chain: 'bsc', value: '0x46cEeFDa28Dd7207059ed19B0acdc026955bb15C', note: 'GMEB (GameStop / PreStocks)' },
];

// ТЗ v5 Defect 1: unique + stable id = 'bl_' + full sha256hex(type|chain|value).
function blacklistRuleId(type: string, chain: string, value: string): string {
  return 'bl_' + createHash('sha256').update(`${type}|${chain}|${value}`).digest('hex');
}

// Normalise a blacklist tuple for DUPLICATE detection:
//   - address rules: EVM addresses are case-insensitive, solana case-sensitive as-is
//   - symbol-regex: never normalise case (regex is case-sensitive)
function normalizeBlacklistTuple(type: string, chain: string, value: string): { type: string; chain: string; value: string } {
  const c = String(chain || '').trim().toLowerCase();
  let v = String(value || '').trim();
  // v9.32aw AW1: lowercase address ONLY on a concrete EVM chain — never for 'all' (solana is case-sensitive).
  if (type === 'address' && c !== 'all' && c !== 'solana' && isEVMChain(c)) v = v.toLowerCase();
  return { type, chain: c, value: v };
}
/** Identity for duplicate detection: EVM 0x-addr is chain-agnostic + case-insensitive; solana exact. */
function blacklistIdentity(type: string, chain: string, value: string): string {
  const t = normalizeBlacklistTuple(type, chain, value);
  if (t.type === 'address' && /^0x[0-9a-fA-F]{40}$/.test(t.value)) return 'addr:evm:' + t.value.toLowerCase();
  if (t.type === 'address') return 'addr:sol:' + t.value;
  return `re:${t.chain}:${t.value}`;
}

let blacklistRules: BlacklistRule[] = defaultBlacklistSeed.map((r, i) => ({
  id: blacklistRuleId(r.type, r.chain, r.value),
  type: r.type,
  chain: r.chain,
  value: r.value,
  note: r.note,
  enabled: true,
  hits: 0,
}));

// Per-rule skip counter exposed in /api/status
const blacklistSkipped: Record<string, number> = {};
let blacklistCounterLeg = 0;
let blacklistLoadedAt = 0;
const BLACKLIST_CACHE_TTL = 10 * 1000; // ≤10s cache with invalidation on mutation

function rebuildBlacklistFromDbRows(rows: Array<{
  id: string; type: string; chain: string; value: string;
  note?: string | null; enabled?: string | boolean; hits?: string | number;
}>): void {
  const byId = new Map<string, BlacklistRule>();
  for (const r of blacklistRules) byId.set(r.id, r);
  const rebuilt: BlacklistRule[] = [];
  for (const row of rows) {
    const existing = byId.get(row.id);
    rebuilt.push({
      id: row.id,
      type: (row.type as 'address' | 'symbol-regex') || 'address',
      chain: String(row.chain || ''),
      value: String(row.value || ''),
      note: row.note || undefined,
      enabled: row.enabled !== 'false' && row.enabled !== false,
      hits: existing?.hits || parseInt(String(row.hits || '0'), 10) || 0,
    });
  }
  // v9.32aw AW2: keep existing ids (stable hits/skipped). Reissue only on missing/collision.
  const usedIds = new Set<string>();
  let migrated = 0;
  for (const r of rebuilt) {
    const norm = normalizeBlacklistTuple(r.type, r.chain, r.value);
    r.chain = norm.chain;
    if (r.type === 'address' && norm.chain !== 'all' && norm.chain !== 'solana') r.value = norm.value;
    if (!r.id || usedIds.has(r.id)) {
      let id = blacklistRuleId(r.type, r.chain, r.value);
      let n = 1;
      while (usedIds.has(id)) { id = blacklistRuleId(r.type, r.chain, r.value) + '_' + (n++); }
      if (r.id !== id) migrated++;
      r.id = id;
    }
    usedIds.add(r.id);
  }
  const seenIdent = new Set(rebuilt.map(x => blacklistIdentity(x.type, x.chain, x.value)));
  for (const seed of defaultBlacklistSeed) {
    const ident = blacklistIdentity(seed.type, seed.chain, seed.value);
    if (seenIdent.has(ident)) continue;
    const copy: BlacklistRule = {
      id: blacklistRuleId(seed.type, seed.chain, seed.value),
      type: seed.type, chain: seed.chain, value: seed.value,
      note: seed.note, enabled: true, hits: 0,
    };
    if (usedIds.has(copy.id)) copy.id = copy.id + '_seed';
    usedIds.add(copy.id);
    seenIdent.add(ident);
    rebuilt.push(copy);
  }
  if (migrated) log(`[blacklist] migrated ${migrated} rules`);
  blacklistRules = rebuilt;
}
export function __rebuildBlacklistFromDbRowsForTest(rows: Array<{
  id: string; type: string; chain: string; value: string;
  note?: string | null; enabled?: string | boolean; hits?: string | number;
}>): void {
  rebuildBlacklistFromDbRows(rows);
}
export function __checkBlacklistForTest(chainId: string, addr: string, sym: string): { skip: boolean; ruleId?: string } {
  return checkBlacklist(chainId, addr, sym);
}

async function loadBlacklistFromDb(): Promise<void> {
  if (!process.env.DATABASE_URL && !blacklistDbOverride) return;
  try {
    let rows: Array<{ id: string; type: string; chain: string; value: string; note?: string | null; enabled?: string; hits?: string }> = [];
    if (blacklistDbOverride) {
      rows = await blacklistDbOverride.select().from({}) as typeof rows;
    } else {
      const { db } = await import('@/db');
      const { blacklistRules: tbl } = await import('@/db/schema');
      rows = await db.select().from(tbl) as typeof rows;
    }
    if (rows.length === 0) {
      await persistBlacklistToDb();
      return;
    }
    rebuildBlacklistFromDbRows(rows);
  } catch (e) {
    log(`[blacklist] DB load error: ${(e as Error).message}`);
  }
}

// v9.32aq B1: инжектируемый БД-хэндл (тесты без сети)
type BlacklistDbHandle = { insert: (t: unknown) => { values: (v: unknown) => { onConflictDoUpdate: (o: unknown) => Promise<void> } }; delete: (t: unknown) => { where: (c: unknown) => Promise<void> }; update: (t: unknown) => { set: (v: unknown) => { where: (c: unknown) => Promise<void> } }; select: () => { from: (t: unknown) => Promise<unknown[]> } } | null;
let blacklistDbOverride: NonNullable<BlacklistDbHandle> | null = null;
export function __setBlacklistDbForTest(db: BlacklistDbHandle): void { blacklistDbOverride = db; }
export function __resetBlacklistForTest(): void {
  blacklistRules = defaultBlacklistSeed.map((r, i) => ({
    id: blacklistRuleId(r.type, r.chain, r.value) + (i ? `_${i}` : ''),
    type: r.type as 'address' | 'symbol-regex', chain: r.chain, value: r.value,
    note: r.note, enabled: true, hits: 0,
  }));
  blacklistLoadedAt = 0;
}
async function blacklistDb(): Promise<{ db: unknown; tbl: unknown } | null> {
  if (blacklistDbOverride) return { db: blacklistDbOverride, tbl: {} };
  if (!process.env.DATABASE_URL) return null;
  try {
    const { db } = await import('@/db');
    const { blacklistRules: tbl } = await import('@/db/schema');
    return { db, tbl };
  } catch { return null; }
}
// v9.32aq B1 (AQ4): persist = UPSERT по id, НИКОГДА delete-all. Прежний «delete + rewrite
// из памяти» при редеплое Railway (старый контейнер персистил свою память) стирал правила,
// добавленные в новом; catch молчал и API отвечал ok. Теперь: upsert (onConflictDoUpdate),
// удаление — только точечное при remove-rule, ошибка БД возвращается вызывающему.
async function persistBlacklistToDb(): Promise<{ ok: boolean; error?: string }> {
  const handle = await blacklistDb();
  if (!handle) return { ok: true };
  const { db, tbl } = handle as { db: NonNullable<BlacklistDbHandle>; tbl: never };
  try {
    for (const r of blacklistRules) {
      await db.insert(tbl).values({
        id: r.id, type: r.type, chain: r.chain, value: r.value,
        note: r.note || null, enabled: r.enabled ? 'true' : 'false',
        hits: String(r.hits), updatedAt: new Date(),
      }).onConflictDoUpdate({
        target: (tbl as { id?: unknown }).id,
        set: { note: r.note || null, enabled: r.enabled ? 'true' : 'false', hits: String(r.hits), updatedAt: new Date() },
      });
    }
    return { ok: true };
  } catch (e) {
    log(`[blacklist] DB save error: ${(e as Error).message}`);
    return { ok: false, error: 'db: ' + (e as Error).message };
  }
}

async function ensureBlacklistLoaded(): Promise<void> {
  if (!process.env.DATABASE_URL) return;
  if (Date.now() - blacklistLoadedAt > BLACKLIST_CACHE_TTL) {
    blacklistLoadedAt = Date.now();
    await loadBlacklistFromDb();
  }
}

// Validate a blacklist rule value before adding (regex compiles, chain valid, value non-empty)
export function validateBlacklistRule(type: string, chain: string, value: string): { ok: boolean; error?: string } {
  if (!type || !['address', 'symbol-regex'].includes(type)) return { ok: false, error: 'invalid type' };
  if (!value || !String(value).trim()) return { ok: false, error: 'value is empty' };
  if (type === 'symbol-regex') {
    try { new RegExp(String(value)); } catch { return { ok: false, error: 'invalid regex' }; }
    if (chain !== 'all' && !CHAIN_IDS.includes(chain)) return { ok: false, error: 'invalid chain' };
  } else {
    // v9.32ao-RC2 BP4: адресный блэклист глобален по умолчанию — 'all' валиден
    // (контракт токена уникален глобально; EVM-адрес на нескольких цепях — тем более закрыть везде)
    if (chain !== 'all' && !CHAIN_IDS.includes(chain)) return { ok: false, error: 'invalid chain' };
  }
  return { ok: true };
}

function checkBlacklist(chainId: string, addr: string, sym: string): { skip: boolean; ruleId?: string } {
  for (const rule of blacklistRules) {
    if (!rule.enabled) continue;
    if (rule.type === 'address') {
      if (rule.chain !== 'all' && rule.chain !== chainId) continue; // RC2 BP4: 'all' = все цепи
      const match = isEVMChain(chainId) ? lower(rule.value) === lower(addr) : rule.value === addr;
      if (match) {
        rule.hits++;
        blacklistSkipped[rule.id] = (blacklistSkipped[rule.id] || 0) + 1;
        return { skip: true, ruleId: rule.id };
      }
    } else if (rule.type === 'symbol-regex') {
      if (rule.chain !== 'all' && rule.chain !== chainId) continue;
      try {
        if (new RegExp(rule.value).test(sym)) {
          rule.hits++;
          blacklistSkipped[rule.id] = (blacklistSkipped[rule.id] || 0) + 1;
          return { skip: true, ruleId: rule.id };
        }
      } catch { /* invalid regex — skip */ }
    }
  }
  return { skip: false };
}

export function getBlacklistRules(): BlacklistRule[] {
  return blacklistRules.map(r => ({ ...r }));
}

export async function addBlacklistRule(type: string, chain: string, value: string, note?: string): Promise<{ ok: boolean; error?: string; rule?: BlacklistRule }> {
  const v = validateBlacklistRule(type, chain, value);
  if (!v.ok) return { ok: false, error: v.error };
  const trimmed = String(value).trim();
  const target = normalizeBlacklistTuple(type, chain, trimmed);
  const ident = blacklistIdentity(type, chain, trimmed);
  const dup = blacklistRules.some(r => blacklistIdentity(r.type, r.chain, r.value) === ident);
  if (dup) return { ok: false, error: 'rule already exists' };
  const storedValue = (type === 'address' && target.chain !== 'all' && target.chain !== 'solana') ? target.value : trimmed;
  const id = blacklistRuleId(type, target.chain, storedValue);
  const rule: BlacklistRule = { id, type: type as 'address' | 'symbol-regex', chain: target.chain, value: storedValue, note, enabled: true, hits: 0 };
  blacklistRules.push(rule);
  const persisted = await persistBlacklistToDb();
  if (!persisted.ok) return { ok: false, error: persisted.error }; // v9.32aq B1: ошибка БД наружу
  log(`[blacklist] added rule ${id}`);
  return { ok: true, rule };
}

export async function removeBlacklistRule(id: string): Promise<{ ok: boolean; error?: string }> {
  const before = blacklistRules.length;
  blacklistRules = blacklistRules.filter(r => r.id !== id);
  if (blacklistRules.length === before) return { ok: false, error: 'not found' };
  // v9.32aq B1: удаление ТОЛЬКО своей строки (никогда delete-all)
  const handle = await blacklistDb();
  if (handle && !blacklistDbOverride) {
    try {
      const { eq } = await import('drizzle-orm');
      const { db, tbl } = handle as { db: { delete: (t: unknown) => { where: (c: unknown) => Promise<void> } }; tbl: never };
      await db.delete(tbl).where(eq((tbl as never as { id: never }).id, id));
    } catch (e) {
      log(`[blacklist] DB delete error: ${(e as Error).message}`);
      return { ok: false, error: 'db: ' + (e as Error).message };
    }
  }
  const persisted = await persistBlacklistToDb();
  if (!persisted.ok) return { ok: false, error: persisted.error };
  log(`[blacklist] removed rule ${id}`);
  return { ok: true };
}

export async function toggleBlacklistRule(id: string, enabled?: boolean): Promise<{ ok: boolean; error?: string }> {
  const rule = blacklistRules.find(r => r.id === id);
  if (!rule) return { ok: false, error: 'not found' };
  // R3: when no explicit enabled is passed → INVERT the current value (true↔false each call).
  // Explicit `enabled` keeps the previous explicit-set semantics (PUT route).
  rule.enabled = enabled === undefined ? !rule.enabled : !!enabled;
  await persistBlacklistToDb();
  log(`[blacklist] toggled rule ${id} -> ${rule.enabled}`);
  return { ok: true };
}

// Reset to seed rules (used on startup/migration)
async function initBlacklist(): Promise<void> {
  await loadBlacklistFromDb();
  blacklistLoadedAt = Date.now();
}
let top10Symbols: string[] = [];
const cexFilterStats = {
  matchHits: 0,
  matchMisses: 0,
  passedEvents: 0,
  skippedCex: 0,
  symbolFallbackHits: 0,
  wrappedNativeHits: 0,
  lastHits: [] as CexHitRecord[],
};
const codeCache = new Map<string, { t: number; v: boolean }>();
const CODE_CACHE_TTL = 24 * 60 * 60 * 1000;
const POOL_CACHE_TTL = 15 * 60 * 1000;
const poolInfoCache = new Map<string, PoolInfo>();

// ─────────────── Watchlist Meta (ТЗ v5 Defect 2) ───────────────
// Per-pool metadata used by /api/debug/watchlist and why-tx subjectWatchlistPools.
interface WatchPoolEntry {
  address: string;           // pool address (preserve case for solana)
  subjectToken: string;      // subject (non-quote) token address
  subjectSymbol: string;
  quoteSymbol: string;
  reserveUsd: number | null;
  vol24hUsd: number | null;
  // 'anchor' = unconditional anchor (GT enrichment failed → metrics null).
  // 'anchor+volume/reserve/both' = anchor with successful enrichment.
  // 'reserve' | 'volume' | 'both' = organically discovered.
  // 'settlement_observed' = route pool observed in a CoW settlement for a CEX candidate subject (F5).
  // R5: 'settlement_observed+<old>' when the pool was already known (settlement_observed has priority).
  source: string;
  // EVM pre-filter metadata
  token0?: string;
  token1?: string;
  token0Decimals?: number;
  token1Decimals?: number;
  quoteTokenIndex?: 0 | 1;
  quoteType?: 'stable' | 'native' | 'other';
  cachedQuotePriceUsd?: number;
  metadataValidatedAt?: number;
}
const chainWatchMeta: Record<string, Record<string, WatchPoolEntry>> = {};

const evmPrefilterStats: Record<string, {
  poolsMetaReady: number;
  poolsMetaPending: number;
  poolsMetaFailed: number;
  prefilterEvaluated: number;
  prefilterDropped: number;
  prefilterFailOpenMissingMeta: number;
  prefilterFailOpenMissingPrice: number;
}> = {};
for (const c of Object.keys(CHAINS).filter(isEVMChain)) {
  evmPrefilterStats[c] = {
    poolsMetaReady: 0,
    poolsMetaPending: 0,
    poolsMetaFailed: 0,
    prefilterEvaluated: 0,
    prefilterDropped: 0,
    prefilterFailOpenMissingMeta: 0,
    prefilterFailOpenMissingPrice: 0,
  };
}

const nativePriceCache: Record<string, { price: number; at: number }> = {};

export function __setNativePriceForTest(chainId: string, price: number): void {
  nativePriceCache[chainId] = { price, at: Date.now() };
}

export function getCachedNativePriceUsd(chainId: string): number {
  // Primary source: native price service (trusted, multi-source)
  const serviceSnapshot = getCachedNativePrice(chainId);
  if (serviceSnapshot && serviceSnapshot.priceUsd > 0) {
    const age = Date.now() - serviceSnapshot.observedAt;
    if (age < 600_000) { // 10 minutes max staleness
      // Also sync to legacy cache for compatibility
      nativePriceCache[chainId] = { price: serviceSnapshot.priceUsd, at: serviceSnapshot.observedAt };
      return serviceSnapshot.priceUsd;
    }
  }
  // Fallback: legacy test/manual cache
  const cached = nativePriceCache[chainId];
  if (cached && Date.now() - cached.at < 600000 && cached.price > 0) {
    return cached.price;
  }
  return 0;
}

// B4.5b: очередь meta-enrichment переехала в kernel runtime (`runtime.metaEnrich`). Facade передаёт
// enrich и signal параметрами на каждый вызов: runtime не импортирует facade (нет цикла).
function evmMetaEnrichDeps(): EvmMetaEnrichmentDeps {
  return { enrich: enrichEvmPoolFilterMeta, signal: () => runtime.rootAbortController?.signal };
}

export function queueEvmPoolMetaEnrichment(
  chainId: string,
  pool: string,
  adapter?: string,
  subject?: string,
  quote?: string
): void {
  // bm2z (блокер 5 bm2u): фоновое обогащение — реальные EVM RPC (по образцу startPollWorkers /
  // SOL_POLL_ALLOW_IN_TEST / DEAD_CHAIN_GUARD_ALLOW_IN_TEST). В тестах очередь молчит: подхват
  // seedWatchPool/composeWatchlist/якорей тянул сеть в юнит-тестах (T3-сценарий: 65 внешних
  // вызовов за прогон) и делал сьют зависимым от сети песочницы. Явный прогон очереди — opt-in
  // EVM_META_ENRICH_ALLOW_IN_TEST=1 (тесты очереди: v931-round5/6).
  if (process.env.NODE_TEST_CONTEXT && process.env.EVM_META_ENRICH_ALLOW_IN_TEST !== '1') return;
  if (chainId === 'solana' || !isValidEvmAddress(pool) || adapter === 'uniswap-v4') return;
  const key = `${chainId}:${lower(pool)}`;

  const meta = chainWatchMeta[chainId]?.[lower(pool)];
  if (meta && meta.token0Decimals !== undefined && meta.token1Decimals !== undefined && meta.quoteTokenIndex !== undefined) {
    return; // already enriched
  }
  getEvmRuntime().metaEnrich.enqueue({ key, chainId, pool, adapter, subject, quote }, evmMetaEnrichDeps());
}

export function __resetEvmMetaEnrichmentQueueForTest(): void {
  getEvmRuntime().metaEnrich.reset();
}

export function stopEvmMetaEnrichmentQueue(): void {
  getEvmRuntime().metaEnrich.reset();
}

export function __getEvmMetaEnrichmentQueueStateForTest(): {
  queued: number;
  inFlight: number;
  active: number;
  retryTimerActive: boolean;
} {
  return getEvmRuntime().metaEnrich.state();
}

export async function enrichEvmPoolFilterMeta(
  chainId: string,
  pool: string,
  adapter?: string,
  knownSubject?: string,
  knownQuote?: string,
  rpcFetcher?: (method: string, params: unknown[]) => Promise<any>
): Promise<boolean> {
  const normP = normAddr(chainId, pool);
  const key = lower(normP);
  if (!chainWatchMeta[chainId]) chainWatchMeta[chainId] = {};

  const stats = evmPrefilterStats[chainId];
  const alreadyReady = !!(chainWatchMeta[chainId][key]?.metadataValidatedAt);
  if (stats && !alreadyReady) stats.poolsMetaPending++;

  const rpc = RPC_ENDPOINTS[chainId];
  const callRpc = rpcFetcher || (async (method: string, params: unknown[]) => {
    if (!rpc) return null;
    const res = await fetchWithTimeout(rpc, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
    }, 5000);
    return await res.json();
  });

  try {
    const t0Res = await callRpc('eth_call', [{ to: pool, data: '0x0dfe1681' }, 'latest']);
    const t1Res = await callRpc('eth_call', [{ to: pool, data: '0xd21220a7' }, 'latest']);

    if (!t0Res?.result || t0Res.result === '0x' || !t1Res?.result || t1Res.result === '0x') {
      if (stats && !alreadyReady) {
        stats.poolsMetaPending = Math.max(0, stats.poolsMetaPending - 1);
        stats.poolsMetaFailed++;
      }
      return false;
    }

    const t0 = '0x' + t0Res.result.slice(26).toLowerCase();
    const t1 = '0x' + t1Res.result.slice(26).toLowerCase();

    const [dec0, dec1] = await Promise.all([
      getEvmTokenDecimals(chainId, t0, callRpc),
      getEvmTokenDecimals(chainId, t1, callRpc),
    ]);

    let quoteTokenIndex: 0 | 1 = 1;
    let quoteAddr = t1;
    let subjectAddr = t0;

    const isQuoteToken = (addr: string) => {
      const a = lower(addr);
      return STABLE_ADDRS.has(a) || !!WRAPPED_TO_NATIVE[a] || (KNOWN_QUOTE_TOKENS[chainId] || []).some(k => lower(k) === a);
    };

    if (isQuoteToken(t0) && !isQuoteToken(t1)) {
      quoteTokenIndex = 0;
      quoteAddr = t0;
      subjectAddr = t1;
    } else if (isQuoteToken(t1) && !isQuoteToken(t0)) {
      quoteTokenIndex = 1;
      quoteAddr = t1;
      subjectAddr = t0;
    } else if (knownQuote) {
      if (lower(knownQuote) === t0) {
        quoteTokenIndex = 0;
        quoteAddr = t0;
        subjectAddr = t1;
      } else if (lower(knownQuote) === t1) {
        quoteTokenIndex = 1;
        quoteAddr = t1;
        subjectAddr = t0;
      }
    }

    const qSym = resolveSymFromAddr(quoteAddr) || 'USDC';
    const isStable = isStableQuoteSymbol(qSym) || STABLE_ADDRS.has(quoteAddr);
    const isNative = isNativeQuoteSymbol(qSym) || !!WRAPPED_TO_NATIVE[quoteAddr];
    const quoteType: 'stable' | 'native' | 'other' = isStable ? 'stable' : (isNative ? 'native' : 'other');

    const cachedQuotePriceUsd = isStable ? 1.0 : (quoteType === 'native' ? getCachedNativePriceUsd(chainId) : 0);

    const existing = chainWatchMeta[chainId][key] || {
      address: normP,
      subjectToken: subjectAddr,
      subjectSymbol: '?',
      quoteSymbol: qSym,
      reserveUsd: null,
      vol24hUsd: null,
      source: 'organic',
    };

    markWatchlistDirty();
    chainWatchMeta[chainId][key] = {
      ...existing,
      token0: t0,
      token1: t1,
      token0Decimals: dec0,
      token1Decimals: dec1,
      quoteTokenIndex,
      quoteType,
      cachedQuotePriceUsd,
      metadataValidatedAt: Date.now(),
    };

    if (stats && !alreadyReady) {
      stats.poolsMetaPending = Math.max(0, stats.poolsMetaPending - 1);
      stats.poolsMetaReady++;
    }

    return true;
  } catch {
    if (stats && !alreadyReady) {
      stats.poolsMetaPending = Math.max(0, stats.poolsMetaPending - 1);
      stats.poolsMetaFailed++;
    }
    return false;
  }
}
const WATCH_TOP_TOKENS = Number(process.env.WATCH_TOP_TOKENS || '200'); // ТЗ v7: env, default ≥200 — candidate enumeration target
// ТЗ v8 Defect 2: round-robin batch of CEX subject-token candidates to expand per discovery cycle.
const DISC_BATCH_SIZE = Number(process.env.DISC_BATCH_SIZE || '100'); // default ≥100 (ТЗ v8)
// ТЗ v8 Defect 2: discovery cycle budget (seconds) — cursor stops near this per cycle.
const DISC_CYCLE_SEC = Number(process.env.DISC_CYCLE_SEC || '60'); // default ≤120 (ТЗ v8)
// ТЗ v8 Defect 2: DexScreener fallback results cached to avoid storms.
const DS_POOL_CACHE_TTL = 10 * 60 * 1000;
// Caps surfaced in /api/status: per-token 30; per-chain is NOT bounded by a cut (removed so
// per-token eligible pools are never dropped).
const WATCH_CHAIN_CAP = null; // null = no chain-level cut
// ТЗ v6: anchor pools are UNCONDITIONAL and unkillable — they are force-added to EVERY watchlist
// build (independent of GeckoTerminal availability), and on enrichment failure they still appear
// with source:'anchor' and null metrics. On successful enrichment metrics fill in
// (source becomes 'anchor+…').
export interface AnchorPool {
  pool: string;      // pool address (preserve case for solana)
  subject?: string;  // subject (base) token address
  symbol?: string;   // subject symbol
  quote?: string;    // quote symbol
}
const ANCHOR_POOLS: Record<string, AnchorPool[]> = (() => {
  const base: Record<string, AnchorPool[]> = {
    solana: [],
    ethereum: [],
    bsc: [],
    base: [],
    arbitrum: [],
    polygon: [],
  };
  for (const p of VERIFIED_ANCHOR_POOLS) {
    if (!base[p.chainId]) base[p.chainId] = [];
    base[p.chainId].push({
      pool: p.pool,
      subject: p.subject,
      symbol: p.symbol,
      quote: p.quoteSymbol,
    });
  }
  // D4: env anchors (ANCHOR_POOLS_<chain>) — comma list of pool addresses, OR JSON array.
  // For solana the address CASE IS PRESERVED (base58 is case-sensitive).
  for (const chainId of Object.keys(CHAINS)) {
    const v = process.env[`ANCHOR_POOLS_${chainId}`];
    if (!v) continue;
    let extra: AnchorPool[] = [];
    try {
      const parsed = JSON.parse(v);
      if (Array.isArray(parsed)) {
        extra = parsed.map((x: any) => typeof x === 'string' ? { pool: x } : { pool: x.pool, subject: x.subject, symbol: x.symbol, quote: x.quote }).filter((a: AnchorPool) => !!a.pool);
      }
    } catch {
      // comma list (preserve original case, trim whitespace, drop empty segments)
      extra = v.split(',').map(s => s.trim()).filter(Boolean).map(pool => ({ pool }));
    }
    if (extra.length) base[chainId] = (base[chainId] || []).concat(extra);
  }
  return base;
})();

// D4: env-provided anchors (subset of ANCHOR_POOLS) — tracked for anchorsLoaded metric and for
// subject/quote async resolution (env anchors have no config-level subject).
// D4: parse an ANCHOR_POOLS_<chain> value into AnchorPool[]. Pure/testable. Handles:
// comma-list (whitespace trimmed, empty segments dropped, solana case preserved) or JSON array.
export function parseEnvAnchors(raw: string | undefined): AnchorPool[] {
  if (!raw) return [];
  const v = String(raw).trim();
  if (!v) return [];
  let out: AnchorPool[] = [];
  try {
    const parsed = JSON.parse(v);
    if (Array.isArray(parsed)) {
      out = parsed.map((x: any) => typeof x === 'string' ? { pool: x } : { pool: x.pool, subject: x.subject, symbol: x.symbol, quote: x.quote }).filter((a: AnchorPool) => !!a.pool);
    } else if (typeof parsed === 'string' && parsed) {
      out = parsed.split(',').map(s => s.trim()).filter(Boolean).map(pool => ({ pool }));
    }
  } catch {
    out = v.split(',').map(s => s.trim()).filter(Boolean).map(pool => ({ pool }));
  }
  return out;
}

const ENV_ANCHOR_POOLS: Record<string, AnchorPool[]> = (() => {
  const env: Record<string, AnchorPool[]> = {};
  for (const chainId of Object.keys(CHAINS)) {
    const parsed = parseEnvAnchors(process.env[`ANCHOR_POOLS_${chainId}`]);
    if (parsed.length) env[chainId] = parsed;
  }
  return env;
})();

interface LiquidityInfo {
  totalUsd: number;
  poolsCount: number;
  subjectAmount: number;
  subjectUsd: number;
  quoteUsd: number;
  cachedAt: number;
}

const liquidityCache = new Map<string, LiquidityInfo>();
const LIQUIDITY_CACHE_TTL = 60 * 1000;

interface PositionInfo {
  balanceUsd: number;
  pctSupply: number;
  cachedAt: number;
}

const positionCache = new Map<string, PositionInfo>();
const POSITION_CACHE_TTL = 60 * 1000;

// А1: per-chain known stable/quote tokens for quote-balance resolution (USDT/USDC/DAI/USDS etc.).
// EVM = contract addresses (lowercase); solana = SPL mint addresses (case preserved).
const KNOWN_QUOTE_TOKENS: Record<string, string[]> = {
  ethereum: ['0xdac17f958d2ee523a2206206994597c13d831ec7','0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48','0x6b175474e89094c44da98b954eedeac495271d0f','0xdc035d45d973e3ec169d2276ddab16f1e407384f','0x4c9edd5852cd905f086c759e8383e09bff1e68b3','0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'],
  bsc: ['0x55d398326f99059ff775485246999027b3197955','0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d','0xe9e7cea3dedca5984780bafc599bd69add087d56','0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3','0x1cd0c718cd64b56e1fe34b00571cce0dc4b59c6e','0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c'],
  base: ['0x833589fcd6edb6e08f4c7c32d4f71b54bda02913','0x820c137fa70c8691f0e44dc420a5e53c168921dc','0x50c5725949a6f0c72e6c4a641f24049a917db0cb','0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca','0x4200000000000000000000000000000000000006','0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf','0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42'],
  arbitrum: ['0xaf88d065e77c8cc2239327c5edb3a432268e5831','0xff970a61a04b1ca14834a43f5de4533ebddb5cc8','0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9','0xda10009cbd5d07dd0cecc66161fc93d7c9000da1','0x82af49447d8a07e3bd95bd0d56f35241523fbab1','0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf','0x17fc002b466eec40dae837fc4be5c67993ddbd6f','0xeb466342c4d449bc9f53a865d5cb90586f405215' /* axlUSDC, verified 2026-08-25 eth_call symbol() */],
  polygon: ['0x3c499c542cef5e3811e1192ce70d8cc03d5c3359','0x2791bca1f2de4661ed88a30c99a7a9449aa84174','0xc2132d05d31c914a87c6611c10748aeb04b58e8f','0x8f3cf7ad23cd3cadbd9735aff958023239c6a063','0x7ceb23fd6bc0add59e62ac25578270cff1b9f619','0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270','0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6'],
  solana: ['Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB','EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v','USDSwr9ApdRwWiMw1PFrbs23z6AA2stzkPMWBXYzZiQ','So11111111111111111111111111111111111111112'],
};

interface QuoteBalanceResult {
  usd: number;
  nativeAmount?: number;
  nativeSymbol?: string;
  nativeUsd?: number;
  stablesUsd?: number;
}

// А1: fetch trader's total quote/stable balance in USD and native amount for a chain (best-effort, RPC).
// EVM → eth_getBalance + balanceOf on known stables/wrapped; solana → getBalance + SPL token accounts (WSOL, USDC...).
// Returns null on any failure (line is omitted from card, not shown as $0).
const SUBJECT_BALANCE = () => process.env.SUBJECT_BALANCE !== '0';

export async function fetchSubjectBalance(
  chainId: string, trader: string, subjectMint: string,
): Promise<{ amount: number; known: boolean } | null> {
  if (!SUBJECT_BALANCE()) return null;
  if (!trader || trader === 'unresolved' || trader.length < 10) return null;
  if (!subjectMint) return null;
  if (chainId === 'solana') {
    try {
      const r = await solPubsubPost('getTokenAccountsByOwner', [trader, { mint: subjectMint }, { encoding: 'jsonParsed' }], 5000);
      if (!r?.ok) return { amount: 0, known: false };
      let amount = 0;
      for (const a of (r.result?.value || [])) {
        const ui = a?.account?.data?.parsed?.info?.tokenAmount?.uiAmount;
        if (typeof ui === 'number' && Number.isFinite(ui)) amount += ui;
      }
      return { amount, known: true };
    } catch {
      return { amount: 0, known: false };
    }
  }
  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc || !trader.startsWith('0x')) return null;
  try {
    const balData = '0x70a08231' + trader.replace(/^0x/i, '').toLowerCase().padStart(64, '0');
    const res = await fetchWithTimeout(rpc, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: subjectMint, data: balData }, 'latest'] }),
    }, 5000);
    const hex = (await res.json())?.result as string | undefined;
    if (hex === undefined || hex === null) return { amount: 0, known: false };
    if (!hex || hex === '0x' || hex === '0x0') return { amount: 0, known: true };
    const dec = await getEvmTokenDecimals(chainId, subjectMint);
    const amount = Number(BigInt(hex)) / Math.pow(10, dec);
    if (!Number.isFinite(amount)) return { amount: 0, known: false };
    return { amount, known: true };
  } catch {
    return { amount: 0, known: false };
  }
}

// v9.32ao-RC3 П3: пост-трейд остаток subject-токена. Buy + известный 0 сразу после сделки —
// консистентность ATA/слепок до credits: один речек (SUBJECT_BALANCE_RECHECK_MS); если 0 снова —
// не верим нулю (known:false → «н/д»), если речек нашёл баланс — берём его.
const SUBJECT_BALANCE_RECHECK_MS = () => Number(process.env.SUBJECT_BALANCE_RECHECK_MS || '1200');
let subjectBalanceRecheckMsOverride: number | null = null;
export function __setSubjectBalanceRecheckMsForTest(ms: number | null): void { subjectBalanceRecheckMsOverride = ms; }
export async function resolveSubjectBalance(
  chainId: string, trader: string, subjectMint: string,
  opts: { side: string; subjectAmount: number },
): Promise<{ amount: number; known: boolean } | null> {
  const first = await fetchSubjectBalance(chainId, trader, subjectMint);
  if (!first) return null;
  if (first.known && first.amount === 0 && opts.side === 'buy' && opts.subjectAmount > 0) {
    const delay = subjectBalanceRecheckMsOverride ?? SUBJECT_BALANCE_RECHECK_MS();
    if (delay > 0) await new Promise(r => setTimeout(r, delay));
    const second = await fetchSubjectBalance(chainId, trader, subjectMint);
    if (second) {
      if (second.known && second.amount > 0) return second;
      return { amount: 0, known: false }; // подтверждённый ноль не выдаём за баланс
    }
  }
  return first;
}

async function fetchQuoteBalanceUsd(chainId: string, trader: string): Promise<QuoteBalanceResult | null> {
  if (!trader) return null;
  if (chainId === 'solana') {
    let solAmount = 0;
    let stablesUsd = 0;
    const solPrice = await getSolPriceUsd();
    const rpc = solanaPublicRpc();

    // 1. Native SOL balance (lamports)
    try {
      const res = await fetchWithTimeout(rpc, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [trader] }),
      }, 5000);
      const data = await res.json();
      const lamports = data?.result?.value ?? data?.result;
      if (typeof lamports === 'number' && lamports > 0) {
        solAmount += lamports / 1e9;
      }
    } catch { /* ignore */ }

    // 2. SPL token accounts (WSOL, USDC, USDT, USDS)
    for (const mint of KNOWN_QUOTE_TOKENS.solana) {
      try {
        const res = await fetchWithTimeout(rpc, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getTokenAccountsByOwner', params: [trader, { mint }, { encoding: 'jsonParsed' }] }),
        }, 5000);
        const data = await res.json();
        const accounts = data?.result?.value || [];
        for (const a of accounts) {
          const ui = a?.account?.data?.parsed?.info?.tokenAmount?.uiAmount;
          if (typeof ui === 'number' && ui > 0) {
            if (mint === 'So11111111111111111111111111111111111111112') {
              solAmount += ui;
            } else {
              stablesUsd += ui;
            }
          }
        }
      } catch { /* skip mint */ }
    }

    const effectiveSolPrice = solPrice > 0 ? solPrice : 180;
    const nativeUsd = solAmount * effectiveSolPrice;
    const totalUsd = nativeUsd + stablesUsd;
    return {
      usd: totalUsd,
      nativeAmount: solAmount,
      nativeSymbol: 'SOL',
      nativeUsd,
      stablesUsd,
    };
  }

  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return null;

  try {
    const nativeSym = CHAINS[chainId]?.short || 'ETH';
    let nativeAmount = 0;
    let nativePrice = 0;
    let stablesUsd = 0;

    const wrappedAddr = CHAINS[chainId]?.wrapped;
    if (wrappedAddr) {
      try {
        const meta = await fetchEvmTokenMeta(chainId, wrappedAddr);
        if (meta.priceUsd > 0) nativePrice = meta.priceUsd;
      } catch { /* ignore */ }
    }

    // 1. Native currency balance (eth_getBalance)
    try {
      const res = await fetchWithTimeout(rpc, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getBalance', params: [trader, 'latest'] }),
      }, 5000);
      const hex = (await res.json())?.result as string | undefined;
      if (hex && hex !== '0x' && hex !== '0x0') {
        nativeAmount += Number(BigInt(hex)) / 1e18;
      }
    } catch { /* ignore */ }

    // 2. ERC-20 Quote tokens (wrapped native + stables)
    const balData = (token: string) => '0x70a08231' + trader.replace(/^0x/, '').toLowerCase().padStart(64, '0');
    for (const token of KNOWN_QUOTE_TOKENS[chainId] || []) {
      try {
        const res = await fetchWithTimeout(rpc, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: token, data: balData(token) }, 'latest'] }),
        }, 5000);
        const hex = (await res.json())?.result as string | undefined;
        if (hex && hex !== '0x' && hex !== '0x0') {
          const dec = await getEvmTokenDecimals(chainId, token);
          const amt = Number(BigInt(hex)) / Math.pow(10, dec);
          if (lower(token) === lower(wrappedAddr)) {
            nativeAmount += amt;
          } else if (isStablecoin(token, resolveSymFromAddr(token))) {
            stablesUsd += amt;
          }
        }
      } catch { /* skip token */ }
    }

    const nativeUsd = nativeAmount * nativePrice;
    const totalUsd = nativeUsd + stablesUsd;
    return {
      usd: totalUsd,
      nativeAmount,
      nativeSymbol: nativeSym,
      nativeUsd,
      stablesUsd,
    };
  } catch { return null; }
}
const notifDedup = new Map<string, number>();
let notifsDedupDropped = 0;

interface NotifRateState {
  globalSent1m: number[];
  chainSent1m: Record<string, number[]>;
  digestQueue: DigestItem[];
  droppedByRate: number;
  lastDigestAt: number;
}

interface DigestItem {
  ts: number;
  chain: string;
  symbol: string;
  token: string;
  usd: number;
  side: string;
  txHash: string;
}

const notifRate: NotifRateState = {
  globalSent1m: [],
  chainSent1m: {},
  digestQueue: [],
  droppedByRate: 0,
  lastDigestAt: 0,
};

// v9.32as SD5: rate-limited solo-алерты больше не пропадают. Очередь ожидает окно
// DIGEST_FLUSH_MS; воркер прогоняет её по убыванию USD (крупные — в свободный бюджет
// rate-limit, мелочь — одним TG-диджитестом). droppedByRate остаётся 0 при живом диджите.
const pendingSolo: Notification[] = [];
let pendingSoloTimer: ReturnType<typeof setTimeout> | null = null;
let digestSentTotal = 0;
const DIGEST_FLUSH_MS = () => Number(process.env.DIGEST_FLUSH_MS ?? '60000');
const PENDING_SOLO_CAP = () => Number(process.env.PENDING_SOLO_CAP ?? '200');
const DIGEST_QUEUE_CAP = 500;
const DIGEST_TOP_LINES = 15;

function scheduleSoloFlush(): void {
  if (pendingSoloTimer) return;
  const t = setTimeout(() => { pendingSoloTimer = null; void flushSoloWindow(); }, DIGEST_FLUSH_MS());
  pendingSoloTimer = t;
  (t as unknown as { unref?: () => void }).unref?.(); // тест/stop: не держим процесс
}

function pushDigestItem(notif: Notification): void {
  notifRate.digestQueue.push({ ts: notif.ts, chain: notif.chain, symbol: notif.symbol, token: notif.token, usd: notif.usd, side: notif.side, txHash: notif.txHash });
  if (notifRate.digestQueue.length > DIGEST_QUEUE_CAP) notifRate.digestQueue.shift();
}

// v9.32as SD5: «хвост» solo-алерта — общий для мгновенного пути и воркера rate-окна.
async function deliverSoloNotif(notif: Notification, suffix = ''): Promise<void> {
  const chainId = notif.chain;
  const st = state.chain[chainId];
  const tg = await sendTgSolo(notif);
  notif.tgOk = tg.ok;
  notif.tgError = tg.error || null;
  notif.tgMessageText = tg.text;
  if (tg.ok) noteSolMintAlert(notif);
  if (!tg.ok && tg.error) tgRetryEnqueue({ text: tg.text, chain: chainId, error: tg.error, notif });
  recordNotifSent(chainId);
  soloStats.instant++;
  if (st) st.notified++;
  state.notifs.unshift(notif);
  if (state.notifs.length > 200) state.notifs = state.notifs.slice(0, 200);
  noteSolanaHotMint(notif);
  log(`📣 SOLO ${notif.side.toUpperCase()} ${notif.symbol} ${fmtUsd(notif.usd)} [${chainId}]${suffix}`);
}

async function sendDigestIfAny(): Promise<void> {
  if (!notifRate.digestQueue.length) return;
  const items = notifRate.digestQueue.splice(0, notifRate.digestQueue.length).sort((a, b) => b.usd - a.usd);
  const top = items.slice(0, DIGEST_TOP_LINES);
  const lines = top.map(it => `• ${it.symbol || shortAddr(it.token)} $${fmtUsd(it.usd)} ${it.side === 'buy' ? 'покупка' : 'продажа'}`);
  const more = items.length > top.length ? `\n… ещё ${items.length - top.length} (топ-${DIGEST_TOP_LINES} по USD)` : '';
  const n = items.length;
  const text = `⚠️ Rate-limit digest: ${n} ${n === 1 ? 'сделка' : 'сделок'} за минуту (ни одна не потеряна):${lines.join('\n')}${more}`;
  const tg = await tgSend(text);
  if (tg.ok) { digestSentTotal++; notifRate.lastDigestAt = Date.now(); }
  log(`[rate-digest] ${n} сделок → 1 TG-сообщение (ok=${tg.ok}${tg.error ? ', err=' + tg.error : ''})`);
}

async function flushSoloWindow(): Promise<void> {
  if (!pendingSolo.length) return;
  const batch = pendingSolo.splice(0, pendingSolo.length);
  batch.sort((a, b) => b.usd - a.usd); // крупные первыми — в свободный бюджет
  let sent = 0;
  for (const notif of batch) {
    if (checkRateLimit(notif.chain).allowed) { await deliverSoloNotif(notif, ' (rate-window)'); sent++; }
    else pushDigestItem(notif);
  }
  await sendDigestIfAny();
  log(`[rate-digest] окно: ${batch.length} ожидавших → ${sent} solo + ${batch.length - sent} в digest`);
}

export function __flushSoloWindowForTest(): Promise<void> { return flushSoloWindow(); }
export function __flushDigestForTest(): Promise<void> { return sendDigestIfAny(); }
export function __pendingSoloForTest(): number { return pendingSolo.length; }
export function __resetPendingSoloForTest(): void { pendingSolo.length = 0; }
export function __resetDigestQueueForTest(): void { notifRate.digestQueue.length = 0; }
export function __digestSentForTest(): number { return digestSentTotal; }

interface TraderResolveStats {
  resolved: number;
  viaSenderEoa: number;
  viaTxFrom: number;
  viaTxFromAddress: number;
  fallback: number;
  fails: number;
  unresolvedShown: number;
}

const traderResolveStats: TraderResolveStats = { resolved: 0, viaSenderEoa: 0, viaTxFrom: 0, viaTxFromAddress: 0, fallback: 0, fails: 0, unresolvedShown: 0 };
const traderResolveCache = new Map<string, string>();

const RPC_ENDPOINTS: Record<string, string> = {
  ethereum: 'https://ethereum-rpc.publicnode.com',
  bsc: 'https://bsc-dataseed.binance.org',
  base: 'https://mainnet.base.org',
  arbitrum: 'https://arb1.arbitrum.io/rpc',
  polygon: 'https://polygon-bor-rpc.publicnode.com',
};

// Fallback RPCs for archive calls (balanceOf at block-1)
// These support eth_call with block number param
// v9.28 F2: no hardcoded API keys — the NodeReal BSC endpoint used to carry a public demo key in
// the source. It is now supplied via NODEREAL_BSC_RPC env (optional; omitted → that fallback is
// simply not used). Public fallbacks (drpc/ankr) remain as-is.
const NODEREAL_BSC_RPC = process.env.NODEREAL_BSC_RPC || '';
const ARCHIVE_RPC_FALLBACKS: Record<string, string[]> = {
  ethereum: [
    'https://ethereum-rpc.publicnode.com',
    'https://eth.drpc.org',
    'https://rpc.ankr.com/eth',
  ],
  bsc: [
    'https://bsc-dataseed.bnbchain.org',
    'https://bsc-dataseed1.defibit.io',
    'https://bsc-dataseed2.defibit.io',
    'https://bsc-dataseed1.ninicoin.io',
    'https://bsc-dataseed2.ninicoin.io',
    'https://rpc.ankr.com/bsc',
    'https://bsc.drpc.org',
    ...(NODEREAL_BSC_RPC ? [NODEREAL_BSC_RPC] : []),
  ],
  base: [
    'https://mainnet.base.org',
    'https://base.drpc.org',
    'https://rpc.ankr.com/base',
  ],
  arbitrum: [
    'https://arb1.arbitrum.io/rpc',
    'https://arbitrum.drpc.org',
    'https://rpc.ankr.com/arbitrum',
  ],
  polygon: [
    'https://polygon-bor-rpc.publicnode.com',
    'https://polygon.drpc.org',
    'https://rpc.ankr.com/polygon',
  ],
};

// B4.5b follow-up, slice A: analyze telemetry and Arbitrum diagnostics moved to src/lib/evm/analyze-telemetry.ts.
// Facade keeps the call sites and the export surface; behaviour is unchanged. The per-chain RPC cell
// (v9.32bm1 S7) lives in the RPC routing module (slice B); the journal bumps it through onRpcCellHttpErr.
// Slice B: EVM RPC routing & health moved to src/lib/evm/rpc-routing.ts. Created BEFORE analyze-telemetry
// because the journal's http-error accounting feeds the per-chain cell it owns.
const rpcRouting = createEvmRpcRouting({
  env: (name) => process.env[name],
  hostOf: (url) => rpcHostOf(url),
  spendEvm: () => rpcSpend.evm, // EVM section of the shared store stays owned by the Solana pubsub runtime
  rpcEndpoints: () => RPC_ENDPOINTS,
  archiveFallbacks: () => ARCHIVE_RPC_FALLBACKS,
  arbDiagnosticsSnapshot: () => analyzeTelemetry.arbitrumAnalyzeDiagnosticsSnapshot(),
});
const analyzeTelemetry = createEvmAnalyzeTelemetry({
  now: () => Date.now(),
  log,
  env: (name) => process.env[name],
  shortAddr,
  alchemyHttpUrl: (chainId) => alchemyHttpUrl(chainId),
  onRpcCellHttpErr: (chainId) => { rpcRouting.noteCellHttpErr(chainId); }, // v9.32bm1 S7
});
const evmAnalyze = analyzeTelemetry.analyze; // v9.32bm1 S5: httpErr, txMissing (live counters object)
const beginArbitrumAnalyzeDiagnostic = analyzeTelemetry.beginArbitrumAnalyzeDiagnostic;
const beginArbitrumRpcAttempt = analyzeTelemetry.beginArbitrumRpcAttempt;
const finishArbitrumRpcAttempt = analyzeTelemetry.finishArbitrumRpcAttempt;
const setArbitrumTryDecision = analyzeTelemetry.setArbitrumTryDecision;
const finishArbitrumAnalyzeDiagnostic = analyzeTelemetry.finishArbitrumAnalyzeDiagnostic;
const arbitrumAnalyzeDiagnosticsSnapshot = analyzeTelemetry.arbitrumAnalyzeDiagnosticsSnapshot;
export function __arbitrumAnalyzeDiagnosticsForTest() { return analyzeTelemetry.arbitrumAnalyzeDiagnosticsSnapshot(); }
function resetArbitrumAnalyzeDiagnosticsForTest(): void { analyzeTelemetry.resetArbitrumAnalyzeDiagnostics(); }
// ── EVM RPC routing & health (B4.5b follow-up, slice B) ───────────────────────────────────────
// Cull/rotation/starvation decisions for the analyze path live in ./evm/rpc-routing. The facade keeps
// live references through accessors so every call site and metrics port keeps its original shape.
const evmAnalyzeRpcByChain = rpcRouting.analyzeRpcByChainRecord();
const evmAnalyzeRpcRotateCursor = rpcRouting.rotateCursorMap();
const markEvmPublicRpcResult = rpcRouting.markResult;
const evmPublicRpcPoolBase = rpcRouting.poolBase;
const rpcSpendEvmSnapshot = rpcRouting.rpcSpendEvmSnapshot;
export function evmPublicRpcPool(chainId: string): string[] { return rpcRouting.pool(chainId); }
export function __markEvmPublicRpcResultForTest(url: string, ok: boolean, chainId?: string, lane: EvmRpcLane = 'poll', reason?: AnalyzeFailReason): void { rpcRouting.markResult(url, ok, chainId, lane, reason); }
export function __evmRpcCullForTest(): { map: Record<string, number>; starved: number } { return rpcRouting.cullForTest(); }
export function __setEvmRpcCullTimeForTest(t: number | null): void { rpcRouting.setCullTimeForTest(t); }
export function __setEvmEndpointStatsForTest(o: Record<string, { ok: number; fail: number }>, opts?: { keepCull?: boolean }): void { rpcRouting.setEndpointStatsForTest(o, opts); }
export function __evmPublicRpcPoolForTest(chainId: string): string[] { return rpcRouting.pool(chainId); }
export function __rpcEndpointsForTest(): Record<string, string> { return { ...RPC_ENDPOINTS }; }
export function __archiveRpcFallbacksForTest(): Record<string, string[]> { return Object.fromEntries(Object.entries(ARCHIVE_RPC_FALLBACKS).map(([k, v]) => [k, [...v]])); }


interface SseConnectionState {
  lastConnectAttempt: Record<string, number>;
  backoffMs: Record<string, number>;
  connectQueue: string[];
  maxConcurrentSse: number;
  // v9.20: chains that hit a free-tier rate-limit (429/403 + tier:"free") on the reserves stream.
  // DexPaprika's free tier only allows a couple of concurrent streams, so the rest get 429. Each
  // connect attempt costs a REST credit (recordCreditUse('rest',1)), so reconnecting a cut-off chain
  // every few minutes just burns credits for nothing. Once a chain is marked free-tier-limited, we
  // keep it on poll and only retry SSE after a LONG backoff (FREE_TIER_SSE_RETRY_MS).
  freeTierLimited: Record<string, boolean>;
}

const FREE_TIER_SSE_RETRY_MS = 30 * 60 * 1000; // 30 min before retrying a free-tier-limited stream

// v9.20: detect a free-tier rate limit from a DexPaprika SSE error body. The reserves stream on the
// free tier only allows a couple of concurrent subscriptions; extra chains get:
//   {"error":"rate_limited","tier":"free",...}  (HTTP 429)  or a 403 with tier:"free".
// When this is seen we keep the chain on poll and stop spending REST credits on reconnect attempts
// until FREE_TIER_SSE_RETRY_MS elapses. Pure + unit-testable.
// v9.22 F1: also treat a 402 payment_required + tier:"free" + "monthly credit limit reached" as a
// CONFIRMED monthly exhaustion (not a transient rate-limit) so we stop the reconnect credit-burn on a
// dead/exhausted key too (all 6 chains hit exactly this on prod).
export function isFreeTierSseRateLimit(status: number, bodyText: string): boolean {
  const b = String(bodyText || '').toLowerCase();
  // 402 + tier:"free" + a monthly-limit signal = confirmed exhaustion (not just any payment_required)
  if (status === 402) {
    return b.includes('"tier":"free"') &&
      (b.includes('monthly credit') || b.includes('limit reached'));
  }
  if (status !== 429 && status !== 403) return false;
  return b.includes('"tier":"free"') || (b.includes('rate_limited') && b.includes('free'));
}

const sseState: SseConnectionState = {
  lastConnectAttempt: {},
  backoffMs: {},
  connectQueue: [],
  maxConcurrentSse: 2,
  freeTierLimited: {},
};

// v9.8 F2: DexPaprika SSE /sse/reserves push for Solana (top active pools). The reserves stream is
// per-block reserve deltas — a swap on a watched pool pushes one frame. Each frame is normalized to
// an ExtractedSubject and pushed through processExtractedEvent with a 30s SSE freshness gate (the
// push is near-real-time, so anything >30s old on the wire is stale noise). Note: live verification
// showed /sse/reserves requires an API key (keyless returns "preview_only"), unlike /sse/prices
// which streams keyless. So this path uses DEXPAPRIKA_API_KEY when present and honestly reports
// "preview_only" otherwise (falling back to poll + REST, which already lower Solana stale).
const SOLANA_SSE_TOP_POOLS = Number(process.env.SOLANA_SSE_TOP_POOLS || '20');
const SOLANA_SSE_MIN_VOL_USD = Number(process.env.SOLANA_SSE_MIN_VOL_USD || '100000');
const SOLANA_SSE_MAX_AGE_SEC = 30; // SSE-pushed events accepted only if ≤30s old (bypass GT 180s)
// per-chain SSE reserves metrics, surfaced as status.sse.dexpaprika
const dpReservesSse: Record<string, {
  connected: boolean; frames: number; parsedOk: number; dropped: number;
  lastDropReason: string | null; lastFrameAt: number; lastError: string | null; subscribers: number;
}> = {};
function getDpReservesMetrics(chainId: string) {
  if (!dpReservesSse[chainId]) {
    dpReservesSse[chainId] = { connected: false, frames: 0, parsedOk: 0, dropped: 0, lastDropReason: null, lastFrameAt: 0, lastError: null, subscribers: 0 };
  }
  return dpReservesSse[chainId];
}


interface TokenMetaCache {
  symbol: string;
  name: string;
  cachedAt: number;
}

const tokenMetaCache = new Map<string, TokenMetaCache>();
const TOKEN_META_TTL = 15 * 60 * 1000;

interface PoolCreationCache {
  createdAt: number | null;
  cachedAt: number;
}

const poolCreationCache = new Map<string, PoolCreationCache>();
let settingsSource: 'persisted' | 'memory' | 'env' | 'defaults' = 'defaults';

function createDefaultSseStats(): SseStats {
  return {
    framesTotal: 0, parsedOk: 0, matchedWatchlist: 0, dropped: 0,
    pingCount: 0, lastDropReason: null, lastFrameAt: 0, lastRawFrame: null, lastParseSteps: null
  };
}


function createEngineState() {
  return {
  running: false,

  // v9.32am AM3 (LD4/AL3/AL6): режим паузы — анализ заморожен, подписки/кэши/курсоры живут.
  // Паттерн владельца «выключил-поправил-включил» раньше = стоп: ручной рестарт убивал
  // резерв-слой до конца рана (инцидент fone 2026-09-01: health:"awaiting" весь ран после
  // рестарта). Пауза не рвёт WS и не пере-разогревается на resume.
  paused: false,
  pausedAt: 0 as number,
  startedAt: null as number | null,
  dedup: new Set<string>(),
  antispam: new Map<string, number>(),
  buckets: new Map<string, number[]>(),
  groupCooldown: new Map<string, number>(),
  notifs: [] as Notification[],
  chain: {} as Record<string, ChainState>,
  errors: [] as { t: number; msg: string }[],
  dpPauseUntil: 0,
  tgStatus: { ok: false, lastError: null as string | null, lastSendAt: 0, sent: 0 },
  lastRealTrade: null as { chain: string; trade: Record<string, unknown>; parsed: Record<string, unknown> } | null,
  lastRealTradeByChain: {} as Record<string, { chain: string; trade: Record<string, unknown>; parsed: Record<string, unknown> }>,
  };
}
if (!engineKernel.state) engineKernel.state = createEngineState();
const state = engineKernel.state as ReturnType<typeof createEngineState>;

const runtime = {
  timer: null as ReturnType<typeof setInterval> | null,
  ticking: false,
  dpRestCount: 0,
  dpRestStarted: Date.now(),
  lastDiscovery: {} as Record<string, number>,
  running: false,
  warnings: [] as string[],
  cexRefreshTimer: null as ReturnType<typeof setInterval> | null,
  dealValidationTimer: null as ReturnType<typeof setInterval> | null, // bm2n: контур валидации сделок (Фаза 1)
  solRpcHealthTimer: null as ReturnType<typeof setInterval> | null,
  deliveryWatchTimer: null as ReturnType<typeof setInterval> | null,
  statusSnapshotTimer: null as ReturnType<typeof setInterval> | null, // v9.32ao-RC1 BP1
  nativePriceRefreshTimer: null as ReturnType<typeof setInterval> | null,
  initPromises: new Map<string, Promise<void>>(),
  sseAbortControllers: {} as Record<string, AbortController>,
  telegramInitialized: false,
  rootAbortController: null as AbortController | null,
};

type OracleCounterRow = ReturnType<OracleRuntimeDeps['readOracleCounters']>['global'];
function oracleCounterSnapshot(): { global: OracleCounterRow; byChain: Record<string, OracleCounterRow> } {
  let stale = 0, dup = 0;
  const byChain: Record<string, OracleCounterRow> = {};
  for (const [cid, c] of Object.entries(state.chain)) {
    const chainStale = c.skipReasons?.stale || 0; const chainDup = c.skipReasons?.dup || 0;
    stale += chainStale; dup += chainDup;
    const sol = cid === 'solana';
    const evm = isEVMChain(cid);
    const rpc = evmAnalyzeRpcByChain[cid];
    byChain[cid] = {
      stale: chainStale, dup: chainDup,
      capAnchor: sol ? solPubsubMetrics.anchorRateDropped : 0,
      capWl: sol ? solPubsubMetrics.wlRateDropped : 0,
      capBorrow: sol ? solPubsubMetrics.wlBorrowed : 0,
      capEvm: evm ? (evmWsChains[cid]?.rateLimited || 0) : 0,
      rpcStarved: evm ? rpcRouting.analyzeStarvedForChain(cid) : 0,
      rpcErr: evm && rpc ? rpc.httpErr + rpc.fail : 0,
      rate429: sol ? helius429Totals.global + solRpc429LaneAttempts() : (evm ? evmRuntime.spend.pause().pauses : 0),
      // W1-T5: единица — КАНДИДАТ, отбитый по версии (не сделка). Окно — как у solReserveMetrics
      // (накопление с боота, сброс только вместе с резетом рантайма).
      txVersionUnsupported: sol ? solReserveMetrics.txVersionUnsupported : 0,
    };
  }
  let capEvm = 0, rpcErr = 0;
  for (const c of Object.values(evmWsChains)) capEvm += c.rateLimited || 0;
  rpcErr = evmAnalyze.httpErr + evmAnalyze.failed;
  return {
    global: {
      stale, dup,
      capAnchor: solPubsubMetrics.anchorRateDropped, capWl: solPubsubMetrics.wlRateDropped, capBorrow: solPubsubMetrics.wlBorrowed,
      capEvm, rpcStarved: rpcRouting.analyzeStarvedCount(), rpcErr, rate429: helius429Totals.global + solRpc429LaneAttempts() + evmRuntime.spend.pause().pauses,
      txVersionUnsupported: solReserveMetrics.txVersionUnsupported, // W1-T5 (единица — кандидат; окно — как у solReserveMetrics)
    },
    byChain,
  };
}

// B4.3: instantiate once into the process-wide engine kernel; the runtime constructor is inert.

for (const c of Object.keys(CHAINS)) {
  state.chain[c] = {
    enabled: true,
    seenEvents: 0,
    notified: 0,
    skipped: 0,
    skipReasons: { stable: 0, top10: 0, cex: 0, blacklist: 0, contract: 0, infra: 0, amount: 0, dup: 0, noSubject: 0, rate: 0, series: 0, stale: 0, staleWs: 0, stalePoll: 0, noBlockTs: 0, major: 0, contractExec: 0, contractTransient: 0, safeAllowed: 0, contractExecUnknown: 0, contract_from: 0, invalid_tx_format: 0,
      // v9.32bm1i T5: self_swap разложен по точкам убийства; инвариант preEnrich + soloGate + seriesAdmission == self_swap
      self_swap_preEnrich: 0, self_swap_soloGate: 0, self_swap_seriesAdmission: 0 },
    lastEventAt: 0,
    lastPollAt: 0,
    lastError: null,
    errors: 0,
    watchPools: [],
    watchPoolsDiscovery: [],
    watchPoolsAnchors: [],
    discoveryTokensTried: 0,
    discoveryTokensWithPools: 0,
    discovery429s: 0,
    discoveryCandidates: 0,
    discoveryCursorPos: 0,
    discoveryDsFallbacks: 0,
    poolPollEmptySubjects: 0,
    priorityTokensQueued: 0,
    discoveryGtAttempted: 0,
    discoveryGtOk: 0,
    discoveryGt429: 0,
    discoveryGtBudgetSkip: 0,
    discoveryDsAttempted: 0,
    discoveryDsOk: 0,
    discoveryDsEmpty: 0,
    discoveryDsEmptyFresh: 0,
    discoveryDsEmptyRescan: 0,
    discoveryCandidatesFromDs: 0,
    discoveryCycles: 0,
    discoveryStalledCycles: 0,
    lastDiscoveryAt: 0,
    lastDiscoveryError: null,
    watchStablePools: 0,
    hotToken: null,
    hotTokenName: '',
    sseConnected: false,
    source: 'poll',
    sseLastEvent: 0,
    sseFails: 0,
    sseLastError: null,
    sse: createDefaultSseStats(),
    _pollIdx: 0,
    label: CHAINS[c].label,
    initStatus: 'pending',
    pollTicks1m: [],
    _seedDone: false,
    startupSeeded: 0,
    watchlistRetries: 0,
  };
}

// ТЗ v6: apply anchors unconditionally at boot so acceptance pools (Fartcoin FAYL7) are present
// in the watchlist BEFORE any discovery attempt — even if GeckoTerminal is fully unavailable.
for (const c of Object.keys(CHAINS)) ensureAnchorsApplied(c);

function addError(msg: string) {
  state.errors.push({ t: Date.now(), msg: String(msg).slice(0, 300) });
  if (state.errors.length > 50) state.errors.shift();
  log('ERROR: ' + msg);
}

// ─────────────── Telegram Auto-Configuration ───────────────
async function initTelegramFromEnv(): Promise<void> {
  if (runtime.telegramInitialized) return;
  runtime.telegramInitialized = true;

  if (!settings.telegramToken && process.env.TELEGRAM_BOT_TOKEN) {
    settings.telegramToken = process.env.TELEGRAM_BOT_TOKEN;
    log('[telegram] Token loaded from TELEGRAM_BOT_TOKEN env');
  }
  if (!settings.telegramChatId && process.env.TELEGRAM_CHAT_ID) {
    settings.telegramChatId = process.env.TELEGRAM_CHAT_ID;
    log('[telegram] Chat ID loaded from TELEGRAM_CHAT_ID env');
  }

  if (settings.telegramToken) {
    try {
      const url = `https://api.telegram.org/bot${settings.telegramToken}/getMe`;
      const res = await fetchWithTimeout(url, {}, 10000);
      const data = await res.json();
      if (data.ok) {
        state.tgStatus.ok = true;
        state.tgStatus.lastError = null;
        log(`[telegram] Self-check OK: bot @${data.result?.username || 'unknown'}`);
      } else {
        state.tgStatus.ok = false;
        state.tgStatus.lastError = data.description || 'getMe failed';
        log(`[telegram] Self-check FAILED: ${state.tgStatus.lastError}`);
      }
    } catch (e: unknown) {
      state.tgStatus.ok = false;
      state.tgStatus.lastError = (e as Error).message;
      log(`[telegram] Self-check ERROR: ${state.tgStatus.lastError}`);
    }
  }
}

// ─────────────── Token Classification ───────────────
function isNativeOrWrapped(addr: string): boolean {
  return NATIVE_OR_WRAPPED.has(lower(addr));
}

function isStablecoin(addr: string, symbol: string): boolean {
  if (STABLE_ADDRS.has(lower(addr))) return true;
  if (symbol && symbol !== '?' && STABLE_SYMS.has(lower(symbol))) return true;
  return false;
}

function isBaseToken(addr: string, symbol: string): boolean {
  return isNativeOrWrapped(addr) || isStablecoin(addr, symbol);
}

function getWrappedNativeSymbol(addr: string): string | null {
  return WRAPPED_TO_NATIVE[lower(addr)] || null;
}

function determineSubject(
  fromAddr: string, fromSym: string,
  toAddr: string, toSym: string,
  fromUsd: number, toUsd: number
): { subjectAddr: string; subjectSym: string; side: string } | null {
  const fromIsStable = isStablecoin(fromAddr, fromSym);
  const toIsStable = isStablecoin(toAddr, toSym);
  const fromIsNative = isNativeOrWrapped(fromAddr);
  const toIsNative = isNativeOrWrapped(toAddr);
  const fromIsBase = fromIsStable || fromIsNative;
  const toIsBase = toIsStable || toIsNative;

  if (fromIsBase && !toIsBase) return { subjectAddr: toAddr, subjectSym: toSym, side: 'buy' };
  if (!fromIsBase && toIsBase) return { subjectAddr: fromAddr, subjectSym: fromSym, side: 'sell' };
  if (!fromIsBase && !toIsBase) {
    if (fromUsd >= toUsd) return { subjectAddr: fromAddr, subjectSym: fromSym, side: 'sell' };
    return { subjectAddr: toAddr, subjectSym: toSym, side: 'buy' };
  }
  // v9.32bj BJ1 (AR1): обе ноги базовые (stable/native/wrapped) — это конверсия базовых
  // активов, а не покупка/продажа монеты. Раньше отсекалось только stable×stable, и
  // USDT×WBNB / WBNB×USDT / USDC×WETH / WETH×WBNB проваливались ниже, получая
  // subject=BNB|ETH|POL со стороной buy/sell (дефект владельца, все сети). Опора на
  // MAJOR_TOKENS_CONFIG дыру не закрывала: любой не-major native/wrapped в паре со
  // стейблом проскакивал через :9611/why-tx. Строки ниже (getWrappedNativeSymbol,
  // сравнение fromUsd >= toUsd) для base↔base теперь недостижимы, но остаются как
  // защита на случай расширения классификации.
  if (fromIsBase && toIsBase) return null;

  const fromNativeSym = getWrappedNativeSymbol(fromAddr);
  const toNativeSym = getWrappedNativeSymbol(toAddr);

  if (fromNativeSym && !toNativeSym) return { subjectAddr: fromAddr, subjectSym: fromNativeSym.toUpperCase(), side: 'sell' };
  if (toNativeSym && !fromNativeSym) return { subjectAddr: toAddr, subjectSym: toNativeSym.toUpperCase(), side: 'buy' };

  if (fromUsd >= toUsd) {
    const sym = fromNativeSym ? fromNativeSym.toUpperCase() : fromSym;
    return { subjectAddr: fromAddr, subjectSym: sym, side: 'sell' };
  }
  const sym = toNativeSym ? toNativeSym.toUpperCase() : toSym;
  return { subjectAddr: toAddr, subjectSym: sym, side: 'buy' };
}
// v9.32bj BJ1: тест-хук — таблица поведения base↔base проверяется node:test напрямую.
export function __determineSubjectForTest(
  fromAddr: string, fromSym: string, toAddr: string, toSym: string, fromUsd: number, toUsd: number,
): { subjectAddr: string; subjectSym: string; side: string } | null {
  return determineSubject(fromAddr, fromSym, toAddr, toSym, fromUsd, toUsd);
}

// ─────────────── CEX Filter ───────────────
// v9.32an ND2 (AN5, решение владельца): крупные сделки не-CEX токенов не режутся —
// CEX_FILTER_MIN_USD (default 7000): usd ≥ порога → cexNotListed не применяется (exempt,
// счётчик cexExempted + пометка в why-tx). 0 = легаси (фильтр всех). Жертвы 2 дней:
// quq $6.8K, PINK $10.8K, sus $5K, CATE ×4, bsc-meme 0x89973f… ~$27K.
const CEX_FILTER_MIN_USD = () => Number(process.env.CEX_FILTER_MIN_USD || '7000'); // classifyCexDrop / status only — не влияет на pass (AX1)
// Крупные ликвидные CEX. Без DEX и без мелких/низколиквидных площадок.
const DEFAULT_CEX_SOURCES = [
  'binance', 'coinbase', 'okx', 'bybit', 'bitget', 'kucoin', 'gate', 'mexc', 'htx', 'kraken',
  'cryptocom', 'upbit', 'bitfinex', 'gemini', 'whitebit', 'bingx', 'bitstamp', 'bithumb',
  'bitmart', 'xt', 'coinex', 'lbank', 'poloniex', 'binance_us',
].join(',');
const CEX_SOURCES = () => String(process.env.CEX_SOURCES || DEFAULT_CEX_SOURCES).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const CEX_GECKO_TICKER_PAGES = () => {
  const n = Number(process.env.CEX_GECKO_TICKER_PAGES ?? '5');
  return Number.isFinite(n) && n > 0 ? Math.min(12, n) : 5;
};
const CEX_MIN_TICKER_USD = () => {
  const n = Number(process.env.CEX_MIN_TICKER_USD ?? '5000');
  return Number.isFinite(n) && n >= 0 ? n : 5000;
};
const CEX_SOURCE_REFRESH_MS = () => {
  const n = Number(process.env.CEX_SOURCE_REFRESH_MS ?? '14400000');
  return Number.isFinite(n) && n > 0 ? n : 14400000;
};
const CEX_REQUIRE_EXCHANGE = () => process.env.CEX_REQUIRE_EXCHANGE !== '0';
const IMPACT_MAX_PCT = () => {
  const n = Number(process.env.IMPACT_MAX_PCT ?? '30');
  return Number.isFinite(n) && n > 0 ? n : 30;
};
let priceStrictOverride: boolean | null = null;
const PRICE_STRICT_CONTRACT = () => priceStrictOverride ?? (process.env.PRICE_STRICT_CONTRACT === '1');
export function __setPriceStrictForTest(v: boolean | null): void { priceStrictOverride = v; }
const DS_BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
let cexExempted = 0; // AX1: больше не инкрементируется; поле статуса сохранено
const cexSourceStats: Record<string, number> = { mexc: 0, gate: 0, bybit: 0, coingeckoOnly: 0, noAddress: 0 };
let usdUnresolved = 0;
// v9.32ay AY3: честный счётчик резолва цены. Старый dsPriceFallback был асимметричен
// (ok++ только в ветке DS tokens/v1, fail++ в общем хвосте) и врал в статусе.
// ok = резолвов всего; bySource = по источникам; fail = хвост без цены;
// notCached = хвостов без записи в кэш (rate-skip / транспортные ошибки).
const priceResolve = { ok: 0, fail: 0, bySource: { ds: 0, gt: 0, dp: 0 }, notCached: 0 };
export function __cexExemptedForTest(): number { return cexExempted; }
export function __cexSourceStatsForTest(): typeof cexSourceStats { return { ...cexSourceStats }; }
export function __usdUnresolvedForTest(): number { return usdUnresolved; }
export function __dsPriceFallbackForTest(): { ok: number; fail: number } { return { ok: priceResolve.bySource.ds, fail: priceResolve.fail }; }
export function __priceResolveForTest(): { ok: number; fail: number; bySource: { ds: number; gt: number; dp: number }; notCached: number } {
  return { ok: priceResolve.ok, fail: priceResolve.fail, bySource: { ...priceResolve.bySource }, notCached: priceResolve.notCached };
}
export function __impactAnomalyForTest(impact: number | null): boolean {
  return impact !== null && Number.isFinite(impact) && impact > IMPACT_MAX_PCT();
}
const CEX_GATE_SAMPLE_EVERY = () => {
  const n = Number(process.env.CEX_GATE_SAMPLE_EVERY ?? '50');
  return n > 0 ? n : 50;
};
let cexDropSampleN = 0;
export function __cexFilterDecisionForTest(chainId: string, addr: string, sym: string, usd: number, trader?: string): { pass: boolean; exempt: boolean; method?: string; reason?: CexRejectReason } {
  return cexFilterDecision(chainId, addr, sym, usd, trader);
}
function cexEntryHasExchange(entry: CexTokenEntry | undefined): boolean {
  if (!entry) return false;
  if (!CEX_REQUIRE_EXCHANGE()) return true;
  return entry.cexSources.some(s => s !== 'coingecko');
}
function classifyCexDrop(tokenAddr: string, amountUsd: number, trader?: string): CexRejectReason {
  // Observability only — does not change pass/fail (AU3). AX1: non-CEX always drops.
  if ((trader && getExchangeWalletLabel(trader)) || getExchangeWalletLabel(tokenAddr)) {
    return 'known_cex_address';
  }
  const min = CEX_FILTER_MIN_USD();
  if (min > 0 && amountUsd < min) return 'threshold';
  return 'heuristic';
}
function recordCexReject(chainId: string, reason: CexRejectReason): void {
  if (!cexRejects[chainId]) cexRejects[chainId] = emptyCexRejectBucket();
  cexRejects[chainId][reason]++;
}
function sampleCexDrop(chainId: string, reason: CexRejectReason, tokenSymbol: string, amountUsd: number, txHash: string): void {
  cexDropSampleN++;
  const every = CEX_GATE_SAMPLE_EVERY();
  if (cexDropSampleN % every === 0) {
    log(`[cex-drop] ${chainId} ${reason} ${tokenSymbol} $${Math.round(amountUsd)} tx=${txHash}`);
  }
}
function cexFilterDecision(chainId: string, tokenAddr: string, tokenSymbol: string, amountUsd: number, trader?: string): { pass: boolean; exempt: boolean; method?: string; reason?: CexRejectReason } {
  const r = checkCexFilter(chainId, tokenAddr, tokenSymbol);
  if (r.pass) return { pass: true, exempt: false, method: r.method };
  // v9.32ax AX1: exempt-threshold удалён — не-CEX не проходит ни при какой сумме.
  return { pass: false, exempt: false, reason: classifyCexDrop(tokenAddr, amountUsd, trader) };
}

function checkCexFilter(chainId: string, tokenAddr: string, tokenSymbol: string): { pass: boolean; method?: string } {
  if (!settings.cexFilterEnabled) return { pass: true, method: 'disabled' };
  const lowerAddr = lower(tokenAddr);
  const addrKey = chainId + ':' + lowerAddr;

  const recordHit = (matchedKey: string, source: 'direct' | 'wrapped') => {
    const hit: CexHitRecord = { chain: chainId, subjectAddr: lowerAddr, subjectSym: tokenSymbol, matchedKey, source, ts: Date.now() };
    cexFilterStats.lastHits.push(hit);
    if (cexFilterStats.lastHits.length > 20) cexFilterStats.lastHits.shift();
  };

  const direct = cexCache.byAddress[addrKey] || cexCache.byAddress['all:' + lowerAddr];
  if (direct && cexEntryHasExchange(direct)) {
    cexFilterStats.matchHits++;
    cexFilterStats.passedEvents++;
    recordHit(addrKey, 'direct');
    return { pass: true, method: 'address' };
  }

  const nativeSymbol = WRAPPED_TO_NATIVE[lowerAddr];
  if (nativeSymbol && cexCache.bySymbol[nativeSymbol] && cexEntryHasExchange(cexCache.bySymbol[nativeSymbol])) {
    cexFilterStats.matchHits++;
    cexFilterStats.passedEvents++;
    cexFilterStats.wrappedNativeHits++;
    recordHit(`wrapped:${nativeSymbol}`, 'wrapped');
    return { pass: true, method: 'wrapped-native' };
  }

  cexFilterStats.matchMisses++;
  cexFilterStats.skippedCex++;
  return { pass: false };
}

// ─────────────── Contract Check ───────────────
// Test hook: deterministic getCode override (null/undefined = use real RPC/cache).
let contractCheckOverride: ((chainId: string, addr: string) => boolean | null | undefined) | null = null;
export function __setContractCheckMock(fn: ((chainId: string, addr: string) => boolean | null | undefined) | null): void {
  contractCheckOverride = fn;
}

async function isContract(chainId: string, addr: string, opts?: { retry?: boolean }): Promise<boolean | null> {
  if (chainId === 'solana') return false;
  if (contractCheckOverride) {
    const v = contractCheckOverride(chainId, addr);
    if (v !== undefined) return v; // null = unresolved getCode (v9.32l M2)
  }
  const key = chainId + ':' + lower(addr);
  if (!opts?.retry) {
    const cached = codeCache.get(key);
    if (cached !== undefined && Date.now() - cached.t < CODE_CACHE_TTL) return cached.v;
  }

  const rpcs = [RPC_ENDPOINTS[chainId], ...(ARCHIVE_RPC_FALLBACKS[chainId] || []), alchemyHttpUrl(chainId)].filter(Boolean);
  if (!rpcs.length) return null;

  const start = opts?.retry ? 1 : 0;
  if (opts?.retry && start >= rpcs.length) return null;
  const limit = opts?.retry ? start + 1 : rpcs.length;

  const alcCode = alchemyHttpUrl(chainId);
  for (const rpc of rpcs.slice(start, limit)) {
    // v9.32al CK1: getCode-подтверждение «это контракт» — самый дорогой не-анализовый поток (690/мин
    // в инциденте): на alchemy-фолбэке идёт через бакет, при отказе fail-open-путь (null) как раньше
    if (rpc === alcCode && !(await alchemyCuGate(alchemyCuCost('eth_getCode'), { src: 'getCode' }))) continue;
    try {
      recordRpcCall('eth_getCode', classifyRpcSource(rpc), 'code');
      const res = await fetchWithTimeout(rpc, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getCode', params: [addr, 'latest'] }),
      }, 5000);
      if (rpc === alcCode) noteAlchemyHttpStatus(res.status); // v9.32al CK5
      const data = await res.json();
      const code = data?.result as string | undefined;
      if (code && typeof code === 'string') {
        // EIP-7702 delegation designation: 0xef0100 + 20-byte target address (48 chars total).
        // These are user EOA wallets utilizing Account Abstraction / delegated smart features, NOT MEV bots.
        if (code.toLowerCase().startsWith('0xef0100') && code.length === 48) {
          codeCache.set(key, { t: Date.now(), v: false });
          return false;
        }
        const isContractAddr = !!(code !== '0x' && code.length > 4);
        codeCache.set(key, { t: Date.now(), v: isContractAddr });
        return isContractAddr;
      }
    } catch { /* try next fallback */ }
  }
  return null;
}

// ─────────────── contractExec Filter (ТЗ v2: inventory-based) ───────────────
// NEW LOGIC: Cut only "piggy-bank" contracts (hold token inventory at block-1).
// Pass transit/DEX contracts (balance≈0) and Gnosis Safes.
// Returns: 'contractExec' (skip piggy-bank), 'safe' (allow), 'eoa' (allow),
//          'transient' (allow transit contract), 'unknown' (allow, fail-open)
// R10-C: Execution classification
export type ExecutionClass =
  | 'user_signed_router'
  | 'settlement_solver'
  | 'contract_executor'
  | 'eoa_with_trader_movement'
  | 'eoa_without_trader_movement'
  | 'safe'
  | 'infrastructure'
  | 'unknown';

interface ContractExecResult {
  counterparty: string | null;
  isContract: boolean;
  isSafe: boolean;
  preBalanceUsd: number | null;
  decision: 'contractExec' | 'safe' | 'eoa' | 'unknown' | 'transient';
  receiptFound: boolean;
  executionClass?: ExecutionClass;
  bypassReason?: string;
  protocolRole?: string | null;
}

async function isSafeMultisig(chainId: string, addr: string, rpc: string): Promise<boolean> {
  const cacheKey = `${chainId}:${lower(addr)}`;
  const cached = safeCheckCache.get(cacheKey);
  if (cached !== undefined) return cached;

  try {
    // getOwners() selector: 0xa0e67e2b
    const res = await fetchWithTimeout(rpc, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        jsonrpc: '2.0', id: 1, method: 'eth_call',
        params: [{ to: addr, data: '0xa0e67e2b' }, 'latest'],
      }),
    }, 5000);
    const data = await res.json();
    const result = data?.result as string | undefined;
    // Valid ABI-encoded address[] starts with 0x + 32-byte offset (0x20) + length > 0
    if (result && result.length > 130 && result.startsWith('0x')) {
      // Parse: offset (32 bytes) + length (32 bytes) + data
      const hex = result.slice(2);
      // length of array is at bytes 32–64
      const arrLen = parseInt(hex.slice(64, 128), 16);
      const isSafe = arrLen > 0;
      safeCheckCache.set(cacheKey, isSafe);
      return isSafe;
    }
    safeCheckCache.set(cacheKey, false);
    return false;
  } catch {
    safeCheckCache.set(cacheKey, false);
    return false;
  }
}

// Helper: call balanceOf on token contract at block (block-1) via multiple RPC fallbacks
async function getBalanceAtBlock(
  chainId: string,
  tokenAddr: string,
  holderAddr: string,
  blockNumber: string,  // hex string like '0x...'
): Promise<bigint | null> {
  // balanceOf(address) selector = 0x70a08231, padded address (32 bytes)
  const paddedAddr = holderAddr.replace(/^0x/, '').toLowerCase().padStart(64, '0');
  const data = '0x70a08231' + paddedAddr;
  const blockParam = '0x' + (parseInt(blockNumber, 16) - 1).toString(16);

  const rpcs = ARCHIVE_RPC_FALLBACKS[chainId] || [RPC_ENDPOINTS[chainId]];
  for (const rpc of rpcs) {
    if (!rpc) continue;
    try {
      const res = await fetchWithTimeout(rpc, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jsonrpc: '2.0', id: 1, method: 'eth_call',
          params: [{ to: tokenAddr, data }, blockParam],
        }),
      }, 6000);
      const d = await res.json();
      if (d?.error?.code === -32000 || d?.error?.code === -32001 ||
          d?.error?.code === -32002 || d?.error?.code === -32015 ||
          res.status === 400 || res.status === 404) {
        // Archive not supported on this RPC — try next
        continue;
      }
      if (d?.result && typeof d.result === 'string' && d.result !== '0x') {
        return BigInt(d.result);
      }
      if (d?.result === '0x' || d?.result === '0x0') return BigInt(0);
    } catch {
      // try next RPC
    }
  }
  // Правка 1: All archive fallbacks failed — try 'latest' as approximation
  // This is only an approximation (for old-block replays) — fresh blocks always have archive
  const fallbackRpc = rpcs[0] || RPC_ENDPOINTS[chainId];
  if (fallbackRpc) {
    try {
      const res = await fetchWithTimeout(fallbackRpc, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jsonrpc: '2.0', id: 1, method: 'eth_call',
          params: [{ to: tokenAddr, data }, 'latest'],
        }),
      }, 6000);
      const d = await res.json();
      if (d?.result && typeof d.result === 'string' && d.result !== '0x') {
        // Return as negative BigInt to signal "approx" to callers
        // We encode approx by returning { value, approx:true } — but since we need bigint, we
        // use a special sentinel: returning the real balance, caller must set approxPreBalance=true
        // Правка 1: use a wrapper approach — encode approx in separate global flag
        return BigInt(d.result); // approx — caller uses lastApproxBalance flag
      }
      if (d?.result === '0x' || d?.result === '0x0') return BigInt(0);
    } catch { /* ignore */ }
  }
  return null; // truly failed all fallbacks
}

async function checkContractExecFilter(
  chainId: string,
  txHash: string,
  subjectTokenAddr: string,
  poolAddr: string | null
): Promise<ContractExecResult> {
  const NORET: ContractExecResult = { counterparty: null, isContract: false, isSafe: false, preBalanceUsd: null, decision: 'eoa', receiptFound: false };

  if (CONTRACT_EXEC_FILTER === 'off') {
    return NORET;
  }
  if (chainId === 'solana') {
    return NORET;
  }

  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc || !txHash || !txHash.startsWith('0x')) {
    contractExecUnknown++;
    return { ...NORET, decision: 'unknown' };
  }

  // Check tx-level cache first
  const txCacheKey = `${chainId}:${txHash}`;
  const txCached = contractExecCache.get(txCacheKey);
  if (txCached) {
    if (txCached === 'contractExec') return { ...NORET, isContract: true, decision: 'contractExec', receiptFound: true };
    if (txCached === 'safe') return { ...NORET, isContract: true, isSafe: true, decision: 'safe', receiptFound: true };
    if (txCached === 'eoa') return { ...NORET, decision: 'eoa', receiptFound: true };
    if (txCached === 'transient') return { ...NORET, isContract: true, decision: 'transient', receiptFound: true };
    return { ...NORET, decision: 'unknown' };
  }

  try {
    // Step 1: Get receipt to find counterparty from Transfer logs
    recordRpcCall('eth_getTransactionReceipt', classifyRpcSource(rpc), 'contractExec');
    const receiptRes = await fetchWithTimeout(rpc, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [txHash] }),
    }, 8000);
    const receiptData = await receiptRes.json();
    const receipt = receiptData?.result;
    if (!receipt || !receipt.logs) {
      contractExecUnknown++;
      contractExecCache.set(txCacheKey, 'unknown');
      return { ...NORET, decision: 'unknown', receiptFound: false };
    }

    const blockNumber: string = receipt.blockNumber || '0x0';
    const lowerSubject = lower(subjectTokenAddr);
    const lowerPool = poolAddr ? lower(poolAddr) : null;

    // Step 2: Find Transfer log of subject token involving pool → get counterparty
    let counterparty: string | null = null;
    for (const logEntry of receipt.logs) {
      const logAddr = lower(logEntry.address || '');
      if (logAddr !== lowerSubject) continue;
      const topics = logEntry.topics as string[] | undefined;
      if (!topics || topics.length < 3) continue;
      if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;

      const from = '0x' + topics[1].slice(-40);
      const to = '0x' + topics[2].slice(-40);

      if (lowerPool) {
        if (lower(from) === lowerPool) { counterparty = to; break; }
        if (lower(to) === lowerPool) { counterparty = from; break; }
      } else {
        const ZERO = '0x0000000000000000000000000000000000000000';
        if (lower(from) !== ZERO && lower(to) !== ZERO) {
          if (isUserEntrypoint(chainId, from) || isSettlementAddr(chainId, from)) {
            counterparty = to;
          } else if (isUserEntrypoint(chainId, to) || isSettlementAddr(chainId, to)) {
            counterparty = from;
          } else {
            counterparty = to;
          }
          break;
        }
      }
    }

    if (!counterparty) {
      contractExecUnknown++;
      contractExecCache.set(txCacheKey, 'unknown');
      return { ...NORET, decision: 'unknown', receiptFound: true };
    }

    // Step 3: Is counterparty EOA or known router/settlement/infrastructure contract?
    if (isRouterAddr(chainId, counterparty) || isSettlementAddr(chainId, counterparty) ||
        isProtocolInfrastructure(chainId, counterparty)) {
      contractExecCache.set(txCacheKey, 'transient');
      return { counterparty, isContract: true, isSafe: false, preBalanceUsd: 0, decision: 'transient', receiptFound: true };
    }

    // R11-F6: Movement-aware classification — EOA tx.from does NOT automatically bypass
    // Check if tx.from is the resolved trader (has actual movement in the swap)
    const txFrom = receipt.from ? lower(receipt.from) : null;
    const txFromIsEOA = txFrom && (await isContract(chainId, txFrom)) === false;
    // Check if tx.from appears in any Transfer log as sender or receiver
    let txFromHasMovement = false;
    if (txFrom) {
      for (const logEntry of receipt.logs) {
        const topics = logEntry.topics as string[] | undefined;
        if (!topics || topics.length < 3) continue;
        if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;
        const logFrom = '0x' + topics[1].slice(-40);
        const logTo = '0x' + topics[2].slice(-40);
        if (lower(logFrom) === txFrom || lower(logTo) === txFrom) {
          txFromHasMovement = true;
          break;
        }
      }
    }

    // Only bypass if tx.from is EOA AND has actual trader movement
    if (txFromIsEOA && txFromHasMovement) {
      contractExecCache.set(txCacheKey, 'eoa');
      return { counterparty: txFrom, isContract: false, isSafe: false, preBalanceUsd: 0, decision: 'eoa', receiptFound: true };
    }

    // If tx.from is EOA but has NO movement (e.g., solver/filler), continue to counterparty check
    // Do NOT automatically bypass — solver EOA is not a human trader

    const contractCode = await isContract(chainId, counterparty);
    if (contractCode === false) {
      contractExecCache.set(txCacheKey, 'eoa');
      return { counterparty, isContract: false, isSafe: false, preBalanceUsd: 0, decision: 'eoa', receiptFound: true };
    }
    if (contractCode === null) {
      contractExecUnknown++;
      contractExecCache.set(txCacheKey, 'unknown');
      return { counterparty, isContract: true, isSafe: false, preBalanceUsd: null, decision: 'unknown', receiptFound: true };
    }

    // Step 4: Contract → check Gnosis Safe (always allowed)
    const safe = await isSafeMultisig(chainId, counterparty, rpc);
    if (safe) {
      safeAllowed++;
      contractExecCache.set(txCacheKey, 'safe');
      return { counterparty, isContract: true, isSafe: true, preBalanceUsd: 0, decision: 'safe', receiptFound: true };
    }

    // Step 5: INVENTORY CHECK — balanceOf(counterparty) at blockNumber-1
    const inventoryCacheKey = `${chainId}:${lower(counterparty)}:${blockNumber}`;
    let preBalanceUsd: number | null = null;

    const invCached = inventoryCache.get(inventoryCacheKey);
    if (invCached && Date.now() - invCached.cachedAt < INVENTORY_CACHE_TTL) {
      preBalanceUsd = invCached.preBalanceUsd;
    } else {
      const rawBalance = await getBalanceAtBlock(chainId, subjectTokenAddr, counterparty, blockNumber);
      if (rawBalance === null) {
        // Правка 3: All fallbacks failed → fail-open (pass through), contractExecUnknown+1
        // NEVER silently skip here — always count
        contractExecUnknown++;
        contractExecCache.set(txCacheKey, 'unknown');
        contractTransient++; // count as transient so it passes
        log(`[contractExec] preBalance unavailable (all RPCs failed) for ${shortAddr(counterparty)} on ${chainId} — fail-open (pass)`);
        return { counterparty, isContract: true, isSafe: false, preBalanceUsd: null, decision: 'unknown', receiptFound: true };
      }

      // Convert raw balance to USD using price from liquidity cache
      const liqCacheKey = `${chainId}:${lower(subjectTokenAddr)}`;
      const liqCached = liquidityCache.get(liqCacheKey);
      let price = 0;
      if (liqCached && liqCached.subjectAmount > 0 && liqCached.subjectUsd > 0) {
        price = liqCached.subjectUsd / liqCached.subjectAmount;
      }
      // If no price in cache, try DexScreener fallback
      if (price === 0) {
        try {
          const dsRes = await dsFetch<{ pairs?: Array<{ priceUsd?: string; baseToken?: { address: string } }> }>(   // bm2r D5: через единый шлюз (учёт/бэкофф/лейн price)
            'price',
            `https://api.dexscreener.com/latest/dex/tokens/${subjectTokenAddr}`
          );
          if (dsRes.ok && dsRes.data?.pairs && dsRes.data.pairs.length > 0) {
            const lowerToken = lower(subjectTokenAddr);
            for (const pair of dsRes.data.pairs) {
              if (pair.priceUsd && parseFloat(pair.priceUsd) > 0 &&
                  lower(pair.baseToken?.address || '') === lowerToken) {
                price = parseFloat(pair.priceUsd);
                break;
              }
            }
          }
        } catch { /* ignore */ }
      }

      // R11-F6: Use exact token decimals, not hardcoded 1e18
      let tokenDecimals = 18; // default fallback
      try {
        tokenDecimals = await getEvmTokenDecimals(chainId, subjectTokenAddr);
      } catch { /* use default */ }
      const balNorm = Number(rawBalance) / Math.pow(10, tokenDecimals);
      preBalanceUsd = price > 0 ? balNorm * price : 0;

      inventoryCache.set(inventoryCacheKey, { preBalanceUsd, cachedAt: Date.now() });
    }

    // Step 6: Classify by inventory
    if (preBalanceUsd !== null && preBalanceUsd >= CONTRACT_INVENTORY_USD) {
      // PIGGY-BANK: contract holds significant inventory → cut
      contractExecSkipped++;
      contractExecCache.set(txCacheKey, 'contractExec');
      log(`[contractExec] PIGGY-BANK cut: ${shortAddr(counterparty)} on ${chainId} preBalanceUsd=$${preBalanceUsd.toFixed(2)} >= $${CONTRACT_INVENTORY_USD}`);
      return { counterparty, isContract: true, isSafe: false, preBalanceUsd, decision: 'contractExec', receiptFound: true };
    } else {
      // TRANSIT: contract has little/no inventory → pass through
      contractTransient++;
      contractExecCache.set(txCacheKey, 'transient');
      log(`[contractExec] TRANSIT pass: ${shortAddr(counterparty)} on ${chainId} preBalanceUsd=$${(preBalanceUsd ?? 0).toFixed(4)}`);
      return { counterparty, isContract: true, isSafe: false, preBalanceUsd: preBalanceUsd ?? 0, decision: 'transient', receiptFound: true };
    }

  } catch (e) {
    log(`[contractExec] Error for ${chainId}:${shortAddr(txHash)}: ${(e as Error).message}`);
    contractExecUnknown++;
    return { ...NORET, decision: 'unknown', receiptFound: false };
  }
}

// Public explain function for /api/debug/explain-tx (ТЗ v2: inventory fields)
export async function explainTx(chainId: string, txHash: string): Promise<{
  counterparty: string | null;
  isContract: boolean;
  isSafe: boolean;
  preBalanceUsd: number | null;
  approxPreBalance?: boolean;
  wouldSkip: 'contractExec' | null;
  receiptFound: boolean;
  note?: string;
}> {
  const EMPTY = { counterparty: null as string | null, isContract: false, isSafe: false, preBalanceUsd: null as number | null, wouldSkip: null as 'contractExec' | null, receiptFound: false };

  if (chainId === 'solana') return EMPTY;

  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return EMPTY;

  try {
    // Get receipt + tx in parallel
    const [receiptRes, txRes] = await Promise.all([
      fetchWithTimeout(rpc, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [txHash] }),
      }, 5000), // v9.30 F2: aggressive timeout
      fetchWithTimeout(rpc, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'eth_getTransactionByHash', params: [txHash] }),
      }, 5000), // v9.30 F2: aggressive timeout
    ]);
    const receiptData = await receiptRes.json();
    const txData = await txRes.json();
    const receipt = receiptData?.result;
    if (!receipt || !receipt.logs) return { ...EMPTY, receiptFound: false };

    const blockNumber: string = receipt.blockNumber || '0x0';
    const txFrom = lower(txData?.result?.from || '');
    const txTo = lower(txData?.result?.to || '');

    // Scan Transfer logs to find contract executor candidates
    const ZERO = '0x0000000000000000000000000000000000000000';
    const candidates = new Set<string>();
    let subjectTokenAddr: string | null = null; // use first token seen in Transfer for balanceOf

    for (const logEntry of receipt.logs) {
      const topics = logEntry.topics as string[] | undefined;
      if (!topics || topics.length < 3) continue;
      if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;

      const from = '0x' + topics[1].slice(-40);
      const to = '0x' + topics[2].slice(-40);
      if (!subjectTokenAddr) subjectTokenAddr = lower(logEntry.address || '');

      for (const addr of [from, to]) {
        if (lower(addr) !== ZERO && lower(addr) !== txFrom) candidates.add(lower(addr));
      }
    }
    if (txTo && txTo !== ZERO) candidates.add(txTo);

    // Find first contract candidate
    let counterparty: string | null = null;
    let cpIsContract = false;
    let cpIsSafe = false;

    const orderedCandidates = txTo
      ? [txTo, ...Array.from(candidates).filter(c => c !== txTo)]
      : Array.from(candidates);

    for (const addr of orderedCandidates) {
      const isC = await isContract(chainId, addr);
      if (isC === true) {
        counterparty = addr;
        cpIsContract = true;
        cpIsSafe = await isSafeMultisig(chainId, addr, rpc);
        break;
      }
    }

    if (!counterparty) {
      return { counterparty: txTo || null, isContract: false, isSafe: false, preBalanceUsd: 0, wouldSkip: null, receiptFound: true };
    }
    if (cpIsSafe) {
      return { counterparty, isContract: true, isSafe: true, preBalanceUsd: 0, wouldSkip: null, receiptFound: true };
    }

    // ТЗ v2: Inventory check — balanceOf at block-1
    let preBalanceUsd: number | null = null;
    if (subjectTokenAddr) {
      const inventoryCacheKey = `${chainId}:${lower(counterparty)}:${blockNumber}`;
      const invCached = inventoryCache.get(inventoryCacheKey);
      if (invCached && Date.now() - invCached.cachedAt < INVENTORY_CACHE_TTL) {
        preBalanceUsd = invCached.preBalanceUsd;
      } else {
        const rawBalance = await getBalanceAtBlock(chainId, subjectTokenAddr, counterparty, blockNumber);
        if (rawBalance !== null) {
          // Get price from liquidity cache, or fetch from DexScreener as fallback
          const liqCacheKey = `${chainId}:${lower(subjectTokenAddr)}`;
          const liqCached = liquidityCache.get(liqCacheKey);
          let price = 0;
          if (liqCached && liqCached.subjectAmount > 0 && liqCached.subjectUsd > 0) {
            price = liqCached.subjectUsd / liqCached.subjectAmount;
          }
          // If no price in cache, try DexScreener for token price
          if (price === 0) {
            try {
              const dsRes = await dsFetch<{ pairs?: Array<{ priceUsd?: string; baseToken?: { address: string }; quoteToken?: { address: string } }> }>(   // bm2r D5: шлюз, лейн price
                'price',
                `https://api.dexscreener.com/latest/dex/tokens/${subjectTokenAddr}`
              );
              if (dsRes.ok && dsRes.data?.pairs && dsRes.data.pairs.length > 0) {
                const lowerToken = lower(subjectTokenAddr);
                for (const pair of dsRes.data.pairs) {
                  if (pair.priceUsd && parseFloat(pair.priceUsd) > 0) {
                    // Check if the subject token is base or quote
                    const baseAddr = lower(pair.baseToken?.address || '');
                    if (baseAddr === lowerToken) {
                      price = parseFloat(pair.priceUsd);
                      break;
                    }
                  }
                }
              }
            } catch { /* ignore price fetch errors */ }
          }
          // Assume 18 decimals for most ERC-20 tokens
          const balNorm = Number(rawBalance) / 1e18;
          preBalanceUsd = price > 0 ? balNorm * price : 0;
          inventoryCache.set(inventoryCacheKey, { preBalanceUsd, cachedAt: Date.now() });
        }
      }
    }

    // Classify: piggy-bank vs transit
    const isPiggyBank = preBalanceUsd !== null && preBalanceUsd >= CONTRACT_INVENTORY_USD;
    const noteStr = preBalanceUsd === null ? 'preBalance unavailable (all archive RPCs failed — fail-open pass)' : undefined;
    return {
      counterparty,
      isContract: cpIsContract,
      isSafe: cpIsSafe,
      preBalanceUsd,
      wouldSkip: isPiggyBank ? 'contractExec' : null,
      receiptFound: true,
      note: noteStr,
    };

  } catch (e) {
    log(`[explainTx] Error for ${chainId}:${shortAddr(txHash)}: ${(e as Error).message}`);
    return EMPTY;
  }
}

// ─────────────── Trader Resolution ───────────────
interface ResolvedTrader {
  address: string;
  isResolved: boolean;
  poolSender: string;
  method: 'sender_eoa' | 'tx_from' | 'tx_from_address' | 'fallback' | 'token_movement' | 'fee_payer_fallback';
  resolvedBy?: string;
  sideConfirmed?: boolean;
  sideUnconfirmed?: 'hop_local' | 'no_data' | null;
}

const SOLANA_RPC_DEFAULT = 'https://api.mainnet-beta.solana.com';
function solanaRpcPrimary(): string { return process.env.SOLANA_RPC || SOLANA_RPC_DEFAULT; }
const SOLANA_RPC = solanaRpcPrimary();
export function solanaPublicRpcPool(): string[] {
  const a = (process.env.SOLANA_RPC || SOLANA_RPC_DEFAULT).trim();
  const bRaw = process.env.SOLANA_RPC_2;
  const b = bRaw === undefined ? SOLANA_RPC_DEFAULT : String(bRaw).trim();
  const c = String(process.env.SOLANA_RPC_3 || '').trim();
  const out: string[] = [];
  for (const u of [a, b, c]) { if (u && !out.includes(u)) out.push(u); }
  return orderSolanaPublicEndpoints(out);
}
function orderSolanaPublicEndpoints(urls: string[]): string[] {
  const isOfficial = (u: string) => /api\.mainnet-beta\.solana\.com/i.test(u);
  const failRate = (u: string): number => {
    try {
      const host = (() => { try { return new URL(u).host; } catch { return u; } })();
      const cell = rpcSpend.solana.byEndpoint[host];
      const n = (cell?.ok || 0) + (cell?.fail || 0);
      if (n < 5) return 0;
      return (cell?.fail || 0) / n;
    } catch { return 0; }
  };
  const rest = urls.filter(u => !isOfficial(u));
  const off = urls.filter(isOfficial);
  const rank = (arr: string[]) => {
    const good = arr.filter(u => failRate(u) <= 0.4);
    const bad = arr.filter(u => failRate(u) > 0.4);
    return [...good, ...bad];
  };
  return [...rank(rest), ...rank(off)];
}
export function __solanaPublicRpcPoolForTest(): string[] { return solanaPublicRpcPool(); }

// ─────────────── Solana Trader Resolution by Token Movement (ТЗ v4 Block 2) ───────────────
// Solana trades routed through apps use a fee-payer wallet (accountKeys[0]) that may have
// zero token deltas. The REAL trader is the owner whose subject-token balance delta matches
// the trade side and is >= 90% of the measured subject-token volume.
// Resolution: aggregate pre/postTokenBalances by OWNER (not token account), prefer signers.
// Fallback: fee payer (accountKeys[0]) with resolvedBy='fee_payer_fallback'.
export interface SolanaBalanceEntry {
  accountIndex: number;
  mint: string;
  owner: string;
  uiTokenAmount?: { amount?: string; uiAmount?: number | null; decimals?: number };
}
export interface SolanaAccountKey {
  pubkey: string;
  signer?: boolean;
}

export function resolveSolanaTraderFromMeta(
  accountKeys: SolanaAccountKey[],
  preTokenBalances: SolanaBalanceEntry[],
  postTokenBalances: SolanaBalanceEntry[],
  subjectMint: string,
  side: string,
  subjectAmount: number,
  feePayerFallback: string
): { address: string; resolvedBy: string; sideConfirmed: boolean; sideUnconfirmed: 'hop_local' | 'no_data' | null } {
  const signers = new Set<string>();
  for (const k of accountKeys || []) if (k.signer) signers.add(String(k.pubkey));
  const feePayer = (accountKeys && accountKeys.length > 0) ? String(accountKeys[0].pubkey) : feePayerFallback;

  // pre balances by accountIndex (decimal units via uiAmount)
  const uiAmt = (b: SolanaBalanceEntry): number => {
    const u = b?.uiTokenAmount?.uiAmount;
    return (typeof u === 'number' && isFinite(u)) ? u : 0;
  };
  const pre: Record<number, { mint: string; owner: string; amt: number }> = {};
  for (const b of preTokenBalances || []) {
    pre[b.accountIndex] = { mint: b.mint, owner: b.owner, amt: uiAmt(b) };
  }
  // aggregate deltas by owner for subjectMint (decimal units)
  const deltas: Record<string, number> = {};
  for (const b of postTokenBalances || []) {
    if (String(b.mint).toLowerCase() !== subjectMint.toLowerCase()) continue;
    const owner = b.owner;
    const post = uiAmt(b);
    const prem = pre[b.accountIndex];
    let delta = post;
    if (prem && String(prem.mint).toLowerCase() === subjectMint.toLowerCase() && prem.owner === owner) delta = post - prem.amt;
    deltas[owner] = (deltas[owner] || 0) + delta;
  }

  const targetSign = side === 'buy' ? 1 : -1;
  const candidates: Array<{ owner: string; delta: number }> = [];
  for (const [owner, delta] of Object.entries(deltas)) {
    const sign = delta >= 0 ? 1 : -1;
    if (sign !== targetSign) continue;
    if (Math.abs(delta) < 1e-6) continue;
    candidates.push({ owner, delta });
  }

  // Only SIGNER candidates qualify — real traders always sign on Solana.
  // Prefer the signer candidate with the largest delta in the traded token.
  const signerCands = candidates.filter(c => signers.has(c.owner));
  if (signerCands.length > 0) {
    signerCands.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
    return { address: signerCands[0].owner, resolvedBy: 'token_movement', sideConfirmed: true, sideUnconfirmed: null };
  }

  // Fallback 1: any signer with non-zero movement in the subject mint
  const anySignerCand = Object.entries(deltas)
    .filter(([owner, delta]) => signers.has(owner) && Math.abs(delta) > 1e-6)
    .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
  if (anySignerCand.length > 0) {
    // v9.32l/m: hop-local side (DP row) contradicts trader net delta — skip series.
    return { address: anySignerCand[0][0], resolvedBy: 'token_movement', sideConfirmed: false, sideUnconfirmed: 'hop_local' };
  }

  return { address: feePayer, resolvedBy: 'fee_payer_fallback', sideConfirmed: false, sideUnconfirmed: 'no_data' };
}

// R2 (v9.4): pure Solana multi-hop subject selection. Given per-(owner,mint) deltas (decimal units)
// and a trader, pick the TERMINAL subject — the non-quote/non-major mint the trader RECEIVES
// (positive delta), falling back to the largest non-quote/non-major mint the trader SOLD (when the
// terminal leg is a quote/stable). Mirrors the EVM M1 logic for multi-hop (Jupiter/Orca/Meteora/…).
function pickSolanaTerminalSubject(
  deltasByOwnerMint: Record<string, number>,
  trader: string,
): { mint: string; delta: number } {
  const isQuote = (m: string) => STABLE_ADDRS.has(lower(m)) || !!WRAPPED_TO_NATIVE[lower(m)];
  let recvMint = ''; let recvDelta = 0;
  let sentMint = ''; let sentDelta = 0;
  for (const [key, delta] of Object.entries(deltasByOwnerMint)) {
    const [owner, mint] = key.split('|');
    if (owner !== trader || isQuote(mint) || isMajorToken('solana', mint)) continue;
    if (delta > 0 && delta > recvDelta) { recvMint = mint; recvDelta = delta; }
    if (delta < 0 && -delta > sentDelta) { sentMint = mint; sentDelta = delta; }
  }
  if (recvMint) return { mint: recvMint, delta: recvDelta };
  return { mint: sentMint, delta: sentDelta };
}

// v9.32at AT5: alias на объект ядра (тот же приём, что logs/settings).
const solanaValuationSourceCounts: Record<SolanaUsdSource, number> = engineKernel.metrics!.solanaValuationSourceCounts;
let solanaLastKnownUsd = 0;
let lastSolanaValuationSource: SolanaUsdSource = 'unresolved';

function recordSolanaValuationSource(source: SolanaUsdSource): void {
  solanaValuationSourceCounts[source] = (solanaValuationSourceCounts[source] || 0) + 1;
  lastSolanaValuationSource = source;
}

// R2 (v9.4): pure Solana trade valuation (decimal units). Prefer the trader's own quote/stable leg;
// if the trader's deltas contain no quote (multi-hop through pools), value from the largest stable
// leg anywhere in the tx (full trade amount). Returns { usd, solPrice }.
function computeSolanaUsd(
  deltasByOwnerMint: Record<string, number>,
  trader: string,
  subjectMint: string,
  solPrice: number,
  traderSolDelta?: number,
  otherPricedTokens?: Record<string, number>,
  linkedRouteDelta?: { mint: string; amount: number } | null,
  trustedSubjectPrice?: number,
  subjectAmount?: number,
): number {
  const res = computeSolanaUsdDetailed(
    deltasByOwnerMint,
    trader,
    subjectMint,
    solPrice,
    traderSolDelta,
    otherPricedTokens,
    linkedRouteDelta,
    trustedSubjectPrice,
    subjectAmount,
  );
  recordSolanaValuationSource(res.source);
  return res.usd;
}

type SolTxFetch = (url: string, body: unknown) => Promise<{ json: () => Promise<unknown> }>;
let solTxFetchImpl: SolTxFetch | null = null;
export function __setSolTxFetchForTest(fn: SolTxFetch | null): void {
  solTxFetchImpl = fn;
}
let evmTxFetchImpl: SolTxFetch | null = null;
export function __setEvmTxFetchForTest(fn: SolTxFetch | null): void {
  evmTxFetchImpl = fn;
}
// W1-T2: потолок версии — то же значение, что читает solana/pubsub-runtime (см. solana/tx-version).
const SOL_TX_MAX_VERSION = () => solanaMaxSupportedTxVersion(process.env.SOLANA_MAX_SUPPORTED_TX_VERSION);

/** v9.32l: getTransaction with the analyzeSolanaTx multi-RPC pattern (public then Helius). */
const SOL_TX_CACHE_TTL_MS = () => {
  const n = Number(process.env.SOL_TX_CACHE_TTL_MS ?? '120000');
  return Number.isFinite(n) && n > 0 ? n : 120000;
};
const SOL_TX_CACHE_MAX = () => {
  const n = Number(process.env.SOL_TX_CACHE_MAX ?? '2000');
  return Number.isFinite(n) && n > 0 ? n : 2000;
};
const solTxCache = new Map<string, { t: number; tx: any }>();
const solTxCacheStats = { hits: 0, misses: 0 };
let heliusRetryFails = 0;
function solTxCacheGet(txHash: string): any | undefined {
  const e = solTxCache.get(txHash);
  if (!e) { solTxCacheStats.misses++; return undefined; }
  if (Date.now() - e.t > SOL_TX_CACHE_TTL_MS()) { solTxCache.delete(txHash); solTxCacheStats.misses++; return undefined; }
  solTxCache.delete(txHash);
  solTxCache.set(txHash, e);
  solTxCacheStats.hits++;
  return e.tx;
}
function solTxCacheSet(txHash: string, tx: any): void {
  if (!tx || !tx.meta) return; // v9.32aw: не кэшировать отрицательный результат
  if (solTxCache.has(txHash)) solTxCache.delete(txHash);
  solTxCache.set(txHash, { t: Date.now(), tx });
  const max = SOL_TX_CACHE_MAX();
  while (solTxCache.size > max) {
    const oldest = solTxCache.keys().next().value;
    if (oldest === undefined) break;
    solTxCache.delete(oldest);
  }
}
export function __solTxCacheForTest(): { hits: number; misses: number; size: number } {
  return { hits: solTxCacheStats.hits, misses: solTxCacheStats.misses, size: solTxCache.size };
}
export function __resetSolTxCacheForTest(): void {
  solTxCache.clear();
  solTxCacheStats.hits = 0;
  solTxCacheStats.misses = 0;
  heliusRetryFails = 0;
}
export function __heliusRetryFailsForTest(): number { return heliusRetryFails; }

export async function solanaGetTransactionMultiRpc(txHash: string, out?: { reason?: SolTxFetchReason }): Promise<any | null> {
  const cached = solTxCacheGet(txHash);
  if (cached !== undefined) return cached;
  const pubPool = solanaPublicRpcPool();
  const nowTx = Date.now();
  const livePub = pubPool.filter(u => solRpcEp(u).deadUntil <= nowTx);
  // v9.32bm1c-d D2: Helius здесь — резервный источник, и он обязан подчиняться дневному капу и
  // 429-бану уже на этапе сборки списка. Прод 09.09: при degraded:true (кап выбран) helius.analysis
  // +12 за 90 с, +58 за первые 75 с боота — это и есть утечка, которую T2 bm1c (ведро indexed-ветки)
  // закрыть не мог. Публичные узлы — как прежде.
  const heliusOk = !!heliusRpcUrl() && !heliusBudgetExceeded() && !helius429Banned();
  const txRpcSources = [...(livePub.length ? livePub : pubPool), ...(heliusOk ? [heliusRpcUrl()] : [])];
  const body = {
    jsonrpc: '2.0', id: 1, method: 'getTransaction',
    // v9.32bj BJ5: commitment 'confirmed' — как у logsSubscribe/accountSubscribe. Без него
    // нода отвечает по finalized и свежая tx ~13 с «не видна» (замер: null 20/20 при 0 и 5 с).
    params: [txHash, { encoding: 'jsonParsed', maxSupportedTransactionVersion: SOL_TX_MAX_VERSION(), commitment: 'confirmed' }],
  };
  let tx: any = null;
  // W1-T3 (WR4): «нода не отдала расписку» и «нода отбила её из-за НАШЕГО потолка» — разные
  // диагнозы, а выглядели одинаково (receipt_missing). Причина поднимается вызывающему.
  let sawVersionUnsupported = false;
  let sawRateLimited = false;
  let sawRpcError = false;
  for (const rpc of txRpcSources) {
    if (isHeliusUrl(rpc) && (helius429Banned() || helius429GloballyBlocked() || helius429PathBlocked('getTransaction'))) continue;
    if (isHeliusUrl(rpc)) { helius429AcquireGlobalProbe(); helius429PathAcquire('getTransaction'); }
    for (let attempt = 0; attempt < 2; attempt++) {
      try {
        if (isHeliusUrl(rpc)) {
          // v9.32bm1c-d D2: ведро 'reserve' (как индексная ветка резерва), не shared: резервный
          // источник не имеет права вставать в общую очередь ws/warmup-трафика; отказ ведра — не
          // ошибка Helius (heliusRetryFails не растёт), учёт — queuedDropped/gatewayDropped в ведре.
          // Отказ пропускает источник целиком (break, не вторая попытка): иначе одна tx давала два дропа.
          const slot = await acquireHeliusHttpSlot('reserve');
          if (!slot) break;
        }
        let data: any;
        let http429 = false;
        let retryAfterMs = 0;
        if (solTxFetchImpl) {
          const res = await solTxFetchImpl(rpc, body);
          http429 = (res as { status?: number }).status === 429;
          retryAfterMs = http429 ? retryAfterHeaderMs(res as { headers?: { get?: (name: string) => string | null } }) : 0;
          data = http429 ? null : await res.json();
        } else {
          const res = await fetchWithTimeout(rpc, {
            method: 'POST', headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
          }, 8000);
          http429 = res.status === 429;
          retryAfterMs = http429 ? retryAfterHeaderMs(res) : 0;
          data = http429 ? null : await res.json(); // v9.32bk BK2: 429 с текстовым телом не должен падать в catch как «сеть»
        }
        // v9.32aw AW3: CU только за фактически отправленный запрос (после ответа).
        if (isHeliusUrl(rpc)) bumpRpcSpend('helius', 'analysis');
        bumpSolRpcMethod('getTransaction', http429 || isSolRpcRateLimitPayload(data)); // v9.32bk BK2
        if (http429 && isHeliusUrl(rpc)) { helius429MarkPath('getTransaction', retryAfterMs); break; }
        if (!http429 && isHeliusUrl(rpc) && data?.result) helius429PathNoteOk('getTransaction');
        if (isSolRpcTxVersionUnsupported(data)) sawVersionUnsupported = true;
        else if (http429) sawRateLimited = true;
        else if (data?.error) sawRpcError = true;
        tx = data?.result;
        if (tx && tx.meta) {
          solTxCacheSet(txHash, tx);
          if (out) out.reason = 'ok';
          solTxAttempts.note({ sig: txHash, path: 'fallback', attempt: 0, outcome: 'decoded', at: Date.now() }); // W1-T4
          return tx;
        }
      } catch {
        sawRpcError = true; // сетевая/парс-ошибка — не «расписки нет»
        if (isHeliusUrl(rpc)) heliusRetryFails++;
      }
    }
  }
  const reason: SolTxFetchReason = (tx && tx.meta) ? 'ok'
    : sawVersionUnsupported ? 'tx_version_unsupported' : sawRateLimited ? 'rate_limited' : sawRpcError ? 'rpc_error' : 'not_found';
  if (out) out.reason = reason;
  // W1-T4: исход fallback-ветки входит в след подписи — иначе оракул судит «ничего не видно».
  solTxAttempts.note({
    sig: txHash, path: 'fallback', attempt: 0,
    outcome: (reason === 'ok' ? 'decoded' : reason === 'tx_version_unsupported' ? 'unsupported_version'
      : reason === 'rate_limited' ? 'rate_limited' : reason === 'rpc_error' ? 'rpc_error' : 'not_found') as SolAttemptOutcome,
    at: Date.now(),
  });
  return (tx && tx.meta) ? tx : null;
}

async function resolveSolanaTraderByTokenMovement(
  txHash: string,
  subjectMint: string,
  side: string,
  subjectAmount: number,
  feePayerFallback: string
): Promise<{ address: string; resolvedBy: string; sideConfirmed: boolean; sideUnconfirmed: 'hop_local' | 'no_data' | null }> {
  try {
    const tx = await solanaGetTransactionMultiRpc(txHash);
    if (!tx || !tx.meta) return { address: feePayerFallback, resolvedBy: 'fee_payer_fallback', sideConfirmed: false, sideUnconfirmed: 'no_data' };
    return resolveSolanaTraderFromMeta(
      tx.transaction?.message?.accountKeys || [],
      tx.meta.preTokenBalances || [],
      tx.meta.postTokenBalances || [],
      subjectMint, side, subjectAmount, feePayerFallback
    );
  } catch (e) {
    log(`[resolveTrader-sol] RPC error ${shortAddr(txHash)}: ${(e as Error).message}`);
    return { address: feePayerFallback, resolvedBy: 'fee_payer_fallback', sideConfirmed: false, sideUnconfirmed: 'no_data' };
  }
}
export async function __resolveSolTraderForTest(
  txHash: string, subjectMint: string, side: string, subjectAmount: number, feePayerFallback: string,
): Promise<{ address: string; resolvedBy: string }> {
  return resolveSolanaTraderByTokenMovement(txHash, subjectMint, side, subjectAmount, feePayerFallback);
}
export async function __resolveTraderForTest(
  chainId: string, sender: string, txHash: string, rawData?: Record<string, unknown>, subjectMint?: string, side?: string, subjectAmount?: number,
) {
  return resolveTrader(chainId, sender, txHash, rawData, subjectMint, side, subjectAmount);
}
export function __clearTraderResolveCacheForTest(): void {
  traderResolveCache.clear();
}
export function __resetNotifRateForTest(): void {
  notifRate.globalSent1m = [];
  notifRate.chainSent1m = {};
}

async function resolveTrader(chainId: string, sender: string, txHash: string, rawData?: Record<string, unknown>, subjectMint?: string, side?: string, subjectAmount?: number): Promise<ResolvedTrader> {
  const poolSender = sender;
  const cacheKey = `${chainId}:${txHash}`;

  // ─── Solana: resolve by token movement, NOT by fee payer ───
  if (chainId === 'solana') {
    const cached = traderResolveCache.get(cacheKey);
    if (cached) return { address: cached, isResolved: true, poolSender, method: 'token_movement', resolvedBy: 'token_movement', sideConfirmed: true, sideUnconfirmed: null };
    const res = await resolveSolanaTraderByTokenMovement(txHash, subjectMint || '', side || 'buy', subjectAmount || 0, sender);
    // v9.32l: cache ONLY side-confirmed token_movement — a fallback router / hop-local side must not stick.
    if (res.resolvedBy === 'token_movement' && res.sideConfirmed) traderResolveCache.set(cacheKey, res.address);
    if (res.resolvedBy === 'fee_payer_fallback') {
      traderResolveStats.fallback++;
      return { address: res.address, isResolved: false, poolSender, method: 'fee_payer_fallback', resolvedBy: res.resolvedBy, sideConfirmed: false, sideUnconfirmed: res.sideUnconfirmed || 'no_data' };
    }
    traderResolveStats.resolved++;
    traderResolveStats.viaTxFrom++;
    log(`[resolveTrader] Solana resolved ${shortAddr(sender)} → ${shortAddr(res.address)} via ${res.resolvedBy} for tx ${shortAddr(txHash)}`);
    return { address: res.address, isResolved: true, poolSender, method: 'token_movement', resolvedBy: res.resolvedBy, sideConfirmed: !!res.sideConfirmed, sideUnconfirmed: res.sideUnconfirmed ?? null };
  }

  const cached = traderResolveCache.get(cacheKey);
  if (cached) return { address: cached, isResolved: true, poolSender, method: 'tx_from' };

  const senderIsContract = await isContract(chainId, sender);
  if (senderIsContract === false) {
    traderResolveStats.resolved++;
    traderResolveStats.viaSenderEoa++;
    return { address: sender, isResolved: false, poolSender, method: 'sender_eoa' };
  }

  if (rawData?.tx_from_address) {
    const txFromAddr = String(rawData.tx_from_address);
    if (txFromAddr && /^0x[a-fA-F0-9]{40}$/.test(txFromAddr)) {
      traderResolveStats.resolved++;
      traderResolveStats.viaTxFromAddress++;
      traderResolveCache.set(cacheKey, txFromAddr);
      return { address: txFromAddr, isResolved: true, poolSender, method: 'tx_from_address' };
    }
  }

  const attrs = rawData?.attributes as Record<string, unknown> | undefined;
  if (attrs?.tx_from_address) {
    const txFromAddr = String(attrs.tx_from_address);
    if (txFromAddr && /^0x[a-fA-F0-9]{40}$/.test(txFromAddr)) {
      traderResolveStats.resolved++;
      traderResolveStats.viaTxFromAddress++;
      traderResolveCache.set(cacheKey, txFromAddr);
      return { address: txFromAddr, isResolved: true, poolSender, method: 'tx_from_address' };
    }
  }

  if (chainId !== 'solana' && txHash) {
    // v9.32l: multi-RPC like isContract — public → archive fallbacks → Alchemy. 2 attempts each.
    const sources = [RPC_ENDPOINTS[chainId], ...(ARCHIVE_RPC_FALLBACKS[chainId] || []), alchemyHttpUrl(chainId)].filter(Boolean);
    const body = { jsonrpc: '2.0', id: 1, method: 'eth_getTransactionByHash', params: [txHash] };
    const alcRes = alchemyHttpUrl(chainId);
    for (const rpc of sources) {
      if (rpc === alcRes && !(await alchemyCuGate(alchemyCuCost('eth_getTransactionByHash'), { src: 'txByHash' }))) continue; // v9.32al CK1
      recordRpcCall('eth_getTransactionByHash', classifyRpcSource(rpc), 'traderResolve');
      for (let attempt = 0; attempt < 2; attempt++) {
        try {
          let data: any;
          if (evmTxFetchImpl) {
            const res = await evmTxFetchImpl(rpc, body);
            if (rpc === alcRes) noteAlchemyHttpStatus(Number((res as { status?: number }).status ?? 0)); // v9.32al CK5: мок-транспорт без status — no-op
            data = await res.json();
          } else {
            const res = await fetchWithTimeout(rpc, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(body),
            }, 5000);
            data = await res.json();
          }
          const txFrom = data?.result?.from;
          if (txFrom && /^0x[a-fA-F0-9]{40}$/i.test(txFrom)) {
            traderResolveStats.resolved++;
            traderResolveStats.viaTxFrom++;
            traderResolveCache.set(cacheKey, lower(txFrom));
            log(`[resolveTrader] Resolved ${shortAddr(sender)} → ${shortAddr(txFrom)} for tx ${shortAddr(txHash)}`);
            return { address: lower(txFrom), isResolved: true, poolSender, method: 'tx_from' };
          }
        } catch (e) {
          traderResolveStats.fails++;
          log(`[resolveTrader] RPC error for ${chainId}:${shortAddr(txHash)}: ${(e as Error).message}`);
        }
      }
    }
  }

  traderResolveStats.fallback++;
  return { address: sender, isResolved: false, poolSender, method: 'fallback' };
}

// ─────────────── Subject Extraction ───────────────

// ─────────────── noSubject samples ───────────────
// B4.5b follow-up, slice A: sampler moved to analyzeTelemetry (src/lib/evm/analyze-telemetry.ts).
const recordNoSubjectSample = analyzeTelemetry.recordNoSubjectSample;
const getLastNoSubjectSamples = analyzeTelemetry.getLastNoSubjectSamples;

// ─────────────── GeckoTerminal Trade Extraction ───────────────
function extractSubjectFromGeckoTrade(trade: Record<string, unknown>, chainId: string): ExtractedSubject | null {
  const attrs = trade.attributes as Record<string, unknown> | undefined;
  const poolId = String(trade.pool_id || trade.id?.toString().split('_').pop() || '');

  if (!attrs) {
    recordNoSubjectSample(chainId, poolId, '', '', 'no attributes in trade');
    return null;
  }

  const fromAddr = normAddr(chainId, attrs.from_token_address as string || '');
  const toAddr = normAddr(chainId, attrs.to_token_address as string || '');
  const fromAmount = parseFloat(String(attrs.from_token_amount || '0'));
  const toAmount = parseFloat(String(attrs.to_token_amount || '0'));
  const fromPriceUsd = parseFloat(String(attrs.price_from_in_usd || '0'));
  const toPriceUsd = parseFloat(String(attrs.price_to_in_usd || '0'));
  const volumeUsd = parseFloat(String(attrs.volume_in_usd || '0'));
  const trader = String(attrs.tx_from_address || '');
  const txHash = String(attrs.tx_hash || '');

  // ТЗ Bug 1: blockTs STRICTLY from source field block_timestamp (GeckoTerminal v2 API)
  // NOTE: GeckoTerminal /trades endpoint returns block_timestamp (ISO string), NOT block_executed_at
  // Live verified: data[i].attributes.block_timestamp = "2026-08-09T15:04:19Z"
  const blockTimestampRaw = (attrs.block_timestamp ?? attrs.block_executed_at) as string | undefined;
  let blockTs: number | null = null;
  let hasSourceBlockTs = false;

  if (blockTimestampRaw && typeof blockTimestampRaw === 'string' && blockTimestampRaw.length > 0) {
    const parsed = Math.floor(new Date(blockTimestampRaw).getTime() / 1000);
    if (!isNaN(parsed) && parsed > 1000000000) {
      blockTs = parsed;
      hasSourceBlockTs = true;
    }
  }

  // If no blockTs from source, blockTs = null (will be skipped by gate)
  if (!fromAddr || !toAddr) {
    recordNoSubjectSample(chainId, poolId, fromAddr, toAddr, 'missing token');
    return null;
  }

  const fromUsd = (fromAmount * fromPriceUsd) || (volumeUsd * 0.5);
  const toUsd = (toAmount * toPriceUsd) || (volumeUsd * 0.5);
  const amountUsd = volumeUsd || Math.max(fromUsd, toUsd);

  const poolKey = fromAddr + ':' + toAddr;
  const poolInfo = poolInfoCache.get(poolKey);
  let fromSym = '?';
  let toSym = '?';

  if (poolInfo) {
    if (lower(poolInfo.baseToken.address) === fromAddr) {
      fromSym = poolInfo.baseToken.symbol;
      toSym = poolInfo.quoteToken.symbol;
    } else {
      fromSym = poolInfo.quoteToken.symbol;
      toSym = poolInfo.baseToken.symbol;
    }
  }

  if (fromSym === '?') { const n = getWrappedNativeSymbol(fromAddr); if (n) fromSym = n.toUpperCase(); }
  if (toSym === '?') { const n = getWrappedNativeSymbol(toAddr); if (n) toSym = n.toUpperCase(); }
  if (fromSym === '?') {
    const c = tokenMetaCache.get(chainId + ':' + fromAddr);
    if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) fromSym = c.symbol;
  }
  if (toSym === '?') {
    const c = tokenMetaCache.get(chainId + ':' + toAddr);
    if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) toSym = c.symbol;
  }
  if (fromSym === '?') fromSym = resolveSymFromAddr(fromAddr);
  if (toSym === '?') toSym = resolveSymFromAddr(toAddr);

  const subj = determineSubject(fromAddr, fromSym, toAddr, toSym, fromUsd, toUsd);
  if (!subj) {
    recordNoSubjectSample(chainId, poolId, fromAddr, toAddr, 'stable×stable or undetermined', fromSym, toSym);
    return null;
  }

  return {
    subjectAddr: subj.subjectAddr,
    subjectSym: subj.subjectSym,
    side: subj.side,
    amountUsd,
    fromAddr, fromSym, fromAmount,
    toAddr, toSym, toAmount,
    trader, txHash,
    blockTs, hasSourceBlockTs,
    subjectAmount: subj.side === 'buy' ? toAmount : fromAmount,
  };
}

// ─────────────── DexPaprika SSE Extraction ───────────────
function extractSubjectFromDpTransaction(frame: Record<string, unknown>, chainId: string): ExtractedSubject | null {
  const token0 = normAddr(chainId, String(frame.token_0 || ''));
  const token1 = normAddr(chainId, String(frame.token_1 || ''));
  const amount0 = parseFloat(String(frame.amount_0 || '0'));
  const amount1 = parseFloat(String(frame.amount_1 || '0'));
  const amount0Usd = parseFloat(String(frame.amount_0_usd || '0'));
  const amount1Usd = parseFloat(String(frame.amount_1_usd || '0'));
  const volumeUsd = parseFloat(String(frame.volume_usd || '0'));
  const txHash = String(frame.tx_hash || '');
  const sender = String(frame.sender || frame.recipient || '');
  const poolId = String(frame.pool_id || '');

  // ТЗ Bug 1: blockTs STRICTLY from source field block_timestamp
  const blockTimestamp = frame.block_timestamp;
  let blockTs: number | null = null;
  let hasSourceBlockTs = false;

  if (typeof blockTimestamp === 'number' && blockTimestamp > 1000000000) {
    blockTs = blockTimestamp;
    hasSourceBlockTs = true;
  } else if (typeof blockTimestamp === 'string' && blockTimestamp.length > 0) {
    const parsed = Math.floor(new Date(blockTimestamp).getTime() / 1000);
    if (!isNaN(parsed) && parsed > 1000000000) {
      blockTs = parsed;
      hasSourceBlockTs = true;
    }
  }

  if (!token0 || !token1) {
    recordNoSubjectSample(chainId, poolId, token0, token1, 'missing token address');
    return null;
  }

  let fromAddr: string;
  let toAddr: string;
  let fromUsd: number;
  let toUsd: number;
  let fromAmount: number;
  let toAmount: number;

  if (amount0 < 0 && amount1 > 0) {
    fromAddr = token0; toAddr = token1;
    fromUsd = Math.abs(amount0Usd); toUsd = Math.abs(amount1Usd);
    fromAmount = Math.abs(amount0); toAmount = Math.abs(amount1);
  } else if (amount1 < 0 && amount0 > 0) {
    fromAddr = token1; toAddr = token0;
    fromUsd = Math.abs(amount1Usd); toUsd = Math.abs(amount0Usd);
    fromAmount = Math.abs(amount1); toAmount = Math.abs(amount0);
  } else if (amount0 !== 0 && amount1 !== 0) {
    const abs0Usd = Math.abs(amount0Usd);
    const abs1Usd = Math.abs(amount1Usd);
    if (abs0Usd >= abs1Usd) {
      fromAddr = token0; toAddr = token1;
      fromUsd = abs0Usd; toUsd = abs1Usd;
      fromAmount = Math.abs(amount0); toAmount = Math.abs(amount1);
    } else {
      fromAddr = token1; toAddr = token0;
      fromUsd = abs1Usd; toUsd = abs0Usd;
      fromAmount = Math.abs(amount1); toAmount = Math.abs(amount0);
    }
  } else if (amount0 !== 0) {
    fromAddr = token0; toAddr = token1;
    fromUsd = Math.abs(amount0Usd); toUsd = Math.abs(amount1Usd);
    fromAmount = Math.abs(amount0); toAmount = Math.abs(amount1);
  } else if (amount1 !== 0) {
    fromAddr = token1; toAddr = token0;
    fromUsd = Math.abs(amount1Usd); toUsd = Math.abs(amount0Usd);
    fromAmount = Math.abs(amount1); toAmount = Math.abs(amount0);
  } else {
    recordNoSubjectSample(chainId, poolId, token0, token1, 'both amounts zero');
    return null;
  }

  const amountUsd = volumeUsd || Math.max(fromUsd, toUsd) || Math.abs(amount0Usd) + Math.abs(amount1Usd);
  if (amountUsd === 0) return null;

  const poolKey = token0 + ':' + token1;
  const poolInfo = poolInfoCache.get(poolKey);
  let fromSym = '?';
  let toSym = '?';

  if (poolInfo) {
    fromSym = lower(poolInfo.baseToken.address) === fromAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
    toSym = lower(poolInfo.baseToken.address) === toAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
  }

  if (fromSym === '?') { const n = getWrappedNativeSymbol(fromAddr); if (n) fromSym = n.toUpperCase(); }
  if (toSym === '?') { const n = getWrappedNativeSymbol(toAddr); if (n) toSym = n.toUpperCase(); }
  if (fromSym === '?') {
    const c = tokenMetaCache.get(chainId + ':' + fromAddr);
    if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) fromSym = c.symbol;
  }
  if (toSym === '?') {
    const c = tokenMetaCache.get(chainId + ':' + toAddr);
    if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) toSym = c.symbol;
  }

  const subj = determineSubject(fromAddr, fromSym, toAddr, toSym, fromUsd, toUsd);
  if (!subj) {
    recordNoSubjectSample(chainId, poolId, token0, token1, 'stable×stable or undetermined', fromSym, toSym);
    return null;
  }

  return {
    subjectAddr: subj.subjectAddr,
    subjectSym: subj.subjectSym,
    side: subj.side,
    amountUsd,
    fromAddr, fromSym, fromAmount,
    toAddr, toSym, toAmount,
    trader: sender, txHash,
    blockTs, hasSourceBlockTs,
    subjectAmount: subj.side === 'buy' ? toAmount : fromAmount,
  };
}

// ─────────────── Telegram Send ───────────────
// А2: in-process Telegram retry queue. A failed send (any error incl. 'No token/chat', network,
// 429/5xx) is retried at 10s → 30s → 2min → 10min, hard deadline 30min from first attempt.
// Config (token/chat) is re-read on EVERY delivery attempt (never captured at enqueue).
interface TgRetryItem { text: string; chain: string; firstAttemptAt: number; nextAttemptAt: number; attempt: number; lastError?: string; notif?: Notification; }
const tgRetryQueue: TgRetryItem[] = [];
const TG_RETRY_SCHEDULE = [10_000, 30_000, 120_000, 600_000]; // 10s, 30s, 2min, 10min
const TG_RETRY_DEADLINE = 30 * 60 * 1000; // 30 min
let tgRetryQueueTimer: ReturnType<typeof setTimeout> | null = null;
const tgRetryStats = { queued: 0, retriedOk: 0, failed: 0 };

// Re-read config on each delivery attempt (A2). Separate from droppedByRate — retries that are
// rate-limited are re-queued, not counted as dropped.
// v9.32as SD5: Telegram 429 — глобальный backoff по parameters.retry_after.
// До SD5 retry_after игнорировался: при 429-шторме каждый запрос нёсся в стену,
// часть алертов уходила в retry-очередь без соблюдения лимита.
let tgRetryAfterUntil = 0;
export function __tgBackoffRemainingMsForTest(): number { return Math.max(0, tgRetryAfterUntil - Date.now()); }
export async function __tgSendForTest(text: string): Promise<{ ok: boolean; error?: string }> { return tgSend(text); }

async function tgSend(text: string): Promise<{ ok: boolean; error?: string }> {
  const backoffMs = tgRetryAfterUntil - Date.now();
  if (backoffMs > 0) await new Promise(r => setTimeout(r, Math.min(backoffMs, 30000))); // cap 30s — не блокируем движок
  if (!settings.telegramToken || !settings.telegramChatId) return { ok: false, error: 'No token/chat' };
  try {
    const url = `https://api.telegram.org/bot${settings.telegramToken}/sendMessage`;
    const res = await fetchWithTimeout(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        chat_id: settings.telegramChatId,
        text,
        parse_mode: 'HTML',
        disable_web_page_preview: true
      }),
    }, 10000);
    const data = await res.json();
    if (data.ok) {
      state.tgStatus.ok = true;
      state.tgStatus.lastSendAt = Date.now();
      state.tgStatus.sent++;
      state.tgStatus.lastError = null;
      return { ok: true };
    }
    // v9.32as SD5: 429 — читаем parameters.retry_after и ставим глобальный backoff.
    const retryAfterSec = Number(data?.parameters?.retry_after ?? 0);
    if (data?.error_code === 429 && retryAfterSec > 0) {
      tgRetryAfterUntil = Date.now() + retryAfterSec * 1000;
      log(`[tg] 429 — backoff ${retryAfterSec}s до следующей отправки (retry_after)`);
    }
    state.tgStatus.ok = false;
    state.tgStatus.lastError = data.description || 'Send failed';
    return { ok: false, error: state.tgStatus.lastError || undefined };
  } catch (e: unknown) {
    state.tgStatus.ok = false;
    state.tgStatus.lastError = (e as Error).message;
    return { ok: false, error: state.tgStatus.lastError };
  }
}

// А2: enqueue a failed TG message for retry (config re-read per attempt).
function tgRetryEnqueue(item: { text: string; chain: string; error?: string; notif?: Notification }): void {
  tgRetryStats.queued++;
  const now = Date.now();
  tgRetryQueue.push({ text: item.text, chain: item.chain, firstAttemptAt: now, nextAttemptAt: now + TG_RETRY_SCHEDULE[0], attempt: 0, lastError: item.error, notif: item.notif });
  scheduleTgRetry();
}

function scheduleTgRetry(): void {
  if (tgRetryQueueTimer) return;
  tgRetryQueueTimer = setTimeout(() => { void runTgRetries(); }, 500);
  if (typeof tgRetryQueueTimer === 'object' && tgRetryQueueTimer && 'unref' in tgRetryQueueTimer) {
    (tgRetryQueueTimer as NodeJS.Timeout).unref();
  }
}

// Process due retries; re-queue if not past deadline. Rate-limit applies to retries too, but a
// rate-limited retry is re-queued (NOT counted as droppedByRate).
async function runTgRetries(): Promise<void> {
  tgRetryQueueTimer = null;
  if (tgRetryQueue.length === 0) return;
  const now = Date.now();
  const due = tgRetryQueue.filter(i => i.nextAttemptAt <= now);
  if (due.length === 0) { if (tgRetryQueue.length) scheduleTgRetry(); return; }
  for (const item of due) {
    // re-read config on every attempt
    if (!settings.telegramToken || !settings.telegramChatId) {
      item.attempt++;
      item.lastError = 'No token/chat';
    } else if (!checkRateLimit(item.chain).allowed) {
      // rate-limited → re-queue, do NOT count as dropped; keep config re-read next time
    } else {
      const r = await tgSend(item.text);
      if (r.ok) {
        tgRetryStats.retriedOk++;
        if (item.notif) { item.notif.tgOk = true; item.notif.tgError = null; item.notif.tgMessageText = item.text; }
        tgRetryQueue.splice(tgRetryQueue.indexOf(item), 1);
        continue;
      }
      item.attempt++;
      item.lastError = r.error;
    }
    // decide next attempt / deadline
    if (now - item.firstAttemptAt >= TG_RETRY_DEADLINE) {
      tgRetryStats.failed++;
      tgRetryQueue.splice(tgRetryQueue.indexOf(item), 1);
      recordTgRetryFailure(item);
      continue;
    }
    const delay = item.attempt < TG_RETRY_SCHEDULE.length ? TG_RETRY_SCHEDULE[item.attempt] : TG_RETRY_SCHEDULE[TG_RETRY_SCHEDULE.length - 1];
    item.nextAttemptAt = now + delay;
  }
  if (tgRetryQueue.length) scheduleTgRetry();
}

// v9.32ao OD3 (AO4): ретеншн уведомлений — TTL + жёсткий cap.
// Раньше: engine-буфер рос до 200 и никогда не чистился по времени, а фронт рендерил
// всё, что /api/notifications отдавал. Теперь: вставка выметает всё старше
// NOTIFS_RETENTION_HOURS (24) и держит хранилище ≤ NOTIFS_MAX_STORED (500).
const NOTIFS_RETENTION_HOURS = () => Number(process.env.NOTIFS_RETENTION_HOURS || '24');
const NOTIFS_MAX_STORED = () => Number(process.env.NOTIFS_MAX_STORED || '500');
function addNotification(notif: Notification): void {
  const now = Date.now();
  const ttlMs = NOTIFS_RETENTION_HOURS() * 3600_000;
  state.notifs.unshift(notif);
  // TTL выметает и саму вставку, если она старше окна (поздний ретрай и т.п.)
  state.notifs = state.notifs.filter(n => now - n.ts <= ttlMs);
  const cap = NOTIFS_MAX_STORED();
  if (state.notifs.length > cap) state.notifs = state.notifs.slice(0, cap);
}

// А2: record a final failed delivery (so why-diagnosis sees the loss).
function recordTgRetryFailure(item: TgRetryItem): void {
  const notif: Notification = {
    ts: Date.now(), chain: item.chain, side: '?', symbol: '?', token: '', trader: '',
    txHash: '', usd: 0, impact: null, liquidity: 0, fdv: 0, via: null, links: {},
    source: 'tg-retry-failed', tgOk: false, tgError: item.lastError || 'retry deadline exceeded',
    kind: 'solo', tgMessageText: item.text.slice(0, 500),
  };
  addNotification(notif); // v9.32ao AO4: TTL + cap
}

// Test hook for А2 retry scheduling/behavior.
export function __tgRetryStatsForTest(): { queued: number; retriedOk: number; failed: number; queueLen: number } {
  return { ...tgRetryStats, queueLen: tgRetryQueue.length };
}
export function __tgRetryDrainForTest(): void {
  tgRetryQueue.length = 0;
}
// А2 test hook: enqueue a retry directly and force process all DUE retries (deterministic).
export function __tgRetryEnqueueForTest(text: string, chain: string): void {
  tgRetryStats.queued++;
  const now = Date.now();
  tgRetryQueue.push({ text, chain, firstAttemptAt: now, nextAttemptAt: now, attempt: 0, lastError: 'test' });
}
export async function __tgRunRetriesForTest(): Promise<void> {
  await runTgRetries();
}
// expose schedule for assertion
export function __tgRetryScheduleForTest(): number[] {
  return [...TG_RETRY_SCHEDULE];
}
export function __tgRetryDeadlineForTest(): number {
  return TG_RETRY_DEADLINE;
}
export function __tgRetryForceDueForTest(): void {
  for (const i of tgRetryQueue) i.nextAttemptAt = 0;
}
// Test hooks for v9: build a solo card with a custom quoteBalance, and pick anchor subject.
export function __buildSoloTextForTest(n: Notification): string {
  return buildSoloText(n);
}
export function __buildSeriesTextForTest(n: Notification): string {
  return buildSeriesText(n);
}
export function __pickAnchorSubjectForTest(chainId: string, base: { address: string; symbol: string }, quote: { address: string; symbol: string }): { address: string; symbol: string } {
  return pickAnchorSubject(chainId, base, quote);
}
export function __setCexAddressForTest(chainId: string, addr: string, symbol: string): void {
  cexCache.byAddress[chainId + ':' + lower(addr)] = { id: symbol.toLowerCase(), symbol: lower(symbol), rank: 0, addresses: { [chainId]: addr }, cexSources: ['test'] };
  cexCache.updatedAt = Date.now();
}
export function __clearCexForTest(): void { cexCache.byAddress = {}; cexCache.updatedAt = Date.now(); }
export function __resetCexCacheForTest(): void {
  cexCache.byAddress = {};
  cexCache.bySymbol = {};
  cexCache.updatedAt = 0;
  cexCache.tokenCount = 0;
  cexCache.chainsWithAddresses = 0;
  for (const k of Object.keys(cexSourceStats)) cexSourceStats[k] = 0;
}
type CexHttpFn = (url: string) => Promise<FetchResult<any>>;
let cexHttpImpl: CexHttpFn | null = null;
export function __setCexHttpForTest(fn: CexHttpFn | null): void { cexHttpImpl = fn; }
export async function __refreshCexCacheForTest(force = false): Promise<void> { await refreshCexCache(force); }

// ─────────────── Liquidity Info ───────────────
// BUG A FIX: Always use the wrapped token address for enrichment — never redirect to native symbol.
// Wrapped tokens (WETH, WBNB, WPOL, wSOL) have their own pools in GT/DS and must be queried by address.
// BUG B FIX: Use DexScreener real per-side reserves (liquidity.base * priceUsd, liquidity.quote * quotePrice)
//            instead of fabricated 50/50 split. Never compute one side as total - other.

// bm2r R5: прямой доступ к liquidity-пути — бэкофф шлюза должен гасить его без сети.
export function __fetchLiquidityForTest(chainId: string, tokenAddr: string): Promise<LiquidityInfo | null> {
  return fetchLiquidityFromDexScreener(chainId, tokenAddr);
}
async function fetchLiquidityFromDexScreener(chainId: string, tokenAddr: string): Promise<LiquidityInfo | null> {
  const dsChain = CHAINS[chainId]?.ds;
  if (!dsChain) return null;
  try {
    // bm2r D5: через единый шлюз (лейн liquidity — приоритет для алертов/сделок).
    const res = await dsFetch<{
      pairs?: Array<{
        chainId?: string;
        baseToken?: { address?: string; symbol?: string };
        quoteToken?: { address?: string; symbol?: string };
        priceUsd?: string;
        liquidity?: { usd?: number; base?: number; quote?: number };
      }>;
    }>('liquidity', `https://api.dexscreener.com/latest/dex/tokens/${tokenAddr}`);

    if (!res.ok || !res.data?.pairs?.length) return null;

    const lowerToken = lower(tokenAddr);
    // Only take pairs on this chain
    const chainPairs = res.data.pairs.filter(p => {
      const pChain = (p.chainId || '').toLowerCase();
      return pChain === dsChain || pChain === chainId;
    });
    if (chainPairs.length === 0) return null;

    let totalUsd = 0;
    let subjectAmount = 0;
    let subjectUsd = 0;
    let quoteUsd = 0;
    const poolsCount = chainPairs.length;

    for (const pair of chainPairs.slice(0, 10)) {
      const liq = pair.liquidity;
      if (!liq || !liq.usd || liq.usd <= 0) continue;

      totalUsd += liq.usd;
      const isBase = lower(pair.baseToken?.address || '') === lowerToken;
      const priceUsd = parseFloat(pair.priceUsd || '0') || 0;

      if (isBase) {
        // Real reserves: base = liq.base tokens, quote = liq.quote tokens
        const baseQty = liq.base || 0;
        const baseUsdVal = priceUsd > 0 && baseQty > 0 ? baseQty * priceUsd : liq.usd * 0.5;
        const quoteUsdVal = liq.usd - baseUsdVal;
        subjectAmount += baseQty;
        subjectUsd += baseUsdVal;
        quoteUsd += quoteUsdVal;
      } else {
        // Subject is quote token
        const quoteQty = liq.quote || 0;
        // quotePrice = liq.usd / (liq.base * basePrice + liq.quote) — but we can derive:
        // quoteUsdVal + baseUsdVal = liq.usd; quoteUsdVal = quoteQty * quotePrice
        // For Uniswap-style: quotePrice = 1/priceNative * priceUsd, but we can estimate:
        const quoteUsdVal = liq.quote && liq.base && priceUsd > 0
          ? liq.usd - (liq.base * priceUsd)
          : liq.usd * 0.5;
        subjectAmount += quoteQty;
        subjectUsd += Math.max(0, quoteUsdVal);
        quoteUsd += liq.usd - Math.max(0, quoteUsdVal);
      }
    }

    if (totalUsd <= 0) return null;

    // Sanity: ensure subjectUsd and quoteUsd are non-negative and consistent
    if (subjectUsd < 0) subjectUsd = 0;
    if (quoteUsd < 0) quoteUsd = 0;

    const info: LiquidityInfo = { totalUsd, poolsCount, subjectAmount, subjectUsd, quoteUsd, cachedAt: Date.now() };
    log(`[liquidity] DexScreener OK for ${shortAddr(tokenAddr)}: totalUsd=${totalUsd.toFixed(0)}, pools=${poolsCount}, subjectUsd=${subjectUsd.toFixed(0)}, quoteUsd=${quoteUsd.toFixed(0)}`);
    return info;
  } catch (e) {
    log(`[liquidity] DexScreener error for ${shortAddr(tokenAddr)}: ${(e as Error).message}`);
    return null;
  }
}

async function fetchLiquidityInfo(chainId: string, tokenAddr: string): Promise<LiquidityInfo | null> {
  const cacheKey = `${chainId}:${lower(tokenAddr)}`;
  const cached = liquidityCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < LIQUIDITY_CACHE_TTL) return cached;

  // Primary source: DexScreener (accurate, real multi-pool liquidity and token prices)
  const dsInfo = await fetchLiquidityFromDexScreener(chainId, tokenAddr);
  if (dsInfo && dsInfo.totalUsd > 0) {
    liquidityCache.set(cacheKey, dsInfo);
    return dsInfo;
  }

  const gecko = CHAINS[chainId]?.gecko;
  if (gecko) {
    try {
      const geckoResult = await geckoGetForEnrich<{
        data?: Array<{
          attributes?: {
            reserve_in_usd?: string | number;
            base_token_price_usd?: string | number;
            quote_token_price_usd?: string | number;
            fdv_usd?: string | number;
          };
          relationships?: Record<string, { data?: { id?: string } }>;
        }>;
      }>(
        `/networks/${gecko}/tokens/${lower(tokenAddr)}/pools?page=1`
      );
      log(`[liquidity-gecko] ${chainId} HTTP=${geckoResult.status} err=${geckoResult.error ? 'yes' : 'none'} pools=${geckoResult.data?.data?.length ?? 'null'}`);

      if (!geckoResult.error && geckoResult.data?.data && geckoResult.data.data.length > 0) {
        const gtPools = geckoResult.data.data;
        const lowerToken = lower(tokenAddr);
        let totalUsd = 0;
        const poolsCount = gtPools.length;
        let subjectAmount = 0;
        let subjectUsd = 0;
        let quoteUsd = 0;

        for (const pool of gtPools.slice(0, 10)) {
          const attrs = pool.attributes || {};
          const liq = parseFloat(String(attrs.reserve_in_usd || '0'));
          if (isNaN(liq) || liq <= 0) continue;
          totalUsd += liq;

          const baseTokenId = pool.relationships?.base_token?.data?.id || '';
          const baseAddrRaw = baseTokenId.includes('_') ? baseTokenId.split('_').slice(1).join('_') : baseTokenId;
          const baseAddr = lower(baseAddrRaw);
          const isBase = baseAddr === lowerToken;

          const basePrice = parseFloat(String(attrs.base_token_price_usd || '0')) || 0;
          const quotePrice = parseFloat(String(attrs.quote_token_price_usd || '0')) || 0;

          let subjectFrac: number;
          if (isBase) {
            subjectFrac = (basePrice > 0 && quotePrice > 0) ? basePrice / (basePrice + quotePrice) : 0.5;
            const subjectUsdPool = liq * subjectFrac;
            const quoteUsdPool = liq - subjectUsdPool;
            const qty = basePrice > 0 ? subjectUsdPool / basePrice : 0;
            subjectAmount += qty;
            subjectUsd += subjectUsdPool;
            quoteUsd += quoteUsdPool;
          } else {
            subjectFrac = (basePrice > 0 && quotePrice > 0) ? quotePrice / (basePrice + quotePrice) : 0.5;
            const subjectUsdPool = liq * subjectFrac;
            const quoteUsdPool = liq - subjectUsdPool;
            const qty = quotePrice > 0 ? subjectUsdPool / quotePrice : 0;
            subjectAmount += qty;
            subjectUsd += subjectUsdPool;
            quoteUsd += quoteUsdPool;
          }
        }

        if (totalUsd > 0) {
          const info: LiquidityInfo = { totalUsd, poolsCount, subjectAmount, subjectUsd, quoteUsd, cachedAt: Date.now() };
          liquidityCache.set(cacheKey, info);
          log(`[liquidity] GeckoTerminal OK for ${shortAddr(tokenAddr)}: totalUsd=${totalUsd.toFixed(0)}, pools=${poolsCount}, subjectUsd=${subjectUsd.toFixed(0)}, quoteUsd=${quoteUsd.toFixed(0)}`);
          return info;
        }
      }
    } catch (e) {
      const errWithCode = e instanceof Error ? e.message : String(e);
      log(`[liquidity] GeckoTerminal error for ${shortAddr(tokenAddr)}: ${errWithCode}`);
    }
  }

  // Fallback: DexPaprika
  const dpNet = CHAINS[chainId]?.dpNetwork;
  const dpKey = getDexPaprikaApiKey();
  if (dpNet && dpKey) {
    try {
      const fetchOpts: RequestInit = dpKey ? { headers: { 'Authorization': `Bearer ${dpKey}` } } : {};
      const res = await getJSON<{
        summary?: { liquidity?: number; liquidity_usd?: number; price_usd?: number; pools?: number };
        pools?: Array<{ token_0?: { amount?: number; amount_usd?: number }; token_1?: { amount?: number; amount_usd?: number } }>;
        price_usd?: number;
        total_supply?: number;
      }>(
        `https://api.dexpaprika.com/networks/${dpNet}/tokens/${lower(tokenAddr)}`,
        fetchOpts,
        'tm_liquidity' // bm2f: по URL неотличимо от трёх других функций — источник только явно
      );
      if (res.ok && res.data) {
        const d = res.data;
        const totalUsd = d.summary?.liquidity_usd ?? d.summary?.liquidity ?? 0;
        const pools = Array.isArray(d.pools) ? d.pools : [];
        const poolsCount = typeof d.summary?.pools === 'number' ? d.summary.pools : (pools.length || 1);
        let subjectAmount = 0;
        let subjectUsd = 0;
        let quoteUsd = 0;
        const priceUsd = d.summary?.price_usd ?? d.price_usd ?? 0;

        for (const pool of pools.slice(0, 10)) {
          const t0usd = pool.token_0?.amount_usd || 0;
          const t1usd = pool.token_1?.amount_usd || 0;
          if (t0usd >= t1usd) {
            subjectAmount += pool.token_0?.amount || 0;
            subjectUsd += t0usd;
            quoteUsd += t1usd;
          } else {
            subjectAmount += pool.token_1?.amount || 0;
            subjectUsd += t1usd;
            quoteUsd += t0usd;
          }
        }

        if (totalUsd > 0) {
          if (subjectUsd === 0) {
            subjectUsd = totalUsd * 0.5;
            quoteUsd = totalUsd * 0.5;
            if (priceUsd > 0) subjectAmount = subjectUsd / priceUsd;
          }
          const info: LiquidityInfo = { totalUsd, poolsCount, subjectAmount, subjectUsd, quoteUsd, cachedAt: Date.now() };
          liquidityCache.set(cacheKey, info);
          log(`[liquidity] DexPaprika OK for ${shortAddr(tokenAddr)}: totalUsd=${totalUsd}, pools=${poolsCount}`);
          return info;
        }
      }
    } catch (e) {
      log(`[liquidity] DexPaprika error for ${shortAddr(tokenAddr)}: ${(e as Error).message}`);
    }
  }

  return null;
}

// ─────────────── Position Info ───────────────
async function fetchPositionInfo(chainId: string, tokenAddr: string, traderAddr: string, supplyFromCache?: number): Promise<PositionInfo | null> {
  if (!traderAddr || traderAddr.length < 10) return null;
  if (chainId === 'solana') return null;

  const cacheKey = `${chainId}:${lower(tokenAddr)}:${lower(traderAddr)}`;
  const cached = positionCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < POSITION_CACHE_TTL) return cached;

  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return null;

  try {
    // ERC20 balanceOf call
    const balData = '0x70a08231' + traderAddr.replace('0x', '').padStart(64, '0');
    const balRes = await fetchWithTimeout(rpc, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: tokenAddr, data: balData }, 'latest'] }),
    }, 5000);
    const balJson = await balRes.json();
    const balHex = balJson?.result as string | undefined;
    // БАГ 2 FIX: Return {0,0} for zero balance instead of null — null is reserved for errors
    if (!balHex || balHex === '0x' || balHex === '0x0') {
      const zeroInfo: PositionInfo = { balanceUsd: 0, pctSupply: 0, cachedAt: Date.now() };
      positionCache.set(cacheKey, zeroInfo);
      return zeroInfo;
    }
    const balRawBig = BigInt(balHex);
    if (balRawBig === BigInt(0)) {
      const zeroInfo: PositionInfo = { balanceUsd: 0, pctSupply: 0, cachedAt: Date.now() };
      positionCache.set(cacheKey, zeroInfo);
      return zeroInfo;
    }

    // Fetch token decimals (decimals() = 0x313ce567)
    let decimals = 18;
    try {
      const decRes = await fetchWithTimeout(rpc, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'eth_call', params: [{ to: tokenAddr, data: '0x313ce567' }, 'latest'] }),
      }, 5000);
      const decJson = await decRes.json();
      const decHex = decJson?.result as string | undefined;
      if (decHex && decHex !== '0x') {
        const decVal = parseInt(decHex, 16);
        if (!isNaN(decVal) && decVal >= 0 && decVal <= 18) decimals = decVal;
      }
    } catch { /* use default 18 */ }

    // ERC20 totalSupply call
    const supplyData = '0x18160ddd';
    const supplyRes = await fetchWithTimeout(rpc, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'eth_call', params: [{ to: tokenAddr, data: supplyData }, 'latest'] }),
    }, 5000);
    const supplyJson = await supplyRes.json();
    const supplyHex = supplyJson?.result as string | undefined;
    const supRawBig = supplyHex && supplyHex !== '0x' && supplyHex !== '0x0' ? BigInt(supplyHex) : BigInt(0);

    const divisor = BigInt(10 ** decimals);
    const balNorm = Number(balRawBig) / Number(divisor);
    const supNorm = supRawBig > BigInt(0) ? Number(supRawBig) / Number(divisor) : 0;

    let pctSupply = 0;
    if (supNorm > 0) pctSupply = (balNorm / supNorm) * 100;
    else if (supplyFromCache && supplyFromCache > 0) pctSupply = (balNorm / supplyFromCache) * 100;

    let price = 0;
    const liqCacheKey = `${chainId}:${lower(tokenAddr)}`;
    const liqCached = liquidityCache.get(liqCacheKey);
    if (liqCached && liqCached.subjectAmount > 0 && liqCached.subjectUsd > 0) {
      price = liqCached.subjectUsd / liqCached.subjectAmount;
    }
    if (!(price > 0)) {
      try {
        const meta = await fetchEvmTokenMeta(chainId, tokenAddr);
        if (meta.priceUsd > 0) price = meta.priceUsd;
      } catch { /* ignore */ }
    }
    if (!(price > 0)) {
      try {
        const ds = await fetchLiquidityFromDexScreener(chainId, tokenAddr);
        if (ds && ds.subjectAmount > 0 && ds.subjectUsd > 0) {
          price = ds.subjectUsd / ds.subjectAmount;
        }
      } catch { /* ignore */ }
    }

    const balanceUsd = balNorm * price;

    const info: PositionInfo = { balanceUsd, pctSupply, cachedAt: Date.now() };
    positionCache.set(cacheKey, info);
    return info;
  } catch (e) {
    log(`[position] Error for ${shortAddr(traderAddr)}: ${(e as Error).message}`);
    return null;
  }
}

// v9.11 F3: full wallet balances at trade time. Queries ERC20 balanceOf for the subject token plus
// the chain's known quote/stable tokens, values each in USD (from liquidity cache or a price fetch),
// and returns the top-N by USD. Empty array on any failure (line simply omitted from the card).
async function fetchWalletBalances(
  chainId: string,
  traderAddr: string,
  subjectToken: string,
  subjectSymbol: string,
): Promise<Array<{ address: string; symbol: string; amount: number; usd: number }>> {
  if (chainId === 'solana' || !traderAddr || traderAddr.length < 10) return [];
  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return [];

  const priceOf = async (tokenAddr: string): Promise<number> => {
    const liqCacheKey = `${chainId}:${lower(tokenAddr)}`;
    const liqCached = liquidityCache.get(liqCacheKey);
    if (liqCached && liqCached.subjectAmount > 0 && liqCached.subjectUsd > 0) {
      return liqCached.subjectUsd / liqCached.subjectAmount;
    }
    try { return (await fetchEvmTokenMeta(chainId, tokenAddr)).priceUsd; } catch { return 0; }
  };

  const balanceOf = async (tokenAddr: string): Promise<number> => {
    try {
      const balData = '0x70a08231' + traderAddr.replace('0x', '').padStart(64, '0');
      const res = await fetchWithTimeout(rpc, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: tokenAddr, data: balData }, 'latest'] }),
      }, 5000);
      const hex = (await res.json())?.result as string | undefined;
      if (!hex || hex === '0x' || hex === '0x0') return 0;
      const dec = await getEvmTokenDecimals(chainId, tokenAddr);
      return Number(BigInt(hex)) / Math.pow(10, dec);
    } catch { return 0; }
  };

  const tokens = new Set<string>([lower(subjectToken)]);
  for (const q of (KNOWN_QUOTE_TOKENS[chainId] || [])) tokens.add(lower(q));

  const results: Array<{ address: string; symbol: string; amount: number; usd: number }> = [];
  for (const tok of tokens) {
    try {
      const amount = await balanceOf(tok);
      if (!(amount > 0)) continue;
      const price = await priceOf(tok);
      const usd = amount * price;
      if (!(usd > 0)) continue;
      const sym = lower(tok) === lower(subjectToken)
        ? (subjectSymbol !== '?' ? subjectSymbol : resolveSymFromAddr(tok))
        : (resolveSymFromAddr(tok) || '?');
      results.push({ address: tok, symbol: sym, amount, usd });
    } catch { /* skip token */ }
  }
  results.sort((a, b) => b.usd - a.usd);
  return results.slice(0, 5); // top-5 by USD
}

// ─────────────── CEX Cache ───────────────
function cexGetJSON(url: string): Promise<FetchResult<any>> {
  if (cexHttpImpl) return cexHttpImpl(url);
  return getJSON<any>(url);
}
function isEvmContractAddr(addr: string): boolean {
  return /^0x[0-9a-fA-F]{40}$/.test(addr);
}
function gateChainToId(chain: string): string | undefined {
  const raw = String(chain || '').toUpperCase().replace(/[\s_-]+/g, ' ').trim();
  const compact = raw.replace(/\s+/g, '');
  const m: Record<string, string> = {
    ETH: 'ethereum', ETHEREUM: 'ethereum', ERC20: 'ethereum', ETHEREUMMAINNET: 'ethereum',
    BSC: 'bsc', BNB: 'bsc', BEP20: 'bsc', BNBSMARTCHAIN: 'bsc', 'BNB SMART CHAIN': 'bsc', BINANCE: 'bsc',
    BASE: 'base', BASEMAINNET: 'base',
    ARB: 'arbitrum', ARBITRUM: 'arbitrum', ARBITRUMONE: 'arbitrum', ARBITRUMONEETH: 'arbitrum',
    POLYGON: 'polygon', MATIC: 'polygon', POL: 'polygon', POLYGONPOS: 'polygon',
    SOL: 'solana', SOLANA: 'solana', SPL: 'solana',
    AVAX: 'avalanche', AVALANCHE: 'avalanche', CCHAIN: 'avalanche', AVAXC: 'avalanche',
    OP: 'optimism', OPTIMISM: 'optimism', OPETH: 'optimism',
    KCC: 'kcc',
  };
  return m[raw] || m[compact];
}
const GECKO_CEX_TICKER_IDS: Record<string, string> = {
  binance: 'binance',
  binance_us: 'binance_us',
  coinbase: 'gdax',
  okx: 'okx',
  kraken: 'kraken',
  cryptocom: 'crypto_com',
  upbit: 'upbit',
  bitfinex: 'bitfinex',
  gemini: 'gemini',
  bitstamp: 'bitstamp',
  bithumb: 'bithumb',
};
async function refreshCexCache(force = false): Promise<void> {
  if (!force && Date.now() - cexCache.updatedAt < CEX_SOURCE_REFRESH_MS()) return;
  const sources = CEX_SOURCES();
  const newByAddress: Record<string, CexTokenEntry> = {};
  const newBySymbol: Record<string, CexTokenEntry> = {};
  const byGeckoId: Record<string, CexTokenEntry> = {};
  let tokenCount = 0;
  let addressCount = 0;
  let exchangeHits = 0;
  const stats: Record<string, number> = { coingeckoOnly: 0, noAddress: 0 };

  const bump = (src: string, n = 1) => { stats[src] = (stats[src] || 0) + n; };

  const upsert = (src: string, symbol: string, addr?: string, chainId?: string) => {
    const sym = lower(String(symbol || ''));
    if (!sym) return;
    let entry = newBySymbol[sym];
    if (!entry) {
      entry = { id: sym, symbol: sym, rank: 0, addresses: {}, cexSources: [] };
      newBySymbol[sym] = entry;
      tokenCount++;
    }
    if (!entry.cexSources.includes(src)) entry.cexSources.push(src);
    const a = String(addr || '').trim();
    if (!a) { if (src !== 'coingecko') stats.noAddress++; return; }
    const keyChain = chainId || (isEvmContractAddr(a) ? 'all' : '');
    if (!keyChain) { if (src !== 'coingecko') stats.noAddress++; return; }
    const addrKey = keyChain + ':' + lower(a);
    if (chainId) entry.addresses[chainId] = a;
    const prev = newByAddress[addrKey];
    if (!prev || (src !== 'coingecko' && !cexEntryHasExchange(prev))) newByAddress[addrKey] = entry;
    else if (src !== 'coingecko') newByAddress[addrKey] = entry;
    addressCount++;
    if (src !== 'coingecko') {
      exchangeHits++;
      bump(src);
    }
  };

  const ingestChains = (src: string, symbol: string, chains: Array<{ chain?: string; addr?: string }>) => {
    if (!chains.length) { upsert(src, symbol); return; }
    for (const ch of chains) upsert(src, symbol, ch.addr, gateChainToId(ch.chain || ''));
  };

  const pickAddr = (o: Record<string, unknown> | undefined): string => {
    if (!o) return '';
    for (const k of ['contractAddress', 'contract_address', 'contractAddr', 'addr', 'address', 'tokenAddress', 'token_address']) {
      const v = o[k];
      if (typeof v === 'string' && v.trim()) return v.trim();
    }
    return '';
  };

  if (sources.includes('mexc')) {
    try {
      const res = await cexGetJSON('https://api.mexc.com/api/v3/exchangeInfo');
      const symbols = (res.ok && res.data && (res.data as { symbols?: unknown[] }).symbols) || [];
      for (const raw of symbols) {
        const s = raw as { symbol?: string; status?: string | number; isSpotTradingAllowed?: boolean; baseAsset?: string; contractAddress?: string };
        if (s.isSpotTradingAllowed === false) continue;
        const st = String(s.status ?? '1');
        if (st !== '1' && st.toUpperCase() !== 'ENABLED' && st.toUpperCase() !== 'TRADING') continue;
        upsert('mexc', s.baseAsset || s.symbol || '', s.contractAddress);
      }
    } catch (e: unknown) {
      log(`[cex] mexc: ${(e as Error).message}`);
    }
  }
  if (sources.includes('gate')) {
    try {
      const res = await cexGetJSON('https://api.gateio.ws/api/v4/spot/currencies');
      const list = (res.ok && Array.isArray(res.data)) ? res.data as Array<{ currency?: string; delisted?: boolean; trade_disabled?: boolean; addr?: string; chain?: string; chains?: Array<{ name?: string; chain?: string; addr?: string; address?: string }> }> : [];
      for (const c of list) {
        if (c.delisted || c.trade_disabled) continue;
        const chains = c.chains || [];
        if (chains.length) {
          for (const ch of chains) upsert('gate', c.currency || '', ch.addr || ch.address, gateChainToId(ch.name || ch.chain || ''));
        } else {
          upsert('gate', c.currency || '', c.addr, gateChainToId(c.chain || ''));
        }
      }
    } catch (e: unknown) {
      log(`[cex] gate: ${(e as Error).message}`);
    }
  }
  if (sources.includes('bybit')) {
    try {
      const res = await cexGetJSON('https://api.bybit.com/v5/market/instruments-info?category=spot');
      const list = (res.ok && res.data && (res.data as { result?: { list?: unknown[] } }).result?.list) || [];
      for (const raw of list) {
        const s = raw as { status?: string; baseCoin?: string; symbol?: string; contractAddress?: string; baseToken?: string };
        if (s.status && String(s.status).toUpperCase() !== 'TRADING') continue;
        upsert('bybit', s.baseCoin || s.symbol || '', s.contractAddress || s.baseToken);
      }
    } catch (e: unknown) {
      log(`[cex] bybit: ${(e as Error).message}`);
    }
  }
  if (sources.includes('kucoin')) {
    try {
      const res = await cexGetJSON('https://api.kucoin.com/api/v3/currencies');
      const list = (res.ok && res.data && (res.data as { data?: unknown[] }).data) || (Array.isArray(res.data) ? res.data : []);
      for (const raw of list) {
        const c = raw as { currency?: string; isWithdrawEnabled?: boolean; chains?: Array<Record<string, unknown>> };
        const chains = (c.chains || []).map(ch => ({ chain: String(ch.chainName || ch.chainId || ch.chain || ''), addr: pickAddr(ch) }));
        ingestChains('kucoin', c.currency || '', chains);
      }
    } catch (e: unknown) {
      log(`[cex] kucoin: ${(e as Error).message}`);
    }
  }
  if (sources.includes('bitget')) {
    try {
      const res = await cexGetJSON('https://api.bitget.com/api/v2/spot/public/coins');
      const list = (res.ok && res.data && (res.data as { data?: unknown[] }).data) || [];
      for (const raw of list) {
        const c = raw as { coin?: string; chains?: Array<Record<string, unknown>> };
        const chains = (c.chains || []).map(ch => ({ chain: String(ch.chain || ch.chainName || ''), addr: pickAddr(ch) }));
        ingestChains('bitget', c.coin || '', chains);
      }
    } catch (e: unknown) {
      log(`[cex] bitget: ${(e as Error).message}`);
    }
  }
  if (sources.includes('htx')) {
    try {
      const res = await cexGetJSON('https://api.huobi.pro/v2/reference/currencies');
      const list = (res.ok && res.data && (res.data as { data?: unknown[] }).data) || [];
      for (const raw of list) {
        const c = raw as { currency?: string; instStatus?: string; chains?: Array<Record<string, unknown>> };
        if (c.instStatus && String(c.instStatus).toLowerCase() === 'delisted') continue;
        const chains = (c.chains || []).map(ch => ({ chain: String(ch.baseChain || ch.chain || ch.fullName || ''), addr: pickAddr(ch) }));
        ingestChains('htx', c.currency || '', chains);
      }
    } catch (e: unknown) {
      log(`[cex] htx: ${(e as Error).message}`);
    }
  }
  if (sources.includes('whitebit')) {
    try {
      const res = await cexGetJSON('https://whitebit.com/api/v4/public/assets');
      const obj = (res.ok && res.data && typeof res.data === 'object' && !Array.isArray(res.data)) ? res.data as Record<string, { name?: string; networks?: Record<string, Record<string, unknown>> }> : {};
      for (const [sym, info] of Object.entries(obj)) {
        const nets = info.networks || {};
        const chains = Object.entries(nets).map(([net, v]) => ({ chain: net, addr: pickAddr(v) }));
        ingestChains('whitebit', info.name || sym, chains);
      }
    } catch (e: unknown) {
      log(`[cex] whitebit: ${(e as Error).message}`);
    }
  }
  if (sources.includes('bingx')) {
    try {
      const res = await cexGetJSON('https://open-api.bingx.com/openApi/wallets/v1/capital/config/getall');
      const list = (res.ok && res.data && ((res.data as { data?: unknown[] }).data || (res.data as { coins?: unknown[] }).coins)) || [];
      for (const raw of list) {
        const c = raw as { coin?: string; name?: string; networkList?: Array<Record<string, unknown>>; chains?: Array<Record<string, unknown>> };
        const nets = c.networkList || c.chains || [];
        ingestChains('bingx', c.coin || c.name || '', nets.map(ch => ({ chain: String(ch.network || ch.chain || ch.name || ''), addr: pickAddr(ch) })));
      }
    } catch (e: unknown) {
      log(`[cex] bingx: ${(e as Error).message}`);
    }
  }
  if (sources.includes('bitmart')) {
    try {
      const res = await cexGetJSON('https://api-cloud.bitmart.com/account/v1/currencies');
      const list = (res.ok && res.data && (res.data as { data?: { currencies?: unknown[] } }).data?.currencies) || (res.ok && Array.isArray((res.data as { data?: unknown[] })?.data) ? (res.data as { data: unknown[] }).data : []);
      for (const raw of list) {
        const c = raw as Record<string, unknown>;
        upsert('bitmart', String(c.currency || c.coin || c.id || ''), pickAddr(c), gateChainToId(String(c.network || c.chain || '')));
      }
    } catch (e: unknown) {
      log(`[cex] bitmart: ${(e as Error).message}`);
    }
  }
  if (sources.includes('xt')) {
    try {
      const res = await cexGetJSON('https://sapi.xt.com/v4/public/wallet/support/currency');
      const list = (res.ok && res.data && (res.data as { result?: unknown[] }).result) || (Array.isArray(res.data) ? res.data : []);
      for (const raw of list) {
        const c = raw as Record<string, unknown>;
        const chains = Array.isArray(c.chains) ? (c.chains as Array<Record<string, unknown>>) : [c];
        ingestChains('xt', String(c.currency || c.coin || ''), chains.map(ch => ({ chain: String(ch.chain || ch.network || ''), addr: pickAddr(ch) })));
      }
    } catch (e: unknown) {
      log(`[cex] xt: ${(e as Error).message}`);
    }
  }
  if (sources.includes('coinex')) {
    try {
      const res = await cexGetJSON('https://api.coinex.com/v1/common/asset/config');
      const obj = (res.ok && res.data && (res.data as { data?: Record<string, Record<string, unknown>> }).data) || {};
      for (const [sym, rawInfo] of Object.entries(obj)) {
        const info = rawInfo as Record<string, unknown>;
        upsert('coinex', sym, pickAddr(info), gateChainToId(String(info.chain || info.network || '')));
      }
    } catch (e: unknown) {
      log(`[cex] coinex: ${(e as Error).message}`);
    }
  }
  if (sources.includes('lbank')) {
    try {
      const res = await cexGetJSON('https://api.lbank.info/v2/supplement/assetDetail.do');
      const list = (res.ok && res.data && (res.data as { data?: unknown[] }).data) || [];
      for (const raw of list) {
        const c = raw as Record<string, unknown>;
        upsert('lbank', String(c.assetCode || c.coin || ''), pickAddr(c), gateChainToId(String(c.chainName || c.chain || '')));
      }
    } catch (e: unknown) {
      log(`[cex] lbank: ${(e as Error).message}`);
    }
  }
  if (sources.includes('poloniex')) {
    try {
      const res = await cexGetJSON('https://api.poloniex.com/currencies');
      const obj = (res.ok && res.data && typeof res.data === 'object') ? res.data as Record<string, Record<string, unknown>> : {};
      const list: Record<string, unknown>[] = Array.isArray(res.data)
        ? res.data as Array<Record<string, unknown>>
        : Object.entries(obj).map(([k, v]) => ({ currency: k, ...(v as Record<string, unknown>) }));
      for (const c of list) {
        upsert('poloniex', String(c.currency || c.coin || c.id || ''), pickAddr(c), gateChainToId(String(c.blockchain || c.chainName || c.network || '')));
      }
    } catch (e: unknown) {
      log(`[cex] poloniex: ${(e as Error).message}`);
    }
  }
  if (sources.includes('okx')) {
    try {
      const res = await cexGetJSON('https://www.okx.com/api/v5/asset/currencies');
      const list = (res.ok && res.data && (res.data as { data?: unknown[] }).data) || [];
      for (const raw of list) {
        const c = raw as Record<string, unknown>;
        if (String(c.canSpot || '1') === '0') continue;
        upsert('okx', String(c.ccy || c.coin || ''), pickAddr(c), gateChainToId(String(c.chain || '')));
      }
    } catch (e: unknown) {
      log(`[cex] okx: ${(e as Error).message}`);
    }
  }

  try {
    const res = await cexGetJSON('https://api.coingecko.com/api/v3/coins/list?include_platform=true');
    const coins = (res.ok && Array.isArray(res.data)) ? res.data as Array<{ id: string; symbol: string; platforms?: Record<string, string> }> : [];
    for (const coin of coins) {
      const sym = lower(coin.symbol);
      let entry = newBySymbol[sym];
      if (!entry) {
        entry = { id: coin.id, symbol: sym, rank: 0, addresses: {}, cexSources: ['coingecko'] };
        newBySymbol[sym] = entry;
        tokenCount++;
      } else {
        entry.id = coin.id;
        if (!entry.cexSources.includes('coingecko')) entry.cexSources.push('coingecko');
      }
      byGeckoId[coin.id] = entry;
      for (const [platform, addr] of Object.entries(coin.platforms || {})) {
        const chainId = GECKO_PLATFORM_TO_CHAIN[platform];
        if (chainId && addr) {
          if (!entry.addresses[chainId]) entry.addresses[chainId] = addr;
          const addrKey = chainId + ':' + lower(addr);
          if (!newByAddress[addrKey]) newByAddress[addrKey] = entry;
          addressCount++;
        }
      }
    }
  } catch (e: unknown) {
    log(`[cex] gecko: ${(e as Error).message}`);
  }

  const tickerPages = CEX_GECKO_TICKER_PAGES();
  const minTickerUsd = CEX_MIN_TICKER_USD();
  const markGeckoTicker = (src: string, coinId: string, base?: string) => {
    const entry = byGeckoId[coinId] || (base ? newBySymbol[lower(base)] : undefined);
    if (!entry) return;
    if (!entry.cexSources.includes(src)) entry.cexSources.push(src);
    exchangeHits++;
    bump(src);
    for (const [chainId, addr] of Object.entries(entry.addresses)) {
      newByAddress[chainId + ':' + lower(addr)] = entry;
    }
  };
  for (const src of sources) {
    const geckoEx = GECKO_CEX_TICKER_IDS[src];
    if (!geckoEx) continue;
    try {
      for (let page = 1; page <= tickerPages; page++) {
        const res = await cexGetJSON(`https://api.coingecko.com/api/v3/exchanges/${geckoEx}/tickers?page=${page}`);
        if (!res.ok || !res.data) break;
        const tickers = (res.data as { tickers?: Array<{ coin_id?: string; base?: string; converted_volume?: { usd?: number } }> }).tickers || [];
        if (!tickers.length) break;
        for (const t of tickers) {
          const vol = Number(t.converted_volume?.usd || 0);
          if (minTickerUsd > 0 && vol > 0 && vol < minTickerUsd) continue;
          if (t.coin_id) markGeckoTicker(src, t.coin_id, t.base);
        }
        if (tickers.length < 50) break;
      }
    } catch (e: unknown) {
      log(`[cex] gecko-ticker ${src}: ${(e as Error).message}`);
    }
  }

  for (const e of Object.values(newBySymbol)) {
    if (!cexEntryHasExchange(e)) stats.coingeckoOnly++;
  }

  if (exchangeHits === 0 && Object.keys(cexCache.byAddress).length > 0) {
    log('[cex] exchange APIs empty/fail — keeping previous snapshot');
    return;
  }

  cexCache.byAddress = newByAddress;
  cexCache.bySymbol = newBySymbol;
  cexCache.tokenCount = tokenCount;
  cexCache.chainsWithAddresses = addressCount;
  cexCache.updatedAt = Date.now();
  for (const k of Object.keys(cexSourceStats)) delete cexSourceStats[k];
  for (const [k, v] of Object.entries(stats)) cexSourceStats[k] = v;
  log(`[cex] Cache updated: ${tokenCount} tokens, ${addressCount} addresses, exchangeHits=${exchangeHits}`);
}

// ─────────────── GeckoTerminal API ───────────────
interface RequestBudget {
  gecko: number[];
  dexpaprika: number[];
  dexscreener: number[];
}

const requestBudget: RequestBudget = { gecko: [], dexpaprika: [], dexscreener: [] };
const GECKO_RATE_LIMIT = 25;
// Reserve this many gecko slots for enrichment (liquidity/position), so polling never starves enrichment
const GECKO_ENRICH_RESERVE = 5;
// v9.21 A1: also reserve gecko slots for DISCOVERY. Before v9.21 the poll workers skipped
// SSE-connected chains, so polling rarely used its full share and the discovery base layer
// (trending_pools + /pools) always got GT. v9.21 started GT-polling all 6 SSE chains, which filled
// the shared 20/25 budget and starved discovery → organic pools stopped growing (eth 48→23, sol
// 94→3 live). Giving discovery a ceiling ABOVE polling (GECKO_DISCOVERY_LIMIT > GECKO_POLL_LIMIT)
// guarantees the watchlist-builder always has GT priority over polling, so the pool count recovers
// without losing the real-trade detection that v9.21 restored.
// bm1t Task 4: потолок discovery снижен до 12 < POLL=16 — «discovery выше polling» снято решением
// по 429-шуму (см. комментарий у GECKO_DISCOVERY_LIMIT ниже); базовый слой (≤22) всё ещё выше всех.
const GECKO_DISCOVERY_RESERVE = 4; // discovery keeps 4 slots of priority over polling
const GECKO_POLL_LIMIT = GECKO_RATE_LIMIT - GECKO_ENRICH_RESERVE - GECKO_DISCOVERY_RESERVE; // poll ≤16/25
// bm1t Task 4 (решение юзера): потолок discovery 20 → 12/мин. Отклонение от буквы ТЗ («снизить
// GECKO_DISCOVERY_RESERVE»): формулой через RESERVE этого добиться нельзя — RESERVE входит только
// в POLL (25−5−8=12 сломал бы «poll ≤16»), а старая формула DISC = RATE − ENRICH от RESERVE не
// зависела вовсе. Поэтому DISC задан константой независимо; POLL ≤16, ENRICH-резерв и BASELAYER ≤22
// не тронуты. Приоритет discovery над polling из v9.21 A1 снят осознанно: замер ТЗ — при 30 req/мин
// 429=24, при 15 req/мин 429=3 — меньше GT-давления, discovery раньше уходит в DS-фолбэк.
const GECKO_DISCOVERY_LIMIT = 12; // discovery ≤12/25 (было ≤20/25)
// v9.23 F1: the discovery GT BASE LAYER (trending_pools + top pools, a handful of cheap calls per
// cycle) gets priority ABOVE the discovery ceiling — it borrows a couple of enrichment's reserved
// slots so it ALWAYS runs even when polling+per-token expansion have saturated the 16-20 band. This
// restores the trending-pool base layer that v9.21 A1 was meant to protect but which still returned
// 0 pools under full discovery usage (gtOk≈0 on prod, base chain starved).
const GECKO_BASELAYER_RESERVE = 2; // base layer can use 2 slots into enrichment's band (20-22)
const GECKO_BASELAYER_LIMIT = GECKO_RATE_LIMIT - GECKO_ENRICH_RESERVE + GECKO_BASELAYER_RESERVE; // ≤22/25

function cleanBudget() {
  const oneMinAgo = Date.now() - 60000;
  requestBudget.gecko = requestBudget.gecko.filter(t => t > oneMinAgo);
  requestBudget.dexpaprika = requestBudget.dexpaprika.filter(t => t > oneMinAgo);
  requestBudget.dexscreener = requestBudget.dexscreener.filter(t => t > oneMinAgo);
}

async function geckoGet<T>(endpoint: string): Promise<T | null> {
  const r = await geckoGetWithStatus<T>(endpoint);
  return r.data ?? null;
}

// Discovery uses direct GT fetches (bypasses the poll budget so watchlist init can
// gather enough pools per chain without being starved by GECKO_POLL_LIMIT).
// ТЗ v6: injectable GeckoTerminal discovery transport (tests replace it to simulate 429/timeout or
// mock Fartcoin pools without hitting the network). Also tracks 429s for per-chain discovery metrics.
let geckoDiscovery429Total = 0;
// bm1t Task 4: агрегат лога discovery-429 — не чаще одной строки в минуту (прод: 2959 построчных
// «Rate limited (discovery)» за ~4ч лог-ринга). ×N — сколько 429 накопилось с прошлой строки
// (включая текущий); хвост suppressed-счётчика печатается следующей строкой, счётчики точны всегда.
let lastDisc429LogAt = 0;
let disc429SinceLastLog = 0;
// B4.2: ANONYMOUS_GT_PATHS, per-path метрики, gecko429ByPath, last probe/recovery — в канале
// (src/lib/gecko/anonymous-channel.ts); facade импортирует только типы (см. шапку файла).
type GeckoDiscoveryImpl = (endpoint: string, ceiling?: number) => Promise<unknown>;
// ТЗ v6: pace discovery so the GeckoTerminal budget (geckoPerMinUsed ≤ limit, reserve kept) is
// never blown by a discovery burst; discovery runs on its own transport (separate from poll/enrich
// budget) but is throttled to stay under GT's server-side limits.
let lastDiscReqAt = 0;
const DISC_MIN_INTERVAL = 350;
// ТЗ v7: discovery shares the GT budget. Never exceed geckoPerMinUsed=25 with 5 reserved for
// enrich → discovery stops issuing GT calls at its own ceiling (bm1t Task 4: 12/min); callers fall
// back to DexScreener when this returns null.
// B4.2: bm2t/b2 разбор (R1–R3), константы GT_DEAD_* и dead/probe/strike state — в канале
// (src/lib/gecko/anonymous-channel.ts). Ниже остаются только счётчики транспорта/оркестрации
// discovery (пишутся facade-кодом, сбрасываются facade-частью __resetGecko429ForTest).
let gtRequestsTotal = 0;
let gtRequestsPerCycle = 0;
// B4.2: тонкие делегаты composition root — состояние и логика живут в канале (ядро).
/** R2: «канал закрыт прямо сейчас» — мёртв, и проба недоступна (пауза не вышла либо уже в полёте). */
function gtDeadBlocked(): boolean {
  return anonymousGtChannel().blocked();
}
type AnonymousGtRequest<T> = () => AnonymousGtOutcome<T> | Promise<AnonymousGtOutcome<T>>;
type AnonymousGtLatchResult<T> = AnonymousGtResult<T>;
/** The one anonymous HTTP admission/outcome gate shared by every GeckoTerminal path. */
async function withAnonymousGtLatch<T>(path: AnonymousGtPath, request: AnonymousGtRequest<T>): Promise<AnonymousGtLatchResult<T>> {
  // B4.2: тонкий делегат — admission/учёт/probe владеет канал в ядре.
  return anonymousGtChannel().run(path, request);
}
// native-price-service delegates its anonymous HTTP callback to this exact token-price gate.
setNativePriceGtLatch(async request => {
  let nativeOutcome: Awaited<ReturnType<typeof request>> | null = null;
  const gated = await withAnonymousGtLatch('token-price', async () => {
    const outcome = await request();
    nativeOutcome = outcome;
    return outcome;
  });
  return { blocked: gated.blocked, value: nativeOutcome };
});
function recordAnonymousGtDeadSkip(path: AnonymousGtPath): void {
  anonymousGtChannel().recordDeadSkip(path);
}
export function __withAnonymousGtLatchForTest<T = unknown>(path: AnonymousGtPath, request: AnonymousGtRequest<T>): Promise<AnonymousGtLatchResult<T>> {
  return withAnonymousGtLatch(path, request);
}
export function __anonymousGtLatchForTest() {
  // B4.2: чистый делегат — channel.snapshot() возвращает ровно эту форму.
  return anonymousGtChannel().snapshot();
}
const realGeckoDiscovery: GeckoDiscoveryImpl = async <T>(endpoint: string, ceiling?: number): Promise<T | null> => {
  if (typeof gtBan !== 'undefined' && Date.now() < gtBan.until) {
    gtBan.banSkipped++;
    return null;
  }
  // Preserve the pre-budget dead skip. Actual HTTP admission is repeated through the shared wrapper
  // immediately before transport, after warmup, caller ceiling, and the discovery pacer.
  if (gtDeadBlocked()) { recordAnonymousGtDeadSkip('discovery'); return null; }
  if (typeof gtWarmupAllow === 'function' && !gtWarmupAllow()) return null;
  cleanBudget();
  // v9.21 A1: discovery has its own ceiling (GECKO_DISCOVERY_LIMIT > GECKO_POLL_LIMIT) so polling
  // cannot starve the watchlist-builder's GT base layer.
  // v9.23 F1: the GT base layer (trending_pools/top pools) may pass a HIGHER ceiling
  // (GECKO_BASELAYER_LIMIT, borrowing 2 enrichment slots) so it always runs even when per-token
  // expansion has saturated the 16-20 discovery band.
  const limit = ceiling ?? GECKO_DISCOVERY_LIMIT;
  if (requestBudget.gecko.length >= limit) return null; // GT saturated → DS fallback
  requestBudget.gecko.push(Date.now());
  const wait = DISC_MIN_INTERVAL - (Date.now() - lastDiscReqAt);
  if (wait > 0) await sleep(wait);
  lastDiscReqAt = Date.now();

  // The one shared probe is acquired at the last possible point before the anonymous HTTP request.
  try {
    const gated = await withAnonymousGtLatch('discovery', async () => {
      gtRequestsTotal++;
      const response = await getJSON<T>(anonymousGtUrl('/api/v2' + endpoint), { headers: { 'Accept': 'application/json' } });
      return {
        value: response, status: response.status, ok: response.ok,
        useful: isUsefulGtPayload(response.data, response.ok),
      };
    });
    if (gated.blocked) { requestBudget.gecko.pop(); return null; }
    const res = gated.value;
    if (!res) return null;
    if (res.status === 429) {
      geckoDiscovery429Total++;
      // bm1t Task 4: счётчики — на каждый 429; лог — максимум раз в 60с. Знаменатель — фактический
      // limit решения (был GECKO_RATE_LIMIT=25, хотя гейт всегда был по GECKO_DISCOVERY_LIMIT/ceiling).
      const nowMs = Date.now();
      if (nowMs - lastDisc429LogAt >= 60000) {
        log(`[gecko] discovery 429 ×${disc429SinceLastLog + 1} за минуту (фолбэк DS), Used ${requestBudget.gecko.length}/${limit}`);
        lastDisc429LogAt = nowMs;
        disc429SinceLastLog = 0;
      } else {
        disc429SinceLastLog++;
      }
    }
    return res.ok ? (res.data ?? null) : null;
  } catch {
    // Preserve the discovery caller's historical null/DexScreener fallback on transport exceptions.
    // withAnonymousGtLatch releases the single probe in its own finally block.
    return null;
  }
};
let geckoDiscoveryImpl: GeckoDiscoveryImpl = realGeckoDiscovery;
/** Test hook: replace the GeckoTerminal discovery transport. */
let geckoDiscLocalGuard = true;
export function __setGeckoDiscoveryMock(fn: GeckoDiscoveryImpl | null): void {
  geckoDiscoveryImpl = fn || realGeckoDiscovery;
  // Mocks drive their own budget; local GT guard is for the real upstream only.
  geckoDiscLocalGuard = !fn;
}
/** Test hook: replace the anchor list for a chain (e.g. clear anchors to prove organic coverage). */
export function __setAnchorPoolsForTest(chainId: string, anchors: AnchorPool[]): void {
  ANCHOR_POOLS[chainId] = anchors;
}
// F1: test hook to override the env-anchor pools (simulate cold-start env config).
export function __setEnvAnchorPoolsForTest(chainId: string, anchors: AnchorPool[]): void {
  ENV_ANCHOR_POOLS[chainId] = anchors;
}
// F1: clear cached env-anchor pool info + expose a way to run re-resolution in tests.
export function __clearEnvAnchorInfoForTest(): void {
  for (const k of Object.keys(envAnchorInfoCache)) delete envAnchorInfoCache[k];
}
export async function __resolveEnvAnchorSubjectsForTest(): Promise<void> {
  await resolveEnvAnchorSubjects();
}
export function __setEnvAnchorInfoForTest(chainId: string, addr: string, base: { address: string; symbol: string }, quote: { address: string; symbol: string }): void {
  envAnchorInfoCache[chainId + ':' + lower(normAddr(chainId, addr))] = { base, quote };
}
export function __setWatchMetaEntryForTest(chainId: string, addr: string, entry: Partial<WatchPoolEntry>): void {
  if (!chainWatchMeta[chainId]) chainWatchMeta[chainId] = {};
  const key = lower(normAddr(chainId, addr));
  const qSym = (entry.quoteSymbol || '').toUpperCase();
  const quoteType: 'stable' | 'native' | 'other' = entry.quoteType || (isStableQuoteSymbol(qSym) ? 'stable' : (isNativeQuoteSymbol(qSym) ? 'native' : 'other'));
  let cachedQuotePriceUsd = entry.cachedQuotePriceUsd;
  if (!cachedQuotePriceUsd) {
    if (quoteType === 'stable') cachedQuotePriceUsd = 1.0;
    else if (qSym === 'WETH' || qSym === 'ETH') cachedQuotePriceUsd = 2500;
    else if (qSym === 'WBNB' || qSym === 'BNB') cachedQuotePriceUsd = 600;
    else if (qSym === 'POL' || qSym === 'WPOL' || qSym === 'MATIC') cachedQuotePriceUsd = 0.5;
  }
  let token0Decimals = entry.token0Decimals;
  let token1Decimals = entry.token1Decimals;
  let quoteTokenIndex = entry.quoteTokenIndex;

  if (quoteTokenIndex === undefined && qSym) {
    if (qSym === 'USDC' || qSym === 'USDC.E' || qSym === 'USDBC' || qSym === 'EURC') {
      quoteTokenIndex = 1;
      token0Decimals = token0Decimals ?? 18;
      token1Decimals = token1Decimals ?? 6;
    } else if (qSym === 'USDT') {
      if (chainId === 'bsc') {
        quoteTokenIndex = 0;
        token0Decimals = token0Decimals ?? 18;
        token1Decimals = token1Decimals ?? 18;
      } else {
        quoteTokenIndex = 1;
        token0Decimals = token0Decimals ?? 18;
        token1Decimals = token1Decimals ?? 6;
      }
    } else if (qSym === 'WBNB' || qSym === 'WETH') {
      quoteTokenIndex = 1;
      token0Decimals = token0Decimals ?? 18;
      token1Decimals = token1Decimals ?? 18;
    }
  }

  chainWatchMeta[chainId][key] = {
    address: normAddr(chainId, addr),
    subjectToken: '',
    subjectSymbol: '?',
    quoteSymbol: '?',
    reserveUsd: null,
    vol24hUsd: null,
    source: 'anchor',
    token0Decimals,
    token1Decimals,
    quoteTokenIndex,
    quoteType,
    cachedQuotePriceUsd,
    ...entry,
  } as any;
}
export function __resolveReadableSymbolForTest(chainId: string, addr: string): string | null {
  return resolveReadableSymbol(chainId, addr);
}
// M1/M2/M3 test hook: run the pure EVM-trade analysis on synthetic tx+receipt fixtures.
export function __analyzeEvmTradeForTest(
  chainId: string,
  txd: { from?: string; to?: string; hash?: string },
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }>; blockNumber?: string; transactionHash?: string },
  txHash?: string | null,
): Promise<{
  found: boolean; trader: string; resolvedBy: string; subject: { address: string; symbol: string | null } | null;
  side: string; usd: number; subjectAmount: number; blockTs: number | null; rpcOk: boolean; error?: string | null;
  attributedPool?: { pool: string; side: 'buy' | 'sell' } | null; // v9.32ak AK4
  swapEmitterPool?: string | null; // ТЗ bm2n6 п.3: валидированный пул из Swap-эмиттера чека
}> {
  return analyzeEvmTrade(chainId, txd, receipt, null, txHash ?? txd.hash ?? receipt.transactionHash ?? null);
}
// M3 test hook: universal settlement-owner recovery from a transfer stream.
export function __recoverSettlementOwnerForTest(chainId: string, logs: EvmTransferLog[], settlement: string): string | null {
  return recoverSettlementOwner(chainId, logs, settlement);
}
// R2 (v9.4) test hooks: pure Solana multi-hop subject / valuation.
export function __pickSolanaTerminalSubjectForTest(deltasByOwnerMint: Record<string, number>, trader: string): { mint: string; delta: number } {
  return pickSolanaTerminalSubject(deltasByOwnerMint, trader);
}
export function __computeSolanaUsdForTest(
  deltasByOwnerMint: Record<string, number>,
  trader: string,
  subjectMint: string,
  solPrice: number,
  traderSolDelta?: number,
  otherPricedTokens?: Record<string, number>,
  linkedRouteDelta?: { mint: string; amount: number } | null,
  trustedSubjectPrice?: number,
  subjectAmount?: number,
): number {
  return computeSolanaUsd(
    deltasByOwnerMint,
    trader,
    subjectMint,
    solPrice,
    traderSolDelta,
    otherPricedTokens,
    linkedRouteDelta,
    trustedSubjectPrice,
    subjectAmount,
  );
}
// v9.30 test hooks: expose the pure native-SOL delta + swap-legs helpers extracted from
// analyzeSolanaTx so the CARDS→SOL regression fixture can be asserted deterministically.
export function __computeTraderSolDeltaForTest(
  accountKeys: SolanaAccountKey[],
  preBalances: number[] | undefined,
  postBalances: number[] | undefined,
  trader: string,
  feePayer: string,
  fee: number | undefined,
): number {
  return computeTraderSolDelta(accountKeys, preBalances, postBalances, trader, feePayer, fee);
}
export function __solanaSwapLegsForTest(
  deltasByOwnerMint: Record<string, number>,
  trader: string,
  traderSolDelta: number,
): { traderIn: number; traderOut: number } {
  return solanaSwapLegs(deltasByOwnerMint, trader, traderSolDelta);
}
// R1 (v9.4) test hook: read a chain's skip reason counters (to assert contract_from skips).
export function __getChainSkipReasonsForTest(chainId: string): Record<string, number> {
  return { ...(state.chain[chainId]?.skipReasons || {}) };
}
// R1 (v9.4) test hook: drive the LIVE alert flow (processExtractedEvent) with a synthetic event.
export function __processExtractedEventForTest(chainId: string, extracted: ExtractedSubject, rawData?: Record<string, unknown>): Promise<void> {
  return processExtractedEvent(chainId, extracted, 'test', rawData, true);
}
export function __processExtractedEventFullForTest(
  chainId: string, extracted: ExtractedSubject, source: string, rawData?: Record<string, unknown>, maxAgeSecOverride?: number,
): Promise<void> {
  return processExtractedEvent(chainId, extracted, source, rawData, true, maxAgeSecOverride);
}
// bm2x S3 test hook: прогнать чистку series-буфера с подменённым now (проверка expiredWithoutFire без ожидания 6 мин).
export function __cleanSeriesBufferForTest(now?: number): void { cleanSeriesBuffer(now); }
// R1 (v9.4) test hook: read a chain's notified + notifs total.
export function __getNotifCountsForTest(chainId: string): { notified: number; notifsTotal: number } {
  return { notified: state.chain[chainId]?.notified || 0, notifsTotal: state.notifs.length };
}
// v9.10 F1 test hooks: alert-worthiness gate.
export function __shouldNotifyForTest(ctx: { chainId: string; subjectAddr: string; subjectSym: string; amountUsd: number; hasSourceBlockTs: boolean; blockTs: number | null; selfSwap?: boolean }): { allow: boolean; reason?: string } {
  return shouldNotify(ctx);
}
export function __skippedReasonsForTest(): Record<string, number> {
  return { ...skippedReasonsStats };
}
export function __resetSkippedReasonsForTest(): void {
  for (const k of Object.keys(skippedReasonsStats)) delete skippedReasonsStats[k];
}
// v9.10 F2 test hook: fmtImpact display helper.
export function __fmtImpactForTest(v: number): string | null {
  return fmtImpact(v);
}
// v9.11 test hooks.
export function __isSelfSwapForTest(a: string, b: string): boolean {
  return isSelfSwap({ fromAddr: a, toAddr: b });
}
export function __notifSkippedForTest(): Record<string, number> {
  const out: Record<string, number> = {};
  for (const [k, v] of Object.entries(notifSkipped)) {
    if (typeof v === 'number') out[k] = v;
  }
  return out;
}
export function __aggregatorRejectsForTest(): Record<AggregatorRejectReason, number> {
  return { ...notifSkipped.aggregatorRejects };
}
export function __cexRejectsForTest(): Record<string, Record<CexRejectReason, number>> {
  return Object.fromEntries(Object.entries(cexRejects).map(([c, r]) => [c, { ...r }]));
}
export function __resetNotifSkippedForTest(): void {
  notifSkipped.phantom_tx = 0;
  notifSkipped.self_swap = 0;
  notifSkipped.price_oracle_invalid = 0;
  notifSkipped.no_swap_legs = 0;
  notifSkipped.aggregatorNoLegs = 0;
  notifSkipped.aggregatorAttributed = 0;
  notifSkipped.aggregatorStillNoLegs = 0;
  notifSkipped.aggregatorSkippedBelowMin = 0;
  notifSkipped.aggregatorRejects.no_candidates = 0;
  notifSkipped.aggregatorRejects.usd_zero = 0;
  notifSkipped.aggregatorRejects.below_min = 0;
  notifSkipped.aggregatorRejects.is_contract = 0;
  notifSkipped.aggregatorRejects.getcode_unresolved = 0;
  notifSkipped.aggregatorRejects.non_wallet = 0;
  notifSkipped.blacklistSubjectSwapped = 0;
  notifSkipped.invalid_tx_format = 0;
  notifSkipped.invalid_tx_format_synthetic = 0;
  notifSkipped.invalid_tx_format_other = 0;
  notifSkipped.invalid_tx_format_regex_evm = 0;
  notifSkipped.invalid_tx_format_regex_solana = 0;
  notifSkipped.invalid_tx_format_non_hex = 0;
}
export function __resetCexRejectsForTest(): void {
  for (const c of Object.keys(cexRejects)) {
    cexRejects[c].known_cex_address = 0;
    cexRejects[c].heuristic = 0;
    cexRejects[c].threshold = 0;
  }
  cexDropSampleN = 0;
}
export function __resetAggregatorRejectSampleForTest(): void {
  aggregatorRejectSampleN = 0;
  lastAggregatorRejects.length = 0;
  aggregatorRejectsByRouter.clear(); // v9.32bm1g H7
}
export function __recordSolanaValuationSourceForTest(source: SolanaUsdSource): void {
  recordSolanaValuationSource(source);
}
export async function __phantomTxForTest(chainId: string, txHash: string): Promise<boolean> {
  return isPhantomTx(chainId, txHash);
}
export function __formatWalletBalancesForTest(n: { walletBalances?: Array<{ address: string; symbol: string; amount: number; usd: number }> }): string | null {
  return formatWalletBalances(n as Notification);
}
export function __fetchPositionInfoForTest(chainId: string, tokenAddr: string, traderAddr: string): Promise<PositionInfo | null> {
  return fetchPositionInfo(chainId, tokenAddr, traderAddr);
}
export function __getWatchMetaEntryForTest(chainId: string, addr: string): { subjectToken: string; subjectSymbol: string; quoteSymbol: string } | null {
  const meta = chainWatchMeta[chainId];
  if (!meta) return null;
  const e = meta[lower(normAddr(chainId, addr))];
  if (!e) return null;
  return { subjectToken: e.subjectToken, subjectSymbol: e.subjectSymbol, quoteSymbol: e.quoteSymbol };
}
/** Test hook: inject a DexScreener pool transport (deterministic DS fallback tests). */
type DsPoolImpl = (chainId: string, tokenAddr: string) => Promise<DiscoveryPoolLike[] | null>;
const realDsPoolImpl: DsPoolImpl = (chainId, tokenAddr) => fetchDsReal(chainId, tokenAddr);
let dsPoolImpl: DsPoolImpl = realDsPoolImpl;
let dsPoolsMockInstalled = false;
export function __setDsPoolsMock(fn: DsPoolImpl | null): void {
  dsPoolsMockInstalled = fn !== null;
  dsPoolImpl = fn || realDsPoolImpl;
}
const WATCH_POOLS_HOT_TOP_N = () => Number(process.env.WATCH_POOLS_HOT_TOP_N || '3');
const DS_REFRESH_EVERY_MS = () => Number(process.env.DS_REFRESH_EVERY_MS || '600000');
const watchlistRefresh = { called: 0, added: 0, failed: 0, ok: 0, empty: 0, http429: 0, httpErr: 0, parseErr: 0 };
const lastHotRefreshAt = new Map<string, number>();
function classifyDsFetchError(e: unknown): 'http429' | 'httpErr' | 'parseErr' {
  const any = e as { status?: number; message?: string };
  const msg = String(any?.message || e);
  if (any?.status === 429 || /\b429\b/.test(msg)) return 'http429';
  if (/json|parse/i.test(msg)) return 'parseErr';
  return 'httpErr';
}
export function __resetWatchlistRefreshForTest(): void {
  watchlistRefresh.called = 0; watchlistRefresh.added = 0; watchlistRefresh.failed = 0;
  watchlistRefresh.ok = 0; watchlistRefresh.empty = 0; watchlistRefresh.http429 = 0;
  watchlistRefresh.httpErr = 0; watchlistRefresh.parseErr = 0;
  lastHotRefreshAt.clear();
}
export async function refreshHotTokenPools(chainId: string, subjectAddr: string): Promise<void> {
  const k = chainId + ':' + lower(subjectAddr);
  const every = DS_REFRESH_EVERY_MS();
  if (every > 0 && Date.now() - (lastHotRefreshAt.get(k) || 0) < every) return;
  lastHotRefreshAt.set(k, Date.now());
  watchlistRefresh.called++;
  try {
    const pools = await fetchDsTokenPools(chainId, subjectAddr);
    if (!pools || !pools.length) { watchlistRefresh.empty++; return; }
    const n = WATCH_POOLS_HOT_TOP_N();
    const top = [...pools].sort((a, b) => b.volume24h - a.volume24h).slice(0, n);
    const before = (state.chain[chainId]?.watchPools || []).length;
    for (const p of top) {
      seedWatchPool(chainId, p.address, subjectAddr, '?', '?', p.reserveUsd, p.volume24h);
    }
    watchlistRefresh.added += Math.max(0, (state.chain[chainId]?.watchPools || []).length - before);
    watchlistRefresh.ok++;
  } catch (e) {
    const reason = classifyDsFetchError(e);
    watchlistRefresh[reason]++;
    watchlistRefresh.failed++;
  }
}

const DISCOVERY_DS_FALLBACK = () => (process.env.DISCOVERY_DS_FALLBACK ?? '1') !== '0';
// v9.32aj: 6→25 — фид boosts расширил поток кандидатов; бюджет воронок держим ≤ +30 DS/мин (ТЗ)
const DS_DISCOVERY_PER_MIN = () => Number(process.env.DS_DISCOVERY_PER_MIN || '25');
const discoveryDsHot = { cycles: 0, tokensSeen: 0, tokensAdded: 0, skippedReasons: {} as Record<string, number> };
type DsTokenProfile = { chainId?: string; tokenAddress?: string };
let dsProfilesImpl: (() => Promise<DsTokenProfile[]>) | null = null;
export function __setDsProfilesMock(fn: (() => Promise<DsTokenProfile[]>) | null): void {
  dsProfilesImpl = fn;
}
function bumpDsHotSkip(reason: string): void {
  discoveryDsHot.skippedReasons[reason] = (discoveryDsHot.skippedReasons[reason] || 0) + 1;
}
function subjectAlreadyWatched(chainId: string, token: string): boolean {
  const meta = chainWatchMeta[chainId] || {};
  const t = lower(token);
  for (const e of Object.values(meta)) {
    if (e.subjectToken && lower(e.subjectToken) === t) return true;
  }
  return false;
}
async function fetchDsTokenProfiles(): Promise<DsTokenProfile[]> {
  if (dsProfilesImpl) return dsProfilesImpl();
  if (process.env.NODE_TEST_CONTEXT) return []; // null test mock means an empty fixture, never live DS
  const res = await dsFetch<DsTokenProfile[]>('discovery', 'https://api.dexscreener.com/token-profiles/latest/v1');   // bm2r D5: шлюз, лейн discovery
  if (!res.ok || !Array.isArray(res.data)) return [];
  return res.data;
}
export async function runDiscoveryDsHot(): Promise<void> {
  if (!DISCOVERY_DS_FALLBACK()) return;
  if (!gtBanState().banned) return;
  if (!rateAllow('ds-discovery', DS_DISCOVERY_PER_MIN(), 60000)) {
    bumpDsHotSkip('budget');
    return;
  }
  discoveryDsHot.cycles++;
  let profiles: DsTokenProfile[] = [];
  try { profiles = await dsFeedProfiles(); } catch { bumpDsHotSkip('httpErr'); return; }
  const our = new Set(Object.keys(CHAINS));
  const candidates: Array<{ chainId: string; token: string }> = [];
  for (const p of profiles) {
    const chainId = String(p.chainId || '');
    const token = String(p.tokenAddress || '');
    if (!token) continue;
    discoveryDsHot.tokensSeen++;
    if (!our.has(chainId)) { bumpDsHotSkip('notOurChain'); continue; }
    if (isStablecoin(token, '')) { bumpDsHotSkip('stable'); continue; }
    if (isMajorToken(chainId, token)) { bumpDsHotSkip('major'); continue; }
    if (checkBlacklist(chainId, token, '').skip) { bumpDsHotSkip('blacklist'); continue; }
    if (subjectAlreadyWatched(chainId, token)) { bumpDsHotSkip('alreadyWatched'); continue; }
    candidates.push({ chainId, token });
  }
  const top = candidates.slice(0, 5);
  for (const c of top) {
    if (!rateAllow('ds-discovery', DS_DISCOVERY_PER_MIN(), 60000)) { bumpDsHotSkip('budget'); break; }
    try {
      const pools = await fetchDsTokenPools(c.chainId, c.token);
      if (!pools || !pools.length) { bumpDsHotSkip('emptyPools'); continue; }
      const n = WATCH_POOLS_HOT_TOP_N();
      const ranked = [...pools].sort((a, b) => b.volume24h - a.volume24h).slice(0, n);
      const before = (state.chain[c.chainId]?.watchPools || []).length;
      for (const p of ranked) seedWatchPool(c.chainId, p.address, c.token, '?', '?', p.reserveUsd, p.volume24h);
      const added = Math.max(0, (state.chain[c.chainId]?.watchPools || []).length - before);
      if (added > 0) discoveryDsHot.tokensAdded++;
      else bumpDsHotSkip('noNewPools');
    } catch { bumpDsHotSkip('httpErr'); }
  }
}
export async function __runDiscoveryDsHotForTest(): Promise<void> { return runDiscoveryDsHot(); }
export function __resetDiscoveryDsHotForTest(): void {
  discoveryDsHot.cycles = 0;
  discoveryDsHot.tokensSeen = 0;
  discoveryDsHot.tokensAdded = 0;
  discoveryDsHot.skippedReasons = {};
  rateMap.delete('ds-discovery');
}
// ── v9.32aj JD1: воронки SLA-дискавери (независимы от GT-бана) ──
// Live-проверка 2026-08-31: DS /latest/dex/pairs → 404, DP trending-* → 404 (мертвы);
// ЖИВОЙ фид новых/горячих токенов — DS token-boosts latest+top (создатели мемкоинов платят за
// буст → самый ранний сигнал; PUMPCADE-класс бустит себя). Воронка 2 усыновляет токен ТОЛЬКО
// по факту квалифицирующей сделки (avg-trade h1 ≥ DISCOVERY_ADOPT_MIN_USD) — не по факту пары.
type DsBoostRow = { chainId?: string; tokenAddress?: string };
let dsBoostsImpl: (() => Promise<DsBoostRow[]>) | null = null;
export function __setDsBoostsMock(fn: (() => Promise<DsBoostRow[]>) | null): void { dsBoostsImpl = fn; }
async function fetchDsBoosts(): Promise<DsBoostRow[]> {
  if (dsBoostsImpl) return dsBoostsImpl();
  if (process.env.NODE_TEST_CONTEXT) return []; // null test mock means an empty fixture, never live DS
  const out: DsBoostRow[] = [];
  for (const u of ['https://api.dexscreener.com/token-boosts/latest/v1', 'https://api.dexscreener.com/token-boosts/top/v1']) {
    try {
      const res = await dsFetch<DsBoostRow[]>('discovery', u);   // bm2r D5: шлюз, лейн discovery
      if (res.ok && Array.isArray(res.data)) out.push(...res.data);
    } catch { /* фид best-effort */ }
  }
  return out;
}
// bm2z (блокер 5 bm2u): единый тест-хук кормления discovery-фидов DS — по образцу solAnalyzeHandleImpl.
// Тест перехватывает ТОЧКУ fetchDs* (kind → строки) и кладёт в неё фикстуры; null = прод-поведение
// (боевые fetchDsTokenProfiles/fetchDsBoosts со своим пустым контрактом под NODE_TEST_CONTEXT).
// Все discovery-места потребления фидов (appendDsBanCandidates, DS-hot, newpairs, trend-adopt)
// идут через хук; прод-логика без хука не меняется.
type DsFeedRow = DsTokenProfile | DsBoostRow;
type DsFeedImpl = (kind: 'profiles' | 'boosts') => Promise<DsFeedRow[]>;
let dsFetchImpl: DsFeedImpl | null = null;
export function __setDsFetchImplForTest(fn: DsFeedImpl | null): void { dsFetchImpl = fn; }
async function dsFeedProfiles(): Promise<DsTokenProfile[]> {
  if (dsFetchImpl) return (await dsFetchImpl('profiles')) as DsTokenProfile[];
  return fetchDsTokenProfiles();
}
async function dsFeedBoosts(): Promise<DsBoostRow[]> {
  if (dsFetchImpl) return (await dsFetchImpl('boosts')) as DsBoostRow[];
  return fetchDsBoosts();
}
const DISCOVERY_NEW_PAIRS_EVERY_MS = () => Number(process.env.DISCOVERY_NEW_PAIRS_EVERY_MS || '30000');
const discoveryFunnelsAt = { newPairs: 0, trend: 0 }; // v9.32aj: тик-расписание воронок
const DISCOVERY_TREND_POLL_MS = () => Number(process.env.DISCOVERY_TREND_POLL_MS || '60000');
const DISCOVERY_ADOPT_MIN_USD = () => Number(process.env.DISCOVERY_ADOPT_MIN_USD || '5000');
const DISCOVERY_DEFER_RECHECK_MS = 10 * 60 * 1000;
const discoveryNewPairs = { cycles: 0, seen: 0, seeded: 0, deferred: 0, adopted: 0, rejected: 0, budgetSkipped: 0, hourlyCapSkipped: 0, floodSkipped: 0, skippedReasons: {} as Record<string, number> };
const discoveryAdopt = { cycles: 0, candidates: 0, adopted: 0, hourlyCapSkipped: 0, floodSkipped: 0, skippedReasons: {} as Record<string, number> };
const discoveryDeferred = new Map<string, { chainId: string; token: string; attempts: number; recheckAt: number }>();
const discoveryFirstSeen = new Map<string, number>();
const discoverySlaRing: Array<{ token: string; latencyMin: number; at: number }> = [];
const discoverySlaStats = { breaches: 0, lastBreachToken: '' as string };
function bumpFunnelSkip(bucket: 'newpairs' | 'trend', reason: string): void {
  const m = bucket === 'newpairs' ? discoveryNewPairs.skippedReasons : discoveryAdopt.skippedReasons;
  m[reason] = (m[reason] || 0) + 1;
}
function percentile95(nums: number[]): number {
  if (!nums.length) return 0;
  const s = [...nums].sort((a, b) => a - b);
  return s[Math.min(s.length - 1, Math.ceil(0.95 * s.length) - 1)];
}
function noteDiscoveryFirstSeen(chainId: string, token: string, now: number): void {
  const k = chainId + ':' + lower(token);
  if (!discoveryFirstSeen.has(k)) discoveryFirstSeen.set(k, now);
}
function noteDiscoveryAdoption(chainId: string, token: string): void {
  const k = chainId + ':' + lower(token);
  const first = discoveryFirstSeen.get(k);
  discoveryFirstSeen.delete(k);
  if (first === undefined) return;
  const latencyMin = Math.max(0, (Date.now() - first) / 60000);
  discoverySlaRing.push({ token, latencyMin, at: Date.now() });
  const horizon = Date.now() - 24 * 60 * 60 * 1000;
  while (discoverySlaRing.length > 0 && discoverySlaRing[0].at < horizon) discoverySlaRing.shift();
  if (latencyMin > 3) {
    discoverySlaStats.breaches++;
    discoverySlaStats.lastBreachToken = token;
    log(`[discovery-sla] BREACH: ${token} усыновлен за ${latencyMin.toFixed(1)} мин (SLA ≤ 3 мин)`);
  }
}
function discoverySlaSnapshot(): { adopted24h: number; p50Min: number; p95Min: number; breaches: number; lastBreachToken: string } {
  const lats = discoverySlaRing.map(r => r.latencyMin);
  return {
    adopted24h: discoverySlaRing.length,
    p50Min: lats.length ? Math.round(percentile50(lats) * 10) / 10 : 0,
    p95Min: lats.length ? Math.round(percentile95(lats) * 10) / 10 : 0,
    breaches: discoverySlaStats.breaches,
    lastBreachToken: discoverySlaStats.lastBreachToken,
  };
}
function funnelCandidates(boosts: DsBoostRow[]): Array<{ chainId: string; token: string }> {
  const our = new Set(Object.keys(CHAINS));
  const seen = new Set<string>();
  const candidates: Array<{ chainId: string; token: string }> = [];
  for (const b of boosts) {
    const chainId = String(b.chainId || '');
    const token = String(b.tokenAddress || '');
    if (!token) continue;
    const k = chainId + ':' + lower(token);
    if (seen.has(k)) continue;
    seen.add(k);
    if (!our.has(chainId)) { bumpFunnelSkip('newpairs', 'notOurChain'); continue; }
    if (isStablecoin(token, '')) { bumpFunnelSkip('newpairs', 'stable'); continue; }
    if (isMajorToken(chainId, token)) { bumpFunnelSkip('newpairs', 'major'); continue; }
    if (checkBlacklist(chainId, token, '').skip) { bumpFunnelSkip('newpairs', 'blacklist'); continue; }
    if (subjectAlreadyWatched(chainId, token)) continue;
    candidates.push({ chainId, token });
  }
  return candidates;
}
// v9.32ak AK1: прод aj — seeded 25 за 6 мин (~250/сутки), reserveDiag 6342 нотификаций/10мин,
// solRpc 429, доставки 0. Инвариант ТЗ: WL +десятки/сутки, не сотни. Кап — на СОБЫТИЕ посева
// (один токен = один слот), flood-gate гасит обе воронки при захлёбе reserve-конвейера.
const DISCOVERY_SEED_MAX_PER_HOUR = () => Number(process.env.DISCOVERY_SEED_MAX_PER_HOUR || '4');
const DISCOVERY_SEED_FLOOD_GATE = () => Number(process.env.DISCOVERY_SEED_FLOOD_GATE || '2000');
// bm2r D6: гейт мерит ДАВЛЕНИЕ ДОСТАВКИ (стадия 4 'emitted' — свопы, реально ушедшие в конвейер),
// а не входящий поток событий резерва (стадия 1). Прод 5c3f17f: notifications=7362 > 2000 при
// emitted=60 — гейт был закрыт 24/7 и молча глушил усыновление breach, new-pairs и adopt-воронку.
// Откат одной переменной: DISCOVERY_FLOOD_METRIC=notifications.
const DISCOVERY_FLOOD_METRIC = () => String(process.env.DISCOVERY_FLOOD_METRIC || 'emitted').trim().toLowerCase();
function reserveFloodPressure(): number {
  const s = solReserveDiagSnapshot();
  return DISCOVERY_FLOOD_METRIC() === 'notifications' ? s.notifications : s.emitted;
}
const discoverySeedHourRing: number[] = [];
function discoverySeedBudgetLeft(now = Date.now()): boolean {
  const horizon = now - 3600_000;
  while (discoverySeedHourRing.length > 0 && discoverySeedHourRing[0] <= horizon) discoverySeedHourRing.shift();
  return discoverySeedHourRing.length < DISCOVERY_SEED_MAX_PER_HOUR();
}
function seedDiscoveryTopPools(chainId: string, token: string, pools: Array<{ address: string; volume24h: number; reserveUsd: number; baseSymbol?: string }>, bucket: 'newpairs' | 'trend'): number {
  if (!discoverySeedBudgetLeft()) {
    (bucket === 'newpairs' ? discoveryNewPairs : discoveryAdopt).hourlyCapSkipped++;
    return 0;
  }
  const top = [...pools].sort((a, b) => b.volume24h - a.volume24h).slice(0, WATCH_POOLS_HOT_TOP_N());
  const before = (state.chain[chainId]?.watchPools || []).length;
  for (const p of top) seedWatchPool(chainId, p.address, token, p.baseSymbol || '?', '?', p.reserveUsd, p.volume24h);
  const added = Math.max(0, (state.chain[chainId]?.watchPools || []).length - before);
  if (added > 0) {
    discoverySeedHourRing.push(Date.now());
    solPubsubResubIfConnected(); // живой сокет подписывает новые пулы сразу
  }
  return added;
}
export async function runDiscoveryNewPairs(): Promise<void> {
  if (reserveFloodPressure() > DISCOVERY_SEED_FLOOD_GATE()) { discoveryNewPairs.floodSkipped++; return; } // bm2r D6: гейт смотрит на давление доставки (emitted), а не на входящий поток
  discoveryNewPairs.cycles++;
  const now = Date.now();
  const boosts = await dsFeedBoosts();
  const candidates = funnelCandidates(boosts);
  discoveryNewPairs.seen += candidates.length;
  for (const c of candidates) noteDiscoveryFirstSeen(c.chainId, c.token, now);
  // отложенные (слабые на первый взгляд) созрели к пересмотру
  const due: Array<{ chainId: string; token: string }> = [];
  for (const d of discoveryDeferred.values()) if (d.recheckAt <= now) due.push({ chainId: d.chainId, token: d.token });
  for (const c of [...due, ...candidates].slice(0, 10)) {
    if (!rateAllow('ds-newpairs', DS_DISCOVERY_PER_MIN(), 60000)) { discoveryNewPairs.budgetSkipped++; break; }
    let pools: DiscoveryPoolLike[] | null = null;
    try { pools = await fetchDsTokenPools(c.chainId, c.token); } catch { bumpFunnelSkip('newpairs', 'httpErr'); continue; }
    const k = c.chainId + ':' + lower(c.token);
    if (!pools || !pools.length) { bumpFunnelSkip('newpairs', 'emptyPools'); discoveryDeferred.delete(k); continue; }
    const best = [...pools].sort((a, b) => b.volume24h - a.volume24h)[0];
    const qualifies = best.volume24h >= POOL_MIN_24H_VOLUME_USD || best.reserveUsd >= POOL_MIN_RESERVE_USD;
    if (qualifies) {
      const added = seedDiscoveryTopPools(c.chainId, c.token, pools, 'newpairs');
      if (added > 0) { discoveryNewPairs.seeded++; discoveryNewPairs.adopted++; noteDiscoveryAdoption(c.chainId, c.token); }
      discoveryDeferred.delete(k);
    } else {
      const d = discoveryDeferred.get(k) || { chainId: c.chainId, token: c.token, attempts: 0, recheckAt: 0 };
      d.attempts++;
      d.recheckAt = now + DISCOVERY_DEFER_RECHECK_MS;
      if (d.attempts >= 2) { discoveryNewPairs.rejected++; discoveryDeferred.delete(k); } // повторно слабая — отбраковка
      else { discoveryDeferred.set(k, d); discoveryNewPairs.deferred++; }
    }
  }
}
export async function runDiscoveryTrendAdopt(): Promise<void> {
  if (DISCOVERY_TREND_POLL_MS() <= 0) return; // A/B: легаси-режим без воронки
  if (reserveFloodPressure() > DISCOVERY_SEED_FLOOD_GATE()) { discoveryAdopt.floodSkipped++; return; } // bm2r D6
  discoveryAdopt.cycles++;
  const now = Date.now();
  const boosts = await dsFeedBoosts();
  const candidates = funnelCandidates(boosts);
  for (const c of candidates.slice(0, 10)) {
    discoveryAdopt.candidates++;
    noteDiscoveryFirstSeen(c.chainId, c.token, now);
    if (!rateAllow('ds-newpairs', DS_DISCOVERY_PER_MIN(), 60000)) { bumpFunnelSkip('trend', 'budget'); break; }
    let pools: DiscoveryPoolLike[] | null = null;
    try { pools = await fetchDsTokenPools(c.chainId, c.token); } catch { bumpFunnelSkip('trend', 'httpErr'); continue; }
    if (!pools || !pools.length) { bumpFunnelSkip('trend', 'emptyPools'); continue; }
    const best1h = [...pools].sort((a, b) => (b.volume1h ?? 0) - (a.volume1h ?? 0))[0];
    const vol1h = best1h?.volume1h ?? 0;
    const txns1h = Math.max(1, best1h?.txns1h ?? 1);
    // квалифицирующая сделка: в последний час оборот ≥ порога И средний размер сделки ≥ порога
    // (сотня мелких сделок ≠ сделка $5K+ — скам-гейт без новых RPC)
    if (vol1h >= DISCOVERY_ADOPT_MIN_USD() && vol1h / txns1h >= DISCOVERY_ADOPT_MIN_USD()) {
      const added = seedDiscoveryTopPools(c.chainId, c.token, pools, 'trend');
      if (added > 0) {
        discoveryAdopt.adopted++;
        if (c.chainId === 'solana') {
          // hot-mint: строка переживает рестарты (TROLL-класс: один якорь на сутки)
          noteSolanaHotMint({ chain: 'solana', usd: vol1h, token: c.token, symbol: best1h?.baseSymbol || '?', ts: now }); // v9.32ak AK1: реальный символ
        }
        noteDiscoveryAdoption(c.chainId, c.token);
      }
    } else {
      bumpFunnelSkip('trend', 'belowThreshold');
    }
  }
}
export async function __runDiscoveryNewPairsForTest(): Promise<void> { return runDiscoveryNewPairs(); }
export async function __runDiscoveryTrendAdoptForTest(): Promise<void> { return runDiscoveryTrendAdopt(); }
export function __discoveryNewPairsForTest() { return { ...discoveryNewPairs, skippedReasons: { ...discoveryNewPairs.skippedReasons } }; }
export function __discoveryAdoptForTest() { return { ...discoveryAdopt, skippedReasons: { ...discoveryAdopt.skippedReasons } }; }
export function __discoverySlaForTest() { return discoverySlaSnapshot(); }
export function __watchPoolsForTest(chainId: string): string[] {
  return [...(state.chain[chainId]?.watchPools || [])];
}
// v9.32bh BH1: тестам детектора нужен детерминированный вотчлист без прохода через discovery.
export function __setWatchPoolsForTest(chainId: string, pools: string[]): void {
  const st = state.chain[chainId];
  if (st) st.watchPools = [...pools];
}
export function __watchPoolsMetaForTest(chainId: string): Record<string, { address: string; subjectSymbol: string }> {
  return Object.fromEntries(Object.entries(chainWatchMeta[chainId] || {}).map(([k, v]) => [k, { ...v }]));
}

// ═══════════════════════════════════════════════════════════════════════════
// v9.32ar RD1/RD2/RD3: Coverage Audit — замкнутая обратная связь «рынок → покрытие».
// Корень класса pools:0 (11+ жертв недели): intake = f(DS-ленты), а событие рождается
// только в наблюдаемых пулах. Audit-цикл строит РЫНОЧНЫЙ СРЕЗ (DS-батч /tokens/v1 по
// WL-токенам → их вторичные пулы + соседи; DP /networks/{net}/pools топ окна), вычитает
// текущее покрытие, и каждый квалифицирующий остаток = видимый breach, который
// усыновляется в тот же цикл (посев пулов / hot-mint / хотлист). SLA получает честный
// знаменатель: breachRate = breaches/qualified, slaViolations = breach >3мин без покрытия.
// ═══════════════════════════════════════════════════════════════════════════
const COVERAGE_AUDIT_EVERY_MS = () => Number(process.env.COVERAGE_AUDIT_EVERY_MS ?? '120000');
const COVERAGE_BREACH_MIN_USD = () => Number(process.env.COVERAGE_BREACH_MIN_USD ?? '5000');
const MINT_SLOTS_MAX = () => Number(process.env.MINT_SLOTS_MAX ?? '40'); // v9.32ar RD2: потолок mint-слотов (рост от 25 по потребности)
const COVERAGE_AUDIT_MAX_REQ = 12; // инвариант ТЗ: ≤12 DS/DP-запросов на цикл (цикл 2мин → ≤12/мин)
// bm2r D7: колпак усыновления breach. Раньше эту роль исполнял flood-гейт — и, будучи сломанным
// (B13), он не ОГРАНИЧИВАЛ, а ЗАПРЕЩАЛ усыновление навсегда (прод: adoptedFromBreach заморожен
// на 52 при 10 открытых breach). Теперь гейт мерит флуд доставки (D6), а частоту держит явный
// скользящий колпак.
const COVERAGE_ADOPT_MAX_PER_10MIN = () => Math.max(0, Number(process.env.COVERAGE_ADOPT_MAX_PER_10MIN ?? '6') || 0);
// bm2q D3: предохранитель мёртвого DP-канала. Прод 48190c3: канал падал в КАЖДОМ цикле по каждой
// сети (410 Gone), сжигая ~3 кредита/мин ≈ 43 % месячного кэпа. Цикл без единого успешного ответа
// DP = канал мёртв → пауза: кредиты не жжём, слепота остаётся видимой (partial:true, skipped-dead).
const COVERAGE_DP_FALLBACK_MS = () => Math.max(0, Number(process.env.COVERAGE_DP_FALLBACK_MS ?? '300000') || 0);
// bm2q D4: полоса DS-бюджета, зарезервированная за аудитом. Прод 48190c3: 3 из 4 срезов пришли со
// skipped-rate — discovery выедал общий потолок 250 ([disc] exit=ds-budget-saturated) и аудит терял
// DS-канал целиком. Резерв 6 (= числу сетей) держим за аудитом; провайдерский потолок ~300/мин,
// поэтому 250 + 6 остаётся в безопасной зоне.
const COVERAGE_DS_RESERVE = () => Math.max(0, Number(process.env.COVERAGE_DS_RESERVE ?? '6') || 0);
const COVERAGE_ADOPT_TTL_MS = 10 * 60_000; // повторный breach того же токена <TTL не дублирует adoption
const COVERAGE_SLA_MS = 3 * 60_000; // breach живёт дольше без adoption → slaViolation

export interface CoverageSlicePool {
  chainId: string; pool: string; token: string; symbol?: string; quoteSymbol?: string;
  volWindowUsd: number; vol24hUsd?: number; txnsWindow?: number; reserveUsd?: number;
}
export interface CoverageBreachRow {
  chain: string; pool: string; token: string; symbol: string; usd: number;
  firstSeenAt: number; lastSeenAt: number; adoptedAt: number; slaFlagged: boolean;
}
// bm2p: состояние канала среза. 'no-input' — спрашивать было нечего (не слепота);
// 'skipped-rate' / 'skipped-no-key' — запрос НЕ сделан по нашей причине (слепота: рынок не проверен).
type SliceChannelState = 'ok' | 'no-input' | 'fail' | 'skipped-rate' | 'skipped-no-key' | 'skipped-dead' | 'throttled';   // bm2r D4: +throttled (429 провайдера / активный бэкофф шлюза)
type SliceCompletenessSource = 'ds' | 'dp' | 'both' | 'none';
type CoverageSliceResult = {
  pools: CoverageSlicePool[]; requests: number; partial?: boolean;
  partialReason?: string;                                          // bm2t T6: 'ds-throttled' не блокирует DP
  completenessSource?: SliceCompletenessSource;                    // bm2t T6: какой канал закрыл все сети
  channels?: { ds: SliceChannelState; dp: SliceChannelState };   // bm2p D3
  // bm2q D7: чем ответил канал (HTTP-статус + короткая ошибка последней попытки) и сколько раз
  // спрашивали — без этого слепоту приходится расследовать по внешней документации.
  // bm2r D4: +retryAfterSec — сколько секунд провайдер просил подождать (0 = не просил).
  channelDetail?: {
    ds: { status: number; error: string; retryAfterSec: number };
    dp: { status: number; error: string; retryAfterSec: number };
  };
  attempts?: { ds: number; dp: number };
};
function sliceChannelBlind(s: SliceChannelState): boolean {
  return s === 'fail' || s === 'skipped-rate' || s === 'skipped-no-key' || s === 'skipped-dead' || s === 'throttled'; // bm2r: throttled = та же слепота, но причина провайдерская
}
// Слепота важнее успеха: успех следующей сети обязан затирать 'no-input', но не отказ.
function sliceChannelWorst(a: SliceChannelState, b: SliceChannelState): SliceChannelState {
  const sev = (s: SliceChannelState) => (s === 'no-input' ? 0 : s === 'ok' ? 1 : s === 'fail' ? 2 : s === 'skipped-no-key' ? 4 : 3);
  return sev(a) >= sev(b) ? a : b;
}
// bm2r D4: тело 429 от Cloudflare — HTML-страница на десятки килобайт; в статус идёт метка,
// а не страница. Прочие коды — первые 60 символов текста с вырезанными тегами.
function sliceErrorLabel(status: number, raw?: string): string {
  if (status === 429) return 'rate-limit';
  if (status >= 500) return 'http-5xx';
  if (status === 0) return 'network';
  const t = String(raw || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
  return t ? t.slice(0, 60) : `http-${status}`;
}
// bm2q D2/D5: строка DP pools/search → кандидат среза. Форма — по документации вендора
// (docs.dexpaprika.com, tutorial token-pools): id (адрес пула), volume_usd_24h, transactions_24h,
// liquidity_usd, tokens[] — ТОЛЬКО адреса («a search row carries no symbol or name»); часового
// окна нет. Отсюда два решения: (1) subject — первый НЕ-базовый токен: позиция ничего не значит,
// интересный токен часто идёт второй стороной; (2) окно нормализуем 24h/24, чтобы порог
// COVERAGE_BREACH_MIN_USD и логи остались часовыми; vol24hUsd — сырое 24h (его читают
// seedBreachPools/seedWatchPool).
type DpSearchRow = {
  id?: string; volume_usd_24h?: number; liquidity_usd?: number; transactions_24h?: number;
  tokens?: Array<{ id?: string }>;
};
// bm2q D8: «база по адресу» — общий язык EVM и Solana. Нужна обеим ветвям: search-строки приходят
// без символов, и символьная сетка для них не работает.
function coverageBaseByAddress(chainId: string, token: string, symbol: string): boolean {
  return isNativeOrWrapped(token) || isStablecoin(token, symbol) || isMajorToken(chainId, token);
}
function dpSlicePoolFromSearch(chainId: string, row: DpSearchRow): CoverageSlicePool | null {
  const pool = String(row?.id || '');
  if (!pool) return null;
  const tokens = (Array.isArray(row?.tokens) ? row.tokens : []).map(t => String(t?.id || '')).filter(Boolean);
  if (tokens.length < 2) return null;                       // нужны обе стороны пары
  const subject = tokens.find(t => !coverageBaseByAddress(chainId, t, ''));
  if (!subject) return null;                                // пул из одних баз — не кандидат аудита
  const vol24h = Number(row.volume_usd_24h) || 0;
  return {
    chainId, pool, token: subject, symbol: '', quoteSymbol: '',
    volWindowUsd: vol24h / 24, vol24hUsd: vol24h,
    txnsWindow: Number(row.transactions_24h) || 0,
    reserveUsd: Number(row.liquidity_usd) || 0,
  };
}
let coverageSliceImpl: ((chains: string[]) => Promise<CoverageSliceResult>) | null = null;
// bm2o: DS/DP-каналы среза инжектируются — прод-путь «источник отказал» обязан быть тестируемым
// без сети (иначе дефект B2 не поймать: реальный фетчер не бросает исключение, а копит fails).
let coverageSliceHttpImpl: typeof getJSON = getJSON;
export function __setCoverageSliceHttpForTest(fn: typeof getJSON | null): void { coverageSliceHttpImpl = fn || getJSON; }
// bm2q: снять ведро rate-гейта цикла, не трогая состояние аудита (иначе теряются счётчики и
// предохранитель). Ровно то, что делает __resetCoverageAuditForTest внутри себя.
export function __clearCoverageAuditRateForTest(): void { rateMap.delete('coverage-audit'); }
// bm2q D4: точная занятость общего DS-лейна — живой сценарий «discovery выел бюджет».
export function __setSliceDsBudgetForTest(n: number | null): void {
  requestBudget.dexscreener.length = 0;
  if (n == null) return;
  const now = Date.now();
  for (let i = 0; i < Math.max(0, Math.floor(n)); i++) requestBudget.dexscreener.push(now);
}
// bm2q D4: занят ровно discovery-потолок — аудит обязан сохранить право на свой запрос из резерва.
export function __setSliceDsBudgetDiscoveryFullForTest(): void { __setSliceDsBudgetForTest(DS_POOL_RATE_MIN); }
// bm2p: насыщение бюджета среза. Живой сценарий «свой лимитер не дал запрос» иначе не
// воспроизвести: канал молча пропускается — ровно тот класс, который bm2o объявил слепотой.
// Очистка обязательна: свежие отметки не вычищаются cleanBudget() и отравили бы следующие тесты.
export function __setSliceBudgetFullForTest(full = true): void {
  requestBudget.dexscreener.length = 0;
  requestBudget.dexpaprika.length = 0;
  if (!full) return;
  const now = Date.now();
  // bm2q D4: «насыщено» = оба лейна в потолке (иначе bm2p-тест P8 перестанет проверять слепоту:
  // резерв сам пропустил бы запрос)
  for (let i = 0; i < DS_POOL_RATE_MIN + COVERAGE_DS_RESERVE(); i++) requestBudget.dexscreener.push(now);
  for (let i = 0; i < DEXPAPRIKA_REST_PER_MIN_KEYED; i++) requestBudget.dexpaprika.push(now);
}
// bm2o D5: мок-срез считается полным. Тест, воспроизводящий НЕПОЛНЫЙ источник, обязан
// вернуть `partial: true` — иначе он проверяет не то поведение (прод-путь «источник отказал»
// бросает исключение, а «источник ответил частично» ставит partial из fetcher'а).
export function __setCoverageSliceForTest(fn: ((chains: string[]) => Promise<CoverageSliceResult>) | null): void { coverageSliceImpl = fn; }

const coverageAuditState = {
  cycles: 0, breachesTotal: 0, adoptedFromBreach: 0, auditSourceFails: 0,
  requestsLastCycle: 0, budgetClamped: false, lastSliceOkAt: 0,
  lastSlicePartial: false, sliceFailsTotal: 0,                       // bm2o D3
  sliceChannels: null as null | { ds: SliceChannelState; dp: SliceChannelState },   // bm2p D3
  partialReason: null as null | string,                                                              // bm2t T6
  sliceCompletenessSource: 'none' as SliceCompletenessSource,                                        // bm2t T6
  sliceChannelDetail: null as null | { ds: { status: number; error: string; retryAfterSec: number }; dp: { status: number; error: string; retryAfterSec: number } },  // bm2q D7 + bm2r D4 (retryAfterSec)
  sliceAttempts: null as null | { ds: number; dp: number },                                                          // bm2q D7
  adoptionFloodSkips: 0, adoptionCapSkips: 0,                                                                          // bm2r D8: причины отказа усыновления — видны
  lastAdoptionBlock: null as null | { reason: 'flood' | 'cap'; at: number; pressure: number },                       // bm2r D8
};
const coverageAdoptGateRing: number[] = [];   // bm2r D7: времена успешных усыновлений (скользящее окно 10 мин)
const coverageBreaches = new Map<string, CoverageBreachRow>();   // chain:pool → breach
const coverageBacklog = new Map<string, CoverageBreachRow>();    // ниже порога — видимость без adoption
const coverageAdoptedAt = new Map<string, number>();             // chain:token → adoptedAt (TTL-дедуп)
const coveragePendingSolSlots = new Map<string, number>();       // solana mint → нужен слот (до подписки), TTL
const coverageQualifiedRing: Array<{ at: number; n: number }> = [];
const coverageBreachRing: Array<{ at: number }> = [];
const coverageAdoptRing: Array<{ at: number; ms: number }> = [];
let coverageAuditAt = 0;
let coverageDpDeadUntil = 0;   // bm2q D3: предохранитель DP-канала (until, мс по coverageNow)

function coverageNow(): number { return getSolanaPubsub().subNowImpl(); }
function coverageRingPrune(now: number): void {
  const horizon = now - 24 * 3600_000;
  while (coverageQualifiedRing.length && coverageQualifiedRing[0].at < horizon) coverageQualifiedRing.shift();
  while (coverageBreachRing.length && coverageBreachRing[0].at < horizon) coverageBreachRing.shift();
  while (coverageAdoptRing.length && coverageAdoptRing[0].at < horizon) coverageAdoptRing.shift();
}
function coverageSlaSnapshot(): {
  qualified24h: number; breaches24h: number; breachRate: number; adoptedFromBreach24h: number;
  p50TimeToAdoptSec: number; slaViolations: number;
} {
  const now = coverageNow();
  coverageRingPrune(now);
  const qualified = coverageQualifiedRing.reduce((a, r) => a + r.n, 0);
  const breaches = coverageBreachRing.length;
  const slaViolations = [...coverageBreaches.values()].filter(b => b.slaFlagged).length + 0;
  return {
    qualified24h: qualified,
    breaches24h: breaches,
    breachRate: qualified > 0 ? Math.round((breaches / qualified) * 1000) / 1000 : 0,
    adoptedFromBreach24h: coverageAdoptRing.length,
    p50TimeToAdoptSec: coverageAdoptRing.length ? Math.round(percentile50(coverageAdoptRing.map(r => r.ms)) / 100) / 10 : 0,
    slaViolations,
  };
}

// покрытие на момент среза: пул ИЛИ токен уже наблюдается (watchPools ∪ хотлист ∪ anchors ∪ mint-слоты/hot-mints)
function coverageSetsFor(chainId: string): { pools: Set<string>; tokens: Set<string> } {
  const pools = new Set<string>();
  const tokens = new Set<string>();
  for (const p of state.chain[chainId]?.watchPools || []) pools.add(lower(p));
  for (const k of Object.keys(chainWatchMeta[chainId] || {})) pools.add(lower(k));
  for (const e of Object.values(chainWatchMeta[chainId] || {})) if (e.subjectToken) tokens.add(lower(e.subjectToken));
  for (const a of ANCHOR_POOLS[chainId] || []) { if (a.pool) pools.add(lower(a.pool)); if (a.subject) tokens.add(lower(a.subject)); }
  if (chainId === 'solana') {
    // v9.32ar: getSolanaPubsub().hotMintsCache — кандидат-пул, НЕ покрытие: вытесненный из слота hot-mint без
    // текущей подписки обязан числиться непокрытым (иначе его «новый breach» невидим — Т3в).
    for (const m of collectSolWlMints()) tokens.add(lower(m));
    for (const m of solMintMentionSubs) tokens.add(lower(m));
  } else {
    for (const a of evmHotAddrs(chainId)) pools.add(lower(a));
  }
  return { pools, tokens };
}

function coverageCandidateFiltered(c: CoverageSlicePool): boolean {
  const sym = String(c.symbol || '');
  const chainId = c.chainId;
  // bm2q D8: база — по адресу, первым делом. Строки pools/search приходят без символов, поэтому
  // солана-пулы с WSOL/USDC-стороной могли квалифицироваться как breach (шум и ложное усыновление).
  if (coverageBaseByAddress(chainId, c.token, sym)) return true;
  if (chainId === 'solana' && /^(wsol|sol|usdc|usdt|usde|dai)$/i.test(sym)) return true; // фолбэк символов (DS-путь)
  if (checkBlacklist(chainId, c.token, sym).skip) return true;
  return false;
}

// реальный срез: DS-батч по WL-токенам (вторичные пулы + соседи) + DP top-пулы сети
async function fetchCoverageSliceReal(chains: string[]): Promise<CoverageSliceResult> {
  const pools: CoverageSlicePool[] = [];
  let requests = 0;
  let fails = 0;
  let truncated = false;
  // bm2p D1/D2: канал DP обязан идти с ключом. Прод 2026-09-25: анонимный тариф отдаёт
  // 402 «Monthly limit reached for unauthenticated use», срез навсегда partial, усыновление
  // breach выключено — при том что ключ в окружении ЕСТЬ (dexPaprika.keyed:true, 26/300000).
  // Без ключа канал не вызывается вовсе: слепота фиксируется состоянием, а не сожжённым запросом.
  // trim() — ключ из пробелов обязан считаться отсутствующим, иначе в заголовок уйдёт «Bearer    ».
  const dpKey = String(getDexPaprikaApiKey() || '').trim();
  const dpPerMinLimit = dpKey ? DEXPAPRIKA_REST_PER_MIN_KEYED : DEXPAPRIKA_REST_PER_MIN; // bm2p D5: лимит канала, а не DS-потолок 250
  const dpDead = coverageNow() < coverageDpDeadUntil;   // bm2q D3: пауза предохранителя
  let dpAttempts = 0; let dpOk = 0;                     // bm2q D3/D7: попытки и успехи DP за цикл
  let dpStatus = 0; let dpError = '';                   // bm2q D7: чем ответил канал (для статуса)
  let dsAttempts = 0; let dsStatus = 0; let dsError = '';   // bm2q D7: то же для DS-канала
  let dsRetryAfterSec = 0;              // bm2r D4: сколько провайдер просил подождать (секунды)
  let dsThrottledThisCycle = false;     // bm2r A6: 429 замечен ИМЕННО на этом цикле (для честного счёта фейлов)
  let dsState: SliceChannelState = 'no-input';
  let dpState: SliceChannelState = 'no-input';
  let dsOkChains = 0;
  let dpOkChains = 0;
  for (const chainId of chains) {
    if (requests + 2 > COVERAGE_AUDIT_MAX_REQ) { truncated = true; break; } // бюджет цикла — жёсткий инвариант
    const meta = chainWatchMeta[chainId] || {};
    const wlTokens = [...new Set(Object.values(meta).map(e => String(e.subjectToken || '')).filter(Boolean))];
    const queried = new Set(wlTokens.map(lower));
    // (1) DS батч: до 30 адресов → ВСЕ пулы WL-токенов (вторичные FONE-класс + соседи JIMOTHY-класс)
    if (wlTokens.length > 0) {
      try {
        // bm2r D4: решение о запросе — за общим шлюзом: он один знает про бэкофф 429 и про
        // занятость бюджета (резерв аудита bm2q D4 живёт внутри dsGate для источника 'slice').
        const gate = dsGate('slice');
        if (!gate.allowed) {
          dsState = sliceChannelWorst(dsState, gate.reason === 'backoff' ? 'throttled' : 'skipped-rate');
          if (gate.reason === 'backoff' && !dsError) dsError = 'backoff';
          // bm2r A6: 429-цикл ослепил ВСЕ входные сети цикла («смотрели и не увидели», как при
          // настоящем отказе) — фейл считаем каждой. Чистые бэкоффные циклы (ни одного запроса,
          // запросов просто не было) фейлов не добавляют (A7: "новых фейлов нет").
          if (gate.reason === 'backoff' && dsThrottledThisCycle) fails++;
        } else {
          requests++;
          dsAttempts++;   // bm2q D7
          const dsChain = CHAINS[chainId]?.ds;
          const res = await coverageSliceHttpImpl<Array<Record<string, any>>>('https://api.dexscreener.com/tokens/v1/' + dsChain + '/' + wlTokens.slice(0, 30).join(','));
          dsNoteResult('slice', res.status, res.retryAfterMs);   // bm2r D3: 429 взводит общий бэкофф
          // bm2q D7 + bm2r D4: статус объясняет СЛЕПОТУ — отказ запоминается (успехи других сетей
          // его не затирают), а 429 получает читаемую метку 'rate-limit' вместо HTML-страницы.
          if (!res.ok) {
            dsStatus = res.status;
            dsError = sliceErrorLabel(res.status, res.error);
            dsRetryAfterSec = res.retryAfterMs ? Math.round(res.retryAfterMs / 1000) : 0;
          } else if (dsStatus === 0) { dsStatus = res.status; dsError = ''; dsRetryAfterSec = 0; }
          if (!res.ok) {
            fails++;
            dsState = sliceChannelWorst(dsState, res.status === 429 ? 'throttled' : 'fail');
            if (res.status === 429) dsThrottledThisCycle = true;
          } else {
            if (Array.isArray(res.data)) {
              for (const p of res.data) {
                const pairAddr = String(p.pairAddress || '');
                const base = String(p.baseToken?.address || '');
                const quote = String(p.quoteToken?.address || '');
                if (!pairAddr) continue;
                // сосед = сторона, НЕ совпадающая с запрошенным WL-токеном
                let token = base, symbol = String(p.baseToken?.symbol || ''), quoteSymbol = String(p.quoteToken?.symbol || '');
                if (queried.has(lower(base)) && !queried.has(lower(quote))) { token = quote; symbol = String(p.quoteToken?.symbol || ''); quoteSymbol = String(p.baseToken?.symbol || ''); }
                pools.push({
                  chainId, pool: pairAddr, token, symbol, quoteSymbol,
                  volWindowUsd: Number(p.volume?.h1) || 0, vol24hUsd: Number(p.volume?.h24) || 0,
                  txnsWindow: (Number(p.txns?.h1?.buys) || 0) + (Number(p.txns?.h1?.sells) || 0),
                  reserveUsd: Number(p.liquidity?.usd) || 0,
                });
              }
            }
            dsState = sliceChannelWorst(dsState, 'ok');
            dsOkChains++;
          }
        }
      } catch { fails++; dsStatus = 0; dsError = 'network'; dsRetryAfterSec = 0; dsState = sliceChannelWorst(dsState, 'fail'); }
    }
    // (2) DP universe-топ: пулы сети по объёму (JIMOTHY-класс: токен вообще вне WL-окружения).
    // bm2q D1: ЖИВОЙ эндпоинт. Старый /networks/{net}/pools удалён вендором и возвращает 410 Gone
    // (docs: «replaces the old … /networks/{network}/pools»); прод 48190c3 жёг на нём ~3 кредита/мин
    // (pools_list == sliceFailsTotal в каждом срезе) и получал fail в каждом цикле.
    try {
      const dpNet = CHAINS[chainId]?.dpNetwork;
      if (dpNet) {
        if (!dpKey) {
          dpState = sliceChannelWorst(dpState, 'skipped-no-key');   // bm2p D2: анонимно не ходим
        } else if (dpDead) {
          dpState = sliceChannelWorst(dpState, 'skipped-dead');   // bm2q D3: пауза предохранителя
        } else if (requestBudget.dexpaprika.length < dpPerMinLimit) {
          cleanBudget();
          requestBudget.dexpaprika.push(Date.now());
          requests++;
          dpAttempts++;
          const res = await coverageSliceHttpImpl<{ results?: DpSearchRow[] }>(
            `https://api.dexpaprika.com/networks/${dpNet}/pools/search?order_by=volume_usd_24h&sort=desc&limit=30`,
            { headers: { Authorization: `Bearer ${dpKey}` } },      // bm2p D1: ключ, как во всех DP-путях движка
          );
          // bm2q D7: отказ не затирается успехами других сетей (см. DS-ветку)
          if (!res.ok) { dpStatus = res.status; dpError = String(res.error || '').slice(0, 120); }
          else if (dpStatus === 0) dpStatus = res.status;
          if (!res.ok) {
            fails++;
            dpState = sliceChannelWorst(dpState, 'fail');
          } else {
            dpOk++;
            const rows = Array.isArray(res.data?.results) ? res.data.results : [];
            for (const row of rows) {
              const cand = dpSlicePoolFromSearch(chainId, row);
              if (cand) pools.push(cand);
            }
            dpState = sliceChannelWorst(dpState, 'ok');
            dpOkChains++;
          }
        } else {
          dpState = sliceChannelWorst(dpState, 'skipped-rate');
        }
      }
    } catch { fails++; dpStatus = 0; dpError = 'network'; dpState = sliceChannelWorst(dpState, 'fail'); }
  }
  // bm2q D3: ни одной удачи при ≥1 попытке = канал мёртв (410/403/5xx/сеть) → пауза. Без этого
  // битый эндпоинт жжётся вечно (прод: 24 фейла за 5 циклов = 24 кредита впустую).
  if (dpAttempts > 0 && dpOk === 0) coverageDpDeadUntil = coverageNow() + COVERAGE_DP_FALLBACK_MS();
  coverageAuditState.auditSourceFails += fails;
  // bm2o D2 + bm2p D4: срез неполный, если канал отказал, был пропущен своим лимитером или не
  // мог быть спрошен без ключа. 'no-input' слепотой не считается: спрашивать было нечего.
  return {
    pools, requests,
    channels: { ds: dsState, dp: dpState },
    // bm2q D7: чем ответил канал последней попыткой и сколько раз спрашивали
    // bm2r D4: +retryAfterSec (у DP провайдер паузу не просил — всегда 0)
    channelDetail: {
      ds: { status: dsStatus, error: dsError, retryAfterSec: dsRetryAfterSec },
      dp: { status: dpStatus, error: dpError, retryAfterSec: 0 },
    },
    attempts: { ds: dsAttempts, dp: dpAttempts },
    partial: fails > 0 || truncated || sliceChannelBlind(dsState) || sliceChannelBlind(dpState),
    // bm2t T6: канал «закрыл срез», если ответил 200 по каждой сети и обход не оборван бюджетом.
    // Пустой 200 — это данные («смотрели»), не слепота. Один живой канал достаточен для усыновления.
    completenessSource: (!truncated && dsOkChains === chains.length && dpOkChains === chains.length) ? 'both'
      : (!truncated && dsOkChains === chains.length) ? 'ds'
      : (!truncated && dpOkChains === chains.length) ? 'dp'
      : 'none',
    partialReason: dsState === 'throttled' ? 'ds-throttled' : undefined,
  };
}

// посев из breach — существующие контент-фильтры уже пройдены (coverageCandidateFiltered),
// анти-скам: порог квалификации = сам факт объёма окна ≥ COVERAGE_BREACH_MIN_USD
// (avg-trade-гейт воронок защищает от скам-ФЛУДА из лент; здесь триггер — единичный факт
// рынка, JIMOTHY $9 493 при txns=2 прошёл бы avg-гейт ложно негативно). Flood-gate — да.
let lastAdoptionBlockLogAt = 0;
function logAdoptionBlocked(reason: string): void {
  const t = Date.now();
  if (t - lastAdoptionBlockLogAt < 5 * 60_000) return;
  lastAdoptionBlockLogAt = t;
  log(`[coverage] усыновление придержано: ${reason}`);   // 1/5мин — не флудим лог
}
// bm2t T6: при живом DP и давлении (открытые breach >20 или backlog >20) колпак 12/10мин.
// База остаётся 6 — иначе R12 (8 breach, DP не жив) перестанет держать ровно 6.
function coverageDpLive(now = coverageNow()): boolean {
  return coverageAuditState.sliceChannels?.dp === 'ok' && now >= coverageDpDeadUntil;
}
function coverageAdoptEffectiveCap(now = coverageNow()): number {
  const base = COVERAGE_ADOPT_MAX_PER_10MIN();
  if (base <= 0) return base;
  const open = [...coverageBreaches.values()].filter(b => !b.adoptedAt).length;
  if (coverageDpLive(now) && (open > 20 || coverageBacklog.size > 20)) return Math.max(base, 12);
  return base;
}
// bm2r D7: скользящее окно 10 минут. Колпак 0 = выключен (страховка для тестов/экспериментов).
function coverageAdoptCapOpen(now: number, pressure: number): boolean {
  const win = 10 * 60_000;
  while (coverageAdoptGateRing.length > 0 && coverageAdoptGateRing[0] <= now - win) coverageAdoptGateRing.shift();
  const cap = coverageAdoptEffectiveCap(now);
  if (cap > 0 && coverageAdoptGateRing.length >= cap) {
    coverageAuditState.adoptionCapSkips++;
    coverageAuditState.lastAdoptionBlock = { reason: 'cap', at: now, pressure };
    return false;
  }
  return true;
}
function seedBreachPools(b: CoverageBreachRow, slice: CoverageSlicePool[], now = coverageNow()): boolean {
  // bm2r D6/D8: гейт мерит давление доставки и НАЗЫВАЕТ причину отказа — молчаливый return false
  // (прод: adoptedFromBreach заморожен на 52, и причины нигде не было) больше недопустим.
  const pressure = reserveFloodPressure();
  if (pressure > DISCOVERY_SEED_FLOOD_GATE()) {
    coverageAuditState.adoptionFloodSkips++;
    coverageAuditState.lastAdoptionBlock = { reason: 'flood', at: now, pressure };
    logAdoptionBlocked(`flood pressure=${pressure} > ${DISCOVERY_SEED_FLOOD_GATE()}`);
    return false;
  }
  // bm2r D7: колпак успешных усыновлений за 10 минут; сверх — ретрай в следующем цикле.
  if (!coverageAdoptCapOpen(now, pressure)) return false;
  const mine = slice
    .filter(p => p.chainId === b.chain && lower(p.token) === lower(b.token))
    .sort((x, y) => y.volWindowUsd - x.volWindowUsd)
    .slice(0, WATCH_POOLS_HOT_TOP_N());
  if (mine.length === 0) return false;
  for (const p of mine) {
    seedWatchPool(b.chain, p.pool, p.token, p.symbol || '?', p.quoteSymbol || '?', p.reserveUsd || 0, p.vol24hUsd || p.volWindowUsd * 24);
  }
  if (b.chain === 'solana') {
    noteSolanaHotMint({ chain: 'solana', usd: b.usd, token: b.token, symbol: b.symbol || '?', ts: coverageNow() });
    coveragePendingSolSlots.set(b.token, coverageNow() + COVERAGE_ADOPT_TTL_MS); // base58: БЕЗ lower()
  } else {
    solPubsubResubIfConnected();
  }
  return true;
}

async function adoptPendingBreaches(now: number): Promise<void> {
  for (const b of coverageBreaches.values()) {
    if (b.adoptedAt > 0) continue;
    const tokenKey = b.chain + ':' + lower(b.token);
    const adoptedAt = coverageAdoptedAt.get(tokenKey);
    if (adoptedAt !== undefined && now - adoptedAt < COVERAGE_ADOPT_TTL_MS) continue; // дедуп-TTL
    // Flood-gate/RPC pressure is temporary; keep retrying an active breach.
    // A time limit here used to turn a live coverage hole into a stuck state.
    const lastSlice = lastCoverageSliceRows;
    const ok = lastSlice ? seedBreachPools(b, lastSlice, now) : false;
    if (ok) {
      b.adoptedAt = now;
      coverageAdoptedAt.set(tokenKey, now);
      coverageAuditState.adoptedFromBreach++;
      coverageAdoptGateRing.push(now);   // bm2r D7: успешное усыновление занимает слот колпака
      coverageAdoptRing.push({ at: now, ms: now - b.firstSeenAt });
      log(`[coverage] adopted ${b.chain} ${b.symbol || b.token} ($${Math.round(b.usd)}) за ${Math.round((now - b.firstSeenAt) / 1000)}с от первого факта`);
    }
  }
}
let lastCoverageSliceRows: CoverageSlicePool[] | null = null;

function coverageSlaSweep(now: number): void {
  for (const b of coverageBreaches.values()) {
    if (b.adoptedAt > 0 || b.slaFlagged) continue;
    if (now - b.firstSeenAt > COVERAGE_SLA_MS && coverageAuditState.lastSliceOkAt > 0) {
      b.slaFlagged = true;
      log(`[coverage] SLA VIOLATION: ${b.chain} ${b.symbol || b.token} без покрытия ${Math.round((now - b.firstSeenAt) / 60000)}мин (источники живы)`);
    }
  }
}

export async function runCoverageAudit(): Promise<void> {
  if (COVERAGE_AUDIT_EVERY_MS() <= 0) return; // выключен
  // бюджет ≤12 DS/DP-запросов/мин — реальный фетчер сам ограничен циклом; rate-гейт страхует
  // серию быстрых циклов (и не трогает тестовый мок-срез)
  if (!coverageSliceImpl && !rateAllow('coverage-audit', COVERAGE_AUDIT_MAX_REQ, 60000)) return;
  coverageAuditState.cycles++;
  const now = coverageNow();
  const auditSourceFailsBefore = coverageAuditState.auditSourceFails;
  let slice: CoverageSliceResult;
  try {
    const chains = Object.keys(CHAINS);
    if (coverageSliceImpl) {
      // bm2o D5: мок-срез по умолчанию считается ПОЛНЫМ (нулевая правка существующих тестов);
      // неполноту тест объявляет явно — `partial: true`.
      const r = await coverageSliceImpl(chains);
      // bm2t T6: строковый partial ('ds-throttled') сохраняем как причину, не выбрасываем.
      const partialReason = typeof r.partial === 'string' ? r.partial : r.partialReason;
      slice = {
        pools: r.pools, requests: r.requests,
        partial: r.partial === true || typeof r.partial === 'string',
        channels: r.channels, channelDetail: r.channelDetail, attempts: r.attempts,
        partialReason, completenessSource: r.completenessSource,
      };
    } else {
      slice = await fetchCoverageSliceReal(chains);
    }
  } catch (e) {
    coverageAuditState.auditSourceFails++;
    log(`[coverage] источник среза недоступен: ${(e as Error).message} (fails=${coverageAuditState.auditSourceFails})`);
    slice = { pools: [], requests: 0, partial: true };
  }
  // bm2o D1/D2: цикл считается ПОЛНЫМ только если ни один канал не отказал и обход сетей не
  // оборван бюджетом. Дефект B1: при фейле источника `pools: []` попадал в cleanup, и через час
  // удалялись все breaches, а slaViolations обнулялся — метрика зеленела РОВНО в момент слепоты
  // (пробник: breaches 0 при breachRate 1). Деградация обязана быть громкой.
  const partial = slice.partial === true;
  const source = slice.completenessSource ?? (partial ? 'none' : 'both');
  // bm2t T6: один канал с данными по каждой сети — срез пригоден для усыновления, даже если
  // второй throttled. Чистку breaches это НЕ разрешает: неполный срез не доказывает исчезновение.
  const channelLive = source === 'ds' || source === 'dp' || source === 'both';
  coverageAuditState.lastSlicePartial = partial;
  coverageAuditState.partialReason = partial ? (slice.partialReason ?? null) : null;
  coverageAuditState.sliceCompletenessSource = source;
  coverageAuditState.sliceChannels = slice.channels ?? null;   // bm2p D3: почему срез неполный
  coverageAuditState.sliceChannelDetail = slice.channelDetail ?? null;   // bm2q D7
  coverageAuditState.sliceAttempts = slice.attempts ?? null;             // bm2q D7
  if (!partial || channelLive) {
    coverageAuditState.lastSliceOkAt = now;
    lastCoverageSliceRows = slice.pools;
  }
  // bm2p D7: счётчик считает ВСЕ отказы каналов (имя = семантика), а не только «в неполных циклах».
  coverageAuditState.sliceFailsTotal += coverageAuditState.auditSourceFails - auditSourceFailsBefore;
  coverageAuditState.requestsLastCycle = Math.min(slice.requests || 0, COVERAGE_AUDIT_MAX_REQ);
  coverageAuditState.budgetClamped = (slice.requests || 0) > COVERAGE_AUDIT_MAX_REQ;
  const minUsd = COVERAGE_BREACH_MIN_USD();
  let qualified = 0;
  const seenKeys = new Set<string>();
  for (const c of slice.pools) {
    if (!c || !c.pool || !c.token || !CHAINS[c.chainId]) continue;
    const key = c.chainId + ':' + lower(c.pool);
    seenKeys.add(key);
    if (coverageCandidateFiltered(c)) continue; // контент-фильтры: stable/major/blacklist — не кандидаты
    if (c.volWindowUsd < minUsd) { // ниже порога — видимость (backlog), в знаменатель не входит
      coverageBacklog.set(key, {
        chain: c.chainId, pool: c.pool, token: c.token, symbol: String(c.symbol || ''),
        usd: c.volWindowUsd, firstSeenAt: now, lastSeenAt: now, adoptedAt: 0, slaFlagged: false,
      });
      continue;
    }
    qualified++; // v9.32ar RD3: знаменатель SLA = ВСЕ qualified пула среза (покрытые и нет)
    const sets = coverageSetsFor(c.chainId);
    if (sets.pools.has(lower(c.pool)) || sets.tokens.has(lower(c.token))) continue; // уже покрыт
    {
      const ex = coverageBreaches.get(key);
      if (ex) { ex.lastSeenAt = now; ex.usd = Math.max(ex.usd, c.volWindowUsd); continue; }
      coverageBreaches.set(key, {
        chain: c.chainId, pool: c.pool, token: c.token, symbol: String(c.symbol || ''),
        usd: c.volWindowUsd, firstSeenAt: now, lastSeenAt: now, adoptedAt: 0, slaFlagged: false,
      });
      coverageAuditState.breachesTotal++;
      coverageBreachRing.push({ at: now });
      log(`[coverage] breach ${c.chainId} ${c.symbol || c.token} $${Math.round(c.volWindowUsd)} вне покрытия — усыновляем`);
    }
  }
  // bm2o D1: чистим ТОЛЬКО по полному срезу. Неполный срез не доказывает, что кандидат исчез.
  if (!partial) {
    for (const [k, b] of coverageBreaches) {
      if (!seenKeys.has(k) && now - b.lastSeenAt > 3600_000) coverageBreaches.delete(k);
    }
  }
  if (coverageBacklog.size > 200) {
    const byAge = [...coverageBacklog.entries()].sort((x, y) => x[1].lastSeenAt - y[1].lastSeenAt);
    for (const [k] of byAge.slice(0, coverageBacklog.size - 200)) coverageBacklog.delete(k);
  }
  coverageQualifiedRing.push({ at: now, n: qualified });
  await adoptPendingBreaches(now);
  coverageSlaSweep(now);
  coverageRingPrune(now);
}

export function __runCoverageAuditForTest(): Promise<void> { return runCoverageAudit(); }
export function __coverageAuditForTest(): {
  cycles: number; breaches: number; backlog: number; adoptedFromBreach: number;
  auditSourceFails: number; requestsLastCycle: number; budgetClamped: boolean;
} {
  return {
    cycles: coverageAuditState.cycles,
    breaches: coverageBreaches.size,
    backlog: coverageBacklog.size,
    adoptedFromBreach: coverageAuditState.adoptedFromBreach,
    auditSourceFails: coverageAuditState.auditSourceFails,
    requestsLastCycle: coverageAuditState.requestsLastCycle,
    budgetClamped: coverageAuditState.budgetClamped,
  };
}
export function __coverageBreachesForTest(): CoverageBreachRow[] { return [...coverageBreaches.values()].map(b => ({ ...b })); }
export function __coverageBacklogForTest(): CoverageBreachRow[] { return [...coverageBacklog.values()].map(b => ({ ...b })); }
export function __coverageSlaForTest(): ReturnType<typeof coverageSlaSnapshot> { return coverageSlaSnapshot(); }
export function __resetCoverageAuditForTest(): void {
  coverageAuditState.cycles = 0; coverageAuditState.breachesTotal = 0; coverageAuditState.adoptedFromBreach = 0;
  coverageAuditState.auditSourceFails = 0; coverageAuditState.requestsLastCycle = 0; coverageAuditState.budgetClamped = false;
  coverageAuditState.lastSliceOkAt = 0; coverageAuditState.lastSlicePartial = false; coverageAuditState.sliceFailsTotal = 0;
  coverageAuditState.sliceChannels = null;
  coverageAuditState.partialReason = null;
  coverageAuditState.sliceCompletenessSource = 'none';
  coverageAuditState.sliceChannelDetail = null;   // bm2q D7
  coverageAuditState.sliceAttempts = null;        // bm2q D7
  coverageDpDeadUntil = 0;   // bm2q D3: полный сброс снимает и предохранитель (bm2o T4д ждёт 12 запросов во 2-м цикле)
  coverageBreaches.clear(); coverageBacklog.clear(); coverageAdoptedAt.clear(); coveragePendingSolSlots.clear();
  coverageQualifiedRing.length = 0; coverageBreachRing.length = 0; coverageAdoptRing.length = 0;
  coverageAuditState.adoptionFloodSkips = 0; coverageAuditState.adoptionCapSkips = 0;   // bm2r D8
  coverageAuditState.lastAdoptionBlock = null;
  coverageAdoptGateRing.length = 0; lastAdoptionBlockLogAt = 0;                         // bm2r D7
  rateMap.delete('coverage-audit');
  getSolanaPubsub().clearMintSlotsCurrent();
}
export function __solMintSlotsForTest(): { slotsUsed: number; max: number } {
  return { slotsUsed: solMintMentionSubs.size, max: solMintSlotsEffective() };
}
export function coverageAuditStatus(): Record<string, unknown> {
  const now = coverageNow();
  return {
    cycles: coverageAuditState.cycles,
    breaches: coverageBreaches.size,
    backlog: coverageBacklog.size,
    adoptedFromBreach: coverageAuditState.adoptedFromBreach,
    auditSourceFails: coverageAuditState.auditSourceFails,
    requestsLastCycle: coverageAuditState.requestsLastCycle,
    budgetClamped: coverageAuditState.budgetClamped,
    // bm2o D3: слепота источника видна глазом. lastSliceOkAgeSec=null — полного среза ещё не было;
    // рост возраста при живых breaches = «дыры не подтверждены и не опровергнуты».
    lastSliceOkAgeSec: coverageAuditState.lastSliceOkAt > 0 ? Math.round((now - coverageAuditState.lastSliceOkAt) / 1000) : null,
    lastSlicePartial: coverageAuditState.lastSlicePartial,
    partialReason: coverageAuditState.partialReason,
    sliceCompletenessSource: coverageAuditState.sliceCompletenessSource,
    sliceChannels: coverageAuditState.sliceChannels,
    sliceChannelDetail: coverageAuditState.sliceChannelDetail,   // bm2q D7: state + HTTP-статус + ошибка
    sliceAttempts: coverageAuditState.sliceAttempts,             // bm2q D7: {ds, dp} за цикл
    // bm2q D3/D7: предохранитель DP-канала виден глазом (dpDead=true = запросы приостановлены до dpDeadUntil)
    dpDead: now < coverageDpDeadUntil,
    dpDeadUntil: coverageDpDeadUntil > now ? coverageDpDeadUntil : null,
    sliceFailsTotal: coverageAuditState.sliceFailsTotal,
    openBreaches: [...coverageBreaches.values()].filter(b => !b.adoptedAt).slice(0, 10).map(b => ({
      chain: b.chain, token: b.symbol || b.token, usd: b.usd,
      ageMin: Math.round((now - b.firstSeenAt) / 60000),
      lastSeenAgeMin: Math.round((now - b.lastSeenAt) / 60000),   // bm2o: свежесть записи, а не только возраст
    })),
    // bm2r D8: почему breach не усыновляется — видно в статусе, а не только в голове разработчика.
    adoptionGate: {
      metric: DISCOVERY_FLOOD_METRIC(),
      pressure: reserveFloodPressure(),
      threshold: DISCOVERY_SEED_FLOOD_GATE(),
      open: reserveFloodPressure() <= DISCOVERY_SEED_FLOOD_GATE(),
      cap: COVERAGE_ADOPT_MAX_PER_10MIN(),
      used10m: coverageAdoptGateRing.length,
    },
    adoptEffectiveCap: coverageAdoptEffectiveCap(now),
    adoptionBlocked: { flood: coverageAuditState.adoptionFloodSkips, cap: coverageAuditState.adoptionCapSkips },
    lastAdoptionBlock: coverageAuditState.lastAdoptionBlock,
    ...coverageSlaSnapshot(),
  };
}
export function __resetDiscoveryFunnelsForTest(): void {
  discoveryNewPairs.cycles = 0; discoveryNewPairs.seen = 0; discoveryNewPairs.seeded = 0;
  discoveryNewPairs.deferred = 0; discoveryNewPairs.adopted = 0; discoveryNewPairs.rejected = 0;
  discoveryNewPairs.budgetSkipped = 0; discoveryNewPairs.hourlyCapSkipped = 0; discoveryNewPairs.floodSkipped = 0; discoveryNewPairs.skippedReasons = {};
  discoverySeedHourRing.length = 0; // v9.32ak AK1: часовой кап тоже сбрасывается между тестами
  discoveryAdopt.cycles = 0; discoveryAdopt.candidates = 0; discoveryAdopt.adopted = 0; discoveryAdopt.hourlyCapSkipped = 0; discoveryAdopt.floodSkipped = 0; discoveryAdopt.skippedReasons = {};
  discoveryDeferred.clear(); discoveryFirstSeen.clear(); discoverySlaRing.length = 0;
  discoverySlaStats.breaches = 0; discoverySlaStats.lastBreachToken = '';
  rateMap.delete('ds-newpairs');
}

/** Test hooks for F6/F2: saturate the GT budget and drive a token-pool fetch directly. */
export function __setGeckoBudgetFullForTest(): void {
  // v9.21 A1: fill to the DISCOVERY ceiling (not GECKO_POLL_LIMIT) so the discovery GT-skip branch
  // (`requestBudget.gecko.length >= GECKO_DISCOVERY_LIMIT`) actually trips in tests. GECKO_POLL_LIMIT
  // (16) is now strictly below it, so a "full" budget here guarantees polling AND discovery are both
  // saturated, matching the prod starvation scenario A1 fixes.
  // bm1t Task 4: DISC (12) теперь НИЖЕ POLL (16) — «полный» бюджет = max из потолков, чтобы
  // насыщались ОБА пути (poll-гейт по 16, discovery-гейт по 12).
  const full = Math.max(GECKO_POLL_LIMIT, GECKO_DISCOVERY_LIMIT);
  const now = Date.now();
  for (let i = 0; i < full; i++) requestBudget.gecko.push(now);
}
export function __geckoBudgetConstantsForTest(): { rate: number; pollLimit: number; enrichReserve: number; discoveryReserve: number; discoveryLimit: number } {
  return { rate: GECKO_RATE_LIMIT, pollLimit: GECKO_POLL_LIMIT, enrichReserve: GECKO_ENRICH_RESERVE, discoveryReserve: GECKO_DISCOVERY_RESERVE, discoveryLimit: GECKO_DISCOVERY_LIMIT };
}
export function __clearGeckoBudgetForTest(): void {
  requestBudget.gecko = [];
}
export async function __fetchTokenPoolsForChainForTest(chainId: string, tokenAddr: string): Promise<DiscoveryPoolLike[]> {
  return fetchTokenPoolsForChain(chainId, tokenAddr);
}
export function __dsCacheSizeForTest(): number {
  return dsPoolCache.size;
}
export function __clearDsCacheForTest(): void {
  dsPoolCache.clear();
}
/** F5 test hooks: exercise CoW settlement owner recovery and route-pool pickup on fixtures. */
export function __recoverCowOwnerForTest(chainId: string, logs: EvmTransferLog[]): string | null {
  return recoverCowOwnerFromTransfers(chainId, logs, COW_SETTLEMENT);
}
export function __pickCowRoutePoolForTest(logs: EvmTransferLog[], owner: string): string | null {
  return pickSettlementRoutePool(logs, COW_SETTLEMENT, owner);
}
export function __pickupSettlementRoutePoolForTest(chainId: string, subjectAddr: string, subjectSymbol: string, poolAddr: string | null): void {
  pickupSettlementRoutePool(chainId, subjectAddr, subjectSymbol, poolAddr);
}
export function __isCowSettlementForTest(addr: string): boolean {
  return lower(addr) === COW_SETTLEMENT;
}
/** Test hook: is an address a known settlement on a chain (v9.29 F2 — Velora multi-chain). */
export function __isSettlementAddrForTest(chainId: string, addr: string): boolean {
  return isSettlementAddr(chainId, addr);
}
/** Test hook: is an address a known user-signed aggregator router on a chain (v9.29 F3). */
export function __isRouterAddrForTest(chainId: string, addr: string): boolean {
  return isRouterAddr(chainId, addr);
}
export function __isUserEntrypointForTest(chainId: string, addr: string): boolean {
  return isUserEntrypoint(chainId, addr);
}
/** Test hook: inject CEX cache (byAddress) to control candidate enumeration. */
export function __setCexCacheForTest(byAddress: Record<string, CexTokenEntry>): void {
  cexCache.byAddress = byAddress;
}
const GECKO_DISC_PER_MIN = () => Number(process.env.GECKO_DISC_PER_MIN || '20');
// v9.32j: the GT BASE LAYER gets its own bucket so expansion cannot starve it (restores v9.23 F1).
const GECKO_DISC_BASE_PER_MIN = () => Number(process.env.GECKO_DISC_BASE_PER_MIN || '10');
const geckoDiscStats = { localSkipped: 0, baseLayerCacheHits: 0, baseLocalSkipped: 0 };
const GECKO_BASELAYER_TTL_MS = () => Number(process.env.GECKO_BASELAYER_TTL_MS || '600000');
type BaseLayerCacheEntry = { at: number; pools: DiscoveryPoolLike[] };
const baseLayerCache = new Map<string, BaseLayerCacheEntry>();
export function shouldFetchBaseLayer(chainId: string, now: number, ttlMs: number,
  cache: Map<string, BaseLayerCacheEntry>): boolean {
  const hit = cache.get(chainId);
  if (!hit) return true;
  return (now - hit.at) >= ttlMs;
}
/** v9.32j: the pools the chain's last SUCCESSFUL base-layer fetch produced. Re-injected on a cache
 *  hit so `pools` — and therefore `organic`/`st.watchPoolsDiscovery` — stays whole without re-burning
 *  the GT budget. Without this, composeWatchlist overwrote watchPoolsDiscovery with a list that no
 *  longer contained the base layer, and the metric collapsed to 0 on every cached cycle. */
export function cachedBaseLayerPools(chainId: string,
  cache: Map<string, BaseLayerCacheEntry>): DiscoveryPoolLike[] {
  return cache.get(chainId)?.pools || [];
}
/** Test hook: read the GT base-layer TTL cache entry for a chain. */
export function __baseLayerCacheForTest(chainId: string): BaseLayerCacheEntry | undefined {
  return baseLayerCache.get(chainId);
}
/** Test hook: drop the GT base-layer TTL cache (one chain, or all when chainId is omitted). */
export function __resetBaseLayerCacheForTest(chainId?: string): void {
  if (chainId) baseLayerCache.delete(chainId);
  else baseLayerCache.clear();
}
async function geckoDiscoveryGet<T>(endpoint: string, ceiling?: number, kind: 'expand' | 'base' = 'expand'): Promise<T | null> {
  // v9.32n: shared GT ban — discovery must not keep punching a banned IP.
  // v9.32i: local guard so discovery cannot self-DDoS GT (~25/min upstream). Skip → null
  // (same as existing "couldn't fetch" path; next cycle retries).
  // v9.32j: two buckets — the base layer must not be starved by per-token expansion.
  if (geckoDiscLocalGuard) {
    const base = kind === 'base';
    if (!rateAllow(base ? 'gecko-disc-base' : 'gecko-discovery',
                   base ? GECKO_DISC_BASE_PER_MIN() : GECKO_DISC_PER_MIN(), 60000)) {
      if (base) geckoDiscStats.baseLocalSkipped++; else geckoDiscStats.localSkipped++;
      return null;
    }
  }
  return (await geckoDiscoveryImpl(endpoint, ceiling)) as T | null;
}
export async function __geckoDiscFetchForTest(
  endpoint: string,
  transportOverride?: (endpoint: string, ceiling?: number) => Promise<unknown>,
  kind: 'expand' | 'base' = 'expand',
): Promise<unknown> {
  const prev = geckoDiscoveryImpl;
  const prevGuard = geckoDiscLocalGuard;
  if (transportOverride) geckoDiscoveryImpl = transportOverride;
  geckoDiscLocalGuard = true; // this hook exists to exercise the guard
  try {
    return await geckoDiscoveryGet(endpoint, undefined, kind);
  } finally {
    geckoDiscoveryImpl = prev;
    geckoDiscLocalGuard = prevGuard;
  }
}
export function __geckoDiscStatsForTest(): { localSkipped: number; baseLayerCacheHits: number; baseLocalSkipped: number } {
  return { ...geckoDiscStats };
}
export function __resetGeckoDiscGuardForTest(): void {
  geckoDiscStats.localSkipped = 0;
  geckoDiscStats.baseLayerCacheHits = 0;
  geckoDiscStats.baseLocalSkipped = 0;
  rateMap.delete('gecko-discovery');
  rateMap.delete('gecko-disc-base');
}
/** Test hook: force the local GT discovery guard on/off even while a mock transport is installed.
 *  Call AFTER __setGeckoDiscoveryMock — that hook resets the guard flag. */
export function __setGeckoDiscGuardForTest(on: boolean): void {
  geckoDiscLocalGuard = on;
}
export function __gecko429ByPathForTest(): Record<AnonymousGtPath, number> {
  return anonymousGtChannel().snapshot().http429ByPath;
}
export function __resetGecko429ForTest(): void {
  // B4.2: состояние канала (per-path, 429, last-paths, dead/strikes/cooldown/probe/recoveries —
  // в baseline-порядке внутри channel.resetForTest) + facade-остатки ниже. Всё синхронно,
  // взаимный порядок facade/channel записей ненаблюдаем.
  anonymousGtChannel().resetForTest();
  gtPoll429 = 0; gtPoll429Cooldown = 0;
  gtBan.attempts = 0; gtBan.until = 0; gtBan.probeAcquired = false; gtBan.banSkipped = 0;
  // bm1t Task 4: окно агрегата лога discovery-429 — с чистого листа (первый 429 печатается).
  lastDisc429LogAt = 0; disc429SinceLastLog = 0;
  gtRequestsTotal = 0; gtRequestsPerCycle = 0;
  rateMap.delete('gecko-discovery'); rateMap.delete('gecko-disc-base');
}
/** Test hook (bm1t Task 4): блокирует GT-поллы «исчерпанным probe» БЕЗ временного бана —
 *  gtPollBlocked() → true (probeAcquired), а realGeckoDiscovery видит until=0 и работает.
 *  Нужно тестам реального discovery-транспорта: фоновый поллер движка в тест-процессе иначе
 *  ловит 429 от подменённого fetch, ставит gtBan на 10 минут и убивает вызовы теста. */
export function __lockGtPollProbesForTest(): void {
  gtBan.attempts = 1; gtBan.until = 0; gtBan.probeAcquired = true;
}
/** Test hook (bm1t Task 4): cumulative discovery-429 counter (не сбрасывается reset-хуком). */
export function __geckoDiscovery429TotalForTest(): number {
  return geckoDiscovery429Total;
}
/** Test hook: drive the poll/enrich GT transport directly (429 attribution by path). */
export async function __geckoGetWithStatusForTest<T>(endpoint: string, isEnrich = false): Promise<{ data: T | null; status: number }> {
  const r = await geckoGetWithStatus<T>(endpoint, isEnrich);
  return { data: r.data, status: r.status };
}

// geckoGetForEnrich: uses GECKO_RATE_LIMIT (not POLL_LIMIT) — enrichment has priority access
async function geckoGetForEnrich<T>(endpoint: string): Promise<{ data: T | null; status: number; error?: string }> {
  return geckoGetWithStatus<T>(endpoint, true);
}

const GT_POLL_429_COOLDOWN_MS = () => Number(process.env.GT_POLL_429_COOLDOWN_MS || '30000');
const GT_POLL_429_BACKOFF_BASE_MS = () => Number(process.env.GT_POLL_429_BACKOFF_BASE_MS || String(GT_POLL_429_COOLDOWN_MS()));
const GT_POLL_429_BACKOFF_MAX_MS = () => Number(process.env.GT_POLL_429_BACKOFF_MAX_MS || '600000');
let gtPoll429 = 0;
let gtPoll429Cooldown = 0;
const gtBan = { attempts: 0, until: 0, probeAcquired: false, banSkipped: 0 };
function gtBanBackoffMs(attempts: number): number {
  if (attempts <= 0) return GT_POLL_429_BACKOFF_BASE_MS();
  return Math.min(GT_POLL_429_BACKOFF_MAX_MS(), GT_POLL_429_BACKOFF_BASE_MS() * Math.pow(2, attempts - 1));
}
export function gtBanState(): { banned: boolean; backoffMs: number; attempts: number; probeUntil: number } {
  return { banned: Date.now() < gtBan.until, backoffMs: gtBanBackoffMs(gtBan.attempts), attempts: gtBan.attempts, probeUntil: gtBan.until };
}
export function gtBanMark429(recordDeadStrike = true): void {
  gtBan.attempts++;
  gtBan.until = Date.now() + gtBanBackoffMs(gtBan.attempts);
  gtBan.probeAcquired = false;
  gtPoll429++;
  if (recordDeadStrike) anonymousGtChannel().test.noteDeadStrike('no_key_429'); // direct test hook; network outcomes are recorded by the shared latch
}
export function gtBanMarkOk(): void {
  gtBan.attempts = 0; gtBan.until = 0; gtBan.probeAcquired = false;
}
export function gtBanTryAcquireProbe(): boolean {
  if (Date.now() < gtBan.until) return false;
  if (gtBan.attempts === 0) return true;
  if (gtBan.probeAcquired) return false;
  gtBan.probeAcquired = true;
  return true;
}
export function __expireGtBanForTest(): void {
  gtBan.until = Date.now() - 1;
  gtBan.probeAcquired = false;
}
const GT_WARMUP_MS = () => Number(process.env.GT_WARMUP_MS || '300000');
const GT_WARMUP_MAX_PER_MIN = () => Number(process.env.GT_WARMUP_MAX_PER_MIN || '8');
const GT_DISCOVERY_DELAY_MS = () => Number(process.env.GT_DISCOVERY_DELAY_MS || '90000');
let warmupSkipped = 0;
export function gtWarmupAllow(): boolean {
  if (!state.startedAt) return true;
  if (Date.now() - state.startedAt >= GT_WARMUP_MS()) return true;
  if (GT_WARMUP_MAX_PER_MIN() >= 25) return true;
  if (!rateAllow('gt-warmup', GT_WARMUP_MAX_PER_MIN(), 60000)) {
    warmupSkipped++;
    return false;
  }
  return true;
}
export function __resetGtWarmupForTest(): void {
  rateMap.delete('gt-warmup');
  warmupSkipped = 0;
}
export async function waitDiscoveryDelay(): Promise<void> {
  const delay = GT_DISCOVERY_DELAY_MS();
  if (delay <= 0 || !state.startedAt) return;
  const wait = delay - (Date.now() - state.startedAt);
  if (wait > 0) await sleep(wait);
}
let geckoPollHttpOverride: number | null = null;
export function __setGeckoPollHttpForTest(status: number | null): void {
  geckoPollHttpOverride = status;
  if (status === null) { gtBanMarkOk(); }
}
function gtPollBlocked(): boolean {
  if (Date.now() < gtBan.until) return true;
  if (gtBan.attempts > 0 && !gtBanTryAcquireProbe()) return true;
  return false;
}
async function geckoGetWithStatus<T>(endpoint: string, isEnrich = false): Promise<{ data: T | null; status: number; error?: string }> {
  cleanBudget();
  if (!isEnrich && gtPollBlocked()) {
    gtPoll429Cooldown++;
    return { data: null, status: 0, error: 'gt_poll_429_cooldown' };
  }
  if (!isEnrich && !gtWarmupAllow()) {
    return { data: null, status: 0, error: 'gt_warmup' };
  }
  const limit = isEnrich ? GECKO_RATE_LIMIT : GECKO_POLL_LIMIT;
  if (requestBudget.gecko.length >= limit) return { data: null, status: 0, error: `rate_limit_local (${requestBudget.gecko.length}/${limit})` };
  if (!isEnrich && geckoPollHttpOverride === 429) {
    // B4.2: счётчики — в канал (шовная поверхность: ветка живёт только под тестовым симом);
    // порядок «счётчики → gtBanMark429 (ban + gtPoll429 + strike)» — как в baseline.
    anonymousGtChannel().test.notePathHttp429('poll');
    gtBanMark429();
    return { data: null, status: 429, error: 'HTTP 429 rate limited' };
  }

  const path: AnonymousGtPath = isEnrich ? 'enrich' : 'poll';
  const gated = await withAnonymousGtLatch(path, async () => {
    requestBudget.gecko.push(Date.now());
    const url = anonymousGtUrl('/api/v2' + endpoint);
    const response = await getJSON<T>(url, { headers: { 'Accept': 'application/json' } });
    const errBody = response.data && typeof response.data === 'object' && 'status' in (response.data as object)
      ? response.data as unknown as { status?: { error_code?: number } }
      : null;
    const hasBodyError = !!errBody?.status?.error_code;
    return {
      value: response, status: response.status, ok: response.ok,
      useful: !hasBodyError && isUsefulGtPayload(response.data, response.ok),
      countEmpty: !hasBodyError,
    };
  });
  if (gated.blocked) return { data: null, status: 0, error: 'gt_dead' };
  const res = gated.value;
  if (!res) return { data: null, status: 0, error: 'gt_empty_transport_result' };
  if (res.status === 429) {
    if (!isEnrich) gtBanMark429(false);
    log(`[gecko] Rate limited (${isEnrich ? 'enrich' : 'poll'})! Used ${requestBudget.gecko.length}/${GECKO_RATE_LIMIT}`);
    return { data: null, status: 429, error: 'HTTP 429 rate limited' };
  }
  if (!res.ok) {
    log(`[gecko] HTTP ${res.status} (${isEnrich ? 'enrich' : 'poll'})`);
    return { data: null, status: res.status, error: `HTTP ${res.status}: ${res.error?.slice(0, 80) || 'unknown'}` };
  }
  // GeckoTerminal sometimes returns HTTP 200 with an error body. Preserve its parser-facing code
  // while excluding it from the HTTP-429/empty-200 latch classifications above.
  if (res.data && typeof res.data === 'object' && 'status' in (res.data as object)) {
    const errBody = res.data as unknown as { status?: { error_code?: number; error_message?: string } };
    if (errBody.status?.error_code) {
      const code = errBody.status.error_code;
      const msg = errBody.status.error_message?.slice(0, 100) || 'GeckoTerminal error';
      log(`[gecko] Body error ${code} (${isEnrich ? 'enrich' : 'poll'})`);
      return { data: null, status: code, error: `HTTP ${code}: ${msg}` };
    }
  }
  if (!isEnrich) gtBanMarkOk();
  return { data: res.data ?? null, status: res.status };
}

// ─────────────── Token Symbol Resolution ───────────────
// bm2r R5: прямой доступ к symbol-пути — бэкофф шлюза должен гасить DS-фолбэк без сети.
export function __resolveTokenSymbolForTest(chainId: string, tokenAddr: string): Promise<string> {
  return resolveTokenSymbol(chainId, tokenAddr);
}
async function resolveTokenSymbol(chainId: string, tokenAddr: string): Promise<string> {
  const cacheKey = `${chainId}:${lower(tokenAddr)}`;
  const cached = tokenMetaCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < TOKEN_META_TTL) return cached.symbol;

  const dpNet = CHAINS[chainId]?.dpNetwork;
  const dpKey = getDexPaprikaApiKey();
  if (DP_SYMBOL_ENABLED && dpNet && dpKey) {
    try {
      const res = await getJSON<{ symbol?: string; name?: string }>(
        `https://api.dexpaprika.com/networks/${dpNet}/tokens/${tokenAddr}`,
        { headers: { 'Authorization': `Bearer ${dpKey}` } },
        'tm_symbol' // bm2f
      );
      if (res.ok && res.data?.symbol) {
        tokenMetaCache.set(cacheKey, { symbol: res.data.symbol, name: res.data.name || '?', cachedAt: Date.now() });
        return res.data.symbol;
      }
    } catch (e) {
      log(`[tokenMeta] DexPaprika error for ${shortAddr(tokenAddr)}: ${(e as Error).message}`);
    }
  }

  const gecko = CHAINS[chainId]?.gecko;
  if (gecko) {
    try {
      const data = await geckoGet<{ data?: { attributes?: { symbol?: string; name?: string } } }>(
        `/networks/${gecko}/tokens/${tokenAddr}`
      );
      if (data?.data?.attributes?.symbol) {
        tokenMetaCache.set(cacheKey, { symbol: data.data.attributes.symbol, name: data.data.attributes.name || '?', cachedAt: Date.now() });
        return data.data.attributes.symbol;
      }
    } catch (e) {
      log(`[tokenMeta] Gecko error for ${shortAddr(tokenAddr)}: ${(e as Error).message}`);
    }
  }

  // DexScreener fallback for symbol resolution
  try {
    // bm2r D5: через единый шлюз (лейн symbol — bulk за фоновые обогащения).
    const dsRes = await dsFetch<{
      pairs?: Array<{
        baseToken?: { address?: string; symbol?: string };
        quoteToken?: { address?: string; symbol?: string };
      }>;
    }>('symbol', `https://api.dexscreener.com/latest/dex/tokens/${tokenAddr}`);
    if (dsRes.ok && dsRes.data?.pairs && dsRes.data.pairs.length > 0) {
      const pair = dsRes.data.pairs[0];
      const lowerToken = lower(tokenAddr);
      let sym: string | undefined;
      if (lower(pair.baseToken?.address || '') === lowerToken) sym = pair.baseToken?.symbol;
      else if (lower(pair.quoteToken?.address || '') === lowerToken) sym = pair.quoteToken?.symbol;
      else sym = pair.baseToken?.symbol;
      if (sym && sym !== '?') {
        tokenMetaCache.set(cacheKey, { symbol: sym, name: sym, cachedAt: Date.now() });
        return sym;
      }
    }
  } catch (e) {
    log(`[tokenMeta] DexScreener error for ${shortAddr(tokenAddr)}: ${(e as Error).message}`);
  }

  // Final: cache the short address so we don't retry immediately
  const fallbackSym = shortAddr(tokenAddr);
  tokenMetaCache.set(cacheKey, { symbol: fallbackSym, name: fallbackSym, cachedAt: Date.now() - TOKEN_META_TTL + 60000 });
  return fallbackSym;
}

// R4/R6: resolve a readable, human symbol for a token purely from in-memory sources (CEX cache +
// watchlist meta) — NEVER a truncated address. Returns null when no readable symbol is known, so
// callers can show null instead of an ugly "0x232c…4ee2" fallback.
function resolveReadableSymbol(chainId: string, addr: string): string | null {
  const a = lower(addr);
  const meta = chainWatchMeta[chainId] || {};
  // watchlist subjectSymbol preserves original case (LIT, 币有 …) — prefer it first
  for (const m of Object.values(meta)) {
    if (m.subjectToken && lower(m.subjectToken) === a) {
      const s = m.subjectSymbol;
      if (s && s !== '?' && !s.includes('…')) return s;
    }
  }
  const cexEntry = cexCache.byAddress[chainId + ':' + a];
  if (cexEntry && cexEntry.symbol && !cexEntry.symbol.includes('…')) {
    // CoinGecko symbols are stored lowercase; display them uppercase (币有 is unaffected).
    return cexEntry.symbol.toUpperCase();
  }
  return null;
}

// ─────────────── Pool Creation Time ───────────────
async function getPoolCreationTime(chainId: string, poolAddr: string): Promise<number | null> {
  const cacheKey = `${chainId}:${lower(poolAddr)}`;
  const cached = poolCreationCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < POOL_CACHE_TTL) return cached.createdAt;

  const gecko = CHAINS[chainId]?.gecko;
  if (!gecko) {
    poolCreationCache.set(cacheKey, { createdAt: null, cachedAt: Date.now() });
    return null;
  }

  try {
    const data = await geckoGet<{ data?: { attributes?: { pool_created_at?: string } } }>(
      `/networks/${gecko}/pools/${poolAddr}`
    );
    if (data?.data?.attributes?.pool_created_at) {
      const createdAt = new Date(data.data.attributes.pool_created_at).getTime();
      poolCreationCache.set(cacheKey, { createdAt, cachedAt: Date.now() });
      return createdAt;
    }
  } catch (e) {
    log(`[poolCreation] Error: ${(e as Error).message}`);
  }

  poolCreationCache.set(cacheKey, { createdAt: null, cachedAt: Date.now() });
  return null;
}

// ─────────────── Link Generation ───────────────
export function __generateLinksForTest(
  chainId: string, tokenAddr: string, trader: string, txHash: string,
  isContractTrader: boolean, poolAddr?: string | null, poolCreatedAt?: number | null,
): Record<string, string> {
  return generateLinks(chainId, tokenAddr, trader, txHash, isContractTrader, poolAddr, poolCreatedAt);
}
function generateLinks(
  chainId: string, tokenAddr: string, trader: string, txHash: string,
  isContractTrader: boolean, poolAddr?: string | null, poolCreatedAt?: number | null
): Record<string, string> {
  const c = CHAINS[chainId];
  if (!c) return {};
  const links: Record<string, string> = {};

  if (trader) {
    if (chainId !== 'solana' && /^0x[a-fA-F0-9]{40}$/.test(trader)) {
      links.explorer = `${c.explorer}/address/${trader}`;
      if (!isContractTrader) links.arkham = `https://intel.arkm.com/explorer/address/${trader}`;
    }
    if (chainId === 'solana' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(trader)) {
      links.explorer = `${c.explorer}/account/${trader}`;
      // ТЗ v4 Block 1: Solana cards must include an Arkham link for the trader.
      if (!isContractTrader) links.arkham = `https://intel.arkm.com/explorer/address/${trader}`;
    }
  }

  if (txHash) links.tx = `${c.explorer}/tx/${txHash}`;

  if (tokenAddr) {
    const YOUNG_POOL_THRESHOLD = 7 * 24 * 60 * 60 * 1000;
    const isYoungPool = poolCreatedAt && (Date.now() - poolCreatedAt < YOUNG_POOL_THRESHOLD);

    if (c.gmgnSlug) {
      links.gmgn = `https://gmgn.ai/${c.gmgnSlug}/token/${tokenAddr}`;
    }
    links.dex = `https://dexscreener.com/${c.ds}/${tokenAddr}`;

    if (isYoungPool && poolAddr) {
      links.dex = `https://dexscreener.com/${c.ds}/${poolAddr}`;
    }
  }

  return links;
}

// ─────────────── Link Generation Test ───────────────
export function testLinkGeneration(): { results: Array<{ test: string; expected: string; actual: string; pass: boolean }> } {
  const results: Array<{ test: string; expected: string; actual: string; pass: boolean }> = [];

  const eth = generateLinks('ethereum', '0x1234', '0xabcd', '0xtx', false);
  results.push({ test: 'Ethereum GMGN slug', expected: 'https://gmgn.ai/eth/token/0x1234', actual: eth.gmgn || 'missing', pass: eth.gmgn === 'https://gmgn.ai/eth/token/0x1234' });

  const bsc = generateLinks('bsc', '0x1234', '0xabcd', '0xtx', false);
  results.push({ test: 'BSC GMGN slug', expected: 'https://gmgn.ai/bsc/token/0x1234', actual: bsc.gmgn || 'missing', pass: bsc.gmgn === 'https://gmgn.ai/bsc/token/0x1234' });

  const sol = generateLinks('solana', 'So111', 'ABC123', 'tx123', false);
  results.push({ test: 'Solana GMGN slug', expected: 'https://gmgn.ai/sol/token/So111', actual: sol.gmgn || 'missing', pass: sol.gmgn === 'https://gmgn.ai/sol/token/So111' });

  const arb = generateLinks('arbitrum', '0x1234', '0xabcd', '0xtx', false);
  results.push({ test: 'Arbitrum no GMGN', expected: 'undefined/missing', actual: arb.gmgn || 'missing', pass: !arb.gmgn });

  const pol = generateLinks('polygon', '0x1234', '0xabcd', '0xtx', false);
  results.push({ test: 'Polygon no GMGN', expected: 'undefined/missing', actual: pol.gmgn || 'missing', pass: !pol.gmgn });

  const arkhamEoa = generateLinks('ethereum', '0x1234', '0x' + 'a'.repeat(40), '0xtx', false);
  results.push({ test: 'Arkham EOA link', expected: 'intel.arkm.com', actual: arkhamEoa.arkham || 'missing', pass: !!arkhamEoa.arkham?.includes('intel.arkm.com') });

  const arkhamContract = generateLinks('ethereum', '0x1234', '0x' + 'b'.repeat(40), '0xtx', true);
  results.push({ test: 'Arkham hidden for contract', expected: 'missing', actual: arkhamContract.arkham || 'missing', pass: !arkhamContract.arkham });

  return { results };
}

// ─────────────── TG Text Generation (ТЗ v4 Block 1) ───────────────
// Generates the full card text for a trade. Exposed for unit tests.
// Critical: preserves the original case of Solana addresses (base58 is case-sensitive).
// @dead-legacy — live cards use buildSoloText/buildSeriesText; kept because parser-v4 tests pin GMGN case
export function genTgText(
  chainId: string,
  mint: string,
  trader: string,
  opts: { side?: string; usd?: number; isContract?: boolean; kind?: 'solo' | 'series' } = {}
): string {
  const isContract = !!opts.isContract;
  const links = generateLinks(chainId, mint, trader, 'tx', isContract);
  const notif: Notification = {
    ts: Date.now(),
    chain: chainId,
    side: opts.side || 'buy',
    symbol: '?',
    token: mint,
    trader,
    txHash: 'tx',
    usd: opts.usd ?? 0,
    impact: null,
    liquidity: 0,
    fdv: 0,
    via: null,
    links,
    source: 'test',
    tgOk: false,
    tgError: null,
    kind: opts.kind || 'solo',
  };
  return (opts.kind === 'series') ? buildSeriesText(notif) : buildSoloText(notif);
}

// ─────────────── CEX Self-Test ───────────────
export function getCexSelfTest(): { results: Array<{ test: string; expected: string; actual: string; pass: boolean }> } {
  const results: Array<{ test: string; expected: string; actual: string; pass: boolean }> = [];

  const usdtKey = 'ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7';
  const usdtEntry = cexCache.byAddress[usdtKey];
  if (usdtEntry) {
    const r1 = checkCexFilter('ethereum', '0xdac17f958d2ee523a2206206994597c13d831ec7', 'USDT');
    results.push({ test: 'Known CEX token: USDT on ethereum', expected: 'PASS', actual: r1.pass ? 'PASS' : 'SKIP cex', pass: r1.pass === true });
    if (r1.pass) { cexFilterStats.matchHits--; cexFilterStats.passedEvents--; } else { cexFilterStats.matchMisses--; cexFilterStats.skippedCex--; }
  } else {
    results.push({ test: 'USDT not in CEX cache (loading?)', expected: 'PASS', actual: 'SKIP', pass: false });
  }

  const fakeAddr = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
  const r2 = checkCexFilter('ethereum', fakeAddr, 'FAKECOIN');
  results.push({ test: 'Random token: FAKECOIN', expected: 'SKIP cex', actual: r2.pass ? 'PASS (BUG!)' : 'SKIP cex', pass: r2.pass === false });
  if (r2.pass) { cexFilterStats.matchHits--; cexFilterStats.passedEvents--; } else { cexFilterStats.matchMisses--; cexFilterStats.skippedCex--; }

  const wethAddr = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
  const r3 = checkCexFilter('ethereum', wethAddr, 'WETH');
  results.push({ test: 'Wrapped→Native: WETH→ETH', expected: 'PASS', actual: r3.pass ? `PASS (${r3.method})` : 'SKIP cex', pass: r3.pass === true });
  if (r3.pass) { cexFilterStats.matchHits--; cexFilterStats.passedEvents--; if (r3.method === 'wrapped-native') cexFilterStats.wrappedNativeHits--; } else { cexFilterStats.matchMisses--; cexFilterStats.skippedCex--; }

  const usdtBsc = '0x55d398326f99059ff775485246999027b3197955';
  const wbnb = '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c';
  // v9.32bj BJ1 (AR3): base↔base — конверсия базовых активов, не сделка по монете → null.
  const majorPairSubject = determineSubject(usdtBsc, 'USDT', wbnb, 'WBNB', 100, 100);
  results.push({ test: 'BJ1 base×base: USDT×WBNB → drop', expected: 'null', actual: majorPairSubject ? `subject=${majorPairSubject.subjectSym}` : 'null', pass: majorPairSubject === null });

  const usdc = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
  const stableStableSubject = determineSubject(usdtBsc, 'USDT', usdc, 'USDC', 100, 100);
  results.push({ test: 'Stable×Stable: USDT×USDC → drop', expected: 'null', actual: stableStableSubject ? `subject=${stableStableSubject.subjectSym}` : 'null', pass: stableStableSubject === null });

  const polNative = '0x0000000000000000000000000000000000001010';
  const usdtPolygon = '0xc2132d05d31c914a87c6611c10748aeb04b58e8f';
  // v9.32bj BJ1 (AR3): USDT×POL(0x1010) — обе ноги базовые → null.
  const polPairSubject = determineSubject(usdtPolygon, 'USDT', polNative, 'POL', 100, 100);
  results.push({ test: 'BJ1 base×base: USDT×POL(0x1010) → drop', expected: 'null', actual: polPairSubject ? `subject=${polPairSubject.subjectSym}` : 'null', pass: polPairSubject === null });

  const routerAddr = KNOWN_CONTRACT_ROUTERS[0];
  results.push({ test: `Known router in KNOWN_INFRA: ${shortAddr(routerAddr)}`, expected: 'in KNOWN_INFRA', actual: KNOWN_INFRA[routerAddr] || 'NOT', pass: !!KNOWN_INFRA[routerAddr] });

  const wpol = '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270';
  // v9.32bj BJ1 (AR3): бывш. Round3 Bug1a ожидал subject=POL — теперь base↔base → null.
  const wpolSubject = determineSubject(usdtPolygon, 'USDT', wpol, 'WPOL', 100, 100);
  results.push({ test: 'BJ1 base×base (ex Round3 Bug1a): USDT×WPOL → drop', expected: 'null', actual: wpolSubject ? `subject=${wpolSubject.subjectSym}` : 'null', pass: wpolSubject === null });

  const usdcPoly = '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359';
  const stableStable2 = determineSubject(usdtPolygon, 'USDT', usdcPoly, 'USDC', 100, 100);
  results.push({ test: 'Round3 Bug1b: USDT×USDC → drop', expected: 'null', actual: stableStable2 ? `subject=${stableStable2.subjectSym}` : 'null', pass: stableStable2 === null });

  const wethBase = '0x4200000000000000000000000000000000000006';
  const usdcBase = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913';
  // v9.32bj BJ1 (AR3): бывш. Round3 Bug1c ожидал subject=ETH — теперь base↔base → null.
  const wethBaseSubject = determineSubject(usdcBase, 'USDC', wethBase, 'WETH', 100, 100);
  results.push({ test: 'BJ1 base×base (ex Round3 Bug1c): USDC×WETH base → drop', expected: 'null', actual: wethBaseSubject ? `subject=${wethBaseSubject.subjectSym}` : 'null', pass: wethBaseSubject === null });

  const unknownAddr = '0xdeadbeef1234567890abcdef1234567890abcdef';
  const unknownSymStable = determineSubject(unknownAddr, '?', usdtPolygon, 'USDT', 50, 100);
  results.push({ test: 'Round3 Bug1d: Unknown×USDT → not dropped', expected: 'not null', actual: unknownSymStable ? `subject=${unknownSymStable.subjectAddr.slice(0, 10)}...` : 'null (WRONG)', pass: unknownSymStable !== null });

  const unknownIsStable = isStablecoin('0xdeadbeef1234567890abcdef1234567890abcdef', '?');
  results.push({ test: 'Round3 Bug1e: isStablecoin(unknownAddr,"?") → false', expected: 'false', actual: String(unknownIsStable), pass: unknownIsStable === false });

  const wpolIsStable = isStablecoin(wpol, 'WPOL');
  results.push({ test: 'Round3 Bug1f: isStablecoin(WPOL) → false', expected: 'false', actual: String(wpolIsStable), pass: wpolIsStable === false });

  const fakeHorse = '0x89gzqf0000000000000000000000000000000pump';
  const r8 = checkCexFilter('solana', fakeHorse, 'HORSE');
  results.push({ test: 'Bug3: Fake HORSE → SKIP cex', expected: 'SKIP cex', actual: r8.pass ? 'PASS (BUG!)' : 'SKIP cex', pass: r8.pass === false });
  if (r8.pass) { cexFilterStats.matchHits--; cexFilterStats.passedEvents--; } else { cexFilterStats.matchMisses--; cexFilterStats.skippedCex--; }

  // БАГ 1 FIX: Major token subject filter tests
  const wethEth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
  const wethIsM = isMajorToken('ethereum', wethEth);
  results.push({ test: 'Major: WETH as subject on ethereum → major', expected: 'true', actual: String(wethIsM), pass: wethIsM === true });

  const linkAddr = '0x514910771af9ca656af840dff83e8264ecf986ca'; // LINK on ETH
  const linkIsM = isMajorToken('ethereum', linkAddr);
  results.push({ test: 'Major: LINK (altcoin) NOT major on ethereum', expected: 'false', actual: String(linkIsM), pass: linkIsM === false });

  const wsolAddr = 'So11111111111111111111111111111111111111112';
  const wsolIsM = isMajorToken('solana', wsolAddr);
  results.push({ test: 'Major: WSOL as subject on solana → major', expected: 'true', actual: String(wsolIsM), pass: wsolIsM === true });

  const wbnbAddr = '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c';
  const wbnbIsM = isMajorToken('bsc', wbnbAddr);
  results.push({ test: 'Major: WBNB as subject on bsc → major', expected: 'true', actual: String(wbnbIsM), pass: wbnbIsM === true });

  // LINK as subject with WETH as quote → should NOT be major (only subject matters)
  const linkSubject = determineSubject(linkAddr, 'LINK', wethEth, 'WETH', 100, 100);
  const linkSubjectIsMajor = linkSubject ? isMajorToken('ethereum', linkSubject.subjectAddr) : false;
  results.push({ test: 'Major: LINK/WETH pair — subject=LINK → not major', expected: 'false', actual: String(linkSubjectIsMajor), pass: linkSubjectIsMajor === false });

  // ──── ТЗ NEW TESTS: Major token extensions ────
  // (a) cbBTC on base as subject → major
  const cbBtcBase = '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf';
  const cbBtcBaseIsM = isMajorToken('base', cbBtcBase);
  results.push({ test: 'ТЗ(a) cbBTC subject on base → major', expected: 'true', actual: String(cbBtcBaseIsM), pass: cbBtcBaseIsM === true });

  // (b) stETH on ethereum as subject → major
  const stEthAddr = '0xae7ab96520de3a18e5e111b5eaab095312d7fe84';
  const stEthIsM = isMajorToken('ethereum', stEthAddr);
  results.push({ test: 'ТЗ(b) stETH subject on ethereum → major', expected: 'true', actual: String(stEthIsM), pass: stEthIsM === true });

  // (c) altcoin subject with cbBTC as quote → NOT filtered (only subject matters)
  const altcoinAddr = '0xdeadbeef1234567890abcdef1234567890abcdef';
  const cbBtcQuote = '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf';
  const altSubject = determineSubject(altcoinAddr, 'ALT', cbBtcQuote, 'cbBTC', 100, 100);
  const altSubjectIsM = altSubject ? isMajorToken('base', altSubject.subjectAddr) : true;
  results.push({ test: 'ТЗ(c) altcoin subj, cbBTC quote → not filtered', expected: 'false', actual: String(altSubjectIsM), pass: altSubjectIsM === false });

  // (d) WETH on Arbitrum 0x82aF... → major (regression: NOT 0x4200...06)
  const wethArb = '0x82af49447d8a07e3bd95bd0d56f35241523fbab1';
  const wethArbIsM = isMajorToken('arbitrum', wethArb);
  results.push({ test: 'ТЗ(d) WETH arbitrum 0x82aF... → major (regression fix)', expected: 'true', actual: String(wethArbIsM), pass: wethArbIsM === true });

  // (d2) 0x4200...06 on Arbitrum → NOT major (it's Base WETH, not Arbitrum)
  const wethBase0x42 = '0x4200000000000000000000000000000000000006';
  const weth0x42OnArbIsM = isMajorToken('arbitrum', wethBase0x42);
  results.push({ test: 'ТЗ(d2) 0x4200...06 on arbitrum → NOT major (Base addr)', expected: 'false', actual: String(weth0x42OnArbIsM), pass: weth0x42OnArbIsM === false });

  // (e) jitoSOL on solana → major
  const jitoSolAddr = 'j1toso1uck3rlmjorhttrvwy9hj7x8v9yyac6y7kgcpn';
  const jitoSolIsM = isMajorToken('solana', jitoSolAddr);
  results.push({ test: 'ТЗ(e) jitoSOL on solana → major', expected: 'true', actual: String(jitoSolIsM), pass: jitoSolIsM === true });

  // (f) cbBTC on ethereum → major
  const cbBtcEth = '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf';
  const cbBtcEthIsM = isMajorToken('ethereum', cbBtcEth);
  results.push({ test: 'ТЗ(f) cbBTC subject on ethereum → major', expected: 'true', actual: String(cbBtcEthIsM), pass: cbBtcEthIsM === true });

  // (g) WBTC on arbitrum → major
  const wbtcArb = '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f';
  const wbtcArbIsM = isMajorToken('arbitrum', wbtcArb);
  results.push({ test: 'ТЗ(g) WBTC on arbitrum → major', expected: 'true', actual: String(wbtcArbIsM), pass: wbtcArbIsM === true });

  // (h) mSOL on solana → major
  const mSolAddr = 'msolzycxhdygdzu16g5qsh3i5k3z3kzk7ytfqcjm7so';
  const mSolIsM = isMajorToken('solana', mSolAddr);
  results.push({ test: 'ТЗ(h) mSOL on solana → major', expected: 'true', actual: String(mSolIsM), pass: mSolIsM === true });

  return { results };
}

// ─────────────── SSE Connection ───────────────
function getDexPaprikaApiKey(): string | null {
  return process.env.DEXPAPRIKA_API_KEY || null;
}

// ТЗ (Oracle DexPaprika auth): вынесенное решение о заголовке Authorization основного источника
// оракула. SDK DexPaprika требует СЫРОЙ ключ в Authorization (без префикса Bearer) — иначе 403.
// Заголовок отправляется ТОЛЬКО когда ORACLE_DP_USE_KEY включён И ключ задан; иначе — пусто
// (заголовок не уходит). fetchPrimaryTrades использует эту функцию (тот же url/timeout 4000).
function oraclePrimaryDpAuthHeaders(): Record<string, string> {
  const key = getDexPaprikaApiKey();
  return ORACLE_DP_USE_KEY && key ? { Authorization: key } : {};
}


function coingeckoDemoApiKey(): string { return String(process.env.COINGECKO_DEMO_API_KEY || '').trim(); }

/** Отпечаток ключа для памяти auth-отказа: sha256 (усечённый), НЕ значение. В статус/логи не уходит. */
function coingeckoDemoKeyFp(key: string): string { return createHash('sha256').update(key).digest('hex').slice(0, 16); }

/** Test hook (bm3a): взвести/снять прежний breaker анонимного GT (Review Focus 5 — не мешает Demo-пути). */
export function __setGtDeadForTest(dead: boolean, reason: 'no_key_429' | 'no_key_empty' | null): void {
  // B4.2: механика взведения (5 меток + дедлайн / сброс) — в channel.test.setDead.
  anonymousGtChannel().test.setDead(dead, reason);
}

function pickSolanaReservePools(): string[] {
  const st = state.chain['solana'];
  if (!st || st.watchPools.length === 0) return [];
  const meta = chainWatchMeta['solana'] || {};
  // B4.1: тариф и topN — из ОДНОГО снапшота, чтобы «freeze» и срез списка не разошлись.
  const creditSnap = creditController().snapshot();
  const freezeOnlyFlagship = creditSnap.tier === 'freeze';
  const eligible = st.watchPools
    .filter(addr => {
      if (freezeOnlyFlagship) {
        const m = meta[lower(addr)];
        const subject = m?.subjectToken ? normAddr('solana', m.subjectToken) : '';
        // flagship = major/native token on solana; keep only those in freeze.
        if (!subject || !isMajorToken('solana', subject)) return false;
        return true;
      }
      const m = meta[lower(addr)];
      if (!m) return true; // no meta → include (better than skipping)
      const vol = m.vol24hUsd || 0;
      if (SOLANA_SSE_MIN_VOL_USD <= 0) return true;
      return vol >= SOLANA_SSE_MIN_VOL_USD;
    });
  const topN = creditSnap.solanaTopN;
  if (eligible.length === 0) return st.watchPools.slice(0, topN);
  // sort by vol24hUsd desc, then slice
  eligible.sort((a, b) => (meta[lower(b)]?.vol24hUsd || 0) - (meta[lower(a)]?.vol24hUsd || 0));
  return eligible.slice(0, topN);
}

// v9.13 F1: pick the top-N active EVM pools (BSC/Base/Arbitrum/Polygon/Ethereum) by 24h volume via
// GET /networks/{chain}/pools/search?order_by=volume_usd_24h&sort=desc&limit=N. Refreshed every 6h
// (config dexPaprika.sse.refreshIntervalMs). Cached; returns [] on failure (poll+REST keep working).
const evmSsePoolsCache = new Map<string, { pools: string[]; fetchedAt: number }>();
async function pickActiveEVMReservePools(chainId: string): Promise<string[]> {
  // v9.15 F-AC: topN is tier-driven. In 'freeze' tier EVM SSE is off (topN=0 → no pools → no stream).
  const topN = creditController().snapshot().evmTopN;
  const refreshMs = settings.dexPaprika?.sse?.refreshIntervalMs || 6 * 60 * 60 * 1000;
  const dpNet = CHAINS[chainId]?.dpNetwork;
  if (!dpNet) return [];
  const cached = evmSsePoolsCache.get(chainId);
  if (cached && Date.now() - cached.fetchedAt < refreshMs) return cached.pools;
  try {
    const key = getDexPaprikaApiKey();
    const headers: Record<string, string> = { 'Accept': 'application/json' };
    if (key) headers['Authorization'] = `Bearer ${key}`;
    const res = await getJSON<{ results?: Array<{ id?: string }> }>(
      `https://api.dexpaprika.com/networks/${dpNet}/pools/search?order_by=volume_usd_24h&sort=desc&limit=${topN}`,
      { headers }
    );
    const pools = (res.data?.results || []).map(p => normAddr(chainId, p.id || '')).filter(Boolean);
    if (pools.length > 0) evmSsePoolsCache.set(chainId, { pools, fetchedAt: Date.now() });
    return pools;
  } catch { return cached ? cached.pools : []; }
}

// v9.9 FK1: build the /sse/reserves POST body for a set of pool addresses. The reserves API requires
// `method: 'pool_reserves'` (pool-level data) — omitting it errors even with a valid key.
// Pure + unit-testable.
function buildReserveSubscriptions(chainId: string, pools: string[]): Array<{ chain: string; address: string; method: string; request_id: number }> {
  const dpNet = CHAINS[chainId]?.dpNetwork || chainId;
  return pools.map((addr, idx) => ({ chain: dpNet, address: addr, method: 'pool_reserves', request_id: idx }));
}

// v9.15 F-AC: split a pool list into chunks of at most `maxPerStream` so each /sse/reserves POST
// body stays within DexPaprika's 25-subscription limit (verified live: >25 → HTTP 400 "too many
// subscriptions"). Pure + unit-testable. Every pool is preserved (no lossy truncation).
function chunkSseSubscriptions(pools: string[], maxPerStream = 25): string[][] {
  if (!Array.isArray(pools) || pools.length === 0) return [];
  if (maxPerStream <= 0) return pools.map(p => [p]);
  const out: string[][] = [];
  for (let i = 0; i < pools.length; i += maxPerStream) out.push(pools.slice(i, i + maxPerStream));
  return out;
}

// v9.13 F1: subscribe to DexPaprika SSE /sse/reserves for the top active pools of ANY chain (EVM +
// Solana) and push each per-block reserve frame through the detection pipeline. Uses POST
// (multi-asset, up to 25). Requires an API key (reserves stream is key-gated; keyless = preview_only)
// — with no key we set a truthful sseLastError and keep poll+REST fallbacks. Same reconnect/backoff
// (1/2/5/10/30s via sseState.backoffMs) and graceful shutdown as the Solana path.
// v9.14 F1: isolation knob — set EVM_SSE_ENABLED=0 to disable the EVM (non-solana) DexPaprika
// reserves push and fall back to poll+REST for EVM chains (used to isolate whether F1 is the
// instability source; solana reserves push is unaffected). Default enabled.
const EVM_SSE_ENABLED = process.env.EVM_SSE_ENABLED !== '0';
// v9.24 F1: MASTER switch for the DexPaprika reserves SSE push — OFF BY DEFAULT. Live measurement:
// the reserves stream bills ~1 credit/frame (~1221 frames/min across 6 chains ≈ 900-1200 credits/min),
// draining the whole monthly cap (~300K) in ~5-6 hours, while producing ONLY synthetic `dp-r-…`
// frames that carry no real tx hash and can never alert. Real-trade detection + freshness come from
// GT polling (180s gate) + DexPaprika REST fallback. Set DP_RESERVES_SSE=1 to re-enable.
// Name trap: the runtime flag is DP_RESERVES_SSE_ENABLED; ops docs sometimes say DP_RESERVES_SSE.
// Both are accepted below. Default OFF (SSE burns credits and never produces a real tx hash).
let DP_RESERVES_SSE_ENABLED = process.env.DP_RESERVES_SSE_ENABLED === '1' || process.env.DP_RESERVES_SSE === '1';

async function tryConnectReservesSSE(chainId: string): Promise<void> {
  const st = state.chain[chainId];
  const m = getDpReservesMetrics(chainId);
  if (!settings.sseEnabled || !st.enabled) return;
  // v9.24 F1: reserves SSE is off by default — it bills ~1000 credits/min and never produces a real
  // alert (all frames are synthetic dp-r- ids). Keep poll+REST as the source; honest status.
  if (!DP_RESERVES_SSE_ENABLED) {
    m.lastError = 'reserves SSE disabled (DP_RESERVES_SSE=0) — poll+REST active';
    st.sseLastError = m.lastError;
    if (st.sseConnected) st.sseConnected = false;
    st.source = 'poll';
    return;
  }
  if (!settings.dexPaprika?.enabled) return;
  // v9.20: a chain that was cut off by the free-tier rate limit stays on poll and we do NOT burn
  // credits reconnecting it until the long retry window elapses.
  if (sseState.freeTierLimited[chainId]) {
    const lastAttempt = sseState.lastConnectAttempt[chainId] || 0;
    if (Date.now() - lastAttempt < FREE_TIER_SSE_RETRY_MS) {
      if (!st.sseConnected) st.source = 'poll';
      return;
    }
  }
  // v9.15 F-AC: apiEnabled master switch (also gates the REST fallback / price / liquidity paths).
  if (settings.dexPaprika?.apiEnabled === false) {
    m.lastError = 'DexPaprika API disabled (apiEnabled=false)';
    st.sseLastError = m.lastError;
    return;
  }
  if (chainId !== 'solana' && !EVM_SSE_ENABLED) {
    // isolation: EVM SSE disabled — keep poll+REST as the EVM source, honest status.
    m.lastError = 'EVM SSE disabled via EVM_SSE_ENABLED=0 (poll+REST fallback active)';
    st.sseLastError = m.lastError;
    return;
  }
  // v9.15 F-AC: in 'freeze' tier EVM reserves SSE is off (credit conservation). Solana stays on
  // (flagship-only). The pool picker returns [] for EVM in freeze, so this is a fast honest gate.
  if (chainId !== 'solana' && creditController().snapshot().tier === 'freeze') {
    m.lastError = 'EVM SSE disabled: credit tier=freeze (solana-only, flagship)';
    st.sseLastError = m.lastError;
    return;
  }

  const apiKey = getDexPaprikaApiKey();
  if (!apiKey) {
    m.lastError = 'reserves stream requires API key (keyless = preview_only)';
    st.sseLastError = m.lastError;
    return;
  }

  const now = Date.now();
  const lastAttempt = sseState.lastConnectAttempt[chainId] || 0;
  const backoff = sseState.backoffMs[chainId] || 0;
  if (now - lastAttempt < Math.max(backoff, 5000)) return;
  sseState.lastConnectAttempt[chainId] = now;

  if (runtime.sseAbortControllers[chainId]) runtime.sseAbortControllers[chainId].abort();
  const controller = new AbortController();
  runtime.sseAbortControllers[chainId] = controller;

  let pools: string[] = [];
  if (chainId === 'solana') {
    pools = pickSolanaReservePools();
  } else {
    pools = await pickActiveEVMReservePools(chainId);
  }
  if (pools.length === 0) { m.lastError = 'no active pools found'; return; }
  m.subscribers = pools.length;

  // v9.15 F-AC: DexPaprika caps a single /sse/reserves POST at 25 subscriptions. When a tier asks
  // for more (e.g. high-tier solana topN=50), split into multiple parallel streams of ≤25 each.
  const chunks = chunkSseSubscriptions(pools, 25);

  try {
    const headers: Record<string, string> = { 'Accept': 'text/event-stream', 'Content-Type': 'application/json' };
    if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;

    // Open each chunk as its own stream; run them concurrently (same abort controller).
    await Promise.all(chunks.map(async (chunk, ci) => {
      try {
      const subs = buildReserveSubscriptions(chainId, chunk);
      const res = await fetchWithTimeout('https://streaming.dexpaprika.com/sse/reserves', {
        method: 'POST', headers, body: JSON.stringify(subs), signal: controller.signal,
      }, 15000);
      // v9.15 F-AC + v9.17 F1: the SSE connect POST is a DexPaprika REST call; capture the authoritative
      // X-RateLimit-Remaining/Reset headers. On 402/429, inspect the response body for a remaining value
      // BEFORE downgrading (never blindly freeze on a 402 at startup).
      recordCreditUse('rest', 1, 'sse_reserves'); // bm2e
      // bm4a: те же имена заголовков, что и в getJSON, — через общий экстрактор.
      const sig = applyDpLimitSignals(res.headers);
      const remHdr = sig.creditsRemaining;

      if (res.status !== 200) {
        // v9.18 F1: NEVER await res.text() on a 200 SSE response — the body is a never-ending event
        // stream, so res.text() would hang the connection forever (this was the root cause of SSE
        // streams never connecting in prod). Only drain a finite error body when status != 200.
        let bodyText = '';
        try {
          const t = await res.text();
          bodyText = typeof t === 'string' ? t : '';
        } catch { bodyText = ''; /* error body unreadable; continue */ }

        // On 402/429 inspect the body for a remaining value BEFORE downgrading (never blindly freeze
        // on a 402 at startup).
        if (res.status === 402 || res.status === 429) {
          let confirmed = remHdr >= 0 ? remHdr : -1;
          if (confirmed < 0) {
            const parsed = parseRemainingFromBody(bodyText, -1);
            if (parsed !== undefined && parsed >= 0) confirmed = parsed;
          }
          onCreditHardError(res.status, confirmed >= 0 ? confirmed : undefined);
        }

        st.sseFails++;
        m.lastError = `HTTP ${res.status}: ${bodyText.slice(0, 120)}`;
        if (bodyText.includes('preview_only') || bodyText.includes('API key')) {
          m.lastError = 'reserves stream requires API key (keyless = preview_only)';
        }
        st.sseLastError = m.lastError;
        // v9.20: if the free tier cut this chain off (429/403 + tier:"free"), keep it on poll and do
        // NOT keep reconnecting (each attempt costs a REST credit for nothing) — only retry after a
        // long window. Otherwise use the normal exponential backoff (1/2/5/10/30s → capped 10min).
        if (isFreeTierSseRateLimit(res.status, bodyText)) {
          sseState.freeTierLimited[chainId] = true;
          sseState.backoffMs[chainId] = FREE_TIER_SSE_RETRY_MS;
          sseState.lastConnectAttempt[chainId] = Date.now();
          if (!st.sseConnected) st.source = 'poll';
          // v9.22 F1: a 402 + tier:"free" + "monthly credit limit reached" is a CONFIRMED monthly
          // exhaustion (not a transient rate-limit). Freeze the credit tier so EVM reserves SSE is
          // off (credit conservation) AND make the credit dashboard honest — the real remaining is
          // ~0, not the misleading "300K − spent REST only" estimate (SSE frames aren't billed in
          // usedDailyToday since v9.17 F1, and we never saw the authoritative header).
          // B4.1: подтверждённое исчерпание месяца — переход владеет контроллер (тир + честный
          // авторитетный остаток 0 + отметка даунгрейда); facade решает, надо ли писать лог.
          if (res.status === 402 && creditController().confirmMonthlyExhaustion()) {
            log('[credit] SSE 402 + tier:free + monthly-limit-reached → tier=freeze (monthly credits exhausted; EVM SSE off, poll-only, honest remaining=0)');
          }
          log(`[sse-reserves] ${chainId}: free-tier rate-limit (tier:free) — keeping on poll, retrying SSE in ${Math.round(FREE_TIER_SSE_RETRY_MS / 60000)}min to save credits`);
          return;
        }
        // auto-reconnect backoff 1/2/5/10/30s (doubling, jittered, capped 10min)
        if (res.status === 429 || res.status === 403) {
          sseState.backoffMs[chainId] = Math.min((sseState.backoffMs[chainId] || 1000) * 2, 10 * 60 * 1000) * (0.8 + Math.random() * 0.4);
        } else {
          sseState.backoffMs[chainId] = Math.min((sseState.backoffMs[chainId] || 1000) * 2, 30000);
        }
        return;
      }

      sseState.backoffMs[chainId] = 0;
      sseState.freeTierLimited[chainId] = false; // connected again → not free-tier-limited anymore
      if (!st.sseConnected) {
        st.sseConnected = true;
        st.source = 'sse';
        st.sseLastError = null;
        m.connected = true;
        m.lastError = null;
        log(`[sse-reserves] ${chainId}: Connected (${chunk.length}/${pools.length} pools stream #${ci + 1}/${chunks.length}, key=yes)`);
      }

      const reader = res.body?.getReader();
      if (!reader) { st.sseLastError = 'No readable stream'; st.sseConnected = false; m.connected = false; return; }

      const decoder = new TextDecoder();
      let buffer = '';
      let currentEvent = '';

      // v9.14 F1: bound how many SSE-pushed frames are enriched concurrently. In v9.13 EVERY frame
      // called processExtractedEvent inline (trader resolve → RPC → contract checks → enrichment),
      // so 6 streams × 25 pools could run unbounded heavy RPC fanout on the single thread and starve
      // the HTTP handler. Now the heavy enrichment runs through withEnrichGate: at most
      // ENRICH_CONCURRENCY run at once, the rest queue, and if the queue overflows the frame is
      // dropped (counted) — graceful degradation, never a process freeze.
      let frameBudgetDropped = 0;
      while (true) {
        const readPromise = reader.read();
        let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
        const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) => {
          timeoutHandle = setTimeout(() => resolve({ done: true, value: undefined }), 60000);
        });
        let done: boolean;
        let value: Uint8Array | undefined;
        try {
          const raced = await Promise.race([readPromise, timeoutPromise]);
          done = raced.done;
          value = raced.value;
        } finally {
          if (timeoutHandle) clearTimeout(timeoutHandle); // v9.14 F1: no timer leak per read
        }
        if (done || controller.signal.aborted) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('event:')) { currentEvent = line.slice(6).trim(); }
          else if (line.startsWith('data:')) {
            const dataStr = line.slice(5).trim();
            m.lastFrameAt = Date.now();
            st.sse.lastFrameAt = Date.now();
            st.sseLastEvent = Date.now();
            try {
              const data = JSON.parse(dataStr);
              if (currentEvent === 'ping' || data.ping !== undefined) { st.sse.pingCount++; continue; }
              if (currentEvent === 'warning' || currentEvent === 'error' || (data as Record<string, unknown>).error) { m.dropped++; continue; }
              m.frames++;
              st.sse.framesTotal++;
              // v9.17 F1 (FIX): SSE push frames are NOT separately billed at 1 credit each — counting
              // them inflated usedDailyToday far past the daily budget (21228 while only ~14K real monthly
              // used), which drove the estimate to 0 and caused a premature freeze. The authoritative
              // X-RateLimit-Remaining header already reflects real consumption. We no longer count SSE
              // frames as credits; usedDailyToday now reflects REST requests only, growing with real usage.
              const extracted = extractSubjectFromDpReserve(data, chainId);
              if (!extracted || !extracted.subjectAddr) {
                m.dropped++;
                st.sse.dropped++;
                st.skipReasons.noSubject = (st.skipReasons.noSubject || 0) + 1;
                st.skipped++;
                continue;
              }
              m.parsedOk++;
              st.sse.parsedOk++;
              st.sse.matchedWatchlist++;
              // SSE push → 30s freshness gate (maxAgeSecOverride). Count the push toward seenEvents so
              // the metric reflects SSE detection (and stale_rate has a real denominator). We pass
              // skipSeenIncrement=true because we increment here (matching the legacy SSE path), so
              // processExtractedEvent doesn't double-count.
              st.seenEvents++;
              st.lastEventAt = Date.now();
              // v9.14 F1: run the heavy enrichment under the bounded gate. If the gate is saturated
              // (queue full) the frame is dropped — but seenEvents was already counted, so stale_rate
              // is not polluted. Track drops separately for observability.
              // NOTE: processExtractedEvent is void, so withEnrichGate resolves undefined on BOTH normal
              // completion AND gate-saturation. Use the monotonic global enrichDropped to detect a REAL
              // gate drop so the per-stream `dropped` metric is honest (v9.14 F1 observability fix).
              const beforeDrop = enrichDroppedTotal();
              await enqueueExtracted(chainId, extracted, 'sse', data, true, SOLANA_SSE_MAX_AGE_SEC, extracted.amountUsd >= settings.soloAmountUsd);
              if (enrichDroppedTotal() > beforeDrop) {
                frameBudgetDropped++;
                m.dropped++;
                st.sse.dropped++;
              }
            } catch {
              m.dropped++;
              st.sse.dropped++;
            }
          }
        }
        // v9.14 F1: yield to the event loop every chunk so the HTTP handler (/api/status) gets a
        // chance to run even under a flood of SSE frames.
        await sleep(0);
      }
      } catch (e: unknown) {
        // Per-stream containment: one stream failing must not tear down the sibling streams of the
        // same chain. Record the error on the shared per-chain metrics and let the others continue.
        const err = e as Error;
        st.sseFails++;
        if (err.name !== 'AbortError') {
          m.lastError = err.message;
          st.sseLastError = err.message;
        }
      }
    }));

    st.sseConnected = false;
    st.source = 'poll';
    m.connected = false;
    log(`[sse-reserves] ${chainId}: Stream(s) ended (${chunks.length} streams, ${pools.length} pools)`);
    scheduleSseReconnect(chainId); // v9.13 F1: auto-reconnect
  } catch (e: unknown) {
    const err = e as Error;
    st.sseConnected = false;
    st.source = 'poll';
    m.connected = false;
    st.sseFails++;
    m.lastError = err.name === 'AbortError' ? 'Connection aborted' : err.message;
    st.sseLastError = m.lastError;
    scheduleSseReconnect(chainId); // v9.13 F1: auto-reconnect
  }
}

async function tryConnectSSE(chainId: string): Promise<void> {
  const st = state.chain[chainId];
  if (!settings.sseEnabled || !st.enabled) return;
  const config = CHAINS[chainId];
  if (!config) return;

  const apiKey = getDexPaprikaApiKey();
  if (!apiKey) {
    st.sseLastError = 'disabled: no DEXPAPRIKA_API_KEY';
    st.source = 'poll';
    return;
  }

  const now = Date.now();
  const lastAttempt = sseState.lastConnectAttempt[chainId] || 0;
  const backoff = sseState.backoffMs[chainId] || 0;
  if (now - lastAttempt < Math.max(backoff, 5000)) return;

  const currentSseCount = Object.values(state.chain).filter(c => c.sseConnected).length;
  if (currentSseCount >= sseState.maxConcurrentSse) return;

  sseState.lastConnectAttempt[chainId] = now;

  if (runtime.sseAbortControllers[chainId]) runtime.sseAbortControllers[chainId].abort();
  const controller = new AbortController();
  runtime.sseAbortControllers[chainId] = controller;

  try {
    if (st.watchPools.length === 0) {
      st.sseLastError = 'No pools in watchlist';
      return;
    }

    const poolsToSubscribe = st.watchPools.slice(0, 25);
    const subscriptions = poolsToSubscribe.map((addr, idx) => ({
      chain: config.dpNetwork,
      address: addr,
      method: chainId === 'solana' ? 'token' : 'pool',
      request_id: idx,
    }));

    const sseUrl = 'https://streaming.dexpaprika.com/sse/transactions';
    requestBudget.dexpaprika.push(Date.now());
    // bm2e: POST на подключение SSE — это платный REST-запрос к DexPaprika. Списание здесь
    // отсутствовало (в отличие от SSE reserves), поэтому при включённом потоке его расход был
    // невидим. Поток сейчас выключен (DP_RESERVES_SSE=0), на текущий расход это не влияет.
    recordCreditUse('rest', 1, 'sse_tx');

    const res = await fetchWithTimeout(sseUrl, {
      method: 'POST',
      headers: {
        'Accept': 'text/event-stream',
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${apiKey}`
      },
      body: JSON.stringify(subscriptions),
      signal: controller.signal,
    }, 15000);

    if (res.status !== 200) {
      st.sseFails++;
      if (res.status === 403) {
        st.sseLastError = 'API key required or invalid';
        sseState.backoffMs[chainId] = 60 * 60 * 1000;
        return;
      }
      st.sseLastError = `HTTP ${res.status}`;
      if (res.status === 429) {
        let backoffMs = sseState.backoffMs[chainId] || 30000;
        backoffMs = Math.min(backoffMs * 2, 10 * 60 * 1000) * (0.8 + Math.random() * 0.4);
        sseState.backoffMs[chainId] = Math.round(backoffMs);
      }
      return;
    }

    sseState.backoffMs[chainId] = 0;
    st.sseConnected = true;
    st.source = 'sse';
    st.sseLastError = null;
    log(`[sse] ${chainId}: Connected`);

    const reader = res.body?.getReader();
    if (!reader) {
      st.sseLastError = 'No readable stream';
      st.sseConnected = false;
      return;
    }

    const decoder = new TextDecoder();
    let buffer = '';
    let currentEvent = '';

    while (true) {
      const readPromise = reader.read();
      let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
      const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) => {
        timeoutHandle = setTimeout(() => resolve({ done: true, value: undefined }), 60000);
      });
      let done: boolean;
      let value: Uint8Array | undefined;
      try {
        const raced = await Promise.race([readPromise, timeoutPromise]);
        done = raced.done;
        value = raced.value;
      } finally {
        if (timeoutHandle) clearTimeout(timeoutHandle); // v9.14 F1: no timer leak per read
      }
      if (done || controller.signal.aborted) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        if (line.startsWith('event:')) {
          currentEvent = line.slice(6).trim();
        } else if (line.startsWith('data:')) {
          const dataStr = line.slice(5).trim();
          st.sse.lastFrameAt = Date.now();
          st.sse.lastRawFrame = dataStr;
          st.sseLastEvent = Date.now();

          try {
            const data = JSON.parse(dataStr);
            if (currentEvent === 'ping' || data.time !== undefined) {
              st.sse.pingCount++;
              continue;
            }
            st.sse.framesTotal++;
            if (currentEvent === 'warning' || currentEvent === 'error') continue;

            if (currentEvent === 'pool' || currentEvent === 'token' || !currentEvent) {
              st.seenEvents++;
              st.lastEventAt = Date.now();
              const extracted = extractSubjectFromDpTransaction(data, chainId);
              if (!extracted) {
                st.sse.dropped++;
                st.sse.lastDropReason = 'extraction failed';
                st.skipReasons.noSubject = (st.skipReasons.noSubject || 0) + 1;
                st.skipped++;
                continue;
              }
              st.sse.parsedOk++;
              st.sse.matchedWatchlist++;
              // v9.14 F1: run heavy enrichment under the bounded gate (was fire-and-forget before,
              // which flooded the event loop with unbounded RPC continuations).
              await enqueueExtracted(chainId, extracted, 'sse', data, true);
            } else {
              st.sse.dropped++;
              st.sse.lastDropReason = `unknown event: ${currentEvent}`;
            }
          } catch {
            st.sse.framesTotal++;
            st.sse.dropped++;
            st.sse.lastDropReason = 'parse error';
          }
        }
      }
      await sleep(0); // v9.14 F1: yield to let the HTTP handler run
    }

    st.sseConnected = false;
    st.source = 'poll';
    log(`[sse] ${chainId}: Stream ended`);
  } catch (e: unknown) {
    const err = e as Error;
    st.sseConnected = false;
    st.source = 'poll';
    st.sseFails++;
    st.sseLastError = err.name === 'AbortError' ? 'Connection aborted' : err.message;
  }
}

function isInfra(addr: string): string | null {
  const a = lower(addr);
  if (KNOWN_INFRA[a]) return KNOWN_INFRA[a];
  for (const x of (settings.extraInfra || [])) if (lower(x) === a) return 'custom';
  return null;
}

function isBlacklisted(addr: string): boolean {
  return blacklist.some(x => lower(x) === lower(addr));
}

function isTop10Sym(sym: string): boolean {
  const s = lower(sym);
  return TOP10_HARDCODED.includes(s) || top10Symbols.includes(s);
}

// ─────────────── Anchor pools (ТЗ v6): unconditional + unkillable ───────────────
// D4: async resolution of subject/quote for env anchors (which lack config-level subject).
// Order: (1) poolInfoCache (populated by discovery — no extra GT), (2) GT fetchPoolInfo,
// (3) DexScreener search-by-pool-address (DS budgeted, works even when GT poll-budget is busy).
// On total failure the pool REMAINS in the watchlist (subject/quote stay '?' and fill later).
// Д5: pick the alert-subject token of a pair by priority:
// (1) CEX-candidate token, (2) non-stable AND non-major token, (3) base as last fallback.
function pickAnchorSubject(chainId: string, base: { address: string; symbol: string }, quote: { address: string; symbol: string }): { address: string; symbol: string } {
  // CEX-candidate = CEX-listed AND not stable AND not major AND not blacklisted (a real alert subject).
  const isCexCandidate = (t: { address: string; symbol: string }) =>
    !!cexCache.byAddress[chainId + ':' + lower(t.address)] &&
    !isStablecoin(t.address, t.symbol) && !isMajorToken(chainId, t.address) &&
    !subjectBlacklisted(chainId, t.address, t.symbol);
  const baseIsCex = isCexCandidate(base);
  const quoteIsCex = isCexCandidate(quote);
  if (baseIsCex) return base;
  if (quoteIsCex) return quote;
  const baseGood = !isStablecoin(base.address, base.symbol) && !isMajorToken(chainId, base.address);
  const quoteGood = !isStablecoin(quote.address, quote.symbol) && !isMajorToken(chainId, quote.address);
  if (baseGood) return base;
  if (quoteGood) return quote;
  return base; // last fallback
}

// F1: cache the base/quote token pair of each anchor pool (fetched once), so subject can be
// RE-resolved cheaply (pure pickAnchorSubject) on every cycle after the CEX-candidate cache warms up.
// Keyed for ALL anchors (config + env), not just env anchors.
const envAnchorInfoCache: Record<string, { base: { address: string; symbol: string }; quote: { address: string; symbol: string } }> = {};

// Ensure the base/quote pair of an anchor pool is cached (fetch once; keep trying on failure).
async function ensureAnchorPairCached(chainId: string, poolAddr: string): Promise<void> {
  const addr = normAddr(chainId, poolAddr);
  const key = lower(addr);
  if (envAnchorInfoCache[chainId + ':' + key]) return;
  let info: PoolInfo | null = poolInfoCache.get(key) || poolInfoCache.get(chainId + ':' + key) || null;
  if (!info || !info.baseToken.address) {
    try { info = await fetchPoolInfo(chainId, addr); } catch { info = null; }
  }
  if (!info || !info.baseToken.address) {
    info = await resolveAnchorViaDexScreener(chainId, addr);
  }
  if (info && info.baseToken.address) {
    envAnchorInfoCache[chainId + ':' + key] = { base: info.baseToken, quote: info.quoteToken };
  }
}

// R1: when several anchor pools share a common non-infra leg (a real alert subject like 币有),
// they must ALL resolve to that shared token — even when one pool's *base* is itself CEX-listed
// (e.g. SPYB) and would otherwise win pickAnchorSubject's "base CEX first" rule. Returns a map
// poolAddr(lower) → preferred subject; falls back to per-pool pickAnchorSubject for non-shared pools.
// R1: the full set of anchor pools to re-resolve = config anchors (incl. env-injected ones) ∪ env
// anchors (test hook may inject into ENV_ANCHOR_POOLS only), deduped by address.
function allAnchorPools(chainId: string): AnchorPool[] {
  const seen = new Set<string>();
  const out: AnchorPool[] = [];
  for (const list of [ANCHOR_POOLS[chainId] || [], ENV_ANCHOR_POOLS[chainId] || []]) {
    for (const a of list) {
      const k = lower(normAddr(chainId, a.pool));
      if (!k || seen.has(k)) continue;
      seen.add(k);
      out.push(a);
    }
  }
  return out;
}

function anchorSubjectPreferences(chainId: string): Map<string, { address: string; symbol: string }> {
  const anchors = allAnchorPools(chainId);
  const pairs: Array<{ base: { address: string; symbol: string }; quote: { address: string; symbol: string } }> = [];
  for (const a of anchors) {
    const p = envAnchorInfoCache[chainId + ':' + lower(normAddr(chainId, a.pool))];
    if (p && p.base.address) pairs.push(p);
  }
  const freq: Record<string, number> = {};
  const symOf: Record<string, string> = {};
  for (const p of pairs) {
    for (const t of [p.base, p.quote]) {
      const ad = normAddr(chainId, t.address);
      if (!ad) continue;
      if (isStablecoin(t.address, t.symbol) || isMajorToken(chainId, t.address) || isNativeOrWrapped(t.address)) continue;
      const k = lower(ad);
      freq[k] = (freq[k] || 0) + 1;
      symOf[k] = t.symbol || '?';
    }
  }
  // shared legs appear in >=2 anchor pools → a genuine anchor-theme subject
  const shared = new Set<string>();
  for (const [k, f] of Object.entries(freq)) if (f >= 2) shared.add(k);
  const pref = new Map<string, { address: string; symbol: string }>();
  for (const a of anchors) {
    const addr = normAddr(chainId, a.pool);
    const pair = envAnchorInfoCache[chainId + ':' + lower(addr)];
    if (!pair || !pair.base.address) continue;
    let chosen: { address: string; symbol: string } | null = null;
    for (const t of [pair.base, pair.quote]) {
      const k = lower(normAddr(chainId, t.address));
      if (shared.has(k)) { chosen = { address: normAddr(chainId, t.address), symbol: t.symbol || '?' }; break; }
    }
    pref.set(lower(addr), chosen || pickAnchorSubject(chainId, pair.base, pair.quote));
  }
  return pref;
}

async function resolveEnvAnchorSubjects(): Promise<void> {
  for (const chainId of Object.keys(CHAINS)) {
    const anchors = allAnchorPools(chainId);
    if (anchors.length === 0) continue;
    for (const a of anchors) {
      await ensureAnchorPairCached(chainId, a.pool);
    }
    // R1: re-resolve subject for EVERY anchor pool, regardless of prior subjectToken presence
    // (never skip a pool that already carries a subject — the cold-start wrong pick must be fixed).
    const prefs = anchorSubjectPreferences(chainId);
    for (const a of anchors) {
      const addr = normAddr(chainId, a.pool);
      const key = lower(addr);
      const meta = chainWatchMeta[chainId];
      const entry = meta?.[key];
      if (!entry) continue;
      const pair = envAnchorInfoCache[chainId + ':' + key];
      if (!pair) continue; // unresolved yet → retried next cycle (anchor not dropped)
      const subj = prefs.get(key) || pickAnchorSubject(chainId, pair.base, pair.quote);
      entry.subjectToken = normAddr(chainId, subj.address);
      entry.subjectSymbol = subj.symbol || '?';
      entry.quoteSymbol = pair.quote.symbol || '?';
    }
  }
}

// DS fallback: resolve a pool's base/quote via DexScreener search-by-address (budgeted).
async function resolveAnchorViaDexScreener(chainId: string, poolAddr: string): Promise<PoolInfo | null> {
  const dsChain = CHAINS[chainId]?.ds;
  if (!dsChain) return null;
  try {
    // bm2r D5: ручной бюджет вырос в единый шлюз (бэкофф 429 + лейн pool + учёт).
    const res = await dsFetch<{ pairs?: Array<{ chainId?: string; pairAddress?: string; baseToken?: { address?: string; symbol?: string }; quoteToken?: { address?: string; symbol?: string }; liquidity?: { usd?: number } }> }>(
      'pool',
      'https://api.dexscreener.com/latest/dex/search?q=' + encodeURIComponent(poolAddr)
    );
    if (res.suppressed) return null;
    if (!res.ok || !res.data?.pairs) return null;
    const pair = res.data.pairs.find(p => (p.chainId || '').toLowerCase() === dsChain && lower(p.pairAddress || '') === lower(poolAddr));
    if (!pair?.baseToken?.address || !pair?.quoteToken?.address) return null;
    const info: PoolInfo = {
      address: poolAddr,
      baseToken: { address: normAddr(chainId, pair.baseToken.address), symbol: pair.baseToken.symbol || '?', name: pair.baseToken.symbol || '?' },
      quoteToken: { address: normAddr(chainId, pair.quoteToken.address), symbol: pair.quoteToken.symbol || '?', name: pair.quoteToken.symbol || '?' },
      liquidityUsd: Number(pair.liquidity?.usd) || 0, fdv: 0, cachedAt: Date.now(),
    };
    poolInfoCache.set(lower(poolAddr), info);
    poolInfoCache.set(chainId + ':' + lower(poolAddr), info);
    return info;
  } catch {
    return null;
  }
}

// ensureAnchorsApplied: local, idempotent, ZERO network — force-adds every configured anchor to
// the chain's watchlist + metadata even when GeckoTerminal is down/rate-limited. This guarantees
// acceptance pools (e.g. Fartcoin FAYL7) are present before any successful discovery, survive
// partial failures, and are never dropped by retries/backoff.
function ensureAnchorsApplied(chainId: string): void {
  const anchors = ANCHOR_POOLS[chainId] || [];
  const st = state.chain[chainId];
  if (!st) return;
  if (anchors.length === 0) { st.watchPoolsAnchors = []; return; }
  if (!chainWatchMeta[chainId]) chainWatchMeta[chainId] = {};
  for (const a of anchors) {
    const addr = normAddr(chainId, a.pool);
    const key = lower(addr);
    const existing = chainWatchMeta[chainId][key];
    if (!existing) {
      // seed with subject from config so why-tx subjectWatchlistPools works even w/o GT
      chainWatchMeta[chainId][key] = {
        address: addr,
        subjectToken: a.subject ? normAddr(chainId, a.subject) : '',
        subjectSymbol: a.symbol || '?',
        quoteSymbol: a.quote || '?',
        reserveUsd: null,
        vol24hUsd: null,
        source: 'anchor',
      };
    } else if (!existing.source.startsWith('anchor')) {
      // organically discovered anchor → keep metrics, but mark it as anchor-sourced
      existing.source = `anchor+${existing.source}` as WatchPoolEntry['source'];
    }
  }
  // recompute anchors list + ensure presence in the single source-of-truth list
  st.watchPoolsAnchors = anchors.map(a => normAddr(chainId, a.pool));
  for (const addr of st.watchPoolsAnchors) {
    if (!st.watchPools.some(x => lower(x) === lower(addr))) st.watchPools.push(addr);
  }
  if (isEVMChain(chainId)) {
    for (const a of anchors) {
      if (isValidEvmAddress(a.pool)) {
        queueEvmPoolMetaEnrichment(chainId, a.pool, undefined, a.subject, a.quote);
      }
    }
  }
}

// enrichAnchors: after discovery, fill anchor metrics from whatever the discovery collected
// (so a successful GT round populates reserveUsd/vol24hUsd and flips source to 'anchor+…').
function enrichAnchors(chainId: string, collected: Array<{ address: string; reserveUsd: number; volume24h: number }>): void {
  const st = state.chain[chainId];
  if (!st) return;
  const meta = chainWatchMeta[chainId] || {};
  for (const addr of st.watchPoolsAnchors) {
    const key = lower(addr);
    const entry = meta[key];
    if (!entry || !entry.source.startsWith('anchor')) continue;
    const found = collected.find(p => lower(p.address) === key);
    if (found) {
      const r = found.reserveUsd > 0;
      const v = found.volume24h > 0;
      entry.reserveUsd = r ? found.reserveUsd : null;
      entry.vol24hUsd = v ? found.volume24h : null;
      entry.source = r && v ? 'anchor+both' : r ? 'anchor+reserve' : v ? 'anchor+volume' : 'anchor';
    }
  }
}

// composeWatchlist: single source of truth = organic discovery ∪ anchors ∪ already-watched pools,
// deduped, stable order. Preserving already-watched pools means partial discovery failures and
// pinned/seed pools NEVER drop existing coverage (ТЗ v6: coverage is never lost).
function composeWatchlist(chainId: string, organicPools: string[]): string[] {
  const st = state.chain[chainId];
  if (!st) return [];
  st.watchPoolsDiscovery = [...organicPools];
  const anchors = st.watchPoolsAnchors || [];
  const existing = st.watchPools || [];
  const merged: string[] = [];
  const seen = new Set<string>();
  // ТЗ v7 req.3: existing major×major / blacklisted-subject pools are cleaned from the next build.
  for (const a of [...organicPools, ...anchors, ...existing]) {
    const k = lower(a);
    if (seen.has(k)) continue;
    if (poolIsInvalidForAlert(chainId, a)) continue; // drop invalid (major×major / blacklisted subject)
    seen.add(k);
    merged.push(a);
  }
  st.watchPools = merged;
  if (isEVMChain(chainId)) {
    for (const a of merged) {
      if (isValidEvmAddress(a)) {
        queueEvmPoolMetaEnrichment(chainId, a);
      }
    }
  }
  return merged;
}

// ─────────────── ТЗ v7: systematic CEX-alt coverage helpers ───────────────
type DiscoveryPoolLike = { address: string; volume24h: number; reserveUsd: number; volume1h?: number; txns1h?: number; baseSymbol?: string };
type DsPair = {
  chainId?: string; pairAddress?: string;
  baseToken?: { address?: string; symbol?: string };
  quoteToken?: { address?: string; symbol?: string };
  liquidity?: { usd?: number };
  volume?: { h24?: number; h1?: number };
  txns?: { h1?: { buys?: number; sells?: number } };
};
type GtDiscPool = {
  id?: string;
  attributes?: { address?: string; reserve_in_usd?: string; volume_usd?: { h24?: string } };
  relationships?: { base_token?: { data?: { id?: string } }; quote_token?: { data?: { id?: string } }; dex?: { data?: { id?: string } } };
};
type GtTokInc = { id?: string; type?: string; attributes?: { address?: string; symbol?: string; name?: string } };
type GtDiscResponse = { data?: GtDiscPool[]; included?: GtTokInc[] };

// Cache a GT pool's base/quote tokens into poolInfoCache (used by discovery collectors).
function cacheGtPool(chainId: string, p: GtDiscPool, inc: GtTokInc[], addr: string, vol: number, reserve: number): void {
  const baseId = p.relationships?.base_token?.data?.id;
  const quoteId = p.relationships?.quote_token?.data?.id;
  if (!baseId || !quoteId || inc.length === 0) return;
  const base = inc.find(i => i.id === baseId)?.attributes;
  const quote = inc.find(i => i.id === quoteId)?.attributes;
  if (!base?.address || !quote?.address) return;
  const info: PoolInfo = {
    address: addr,
    baseToken: { address: normAddr(chainId, base.address), symbol: base.symbol || '?', name: base.name || '?' },
    quoteToken: { address: normAddr(chainId, quote.address), symbol: quote.symbol || '?', name: quote.name || '?' },
    liquidityUsd: reserve, fdv: 0, cachedAt: Date.now(),
    dexId: p.relationships?.dex?.data?.id || undefined,
  };
  poolInfoCache.set(lower(addr), info);
  poolInfoCache.set(chainId + ':' + lower(addr), info);
  poolInfoCache.set(lower(base.address) + ':' + lower(quote.address), info);
}

// Non-mutating blacklist check for a subject (active rules only, no stat mutation).
function subjectBlacklisted(chainId: string, addr: string, sym: string): boolean {
  for (const rule of blacklistRules) {
    if (!rule.enabled) continue;
    if (rule.type === 'address') {
      if (rule.chain !== 'all' && rule.chain !== chainId) continue; // RC2 BP4: 'all' = все цепи
      if (isEVMChain(chainId) ? lower(rule.value) === lower(addr) : rule.value === addr) return true;
    } else if (rule.type === 'symbol-regex') {
      if (rule.chain !== 'all' && rule.chain !== chainId) continue;
      try { if (new RegExp(rule.value).test(sym)) return true; } catch { /* ignore */ }
    }
  }
  return false;
}

// Candidate CEX subject tokens for a chain = ALL CEX-listed tokens that could be an alert subject
// (exclude major/stable subjects and blacklisted subjects). ТЗ v7 req.1: candidates are the FULL
// CEX-listed set, not a top-N from trending pages.
// bm2t T3: три пустых подряд — карантин на 6ч. Обход не переставляем, мёртвый токен просто пропускаем.
const DISCOVERY_QUARANTINE_N = 3;
const DISCOVERY_QUARANTINE_TTL_MS = 6 * 3600_000;
const DISCOVERY_DS_CANDIDATES_PER_MIN = 48;
const discoveryQuarantine = new Map<string, { streak: number; until: number }>();
const discoveryPoolsAdded: Array<{ chainId: string; at: number }> = [];
const discoveryDsCandidateHits: number[] = [];
function noteDiscoveryLookup(chainId: string, addr: string, gotPools: boolean): void {
  const k = chainId + ':' + lower(addr);
  if (gotPools) { discoveryQuarantine.delete(k); return; }
  const row = discoveryQuarantine.get(k) || { streak: 0, until: 0 };
  row.streak += 1;
  if (row.streak >= DISCOVERY_QUARANTINE_N) row.until = dsNow() + DISCOVERY_QUARANTINE_TTL_MS;
  discoveryQuarantine.set(k, row);
}
function discoveryTokenQuarantined(chainId: string, addr: string, now = dsNow()): boolean {
  const row = discoveryQuarantine.get(chainId + ':' + lower(addr));
  return !!row && row.until > now;
}
function discoveryQuarantinedCount(chainId: string, now = dsNow()): number {
  const p = chainId + ':';
  let n = 0;
  for (const [k, row] of discoveryQuarantine) if (k.startsWith(p) && row.until > now) n++;
  return n;
}
function discoveryPoolsAdded24h(chainId: string, now = dsNow()): number {
  const cut = now - 24 * 3600_000;
  return discoveryPoolsAdded.filter(x => x.chainId === chainId && x.at > cut).length;
}
function noteDiscoveryPoolAdded(chainId: string): void {
  discoveryPoolsAdded.push({ chainId, at: dsNow() });
}
async function appendDsBanCandidates(chainId: string, candidates: Array<{ address: string; symbol: string }>): Promise<void> {
  if (!gtBanState().banned && !anonymousGtChannel().snapshot().dead) return; // B4.2: dead — из канала
  const now = dsNow();
  const cut = now - 60_000;
  while (discoveryDsCandidateHits.length && discoveryDsCandidateHits[0] <= cut) discoveryDsCandidateHits.shift();
  let room = DISCOVERY_DS_CANDIDATES_PER_MIN - discoveryDsCandidateHits.length;
  if (room <= 0) return;
  const seen = new Set(candidates.map(c => lower(c.address)));
  let profiles: DsTokenProfile[] = [];
  let boosts: DsBoostRow[] = [];
  try { profiles = await dsFeedProfiles(); } catch { profiles = []; }
  try { boosts = await dsFeedBoosts(); } catch { boosts = []; }
  let added = 0;
  for (const row of [...profiles, ...boosts]) {
    if (room <= 0) break;
    if (String(row.chainId || '') !== chainId) continue;
    const addr = String(row.tokenAddress || '');
    if (!addr || seen.has(lower(addr))) continue;
    if (isMajorToken(chainId, addr) || isStablecoin(addr, '') || subjectBlacklisted(chainId, addr, '')) continue;
    seen.add(lower(addr));
    candidates.push({ address: addr, symbol: '' });
    discoveryDsCandidateHits.push(now);
    room--;
    added++;
  }
  const st = state.chain[chainId];
  if (st) st.discoveryCandidatesFromDs = (st.discoveryCandidatesFromDs || 0) + added;
}
function buildDiscoveryCandidates(chainId: string): Array<{ address: string; symbol: string }> {
  const out: Array<{ address: string; symbol: string }> = [];
  const seen = new Set<string>();
  for (const entry of Object.values(cexCache.byAddress)) {
    const addr = entry.addresses[chainId]; // original case preserved (solana + DS case-sensitivity)
    if (!addr) continue;
    const key = chainId + ':' + lower(addr);
    if (seen.has(key)) continue;
    seen.add(key);
    const sym = entry.symbol;
    if (isMajorToken(chainId, addr)) continue;          // WBTC/WETH/… — not an alt subject
    if (isStablecoin(addr, sym)) continue;              // stable — no alt
    if (subjectBlacklisted(chainId, addr, sym)) continue; // blacklisted subject (ТЗ v7 req.3b)
    out.push({ address: addr, symbol: sym });
  }
  return out;
}

const dsPoolCache: Map<string, { pools: DiscoveryPoolLike[]; cachedAt: number }> = new Map();
const DS_POOL_RATE_MIN = 250; // safety ceiling (DexScreener ~300/min free)
// v9.23 F1: per-chain fair-share of the discovery DS budget. The shared 250/min cap was consumed by
// whichever chains ran their discovery first each minute, leaving Base (2545 candidates, running 3rd
// in the loop) with ~0 DS slots → dsOk=0 always, only 3 anchor pools, organic=0. Give every chain a
// guaranteed slice of the DS budget so no chain is starved by the contention order.
const DS_PER_CHAIN_PER_MIN = Number(process.env.DS_PER_CHAIN_PER_MIN || '45'); // 250/6 ≈ 41 → 45 fair share
const dsChainBudget: Record<string, number[]> = {};
function cleanDsChainBudget(chainId: string): void {
  const oneMinAgo = Date.now() - 60000;
  const arr = dsChainBudget[chainId] || [];
  dsChainBudget[chainId] = arr.filter(t => t > oneMinAgo);
}
function dsChainBudgetAvailable(chainId: string): boolean {
  cleanDsChainBudget(chainId);
  return (dsChainBudget[chainId] || []).length < DS_PER_CHAIN_PER_MIN;
}
function recordDsChainBudget(chainId: string): void {
  cleanDsChainBudget(chainId);
  if (!dsChainBudget[chainId]) dsChainBudget[chainId] = [];
  dsChainBudget[chainId].push(Date.now());
}

// ───────────────────────── bm2r D1/D2: единый шлюз DexScreener ─────────────────────────
// Прод 5c3f17f: 429 от DS видел только канал среза (5 из 8 циклов 'fail', detail = HTML Cloudflare),
// а восемь других точек движка ходили в DS без единого учёта (requestBudget.dexscreener покрывал
// 2 вызывающих из 13) — общая реакция на 429 была невозможна, и каждый путь продолжал жать лимит.
// Здесь: одна проверка бэкоффа/бюджета (dsGate), один учёт результата (dsNoteResult) и одна
// обёртка для обычных путей (dsFetch). Канал среза зовёт dsGate/dsNoteResult напрямую:
// его HTTP инжектируется тестами (coverageSliceHttpImpl) и обёртывать его нельзя.
type DsSource = 'price' | 'liquidity' | 'symbol' | 'pool' | 'pair' | 'hotpoll' | 'slice' | 'discovery' | 'profile' | 'other';
type DsSuppress = 'backoff' | 'budget' | 'skipped-budget';
type DsEndpointClass = 'profiles' | 'boosts' | 'dex';
type DsLaneId = 'shared' | 'discovery' | 'symbol';
const DS_SOURCES: readonly DsSource[] = ['price', 'liquidity', 'symbol', 'pool', 'pair', 'hotpoll', 'slice', 'discovery', 'profile', 'other'];
// Живые сделки и алерты важнее фонового добора: price/liquidity — приоритет, остальное bulk.
const DS_PRIORITY_SOURCES: ReadonlySet<DsSource> = new Set<DsSource>(['price', 'liquidity']);
// bulk не может занять последние DS_PRIORITY_RESERVE слотов минуты (250/мин всего, 170 bulk максимум).
const DS_PRIORITY_RESERVE = () => Math.max(0, Number(process.env.DS_PRIORITY_RESERVE ?? '80') || 0);
const DS_BACKOFF_BASE_MS = () => Math.max(1000, Number(process.env.DS_BACKOFF_BASE_MS ?? '30000') || 30000);
const DS_BACKOFF_MAX_MS = () => Math.max(1000, Number(process.env.DS_BACKOFF_MAX_MS ?? '600000') || 600000);
type DsSourceStats = { attempts: number; ok: number; s429: number; s5xx: number; other: number; net: number };
type DsClassStats = { attempts: number; ok: number; s429: number };
type DsLane = { level: number; until: number; probeIssued: boolean; escalatedWindowUntil: number };
function dsEmptySourceStats(): Record<DsSource, DsSourceStats> {
  const o = {} as Record<DsSource, DsSourceStats>;
  for (const s of DS_SOURCES) o[s] = { attempts: 0, ok: 0, s429: 0, s5xx: 0, other: 0, net: 0 };
  return o;
}
function dsEmptyClassStats(): Record<DsEndpointClass, DsClassStats> {
  return { profiles: { attempts: 0, ok: 0, s429: 0 }, boosts: { attempts: 0, ok: 0, s429: 0 }, dex: { attempts: 0, ok: 0, s429: 0 } };
}
function dsEmptyLane(): DsLane { return { level: 0, until: 0, probeIssued: false, escalatedWindowUntil: 0 }; }
const dsLanes: Record<DsLaneId, DsLane> = { shared: dsEmptyLane(), discovery: dsEmptyLane(), symbol: dsEmptyLane() };
const dsClassHits: Record<DsEndpointClass, number[]> = { profiles: [], boosts: [], dex: [] };
const dsGateway = {
  level: 0, until: 0, last429At: 0, lastReason: '', retryAfterMs: 0,
  suppressedBackoff: 0, suppressedBudget: 0,
  levelChanges: 0, probeAttempts: 0, probeOk: 0,
  bySource: dsEmptySourceStats(),
  byEndpointClass: dsEmptyClassStats(),
};
function dsNow(): number { return getSolanaPubsub().subNowImpl(); }
function dsBackoffActive(now = dsNow()): boolean { return now < dsGateway.until; }
function dsBudgetUsedMin(): number {
  cleanBudget();
  return requestBudget.dexscreener.filter(t => t > Date.now() - 60000).length;
}
function dsLaneId(source: DsSource): DsLaneId {
  if (source === 'discovery') return 'discovery';
  if (source === 'symbol') return 'symbol';
  return 'shared';
}
function dsSyncShared(): void {
  dsGateway.level = dsLanes.shared.level;
  dsGateway.until = dsLanes.shared.until;
}
function dsSetLevel(lane: DsLane, next: number): void {
  if (lane.level === next) return;
  lane.level = next;
  dsGateway.levelChanges++;
}
function dsEndpointClass(url?: string): DsEndpointClass {
  const u = String(url || '').toLowerCase();
  if (u.includes('token-profiles')) return 'profiles';
  if (u.includes('token-boosts')) return 'boosts';
  return 'dex';
}
function dsClassLimit(cls: DsEndpointClass): number {
  // Документированные квоты DS × 0.8: profiles/boosts 60/мин, dex 300/мин.
  if (cls === 'dex') return Math.floor(0.8 * Number(process.env.DS_DEX_PER_MIN || '300'));
  return Math.floor(0.8 * Number(process.env.DS_PROFILES_PER_MIN || '60'));
}
function dsClassUsed(cls: DsEndpointClass, now = dsNow()): number {
  const cut = now - 60_000;
  const arr = dsClassHits[cls];
  let i = 0;
  while (i < arr.length && arr[i] <= cut) i++;
  if (i) arr.splice(0, i);
  return arr.length;
}
function dsJitter(ms: number): number { return Math.round(ms * (0.8 + Math.random() * 0.4)); }
function dsLadderMs(level: number): number {
  const lv = Math.max(1, level);
  return Math.min(DS_BACKOFF_MAX_MS(), DS_BACKOFF_BASE_MS() * 2 ** (lv - 1));
}
function dsOccupy(cls: DsEndpointClass, source: DsSource, now: number): void {
  dsClassHits[cls].push(now);
  // profiles/boosts — отдельная квота, не едят dex-поток 250 (иначе фид топит price).
  if (cls === 'dex') requestBudget.dexscreener.push(Date.now());
  void source;
}
function dsGate(source: DsSource, url?: string): { allowed: boolean; reason?: DsSuppress; probe?: boolean } {
  const now = dsNow();
  const cls = dsEndpointClass(url);
  if (cls === 'profiles' || cls === 'boosts') {
    if (dsClassUsed(cls, now) >= dsClassLimit(cls)) {
      dsGateway.suppressedBudget++;
      return { allowed: false, reason: 'skipped-budget' };
    }
  }
  const id = dsLaneId(source);
  const own = dsLanes[id];
  // discovery/symbol не читают чужую лестницу дольше Retry-After: shared.until их глушит
  // только если его выставил сам shared (slice/price) или короткий cross-pause.
  const until = id === 'shared' ? own.until : Math.max(own.until, dsLanes.shared.until);
  if (now < until) { dsGateway.suppressedBackoff++; return { allowed: false, reason: 'backoff' }; }
  const probeLane = id === 'shared' ? dsLanes.shared : own;
  if (probeLane.level > 0) {
    if (probeLane.probeIssued) { dsGateway.suppressedBackoff++; return { allowed: false, reason: 'backoff' }; }
    probeLane.probeIssued = true;
    probeLane.until = now + 60_000; // держим лейн, пока не вернётся ответ пробы
    if (probeLane === dsLanes.shared) dsSyncShared();
    dsGateway.probeAttempts++;
    dsOccupy(cls, source, now);
    return { allowed: true, probe: true };
  }
  // bm2q D4: у канала среза свой резерв поверх общего потолка — сохраняем его и здесь.
  // dex-класс дополнительно режется 0.8×300, но срез резерв не теряет.
  if (cls === 'dex') {
    const laneLimit = source === 'slice'
      ? DS_POOL_RATE_MIN + COVERAGE_DS_RESERVE()
      : DS_POOL_RATE_MIN - (DS_PRIORITY_SOURCES.has(source) ? 0 : DS_PRIORITY_RESERVE());
    const limit = source === 'slice' ? laneLimit : Math.min(laneLimit, dsClassLimit('dex'));
    if (dsBudgetUsedMin() >= limit) { dsGateway.suppressedBudget++; return { allowed: false, reason: 'budget' }; }
  }
  dsOccupy(cls, source, now);
  return { allowed: true };
}
function dsNoteResult(source: DsSource, status: number, retryAfterMs?: number, url?: string): void {
  const b = dsGateway.bySource[source]; b.attempts++;
  const cls = dsEndpointClass(url);
  const cs = dsGateway.byEndpointClass[cls]; cs.attempts++;
  const id = dsLaneId(source);
  const lane = dsLanes[id];
  const now = dsNow();
  const publish = () => { if (id === 'shared') dsSyncShared(); };
  if (status >= 200 && status < 300) {
    b.ok++; cs.ok++;
    if (lane.level > 0) {
      dsSetLevel(lane, Math.max(0, lane.level - 1));
      dsGateway.probeOk++;
      lane.probeIssued = false;
      if (lane.level === 0) { lane.until = 0; lane.escalatedWindowUntil = 0; dsGateway.retryAfterMs = 0; }
      else lane.until = now + dsJitter(dsLadderMs(lane.level));
    } else {
      lane.probeIssued = false;
      if (lane.until > 0 && lane.until <= now + 60_000 && dsGateway.retryAfterMs === 0 && lane.level === 0) {
        // пробный холд без уровня не должен переживать успех
        if (lane.until > now) lane.until = 0;
      }
    }
    publish();
    return;
  }
  if (status === 429) {
    b.s429++; cs.s429++;
    const fromHeader = Math.max(0, Number(retryAfterMs) || 0);
    lane.probeIssued = false;
    dsGateway.last429At = now;
    dsGateway.lastReason = 'rate-limit';
    dsGateway.retryAfterMs = fromHeader;
    if (fromHeader > 0) {
      // провайдер назвал паузу — ждём Retry-After + jitter, лестницу не поднимаем
      const jitter = Math.round(fromHeader * Math.random() * 0.2);
      lane.until = now + fromHeader + jitter;
      if (id !== 'shared') {
        // discovery/symbol не топят price дольше Retry-After и не поднимают его уровень
        const pause = now + fromHeader + jitter;
        if (dsLanes.shared.until < pause) dsLanes.shared.until = pause;
        dsSyncShared();
      }
    } else if (now >= lane.escalatedWindowUntil) {
      dsSetLevel(lane, Math.min(lane.level + 1, 8));
      const jittered = dsJitter(dsLadderMs(lane.level));
      lane.until = now + jittered;
      lane.escalatedWindowUntil = lane.until;
      // изолированный лейн не пишет shared.until — price остаётся на своём уровне
    } else {
      lane.until = now + dsJitter(dsLadderMs(Math.max(1, lane.level)));
    }
    publish();
    return;
  }
  lane.probeIssued = false;
  if (lane.level > 0) lane.until = now + dsJitter(dsLadderMs(lane.level));
  else if (lane.until > now && dsGateway.retryAfterMs === 0) lane.until = 0;
  publish();
  if (status >= 500) { b.s5xx++; return; }
  if (status === 0) { b.net++; return; }
  b.other++;
}
async function dsFetch<T>(source: DsSource, url: string, opts: RequestInit = {}): Promise<FetchResult<T> & { suppressed?: DsSuppress }> {
  const g = dsGate(source, url);
  if (!g.allowed) return { ok: false, status: 0, error: `suppressed:${g.reason}`, suppressed: g.reason };
  const res = await getJSON<T>(url, opts);
  dsNoteResult(source, res.status, res.retryAfterMs, url);
  return res;
}
function dsResetLanes(): void {
  dsLanes.shared = dsEmptyLane(); dsLanes.discovery = dsEmptyLane(); dsLanes.symbol = dsEmptyLane();
  dsClassHits.profiles = []; dsClassHits.boosts = []; dsClassHits.dex = [];
}
// bm2r: тестовые доступы — шлюз это глобальное состояние, тесты обязаны его сбрасывать.
export function __resetDsGatewayForTest(): void {
  dsGateway.level = 0; dsGateway.until = 0; dsGateway.last429At = 0; dsGateway.lastReason = '';
  dsGateway.retryAfterMs = 0; dsGateway.suppressedBackoff = 0; dsGateway.suppressedBudget = 0;
  dsGateway.levelChanges = 0; dsGateway.probeAttempts = 0; dsGateway.probeOk = 0;
  dsGateway.bySource = dsEmptySourceStats();
  dsGateway.byEndpointClass = dsEmptyClassStats();
  dsResetLanes();
}
export function __dsGatewayForTest(): Record<string, unknown> {
  const headerSec = dsGateway.retryAfterMs > 0 ? Math.round(dsGateway.retryAfterMs / 1000) : 0;
  return {
    backoff: {
      active: dsBackoffActive(), until: dsGateway.until, level: dsGateway.level,
      last429At: dsGateway.last429At, lastReason: dsGateway.lastReason,
      retryAfterSec: headerSec || Math.max(0, Math.round((dsGateway.until - dsNow()) / 1000)),
    },
    levelChanges: dsGateway.levelChanges,
    probeAttempts: dsGateway.probeAttempts,
    probeOk: dsGateway.probeOk,
    lanes: {
      price: { level: dsLanes.shared.level, until: dsLanes.shared.until },
      liquidity: { level: dsLanes.shared.level, until: dsLanes.shared.until },
      discovery: { level: dsLanes.discovery.level, until: dsLanes.discovery.until },
      symbol: { level: dsLanes.symbol.level, until: dsLanes.symbol.until },
    },
    byEndpointClass: JSON.parse(JSON.stringify(dsGateway.byEndpointClass)),
    suppressed: { backoff: dsGateway.suppressedBackoff, budget: dsGateway.suppressedBudget },
    bySource: JSON.parse(JSON.stringify(dsGateway.bySource)),
    perMinUsed: dsBudgetUsedMin(), perMinLimit: DS_POOL_RATE_MIN, priorityReserve: DS_PRIORITY_RESERVE(),
  };
}
export async function __dsFetchForTest(source: DsSource, url: string): Promise<FetchResult<unknown> & { suppressed?: DsSuppress }> {
  return dsFetch<unknown>(source, url);
}
// Д1: cached 24h DEX volume per CEX-subject token (chain:addr → usd), updated on each discovery.
// Used to order candidates by volume (descending) so active alts get covered first. tiebreak = address.
const candidateVolumeCache: Map<string, number> = new Map();
// Д1 SLA: track when a high-volume CEX candidate first gets a watched pool (relative to process start).
const SLA_HIGH_VOL = 100_000; // $100K 24h DEX volume
const candidateFirstCoveredAt: Map<string, number> = new Map();
// b12 R5: `let`, а не `const` — только ради тестового шва __setProcessStartedAtForTest (runway на
// неполном дне обязан быть null, а ждать сутки в тесте нельзя). Прод-путь пишет сюда один раз.
let processStartedAt = Date.now();
function noteCandidateCovered(chainId: string, subjectAddr: string): void {
  const key = chainId + ':' + lower(subjectAddr);
  if (!candidateFirstCoveredAt.has(key)) candidateFirstCoveredAt.set(key, Date.now());
}
// coverageSlaSec95: P95 latency (s) from process start to first covered pool, among high-volume candidates.
function computeCoverageSlaSec95(chainId: string): number {
  const latencies: number[] = [];
  for (const [key, coveredAt] of candidateFirstCoveredAt) {
    if (!key.startsWith(chainId + ':')) continue;
    const vol = candidateVolumeCache.get(key) || 0;
    if (vol < SLA_HIGH_VOL) continue;
    latencies.push(Math.max(0, (coveredAt - processStartedAt) / 1000));
  }
  if (latencies.length === 0) return 0;
  latencies.sort((a, b) => a - b);
  const idx = Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95));
  return Math.round(latencies[idx]);
}
// priorityMisses: high-volume candidates with 0 watched pools at this moment.
function computePriorityMisses(chainId: string): string[] {
  const meta = chainWatchMeta[chainId] || {};
  const covered = new Set<string>();
  // cover by both organic (subjectToken) and anchor subjects
  for (const m of Object.values(meta)) {
    if (m.subjectToken) covered.add(lower(m.subjectToken));
    if (m.quoteSymbol) { /* quote not a subject */ }
  }
  // also mark anchors' configured subject as covered
  for (const a of (ANCHOR_POOLS[chainId] || [])) if (a.subject) covered.add(lower(normAddr(chainId, a.subject)));
  // collect high-volume candidates with 0 watched pools across ALL of candidateVolumeCache
  const misses: Array<{ sym: string; vol: number }> = [];
  for (const [key, vol] of candidateVolumeCache) {
    if (!key.startsWith(chainId + ':') || vol < SLA_HIGH_VOL) continue;
    const addr = key.slice(chainId.length + 1);
    if (covered.has(addr)) continue;
    // R6: readable symbol only — never a truncated/ellipsised address. Candidate keys originate
    // from cexCache.byAddress, so the symbol is normally available; drop the entry if unknown.
    const sym = resolveReadableSymbol(chainId, addr) || cexCache.byAddress[chainId + ':' + addr]?.symbol || '';
    if (!sym || sym.includes('…')) continue;
    misses.push({ sym, vol });
  }
  // top-50 by volume desc (honest, deterministic)
  misses.sort((a, b) => b.vol - a.vol || (a.sym < b.sym ? -1 : a.sym > b.sym ? 1 : 0));
  return misses.slice(0, 50).map(m => m.sym);
}
function noteCandidateVolume(chainId: string, subjectAddr: string, volumeUsd: number): void {
  if (volumeUsd > 0) candidateVolumeCache.set(chainId + ':' + lower(subjectAddr), volumeUsd);
}
function sortCandidatesByVolume(chainId: string, candidates: Array<{ address: string; symbol: string }>): Array<{ address: string; symbol: string }> {
  return [...candidates].sort((a, b) => {
    const va = candidateVolumeCache.get(chainId + ':' + lower(a.address)) || 0;
    const vb = candidateVolumeCache.get(chainId + ':' + lower(b.address)) || 0;
    if (va !== vb) return vb - va; // volume desc
    return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; // deterministic address tiebreak
  });
}

function cacheDsPoolInfo(chainId: string, addr: string, base: DsPair['baseToken'], quote: DsPair['quoteToken'], reserve: number): void {
  if (!base?.address || !quote?.address) return;
  const info: PoolInfo = {
    address: addr,
    baseToken: { address: normAddr(chainId, base.address), symbol: base.symbol || '?', name: base.symbol || '?' },
    quoteToken: { address: normAddr(chainId, quote.address), symbol: quote.symbol || '?', name: quote.symbol || '?' },
    liquidityUsd: reserve, fdv: 0, cachedAt: Date.now(),
  };
  poolInfoCache.set(lower(addr), info);
  poolInfoCache.set(chainId + ':' + lower(addr), info);
  poolInfoCache.set(lower(base.address) + ':' + lower(quote.address), info);
}

// Real DexScreener fetch (injectable for tests).
// bm2t T1: первый заход токена (cursor / profiles-boosts) — fresh, повтор — rescan.
const discoverySeenTokens = new Set<string>();
const dsEmptyMeta = new Map<string, { status: number; pairs: 'null' | '[]' }>();
const dsEmptyLog = { windowStart: 0, samples: [] as Array<{ addr: string; status: number; pairs: 'null' | '[]' }>, emitted: false };
function discoverySighting(chainId: string, addr: string): 'fresh' | 'rescan' {
  const k = chainId + ':' + lower(addr);
  if (discoverySeenTokens.has(k)) return 'rescan';
  discoverySeenTokens.add(k);
  return 'fresh';
}
function noteDsEmptyLog(sample: { addr: string; status: number; pairs: 'null' | '[]' }): void {
  const now = dsNow();
  if (dsEmptyLog.windowStart === 0) dsEmptyLog.windowStart = now;
  if (now - dsEmptyLog.windowStart >= 60_000) {
    dsEmptyLog.samples = [];
    dsEmptyLog.emitted = false;
    dsEmptyLog.windowStart = now;
  }
  if (dsEmptyLog.emitted) return;
  if (dsEmptyLog.samples.length < 3) dsEmptyLog.samples.push(sample);
  if (dsEmptyLog.samples.length >= 3) {
    const body = dsEmptyLog.samples.map(s => `${s.addr}@${s.status}:${s.pairs === '[]' ? 'pairs:[]' : 'pairs===null'}`).join(' ');
    log(`[disc] ds-empty ${body}`);
    dsEmptyLog.emitted = true;
    dsEmptyLog.samples = [];
  }
}
function resetDsEmptyLogForTest(): void {
  dsEmptyLog.windowStart = 0; dsEmptyLog.samples = []; dsEmptyLog.emitted = false; dsEmptyMeta.clear();
}
async function fetchDsReal(chainId: string, tokenAddr: string): Promise<DiscoveryPoolLike[] | null> {
  const dsChain = CHAINS[chainId]?.ds;
  if (!dsChain) return null;
  const cacheKey = chainId + ':' + lower(tokenAddr);
  const cached = dsPoolCache.get(cacheKey);
  // F6: cache BOTH hit and empty for TTL — never re-hit DS for a token that already returned empty.
  if (cached && Date.now() - cached.cachedAt < DS_POOL_CACHE_TTL) return cached.pools;
  // v9.23 F1: allow the call if BOTH the global DS cap is under its limit AND this chain is under
  // its per-chain fair share (so Base can't be starved by other chains' discovery runs).
  // bm2r D5: глобальная слот-проверка переехала в шлюз (он же следит за 429-бэкоффом);
  // per-chain fair-share остаётся здесь — шлюз его НЕ заменяет.
  if (!dsChainBudgetAvailable(chainId)) return null;
  const gate = dsGate('hotpoll');
  if (!gate.allowed) return null;
  recordDsChainBudget(chainId);
  try {
    const res = await getJSON<DsPair[]>('https://api.dexscreener.com/tokens/v1/' + dsChain + '/' + tokenAddr);
    dsNoteResult('hotpoll', res.status, res.retryAfterMs);   // bm2r D5: учёт результата (429 → общий бэкофф)
    if (res.status === 429) {
      const e: Error & { status?: number } = new Error('429');
      e.status = 429;
      throw e;
    }
    if (res.status === 0) {
      throw new Error(res.error || 'network');
    }
    if (res.error === 'bad json') {
      throw new Error('bad json');
    }
    if (!res.ok || !Array.isArray(res.data) || res.data.length === 0) {
      dsEmptyMeta.set(cacheKey, { status: res.status, pairs: Array.isArray(res.data) ? '[]' : 'null' });
      dsPoolCache.set(cacheKey, { pools: [], cachedAt: Date.now() }); // cache empty to avoid storms
      return null;
    }
    const pools: DiscoveryPoolLike[] = [];
    const seen = new Set<string>();
    for (const p of res.data) {
      const addr = p.pairAddress;
      if (!addr || seen.has(lower(addr))) continue;
      seen.add(lower(addr));
      const norm = normAddr(chainId, addr);
      cacheDsPoolInfo(chainId, norm, p.baseToken, p.quoteToken, Number(p.liquidity?.usd) || 0);
      pools.push({
        address: norm, volume24h: Number(p.volume?.h24) || 0, reserveUsd: Number(p.liquidity?.usd) || 0,
        volume1h: Number(p.volume?.h1) || 0, // v9.32aj: сигнал квалифицирующей сделки воронки усыновления
        baseSymbol: p.baseToken?.symbol || undefined, // v9.32ak AK1: реальный символ вместо '?' в watch-meta
        txns1h: Math.max(1, (Number(p.txns?.h1?.buys) || 0) + (Number(p.txns?.h1?.sells) || 0)),
      });
    }
    dsPoolCache.set(cacheKey, { pools, cachedAt: Date.now() });
    return pools;
  } catch {
    dsPoolCache.set(cacheKey, { pools: [], cachedAt: Date.now() });
    return null;
  }
}

// DexScreener fallback pool source (ТЗ v7 req.2): liquidity.usd ≈ reserve, volume.h24 ≈ vol24h.
async function fetchDsTokenPools(chainId: string, tokenAddr: string): Promise<DiscoveryPoolLike[] | null> {
  return dsPoolImpl(chainId, tokenAddr);
}

// GT-first, DS-fallback per-token pool fetch (ТЗ v7 req.2): GT → (429/timeout/saturated) → DS.
// Returns pools already cached into poolInfoCache (GT caching done here). Increments
// st.discoveryDsFallbacks on DS use.
async function fetchTokenPoolsForChain(chainId: string, tokenAddr: string): Promise<DiscoveryPoolLike[]> {
  const st = state.chain[chainId];
  const sight = discoverySighting(chainId, tokenAddr);
  const gecko = CHAINS[chainId]?.gecko;
  const hasExplicitGtTestMock = !!process.env.NODE_TEST_CONTEXT && geckoDiscoveryImpl !== realGeckoDiscovery;
  if (process.env.NODE_TEST_CONTEXT && (gtBanState().banned || gtDeadBlocked()) && !dsPoolsMockInstalled) {
    return []; // ban-candidate expansion in tests is mock-only; null __setDsPoolsMock means no candidates
  }
  if (gecko) {
    // Single GT attempt (no retry-sleep): when GT is budget-saturated it returns null instantly,
    // so retrying just wastes time → fall straight to DexScreener.
    let resp: GtDiscResponse | null = null;
    // v9.21 A1: discovery GT expansion uses the discovery ceiling (priority over polling).
    // ТЗ b2 R2: читаем gtDeadBlocked(), а не сырой флаг — после cooldown'а per-token расширение
    // обязано пропустить пробу внутрь транспорта, иначе «ровно один пробный запрос» не состоится.
    if ((!hasExplicitGtTestMock && gtDeadBlocked()) || (!hasExplicitGtTestMock && requestBudget.gecko.length >= GECKO_DISCOVERY_LIMIT)) {
      if (gtDeadBlocked() && !hasExplicitGtTestMock) recordAnonymousGtDeadSkip('discovery');
      // GT budget-skip (reserve kept) → must fall through to DS in the same cycle.
      if (st) st.discoveryGtBudgetSkip = (st.discoveryGtBudgetSkip || 0) + 1;
    } else {
      if (st) st.discoveryGtAttempted = (st.discoveryGtAttempted || 0) + 1;
      const before429 = geckoDiscovery429Total;
      const beforeSkip = geckoDiscStats.localSkipped;
      resp = await geckoDiscoveryGet<GtDiscResponse>(`/networks/${gecko}/tokens/${tokenAddr}/pools?page=1&include=base_token,quote_token`);
      if (geckoDiscovery429Total > before429 && st) st.discoveryGt429 = (st.discoveryGt429 || 0) + 1;
      // v9.32j: a LOCAL guard skip must not read as an upstream failure — same before/after pattern
      // as the 429 counter above.
      if (st && geckoDiscStats.localSkipped > beforeSkip) st.discoveryGtBudgetSkip = (st.discoveryGtBudgetSkip || 0) + 1;
    }
    if (resp?.data?.length) {
      if (st) st.discoveryGtOk = (st.discoveryGtOk || 0) + 1;
      const inc = resp.included || [];
      const out: DiscoveryPoolLike[] = [];
      for (const p of resp.data) {
        const addr = p.attributes?.address || String(p.id || '').split('_').pop() || '';
        if (!addr) continue;
        const vol = parseFloat(String(p.attributes?.volume_usd?.h24 || '0')) || 0;
        const reserve = parseFloat(String(p.attributes?.reserve_in_usd || '0')) || 0;
        cacheGtPool(chainId, p, inc, normAddr(chainId, addr), vol, reserve);
        out.push({ address: normAddr(chainId, addr), volume24h: vol, reserveUsd: reserve });
      }
      return out;
    }
  }
  // GT failed (429/timeout/saturated/budget-skip) → DexScreener fallback in the SAME cycle (F6).
  if (st) st.discoveryDsFallbacks = (st.discoveryDsFallbacks || 0) + 1;
  if (st) st.discoveryDsAttempted = (st.discoveryDsAttempted || 0) + 1;
  let ds: DiscoveryPoolLike[] | null = null;
  try {
    ds = await fetchDsTokenPools(chainId, tokenAddr);
  } catch {
    ds = null;
  }
  if (ds && ds.length) {
    noteDiscoveryLookup(chainId, tokenAddr, true);
    if (st) st.discoveryDsOk = (st.discoveryDsOk || 0) + 1;
    return ds;
  }
  noteDiscoveryLookup(chainId, tokenAddr, false);
  if (st) {
    st.discoveryDsEmpty = (st.discoveryDsEmpty || 0) + 1;
    if (sight === 'fresh') st.discoveryDsEmptyFresh = (st.discoveryDsEmptyFresh || 0) + 1;
    else st.discoveryDsEmptyRescan = (st.discoveryDsEmptyRescan || 0) + 1;
  }
  const meta = dsEmptyMeta.get(chainId + ':' + lower(tokenAddr));
  if (meta) noteDsEmptyLog({ addr: lower(tokenAddr), status: meta.status, pairs: meta.pairs });
  return [];
}

// ТЗ v8 Defect 2: build the set of priority CEX-subject tokens for a chain. Priority = subjects with
// (а) live events from already-polled/trending pools in the last 24h, (б) recent alerts, (в) anchors.
function buildPrioritySubjects(chainId: string, activeSubj: Set<string>): Set<string> {
  const prio = new Set<string>();
  const dayAgo = Date.now() - 24 * 3600 * 1000;
  // (б) recent alerts (notifs) for this chain
  for (const n of state.notifs) {
    if (n.chain === chainId && n.token && n.ts > dayAgo) prio.add(lower(n.token));
  }
  // (а) most recent real trade seen on this chain
  const lr = state.lastRealTradeByChain[chainId];
  if (lr) {
    const subj = (lr.parsed as unknown as { subjectAddr?: string } | undefined)?.subjectAddr;
    if (subj) prio.add(lower(normAddr(chainId, subj)));
  }
  // (а) active/trending base-layer subjects (recent activity on trending/top pools)
  for (const a of activeSubj) prio.add(a);
  // (в) anchors
  for (const a of (ANCHOR_POOLS[chainId] || [])) if (a.subject) prio.add(lower(normAddr(chainId, a.subject)));
  return prio;
}

// isZeroWatchEntry: a "zero ?/0/0" record — no subject and no metrics → hidden from watchlist + poll queue.
function isZeroWatchEntry(m: WatchPoolEntry | undefined): boolean {
  if (!m) return true;
  // D4: anchor pools are UNCONDITIONAL — they must remain visible even before subject/metrics
  // are resolved (env anchors have source='anchor' with subject '?' until GT enrichment).
  if (m.source.startsWith('anchor')) return false;
  const noSubject = !m.subjectSymbol || m.subjectSymbol === '?';
  const noReserve = m.reserveUsd == null || m.reserveUsd === 0;
  const noVol = m.vol24hUsd == null || m.vol24hUsd === 0;
  return noSubject && noReserve && noVol;
}

// poolIsInvalidForAlert: (ТЗ v7 req.3) both tokens major/stable → no alert subject; or blacklisted subject.
function poolIsInvalidForAlert(chainId: string, poolAddr: string): boolean {
  const ci = poolInfoCache.get(lower(poolAddr)) || poolInfoCache.get(chainId + ':' + lower(poolAddr));
  if (!ci) return false;
  const baseAddr = ci.baseToken.address;
  const quoteAddr = ci.quoteToken.address;
  const baseSym = ci.baseToken.symbol;
  const quoteSym = ci.quoteToken.symbol;
  const baseNonAlt = isStablecoin(baseAddr, baseSym) || isMajorToken(chainId, baseAddr);
  const quoteNonAlt = isStablecoin(quoteAddr, quoteSym) || isMajorToken(chainId, quoteAddr);
  if (baseNonAlt && quoteNonAlt) return true; // e.g. USDC×WETH, USDT×USDC, WETH×ETH — no alt subject
  if (baseAddr && subjectBlacklisted(chainId, baseAddr, baseSym)) return true; // blacklisted subject
  return false;
}

// visibleWatchPools: pools shown in /api/debug/watchlist and counted in status.watchPoolsTotal.
// Excludes zero ?/0/0 entries (no subject AND no metrics) per ТЗ v7 req.4.
function visibleWatchPools(chainId: string): string[] {
  const meta = chainWatchMeta[chainId] || {};
  const list = state.chain[chainId]?.watchPools || [];
  return list.filter(a => !isZeroWatchEntry(meta[lower(a)]));
}

// R7: number of anchor-sourced pools physically present in the visible watchlist output. This is the
// truth for status.watchPoolsAnchors — it must match the `/api/debug/watchlist` anchor count.
function countAnchorPools(chainId: string): number {
  const meta = chainWatchMeta[chainId] || {};
  return visibleWatchPools(chainId).filter(a => {
    const m = meta[lower(a)];
    return !!m && m.source.startsWith('anchor');
  }).length;
}

// countEmptyCexSubjects: CEX subject tokens of the chain with 0 watched pools (goal 0).
function countEmptyCexSubjects(chainId: string): number {
  const meta = chainWatchMeta[chainId] || {};
  const candidates = buildDiscoveryCandidates(chainId);
  const subjectsWithPools = new Set<string>();
  for (const m of Object.values(meta)) if (m.subjectToken) subjectsWithPools.add(lower(m.subjectToken));
  let empty = 0;
  for (const c of candidates) if (!subjectsWithPools.has(lower(c.address))) empty++;
  return empty;
}

// ─────────────── Pool Discovery (ТЗ v4 Block 4.2 + ТЗ v5 Defect 2 + ТЗ v6 + ТЗ v7) ───────────────
// Watchlist built PER TOKEN: for each subject token, union of top pools by reserve_usd AND
// top by 24h volume_usd (all quotes incl. USDC/USDT), thresholds via POOL_MIN_*, cap 30 per
// token. No chain-level cut (so per-token eligible pools are never dropped). Enriches with
// per-pool metadata (chainWatchMeta) for /api/debug/watchlist and why-tx subjectWatchlistPools.
// ТЗ v7: candidates = ALL CEX-listed subject tokens enumerated via a round-robin cursor; GT-first
// with DexScreener fallback; major×major and blacklisted-subject pools excluded; zero ?/0/0 rows hidden.
async function discoverPoolsForChain(chainId: string, phase: 'base' | 'full' | 'cursor' = 'full'): Promise<string[]> {
  const st = state.chain[chainId];
  st.initStatus = 'discovering';
  st.lastDiscoveryAt = Date.now();
  log(`[init] ${chainId} discovery start`);
  const pools: DiscoveryPoolLike[] = [];
  let tokensTried = 0;
  let tokensWithPools = 0;
  const start429 = geckoDiscovery429Total;

  // Always end with anchors applied (unconditional), even on partial/total failure.
  const finalize = (organic: string[], stableCount: number) => {
    st.discovery429s += geckoDiscovery429Total - start429;
    st.discoveryTokensTried = tokensTried;
    st.discoveryTokensWithPools = tokensWithPools;
    ensureAnchorsApplied(chainId);
    enrichAnchors(chainId, pools);
    const merged = composeWatchlist(chainId, organic);
    // D4: after discovery, poolInfoCache holds base/quote for anchor pools → resolve env-anchor
    // subjects from cache (cheap, no extra GT). Fire-and-forget.
    resolveEnvAnchorSubjects().catch(() => { /* best-effort; subject fills on next cycle */ });
    st.watchStablePools = stableCount;
    st.poolPollEmptySubjects = countEmptyCexSubjects(chainId);
    st.initStatus = merged.length > 0 ? 'ready' : 'failed';
    log(`[init] ${chainId} done: ${merged.length} pools (organic=${organic.length}, anchors=${st.watchPoolsAnchors.length}, tried=${tokensTried}/${st.discoveryCandidates || 0}, dsFallbacks=${st.discoveryDsFallbacks || 0}, perToken=${WATCH_POOLS_PER_TOKEN})`);
    return merged;
  };

  try {
    const gecko = CHAINS[chainId]?.gecko;
    if (!gecko) { st.lastDiscoveryError = 'No gecko network ID'; st.discoveryCandidates = 0; return finalize([], 0); }

    const addPool = (p: DiscoveryPoolLike) => {
      if (!p.address) return;
      if (pools.some(x => lower(x.address) === lower(p.address))) return;
      pools.push(p);
      noteDiscoveryPoolAdded(chainId);
    };
    const collectGt = (resp: GtDiscResponse | null) => {
      if (!resp?.data) return;
      const inc = resp.included || [];
      for (const p of resp.data) {
        const addr = p.attributes?.address || String(p.id || '').split('_').pop() || '';
        if (!addr) continue;
        const vol = parseFloat(String(p.attributes?.volume_usd?.h24 || '0')) || 0;
        const reserve = parseFloat(String(p.attributes?.reserve_in_usd || '0')) || 0;
        const norm = normAddr(chainId, addr);
        cacheGtPool(chainId, p, inc, norm, vol, reserve);
        addPool({ address: norm, volume24h: vol, reserveUsd: reserve });
      }
    };

    // v9.32j: shared by the fetch and cache-hit branches so candidate-volume seeding does not drift
    // between them. Pure in-memory (poolInfoCache), no GT calls.
    const seedCandidateVolumes = () => {
      for (const p of pools) {
        const ci = poolInfoCache.get(lower(p.address)) || poolInfoCache.get(chainId + ':' + lower(p.address));
        if (ci?.baseToken.address) noteCandidateVolume(chainId, ci.baseToken.address, p.volume24h);
      }
    };

    // ── Base layer: trending + top pools (GT, budgeted/retry). v9.23 F1: runs at the base-layer
    // ceiling (GECKO_BASELAYER_LIMIT, priority over enrichment) so it always executes even when the
    // 16-20 discovery band is saturated by per-token expansion (gtOk was ~0 on prod; base starved).
    // v9.32h: cursor pass skips re-fetch — organic already in watchPoolsDiscovery from the base phase.
    // v9.32i: TTL cache — recycle cycles stop re-burning the discovery budget.
    // v9.32j: on a cache hit the cached pools are RE-INJECTED into `pools`, otherwise `organic` (and
    // therefore st.watchPoolsDiscovery, which composeWatchlist overwrites) loses the base layer.
    if (phase !== 'cursor') {
      if (!shouldFetchBaseLayer(chainId, Date.now(), GECKO_BASELAYER_TTL_MS(), baseLayerCache)) {
        geckoDiscStats.baseLayerCacheHits++;
        for (const p of cachedBaseLayerPools(chainId, baseLayerCache)) addPool(p);
        seedCandidateVolumes();
        log(`[init] ${chainId} base layer: cached (${pools.length} pools)`);
      } else {
        collectGt(await geckoDiscoveryGet<GtDiscResponse>(`/networks/${gecko}/trending_pools?page=1&include=base_token,quote_token`, GECKO_BASELAYER_LIMIT, 'base'));
        for (let page = 1; page <= 2; page++) {
          if (pools.length >= WATCH_POOLS_PER_TOKEN * 4) break;
          let resp: GtDiscResponse | null = null;
          for (let attempt = 0; attempt < 3 && !resp?.data?.length; attempt++) {
            resp = await geckoDiscoveryGet<GtDiscResponse>(`/networks/${gecko}/pools?page=${page}&include=base_token,quote_token`, GECKO_BASELAYER_LIMIT, 'base');
            if (!resp?.data?.length) await sleep(400);
          }
          collectGt(resp);
        }
        // F2: seed candidate volumes from base-layer pools (so active/high-volume subjects get
        // sorted first even before the cursor visits them).
        seedCandidateVolumes();
        log(`[init] ${chainId} base layer: ${pools.length} pools`);
        // Only stamp the TTL cache after a NON-EMPTY fetch — an empty/429 result must not lock out
        // 10 minutes (v9.32i). pools.slice(): `pools` keeps growing afterwards (anchors, cursor),
        // the cache must hold a snapshot, not a live reference.
        if (pools.length > 0) baseLayerCache.set(chainId, { at: Date.now(), pools: pools.slice() });
      }
    } else {
      log(`[init] ${chainId} skip base-layer GT (cursor pass, organic=${(st.watchPoolsDiscovery || []).length})`);
    }

    // ── Anchor pools (GT multi; anchors are unconditional regardless) ──
    const anchors = ANCHOR_POOLS[chainId] || [];
    if (anchors.length > 0) {
      for (let i = 0; i < anchors.length; i += 30) {
        const batch = anchors.slice(i, i + 30).map(a => a.pool);
        let multiResp: GtDiscResponse | null = null;
        for (let attempt = 0; attempt < 3 && !multiResp?.data; attempt++) {
          multiResp = await geckoDiscoveryGet<GtDiscResponse>(`/networks/${gecko}/pools/multi/${batch.join(',')}?include=base_token,quote_token`, undefined, 'base');
          if (!multiResp?.data) await sleep(400);
        }
        collectGt(multiResp);
      }
    }

    // ── ТЗ v7 + v8: CEX candidate enumeration via round-robin cursor (all CEX-listed subjects) ──
    // v9.32h: phase 'base' skips the cursor so ALL chains get a GT base layer before any cursor pass.
    let candidates = phase === 'base' ? [] : buildDiscoveryCandidates(chainId);
    // ТЗ v8 Defect 2: priority ordering — subjects with recent events/alerts/anchors FIRST,
    // sorted remainder after. This makes actively-trading CEX alts (BNKR/VIRTUAL/SKYAI/币有/Jimothy)
    // get covered in the very first batch, not deep in the round-robin order.
    const activeSubj = new Set<string>();
    for (const p of pools) {
      const ci = poolInfoCache.get(lower(p.address)) || poolInfoCache.get(chainId + ':' + lower(p.address));
      if (ci?.baseToken.address) activeSubj.add(lower(ci.baseToken.address));
    }
    const prio = buildPrioritySubjects(chainId, activeSubj);
    candidates = candidates.filter(c => prio.has(lower(c.address))).concat(
      sortCandidatesByVolume(chainId, candidates.filter(c => !prio.has(lower(c.address))))
    );
    if (phase !== 'base') await appendDsBanCandidates(chainId, candidates);
    // карантин вычёркивает токен, не переставляя остальных
    candidates = candidates.filter(c => !discoveryTokenQuarantined(chainId, c.address));
    st.discoveryCandidates = candidates.length;
    st.priorityTokensQueued = prio.size;
    if (candidates.length > 0) {
      const batchSize = Math.min(DISC_BATCH_SIZE, candidates.length);
      const startPos = (st.discoveryCursorPos || 0) % candidates.length;
      let processed = 0;
      const cycleStart = Date.now();
      const cycleBudgetMs = DISC_CYCLE_SEC * 1000;
      const PAR = 6; // bounded parallel DS fetches to respect the cycle budget
      let exitReason = 'completed-batch';
      const gtBefore = st.discoveryGtAttempted || 0;
      const gtHttpBefore = gtRequestsTotal;
      const dsBefore = st.discoveryDsAttempted || 0;
      for (let i = 0; i < batchSize; i += PAR) {
        const slice: Array<{ address: string; symbol: string }> = [];
        for (let j = 0; j < PAR && (i + j) < batchSize; j++) slice.push(candidates[(startPos + i + j) % candidates.length]);
        const results = await Promise.all(slice.map(c => fetchTokenPoolsForChain(chainId, c.address)));
        for (let j = 0; j < slice.length; j++) {
          tokensTried++;
          const cand = slice[j];
          const got = results[j];
          if (got.length) tokensWithPools++;
          // Д1: cache token's 24h volume for next-cycle candidate ordering
          noteCandidateVolume(chainId, cand.address, got.reduce((s, p) => s + (p.volume24h || 0), 0));
          for (const p of got) addPool(p);
        }
        processed += slice.length;
        // discipline: stop when DS budget is saturated or the cycle budget is nearly exhausted (GT stays reserved)
        // v9.23 F1: exit only when THIS chain's DS fair-share is exhausted (not when other chains
        // saturated the global cap) — Base no longer loses its whole cycle to the contention order.
        if (requestBudget.dexscreener.length >= DS_POOL_RATE_MIN || !dsChainBudgetAvailable(chainId)) { exitReason = 'ds-budget-saturated'; break; }
        if (Date.now() - cycleStart > cycleBudgetMs * 0.9) { exitReason = 'cycle-time-budget'; break; }
      }
      st.discoveryCursorPos = (startPos + processed) % candidates.length;
      st.discoveryCycles = (st.discoveryCycles || 0) + 1;
      const gtDelta = (st.discoveryGtAttempted || 0) - gtBefore;
      gtRequestsPerCycle = gtRequestsTotal - gtHttpBefore;
      const dsDelta = (st.discoveryDsAttempted || 0) - dsBefore;
      // F2/F6: per-cycle provider log (ts, provider, batch, result, exit reason)
      log(`[disc] ${chainId} cycle#${st.discoveryCycles} ts=${Date.now()} tried=${tokensTried} cursor=${st.discoveryCursorPos}/${candidates.length} pools=${pools.length} gt=${gtDelta}(ok=${st.discoveryGtOk}) ds=${dsDelta}(ok=${st.discoveryDsOk}/empty=${st.discoveryDsEmpty}) exit=${exitReason} dsFallbacks=${st.discoveryDsFallbacks || 0}`);
    } else {
      st.discoveryCursorPos = 0;
    }

    // ── Selection: group by subject, per-token top union, filter invalid ──
    const qualifies = (p: DiscoveryPoolLike) =>
      (POOL_MIN_RESERVE_USD <= 0 || p.reserveUsd >= POOL_MIN_RESERVE_USD) ||
      (POOL_MIN_24H_VOLUME_USD <= 0 || p.volume24h >= POOL_MIN_24H_VOLUME_USD);

    const byToken = new Map<string, DiscoveryPoolLike[]>();
    const tokenSymbols: Record<string, string> = {};
    for (const p of pools) {
      const ci = poolInfoCache.get(lower(p.address)) || poolInfoCache.get(chainId + ':' + lower(p.address));
      const baseAddr = ci?.baseToken.address || '';
      const baseSym = ci?.baseToken.symbol || '?';
      if (!baseAddr) continue;
      tokenSymbols[baseAddr] = baseSym;
      if (!byToken.has(baseAddr)) byToken.set(baseAddr, []);
      byToken.get(baseAddr)!.push(p);
    }

    const selected = new Map<string, DiscoveryPoolLike>();
    const sourceFor = (p: DiscoveryPoolLike): 'reserve' | 'volume' | 'both' => {
      const r = POOL_MIN_RESERVE_USD <= 0 || p.reserveUsd >= POOL_MIN_RESERVE_USD;
      const v = POOL_MIN_24H_VOLUME_USD <= 0 || p.volume24h >= POOL_MIN_24H_VOLUME_USD;
      return r && v ? 'both' : r ? 'reserve' : v ? 'volume' : 'volume';
    };
    for (const [, tokenPools] of byToken.entries()) {
      const qualified = tokenPools.filter(qualifies);
      if (qualified.length === 0) continue;
      const byReserve = [...qualified].sort((a, b) => b.reserveUsd - a.reserveUsd).slice(0, WATCH_POOLS_PER_TOKEN);
      const byVolume = [...qualified].sort((a, b) => b.volume24h - a.volume24h).slice(0, WATCH_POOLS_PER_TOKEN);
      const union = new Map<string, DiscoveryPoolLike>();
      for (const p of [...byReserve, ...byVolume]) if (!union.has(lower(p.address))) union.set(lower(p.address), p);
      for (const p of union.values()) if (!selected.has(lower(p.address))) selected.set(lower(p.address), p);
    }
    let result = Array.from(selected.values()).map(p => p.address);
    // ТЗ v7 req.3: drop major×major / blacklisted-subject pools
    result = result.filter(addr => !poolIsInvalidForAlert(chainId, addr));
    if (result.length === 0) {
      for (const p of pools) if (qualifies(p) && !selected.has(lower(p.address))) selected.set(lower(p.address), p);
      result = Array.from(selected.values()).map(p => p.address).filter(addr => !poolIsInvalidForAlert(chainId, addr));
    }

    // Build/refresh metadata (anchors + organic). Zero/unknown pools get a 'pending' entry so they are
    // excluded from watchlist output + poll queue but kept in the source-of-truth list for retry.
    const meta = chainWatchMeta[chainId] || {};
    for (const p of pools) {
      const addr = p.address;
      const key = lower(addr);
      const existing = meta[key];
      if (existing) {
        if (p.reserveUsd > 0) existing.reserveUsd = p.reserveUsd;
        if (p.volume24h > 0) existing.vol24hUsd = p.volume24h;
        if (existing.source.startsWith('anchor') && (existing.reserveUsd != null || existing.vol24hUsd != null)) {
          const r = existing.reserveUsd != null && existing.reserveUsd > 0;
          const v = existing.vol24hUsd != null && existing.vol24hUsd > 0;
          existing.source = (r && v) ? 'anchor+both' : r ? 'anchor+reserve' : v ? 'anchor+volume' : 'anchor';
        }
        continue;
      }
      const ci = poolInfoCache.get(key) || poolInfoCache.get(chainId + ':' + key);
      const baseAddr = ci?.baseToken.address || '';
      // Д5: subject by priority (CEX-candidate > non-stable/non-major > base fallback), never a stable/major.
      const baseT = { address: ci?.baseToken.address || baseAddr, symbol: ci?.baseToken.symbol || '?' };
      const quoteT = { address: ci?.quoteToken.address || '', symbol: ci?.quoteToken.symbol || '?' };
      const subj = pickAnchorSubject(chainId, baseT, quoteT);
      meta[key] = {
        address: addr,
        subjectToken: subj.address,
        subjectSymbol: tokenSymbols[subj.address] || subj.symbol || '?',
        quoteSymbol: ci?.quoteToken.symbol || '?',
        reserveUsd: p.reserveUsd > 0 ? p.reserveUsd : null,
        vol24hUsd: p.volume24h > 0 ? p.volume24h : null,
        source: sourceFor(p),
      };
      if (subj.address) noteCandidateCovered(chainId, subj.address);
    }
    chainWatchMeta[chainId] = meta;
    markWatchlistDirty();

    const stableCount = result.filter(addr => {
      const ci = poolInfoCache.get(lower(addr)) || poolInfoCache.get(chainId + ':' + lower(addr));
      if (!ci) return false;
      return isStablecoin(ci.baseToken.address, ci.baseToken.symbol) && isStablecoin(ci.quoteToken.address, ci.quoteToken.symbol);
    }).length;

    st.lastDiscoveryError = null;
    st.lastError = null;
    return finalize(result, stableCount);
  } catch (err: unknown) {
    const errMsg = (err as Error).message || 'discovery error';
    st.lastDiscoveryError = errMsg;
    st.lastError = errMsg;
    log(`[init] ${chainId} discovery failed: ${errMsg}`);
    return finalize([], 0);
  }
}

// ─────────────── Fetch Pool Info ───────────────
async function fetchPoolInfo(chainId: string, poolAddr: string): Promise<PoolInfo | null> {
  const cacheKey = chainId + ':' + lower(poolAddr);
  const cached = poolInfoCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < POOL_CACHE_TTL) return cached;

  const gecko = CHAINS[chainId]?.gecko;
  if (!gecko) return null;

  try {
    const data = await geckoGet<{
      data?: {
        attributes?: { reserve_in_usd?: string; fdv_usd?: string };
        relationships?: { base_token?: { data?: { id?: string } }; quote_token?: { data?: { id?: string } }; dex?: { data?: { id?: string } } };
      };
      included?: Array<{ id?: string; attributes?: { address?: string; symbol?: string; name?: string } }>;
    }>(`/networks/${gecko}/pools/${poolAddr}?include=base_token,quote_token`);

    if (!data?.data) return null;

    const baseTokenId = data.data?.relationships?.base_token?.data?.id;
    const quoteTokenId = data.data?.relationships?.quote_token?.data?.id;
    let baseToken = { address: '', symbol: '?', name: '?' };
    let quoteToken = { address: '', symbol: '?', name: '?' };

    if (data.included) {
      for (const inc of data.included) {
        if (inc.id === baseTokenId && inc.attributes)
          baseToken = { address: inc.attributes.address || '', symbol: inc.attributes.symbol || '?', name: inc.attributes.name || '?' };
        if (inc.id === quoteTokenId && inc.attributes)
          quoteToken = { address: inc.attributes.address || '', symbol: inc.attributes.symbol || '?', name: inc.attributes.name || '?' };
      }
    }

    const info: PoolInfo = {
      address: poolAddr,
      baseToken,
      quoteToken,
      liquidityUsd: parseFloat(data.data.attributes?.reserve_in_usd || '0'),
      fdv: parseFloat(data.data.attributes?.fdv_usd || '0'),
      cachedAt: Date.now(),
      dexId: data.data.relationships?.dex?.data?.id || undefined,
    };
    poolInfoCache.set(cacheKey, info);
    poolInfoCache.set(lower(baseToken.address) + ':' + lower(quoteToken.address), info);
    poolInfoCache.set(lower(poolAddr), info);
    return info;
  } catch {
    return null;
  }
}

// ─────────────── Telegram: Solo ───────────────
// v9.11 F3: render wallet balances as "💼 Баланс: 1000 UP @ $111K, $95.5K USDT" (top tokens by USD).
function formatWalletBalances(n: Notification): string | null {
  const wb = n.walletBalances;
  if (!wb || wb.length === 0) return null;
  const parts = wb.slice(0, 5).map(b => `${fmtNum(b.amount)} ${b.symbol} @ ${fmtUsd(b.usd)}`);
  return `💼 Баланс: ${parts.join(', ')}`;
}

function fmtAmtCommas(v: number): string {
  const n = Math.round(Number(v) || 0);
  const sign = n < 0 ? '-' : '';
  const s = String(Math.abs(n));
  return sign + s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

function formatQuoteBalanceLine(qb: Notification['quoteBalance'], tradeUsd: number): string | null {
  return formatBalanceLine({ quoteBalance: qb, usd: tradeUsd } as Notification);
}

function formatBalanceLine(n: Notification): string | null {
  const qb = n.quoteBalance;
  const sb = n.subjectBalance;
  const tradeUsd = n.usd || 0;
  const quoteKnown = !!(qb && 'usd' in qb);
  const quoteTotal = quoteKnown && qb && 'usd' in qb ? qb.usd : 0;
  const nativeAmt = quoteKnown && qb && 'usd' in qb ? (qb.nativeAmount || 0) : 0;
  const nativeSym = quoteKnown && qb && 'usd' in qb ? (qb.nativeSymbol || 'ETH') : 'ETH';
  const nativeUsd = quoteKnown && qb && 'usd' in qb ? (qb.nativeUsd || 0) : 0;
  const stables = quoteKnown && qb && 'usd' in qb ? (qb.stablesUsd || 0) : 0;

  if (sb && sb.known === false) {
    return '💼 Баланс: н/д';
  }

  const quoteBits: string[] = [];
  if (nativeAmt > 0 && stables > 0) {
    quoteBits.push(`${fmtNum(nativeAmt)} ${nativeSym} (${fmtUsd(nativeUsd)}) + ${fmtUsd(stables)} стейблы`);
  } else if (nativeAmt > 0) {
    quoteBits.push(`${fmtNum(nativeAmt)} ${nativeSym} (${fmtUsd(nativeUsd)})`);
  } else if (stables > 0 || quoteTotal > 0) {
    quoteBits.push(`${fmtUsd(stables || quoteTotal)} стейблы`);
  }

  let line = '';
  if (sb && sb.known && sb.usd > 0) {
    line = `💼 Позиция: ${fmtAmtCommas(sb.amount)} ${n.symbol || 'TOK'} (~${fmtUsd(sb.usd)})`;
    if (quoteBits.length) line += ' + ' + quoteBits.join(' + ');
  } else if (quoteBits.length) {
    if (nativeAmt > 0 && stables > 0) line = `💼 Баланс: ${quoteBits[0]}`;
    else if (nativeAmt > 0) line = `💼 Баланс ${nativeSym}: ${quoteBits[0]}`;
    else line = `💼 Стейблы: ${fmtUsd(stables || quoteTotal)}`;
  } else if (quoteKnown && !(sb && sb.known && sb.usd > 0)) {
    line = `💼 Баланс: $0`;
  } else {
    return null;
  }

  const subjectUsd = sb && sb.known ? sb.usd : Infinity; // missing subject → cannot prove ALL-IN
  const allIn = !!(sb && sb.known && subjectUsd < tradeUsd * 0.005 && quoteTotal < tradeUsd * 0.05 && quoteTotal < 500);
  if (allIn) line += ' ⚑ ALL-IN';
  return line;
}

const fmtNum = (v: number) => {
  v = Number(v) || 0;
  if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B';
  if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M';
  if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K';
  return v.toFixed(0);
};

// v9.32ao AO5: формат-хелперы позиции — сырые суммы с пробелами, компакт кошелька, короткий usd.
function fmtRawInt(v: number): string {
  const n = Math.round(Number(v) || 0);
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
function fmtUsdShort(v: number): string {
  v = Number(v) || 0;
  if (v >= 1e9) return '$' + (v / 1e9).toFixed(1) + 'B';
  if (v >= 1e6) return '$' + (v / 1e6).toFixed(1) + 'M';
  if (v >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'K';
  return '$' + Math.round(v);
}
function fmtCompactAmt(v: number): string {
  v = Number(v) || 0;
  if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B';
  if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M';
  if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K';
  return String(Math.round(v * 100) / 100);
}
// v9.32ao-RC3 П1: адрес пригоден для explorer-ссылки на этой цепи?
function validTraderAddrForChain(chainId: string, addr: string | undefined | null): boolean {
  if (!addr) return false;
  if (chainId === 'solana') return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr);
  return /^0x[a-fA-F0-9]{40}$/.test(addr);
}
// v9.32ao-RC3 П1: 👤-ссылки с гарантированным fallback: links → traderFallbackAddr → trader.
function traderLinksWithFallback(n: Notification, chainShort: string): string[] {
  const tLinks: string[] = [];
  if (n.links.arkham) tLinks.push(`<a href="${n.links.arkham}"> Arkham </a>`);
  if (n.links.explorer) tLinks.push(`<a href="${n.links.explorer}"> ${chainShort}Scan </a>`);
  if (!tLinks.length) {
    const explorer = CHAINS[n.chain]?.explorer;
    const kind = n.chain === 'solana' ? 'account' : 'address';
    const addr = [n.traderFallbackAddr, n.trader].find(a => validTraderAddrForChain(n.chain, a));
    if (explorer && addr) tLinks.push(`<a href="${explorer}/${kind}/${addr}"> ${chainShort}Scan </a>`);
  }
  return tLinks;
}
// v9.32ao-RC3 П2: читаемое имя токена для рендера: голый symbol, если он symbol (не '?'-заглушка
// и не hex-подстановка shortAddr) — иначе GMGN-линк «Токен» (крайний fallback по спеке владельца).
function readableTokenLabel(n: Notification): { plain: string; html: string; href: string } {
  const sym = n.symbol || '';
  const known = !!(sym && sym !== '?' && !sym.includes('…') && !/^0x[0-9a-fA-F]{4}/.test(sym));
  const plain = known ? sym : (n.token ? shortAddr(n.token) : 'token');
  const gmgn = n.links && n.links.gmgn ? n.links.gmgn : '';
  const dex = n.links && n.links.dex ? n.links.dex : '';
  const explorer = CHAINS[n.chain]?.explorer;
  const exp = explorer && n.token ? `${explorer}/token/${n.token}` : '';
  const href = gmgn || dex || exp || '';
  return { plain, html: plain, href };
}
export function __readableTokenLabelForTest(n: { chain: string; symbol: string; token: string; links: Record<string, string> }): { plain: string; html: string; href: string } {
  return readableTokenLabel(n as Notification);
}
function buildSoloText(n: Notification): string {
  const emoji = n.side === 'buy' ? '🟢' : '🔴';
  const sideText = n.side === 'buy' ? 'ПОКУПКА' : 'ПРОДАЖА';
  const chainShort = CHAINS[n.chain]?.short || n.chain;
  // v9.32ao OD4 (AO5, спека владельца): шапка без контракта — символ достаточно идентифицирует
  // карточку, адрес шумит; контракт всегда доступен в 🪙-ссылках.
  // v9.32ao-RC3 П2: шапка несёт читаемое имя; '?'/hex-подстановки запрещены владельцем.
  const tokenLabel = readableTokenLabel(n);
  const lines = [`${emoji} ${sideText} · ${fmtUsd(n.usd)}`, `#${chainShort} · ${tokenLabel.plain}`];
  // v9.32ao-RC3 П1: 👤-ссылки — всегда (fallback от traderFallbackAddr, не от строки 'unresolved')
  const tLinks = traderLinksWithFallback(n, chainShort);
  const traderNote = n.isContract ? ' (контракт)' : '';
  // v9.32ao-RC2 BP2: биржевой кошелёк (Relay и др.) — метка вместо «обычного трейдера».
  // ПОМЕЧАЕМ, не фильтруем: решение о фильтре — за владельцем.
  const exLabel = getExchangeWalletLabel(String(n.trader || ''));
  // v9.32ao-RC3 П1: пустой trader → честное «не определён» (не '?' от shortAddr(''))
  const traderDisplay = n.trader ? shortAddr(n.trader) : 'не определён';
  lines.push(`👤 ${tLinks.join(' · ')} — ${traderDisplay}${traderNote}${exLabel ? ` · 🏦 ${exLabel} (биржа)` : ''}`);
  // v9.32ax AX6: ровно одна ссылка на токен (не в шапке).
  if (tokenLabel.href) {
    const lab = tokenLabel.href.includes('gmgn') ? 'GMGN' : tokenLabel.href.includes('dexscreener') ? 'DexScreener' : 'Token';
    lines.push(`🪙 <a href="${tokenLabel.href}"> ${lab} </a>`);
  }
  if (n.impact !== null && n.impact > 0) { const imp = fmtImpact(n.impact); if (imp) lines.push(`📈 Impact: ${imp}`); }
  if (n.fdv > 0) lines.push(`🔢 ≈${(n.usd / n.fdv * 100).toFixed(3)}% (FDV ${fmtUsd(n.fdv)})`);

  // v9.32ao OD4 (AO5, спека владельца дословно): ровно ОДНА 💼-строка, смысловая:
  //   sell:  💼 Продано 466 480 ANSEM ($112.2K) · остаток 1.23M (2.1% эмиссии)
  //   sell без supply: без «· остаток …»
  //   buy:  💼 Куплено 5 992 USDG ($6.0K) · на кошельке 12.4K USDG
  //   buy без баланса: без хвоста
  // Суммы сырыми с пробелами-тысячами, кошелёк компактно, usd $112.2K/$6.0K.
  // Старые строки (💼 Позиция/Позиция:/Баланс:/qb/wb) удалены — три строки позиции nobody не читал.
  const sym = tokenLabel.plain;
  const amtStr = n.subjectAmount && n.subjectAmount > 0 ? ` ${fmtRawInt(n.subjectAmount)}${sym ? ' ' + sym : ''}` : (sym ? ' ' + sym : '');
  const usdShort = fmtUsdShort(n.usd);
  // v9.32ao-RC1 BP2: $-оценка остатка — цена из САМОЙ сделки (usd/amount уже известны,
  // ноль RPC): владелец видел «остаток 3 111 UNI» без ответа «сколько это в деньгах».
  const dealPrice = n.subjectAmount && n.subjectAmount > 0 ? n.usd / n.subjectAmount : 0;
  const balUsd = n.subjectBalance?.known && n.subjectBalance.amount > 0
    ? (n.subjectBalance.usd > 0 ? n.subjectBalance.usd : dealPrice > 0 ? dealPrice * n.subjectBalance.amount : 0)
    : 0;
  // v9.32aq B4: бейдж однотрейдерской цепочки
  const chainBadge = n.soloChainIndex && n.soloChainIndex > 1 ? ` · ×${n.soloChainIndex} подряд` : '';
  if (n.side === 'buy') {
    let posLine = `💼 Куплено${amtStr} (${usdShort})`;
    if (n.subjectBalance?.known && n.subjectBalance.amount > 0) {
      posLine += ` · на кошельке ${fmtCompactAmt(n.subjectBalance.amount)}`;
      if (balUsd > 0) posLine += ` (~${fmtUsdShort(balUsd)})`;
    } else {
      // v9.32ao-RC3 П3: RPC-fail или неподтверждённый ноль → «н/д», НИКОГДА ложный «0»;
      // v9.32ap PD5 (AP9): и ОТСУТСТВИЕ хвоста запрещено (12/60 алертов без строки) —
      // контракт ровно два состояния: значение | н/д.
      posLine += ' · на кошельке н/д';
    }
    lines.push(posLine + chainBadge);
  } else {
    let posLine = `💼 Продано${amtStr} (${usdShort})`;
    if (n.subjectBalance?.known && n.subjectBalance.amount > 0) {
      posLine += ` · остаток ${fmtCompactAmt(n.subjectBalance.amount)}`;
      const tail: string[] = [];
      if (balUsd > 0) tail.push(`~${fmtUsdShort(balUsd)}`);
      if (n.positionPctSupply !== undefined && n.positionPctSupply > 0) tail.push(`${n.positionPctSupply.toFixed(1)}% эмиссии`);
      if (tail.length) posLine += ` (${tail.join(' · ')})`;
    } else if (n.subjectBalance?.known && n.subjectBalance.amount === 0) {
      // v9.32ao-RC3 П3: продал всё — честный пост-трейд ноль
      posLine += ' · остаток 0';
    } else {
      // v9.32ap PD5 (AP9): неизвестный остаток — «н/д», отсутствие хвоста запрещено
      posLine += ' · остаток н/д';
    }
    lines.push(posLine + chainBadge);
  }
  if (n.enrichmentErrors && n.enrichmentErrors.length > 0) {
    lines.push(`⚠️ Enrich: ${n.enrichmentErrors[0]}`);
  }
  if (n.via) lines.push(`⚠️ Via: ${n.via}`);
  // v9.32ao-RC3 П1: TX-ссылка — последняя гарантия «≥1 кликабельная ссылка» даже без trader
  const txUrl = n.links.tx || (n.txHash && CHAINS[n.chain]?.explorer ? `${CHAINS[n.chain].explorer}/tx/${n.txHash}` : '');
  if (txUrl) lines.push(`<a href="${txUrl}"> ⛓ TX </a> · ${new Date(n.ts).toISOString().slice(0, 19)}Z`);
  return lines.join('\n');
}

async function sendTgSolo(n: Notification): Promise<{ ok: boolean; text: string; error?: string }> {
  const text = buildSoloText(n);
  const result = await tgSend(text);
  return { ...result, text };
}

// ─────────────── Telegram: Series ───────────────
function buildSeriesText(n: Notification): string {
  const emoji = n.side === 'buy' ? '🟢' : '🔴';
  const actionText = n.side === 'buy' ? 'АКТИВНАЯ ПОКУПКА' : 'АКТИВНАЯ ПРОДАЖА';
  const chainShort = CHAINS[n.chain]?.short || n.chain;
  const totalUsd = n.seriesTotalUsd || n.usd;
  const lines: string[] = [];
  // v9.32ao-RC3 П2: series-шапка тоже несёт читаемое имя ('?'/hex → GMGN-линк «Токен»)
  const tokenLabel = readableTokenLabel(n);
  lines.push(`⚡ ${emoji} ${actionText} ${tokenLabel.plain || '—'} ~${fmtUsd(totalUsd)}`);
  if (tokenLabel.href) lines.push(`🪙 <a href="${tokenLabel.href}"> Token </a>`);
  const now = new Date();
  const hhmm = now.toISOString().slice(11, 16);
  // v9.32ao-RC3 П1: series 👤 — ссылки ВСЕГДА (fallback от traderFallbackAddr/trader)
  const tLinks = traderLinksWithFallback(n, chainShort);
  lines.push(`👤 ${tLinks.join(' · ')} ${shortAddr(n.trader)} · #${chainShort}`);
  lines.push(`🕐 ${hhmm} UTC · ${n.seriesTxCount || 0} ${n.side === 'buy' ? 'покупок' : 'продаж'} / 3м`);

  const seriesItems = n.series || [];
  const showItems = seriesItems.slice(0, 5);
  for (const item of showItems) {
    const txLink = item.txHash ? `<a href="${CHAINS[n.chain]?.explorer}/tx/${item.txHash}"> tx </a>` : 'tx';
    const amtStr = item.amount > 0 ? ` · ${item.amount.toFixed(1)}${tokenLabel.plain ? ' ' + tokenLabel.plain : ''}` : '';
    lines.push(`• ${fmtUsd(item.usd)}${amtStr} · ${txLink}`);
  }
  if (seriesItems.length > 5) {
    const rest = seriesItems.slice(5);
    const restSum = rest.reduce((s, i) => s + i.usd, 0);
    lines.push(`…и ещё ${rest.length} на ${fmtUsd(restSum)}`);
  }
  // v9.32ap PD5 (AP9): series-остаток — суммарная позиция на момент последнего кандидата.
  // Контракт как в solo: значение | н/д (отсутствие строки запрещено).
  {
    const sb = n.subjectBalance;
    if (sb && sb.known && sb.amount > 0) {
      let pos = `💼 Остаток: ${fmtCompactAmt(sb.amount)}${tokenLabel.plain ? ' ' + tokenLabel.plain : ''}`;
      if (sb.usd > 0) pos += ` (~${fmtUsdShort(sb.usd)})`;
      lines.push(pos);
    } else {
      lines.push('💼 Остаток: н/д');
    }
  }

  // ТЗ v4 Block 5: net-flow line
  if (n.netUsd && n.grossBuy !== undefined && n.grossSell !== undefined) {
    const sign = n.netSide === 'buy' ? '+' : '-';
    lines.push(`⚖️ Нетто: ${sign}${fmtUsd(n.netUsd)} ${n.netSide} (buy ${fmtUsd(n.grossBuy)} / sell ${fmtUsd(n.grossSell)})`);
  }

  // БАГ 2 FIX: Always show position line in series card
  if (n.positionUsd !== undefined) {
    if (n.positionUsd > 0) {
      const pctStr = n.positionPctSupply !== undefined && n.positionPctSupply > 0 ? ` · ${n.positionPctSupply.toFixed(2)}% от эмиссии` : '';
      lines.push(`💼 Позиция ${fmtUsd(n.positionUsd)}${pctStr}`);
    } else if (n.positionPctSupply !== undefined && n.positionPctSupply > 0) {
      lines.push(`💼 Позиция: ~${n.positionPctSupply.toFixed(2)}% от эмиссии`);
    } else {
      lines.push(n.side === 'sell' ? `💼 Остаток: 0 (полностью продан)` : `💼 Позиция: нет (баланс 0)`);
    }
  } else if (n.chain !== 'solana') {
    lines.push(`💼 Позиция: н/д`);
  }

  // Quote balance line
  const qbLine = formatBalanceLine({ ...n, usd: totalUsd });
  if (qbLine) lines.push(qbLine);

  // v9.11 F3: full wallet balances (top tokens by USD).
  const wbLine = formatWalletBalances(n);
  if (wbLine) lines.push(wbLine);

  const tkLinks: string[] = [];
  if (n.links.gmgn) tkLinks.push(`<a href="${n.links.gmgn}"> GMGN </a>`);
  if (n.links.dex) tkLinks.push(`<a href="${n.links.dex}"> DexScreener </a>`);
  if (n.links.arkham) tkLinks.push(`<a href="${n.links.arkham}"> Arkham </a>`);
  if (seriesItems[0]?.txHash) {
    const firstTx = `${CHAINS[n.chain]?.explorer}/tx/${seriesItems[0].txHash}`;
    tkLinks.push(`<a href="${firstTx}"> Первый TX </a>`);
  }
  if (tkLinks.length) lines.push(tkLinks.join(' · '));

  return lines.join('\n');
}

async function sendTgSeries(n: Notification): Promise<{ ok: boolean; text: string; error?: string }> {
  const text = buildSeriesText(n);
  const result = await tgSend(text);
  return { ...result, text };
}

// ─────────────── Rate Check ───────────────
// v9.32aq B4: окно однотрейдерских solo-цепочек (exempt от rate-гейта)
const SOLO_CHAIN_WINDOW_SEC = () => Number(process.env.SOLO_CHAIN_WINDOW_SEC || '300');
const soloChainWindow = new Map<string, { count: number; firstTs: number }>();
function checkRateLimit(chainId: string): { allowed: boolean } {
  const now = Date.now();
  const oneMinAgo = now - 60000;
  notifRate.globalSent1m = notifRate.globalSent1m.filter(t => t > oneMinAgo);
  if (!notifRate.chainSent1m[chainId]) notifRate.chainSent1m[chainId] = [];
  notifRate.chainSent1m[chainId] = notifRate.chainSent1m[chainId].filter(t => t > oneMinAgo);
  const overGlobal = notifRate.globalSent1m.length >= settings.maxNotifPerMinGlobal;
  const overChain = notifRate.chainSent1m[chainId].length >= settings.maxNotifPerMinPerChain;
  return { allowed: !overGlobal && !overChain };
}

function recordNotifSent(chainId: string): void {
  const now = Date.now();
  notifRate.globalSent1m.push(now);
  if (!notifRate.chainSent1m[chainId]) notifRate.chainSent1m[chainId] = [];
  notifRate.chainSent1m[chainId].push(now);
}

// ─────────────── Series Buffer Management ───────────────
// bm2x S3: `now` параметризован ТОЛЬКО для теста счётчика (дефолт — прежний Date.now()); логика чистки не менялась.
function cleanSeriesBuffer(now: number = Date.now()): void {
  const windowMs = settings.seriesWindowSec * 1000;
  let pending = 0;
  for (const [key, buf] of seriesBuffer.entries()) {
    const beforeLen = buf.candidates.length;
    buf.candidates = buf.candidates.filter(c => now - c.ts < windowMs * 2);
    // bm2x S3: fire очищает буфер сам (tryFireSeries → candidates = []), поэтому всё, что состарилось
    // здесь, — кандидаты, так и не собравшие seriesMinTx. Разложение skipReasons.series: pending vs expired.
    seriesStats.expiredWithoutFire += beforeLen - buf.candidates.length;
    if (buf.candidates.length === 0) seriesBuffer.delete(key);
    else pending += buf.candidates.length;
  }
  for (const [key, cooldownUntil] of seriesCooldown.entries()) {
    if (Date.now() > cooldownUntil) seriesCooldown.delete(key);
  }
  seriesStats.pending = pending;
}

// ─────────────── Try Fire Series (ТЗ v4 Block 5: net-flow guard) ───────────────
async function tryFireSeries(
  chainId: string, seriesKey: string, buf: SeriesBuffer,
  trader: string, token: string, symbol: string, side: string,
  resolvedBy?: string, traderFallbackAddr?: string
): Promise<boolean> {
  const cooldownUntil = seriesCooldown.get(seriesKey) || 0;
  if (Date.now() < cooldownUntil) {
    seriesStats.suppressedByCooldown++;
    return false;
  }

  buf.candidates.sort((a, b) => a.blockTs - b.blockTs);

  // v9.12 F1: drop any self-swap candidates from the aggregate before counting/firing. If nothing
  // real remains, do NOT send the alert.
  buf.candidates = buf.candidates.filter(c => !c.selfSwap);
  if (buf.candidates.length < settings.seriesMinTx) return false;

  const latestBlockTs = buf.candidates[buf.candidates.length - 1].blockTs;
  const windowSec = settings.seriesWindowSec;
  buf.candidates = buf.candidates.filter(c => latestBlockTs - c.blockTs <= windowSec);
  if (buf.candidates.length < settings.seriesMinTx) return false;

  const span = latestBlockTs - buf.candidates[0].blockTs;
  if (span > windowSec + 10) return false;

  // ─── Net-flow guard (Block 5) ───
  const minUsd = settings.seriesTxMinUsd;
  const oppSide = side === 'buy' ? 'sell' : 'buy';
  const oppKey = `${chainId}:${trader}:${token}:${oppSide}`;
  const oppBuf = seriesBuffer.get(oppKey);
  // v9.12 F1: final send-time re-check — never include a self-swap candidate in the fired set.
  const oppQualified = (oppBuf ? oppBuf.candidates : [])
    .filter(c => !c.selfSwap && latestBlockTs - c.blockTs <= windowSec && c.usd >= minUsd);
  const ownQualified = buf.candidates.filter(c => !c.selfSwap && c.usd >= minUsd);

  const grossBuy = (side === 'buy' ? ownQualified : oppQualified).reduce((s, c) => s + c.usd, 0);
  const grossSell = (side === 'sell' ? ownQualified : oppQualified).reduce((s, c) => s + c.usd, 0);

  let netUsd = 0;
  let netSide = side;
  let fireCandidates = buf.candidates;
  let fireKey = seriesKey;
  let fireBuf = buf;

  if (oppQualified.length >= 1) {
    // Opposite side has qualifying trades → apply net-flow guard (single source: computeNetGuard).
    const buys = (side === 'buy' ? ownQualified : oppQualified).map(c => c.usd);
    const sells = (side === 'sell' ? ownQualified : oppQualified).map(c => c.usd);
    const g = computeNetGuard(buys, sells, settings.seriesMinTx, SERIES_MIN_NET_USD, minUsd);
    netSide = g.netSide || side;
    netUsd = g.netUsd;
    if (g.suppressed) {
      seriesNetSuppressed++;
      log(`[series] churnNet suppressed (${chainId}) netUsd=${netUsd} < ${SERIES_MIN_NET_USD} or netSideCount=${g.netSideCount}`);
      return false; // do NOT set cooldown
    }
    const useOpp = netSide === oppSide;
    fireBuf = useOpp ? oppBuf! : buf;
    fireCandidates = useOpp ? oppQualified : ownQualified;
    fireKey = useOpp ? oppKey : seriesKey;
  }

  const totalUsd = fireCandidates.reduce((s, c) => s + c.usd, 0);
  const lastCandidate = fireCandidates[fireCandidates.length - 1];
  const links = lastCandidate.links;
  const seriesItems = fireCandidates.map(c => ({ txHash: c.txHash, usd: c.usd, amount: c.amount, blockTs: c.blockTs }));
  const finalSide = netSide;

  let liquidityInfo: Notification['liquidityInfo'] | undefined;
  const enrichmentErrors: string[] = [];

  try {
    const liqData = await fetchLiquidityInfo(chainId, token);
    if (liqData) liquidityInfo = {
      totalUsd: liqData.totalUsd, poolsCount: liqData.poolsCount,
      subjectAmount: liqData.subjectAmount, subjectUsd: liqData.subjectUsd, quoteUsd: liqData.quoteUsd
    };
    else {
      const budgetLeft = GECKO_RATE_LIMIT - requestBudget.gecko.filter(t => t > Date.now() - 60000).length;
      const errMsg = budgetLeft <= 0
        ? `Liquidity N/A (gecko rate limit, budget=0/${GECKO_RATE_LIMIT}) for ${shortAddr(token)} on ${chainId}`
        : `No liquidity data for ${shortAddr(token)} on ${chainId}`;
      log(`[series] ${errMsg}`);
      enrichmentErrors.push(errMsg);
    }
  } catch (e) {
    const errMsg = `fetchLiquidityInfo failed: ${(e as Error).message}`;
    log(`[series] ${errMsg}`);
    enrichmentErrors.push(errMsg);
  }

  // БАГ 2 FIX: positionUsd is always set for EVM (0 is valid); undefined only on real error
  let positionUsd: number | undefined;
  let positionPctSupply: number | undefined;
  if (chainId !== 'solana') {
    try {
      const posData = await fetchPositionInfo(chainId, token, trader);
      if (posData) {
        positionUsd = posData.balanceUsd;   // 0 is a valid value
        positionPctSupply = posData.pctSupply;
      } else {
        positionUsd = 0;
        positionPctSupply = 0;
      }
    } catch (e) {
      const errMsg = `fetchPositionInfo failed: ${(e as Error).message}`;
      log(`[series] ${errMsg}`);
      enrichmentErrors.push(errMsg);
      // positionUsd stays undefined → error path
    }
  }

  // v9.32ap PD5 (AP9): остаток subject-токена для series-строки позиции (best-effort,
  // unresolved-трейдер → н/д). Цена — средняя по серии (0 RPC поверх resolveSubjectBalance).
  let seriesSubjectBalance: Notification['subjectBalance'];
  if (trader && trader !== 'unresolved') {
    try {
      const totalAmt = fireCandidates.reduce((s, c) => s + (c.amount || 0), 0);
      const sbr = await resolveSubjectBalance(chainId, trader, token, { side: finalSide, subjectAmount: totalAmt });
      if (sbr) {
        const px = totalAmt > 0 ? totalUsd / totalAmt : 0;
        seriesSubjectBalance = { amount: sbr.amount, usd: sbr.known ? sbr.amount * px : 0, known: sbr.known };
      }
    } catch { /* н/д */ }
  }
  const notif: Notification = {
    ts: Date.now(),
    chain: chainId, side: finalSide, symbol, token, trader,
    traderFallbackAddr, // v9.32ao-RC3 П1: explorer-ссылка от fallback-адреса в 👤 серии
    subjectBalance: seriesSubjectBalance, // v9.32ap PD5
    txHash: lastCandidate.txHash,
    usd: totalUsd, impact: null,
    liquidity: liquidityInfo?.totalUsd || 0, fdv: 0,
    via: lastCandidate.via, links,
    source: 'series', tgOk: false, tgError: null,
    pool: lastCandidate.pool,
    kind: 'series',
    seriesTxCount: fireCandidates.length,
    seriesTotalUsd: totalUsd,
    series: seriesItems,
    resolvedBy,
    netUsd: netUsd || undefined,
    netSide: netUsd ? netSide : undefined,
    grossBuy: netUsd ? Math.round(grossBuy) : undefined,
    grossSell: netUsd ? Math.round(grossSell) : undefined,
    enrichAt: liquidityInfo ? Date.now() : undefined,
    positionUsd, positionPctSupply, liquidityInfo,
    enrichmentErrors: enrichmentErrors.length > 0 ? enrichmentErrors : undefined,
    blockTs: lastCandidate.blockTs,
  };

  // v9.11 F3: full wallet balances at trade time (top tokens by USD).
  if (chainId !== 'solana' && trader && trader.length >= 10) {
    try {
      notif.walletBalances = await fetchWalletBalances(chainId, trader, token, symbol);
    } catch { /* omitted */ }
  }

  const tg = await sendTgSeries(notif);
  notif.tgOk = tg.ok;
  notif.tgError = tg.error || null;
  notif.tgMessageText = tg.text;
  if (tg.ok) noteSolMintAlert(notif);
  if (!tg.ok && tg.error) tgRetryEnqueue({ text: tg.text, chain: chainId, error: tg.error, notif });
  recordNotifSent(chainId);
  seriesStats.fired++;
  addNotification(notif); // v9.32ao AO4: TTL + cap
  noteSolanaHotMint(notif);
  const st = state.chain[chainId];
  st.notified++;

  const cooldownMs = settings.seriesCooldownMin * 60 * 1000;
  seriesCooldown.set(fireKey, Date.now() + cooldownMs);
  // Clear both the fired side's buffer and the opposite side's buffer
  buf.candidates = [];
  if (oppBuf) oppBuf.candidates = [];

  log(`📣 SERIES ${finalSide.toUpperCase()} ${symbol} ~${fmtUsd(totalUsd)} [${chainId}] n=${seriesItems.length} span=${span}s trader=${shortAddr(trader)}${netUsd ? ` net=${netSide} ${fmtUsd(netUsd)}` : ''}`);
  return true;
}

// ─────────────── v9.10 F1: shouldNotify gate ───────────────
// Strict, defensive gate enforced immediately before ANY Telegram send. It re-verifies that an
// event is genuinely alert-worthy (not below threshold, not stable/major/blacklist, has a real
// source blockTs) so a future regression in the branch logic can never leak a belowThreshold /
// tx_not_swap / non-alert event to Telegram. Each rejection is counted in skippedReasonsStats and
// surfaced as status.skippedReasons for monitoring.
const skippedReasonsStats: Record<string, number> = {};
// v9.11 F1: a self-swap is a tx where the same token is both the from-leg and the to-leg
// (subject.address === quote.address). E.g. UP→UP through intermediate pools, 4 Transfer events
// alternating between two pool addresses with NO wallet participation and no BNB. These are not real
// wallet trades — they must never produce a series/solo alert. Detected by address equality of the
// two legs (case-insensitive for EVM, exact for solana).
function isSelfSwap(extracted: { fromAddr: string; toAddr: string }): boolean {
  if (!extracted.fromAddr || !extracted.toAddr) return false;
  if (extracted.fromAddr === extracted.toAddr) return true;
  // EVM addresses are case-insensitive (lowercase both); solana addresses are case-sensitive already
  // normalized by the extractors, so a simple compare above suffices; but guard mixed-case EVM here.
  return lower(extracted.fromAddr) === lower(extracted.toAddr) && extracted.fromAddr.length === 42;
}
// v9.32bm1i T2: собственный вердикт self-swap для Solana-пути. Оба pubsub-экстрактора строят
// extracted с fromAddr === toAddr === mint ПО КОНСТРУКЦИИ (как evmws), поэтому эвристика
// isSelfSwap(from==to) для них всегда true и не является вердиктом. Вердикт считается из уже
// вычисленной нетто-дельты subject (0 RPC): круговой self-swap (A→B→A в одной tx) оставляет
// нулевую нетто-дельту subject; реальный своп — ненулевую. NaN/0/-0 ⇒ self-swap (fail-closed).
export function solanaSubjectNetZero(subjectAmount: number): boolean {
  return !(subjectAmount > 0);
}

// v9.12 F2: price-sanity shared check. When a trade's implied per-token price (usd / amount) is
// absurdly higher than the on-chain oracle price, the USD is inflated by a bad pool / decimal
// mismatch (e.g. KIBSHI $6.18M from a $0.0000017 token). Used by both the why-tx analysis and the
// LIVE alert path (solo).
// Test hook: override the oracle price (unit tests can't hit a real price oracle deterministically).
let priceOracleMock: ((chainId: string, token: string) => number) | null = null;
export function __setPriceOracleMock(fn: ((chainId: string, token: string) => number) | null): void {
  priceOracleMock = fn;
}
// DexPaprika price (second source for the cross-check). Keyed REST is available; returns 0 on any
// failure so the cross-check degrades to a single-source (fail-open).
async function dexPaprikaPriceUsd(chainId: string, token: string): Promise<number> {
  try {
    const dpNet = CHAINS[chainId]?.dpNetwork;
    if (!dpNet) return 0;
    const key = getDexPaprikaApiKey();
    const headers: Record<string, string> = { 'Accept': 'application/json' };
    if (key) headers['Authorization'] = `Bearer ${key}`;
    const res = await getJSON<{ price_usd?: number }>(`https://api.dexpaprika.com/networks/${dpNet}/tokens/${lower(token)}`, { headers }, 'tm_price_dp'); // bm2f
    const p = Number(res.data?.price_usd || 0);
    return p > 0 ? p : 0;
  } catch { return 0; }
}
// v9.12 F2 (refined) — returns a rejection reason or null when the USD is sane.
// Checks, in order: (1) implied-vs-oracle inflation, (2) absolute cap usd > soloAmountUsd*4,
// (3) usd > 50*poolLiquidity, (4) low-liquidity token (<$1M) capped at $1M,
// (5) cross-source price (oracle vs DexPaprika) diff > 25%.
async function checkPriceSanity(
  chainId: string, subjectToken: string, usd: number, subjectAmount: number, poolLiquidityUsd?: number
): Promise<string | null> {
  const res = await evaluatePriceSanity(
    chainId,
    subjectToken,
    usd,
    subjectAmount,
    poolLiquidityUsd,
    {
      priceOracleFn: priceOracleMock ? (c, t) => priceOracleMock!(c, t) : async (c, t) => (await fetchEvmTokenMeta(c, t)).priceUsd,
      ...(PRICE_SANITY_DP_CROSS_ENABLED ? { dexPaprikaPriceFn: (c: string, t: string) => dexPaprikaPriceUsd(c, t) } : {}),
      onDiagnosticDisagreement: (c, t, p1, p2) => {
        log(`[price-sanity] diagnostic disagreement for ${c}:${shortAddr(t)}: DexScreener=$${p1} vs DexPaprika=$${p2}`);
      },
    }
  );
  return res.reason;
}
// Thin boolean wrapper used by callers that only need pass/fail.
// @dead — live path uses checkPriceSanity directly
async function isPriceSane(chainId: string, subjectToken: string, usd: number, subjectAmount: number): Promise<boolean> {
  return (await checkPriceSanity(chainId, subjectToken, usd, subjectAmount)) === null;
}
function shouldNotify(ctx: {
  chainId: string; subjectAddr: string; subjectSym: string;
  amountUsd: number; hasSourceBlockTs: boolean; blockTs: number | null;
  selfSwap?: boolean;
}): { allow: boolean; reason?: string } {
  const deny = (reason: string) => { skippedReasonsStats[reason] = (skippedReasonsStats[reason] || 0) + 1; return { allow: false, reason }; };
  if (ctx.selfSwap) return deny('self_swap');
  if (!ctx.hasSourceBlockTs || ctx.blockTs === null) return deny('noBlockTs');
  if (!(ctx.amountUsd > 0) || Number.isNaN(ctx.amountUsd)) return deny('usd_unresolved');
  if (ctx.amountUsd < settings.seriesTxMinUsd) return deny('belowThreshold');
  if (settings.stableFilterEnabled && isStablecoin(ctx.subjectAddr, ctx.subjectSym)) return deny('stable');
  if (isMajorToken(ctx.chainId, ctx.subjectAddr)) return deny('major');
  if (isBlacklisted(ctx.subjectAddr)) return deny('blacklist');
  return { allow: true };
}

// v9.32as SD4: subject в чёрном списке, НО вторая нога пары чистая (не stable/native/major,
// не в чёрном списке) → переключаем subject на неё (side инвертируется) вместо того, чтобы
// выбрасывать всю сделку. Контр-пример инцидента: SPCXB(black)⇄MarsCoin(clean) — сделка
// MarsCoin-сторона живая, старый код резал её целиком (skip 'blacklist').
// Возвращает новый ExtractedSubject при переключении, либо null (старое поведение).
// Вызывается ТОЛЬКО когда subject уже blacklisted — на чистых событиях нулевая цена.
// Pure decision (no counter, no log) — shared by processExtractedEvent and whyTx (AT6).
function tryBlacklistSubjectSwap(chainId: string, e: ExtractedSubject): ExtractedSubject | null {
  const subj = lower(e.subjectAddr);
  const fromIsSubject = lower(e.fromAddr) === subj;
  const toIsSubject = lower(e.toAddr) === subj;
  if (fromIsSubject === toIsSubject) return null; // нет второй ноги (или from==to)
  const otherAddr = fromIsSubject ? e.toAddr : e.fromAddr;
  const otherSym = fromIsSubject ? e.toSym : e.fromSym;
  const otherAmount = fromIsSubject ? e.toAmount : e.fromAmount;
  if (!otherAddr || lower(otherAddr) === subj) return null;
  if (isStablecoin(otherAddr, otherSym)) return null;   // вторая нога = stable → без switch
  if (isMajorToken(chainId, otherAddr)) return null;    // native/wrapped/major → без switch
  if (checkBlacklist(chainId, otherAddr, otherSym).skip) return null; // обе стороны чёрные → старый skip
  if (isBlacklisted(otherAddr)) return null;
  return { ...e, subjectAddr: otherAddr, subjectSym: otherSym, side: e.side === 'buy' ? 'sell' : 'buy', subjectAmount: otherAmount };
}
function blacklistSubjectSwap(chainId: string, e: ExtractedSubject): ExtractedSubject | null {
  const swapped = tryBlacklistSubjectSwap(chainId, e);
  if (!swapped) return null;
  notifSkipped.blacklistSubjectSwapped++;
  log(`[blacklist-subject-swap] ${chainId}: subject ${e.subjectSym || shortAddr(e.subjectAddr)} в чёрном списке → переключаю на ${swapped.subjectSym || shortAddr(swapped.subjectAddr)} (side ${e.side} → ${swapped.side})`);
  return swapped;
}

// ─────────────── Process Extracted Event ───────────────
function cexCacheWarm(): boolean {
  return cexCache.tokenCount > 0 || Object.keys(cexCache.byAddress).length > 0;
}
/** v9.32ba BA1: гейты без цены. RD2: холодный CEX-кэш не отсекает. RD3: amount сюда не входит. */
function cheapPreEnrichSkip(chainId: string, e: ExtractedSubject): { reason: string; cexReason?: CexRejectReason } | null {
  if (e.selfSwap === true) return { reason: 'self_swap' };
  if (e.selfSwap !== false && isSelfSwap(e)) return { reason: 'self_swap' };
  const bl = checkBlacklist(chainId, e.subjectAddr, e.subjectSym);
  if (bl.skip) {
    if (!tryBlacklistSubjectSwap(chainId, e)) return { reason: 'blacklist' };
    return null; // swap возможен — цена нужна для нового subject
  }
  if (settings.stableFilterEnabled && isStablecoin(e.subjectAddr, e.subjectSym)) return { reason: 'stable' };
  if (isMajorToken(chainId, e.subjectAddr)) return { reason: 'major' };
  if (settings.top10FilterEnabled && isTop10Sym(e.subjectSym)) return { reason: 'top10' };
  if (isBlacklisted(e.subjectAddr) || isBlacklisted(e.trader)) return { reason: 'blacklist' };
  if (cexCacheWarm() && settings.cexFilterEnabled) {
    const cex = cexFilterDecision(chainId, e.subjectAddr, e.subjectSym, e.amountUsd, e.trader);
    if (!cex.pass) return { reason: 'cex', cexReason: cex.reason };
  }
  return null;
}
function rejectCheapPreEnrich(chainId: string, extracted: ExtractedSubject): boolean {
  const hit = cheapPreEnrichSkip(chainId, extracted);
  if (!hit) return false;
  const st = state.chain[chainId];
  if (st) {
    st.skipped++;
    st.skipReasons[hit.reason] = (st.skipReasons[hit.reason] || 0) + 1;
    if (hit.reason === 'self_swap') st.skipReasons.self_swap_preEnrich = (st.skipReasons.self_swap_preEnrich || 0) + 1; // v9.32bm1i T5
  }
  if (hit.reason === 'cex') {
    recordCexReject(chainId, hit.cexReason || 'heuristic');
    sampleCexDrop(chainId, hit.cexReason || 'heuristic', extracted.subjectSym, extracted.amountUsd, extracted.txHash);
  }
  recordFirstPass(chainId, extracted.txHash, {
    usd: extracted.amountUsd, skipReason: hit.reason,
    priceResolved: extracted.amountUsd > 0, gateClosed: false,
  });
  return true;
}
async function enqueueExtracted(
  chainId: string, extracted: ExtractedSubject, source: string,
  rawData?: Record<string, unknown>, skipSeenIncrement = false, maxAgeSecOverride?: number, high?: boolean,
): Promise<void> {
  await ensureBlacklistLoaded();
  if (rejectCheapPreEnrich(chainId, extracted)) return;
  await withEnrichGate(
    () => processExtractedEvent(chainId, extracted, source, rawData, skipSeenIncrement, maxAgeSecOverride),
    { high: high ?? extracted.amountUsd >= settings.soloAmountUsd },
  );
}
export async function __enqueueExtractedForTest(
  chainId: string, extracted: ExtractedSubject, rawData?: Record<string, unknown>,
): Promise<void> {
  return enqueueExtracted(chainId, extracted, 'test', rawData, true);
}

// ─── v9.32bm BM2 (спека Фазы 2 / bm2n5): bounded-диагностика решения по пулу ───
// Цель: по факту (а не по гипотезе) увидеть в GET /api/logs, какой именно branch вернул
// pool:null для свежего EVM-события: нет raw-поля / raw == subject (шаг D) / роль из
// NON_POOL_ROLES (шаг E) / infra. Лимит жёсткий: POOL_DECISION_DIAG_LIMIT записей на процесс
// (default 2, 0 = выключено) — /api/logs не засоряется. Solana бюджет не тратит: там пул = mint,
// role/infra-проверки не выполняются вовсе (см. ветку ниже). Значения, не попавшие в решение
// (роль/infra при более раннем return), помечаются строкой 'not-evaluated(<причина>)'.
const POOL_DECISION_DIAG_LIMIT = (): number => {
  const n = Number(process.env.POOL_DECISION_DIAG_LIMIT ?? '2');
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
let poolDecisionDiagEmitted = 0;
export function __resetPoolDecisionDiagForTest(): void { poolDecisionDiagEmitted = 0; }
function poolDiagValue(v: unknown): string | null {
  if (v === undefined || v === null) return null;
  return typeof v === 'string' ? v : String(v);
}

// ─── POOL-DIAG quota (прод 2026-09-24, бут 65a35e1e, LIMIT=50) ───
// Симптом: 05:03:48+ десятки POOL-DIAG с `"source":"dp"` (шум DexPaprika, AERO pool_id) сожрали
// весь бюджет за ~1-2 минуты; к 05:07 строки пропали, и v4-карточки (PoolManager / poolId 66 hex)
// в логах не оказалось вовсе — охота на v4 при общем лимите невозможна.
// Решение: решение «логировать / тратить квоту» вынесено в чистую функцию (limit — аргумент, env
// не читается), а `diag()` только применяет её вердикт. Счётчик `poolDecisionDiagEmitted` растёт
// ИСКЛЮЧИТЕЛЬНО для 'counted'.
//   off     — LIMIT<=0: kill-switch, не пишем ничего (в т.ч. v4);
//   skip    — solana (пул = mint, EVM-диагностика неприменима) и source=dp (шум): ни лога, ни инкремента;
//   v4      — PoolManager или bytes32 poolId: строка пишется всегда при LIMIT>0, бюджет не тратится;
//   counted — всё остальное (evmws/test/poll/…): как раньше, до исчерпания LIMIT.
// Порядок правил фиксирован: solana → off → v4 → dp → counted. v4 проверяется РАНЬШЕ dp-skip:
// dp-кадр, который всё же принёс v4 poolId, обязан быть залогирован.
export type PoolDiagQuota = 'off' | 'skip' | 'v4' | 'counted';

// v4 poolId = keccak-пара bytes32 → 66 символов с '0x'. 20-байтный адрес пула (42 символа)
// poolId НЕ является — иначе любой hex-адрес получил бы вечную квоту. Без keccak: только форма.
const V4_POOL_ID_SHAPE = /^0x[0-9a-f]{64}$/i;
export function looksLikeV4PoolId(v: string | null | undefined): boolean {
  return typeof v === 'string' && V4_POOL_ID_SHAPE.test(v);
}

export function classifyPoolDiagQuota(input: {
  chainId: string;
  source: string;
  origin: string | null;
  emitter: string | null;
  rawPool: string | null;
}, limit: number): PoolDiagQuota {
  const { chainId, source, emitter, rawPool } = input;
  if (chainId === 'solana') return 'skip';
  if (!(limit > 0)) return 'off';
  // Адрес PM берём из реестра (isPoolManager), а не хардкодом: список вендоров живёт в protocol-registry.
  const isV4 = (!!emitter && isPoolManager(chainId, emitter))
    || (!!rawPool && isPoolManager(chainId, rawPool))
    || looksLikeV4PoolId(emitter)
    || looksLikeV4PoolId(rawPool);
  if (isV4) return 'v4';
  if (source === 'dp') return 'skip';
  return 'counted';
}

async function processExtractedEvent(
  chainId: string, extracted: ExtractedSubject, source: string,
  rawData?: Record<string, unknown>, skipSeenIncrement = false, maxAgeSecOverride?: number
) {
  lastPriceGateClosed = false;
  lastTokenMetaExtra = {};
  const st = state.chain[chainId];
  if (state.paused) { pausedStats.dropped++; getPauseLedger().noteDropped('event:' + chainId); return; } // v9.32am AM3: пояс-гейт (входы уже гейтятся выше)
  if (!skipSeenIncrement) {
    st.seenEvents++;
    st.lastEventAt = Date.now();
  }

  if (rawData) {
    const tradeEntry = { chain: chainId, trade: rawData, parsed: extracted as unknown as Record<string, unknown> };
    state.lastRealTrade = tradeEntry;
    state.lastRealTradeByChain[chainId] = tradeEntry;
  }

  let { subjectAddr, subjectSym, side, amountUsd, trader, txHash } = extracted;
  let netEnriched = false;

  // Event dedup by (chain, txHash, side)
  const dedupKey = `${chainId}:${txHash}:${side}`;
  if (notifDedup.has(dedupKey)) {
    notifsDedupDropped++;
    st.skipReasons.dup = (st.skipReasons.dup || 0) + 1;
    st.skipped++;
    return;
  }
  notifDedup.set(dedupKey, Date.now());
  if (notifDedup.size > 10000) {
    const cutoff = Date.now() - 300000;
    for (const [k, v] of notifDedup) {
      if (v < cutoff) notifDedup.delete(k);
    }
  }

  const skip = (r: string) => {
    st.skipped++;
    if (st.skipReasons[r] !== undefined) st.skipReasons[r]++;
    else st.skipReasons[r] = 1;
    recordFirstPass(chainId, txHash, { usd: amountUsd, skipReason: r, priceResolved: amountUsd > 0, gateClosed: lastPriceGateClosed });
  };

  // ТЗ Bug 1: STRICT blockTs gate — no source blockTs = skip
  if (!extracted.hasSourceBlockTs || extracted.blockTs === null) {
    skip('noBlockTs');
    droppedNoBlockTs++;
    return;
  }
  const blockTs = extracted.blockTs;

  // ТЗ Bug 1: Freshness gate by REAL blockTs from source
  const nowSec = Math.floor(Date.now() / 1000);
  const eventAge = nowSec - blockTs;
  const maxAgeSec = maxAgeSecOverride ?? (source === 'poll' ? POLL_MAX_AGE_SEC() : settings.maxEventAgeSec);
  if (eventAge > maxAgeSec) {
    skip('stale');
    { const cls = staleSourceClass(source); const k = cls === 'ws' ? 'staleWs' : 'stalePoll'; st.skipReasons[k] = (st.skipReasons[k] || 0) + 1; droppedStaleBySource[cls]++; } // v9.32bm1g H4: skip() уже сделал skipped++ — здесь только разбивка
    noteStaleAge(eventAge);
    droppedStaleTotal++;
    return;
  }

  await ensureBlacklistLoaded();
  // ─── Token Blacklist (ТЗ v4 Block 3) — FIRST content filter on entry ───
  // Runs before series/cooldown/CEX/enrich/impact. Matching tx → skip 'blacklist',
  // never enters series windows, no alert/card.
  const bl = checkBlacklist(chainId, subjectAddr, subjectSym);
  if (bl.skip) {
    // v9.32as SD4: subject чёрный, вторая нога чистая → переключаем subject (не бросаем tx).
    const swapped = blacklistSubjectSwap(chainId, extracted);
    if (swapped) {
      extracted = swapped;
      subjectAddr = swapped.subjectAddr;
      subjectSym = swapped.subjectSym;
      side = swapped.side;
      amountUsd = swapped.amountUsd;
      // Дальше фильтры/оценка/алерт — по новому subject. shouldNotify (ниже) увидит новый
      // subject: оба объекта обновлены (extracted + локальные), defensive по ТЗ.
    } else {
      skip('blacklist');
      return;
    }
  }

  // Common filters
  if (settings.stableFilterEnabled && isStablecoin(subjectAddr, subjectSym)) { skip('stable'); return; }
  // БАГ 1 FIX: Major token subject filter — skip if SUBJECT (not quote!) is a major wrapped native
  if (isMajorToken(chainId, subjectAddr)) { skip('major'); return; }
  if (settings.top10FilterEnabled && isTop10Sym(subjectSym)) { skip('top10'); return; }
  if (isBlacklisted(subjectAddr) || isBlacklisted(trader)) { skip('blacklist'); return; }

  const cexResult = cexFilterDecision(chainId, subjectAddr, subjectSym, amountUsd, trader); // v9.32ax AX1: без exempt
  if (!cexResult.pass) {
    const reason = cexResult.reason || 'heuristic';
    recordCexReject(chainId, reason);
    sampleCexDrop(chainId, reason, subjectSym, amountUsd, txHash);
    skip('cex');
    return;
  }

  // v9.32ax AX4: перевод на известный биржевой адрес — не своп.
  if (getExchangeWalletLabel(extracted.toAddr) || getExchangeWalletLabel(extracted.fromAddr)) {
    skip('exchange_transfer');
    notifSkipped.exchange_transfer++;
    return;
  }

  const soloThreshold = settings.soloAmountUsd;
  const seriesThreshold = settings.seriesTxMinUsd;

  // v9.32ax AX5: usd===0 — цена не получена, не «мелкая сделка». Сначала DS-фоллбэк по контракту.
  if (!(amountUsd > 0) && extracted.subjectAmount > 0 && subjectAddr) {
    const filled = await tryFillUsdFromDs(chainId, subjectAddr, extracted.subjectAmount);
    if (filled > 0) amountUsd = filled;
  }
  if (!(amountUsd > 0) || Number.isNaN(amountUsd)) {
    skip('usd_unresolved');
    notifSkipped.usd_unresolved++;
    usdUnresolved++;
    return;
  }

  // Trades below seriesThreshold are suppressed entirely
  // v9.32n: BNKR-class garbage (usd=7.4e-9 with millions of tokens) is a valuation bug, not a
  // sub-threshold trade. Gate BEFORE amount so skipReasons.price_sanity is the honest label.
  const USD_SANITY_MIN = () => Number(process.env.USD_SANITY_MIN || '0.01');
  if (USD_SANITY_MIN() > 0 && amountUsd > 0 && amountUsd < USD_SANITY_MIN()) {
    skip('price_sanity');
    notifSkipped.price_oracle_invalid++;
    return;
  }
  if (amountUsd < seriesThreshold) { skip('amount'); return; }

  const antispamKey = `${chainId}:${lower(subjectAddr)}:${side}`;

  // Check infra
  const infraName = isInfra(trader);
  const via = infraName || null;

  // Resolve trader
  const traderResult = await resolveTrader(chainId, trader, txHash, rawData, subjectAddr, side, extracted.subjectAmount);
  const realTrader = traderResult.address;
  const poolSender = traderResult.poolSender;
  const traderResolved = traderResult.isResolved;
  const resolvedBy = traderResult.resolvedBy;
  // v9.32l: never display an unresolved sender (router / DP hop account) as the trader.
  // Scoped to fallback methods only — EVM sender_eoa is isResolved:false by design and stays the EOA.
  const displayTrader = (traderResult.method === 'fee_payer_fallback' || traderResult.method === 'fallback')
    ? 'unresolved' : realTrader;
  if (displayTrader === 'unresolved') traderResolveStats.unresolvedShown++;

  // ─── M2 (v9.4 R1): hard contract-sender filter in the LIVE alert flow ───
  // Owner's anti-spam rule #1: ANY contract sender (tx.from / resolved trader with code) → do NOT
  // detect (cuts MEV, market-makers, Safe). This mirrors the why-tx `contract_from` path and applies
  // to ALL live sources (GT poll / SSE / DS) that funnel through processExtractedEvent. It is
  // INDEPENDENT of `excludeContractsEnabled` (which only stripped the Arkham link) — the event is
  // hard-skipped and never reaches a notification. getCode is cached by address (contracts don't
  // change), so this does not hit RPC per event.
  if (chainId !== 'solana' && realTrader && txHash && txHash.startsWith('0x')) {
    const realTraderIsContract = await isContract(chainId, realTrader);
    if (realTraderIsContract === true) {
      skip('contract_from');
      log(`[contract_from] SKIP ${chainId} tx ${shortAddr(txHash)} trader ${shortAddr(realTrader)} is a contract (MEV/MM/Safe) — not detecting`);
      return;
    }
    if (realTraderIsContract === null) {
      // v9.32l: null getCode used to silently pass a router as the trader (incident-class: 429 storm).
      const unconfirmed = traderResult.method === 'fallback' || traderResult.method === 'fee_payer_fallback';
      if (unconfirmed) {
        skip('contract_check_unresolved');
        log(`[contract_check_unresolved] SKIP ${chainId} tx ${shortAddr(txHash)} trader ${shortAddr(realTrader)} getCode=null and trader unresolved`);
        return;
      }
      st.skipReasons.contract_check_open = (st.skipReasons.contract_check_open || 0) + 1;
    }
  }

  // Contract check (basic — for isContractTrader flag only; retained for backward-compat links)
  let isContractTrader = false;
  if (settings.excludeContractsEnabled && chainId !== 'solana') {
    const contractCheck = await isContract(chainId, realTrader);
    if (contractCheck === true) {
      isContractTrader = true;
    }
  }

  const poolAddr = (() => {
    const watchHit = st.watchPools.find(p => lower(p) === lower(extracted.fromAddr));
    // v9.32l: honest attribution — no extractor puts the pool in fromAddr (that's the token).
    // The source always knows the real pool (field name depends on source). NEVER watchPools[0].
    // bm2n4 (спека Фазы 2): если вычисленный пул равен адресу subject-токена — это не пул
    // (измерение bm2n3: 37 % нотификаций несли адрес токена в поле пула). Честно null.
    const rd = rawData as { pool?: unknown; pool_id?: unknown; pa?: unknown; attrib?: { origin?: unknown; emitter?: unknown; emitterKind?: unknown } } | undefined;
    const rawPool = rd?.pool ?? rd?.pool_id ?? rd?.pa ?? null;
    // Какое из трёх полей реально дало значение (порядок ?? сохранён) — для диагностики.
    const rawField = rd?.pool != null ? 'pool' : rd?.pool_id != null ? 'pool_id' : rd?.pa != null ? 'pa' : null;
    // ТЗ bm2n6 (эмиттер ≠ пул): контекст атрибуции от источника EVM-WS/hotPoll. `attrib` — наше
    // служебное поле, а не поле источника: в `rawData.keys` (что прислал источник) не попадает.
    const attrib = rd?.attrib;
    const attribStr = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
    // BM2: одна строка на решение (не на каждое событие — см. квоту). EVM-only.
    // POOL-DIAG quota: решение «логировать / тратить counted-бюджет» — в чистой
    // classifyPoolDiagQuota (см. выше). v4 (PoolManager / poolId 66 hex) пишется всегда при
    // LIMIT>0 и счётчик не тратит; `source=dp` (шум DexPaprika) не пишет и не тратит; LIMIT=0
    // глушит всё, включая v4. Тело JSON ниже не изменено (формат /api/logs стабилен).
    const diag = (decision: string | null, reason: string, extra: Record<string, unknown> = {}): void => {
      const limit = POOL_DECISION_DIAG_LIMIT();
      const quota = classifyPoolDiagQuota({
        chainId,
        source,
        origin: attribStr(attrib?.origin),
        emitter: attribStr(attrib?.emitter),
        rawPool: poolDiagValue(rawPool),
      }, limit);
      if (quota === 'off' || quota === 'skip') return;
      if (quota === 'counted') {
        if (poolDecisionDiagEmitted >= limit) return;
        poolDecisionDiagEmitted++;
      }
      log('🧭 POOL-DIAG ' + JSON.stringify({
        source, chainId, txHash: extracted.txHash, subjectAddr: subjectAddr || null,
        origin: attribStr(attrib?.origin), emitterKind: attribStr(attrib?.emitterKind), emitter: attribStr(attrib?.emitter), // ТЗ bm2n6 п.4
        fromAddr: extracted.fromAddr ?? null, // значение, которое ищется в st.watchPools
        rawData: {
          pool: poolDiagValue(rd?.pool), pool_id: poolDiagValue(rd?.pool_id), pa: poolDiagValue(rd?.pa),
          keys: rd ? Object.keys(rd).filter(k => k !== 'attrib').slice(0, 40) : null, // какие поля реально прислал источник
        },
        rawChosen: { field: rawField, value: poolDiagValue(rawPool) },
        watchPools: { matched: watchHit ?? null, poolCount: st.watchPools.length },
        decision, reason, ...extra,
      }));
    };
    if (watchHit) { diag(watchHit, 'ok:watchPools'); return watchHit; }
    if (!rawPool) {
      diag(null, rawField ? 'null:raw_pool_falsy' : 'null:raw_pool_absent', {
        role: 'not-evaluated(raw pool missing)', inNonPoolRoles: 'not-evaluated(raw pool missing)',
        isPoolManager: 'not-evaluated(raw pool missing)', isInfra: 'not-evaluated(raw pool missing)',
      });
      return null;
    }
    const rawPoolAddr = String(rawPool);
    if (lower(rawPoolAddr) === lower(subjectAddr)) {
      diag(null, 'null:raw_pool_is_subject', {
        role: 'not-evaluated(step D: raw pool == subject)', inNonPoolRoles: 'not-evaluated(step D)',
        isPoolManager: 'not-evaluated(step D)', isInfra: 'not-evaluated(step D)',
      });
      return null;
    }
    // Solana pools are mints; EVM-only protocol roles must not classify them.
    if (chainId !== 'solana') {
      const registryChain = chainId as SupportedChain;
      const role = getContractRole(registryChain, rawPoolAddr);
      const poolManager = isPoolManager(registryChain, rawPoolAddr);
      const inNonPoolRoles = !!role && NON_POOL_ROLES.has(role);
      // V4 PoolManager may be marked 'settlement' for subscription purposes but is a real venue.
      if (role && NON_POOL_ROLES.has(role) && !poolManager) {
        diag(null, 'null:non_pool_role(step E)', {
          role, inNonPoolRoles, isPoolManager: poolManager, isInfra: 'not-evaluated(step E)',
        });
        return null;
      }
      const infraName = isInfra(rawPoolAddr);
      if (infraName) {
        diag(null, 'null:infra', { role, inNonPoolRoles, isPoolManager: poolManager, isInfra: infraName });
        return null;
      }
      diag(rawPoolAddr, 'ok:kept', { role, inNonPoolRoles, isPoolManager: poolManager, isInfra: null });
      return rawPoolAddr;
    }
    // Solana: пул = mint, role/infra не проверяются (диагностика сюда не пишется — бюджет EVM).
    return rawPoolAddr;
  })();

  // ─── contractExec filter (ТЗ new task) ───
  // Only applies to EVM chains, only to candidates (amount >= seriesThreshold already checked above).
  // NOTE: the hard contract_from filter above already ran; this inventory-based mode remains as a
  // secondary (piggy-bank) guard and cannot double-fire for the same contract (contract_from returns
  // earlier).
  if (CONTRACT_EXEC_FILTER !== 'off' && chainId !== 'solana' && txHash && txHash.startsWith('0x')) {
    const execResult = await checkContractExecFilter(chainId, txHash, subjectAddr, poolAddr);
    if (execResult.decision === 'contractExec') {
      // v9.32ak AK4: settlement-адаптер (CoW/Fusion/UniswapX) уже резолвнул живого EOA-трейдера —
      // «contract executor» = солвер, а не piggy-bank; скип гасил квалифицирующие сделки (AERO $8 071).
      if (traderResolved && String(resolvedBy || '').startsWith('protocol_adapter:')) {
        st.skipReasons.contractExecAdapterAllowed = (st.skipReasons.contractExecAdapterAllowed || 0) + 1;
      } else {
        skip('contractExec');
        return;
      }
    }
    // Правка 2: Track per-chain counters for non-skip outcomes
    if (execResult.decision === 'safe') {
      st.skipReasons.safeAllowed = (st.skipReasons.safeAllowed || 0) + 1;
    } else if (execResult.decision === 'transient') {
      st.skipReasons.contractTransient = (st.skipReasons.contractTransient || 0) + 1;
    } else if (execResult.decision === 'unknown') {
      st.skipReasons.contractExecUnknown = (st.skipReasons.contractExecUnknown || 0) + 1;
    }
  }
  let liquidity = 0;
  let fdv = 0;
  let enrichedSymbol = subjectSym;
  let poolCreatedAt: number | null = null;

  if (poolAddr) poolCreatedAt = await getPoolCreationTime(chainId, poolAddr);

  if (poolAddr) {
    const poolToFetch = poolAddr;
    const poolInfo = await fetchPoolInfo(chainId, poolToFetch);
    if (poolInfo) {
      liquidity = poolInfo.liquidityUsd;
      fdv = poolInfo.fdv;
      // v9.32ao-RC3 П2: symbol → name (DexScreener даёт оба) — читаемое имя вместо '?'/hex
      if (lower(poolInfo.baseToken.address) === lower(subjectAddr)) enrichedSymbol = poolInfo.baseToken.symbol || poolInfo.baseToken.name || subjectSym;
      else if (lower(poolInfo.quoteToken.address) === lower(subjectAddr)) enrichedSymbol = poolInfo.quoteToken.symbol || poolInfo.quoteToken.name || subjectSym;
    }
  }

  if (enrichedSymbol === '?' || !enrichedSymbol) {
    const resolved = await resolveTokenSymbol(chainId, subjectAddr);
    if (resolved && resolved !== '?') {
      enrichedSymbol = resolved;
    } else {
      // v9.32ao-RC3 П2: hex-подстановка shortAddr запрещена владельцем («6FrrzD…3tgG»-стиль);
      // '?' остаётся маркером → рендер даёт GMGN-линк «Токен».
      enrichedSymbol = '?';
    }
  }

  const linkTrader = displayTrader === 'unresolved' ? '' : realTrader;
  const links = generateLinks(chainId, subjectAddr, linkTrader, txHash, isContractTrader, poolAddr, poolCreatedAt);

  // v9.10 F1: hard gate — never send an alert that fails the alert-worthiness checks.
  // v9.32an ND1: явный вердикт экстрактора (extracted.selfSwap) важнее эвристики
  // fromAddr==toAddr — evmWs-экстрактор ставит обе ноги = subject ПО КОНСТРУКЦИИ,
  // из-за чего каждый found-кадр evmWs умирал здесь как «self_swap» (CRV×3, AN1-AN3).
  const gate = shouldNotify({ chainId, subjectAddr, subjectSym, amountUsd, hasSourceBlockTs: extracted.hasSourceBlockTs, blockTs: extracted.blockTs, selfSwap: extracted.selfSwap ?? isSelfSwap(extracted) });
  if (!gate.allow) {
    st.skipReasons[gate.reason!] = (st.skipReasons[gate.reason!] || 0) + 1;
    if (gate.reason === 'self_swap') st.skipReasons.self_swap_soloGate = (st.skipReasons.self_swap_soloGate || 0) + 1; // v9.32bm1i T5
    st.skipped++;
    return;
  }

  // v9.16 F-TX: pre-notification tx-hash FORMAT validator (replaces F2 as the primary guard). Every
  // alert must carry a real on-chain hash: EVM → 0x + 64 hex; Solana → base58. Synthetic DexPaprika
  // SSE reserve ids (`dp-r-…`) are NOT chain hashes and are rejected here as invalid_tx_format, so
  // they can never reach a Telegram alert as a phantom. Runs before the phantom RPC check.
  if (!isValidChainTxHash(chainId, txHash)) {
    notifSkipped.invalid_tx_format++;
    const reason = classifyInvalidTxHash(chainId, txHash);
    if (reason === 'synthetic') {
      // Expected: DexPaprika SSE reserve frames without a real tx_hash carry a synthetic dp-r-… id,
      // which is NOT a chain hash and must never be alerted on. Tracked separately for F2 diagnosis.
      notifSkipped.invalid_tx_format_synthetic++;
      log(`[invalid_tx_format] SKIP ${chainId} hash ${txHash.slice(0, 28)}… reason=synthetic (dp-r SSE reserve id, not a real chain tx)`);
    } else {
      // A NON-synthetic malformed hash reaching the alert pipeline. Log the FULL hash + reason so F2
      // can confirm we are NOT dropping real transactions (all real txs are 0x+64hex / base58).
      notifSkipped.invalid_tx_format_other++;
      if (reason === 'regex_evm') {
        notifSkipped.invalid_tx_format_regex_evm++;
        log(`[invalid_tx_format] SKIP ${chainId} hash ${txHash} reason=invalid_regex (EVM: wrong length or missing 0x prefix)`);
      } else if (reason === 'regex_solana') {
        notifSkipped.invalid_tx_format_regex_solana++;
        log(`[invalid_tx_format] SKIP ${chainId} hash ${txHash} reason=solana_regex_mismatch (not valid base58)`);
      } else {
        notifSkipped.invalid_tx_format_non_hex++;
        log(`[invalid_tx_format] SKIP ${chainId} hash ${txHash} reason=non_hex_chars (EVM hash contains non-hex characters)`);
      }
    }
    st.skipReasons.invalid_tx_format = (st.skipReasons.invalid_tx_format || 0) + 1;
    st.skipped++;
    return;
  }

  // v9.11 F2: (extra) real on-chain tx validity — for valid-format EVM hashes, confirm the tx exists
  // on-chain before sending. Cached 60s. Applies before BOTH solo and series paths.
  if (chainId !== 'solana' && txHash && txHash.startsWith('0x')) {
    if (await isPhantomTx(chainId, txHash)) {
      st.skipReasons.phantom = (st.skipReasons.phantom || 0) + 1;
      st.skipped++;
      return;
    }
  }

  // v9.12 F2 + v9.31: price-sanity guard on the LIVE path — reject inflated USD before send.
  // Solana: common absolute cap ($100M); EVM: full price sanity checks (absolute cap, liquidity, oracle inflation)
  if (amountUsd >= soloThreshold) {
    if (chainId === 'solana') {
      if (amountUsd > PRICE_SANITY_ABS_CAP_USD) {
        st.skipReasons.price_sanity = (st.skipReasons.price_sanity || 0) + 1;
        skippedReasonsStats.price_sanity = (skippedReasonsStats.price_sanity || 0) + 1;
        notifSkipped.price_oracle_invalid++;
        st.skipped++;
        return;
      }
    } else {
      const sanityReason = await checkPriceSanity(chainId, subjectAddr, amountUsd, extracted.subjectAmount, liquidity);
      if (sanityReason) {
        st.skipReasons.price_sanity = (st.skipReasons.price_sanity || 0) + 1;
        skippedReasonsStats.price_sanity = (skippedReasonsStats.price_sanity || 0) + 1;
        if (sanityReason === 'price_oracle_invalid') notifSkipped.price_oracle_invalid++;
        st.skipped++;
        return;
      }
    }
  }

  // v9.32r: dp/gtws solana solo-candidates with a real hash → NET trader value (ANSEM hop vs deal).
  if (chainId === 'solana' && (source === 'dp' || source === 'gtws')
      && amountUsd >= soloThreshold && isValidChainTxHash('solana', txHash)) {
    try {
      const analysis = await analyzeSolanaTx(txHash, 'pollEnrichment');
      const net = (analysis.found && analysis.usd > 0) ? applyNetEnrichment(extracted, analysis) : null;
      if (net) {
        extracted = net;
        subjectAddr = net.subjectAddr; subjectSym = net.subjectSym;
        side = net.side; amountUsd = net.amountUsd; trader = net.trader; txHash = net.txHash;
        netEnriched = true;
        if (source === 'dp') netEnrich.dpApplied++;
        else netEnrich.gtwsApplied++;
      } else {
        netEnrich.failed++;
      }
    } catch {
      netEnrich.failed++;
    }
  }

  // BRANCH 1: Solo instant alert (≥ soloAmountUsd)
  if (amountUsd >= soloThreshold) {
    // v9.32aq B4 (AQ3): повторные solo ОДНОГО трейдера (chain:trader:token:side за окно
    // SOLO_CHAIN_WINDOW_SEC) не душатся цепочным rate-гейтом (2/мин молча съедали FLORK
    // #2/#3 и CARDS-пару). Research: notifDedup — по tx (не он); antispam — только SET;
    // молчаливый виновник — checkRateLimit. Бейдж «×N подряд» в 💼-строке.
    const soloChainKey = `${chainId}:${lower(String(realTrader || trader || ''))}:${lower(subjectAddr)}:${side}`;
    const nowMs = Date.now();
    let scRec = soloChainWindow.get(soloChainKey);
    if (!scRec || nowMs - scRec.firstTs > SOLO_CHAIN_WINDOW_SEC() * 1000) scRec = { count: 0, firstTs: nowMs };
    scRec.count++;
    soloChainWindow.set(soloChainKey, scRec);
    if (soloChainWindow.size > 1000) {
      for (const [k, v] of soloChainWindow) if (nowMs - v.firstTs > SOLO_CHAIN_WINDOW_SEC() * 1000) soloChainWindow.delete(k);
    }
    const soloChainExempt = scRec.count > 1;
    const rateCheck = soloChainExempt ? { allowed: true } : checkRateLimit(chainId);
    // v9.32as SD5: rate-limit больше НЕ дропает алерт — событие обогащается ниже,
    // notif в pendingSolo, воркер DIGEST_FLUSH_MS решит: в бюджет или в digest.

    let impact: number | null = null;
    if (liquidity > 0 && amountUsd > 0) impact = (amountUsd / liquidity) * 100;

    state.antispam.set(antispamKey, Date.now());

    // Bug 2 fix: Enrich solo alert with liquidityInfo + positionInfo
    let soloLiquidityInfo: Notification['liquidityInfo'] | undefined;
    let soloPositionUsd: number | undefined;
    let soloPositionPctSupply: number | undefined;
    const soloEnrichmentErrors: string[] = [];

    try {
      const liqData = await fetchLiquidityInfo(chainId, subjectAddr);
      if (liqData) {
        soloLiquidityInfo = {
          totalUsd: liqData.totalUsd, poolsCount: liqData.poolsCount,
          subjectAmount: liqData.subjectAmount, subjectUsd: liqData.subjectUsd, quoteUsd: liqData.quoteUsd,
        };
        // Override liquidity with enriched value if pool info was insufficient
        if (liqData.totalUsd > 0 && liquidity === 0) liquidity = liqData.totalUsd;
      } else {
        const budgetLeft = GECKO_RATE_LIMIT - requestBudget.gecko.filter(t => t > Date.now() - 60000).length;
        const errMsg = budgetLeft <= 0
          ? `Liquidity N/A (gecko rate limit, budget=0/${GECKO_RATE_LIMIT}) for ${shortAddr(subjectAddr)} on ${chainId}`
          : `No liquidity data for ${shortAddr(subjectAddr)} on ${chainId}`;
        log(`[solo-enrich] ${errMsg}`);
        soloEnrichmentErrors.push(errMsg);
      }
    } catch (e) {
      const errMsg = `fetchLiquidityInfo failed: ${(e as Error).message}`;
      log(`[solo-enrich] ${errMsg}`);
      soloEnrichmentErrors.push(errMsg);
    }

    // БАГ 2 FIX: positionUsd always set for EVM even when balance=0
    if (chainId !== 'solana') {
      try {
        const posData = await fetchPositionInfo(chainId, subjectAddr, realTrader);
        if (posData) {
          soloPositionUsd = posData.balanceUsd;   // 0 is valid
          soloPositionPctSupply = posData.pctSupply;
        } else {
          soloPositionUsd = 0;
          soloPositionPctSupply = 0;
        }
      } catch (e) {
        const errMsg = `fetchPositionInfo failed: ${(e as Error).message}`;
        log(`[solo-enrich] ${errMsg}`);
        soloEnrichmentErrors.push(errMsg);
        // soloPositionUsd stays undefined → error path
      }
    }

    if (impact === null && soloLiquidityInfo && soloLiquidityInfo.totalUsd > 0) {
      impact = (amountUsd / soloLiquidityInfo.totalUsd) * 100;
    }
    // v9.32ax AX4: аномальный impact только если это не двухногий своп (перевод/мелкий пул).
    const otherLeg = lower(extracted.fromAddr) === lower(subjectAddr) ? extracted.toAddr : extracted.fromAddr;
    const twoLegSwap = !!(otherLeg && lower(otherLeg) !== lower(subjectAddr));
    if (!twoLegSwap && impact !== null && impact > IMPACT_MAX_PCT() && lastTokenMetaExtra.confidence !== 'low') {
      skip('impact_anomaly');
      notifSkipped.impact_anomaly++;
      return;
    }

    // А1: quote/stable balance for the trader (best-effort within enrich budget; omitted on failure).
    let quoteBalance: Notification['quoteBalance'];
    try {
      const qb = await fetchQuoteBalanceUsd(chainId, realTrader);
      quoteBalance = qb ? { usd: qb.usd, nativeAmount: qb.nativeAmount, nativeSymbol: qb.nativeSymbol, nativeUsd: qb.nativeUsd, stablesUsd: qb.stablesUsd } : { status: 'unavailable' };
    } catch { quoteBalance = { status: 'unavailable' }; }

    let subjectBalance: Notification['subjectBalance'];
    if (displayTrader !== 'unresolved') {
      try {
        // v9.32ao-RC3 П3: пост-трейд баланс с одним речеком — ATA-консистентность/слепок до
        // credits давали ложный «на кошельке 0» сразу после buy. Неподтверждённый 0 → known:false.
        const sb = await resolveSubjectBalance(chainId, realTrader, subjectAddr, { side, subjectAmount: extracted.subjectAmount || 0 });
        if (sb) {
          const px = (extracted.subjectAmount || 0) > 0 ? amountUsd / extracted.subjectAmount : 0;
          subjectBalance = { amount: sb.amount, usd: sb.known ? sb.amount * px : 0, known: sb.known };
        }
      } catch {
        subjectBalance = { amount: 0, usd: 0, known: false };
      }
    }

    // v9.11 F3: full wallet balances (top tokens by USD) at trade time.
    let walletBalances: Notification['walletBalances'];
    try {
      walletBalances = await fetchWalletBalances(chainId, realTrader, subjectAddr, enrichedSymbol);
    } catch { walletBalances = undefined; }

    const notif: Notification = {
      ts: Date.now(),
      chain: chainId, side, symbol: enrichedSymbol,
      token: subjectAddr, trader: netEnriched ? trader : displayTrader, txHash,
      traderFallbackAddr: (displayTrader === 'unresolved' && realTrader) ? realTrader : undefined,
      usd: amountUsd, impact, liquidity, fdv, via, links, source,
      tgOk: false, tgError: null,
      isContract: isContractTrader, pool: poolAddr, poolCreatedAt,
      poolSender: traderResolved ? poolSender : undefined, traderResolved,
      resolvedBy: netEnriched ? 'net_enrich' : resolvedBy,
      kind: 'solo', blockTs,
      soloChainIndex: scRec.count, // v9.32aq B4
      enrichAt: soloLiquidityInfo ? Date.now() : undefined,
      liquidityInfo: soloLiquidityInfo,
      positionUsd: soloPositionUsd,
      positionPctSupply: soloPositionPctSupply,
      quoteBalance,
      subjectBalance,
      subjectAmount: extracted.subjectAmount > 0 ? extracted.subjectAmount : undefined,
      walletBalances,
      enrichmentErrors: soloEnrichmentErrors.length > 0 ? soloEnrichmentErrors : undefined,
    };

    if (!rateCheck.allowed) {
      // At a full solo buffer the existing path rejects the new candidate. Record that loss
      // for non-Polygon chains only; do not change buffering, digest, Telegram or public rate counts.
      if (pendingSolo.length < PENDING_SOLO_CAP()) pendingSolo.push(notif);
      else if (chainId !== 'polygon') soloBufferOverflow.note(chainId);
      st.skipReasons.rate = (st.skipReasons.rate || 0) + 1;
      skip('rate');
      scheduleSoloFlush();
      log(`[rate-digest] solo ${notif.symbol} $${fmtUsd(notif.usd)} [${chainId}] → буфер (очередь=${pendingSolo.length})`);
      return;
    }
    await deliverSoloNotif(notif, ` blockTs=${blockTs} trader=${shortAddr(displayTrader)} liq=${soloLiquidityInfo ? fmtUsd(soloLiquidityInfo.totalUsd) : 'N/A'}`);
    recordFirstPass(chainId, txHash, { usd: amountUsd, skipReason: null, priceResolved: amountUsd > 0, gateClosed: lastPriceGateClosed });
    return;
  }

  // BRANCH 2: Series candidate (seriesThreshold ≤ amountUsd < soloThreshold)
  if (amountUsd >= seriesThreshold && amountUsd < soloThreshold) {
    if (chainId === 'solana' && traderResult.sideUnconfirmed === 'hop_local') {
      // v9.32m: skip ONLY when on-chain evidence contradicts the hop-local side.
      st.skipReasons.series_side_unconfirmed = (st.skipReasons.series_side_unconfirmed || 0) + 1;
      st.skipped++;
      return;
    }
    if (chainId === 'solana' && traderResult.sideUnconfirmed === 'no_data') {
      seriesStats.noDataCandidates = (seriesStats.noDataCandidates || 0) + 1;
    }
    const seriesKey = `${chainId}:${lower(realTrader)}:${lower(subjectAddr)}:${side}`;
    if (!seriesBuffer.has(seriesKey)) seriesBuffer.set(seriesKey, { candidates: [] });
    const buf = seriesBuffer.get(seriesKey)!;

    // v9.12 F1: a self-swap must NEVER enter the series buffer (even if it slipped past the gate).
    // v9.32bm1i T1: явный вердикт экстрактора важнее эвристики — как в cheapPreEnrichSkip и на гейте shouldNotify (ND1).
    // evmws/hotPoll ставят обе ноги = subject по конструкции (handleEvmWsLog), и selfSwap:false там честный
    // (analyzeEvmTx сам считает вердикт и возвращает error:'self_swap' до экстрактора). Прод 7c9d8965: bsc self_swap 959 при series 4 за 36 мин.
    const isSelfSwapEvent = extracted.selfSwap ?? isSelfSwap(extracted);
    if (isSelfSwapEvent) {
      st.skipReasons.self_swap = (st.skipReasons.self_swap || 0) + 1;
      st.skipReasons.self_swap_seriesAdmission = (st.skipReasons.self_swap_seriesAdmission || 0) + 1; // v9.32bm1i T5
      st.skipped++;
      return;
    }

    // Dedup within series by txHash
    if (buf.candidates.some(c => c.txHash === txHash)) {
      st.skipReasons.dup = (st.skipReasons.dup || 0) + 1;
      st.skipped++;
      return;
    }

    const subjectTokenAmount = lower(extracted.subjectAddr) === lower(extracted.fromAddr) ? extracted.fromAmount : extracted.toAmount;

    const candidate: SeriesCandidate = {
      ts: Date.now(),
      blockTs,
      txHash,
      usd: amountUsd,
      amount: subjectTokenAmount || 0,
      pool: poolAddr,
      symbol: enrichedSymbol,
      token: subjectAddr,
      links, via,
      selfSwap: isSelfSwapEvent,
    };
    buf.candidates.push(candidate);

    const seriesTrader = (traderResult.sideUnconfirmed === 'no_data')
      ? (source === 'dp' ? 'unresolved' : realTrader)
      : displayTrader;
    const fired = await tryFireSeries(chainId, seriesKey, buf, seriesTrader, subjectAddr, enrichedSymbol, side, resolvedBy,
      seriesTrader === 'unresolved' ? realTrader : undefined); // v9.32ao-RC3 П1
    if (fired) {
      state.antispam.set(antispamKey, Date.now());
      recordFirstPass(chainId, txHash, { usd: amountUsd, skipReason: null, priceResolved: amountUsd > 0, gateClosed: lastPriceGateClosed });
    } else {
      st.skipReasons.series = (st.skipReasons.series || 0) + 1;
      st.skipped++;
      recordFirstPass(chainId, txHash, { usd: amountUsd, skipReason: 'series', priceResolved: amountUsd > 0, gateClosed: lastPriceGateClosed });
    }
    cleanSeriesBuffer();
  }
}

// ─────────────── Process Event (GeckoTerminal) ───────────────
async function processEvent(chainId: string, trade: Record<string, unknown>, source: string) {
  const st = state.chain[chainId];
  st.lastEventAt = Date.now();

  const extracted = extractSubjectFromGeckoTrade(trade, chainId);
  // v9.13 F3: cold-start catch-up — events older than the freshness gate relative to startup are
  // immediately stale, NOT counted into seenEvents.
  if (extracted && extracted.blockTs !== null && isColdStartCatchUpStale(extracted.blockTs)) {
    startupCatchUpStale++;
    st.skipReasons.stale = (st.skipReasons.stale || 0) + 1;
    st.skipReasons.stalePoll = (st.skipReasons.stalePoll || 0) + 1; droppedStaleBySource.poll++; // v9.32bm1g H4: cold-start catch-up — poll-класс
    droppedStaleTotal++;
    return;
  }
  st.seenEvents++;
  if (!extracted || !extracted.subjectAddr) {
    st.skipReasons.noSubject++;
    st.skipped++;
    return;
  }

  // v9.14 F1: run the heavy enrichment under the bounded gate so the poll worker path cannot flood
  // the single-threaded event loop either. On saturation the work is dropped (seenEvents already
  // counted) — graceful degradation.
  await enqueueExtracted(chainId, extracted, source, trade, true, undefined, extracted.amountUsd >= settings.soloAmountUsd);
}

// ─────────────── Poll Queue ───────────────
let globalPollQueue: Array<{ chain: string; poolIdx: number }> = [];
let lastQueueRebuild = 0;

// ─────────────── P1 (v9.5): parallel poll worker pool ───────────────
// The poll queue is drained by POLL_WORKERS concurrent workers instead of the old strictly
// sequential per-tick loop. Each worker pulls the next item, polls it via GT (detection stream),
// and falls back to a cheap DexScreener freshness check when the GT budget is saturated — so the
// cursor advances at the full allowed rate within API limits (never around them). Discovery is
// decoupled from the tick (see tick) so polling is no longer blocked by it.
const POLL_WORKERS = Number(process.env.POLL_WORKERS || '6');
// v9.32k: the poll queue used to contain EVERY visible pool of every chain. On prod that was 3 780
// pools at ~87 tasks/min → a 43-minute full pass against the 180s freshness gate: the poll path
// could not produce a single fresh alert on ANY chain (stale 39-91%). Cap the queue to the top-N
// pools per chain by 24h volume — large trades happen on active pools; the rest stay covered by
// WS/reserve real-time and discovery. Anchors are always polled regardless of the cap.
const POLL_TOP_POOLS_PER_CHAIN = () => Number(process.env.POLL_TOP_POOLS_PER_CHAIN || '30');
// v9.13 F2: per-chain parallel poll cap — how many workers may poll the SAME chain concurrently.
// Round-robin across chains (the queue interleaves chains), and the per-chain cap prevents a single
// heavy chain from monopolizing all workers.
const PARALLEL_WORKERS = Number(process.env.PARALLEL_WORKERS || '6');
const pollInFlightPerChain: Record<string, number> = {};
// Pollers must NOT consume the whole shared GeckoTerminal budget — discovery and alert enrichment
// also need GT. So polling stops issuing GT calls once GT usage reaches this ceiling and falls back
// to the DexScreener freshness tier. This keeps GT headroom (avoids 429s) while lifting throughput.
const POLL_GT_CEILING = Number(process.env.POLL_GT_CEILING || '8');
// Bound the work per pool poll (P1): cap the number of trades processed from one pool's trade page
// and a wall-clock time budget, so a heavy pool can't stall the poll workers (which keeps the
// cursor advancing and all chains covered). GT page 1 is time-ordered (most recent first), so the
// first N trades are the freshest — capping captures the latest activity without missing alerts.
const POLL_MAX_TRADES_PER_POOL = Number(process.env.POLL_MAX_TRADES_PER_POOL || '150');
const POLL_POOL_TIME_BUDGET_MS = Number(process.env.POLL_POOL_TIME_BUDGET_MS || '6000');
// Dedicated per-minute cap for the poll DS freshness tier (separate from the shared dexscreener
// budget used by discovery, so poll checks never starve discovery).
const DS_POLL_LIMIT = Number(process.env.DS_POLL_LIMIT || '80');
const pollDsBudget: number[] = [];
let pollTasksCompletedAt: number[] = [];
let pollWorkersActive = 0;

// ─────────────── v9.7 I1: DexPaprika REST fallback transaction source ───────────────
// Free public source (no API key, no signup): 30 req/min/IP, 36+ chains. Used as a *fallback* on
// top of GT polling — when a pool's GT poll yields zero trades, or GT quota-skipped recently, we
// pull the pool's recent swaps from DexPaprika REST (`/networks/{network}/pools/{pool}/transactions`)
// to keep seenEvents growing and lower stale even under GT throttling.
const DEXPAPRIKA_REST_PER_MIN = Number(process.env.DEXPAPRIKA_REST_PER_MIN || '30'); // keyless per-IP limit
// v9.9 FK1: with a DEXPAPRIKA_API_KEY the free tier raises the allowance (~300K req/mo vs 50K
// keyless). Use a higher per-minute budget when a key is present; keyless stays at the low cap.
const DEXPAPRIKA_REST_PER_MIN_KEYED = Number(process.env.DEXPAPRIKA_REST_PER_MIN_KEYED || '120');
const DEXPAPRIKA_FALLBACK_MS = 5 * 60 * 1000; // broken-endpoint (timeout/429/5xx) fallback-mark duration
const DP_MAX_TXS_PER_FETCH = Number(process.env.DP_MAX_TXS_PER_FETCH || '50');
const ORACLE_DP_USE_KEY = process.env.ORACLE_DP_USE_KEY === '1';
// per-min used/calls/received trackers (mirror requestBudget.dexpaprika for calls; received counts swaps)
let dpCalls1m: number[] = [];
let dpReceived1m: number[] = [];
// when set in the future, DP REST is considered broken and skipped (fallback-mark per requirement)
let dpFallbackUntil = 0;
// last time a GT poll saw a 429/quota-skip → triggers DP fallback across the pool set for 1 min
let lastGtQuotaSkipAt = 0;
// raw dedup by pool:txHash so a re-fetched swap is not counted twice (even across sources)
const dpSeenTxs = new Set<string>();
// how many DexPaprika-restored swaps actually entered the detection pipeline (post raw-dedup +
// subject extraction) — proves the fallback contributes real events, not just fetches.
let dpAccepted = 0;

function cleanDpMetrics(): void {
  const oneMinAgo = Date.now() - 60000;
  dpCalls1m = dpCalls1m.filter(t => t > oneMinAgo);
  dpReceived1m = dpReceived1m.filter(t => t > oneMinAgo);
}


function cleanPollDsBudget(): void {
  const oneMinAgo = Date.now() - 60000;
  for (let i = 0; i < pollDsBudget.length; i++) {
    if (pollDsBudget[i] <= oneMinAgo) { pollDsBudget.splice(i, 1); i--; }
  }
}

// GT is available to polling only while under the poll GT ceiling (leaving room for discovery+enrich).
export function pollGtBudgetAvailable(): boolean {
  cleanBudget();
  if (Date.now() < gtBan.until) return false;
  if (gtBan.attempts > 0 && gtBan.probeAcquired) return false;
  return requestBudget.gecko.length < POLL_GT_CEILING;
}

// v9.21 A: poll-step policy for one worker iteration. SSE-connected chains STILL get GT polling so
// real-trade alerts are not lost when every chain is on the DexPaprika reserves SSE stream (that
// stream only produces synthetic `dp-r-…` reserve frames with no real tx hash, so it can NEVER
// alert on its own). But an SSE chain SKIPS the DexScreener freshness fallback — SSE already keeps
// it fresh, so that call would be pure waste. Non-SSE chains keep the GT→DS-freshness fallback.
// Pure + unit-testable.
export function pollStepPolicy(
  isEnabled: boolean,
  isSseConnected: boolean,
  gtBudgetAvailable: boolean,
  gtBanned?: boolean,
): 'skip' | 'gt_poll' | 'ds_freshness' | 'dp_rest' {
  if (!isEnabled) return 'skip';
  // v9.32n: GT IP-ban → DP REST trades (events), not DS-freshness (no events).
  if (gtBanned) return 'dp_rest';
  if (gtBudgetAvailable) return 'gt_poll';          // poll GT on ALL chains (incl. SSE) → real trades
  // GT budget saturated:
  return isSseConnected ? 'skip' : 'ds_freshness';  // non-SSE falls back to DS freshness; SSE needs none
}

// Atomically take the next poll item and advance the cursor (JS single-thread → safe).
// v9.25 F1: per-chain poll FAIR-SHARE. The poll queue is dominated by the chains with the most pools
// (eth/bsc/sol: 121/145/176 pools vs base/arb/poly: 53/32/40), so the round-robin cursor spent almost
// all budget on them — base/arbitrum/polygon were left un-polled (lastPollAt ~4-5min, polls1m=0) and
// stopped detecting real trades after reserves SSE was disabled (v9.24). A chain may not exceed its
// per-minute share; once it does, nextPollItem skips past it to an under-share chain so every chain
// gets covered.
const POLL_PER_CHAIN_PER_MIN = Number(process.env.POLL_PER_CHAIN_PER_MIN || '18'); // fair share ≈ total poll capacity / chains
function chainPollCount1m(chain: string): number {
  const st = state.chain[chain];
  if (!st?.pollTicks1m) return 0;
  const now = Date.now();
  return st.pollTicks1m.filter(t => t > now - 60000).length;
}
// v9.32m: last successful GT /trades poll per pool — drives anchor cadence (POLL_ANCHOR_GT_EVERY_MS).
const lastGtPollAt = new Map<string, number>();
const POLL_ANCHOR_GT_EVERY_MS = () => Number(process.env.POLL_ANCHOR_GT_EVERY_MS || '120000');
const POLL_MAX_AGE_SEC = () => Number(process.env.POLL_MAX_AGE_SEC || '420');
function gtPollKey(chain: string, addr: string): string { return chain + ':' + lower(addr); }

export function nextPollItem(opts?: { gtOnly?: boolean }): { chain: string; poolIdx: number } | null {
  if (globalPollQueue.length === 0) return null;
  if (opts?.gtOnly) {
    const now = Date.now();
    const cadence = POLL_ANCHOR_GT_EVERY_MS();
    type Cand = { item: { chain: string; poolIdx: number }; last: number; isAnchor: boolean; vol: number };
    const due: Cand[] = [];
    for (const item of globalPollQueue) {
      if (chainPollCount1m(item.chain) >= POLL_PER_CHAIN_PER_MIN) continue;
      const visible = visibleWatchPools(item.chain);
      if (!visible.length) continue;
      const addr = visible[item.poolIdx % visible.length];
      const last = lastGtPollAt.get(gtPollKey(item.chain, addr)) || 0;
      if (last > 0 && now - last < cadence) continue; // recently GT-polled — fairness
      const anchors = state.chain[item.chain]?.watchPoolsAnchors || [];
      const isAnchor = anchors.some(a => lower(a) === lower(addr));
      const vol = chainWatchMeta[item.chain]?.[lower(addr)]?.vol24hUsd || 0;
      due.push({ item, last, isAnchor, vol });
    }
    due.sort((a, b) => {
      if (a.isAnchor !== b.isAnchor) return a.isAnchor ? -1 : 1;
      if (b.vol !== a.vol) return b.vol - a.vol;
      return a.last - b.last;
    });
    return due[0]?.item || null;
  }
  for (let attempt = 0; attempt < globalPollQueue.length; attempt++) {
    const item = globalPollQueue[globalQueueIdx % globalPollQueue.length];
    globalQueueIdx = (globalQueueIdx + 1) % globalPollQueue.length;
    if (chainPollCount1m(item.chain) < POLL_PER_CHAIN_PER_MIN) return item;
  }
  return null; // every chain is at/over its fair share this minute
}

function recordPollTask(): void {
  const now = Date.now();
  pollTasksCompletedAt.push(now);
  pollTasksCompletedAt = pollTasksCompletedAt.filter(t => t > now - 60000);
}

// Is there any poll budget (GT-under-ceiling or poll-DS) left? Workers only take a queue item when
// we can poll. GT counts only while under POLL_GT_CEILING (headroom for discovery + enrichment).
function pollBudgetAvailable(): boolean {
  cleanBudget();
  cleanPollDsBudget();
  return pollGtBudgetAvailable() || pollDsBudget.length < DS_POLL_LIMIT;
}

// Lightweight DexScreener freshness check for a pool (advances cursor/lastPollAt without consuming
// GT budget). Uses its own DS_POLL_LIMIT cap so it never starves discovery's DS fallback.
async function pollSinglePoolDs(chainId: string, poolIdx: number): Promise<boolean> {
  const st = state.chain[chainId];
  const visible = visibleWatchPools(chainId);
  if (!st.enabled || visible.length === 0) return false;
  const poolAddr = visible[poolIdx % visible.length];
  const dsChain = CHAINS[chainId]?.ds;
  if (!dsChain) return false;
  cleanPollDsBudget();
  if (pollDsBudget.length >= DS_POLL_LIMIT) return false;
  pollDsBudget.push(Date.now());
  try {
    await dsFetch('pair', `https://api.dexscreener.com/latest/dex/pairs/${dsChain}/${poolAddr}`);   // bm2r D5: шлюз, лейн pair
    st.lastPollAt = Date.now();
    st.source = 'poll';
    return true;
  } catch {
    return false;
  }
}

// v9.7 I1: normalize a DexPaprika REST /pools/{pool}/transactions entry into the internal
// ExtractedSubject shape (mirrors extractSubjectFromDpTransaction but reads the REST field names:
// signed amount_0/amount_1 direction, human volume_0/volume_1, price_*_usd, sender, id, created_at).
function extractSubjectFromDpRestTransaction(tx: Record<string, unknown>, chainId: string): ExtractedSubject | null {
  const token0 = normAddr(chainId, String(tx.token_0 || ''));
  const token1 = normAddr(chainId, String(tx.token_1 || ''));
  if (!token0 || !token1) {
    recordNoSubjectSample(chainId, String(tx.pool_id || ''), token0, token1, 'missing token address');
    return null;
  }
  const raw0 = Number(tx.amount_0 || 0);
  const raw1 = Number(tx.amount_1 || 0);
  const vol0 = Math.abs(Number(tx.volume_0 || 0));
  const vol1 = Math.abs(Number(tx.volume_1 || 0));
  const p0usd = Number(tx.price_0_usd || 0);
  const p1usd = Number(tx.price_1_usd || 0);

  // Direction: the negative signed amount identifies the token being SOLD (from). Fall back to the
  // higher USD side if both signs are missing/zero.
  let fromAddr: string, toAddr: string, fromAmount: number, toAmount: number, fromUsd: number, toUsd: number;
  if (raw0 < 0) { fromAddr = token0; toAddr = token1; fromAmount = vol0; toAmount = vol1; }
  else if (raw1 < 0) { fromAddr = token1; toAddr = token0; fromAmount = vol1; toAmount = vol0; }
  else if (vol0 * p0usd >= vol1 * p1usd) { fromAddr = token0; toAddr = token1; fromAmount = vol0; toAmount = vol1; }
  else { fromAddr = token1; toAddr = token0; fromAmount = vol1; toAmount = vol0; }
  fromUsd = fromAddr === token0 ? vol0 * p0usd : vol1 * p1usd;
  toUsd = toAddr === token0 ? vol0 * p0usd : vol1 * p1usd;
  const amountUsd = Math.max(fromUsd, toUsd) || (fromUsd + toUsd);
  if (amountUsd === 0) return null;

  const txHash = String(tx.id || '');
  const sender = String(tx.sender || tx.recipient || '');
  const poolId = String(tx.pool_id || '');

  // ТЗ Bug 1: blockTs STRICTLY from source (created_at ISO for REST).
  const created = tx.created_at as string | undefined;
  let blockTs: number | null = null;
  let hasSourceBlockTs = false;
  if (typeof created === 'string' && created.length > 0) {
    const parsed = Math.floor(new Date(created).getTime() / 1000);
    if (!isNaN(parsed) && parsed > 1000000000) { blockTs = parsed; hasSourceBlockTs = true; }
  }

  const poolKey = token0 + ':' + token1;
  const poolInfo = poolInfoCache.get(poolKey);
  let fromSym = '?';
  let toSym = '?';
  if (poolInfo) {
    fromSym = lower(poolInfo.baseToken.address) === fromAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
    toSym = lower(poolInfo.baseToken.address) === toAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
  }
  if (fromSym === '?') { const n = getWrappedNativeSymbol(fromAddr); if (n) fromSym = n.toUpperCase(); }
  if (toSym === '?') { const n = getWrappedNativeSymbol(toAddr); if (n) toSym = n.toUpperCase(); }
  if (fromSym === '?') { const c = tokenMetaCache.get(chainId + ':' + fromAddr); if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) fromSym = c.symbol; }
  if (toSym === '?') { const c = tokenMetaCache.get(chainId + ':' + toAddr); if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) toSym = c.symbol; }

  const subj = determineSubject(fromAddr, fromSym, toAddr, toSym, fromUsd, toUsd);
  if (!subj) {
    recordNoSubjectSample(chainId, poolId, token0, token1, 'stable×stable or undetermined', fromSym, toSym);
    return null;
  }

  return {
    subjectAddr: subj.subjectAddr,
    subjectSym: subj.subjectSym,
    side: subj.side,
    amountUsd,
    fromAddr, fromSym, fromAmount,
    toAddr, toSym, toAmount,
    trader: sender, txHash,
    blockTs, hasSourceBlockTs,
    subjectAmount: subj.side === 'buy' ? toAmount : fromAmount,
  };
}

// v9.8 F2: normalize a DexPaprika SSE /sse/reserves frame (per-block pool reserve deltas) into an
// internal ExtractedSubject. A swap on a watched pool shows up as a reserve delta: the SOLD token
// has a negative delta (from), the BOUGHT token a positive delta (to). Values are human units in
// delta_usd (USD-denominated) and delta (token units). If both deltas share a sign or a token is
// missing, we fall back to the higher |delta_usd| side. blockTs comes strictly from block_timestamp.
function extractSubjectFromDpReserve(frame: Record<string, unknown>, chainId: string): ExtractedSubject | null {
  const tokens = (frame.tokens || []) as Array<Record<string, unknown>>;
  const poolId = String(frame.pool_id || '');
  if (!tokens || tokens.length === 0) {
    recordNoSubjectSample(chainId, poolId, '', '', 'reserve frame has no tokens');
    return null;
  }

  // pick the negative-delta token as "from" (sold) and positive-delta as "to" (bought)
  let fromTok: Record<string, unknown> | null = null;
  let toTok: Record<string, unknown> | null = null;
  for (const t of tokens) {
    const d = Number(t.delta || 0);
    const tid = normAddr(chainId, String(t.token_id || ''));
    if (tid) {
      if (d < 0 && !fromTok) fromTok = t;
      else if (d > 0 && !toTok) toTok = t;
    }
  }
  // If only one signed token exists, pair it with the other token as the counterparty.
  if (tokens.length === 2) {
    if (!fromTok) fromTok = tokens.find(t => normAddr(chainId, String(t.token_id || '')) !== normAddr(chainId, String(toTok?.token_id || ''))) || null;
    if (!toTok) toTok = tokens.find(t => normAddr(chainId, String(t.token_id || '')) !== normAddr(chainId, String(fromTok?.token_id || ''))) || null;
  }
  if (!fromTok || !toTok) {
    recordNoSubjectSample(chainId, poolId, String(fromTok?.token_id || ''), String(toTok?.token_id || ''), 'reserve direction undetermined');
    return null;
  }

  const fromAddr = normAddr(chainId, String(fromTok.token_id || ''));
  const toAddr = normAddr(chainId, String(toTok.token_id || ''));
  if (!fromAddr || !toAddr || fromAddr === toAddr) {
    recordNoSubjectSample(chainId, poolId, fromAddr, toAddr, 'missing token address');
    return null;
  }
  const fromUsd = Math.abs(Number(fromTok.delta_usd || 0));
  const toUsd = Math.abs(Number(toTok.delta_usd || 0));
  const fromAmount = Math.abs(Number(fromTok.delta || 0));
  const toAmount = Math.abs(Number(toTok.delta || 0));
  const amountUsd = Math.max(fromUsd, toUsd) || (fromUsd + toUsd);
  if (amountUsd === 0) return null;

  // blockTs STRICTLY from source (block_timestamp for reserves).
  const bts = frame.block_timestamp;
  let blockTs: number | null = null;
  let hasSourceBlockTs = false;
  if (typeof bts === 'number' && bts > 1000000000) { blockTs = bts; hasSourceBlockTs = true; }
  else if (typeof bts === 'string' && bts.length > 0) {
    const parsed = Math.floor(new Date(bts).getTime() / 1000);
    if (!isNaN(parsed) && parsed > 1000000000) { blockTs = parsed; hasSourceBlockTs = true; }
  }

  const poolKey = fromAddr + ':' + toAddr;
  const poolInfo = poolInfoCache.get(poolKey);
  let fromSym = '?';
  let toSym = '?';
  if (poolInfo) {
    fromSym = lower(poolInfo.baseToken.address) === fromAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
    toSym = lower(poolInfo.baseToken.address) === toAddr ? poolInfo.baseToken.symbol : poolInfo.quoteToken.symbol;
  }
  if (fromSym === '?') { const n = getWrappedNativeSymbol(fromAddr); if (n) fromSym = n.toUpperCase(); }
  if (toSym === '?') { const n = getWrappedNativeSymbol(toAddr); if (n) toSym = n.toUpperCase(); }
  if (fromSym === '?') { const c = tokenMetaCache.get(chainId + ':' + fromAddr); if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) fromSym = c.symbol; }
  if (toSym === '?') { const c = tokenMetaCache.get(chainId + ':' + toAddr); if (c && Date.now() - c.cachedAt < TOKEN_META_TTL) toSym = c.symbol; }

  const subj = determineSubject(fromAddr, fromSym, toAddr, toSym, fromUsd, toUsd);
  if (!subj) {
    recordNoSubjectSample(chainId, poolId, fromAddr, toAddr, 'stable×stable or undetermined', fromSym, toSym);
    return null;
  }

  // v9.16 F-TX: reserve frames usually carry no real per-trade hash — synthesize from block+pool so
  // notifDedup still works (each block×pool frame is unique per side). BUT if the response JSON
  // provides a real on-chain `tx_hash` in valid hex form, prefer it (so an SSE-derived alert can carry
  // a genuine chain hash). Otherwise the synthetic `dp-r-…` id is used, which the pre-notification
  // validator will reject as invalid_tx_format (never a phantom alert).
  const realTxHash = frame.tx_hash ? String(frame.tx_hash).trim() : '';
  let txHash = `dp-r-${chainId}-${poolId}-${String(frame.block || '')}-${subj.side}`;
  if (realTxHash && /^0x[0-9a-fA-F]{64}$/.test(realTxHash)) txHash = realTxHash;
  else if (realTxHash && isEVMChain(chainId) === false && /^[A-HJ-NP-Za-km-z1-9]{40,}$/.test(realTxHash)) txHash = realTxHash;
  const sender = ''; // reserves don't carry a trader; resolution falls back to unknown EOA

  return {
    subjectAddr: subj.subjectAddr,
    subjectSym: subj.subjectSym,
    side: subj.side,
    amountUsd,
    fromAddr, fromSym, fromAmount,
    toAddr, toSym, toAmount,
    trader: sender, txHash,
    blockTs, hasSourceBlockTs,
    subjectAmount: subj.side === 'buy' ? toAmount : fromAmount,
  };
}

// Fetch recent swaps for a pool from DexPaprika REST. Respects the 30/min/IP budget and skips while
// the endpoint is fallback-marked (broken). timeout(0)/429/5xx → fallback-mark 5 min; 404/4xx → empty.
const DP_POLL_PER_MIN = () => Number(process.env.DP_POLL_PER_MIN || '90');
const DP_POLL_TOP_POOLS = () => Number(process.env.DP_POLL_TOP_POOLS || '100');
let dpPollRotationSize = 0;
const dpPollStats = { pollCalls: 0, poll429: 0, pollEmpty: 0, pollAccepted: 0, staleFiltered: 0 };
let dpRestFetchImpl: ((chainId: string, pool: string) => Promise<Record<string, unknown>[]>) | null = null;
export function __setDpRestFetchForTest(fn: typeof dpRestFetchImpl): void { dpRestFetchImpl = fn; }
export function __resetDpPollStatsForTest(): void {
  dpPollStats.pollCalls = 0; dpPollStats.poll429 = 0; dpPollStats.pollEmpty = 0; dpPollStats.pollAccepted = 0; dpPollStats.staleFiltered = 0;
  rateMap.delete('dp-poll');
}
async function pollPoolViaDpRest(chainId: string, poolAddr: string): Promise<boolean> {
  if (!rateAllow('dp-poll', DP_POLL_PER_MIN(), 60000)) return false;
  dpPollStats.pollCalls++;
  lastDpPollAt.set(dpPollKey(chainId, poolAddr), Date.now());
  try {
    const txs = dpRestFetchImpl ? await dpRestFetchImpl(chainId, poolAddr) : await fetchDpRestTransactions(chainId, poolAddr);
    if (!txs.length) { dpPollStats.pollEmpty++; return true; }
    const before = dpAccepted;
    await processDpTrades(chainId, poolAddr, txs);
    dpPollStats.pollAccepted += Math.max(0, dpAccepted - before);
    if (dpRestFetchImpl) dpPollStats.pollAccepted = txs.length; // seam: count provided txs
    return true;
  } catch (e: any) {
    if (e?.status === 429 || String(e?.message || '').includes('429')) dpPollStats.poll429++;
    return false;
  }
}
export async function __pollPoolViaDpRestForTest(chainId: string, poolAddr: string): Promise<boolean> {
  return pollPoolViaDpRest(chainId, poolAddr);
}
async function fetchDpRestTransactions(chainId: string, poolAddr: string): Promise<Record<string, unknown>[]> {
  if (!settings.dexPaprika?.enabled) return [];
  // v9.15 F-AC: apiEnabled master switch (false → no DexPaprika API usage at all).
  if (settings.dexPaprika?.apiEnabled === false) return [];
  // v9.15 F-AC: per-network REST daily cap (optional) — fall back to GT polling for an over-budget chain.
  if (creditController().isChainOverDailyRestBudget(chainId)) return [];
  if (Date.now() < dpFallbackUntil) return [];
  const dpNet = CHAINS[chainId]?.dpNetwork;
  if (!dpNet) return [];
  const apiKey = getDexPaprikaApiKey(); // never logged; used only for auth + higher rate budget
  const perMinLimit = apiKey ? DEXPAPRIKA_REST_PER_MIN_KEYED : DEXPAPRIKA_REST_PER_MIN;
  cleanBudget();
  cleanDpMetrics();
  if (requestBudget.dexpaprika.length >= perMinLimit) return [];
  requestBudget.dexpaprika.push(Date.now());
  dpCalls1m.push(Date.now());
  runtime.dpRestCount++;
  const url = `${settings.dexPaprika.baseUrl}/networks/${dpNet}/pools/${poolAddr}/transactions?limit=${DP_MAX_TXS_PER_FETCH}`;
  const headers: Record<string, string> = { 'Accept': 'application/json' };
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
  // bm2e: ЯВНЫЙ источник 'poll'. Тот же путь /pools/{pool}/transactions использует оракул —
  // по URL их не различить, а это две главные статьи расхода. Без аргумента они слились бы в одну.
  const res = await getJSON<{ transactions?: Record<string, unknown>[] }>(url, { headers }, 'poll');
  if (res.status !== 200) {
    if (res.status === 0 || res.status === 429 || res.status >= 500) {
      dpFallbackUntil = Date.now() + DEXPAPRIKA_FALLBACK_MS; // broken endpoint → mark 5 min
    }
    return [];
  }
  const txs = res.data?.transactions || [];
  const now = Date.now();
  for (let i = 0; i < txs.length; i++) dpReceived1m.push(now);
  return txs;
}

// Feed normalized DexPaprika swaps into the normal detection pipeline (dedup, blockTs, stale, CEX,
// M2 contract filter, etc.). Raw dedup by pool:txHash prevents double-counting re-fetched swaps.
const lastDpPollAt = new Map<string, number>();
function dpPollKey(chain: string, pool: string): string { return chain + ':' + lower(pool); }
export function __setLastDpPollAtForTest(chain: string, pool: string, at: number): void {
  lastDpPollAt.set(dpPollKey(chain, pool), at);
}
export function nextDpRestPool(): { chain: string; pool: string } | null {
  type Cand = { chain: string; pool: string; last: number; isAnchor: boolean; vol: number };
  const cands: Cand[] = [];
  for (const chain of Object.keys(CHAINS)) {
    const st = state.chain[chain];
    if (!st?.enabled) continue;
    const anchors = new Set((st.watchPoolsAnchors || []).map(a => lower(a)));
    for (const pool of visibleWatchPools(chain)) {
      const last = lastDpPollAt.get(dpPollKey(chain, pool)) || 0;
      const vol = chainWatchMeta[chain]?.[lower(pool)]?.vol24hUsd || 0;
      cands.push({ chain, pool, last, isAnchor: anchors.has(lower(pool)), vol });
    }
  }
  if (!cands.length) { dpPollRotationSize = 0; return null; }
  const banned = gtBanState().banned;
  if (banned) {
    const cap = Math.max(1, DP_POLL_TOP_POOLS());
    const top = [...cands].sort((a, b) => b.vol - a.vol).slice(0, cap);
    top.sort((a, b) => (a.last - b.last) || (b.vol - a.vol));
    dpPollRotationSize = top.length;
    return { chain: top[0].chain, pool: top[0].pool };
  }
  dpPollRotationSize = Math.min(cands.length, POLL_TOP_POOLS_PER_CHAIN());
  cands.sort((a, b) => {
    if (a.isAnchor !== b.isAnchor) return a.isAnchor ? -1 : 1;
    return a.last - b.last;
  });
  return { chain: cands[0].chain, pool: cands[0].pool };
}
export async function __processDpTradesForTest(chainId: string, poolAddr: string, txs: Record<string, unknown>[]): Promise<void> {
  return processDpTrades(chainId, poolAddr, txs);
}
async function processDpTrades(chainId: string, poolAddr: string, txs: Record<string, unknown>[]): Promise<void> {
  const st = state.chain[chainId];
  const maxAge = POLL_MAX_AGE_SEC();
  const nowSec = Math.floor(Date.now() / 1000);
  const fresh: Record<string, unknown>[] = [];
  for (const tx of txs) {
    const created = tx.created_at as string | undefined;
    if (typeof created === 'string' && created.length > 0) {
      const ts = Math.floor(new Date(created).getTime() / 1000);
      if (!isNaN(ts) && nowSec - ts > maxAge) { dpPollStats.staleFiltered++; continue; }
    }
    fresh.push(tx);
  }
  txs = fresh;
  for (const tx of txs) {
    const txHash = String(tx.id || '');
    const dedupKey = `${chainId}:${poolAddr}:${txHash}`;
    if (!txHash || dpSeenTxs.has(dedupKey)) continue;
    dpSeenTxs.add(dedupKey);
    if (dpSeenTxs.size > 20000) dpSeenTxs.clear(); // bounded memory; notifDedup still guards 5-min dedup
    const extracted = extractSubjectFromDpRestTransaction(tx, chainId);
    if (!extracted || !extracted.subjectAddr) {
      st.skipReasons.noSubject = (st.skipReasons.noSubject || 0) + 1;
      st.skipped++;
      continue;
    }
    dpAccepted++;
    await enqueueExtracted(chainId, extracted, 'dp', tx, false, undefined, extracted.amountUsd >= settings.soloAmountUsd);
  }
}

// One background poll worker: loops draining the queue, GT-first, DS-freshness fallback.
async function pollWorkerLoop(): Promise<void> {
  pollWorkersActive++;
  try {
    while (state.running && !process.env.NODE_TEST_CONTEXT) {
      if (!pollBudgetAvailable()) { await sleep(400); continue; }
      const gtDue = pollGtBudgetAvailable() ? nextPollItem({ gtOnly: true }) : null;
      const item = gtDue || nextPollItem();
      if (!item) { await sleep(200); continue; }
      const st = state.chain[item.chain];
      // v9.21 A: an SSE-connected chain is NO LONGER skipped here. It still gets GT polling so the
      // parser keeps detecting real trades (with real tx hashes → alerts). The reserves SSE stream
      // only synthesizes `dp-r-…` ids (no real hash), so without GT polling an all-SSE parser could
      // never alert. The old bare-`continue` tight-loop risk (v9.14 F1) is gone: workers now either
      // do real GT work or sleep below, never spin hot. Disabled chains are still skipped.
      if (!st || !st.enabled) { await sleep(50); continue; }
      // v9.13 F2: per-chain concurrency cap — skip the item if this chain already has too many
      // polls in flight (so one chain can't hog all workers). Round-robin (queue order) distributes
      // across chains.
      if ((pollInFlightPerChain[item.chain] || 0) >= PARALLEL_WORKERS) { await sleep(25); continue; }
      pollInFlightPerChain[item.chain] = (pollInFlightPerChain[item.chain] || 0) + 1;
      try {
        // v9.21 A: GT-first on ALL chains (SSE chains still poll GT for real-trade detection); the
        // DexScreener freshness fallback only applies to non-SSE chains (SSE already keeps them fresh).
        const banned = gtBanState().banned;
        const policy = (!banned && gtDue) ? 'gt_poll' : pollStepPolicy(st.enabled, st.sseConnected, pollGtBudgetAvailable(), banned);
        if (policy === 'gt_poll') {
          const gtOk = await pollSinglePool(item.chain, item.poolIdx);
          if (gtOk) {
            recordPollTask();
          } else if (!st.sseConnected) {
            const dsOk = await pollSinglePoolDs(item.chain, item.poolIdx);
            if (dsOk) recordPollTask();
          }
        } else if (policy === 'dp_rest') {
          const pick = nextDpRestPool();
          const visible = visibleWatchPools(item.chain);
          const fallback = visible.length ? visible[item.poolIdx % visible.length] : '';
          const chain = pick?.chain || item.chain;
          const poolAddr = pick?.pool || fallback;
          if (poolAddr && await pollPoolViaDpRest(chain, poolAddr)) recordPollTask();
        } else if (policy === 'ds_freshness') {
          const dsOk = await pollSinglePoolDs(item.chain, item.poolIdx);
          if (dsOk) recordPollTask();
        }
      } finally {
        pollInFlightPerChain[item.chain] = (pollInFlightPerChain[item.chain] || 1) - 1;
      }
      // mark this chain's poll tick (tick1m metric) on any successful poll
      if (st.pollTicks1m) {
        const now = Date.now();
        st.pollTicks1m = st.pollTicks1m.filter(t => t > now - 60000);
        st.pollTicks1m.push(now);
      }
      await sleep(25); // slight yield; rate budgets are the real throttle
    }
  } finally {
    pollWorkersActive--;
  }
}

function startPollWorkers(): void {
  if (process.env.NODE_TEST_CONTEXT) return; // tests drive polling through explicit mocks/hooks only
  for (let i = 0; i < POLL_WORKERS; i++) {
    pollWorkerLoop().catch(e => addError(`[poll-worker] ${(e as Error).message}`));
  }
}
export function __startPollWorkersForTest(): void { startPollWorkers(); }
export function __pollWorkersActiveForTest(): number { return pollWorkersActive; }

/** v9.32k: pure selector — indexes into `visible` that the poll queue should contain. */
export function selectPollPoolIndexes(
  visible: string[],
  anchorsLower: Set<string>,
  cap: number,
  metaMap?: Record<string, { vol24hUsd?: number | null }>,
): number[] {
  if (cap <= 0 || visible.length <= cap) return visible.map((_, i) => i);
  const scored = visible.map((addr, i) => ({ i, addr, vol: metaMap?.[lower(addr)]?.vol24hUsd || 0 }));
  scored.sort((a, b) => b.vol - a.vol);
  const picked = new Set<number>(scored.slice(0, cap).map(s => s.i));
  for (const s of scored) if (anchorsLower.has(lower(s.addr))) picked.add(s.i);
  return [...picked].sort((a, b) => a - b);
}

function rebuildPollQueue() {
  globalPollQueue = [];
  lastQueueRebuild = Date.now();
  // ТЗ v7 req.4: only poll visible (non-zero ?/0/0) pools.
  const enabledChains = Object.keys(CHAINS).filter(c => {
    const st = state.chain[c];
    return st?.enabled && visibleWatchPools(c).length > 0;
  });
  const cap = POLL_TOP_POOLS_PER_CHAIN();
  const selectedByChain: Record<string, number[]> = {};
  for (const chain of enabledChains) {
    const anchorsLower = new Set((state.chain[chain]?.watchPoolsAnchors || []).map(a => lower(a)));
    selectedByChain[chain] = selectPollPoolIndexes(visibleWatchPools(chain), anchorsLower, cap, chainWatchMeta[chain]);
  }
  const maxLen = Math.max(...enabledChains.map(c => selectedByChain[c].length), 1);
  for (let k = 0; k < maxLen; k++) {
    for (const chain of enabledChains) {
      const sel = selectedByChain[chain];
      if (k < sel.length) globalPollQueue.push({ chain, poolIdx: sel[k] });
    }
  }
  log(`[queue] Rebuilt: ${globalPollQueue.length} items for ${enabledChains.length} chains`);
  // v9.26 F1: keep the real-time WS subscription in sync with a grown watchlist.
  gtWsResubIfConnected();
  // v9.26 F2: keep the Solana pubsub subscription in sync with a grown watchlist.
  solPubsubResubIfConnected();
  for (const c of EVM_WS_SUPPORTED_CHAINS) evmWsResubIfConnected(c);
}

async function pollSinglePool(chainId: string, poolIdx: number): Promise<boolean> {
  const st = state.chain[chainId];
  const visible = visibleWatchPools(chainId);
  if (!st.enabled || visible.length === 0) return false;
  const poolAddr = visible[poolIdx % visible.length];
  const gecko = CHAINS[chainId]?.gecko;
  if (!gecko) return false;

  cleanBudget();
  if (requestBudget.gecko.length >= GECKO_POLL_LIMIT) return false;

  const isSeedingPass = !st._seedDone && poolIdx === 0;

  try {
    // v9.7 I1: use the status-aware GT getter so we can detect a GT quota-skip (429) and fall back
    // to DexPaprika REST. data===null on 429/timeout/rate_limit_local.
    const g = await geckoGetWithStatus<{ data?: Record<string, unknown>[] }>(
      `/networks/${gecko}/pools/${poolAddr}/trades?page=1`
    );
    lastGtPollAt.set(gtPollKey(chainId, poolAddr), Date.now());
    st.lastPollAt = Date.now();
    st.source = 'poll';
    const data = g.data ?? null;
    const gtLen = data?.data?.length || 0;
    if (g.status === 429) lastGtQuotaSkipAt = Date.now();

    if (data?.data) {
      // P1 (v9.5): bound the work done per pool so a single heavy pool cannot block the poll
      // workers — cap the number of trades processed AND a wall-clock time budget. This keeps the
      // cursor advancing through the whole queue (all chains covered) within API limits.
      const budgetEnd = Date.now() + POLL_POOL_TIME_BUDGET_MS;
      let processed = 0;
      for (const trade of data.data) {
        if (processed >= POLL_MAX_TRADES_PER_POOL) break;
        if (Date.now() >= budgetEnd) break;
        processed++;
        trade.pool_id = poolAddr;
        if (isSeedingPass) {
          const extracted = extractSubjectFromGeckoTrade(trade, chainId);
          if (extracted) {
            const dedupKey = `${chainId}:${extracted.txHash}:${extracted.side}`;
            notifDedup.set(dedupKey, Date.now());
            st.startupSeeded++;
            st.seenEvents++;
          }
        } else {
          await processEvent(chainId, trade, 'poll');
        }
      }
    }

    // v9.7 I1: DexPaprika REST fallback when GT yielded zero trades OR GT quota-skipped recently.
    // Skip during seeding (that's a GT-only warm-up pass) and while the chain is on SSE push.
    if (!isSeedingPass && settings.dexPaprika?.enabled && !st.sseConnected &&
        (gtLen === 0 || (Date.now() - lastGtQuotaSkipAt) < 60000)) {
      const dpTxs = await fetchDpRestTransactions(chainId, poolAddr);
      if (dpTxs.length > 0) await processDpTrades(chainId, poolAddr, dpTxs);
    }

    if (isSeedingPass) {
      st._seedDone = true;
      log(`[seed] ${chainId} seed-only pass done: ${st.startupSeeded} events seeded`);
    }
    return true;
  } catch (e: unknown) {
    st.lastError = (e as Error).message;
    st.errors++;
    if (isSeedingPass) st._seedDone = true;
    return true;
  }
}

// ─────────────── GeckoTerminal OnchainTrade WebSocket (v9.26 F1) ───────────────
// REAL-TIME trade detection with REAL tx hashes. GT polling (180s gate) misses ~15-20% of large
// trades because each pool is polled every ~3-4min. The DexPaprika reserves SSE (disabled v9.24)
// burned ~1000 credits/min AND only produced synthetic `dp-r-…` ids with no real hash (never an
// alert). The CoinGecko/GT OnchainTrade (G2) WebSocket streams real per-pool trades in real-time
// (as fast as 0.1s) carrying the ACTUAL tx hash, buy/sell type, token amount and USD volume —
// solving both problems. Requires a CoinGecko Pro key (Basic+; Analyst+ for more sockets), bills
// ~0.1 credit/response. OFF by default: set GT_ONCHAIN_WS_ENABLED=1 AND COINGECKO_PRO_API_KEY.
const GT_ONCHAIN_WS_ENABLED = process.env.GT_ONCHAIN_WS_ENABLED === '1';
// GT network-id → internal chain (matches CHAINS[x].gecko ids).
const GT_WS_NETWORK_TO_CHAIN: Record<string, string> = {
  eth: 'ethereum', bsc: 'bsc', base: 'base', arbitrum: 'arbitrum', polygon_pos: 'polygon', solana: 'solana',
};
// G2 trade payload fields (see docs.coingecko.com/websocket/wss-onchain-trade).
export interface GtOnchainTradeFrame {
  ch?: string; n?: string; pa?: string; tx?: string; ty?: 'b' | 's';
  to?: number; toq?: number; vo?: number; pc?: number; pu?: number; t?: number;
}
const gtWsMetrics = {
  connected: false, frames: 0, parsed: 0, dropped: 0, lastError: null as string | null,
  lastFrameAt: 0, reconnects: 0, subscribedPools: 0,
};
function gtWsEnabled(): boolean {
  return GT_ONCHAIN_WS_ENABLED && !!process.env.COINGECKO_PRO_API_KEY && state.running;
}
// Resolve the watched pool's subject (base token) from watchlist meta / pool-info cache.
export function __subjectForWatchedPoolForTest(chainId: string, pool: string) {
  return subjectForWatchedPool(chainId, pool);
}
function subjectForWatchedPool(chainId: string, pool: string): { address: string; symbol: string } | null {
  const key = isEVMChain(chainId) ? lower(pool) : pool;
  // chainWatchMeta is always keyed by lower(addr) (see seedWatchPool); fall back to it for solana
  // too so a case-variant of the pool address still resolves the subject.
  const meta = chainWatchMeta[chainId]?.[key] || chainWatchMeta[chainId]?.[lower(pool)];
  if (meta && meta.subjectToken) return { address: meta.subjectToken, symbol: meta.subjectSymbol };
  const ci = poolInfoCache.get(lower(pool)) || poolInfoCache.get(chainId + ':' + lower(pool));
  if (ci && ci.baseToken.address) return { address: ci.baseToken.address, symbol: ci.baseToken.symbol };
  // v9.32e: manifest anchors resolve instantly — solReserveSubscribeAll must not skip anchor
  // pools while GT discovery is still warming chainWatchMeta (incident: PUMP $25K skipped stale).
  const anchorHit = (ANCHOR_POOLS[chainId] || []).find(
    a => a.pool === pool || lower(a.pool) === lower(pool)
  );
  if (anchorHit && anchorHit.subject) {
    return { address: anchorHit.subject, symbol: anchorHit.symbol || '?' };
  }
  return null;
}
// Pure: map a G2 trade frame to an internal ExtractedSubject (REAL hash, buy/sell, USD volume).
export function extractSubjectFromGtOnchainTrade(frame: GtOnchainTradeFrame, chainId: string): ExtractedSubject | null {
  if (!frame || frame.ch !== 'G2') return null;
  const pool = String(frame.pa || '');
  const tx = String(frame.tx || '');
  const usd = Number(frame.vo) || 0;
  const side = frame.ty === 's' ? 'sell' : (frame.ty === 'b' ? 'buy' : '');
  const blockTs = frame.t && frame.t > 1e9 ? Math.floor(frame.t / 1000) : Math.floor(Date.now() / 1000);
  if (!pool || !tx || !usd || !side) return null;
  const subj = subjectForWatchedPool(chainId, pool);
  if (!subj) return null;
  const subjectAmount = Number(frame.to) || 0;
  return {
    subjectAddr: subj.address, subjectSym: subj.symbol, side, amountUsd: usd,
    fromAddr: subj.address, fromSym: subj.symbol, fromAmount: side === 'buy' ? subjectAmount : 0,
    toAddr: subj.address, toSym: subj.symbol, toAmount: side === 'sell' ? subjectAmount : 0,
    trader: '', txHash: tx, blockTs, hasSourceBlockTs: true, subjectAmount,
  };
}
let gtWsSocket: unknown = null;
let gtWsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
function gtWsSubscribePools(ws: any): void {
  try {
    ws.send(JSON.stringify({ command: 'subscribe', identifier: JSON.stringify({ channel: 'OnchainTrade' }) }));
    const subs: string[] = [];
    for (const chainId of Object.keys(CHAINS)) {
      const net = CHAINS[chainId]?.gecko;
      if (!net) continue;
      for (const p of state.chain[chainId]?.watchPools || []) subs.push(`${net}:${p}`);
    }
    gtWsMetrics.subscribedPools = subs.length;
    if (subs.length) {
      ws.send(JSON.stringify({
        command: 'message',
        identifier: JSON.stringify({ channel: 'OnchainTrade' }),
        data: JSON.stringify({ 'network_id:pool_addresses': subs, action: 'set_pools' }),
      }));
    }
    log(`[gtws] subscribed ${subs.length} watched pools`);
  } catch (e: any) { gtWsMetrics.lastError = (e?.message || String(e)); }
}
// Re-push the current watched-pool set when the watchlist grows mid-run (discovery adds pools) so
// the live subscription stays in sync even without a reconnect. No-op when disconnected.
function gtWsResubIfConnected(): void {
  if (gtWsSocket && gtWsMetrics.connected) gtWsSubscribePools(gtWsSocket);
}
function handleGtWsMessage(raw: string): void {
  gtWsMetrics.frames++;
  gtWsMetrics.lastFrameAt = Date.now();
  try {
    const msg = JSON.parse(raw);
    if (Array.isArray(msg)) { for (const m of msg) handleGtWsMessage(JSON.stringify(m)); return; }
    let frame: GtOnchainTradeFrame | null = null;
    if (msg?.message && typeof msg.message === 'object' && (msg.message.ch === 'G2' || msg.message.n)) frame = msg.message;
    else if (msg?.ch === 'G2') frame = msg;
    if (!frame) return;
    const chainId = GT_WS_NETWORK_TO_CHAIN[String(frame.n || '')];
    if (!chainId) return;
    const st = state.chain[chainId];
    const extracted = extractSubjectFromGtOnchainTrade(frame, chainId);
    if (!extracted || !st) { gtWsMetrics.dropped++; return; }
    gtWsMetrics.parsed++;
    st.seenEvents++;
    st.lastEventAt = Date.now();
    // real-time gate: 60s (trades arrive within seconds of on-chain execution)
    enqueueExtracted(chainId, extracted, 'gtws', frame as unknown as Record<string, unknown>, true, 60, extracted.amountUsd >= settings.soloAmountUsd);
  } catch { gtWsMetrics.dropped++; }
}
function scheduleGtWsReconnect(): void {
  if (!gtWsEnabled() || gtWsReconnectTimer || gtWsSocket) return;
  gtWsReconnectTimer = setTimeout(() => {
    gtWsReconnectTimer = null;
    startGtOnchainWs();
  }, 3000);
}
async function startGtOnchainWs(): Promise<void> {
  if (!gtWsEnabled()) return;
  if (gtWsSocket) return;
  try {
    const { default: WS } = await import('ws');
    const key = process.env.COINGECKO_PRO_API_KEY || '';
    const url = `wss://stream.coingecko.com/v1?x_cg_pro_api_key=${encodeURIComponent(key)}`;
    const ws = new WS(url);
    gtWsSocket = ws;
    gtWsMetrics.lastError = null;
    ws.on('open', () => { gtWsMetrics.connected = true; log('[gtws] connected'); gtWsSubscribePools(ws); });
    ws.on('message', (data: any) => handleGtWsMessage(String(data)));
    ws.on('close', () => {
      gtWsMetrics.connected = false;
      gtWsSocket = null;
      if (gtWsEnabled()) { gtWsMetrics.reconnects++; scheduleGtWsReconnect(); }
    });
    ws.on('error', (e: any) => { gtWsMetrics.lastError = (e?.message || String(e)); log(`[gtws] error: ${gtWsMetrics.lastError}`); });
  } catch (e: any) {
    gtWsMetrics.lastError = (e?.message || String(e));
    log(`[gtws] start error: ${gtWsMetrics.lastError}`);
  }
}
function stopGtOnchainWs(): void {
  if (gtWsSocket) { try { (gtWsSocket as any).close(); } catch { /* ignore */ } gtWsSocket = null; }
  gtWsMetrics.connected = false;
  if (gtWsReconnectTimer) { clearTimeout(gtWsReconnectTimer); gtWsReconnectTimer = null; }
}
export function __gtWsExtractForTest(frame: GtOnchainTradeFrame, chainId: string): ExtractedSubject | null {
  return extractSubjectFromGtOnchainTrade(frame, chainId);
}
export function __gtWsEnabledForTest(): boolean { return GT_ONCHAIN_WS_ENABLED; }
export function __gtWsMetricsForTest() { return { ...gtWsMetrics }; }

// v9.32ai ID3: рантайм-страж доставки ОБЕИХ ветвей (резерв + hotPoll) — «тихая смерть»
// невозможна: состояние видно в status.deliveryHealth, warn-лог не чаще раза в минуту.
export interface DeliveryHealthCtx {
  now: number;
  running?: boolean; // v9.32ak AK5: false → вердикт parser-stopped (ручной stop ≠ мёртвые каналы)
  reserveActiveSubs: number;
  // v9.32an-RC1: ЗАЯВЛЕННЫЕ подписки (solReserveState, вкл. кэш-ресторы) — боевой рана
  // 2026-09-02 01:39: сокет half-dead (open есть, accountSubscribe отправлены, подтверждений
  // НЕТ) → activeAccount пуст → старое условие ">100" молчало вечно. Смотрим оба.
  reserveIntendedSubs?: number;
  reserveRingNotifications: number;
  reserveLastNotificationAt: number;
  // v9.32bm1 S8: дельта нотификаций за окно (prev-поля снапшота). Если не задана — кольцо (10 мин).
  reserveNotificationsWindow?: number;
  // v9.32bm1 S8: callsWindow/logsWindow/windowMs — дельты за окно; если не заданы (окно ещё не
  // набрано) — действует старое кумулятивное правило.
  chains: Array<{
    chainId: string; calls: number; logs: number; lastLogAt: number;
    callsWindow?: number; logsWindow?: number; windowMs?: number;
    // D4-R: outcome-classified logical eth_getLogs calls. Optional keeps the public helper
    // source-compatible for callers that only provide the legacy cumulative fields.
    windowReady?: boolean;
    getLogsOkWindow?: number; getLogsEmptyWindow?: number; getLogsFailedWindow?: number;
    getLogsNeutralWindow?: number; getLogsSkippedWindow?: number;
    activity?: 'active' | 'quiet' | 'transport-failed' | 'unknown';
  }>;
  // v9.32bm1i T6: пауза парсера (POST /api/parser/pause). Прод c375a3bb: paused 12.9 ч, pausedDropped 198 608,
  // overall 'ok' — окно считало молчание нормой, потому что вызовы тоже остановлены. Пауза дольше 5 мин — вердикт.
  paused?: { pausedAt: number; dropped: number } | null;
  // v9.32bm1g H5: возраст открытого pubsub-сокета; -1/undefined — сокет закрыт (тогда subBlind не ставится:
  // это ingestDown, не «подписки без подтверждения»).
  socketAgeMs?: number;
  // v9.33bm1k Task 4: вердикт правила ingest (evaluateIngest + гистерезис). Входит в overall как худший из уровней.
  // Прод bm1j s22: ingestDown:true, intended 0, 36 мин без событий — overall 'ok', т.к. reserve/subBlind вырождаются при intended=0.
  ingest?: IngestVerdict | null;
  reserveStoppedBy?: ReserveStopReason | null; // v9.33bm1k Task 6
  budget?: BudgetVerdict | null; // bm1k-fix Task 3: дневной бюджет Helius (после гистерезиса)
  // ТЗ 2026-10-09 §3.1 req 5: per-chain вердикт покрытия eth_getLogs. Прод 09:30:17Z:
  // polygon getLogsFailed=258 и base degraded=true при deliveryHealth.overall='ok' — правило
  // hotPollDead требует ok===0 && neutral===0 && skipped===0, и base имел getLogsSkipped=323.
  coverage?: EvmCoverageVerdict | null;
  /** W2 T4 (D-W2-1): coverage_gap/unknown интервалов паузы в пределах PAUSE_GAP_RETENTION_MS; backfilled сюда не попадает. */
  pauseGaps?: Array<{ source: string; outcome: 'coverage_gap' | 'unknown'; detail: string }>;
}
const DELIVERY_PAUSED_VERDICT_MS = 5 * 60_000;
// v9.32bm1g H5: грейс на подтверждения после open — раньше него «0 подтверждено» нормально.
const DELIVERY_SUB_BLIND_GRACE_MS = 60_000;
export type DeliveryHealthVerdict = {
  // v9.33bm1k Task 6 давал 'dead' для лимитера; bm1m Task 6 (решение владельца 2026-09-12): лимитер → 'limited-reserve' ранга degraded —
  // резерв отключён самой системой штатно, доставка работает. Значение 'dead' из типа не убираем (контракт payload — только добавления),
  // но код его больше не выдаёт. bm1k-fix Task 3: *-budget — дневной бюджет Helius ≥ 80/95 %.
  // ТЗ 2026-10-11 §4 (WP-H1): 'degraded-hotpoll' — часть оцениваемых цепей без единого успешного
  // ответа провайдера, остальные доставляют. Раньше любая такая цепь красила агрегат в глобальный
  // 'dead-hotpoll' («доставка мертва целиком»), а при непустом skipped — наоборот в 'ok'.
  reserve: 'ok' | 'dead-reserve' | 'dead' | 'limited-reserve' | 'degraded-budget' | 'dead-budget'; hotPoll: 'ok' | 'degraded-hotpoll' | 'dead-hotpoll';
  // v9.33bm1k: + 'dead-ingest' — Solana-ingest мёртв по правилу ingest, а reserve/hotPoll по своим правилам 'ok'
  // bm1m Task 6: + 'degraded' — только от limited-reserve при живой доставке (dead-* по-прежнему означают сломанную доставку)
  overall: 'ok' | 'degraded' | 'dead-reserve' | 'dead-hotpoll' | 'dead-both' | 'dead-ingest' | 'parser-stopped' | 'paused';
  subBlind: 'ok' | 'unconfirmed-subs';
  ingest: 'ok' | 'degraded' | 'dead'; reasons: string[]; // v9.33bm1k Task 4
  budget: 'ok' | 'degraded' | 'dead'; // bm1k-fix Task 3
  // ТЗ 2026-10-09 §3.1 req 5: какая именно цепь деградирована и почему. `overall` больше не вправе
  // показать 'ok', пока хотя бы одна цепь в degraded/coverage_gap.
  coverage: {
    level: 'ok' | 'degraded' | 'coverage_gap';
    chains: Array<{ chainId: string; level: 'ok' | 'degraded' | 'coverage_gap'; reasons: string[] }>;
    reasons: string[];
  };
};
const emptyCoverageVerdict = (): DeliveryHealthVerdict['coverage'] => ({ level: 'ok', chains: [], reasons: [] });
// ТЗ 2026-10-11 §4 (WP-H1): доказательство доставки по ОДНОЙ цепи за окно. Порог calls>20 —
// тот же «valid window», что и прежде (пороги не менялись): на меньшем числе вызовов правило молчит.
export type HotPollChainLevel = 'ok' | 'transport-failed' | 'no-source' | 'unknown';
export function hotPollChainEvidence(c: DeliveryHealthCtx['chains'][number]): HotPollChainLevel {
  if (c.windowReady !== true) return 'unknown';
  const calls = c.callsWindow ?? 0;
  const ok = c.getLogsOkWindow ?? 0;
  const neutral = c.getLogsNeutralWindow ?? 0;
  const failed = c.getLogsFailedWindow ?? 0;
  const skipped = c.getLogsSkippedWindow ?? 0;
  if (calls <= 20) return 'unknown'; // окно ещё не состоялось: первый сэмпл не доказывает отказ
  if (ok > 0 || neutral > 0) return 'ok'; // успешный или нейтральный ответ провайдера
  if (failed > 0) return 'transport-failed'; // попытки были, ни одного успеха
  if (skipped > 0) return 'no-source'; // попытки не было вовсе: все эндпоинты в кулдауне/капе
  return 'unknown'; // нули по всем классам: классифицированных исходов нет
}
export function deliveryHealthDecide(ctx: DeliveryHealthCtx): DeliveryHealthVerdict {
  // v9.32ak AK5: прод 14:36-15:01 — ручной stop читался как dead-hotpoll (ложный диагноз 25 мин).
  // Сокеты закрыты осознанно: reserve/hotPoll честно 'ok', overall = parser-stopped.
  if (ctx.running === false) return { reserve: 'ok', hotPoll: 'ok', overall: 'parser-stopped', subBlind: 'ok', ingest: 'ok', reasons: [], budget: 'ok', coverage: emptyCoverageVerdict() };
  // v9.32bm1i T6: пауза > 5 мин — состояние здоровья, а не тишина; каналы закрыты осознанно (как при stop) → 'ok'.
  // W2 T4 (spec X2): пауза объясняет тишину каналов, но НЕ обнуляет покрытие: per-chain coverage сохраняется.
  if (ctx.paused && ctx.now - ctx.paused.pausedAt > DELIVERY_PAUSED_VERDICT_MS) {
    const pausedCoverage = ctx.coverage ?? emptyCoverageVerdict();
    return { reserve: 'ok', hotPoll: 'ok', overall: 'paused', subBlind: 'ok', ingest: 'ok', reasons: pausedCoverage.reasons.map(r => 'coverage:' + r), budget: 'ok', coverage: pausedCoverage };
  }
  const reserveSubs = Math.max(ctx.reserveActiveSubs, ctx.reserveIntendedSubs ?? 0); // v9.32an-RC1: заявленные тоже считаются
  // v9.32bm1 S8 (S-AR15): прод 2026-09-09 — `reserveSubs > 100` был ложным конъюнктом при 0 подписках
  // (stopSolPubsub чистит state при любом разрыве) → «ok» при полной тишине. Порог → >0; отсутствие
  // подписок — не «ok», а ingestDown (S3). Дельта окна вместо кольца, если снапшот её дал.
  const notifDelta = ctx.reserveNotificationsWindow ?? ctx.reserveRingNotifications;
  const reserveDead = reserveSubs > 0
    && notifDelta === 0
    && (ctx.reserveLastNotificationAt === 0 || ctx.now - ctx.reserveLastNotificationAt > 5 * 60_000);
  // D4-R: a valid hotPoll window is judged by logical eth_getLogs outcomes, not by the
  // absence of event logs. `ok-empty` is a successful provider response and therefore quiet
  // activity, while `dead-hotpoll` is reserved for a valid window of all-failed calls.
  // A caller that has not supplied a valid window is deliberately not allowed to infer death
  // from cumulative zero activity: the first sample is not evidence of transport failure.
  //
  // ТЗ 2026-10-11 §4 (WP-H1): доказательство живости цепи — УСПЕШНЫЙ ОТВЕТ провайдера в окне
  // (success_with_logs / success_empty) либо нейтральный ответ ноды (диапазон слишком широкий,
  // история обрезана). `skipped` — это «попытки не было» (все эндпоинты в кулдауне/капе), оно НЕ
  // доказывает живость транспорта и больше не отменяет dead-вердикт по упавшим вызовам.
  // Прод 06:56Z: polygon callsWindow=550, ok=0, neutral=0, failed=550, skipped=14 — агрегат
  // показывал 'ok' ровно из-за этих 14 пропусков; 06:20–06:22Z та же цепь без пропусков в окне
  // давала глобальный 'dead-hotpoll' при четырёх активно доставляющих сетях.
  // ТЗ 2026-10-11 §2 (WP-H1b, code review P2): знаменатель aggregate-решения — КОНТРАКТНЫЙ список
  // обязательных EVM-цепей (EVM_WS_SUPPORTED_CHAINS — ровно тот, по которому startEvmHotPollers
  // поднимает поллер; в проде это ethereum/bsc/base/arbitrum/polygon), а НЕ только те цепи, для
  // которых случайно нашлась готовая проба в окне. Цепь контракта, отсутствующая во входе или без
  // пригодного окна (не набрано / stale / сброшено), — 'unknown': отсутствие доказательства
  // не является доказательством смерти (и не является доказательством живости).
  const presentChains = new Set(ctx.chains.map(c => c.chainId));
  const contractChains: DeliveryHealthCtx['chains'] = [
    ...ctx.chains,
    ...EVM_WS_SUPPORTED_CHAINS.filter(id => !presentChains.has(id)).map(id => ({
      chainId: id, calls: 0, logs: 0, lastLogAt: 0, windowReady: false,
      activity: 'unknown' as const,
    })),
  ];
  const hotPollChains = contractChains.map(c => ({ chainId: c.chainId, level: hotPollChainEvidence(c) }));
  // Цепи без единого доказательства доставки: отказ транспорта или отсутствие источника.
  const hotPollBad = hotPollChains.filter(c => c.level === 'transport-failed' || c.level === 'no-source');
  // 'unknown' в расчёт смерти не входит: «правилу нечего сказать» ≠ «цепь мертва». dead-hotpoll —
  // только когда КАЖДАЯ обязательная цепь контракта оценена и не имеет ни одного ответа провайдера.
  const hotPollDead = hotPollChains.length > 0
    && hotPollChains.every(c => c.level !== 'ok' && c.level !== 'unknown');
  // bm1k-fix Task 3: reserve = худший из (лимитер, dead-reserve, бюджет дня). Бюджет — worst-of через ранг: dead-budget (≥95 %)
  // и dead-reserve/dead равны по рангу, конкретный доставочный вердикт побеждает; degraded-budget поднимает только 'ok'.
  const budget = ctx.budget ? ctx.budget.level : 'ok';
  // bm1m Task 6 (решение владельца): лимитер → 'limited-reserve' ранга degraded, не 'dead' (прод 171d1941 s7→s11: 16 мин overall dead-reserve
  // при ingest ok и растущих нотификациях — ложная тревога). Намерение bm1k T6 сохранено: значение НЕ 'dead-reserve', поэтому
  // deliveryRecoveryTick (:13500) его не чинит — recoverSolReserve переоткрыл бы сокет, который лимитер закрыл осознанно.
  // Worst-of с бюджетом по рангу: dead-budget (≥95 %) выше limited-reserve; degraded-budget равен ему — доставочный вердикт конкретнее.
  const reserveBase = ctx.reserveStoppedBy ? 'limited-reserve' : reserveDead ? 'dead-reserve' : 'ok';
  const reserve = reserveBase === 'dead-reserve' ? reserveBase
    : budget === 'dead' ? 'dead-budget'
    : reserveBase === 'limited-reserve' ? reserveBase
    : budget === 'degraded' ? 'degraded-budget' : 'ok';
  // ТЗ 2026-10-11 §4 (WP-H1): 'dead-hotpoll' — доставка мертва у ВСЕХ оцениваемых цепей;
  // частичная слепота — 'degraded-hotpoll' (overall 'degraded'), и она видна per-chain в reasons.
  const hotPoll = hotPollDead ? 'dead-hotpoll' : hotPollBad.length > 0 ? 'degraded-hotpoll' : 'ok';
  // bm1k-fix2 Task 3: прод f978fe51 f2 — reserve 'dead-budget' при overall 'ok'. prevOverall собирался из сырых
  // reserveDead/hotPollDead, поэтому итоговый reserve (лимитер Task 6, бюджет bm1k-fix T3) в overall не попадал вовсе.
  // Теперь любой dead-* резерва равнозначен reserveDead; 'degraded-budget' (80 %) overall не красит — как degraded у ingest.
  const reserveDeadAny = reserveDead || reserve.startsWith('dead');
  // ТЗ 2026-10-11 §4 (WP-H1): 'degraded-hotpoll' поднимает overall до 'degraded' НЕ здесь, а на шаге
  // worst-of с ingest (см. overallBeforeCoverage) — иначе 'degraded' в prevOverall маскировал бы
  // 'dead-ingest' (тот поднимается только из 'ok'): слепая цепь hotPoll не должна прятать мёртвый канал.
  const prevOverall = reserveDeadAny && hotPollDead ? 'dead-both' : reserveDeadAny ? 'dead-reserve'
    : hotPollDead ? 'dead-hotpoll'
    : 'ok';
  // v9.33bm1k Task 4: overall = худший из (прежний overall, ingest). Прежние правила не переписаны — только новый вход.
  // dead-ingest поднимает overall лишь когда reserve/hotPoll молчат (иначе их вердикт уже не 'ok' и он конкретнее);
  // degraded в overall не входит (виден в ingest/reasons) — иначе ложные dead-* на старте (ср. H5).
  const ingest = ctx.ingest ? ctx.ingest.level : 'ok';
  // W3 T6 (spec X1): причины покрытия — в агрегатном reasons (формат coverage:<chain>: <reason>); только добавление.
  // W2 T4 (D-W2-1): незакрытый gap интервала паузы поднимает coverage из ok до degraded, пока виден (24 ч); dead-* не затрагивается.
  const pauseGapReasons = [...new Set((ctx.pauseGaps ?? []).map(g => `pause-gap:${g.source}`))];
  const baseCoverage = ctx.coverage ?? emptyCoverageVerdict();
  const coverage = pauseGapReasons.length === 0 ? baseCoverage : {
    ...baseCoverage,
    level: baseCoverage.level === 'ok' ? 'degraded' as const : baseCoverage.level,
    reasons: [...baseCoverage.reasons, ...pauseGapReasons],
  };
  // ТЗ 2026-10-11 §4 (WP-H1): per-chain причины hotPoll — только добавление, формат как у coverage:i.
  // ТЗ 2026-10-11 §2.3 (WP-H1b): при частичной деградации причина-«unknown» показывается отдельно от
  // установленного отказа (различие «проблема» / «неизвестно» не теряется). Если проблемных цепей нет
  // вовсе — неизвестность причин не порождает: иначе каждый старт бомбардировал бы статус шумом.
  const hotPollUnknownReasons = hotPollBad.length > 0
    ? hotPollChains.filter(c => c.level === 'unknown').map(c => 'hotpoll:' + c.chainId + ': unknown')
    : [];
  const reasons = [...(ctx.ingest ? ctx.ingest.reasons : []), ...(ctx.budget ? ctx.budget.reasons : []), ...coverage.reasons.map(r => 'coverage:' + r), ...hotPollBad.map(c => 'hotpoll:' + c.chainId + ': ' + c.level), ...hotPollUnknownReasons];
  // bm1m Task 6: limited-reserve красит overall в 'degraded' только когда ничто не dead (dead-* конкретнее и старше по рангу).
  const overallBeforeCoverage = prevOverall === 'ok' && INGEST_LEVEL_RANK[ingest] >= INGEST_LEVEL_RANK.dead ? 'dead-ingest'
    : prevOverall === 'ok' && (reserve === 'limited-reserve' || hotPoll === 'degraded-hotpoll') ? 'degraded' : prevOverall;
  // ТЗ 2026-10-09 §3.1 req 5: per-chain coverage gap/деградация не маскируется общим 'ok'.
  // dead-* остаётся конкретнее и старше по рангу — coverage поднимает overall только из 'ok'.
  const overall = overallBeforeCoverage === 'ok' && coverage.level !== 'ok' ? 'degraded' : overallBeforeCoverage;
  // v9.32bm1g H5: прод 2026-09-10 — subsIntended 6 / subsConfirmed 0 / reserveAgeMs −1 при overall 'ok':
  // overall меряет доставку (нотификации/логи), а «заявлено, но ничего не подтверждено» на живом сокете
  // дольше грейса — отдельный вердикт. В overall НЕ входит намеренно (иначе снова ложные dead-* на старте).
  const socketAge = ctx.socketAgeMs ?? -1;
  const subBlind = (ctx.reserveIntendedSubs ?? 0) > 0 && ctx.reserveActiveSubs === 0 && socketAge > DELIVERY_SUB_BLIND_GRACE_MS ? 'unconfirmed-subs' : 'ok';
  return { reserve, hotPoll, overall, subBlind, ingest, reasons, budget, coverage };
}
// v9.33bm1k Task 4: пороги правила ingest — env с дефолтами Global Constraints; гистерезис — модульное состояние (один на процесс).
const INGEST_STALE_MS = () => Number(process.env.INGEST_STALE_MS || '300000');
const INGEST_DEAD_MS = () => Number(process.env.INGEST_DEAD_MS || '900000');
const HEALTH_RECOVER_SAMPLES = () => Math.max(1, Number(process.env.HEALTH_RECOVER_SAMPLES || '2'));
const INGEST_DOWN_GRACE_MS = () => Number(process.env.INGEST_DOWN_GRACE_MS || String(INGEST_DOWN_GRACE_MS_DEFAULT)); // bm1k-fix2 вариант А
let ingestHysteresis: HealthHysteresis | null = null;
let lastIngestLevel: 'ok' | 'degraded' | 'dead' = 'ok';
// Базовая точка возраста событий: max(lastEventAt, startedAt, resumedAt). Иначе первый снапшот после старта/резюма читал бы
// возраст «с прошлого боота» (lastEventAt 0 или трёхчасовой давности) и правило давало dead до того, как канал успел бы ожить.
let parserResumedAt = 0;
function ingestEventBaseline(): number | null {
  const sol = state.chain['solana'];
  const t = Math.max(sol && sol.lastEventAt > 0 ? sol.lastEventAt : 0, state.startedAt || 0, parserResumedAt);
  return t > 0 ? t : null;
}
// bm1m Task 2 (rootcause Q3): отметка последнего реконнекта для грейса subs-collapsed. Одного solPubsubReconnect.openedAt мало —
// noteSolPubsubDisconnect обнуляет его (:13224), и в закрытой фазе (весь backoff 3–300 с) грейс не покрыл бы ровно тот интервал,
// ради которого вводится. Открытый сокет ⇒ отсчёт от 'open'; закрытый ⇒ от обрыва (lastDisconnectAt). Ни того ни другого ⇒ −1.
function ingestMsSinceReconnect(now: number): number {
  const since = Math.max(solPubsubReconnect.openedAt > 0 ? solPubsubReconnect.openedAt : 0, solPubsubMetrics.lastDisconnectAt > 0 ? solPubsubMetrics.lastDisconnectAt : 0);
  if (!(since > 0)) return -1;
  const ms = now - since;
  return ms >= 0 ? ms : -1; // отметка «из будущего» (рассинхрон часов) грейса не даёт
}
function ingestHealthEvaluate(now: number, subsIntended: number): IngestVerdict {
  const h = ingestHysteresis ?? (ingestHysteresis = new HealthHysteresis(HEALTH_RECOVER_SAMPLES()));
  const sol = state.chain['solana'];
  const lv = solPubsubLiveness(now);
  const raw = evaluateIngest({
    ingestDown: lv.ingestDown,
    lastEventAt: ingestEventBaseline(),
    subsIntended,
    watchPoolsSolana: sol?.enabled ? sol.watchPools.length : 0,
    ingestDownSinceMs: lv.ingestDownSinceMs, // bm1k-fix2 (вариант А)
    msSinceReconnect: ingestMsSinceReconnect(now), // bm1m Task 2
  }, now, { staleMs: INGEST_STALE_MS(), deadMs: INGEST_DEAD_MS(), downGraceMs: INGEST_DOWN_GRACE_MS() });
  const held = h.apply(raw);
  if (held.level !== lastIngestLevel) { // одна строка на переход (паттерн H5 :14698)
    // bm1k-fix2 Task 5: уровень по целевому состоянию — dead в errors[], degraded/ok только в лог.
    // Прод f978fe51: 2 ERROR из 2 за 5 мин боота, из них один — обычное колебание подписок.
    const msg = `[delivery] ingest ${lastIngestLevel}→${held.level}: ${held.reasons.join(',') || '—'} intended=${subsIntended} watchPools=${sol?.watchPools.length ?? 0} lastEventAge=${sol && sol.lastEventAt > 0 ? Math.round((now - sol.lastEventAt) / 1000) + 's' : 'never'} ingestDown=${lv.ingestDown} sinceReconnect=${ingestMsSinceReconnect(now) >= 0 ? Math.round(ingestMsSinceReconnect(now) / 1000) + 's' : 'never'}`;
    if (held.level === 'dead') addError(msg); else log(`${held.level === 'ok' ? 'INFO' : 'WARN'} ${msg}`);
    lastIngestLevel = held.level;
  }
  return held;
}
export function __resetIngestHealthForTest(): void { ingestHysteresis = null; lastIngestLevel = 'ok'; parserResumedAt = 0; }
// bm1k-fix Task 3: дневной бюджет Helius → вердикт. Пороги env HELIUS_BUDGET_WARN_PCT 80 / HELIUS_BUDGET_DEAD_PCT 95; кап —
// тот же HELIUS_CU_DAY_MAX (не меняется). Гистерезис — тот же класс, что у ingest (bm1k T3), отдельный экземпляр.
const HELIUS_BUDGET_WARN_PCT = () => Number(process.env.HELIUS_BUDGET_WARN_PCT || '80');
const HELIUS_BUDGET_DEAD_PCT = () => Number(process.env.HELIUS_BUDGET_DEAD_PCT || '95');
let budgetHysteresis: HealthHysteresis<BudgetVerdict> | null = null;
let lastBudgetLevel: 'ok' | 'degraded' | 'dead' = 'ok';
function budgetHealthEvaluate(): BudgetVerdict {
  const h = budgetHysteresis ?? (budgetHysteresis = new HealthHysteresis<BudgetVerdict>(HEALTH_RECOVER_SAMPLES()));
  const sp = __heliusSpendForTest();
  const held = h.apply(evaluateBudget({ today: sp.today, cap: sp.cap }, { warnPct: HELIUS_BUDGET_WARN_PCT(), deadPct: HELIUS_BUDGET_DEAD_PCT() }));
  if (held.level !== lastBudgetLevel) { // одна строка на переход (паттерн H5)
    const m = heliusCuMeter();
    addError(`[delivery] helius-budget ${lastBudgetLevel}→${held.level}: ${Math.floor(held.pct)}% дня (${sp.today}/${sp.cap} CU, ключ ${sp.keyFingerprint}), ${forecastText(m.perMin(), sp.cap - sp.today)}`);
    lastBudgetLevel = held.level;
  }
  return held;
}
export function __resetBudgetHealthForTest(): void { budgetHysteresis = null; lastBudgetLevel = 'ok'; }
export function __noteParserResumedForTest(ms: number): void { parserResumedAt = ms; }
export function __setChainLastEventAtForTest(chainId: string, ms: number): void { const st = state.chain[chainId]; if (st) st.lastEventAt = ms; }
let lastDeliveryWarnAt = 0;
let lastDeliverySubBlind: 'ok' | 'unconfirmed-subs' = 'ok'; // v9.32bm1g H5: одна строка errors на переход
// v9.32bm1 S8 (S-AR16): prev-поля для дельт окна. Снапшот кладёт пробу кумулятивов (не чаще раза в
// 15 с); дельта окна = текущий кумулятив − проба возрастом ≥ DELIVERY_HEALTH_WINDOW_MS (5 мин).
// Проба старше 2× окна не годится (дельта покрывала бы «когда-то давно») — тогда поля окна не
// передаются и действует кумулятивное/кольцевое правило. Первые 5 мин после старта — то же.
const DELIVERY_HEALTH_WINDOW_MS = () => Number(process.env.DELIVERY_HEALTH_WINDOW_MS || '300000');
const DELIVERY_HEALTH_SAMPLE_MIN_GAP_MS = 15_000;
const DELIVERY_HEALTH_SAMPLES_MAX = 64;
type DeliveryHealthSample = {
  at: number; calls: number; logs: number;
  getLogsOk: number; getLogsEmpty: number; getLogsFailed: number;
  getLogsNeutral: number; getLogsSkipped: number;
  /** W3 T5: кумулятивные исходы getLogs на момент пробы — для дельты окна в правиле покрытия. */
  outcomes?: Partial<Record<GetLogsOutcome, number>>;
};
const deliveryHealthWindow = {
  chains: {} as Record<string, DeliveryHealthSample[]>,
  reserve: [] as Array<{ at: number; n: number }>,
  reserveNotificationsTotal: 0,
};
export function __resetDeliveryHealthWindowForTest(): void {
  lastDeliveryWarnAt = 0; lastDeliverySubBlind = 'ok';
  deliveryHealthWindow.chains = {}; deliveryHealthWindow.reserve = []; deliveryHealthWindow.reserveNotificationsTotal = 0;
  lastDeliveryHealthCtx = null;
}
function deliveryHealthPickBase<T extends { at: number }>(samples: T[], now: number, windowMs: number): T | null {
  // новейшая проба возрастом ≥ окна, но не старше 2× окна
  let best: T | null = null;
  for (const smp of samples) { const age = now - smp.at; if (age >= windowMs && age <= 2 * windowMs && (!best || smp.at > best.at)) best = smp; }
  return best;
}
function deliveryHealthPush<T extends { at: number }>(samples: T[], smp: T): void {
  const last = samples[samples.length - 1];
  if (last && smp.at - last.at < DELIVERY_HEALTH_SAMPLE_MIN_GAP_MS) return;
  samples.push(smp);
  if (samples.length > DELIVERY_HEALTH_SAMPLES_MAX) samples.splice(0, samples.length - DELIVERY_HEALTH_SAMPLES_MAX);
}
// v9.32bm1c T7: имена ключей — по контракту ТЗ bm1 S8 (subsActive/subsConfirmed/subsIntended/
// notificationsRing/notificationsWindow); прежние reserve*-имена были расхождением с ТЗ.
export type DeliveryHealthStatusCtx = {
  subsActive: number; subsConfirmed: number; subsIntended: number; notificationsRing: number;
  notificationsWindow: number | null; reserveAgeMs: number; windowMs: number;
  chains: Array<{
    chainId: string; calls: number; logs: number; lastLogAgeMs: number;
    callsWindow: number | null; logsWindow: number | null; windowMs: number | null;
    windowReady: boolean;
    getLogsOkWindow: number | null; getLogsEmptyWindow: number | null;
    getLogsFailedWindow: number | null; getLogsNeutralWindow: number | null;
    getLogsSkippedWindow: number | null;
    activity: 'active' | 'quiet' | 'transport-failed' | 'unknown';
    // ТЗ 2026-10-09 §3.1 req 5: per-chain вердикт покрытия и число открытых дыр (единица — range).
    coverageVerdict: 'ok' | 'degraded' | 'coverage_gap';
    coverageReasons: string[];
  }>;
};
let lastDeliveryHealthCtx: DeliveryHealthStatusCtx | null = null;
function deliveryHealthWindowSample(now: number): number {
  const w = deliveryHealthWindow;
  for (const chainId of Object.keys(evmHotPollMetrics)) {
    const m = evmHotPollMetrics[chainId];
    deliveryHealthPush(w.chains[chainId] || (w.chains[chainId] = []), {
      at: now, calls: m.calls, logs: m.logs,
      getLogsOk: m.getLogsOk, getLogsEmpty: m.getLogsEmpty,
      getLogsFailed: m.getLogsFailed, getLogsNeutral: m.getLogsNeutral,
      getLogsSkipped: m.getLogsSkipped,
      outcomes: { ...(evmHotOutcomeCounts[chainId] || {}) },
    });
  }
  deliveryHealthPush(w.reserve, { at: now, n: w.reserveNotificationsTotal });
  return DELIVERY_HEALTH_WINDOW_MS();
}
function deliveryHealthActivity(
  base: DeliveryHealthSample | null,
  now: number,
  current: EvmHotPollMetrics,
): {
  ready: boolean; calls: number; logs: number; ok: number; empty: number; failed: number; neutral: number; skipped: number;
  activity: 'active' | 'quiet' | 'transport-failed' | 'unknown'; windowMs: number | null;
} {
  if (!base) return { ready: false, calls: 0, logs: 0, ok: 0, empty: 0, failed: 0, neutral: 0, skipped: 0, activity: 'unknown', windowMs: null };
  // ТЗ 2026-10-11 §2 (WP-H1b, review P2): откат накопительного счётчика (сброс метрик, рестарт,
  // rollover) обесценивает окно: дельта из отрицательных компонент — не наблюдение и не может
  // доказывать смерть цепи. Такое окно честно 'unknown' (не 'transport-failed' и не 'no-source').
  if (current.calls < base.calls || current.logs < base.logs
      || current.getLogsOk < base.getLogsOk || current.getLogsEmpty < base.getLogsEmpty
      || current.getLogsFailed < base.getLogsFailed || current.getLogsNeutral < base.getLogsNeutral
      || current.getLogsSkipped < base.getLogsSkipped) {
    return { ready: false, calls: 0, logs: 0, ok: 0, empty: 0, failed: 0, neutral: 0, skipped: 0, activity: 'unknown', windowMs: null };
  }
  const calls = Math.max(0, current.calls - base.calls);
  const logs = Math.max(0, current.logs - base.logs);
  const ok = Math.max(0, current.getLogsOk - base.getLogsOk);
  const empty = Math.max(0, current.getLogsEmpty - base.getLogsEmpty);
  const failed = Math.max(0, current.getLogsFailed - base.getLogsFailed);
  const neutral = Math.max(0, current.getLogsNeutral - base.getLogsNeutral);
  const skipped = Math.max(0, current.getLogsSkipped - base.getLogsSkipped);
  const activity = logs > 0 ? 'active'
    : ok > 0 || neutral > 0 ? 'quiet'
    // ТЗ 2026-10-11 §4 (WP-H1): классификация по доказательству, а не по счётчику пропусков.
    // Раньше `skipped > 0` давал 'quiet' даже когда в окне не было НИ ОДНОГО успешного ответа
    // (прод 06:56Z: polygon callsWindow=550, failed=550, ok=0, skipped=14 → 'quiet'). Пропуск —
    // это «попытки не было», он не доказывает живость транспорта, поэтому в 'quiet' не входит.
    : failed > 0 && calls > 20 ? 'transport-failed'
    : 'unknown'; // в т.ч. skipped-only: источник недоступен, но транспорт не проверялся
  return { ready: true, calls, logs, ok, empty, failed, neutral, skipped, activity, windowMs: now - base.at };
}
// ТЗ 2026-10-09 §3.1 req 5: вход правила покрытия — журнал дыр + окно исходов по каждой цепи.
// W3 T5 (spec X3): исходы getLogs для правила — дельта кумулятива по тому же скользящему окну, что и getLogs*Window.
// Нет пробы подходящего возраста (первые минуты после старта / после паузы) — окно пустое: правило молчит,
// а не читает накопленное с boot (давний rate_limited не должен держать цепь degraded вечно).
function evmOutcomeWindowDelta(chainId: string, now: number, windowMs: number): Partial<Record<GetLogsOutcome, number>> {
  const base = deliveryHealthPickBase(deliveryHealthWindow.chains[chainId] || [], now, windowMs);
  if (!base?.outcomes) return {};
  const cur = evmHotOutcomeCounts[chainId] || {};
  const out: Partial<Record<GetLogsOutcome, number>> = {};
  for (const k of Object.keys(cur) as GetLogsOutcome[]) {
    const d = (cur[k] || 0) - (base.outcomes[k] || 0);
    if (d > 0) out[k] = d;
  }
  return out;
}
function evmCoverageVerdictNow(now = Date.now(), windowMs = DELIVERY_HEALTH_WINDOW_MS()): EvmCoverageVerdict {
  const chains = Object.keys(evmHotPollMetrics).map(chainId => {
    const m = evmHotPollMetrics[chainId];
    const snap = evmHotCoverage.snapshotAt(chainId, hotPollNow());
    return {
      chainId,
      verdict: snap.verdict,
      openGaps: snap.gaps.length, // W3 T1: evicted — число слияний, границы уже в агрегате gaps
      outcomeWindow: evmOutcomeWindowDelta(chainId, now, windowMs),
      blind: (m.endpointsCooled?.length ?? 0) > 0 && (m.getLogsOk + m.getLogsNeutral) === 0,
    };
  });
  return evaluateEvmCoverage(chains);
}
export function __deliveryHealthSnapshotForTest(now = Date.now()): ReturnType<typeof deliveryHealthSnapshot> { return deliveryHealthSnapshot(now); }
export function __setEvmHotPollBlindForTest(o: Record<string, { blindMs: number; blindSince: number }>): void {
  for (const [chainId, v] of Object.entries(o)) {
    const m = evmHotM(chainId); // единственный владелец инициализатора EvmHotPollMetrics
    m.blindMs = v.blindMs; m.blindSince = v.blindSince;
  }
}
export function __setRunningForTest(b: boolean): void { state.running = b; }
export function deliveryHealthSnapshot(now = Date.now()): ReturnType<typeof deliveryHealthDecide> & { checkedAt: number; ctx: DeliveryHealthStatusCtx; hotPollBlindMin: number; hotPollBlindMinByChain: Record<string, number> } {
  getPauseLedger().tick(now); // W2 T3: open → unknown по PAUSE_SETTLE_MAX_MS
  const ring = solReserveDiagSnapshot();
  const windowMs = deliveryHealthWindowSample(now);
  const w = deliveryHealthWindow;
  const chains = Object.keys(evmHotPollMetrics).map(chainId => {
    const m = evmHotPollMetrics[chainId];
    const base = deliveryHealthPickBase(w.chains[chainId] || [], now, windowMs);
    const activity = deliveryHealthActivity(base, now, m);
    return {
      chainId, calls: m.calls, logs: m.logs, lastLogAt: m.lastLogAt,
      windowReady: activity.ready,
      callsWindow: activity.ready ? activity.calls : undefined,
      logsWindow: activity.ready ? activity.logs : undefined,
      windowMs: activity.windowMs ?? undefined,
      getLogsOkWindow: activity.ready ? activity.ok : undefined,
      getLogsEmptyWindow: activity.ready ? activity.empty : undefined,
      getLogsFailedWindow: activity.ready ? activity.failed : undefined,
      getLogsNeutralWindow: activity.ready ? activity.neutral : undefined,
      getLogsSkippedWindow: activity.ready ? activity.skipped : undefined,
      activity: activity.activity,
    };
  });
  const rBase = deliveryHealthPickBase(w.reserve, now, windowMs);
  const reserveNotificationsWindow = rBase ? Math.max(0, w.reserveNotificationsTotal - rBase.n) : undefined;
  const reserveIntendedSubs = Math.max(solReserveActiveAccount.size, solReserveState.size); // v9.32an-RC1
  const verdict = deliveryHealthDecide({
    now,
    running: state.running, // v9.32ak AK5
    reserveActiveSubs: solReserveActiveAccount.size,
    reserveIntendedSubs, // v9.32an-RC1: боевой путь умеет «отправлено-но-не-подтверждено»
    reserveRingNotifications: ring.notifications,
    reserveLastNotificationAt: solReserveMetrics.lastNotificationAt,
    reserveNotificationsWindow, // v9.32bm1 S8
    chains,
    paused: state.paused ? { pausedAt: state.pausedAt, dropped: pausedStats.dropped } : null, // v9.32bm1i T6
    socketAgeMs: solPubsubMetrics.connected && solPubsubReconnect.openedAt > 0 ? Math.max(0, now - solPubsubReconnect.openedAt) : -1, // v9.32bm1g H5
    // v9.33bm1k Task 4: при stop/паузе правило не оценивается (сокеты закрыты осознанно) — иначе гистерезис удержал бы dead после resume
    // startedAt > 0 — парсер реально стартовал (в тестах __setRunningForTest без startParser оставляет 0: базы возраста нет).
    ingest: state.running && (state.startedAt || 0) > 0 && !(state.paused && now - state.pausedAt > DELIVERY_PAUSED_VERDICT_MS) ? ingestHealthEvaluate(now, reserveIntendedSubs) : null,
    reserveStoppedBy: heliusReserveLimiter.stoppedBy, // v9.33bm1k Task 6
    budget: budgetHealthEvaluate(), // bm1k-fix Task 3
    // ТЗ 2026-10-09 §3.1 req 5: per-chain покрытие eth_getLogs. Без этого входа overall='ok'
    // маскировал polygon (getLogsFailed=258) и base (getLogsFailed=805) — правило hotPollDead
    // гаснет при любом ненулевом ok/neutral/skipped, а per-chain состояния в вердикте не было.
    coverage: evmCoverageVerdictNow(now, windowMs),
    pauseGaps: getPauseLedger().visibleGaps(now).map(g => ({ source: g.source, outcome: g.outcome as 'coverage_gap' | 'unknown', detail: g.detail })), // W2 T4
  });
  if (verdict.subBlind !== lastDeliverySubBlind) { // v9.32bm1g H5: переход состояния — одна строка, без минутного спама
    addError(`[delivery] subBlind ${lastDeliverySubBlind}→${verdict.subBlind}: intended=${reserveIntendedSubs} confirmed=${solReserveActiveAccount.size} socketAge=${solPubsubMetrics.connected && solPubsubReconnect.openedAt > 0 ? Math.round((now - solPubsubReconnect.openedAt) / 1000) + 's' : 'closed'}`);
    lastDeliverySubBlind = verdict.subBlind;
  }
  const ctx: DeliveryHealthStatusCtx = {
    subsActive: Math.max(solReserveActiveAccount.size, reserveIntendedSubs), subsConfirmed: solReserveActiveAccount.size, subsIntended: reserveIntendedSubs,
    notificationsRing: ring.notifications, notificationsWindow: reserveNotificationsWindow ?? null,
    reserveAgeMs: solReserveMetrics.lastNotificationAt ? now - solReserveMetrics.lastNotificationAt : -1, windowMs,
    chains: [
      ...chains.map(c => ({
        chainId: c.chainId, calls: c.calls, logs: c.logs,
        lastLogAgeMs: c.lastLogAt ? now - c.lastLogAt : -1,
        callsWindow: c.callsWindow ?? null, logsWindow: c.logsWindow ?? null, windowMs: c.windowMs ?? null,
        windowReady: c.windowReady,
        getLogsOkWindow: c.getLogsOkWindow ?? null,
        getLogsEmptyWindow: c.getLogsEmptyWindow ?? null,
        getLogsFailedWindow: c.getLogsFailedWindow ?? null,
        getLogsNeutralWindow: c.getLogsNeutralWindow ?? null,
        getLogsSkippedWindow: c.getLogsSkippedWindow ?? null,
        activity: c.activity,
        // ТЗ §3.1 req 5: вердикт покрытия именно этой цепи — видно «где слепо», а не только « overall ok».
        coverageVerdict: verdict.coverage.chains.find(x => x.chainId === c.chainId)?.level ?? 'ok',
        coverageReasons: verdict.coverage.chains.find(x => x.chainId === c.chainId)?.reasons ?? [],
      })),
      // ТЗ 2026-10-11 §2 (WP-H1b, review P2): обязательная цепь контракта без проб/метрик остаётся
      // в контексте как not-ready ('unknown'), а не исчезает из знаменателя молча. Пригодное окно
      // такая цепь получит, когда появится базовая проба (deliveryHealthWindowSample).
      ...EVM_WS_SUPPORTED_CHAINS.filter(id => !chains.some(c => c.chainId === id)).map(id => ({
        chainId: id, calls: 0, logs: 0, lastLogAgeMs: -1,
        callsWindow: null, logsWindow: null, windowMs: null, windowReady: false,
        getLogsOkWindow: null, getLogsEmptyWindow: null, getLogsFailedWindow: null,
        getLogsNeutralWindow: null, getLogsSkippedWindow: null, activity: 'unknown' as const,
        coverageVerdict: verdict.coverage.chains.find(x => x.chainId === id)?.level ?? 'ok' as const,
        coverageReasons: verdict.coverage.chains.find(x => x.chainId === id)?.reasons ?? [],
      })),
    ],
  };
  lastDeliveryHealthCtx = ctx;
  // v9.32bm1 S8: суммарная слепота hotPoll (blindMs + текущий blindSince) в минутах — по всем сетям
  // v9.32bm1c T7 (F5): плюс разбивка по сетям — скаляр не отвечал «где слепо» (прод: 19.6 и 29.4 мин).
  const hotPollBlindMinByChain: Record<string, number> = {};
  for (const [chainId, m] of Object.entries(evmHotPollMetrics)) {
    hotPollBlindMinByChain[chainId] = Math.round(((m.blindMs || 0) + (m.blindSince ? Math.max(0, now - m.blindSince) : 0)) / 60000 * 10) / 10;
  }
  const hotPollBlindMin = Math.round(Object.values(evmHotPollMetrics).reduce((acc, m) => acc + (m.blindMs || 0) + (m.blindSince ? Math.max(0, now - m.blindSince) : 0), 0) / 60000 * 10) / 10;
  if (verdict.overall === 'parser-stopped') {
    if (now - lastDeliveryWarnAt > 60_000) {
      lastDeliveryWarnAt = now;
      log('[delivery] WARN parser-stopped: парсер остановлен (ручной stop?) — сокеты закрыты осознанно, это NOT dead-каналы');
    }
    return { ...verdict, checkedAt: now, ctx, hotPollBlindMin, hotPollBlindMinByChain };
  }
  if (verdict.overall === 'paused') { // v9.32bm1i T6
    if (now - lastDeliveryWarnAt > 60_000) {
      lastDeliveryWarnAt = now;
      log(`[delivery] WARN paused: парсер на паузе ${Math.round((now - state.pausedAt) / 60_000)} мин, pausedDropped=${pausedStats.dropped} — сокеты закрыты осознанно, это NOT dead-каналы; resume: POST /api/parser/resume`);
    }
    return { ...verdict, checkedAt: now, ctx, hotPollBlindMin, hotPollBlindMinByChain };
  }
  if (verdict.overall !== 'ok' && now - lastDeliveryWarnAt > 60_000) {
    lastDeliveryWarnAt = now;
    log(`[delivery] WARN ${verdict.overall}: reserve confirmed=${solReserveActiveAccount.size} intended=${reserveIntendedSubs} ringNotifications=${ring.notifications} windowNotifications=${reserveNotificationsWindow ?? 'n/a'} lastNotificationAt=${solReserveMetrics.lastNotificationAt ? Math.round((now - solReserveMetrics.lastNotificationAt) / 1000) + 's ago' : 'never'}; hotPoll chains=${JSON.stringify(chains.map(c => ({ c: c.chainId, calls: c.calls, logs: c.logs, cw: c.callsWindow ?? null, lw: c.logsWindow ?? null })))}`); // v9.32an-RC1 + v9.32bm1 S8: окна в причинах
  }
  return { ...verdict, checkedAt: now, ctx, hotPollBlindMin, hotPollBlindMinByChain };
}
export function __lastDeliveryHealthCtxForTest(): DeliveryHealthStatusCtx | null { return lastDeliveryHealthCtx; }
// Handle a reserve account data change: pair base+quote deltas and emit a swap on opposite signs.


// ─────────────── EVM Alchemy WebSocket Real-time (v9.29) ───────────────
// EVM эквивалент Solana pubsub (v9.26 F2): подписка на логи смарт-контрактов пулов через
// `eth_subscribe("logs", {"address": poolAddr})` на Alchemy WebSocket. Каждый лог несёт реальный
// on-chain tx hash — в отличие от DexPaprika SSE (dp-r-… synthetic). При нотификации вызывается
// analyzeEvmTx (через Alchemy HTTP для гарантированной доступности) → processExtractedEvent →
// enrich → алерт. Без ключа (ALCHEMY_API_KEY) — graceful degradation (ничего не запускается).

let ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY || '';
export function formatAlchemyKeyLog(key: string): string {
  const k = String(key || '');
  if (!k) return '[evmws] alchemy key: NOT SET';
  return `[evmws] alchemy key ${k.slice(0, 6)}… (${k.length} chars)`;
}
export function logAlchemyKeyFingerprint(key: string = ALCHEMY_API_KEY): void {
  log(formatAlchemyKeyLog(key));
}
const ALCHEMY_NETWORKS: Record<string, string> = {
  ethereum: 'eth-mainnet',
  bsc: 'bnb-mainnet',
  base: 'base-mainnet',
  arbitrum: 'arb-mainnet',
  polygon: 'polygon-mainnet',
};
// v9.32bb BB4: Alchemy только на сетях, включённых в приложении. Default ethereum,base —
// bsc жрал 62% WS (Finding 5), arb/polygon 403 (сеть не включена в Alchemy App).
function evmAlchemyChainSet(): Set<string> {
  // v9.32bf BF1 (AR1): доставка WS-кадров — 82 % расхода Alchemy (8 001 кадр × 15 CU =
  // 120 015 CU против 26 979 CU всех явных вызовов). Дефолт пуст: ethereum и base уходят
  // на publicnode-WSS + hot-poll. Включение обратно — только явным EVM_ALCHEMY_CHAINS.
  const raw = process.env.EVM_ALCHEMY_CHAINS;
  const s = raw == null ? '' : String(raw);
  return new Set(s.split(',').map(x => x.trim().toLowerCase()).filter(Boolean));
}
function alchemyChainEnabled(chainId: string): boolean {
  return evmAlchemyChainSet().has(chainId);
}
// b3: REST-резерв отдельно от WS — включаем только то, что может остановить дневной кап b2.
function evmAlchemyHttpChainSet(): Set<string> {
  const raw = process.env.EVM_ALCHEMY_HTTP_CHAINS;
  const s = raw == null ? '' : String(raw);
  return new Set(s.split(',').map(x => x.trim().toLowerCase()).filter(Boolean));
}
function alchemyHttpEnabled(chainId: string): boolean {
  return alchemyChainEnabled(chainId) || evmAlchemyHttpChainSet().has(chainId);
}
function alchemyWsUrl(chainId: string): string {
  if (!alchemyChainEnabled(chainId)) return '';
  const slug = ALCHEMY_NETWORKS[chainId];
  return slug && ALCHEMY_API_KEY ? `wss://${slug}.g.alchemy.com/v2/${ALCHEMY_API_KEY}` : '';
}
const PUBLICNODE_WS: Record<string, string> = {
  ethereum: 'wss://ethereum-rpc.publicnode.com',
  bsc: 'wss://bsc-rpc.publicnode.com',
  base: 'wss://base-rpc.publicnode.com',
  arbitrum: 'wss://arbitrum-one-rpc.publicnode.com',
  polygon: 'wss://polygon-bor-rpc.publicnode.com',
};
function evmWsUrl(chainId: string): string {
  return alchemyWsUrl(chainId) || PUBLICNODE_WS[chainId] || '';
}
export function __alchemyWsUrlForTest(chainId: string): string { return alchemyWsUrl(chainId); }
export function __alchemyHttpUrlForTest(chainId: string): string { return alchemyHttpUrl(chainId); }
export function __evmWsUrlForTest(chainId: string): string { return evmWsUrl(chainId); }
// B4.5: all mutable Alchemy accounting belongs to the kernel's EVM runtime.
const evmRuntime = getEvmRuntime();
function makeEvmRuntimeDeps(): EvmRuntimeDeps {
  return {
    initialFlushAt: Date.now(),
    readAnalyzeDefaults: () => ({ fallback: EVM_WS_ANALYZE_PER_MIN, maximum: EVM_WS_ANALYZE_CAP_MAX }),
    clock: {
      now: () => Date.now(), windowNow: () => hotPollNow(),
      dayKey: () => new Date().toISOString().slice(0, 10),
      setTimeout: (callback, ms) => setTimeout(callback, ms),
      clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
      setInterval: (callback, ms) => setInterval(callback, ms),
      clearInterval: handle => clearInterval(handle as ReturnType<typeof setInterval>),
      unref: handle => (handle as { unref?: () => void }).unref?.(),
    },
    env: name => process.env[name],
    keyFingerprint: () => keyFingerprint(ALCHEMY_API_KEY),
    createSpendStore: createAlchemySpendStore,
    onShutdown: callback => { process.once('SIGTERM', callback); return () => { process.removeListener('SIGTERM', callback); }; },
  };
}
function alchemyCuCost(method: string): number { return evmRuntime.spend.cost(method); }
function alchemyCuSourceOf(method: string): AlchemyCuSourceKey { return evmRuntime.spend.sourceOf(method); }
function alchemyCuGate(cu: number, opts?: { solo?: boolean; src?: AlchemyCuSourceKey }): Promise<boolean> { return evmRuntime.spend.gate(cu, opts); }
function noteAlchemyHttpStatus(status: number): void { evmRuntime.spend.noteHttpStatus(status); }
function alchemyCuPerSecNow(): number { return evmRuntime.spend.window().perSec; }
function alchemyCuSourceWindow(): Record<AlchemyCuSourceKey, number> { return evmRuntime.spend.window().window; }
export function __alchemySpendForTest() { return evmRuntime.spend.money(); }
export function __resetAlchemySpendForTest(): void { evmRuntime.test.resetMoney(); }
export function __setAlchemySpendStoreForTest(s: AlchemySpendStore | null): void { evmRuntime.test.setStore(s); }
export function __setAlchemySpendDayKeyForTest(dayKey: string): void { evmRuntime.test.setDayKey(dayKey); }
export function __bumpAlchemySpendForTest(cu: number, src: AlchemyCuSourceKey = 'other'): void { evmRuntime.test.bumpMoney(cu, src); }
export async function __reloadAlchemySpendDayForTest(force = true): Promise<void> { await evmRuntime.test.reloadDay(force); }
export function __alchemyCuSpendForTest() { return evmRuntime.spend.window(); }
export function __resetAlchemyCuSpendForTest(): void { evmRuntime.test.resetWindow(); }
export function __wsBilledPublicForTest() { return { ...wsBilledPublic }; }
export function __billAlchemyWsFrameForTest(kind: 'known' | 'throttled' | 'unknown', chainId: string): void { billAlchemyWsFrame(kind, chainId); }
// v9.32al CK2 (KD2): глушитель цунами старта. Инцидент: 11 000 RPC-вызовов за первые
// 2 минуты рана (снапшот + reserve-разогрев + hotPoll). Warmup-окно от СТАРТА ПАРСЕРА:
// hotPoll-интервал ×2, evmWs-analyze-потолок ×0.5. Якорь — именно startParser (не загрузка
// модуля): тесты без старта не затрагиваются.
const HOTPOLL_MAX_LOOKBACK_BLOCKS = () => Math.max(1, Number(process.env.HOTPOLL_MAX_LOOKBACK_BLOCKS || '200'));
// v9.32bf BF3 (AR8/AR9): шаг курсора ограничен. До bf toBlock='latest' при отставшем
// курсоре давал окно в сотни блоков — публичные ноды отбивают такой ответ по размеру
// ("Archive requests require a personal token" на eth 200 блоков, -32005 "limit exceeded"
// на bsc, "log query range must not exceed 25 blocks" на blockrazor). Живая проверка:
// eth span=20 → OK 1455 логов, span=200 → ERR; bsc span=20 → OK 578, span=200 → ERR.
// Отказы ethereum/bsc были НЕ отказом нод, а следствием ширины диапазона.
// v9.32bg BG1 (AR1): дефолт 120. При span=20 и цикле 15 с arbitrum (~4 блока/с) производил
// ~60 блоков за цикл, а курсор двигался максимум на 20 — отставание росло безвозвратно
// (замер s1: arbitrum 3978 блоков позади, bsc 1835). Значение было поднято в env вручную;
// закрепляем в коде, иначе чистый деплой без env возвращает регресс.
const HOTPOLL_MAX_SPAN_BLOCKS = () => Math.max(1, Number(process.env.HOTPOLL_MAX_SPAN_BLOCKS || '120'));
// v9.32bg BG5 (AR8): один цикл = один шаг span, поэтому отставание в тысячи блоков
// span'ом не лечится. Догон: до HOTPOLL_CATCHUP_STEPS запросов подряд в одном цикле,
// пока курсор не догонит голову. Потолок шагов держит стоимость цикла предсказуемой.
const HOTPOLL_CATCHUP_STEPS = () => Math.max(1, Number(process.env.HOTPOLL_CATCHUP_STEPS || '8'));
const evmHotSpanOverride: Record<string, number> = {}; // рантайм-сужение окна по ответу ноды
function evmHotSpan(chainId: string): number {
  const o = evmHotSpanOverride[chainId];
  return o !== undefined ? Math.max(1, o) : HOTPOLL_MAX_SPAN_BLOCKS();
}
function narrowHotSpan(chainId: string, m: EvmHotPollMetrics): void {
  const cur = evmHotSpan(chainId);
  const next = Math.max(1, Math.floor(cur / 2));
  if (next >= cur) return;
  evmHotSpanOverride[chainId] = next;
  m.spanBlocks = next;
  m.spanNarrows = (m.spanNarrows || 0) + 1;
  log(`[hotpoll] ${chainId}: нода отбила диапазон по ширине → span ${cur} → ${next} блоков`);
}
// ТЗ 2026-10-20 (#257 root-cause Fix 1): храповик обязан быть симметричным. Раньше сужение
// было необратимым (сброс только в test-reset), и BSC залипал на span=1 до рестарта, после
// чего цепочка too-wide схлопывала окно заново за ~6 циклов. Восстановление — только по
// ДОКАЗАТЕЛЬСТВУ: цикл без единого too-wide И с хотя бы одним успешным чтением (anyOk).
// Молчание кулдаунов доказательством не считается.
const HOTPOLL_SPAN_RECOVER_CLEAN_CYCLES = () => Math.max(1, Number(process.env.HOTPOLL_SPAN_RECOVER_CLEAN_CYCLES || '3'));
const evmHotSpanCleanCycles: Record<string, number> = {};
function recoverHotSpan(chainId: string, m: EvmHotPollMetrics, anyOk: boolean): void {
  if (evmHotSpanOverride[chainId] === undefined) { evmHotSpanCleanCycles[chainId] = 0; return; }
  if (!anyOk) return; // нет успешных чтений — ширину ничто не подтвердило, счётчик держим
  const clean = (evmHotSpanCleanCycles[chainId] || 0) + 1;
  evmHotSpanCleanCycles[chainId] = clean;
  if (clean < HOTPOLL_SPAN_RECOVER_CLEAN_CYCLES()) return;
  evmHotSpanCleanCycles[chainId] = 0;
  const cur = evmHotSpan(chainId);
  const max = HOTPOLL_MAX_SPAN_BLOCKS();
  const next = Math.min(max, cur * 2);
  if (next <= cur) return;
  if (next >= max) delete evmHotSpanOverride[chainId];
  else evmHotSpanOverride[chainId] = next;
  m.spanBlocks = evmHotSpan(chainId);
  m.spanWidens = (m.spanWidens || 0) + 1;
  log(`[hotpoll] ${chainId}: ${clean} чистых цикла с успешными чтениями → span ${cur} → ${m.spanBlocks} блоков`);
}
function analysisWarmupActive(now = Date.now()): boolean { return evmRuntime.limits.warmup(now).active; }
function analysisWarmupFactor(): number { return evmRuntime.limits.factor(); }
function effectiveHotPollIntervalMs(): number { return evmRuntime.limits.effectiveHotPollIntervalMs(); }
export function __setAnalysisWarmupForTest(v: boolean | null): void { evmRuntime.limits.setWarmupOverride(v); }
export function __analysisWarmupFactorForTest(): number { return analysisWarmupFactor(); }
export function __effectiveHotPollIntervalMsForTest(): number { return effectiveHotPollIntervalMs(); }
export function __evmWsAnalyzeCapForTest(): number { return Math.max(1, Math.floor(EVM_WS_ANALYZE_PER_MIN * analysisWarmupFactor())); }
export async function __alchemyCuGateForTest(cu: number, opts?: { solo?: boolean; src?: AlchemyCuSourceKey }): Promise<boolean> { return alchemyCuGate(cu, opts); }
export function __alchemy429ForTest() { return evmRuntime.spend.pause(); }
export function __noteAlchemyHttpStatusForTest(status: number): void { noteAlchemyHttpStatus(status); }
export function __setAlchemyApiKeyForTest(key: string): void { ALCHEMY_API_KEY = String(key || ''); }
export function __alchemyCuForTest() { return evmRuntime.spend.bucket(); }
export function __resetAlchemyCuForTest(): void { evmRuntime.test.resetBucket(); }
// ── v9.32am AM4 (LD2/AL4): CU-гард на WS-кадры. ──
// Инцидент 2026-09-01: wsMsg 21 783/ран (~500/мин) тарифицируются провайдером, но предохранитель
// CK1 считал только вызовы — 900K CU рана при alchemy-вызовах всего 8%. Теперь каждый
// доставленный кадр: (1) списывает WS_CU_PER_FRAME из ОБЩЕГО cuGuard-бакета (кадры и вызовы —
// один бюджет); (2) инкрементит скользящее 60с-окно wsFrameBudget (evmWs + solana-WS); при
// превышении WS_FRAME_BUDGET_PER_MIN — wsThrottle: болтливые evmWs-подписки (НЕ settlement —
// solo-значимая доставка отписывается последней) отписываются eth_unsubscribe с авто-ресабом
// через WS_THROTTLE_MS (5 мин) и пер-адресным охладом. Solana-подписки не отписываются
// автоматикой (резерв-слой святое — LD3), их кадры видны в окне/статусе; Helius-экономика
// отдельная (429-бан/бэкофф уже есть), в alchemy-бакет списываются только evmWs-кадры.
const WS_CU_PER_FRAME = () => Number(process.env.WS_CU_PER_FRAME ?? '15');
const WS_FRAME_BUDGET_PER_MIN = () => Number(process.env.WS_FRAME_BUDGET_PER_MIN ?? '900');
const WS_THROTTLE_MS = () => Number(process.env.WS_THROTTLE_MS || '300000');
// v9.32am HOTFIX+ (MD2/AM4): вызовы ≠ кадры — кадровое списание живёт в ОТДЕЛЬНОМ бакете
// (WS_FRAME_CU_PER_SEC/WS_FRAME_CU_BURST), вызывной бакет (ak) кадровым дренажом не трогается.
const WS_FRAME_CU_PER_SEC = () => Number(process.env.WS_FRAME_CU_PER_SEC || '150');
const WS_FRAME_CU_BURST = () => Number(process.env.WS_FRAME_CU_BURST || String(WS_FRAME_BUDGET_PER_MIN() * WS_CU_PER_FRAME()));
const wsFrameCuState = { tokens: -1, lastMs: 0, inited: false };
function wsFrameCuRefill(now = Date.now()): void {
  const rate = WS_FRAME_CU_PER_SEC();
  const burst = WS_FRAME_CU_BURST();
  if (!wsFrameCuState.inited) { wsFrameCuState.inited = true; wsFrameCuState.tokens = burst; wsFrameCuState.lastMs = now; return; }
  const dt = Math.max(0, now - wsFrameCuState.lastMs);
  wsFrameCuState.lastMs = now;
  if (rate <= 0 || burst <= 0) return;
  wsFrameCuState.tokens = Math.min(burst, wsFrameCuState.tokens + (dt / 1000) * rate);
}
export function __wsFrameCuForTest() { wsFrameCuRefill(); return { ...wsFrameCuState, rate: WS_FRAME_CU_PER_SEC(), burst: WS_FRAME_CU_BURST() }; }
const wsFrameStats = { count: 0, throttled: 0, resubbed: 0 };
// v9.32av AV1: провайдер тарифицирует КАЖДЫЙ доставленный кадр. Корзины обязаны
// сходиться с rpcSpend.alchemy.wsMsg (known + throttled + unknown).
const wsBilled = { known: 0, throttled: 0, unknown: 0 };
// v9.32bf BF2 (AR7): расхождение wsMsg Δ8001 против Δ3091 по сетям объяснено — кадры
// СО ВСЕХ evm-сокетов (включая бесплатный publicnode) списывались как alchemy.wsMsg.
// Тарифицирует только Alchemy: считаем кадр, лишь если сокет цепи реально alchemy'евский.
// Кадры публичных сокетов идут в wsMsgPublic — видимы, но бесплатны.
const wsBilledPublic = { known: 0, throttled: 0, unknown: 0 };
function chainWsIsAlchemy(chainId: string): boolean {
  // Переключатель владельца — EVM_ALCHEMY_CHAINS. Наличие ключа не проверяем: без ключа
  // сокет и так публичный и тарифицировать нечего, а привязка к ключу делала бы счётчик
  // зависимым от порядка инициализации.
  return alchemyChainEnabled(chainId);
}
function billAlchemyWsFrame(kind: 'known' | 'throttled' | 'unknown', chainId: string): void {
  if (!chainWsIsAlchemy(chainId)) { wsBilledPublic[kind]++; return; } // AR3: публичный сокет — 0 CU
  wsBilled[kind]++;
  bumpRpcSpend('alchemy', 'wsMsg');
  // v9.32bf BF2 (AR5): кадр списывается из ТОГО ЖЕ бакета, что и вызовы — гейт больше
  // не слеп к доставке (до bf 588 CU/сек фактических против ALCHEMY_CU_PER_SEC=150).
  const per = WS_CU_PER_FRAME();
  evmRuntime.spend.chargeWsFrame(per);
}
// v9.32av AV2: sid, снятый с subIdToTarget (миграция на poll), живёт здесь —
// повторный eth_unsubscribe, пока провайдер не закроет зомби-подписку.
const WS_ZOMBIE_UNSUB_RETRY = () => process.env.WS_ZOMBIE_UNSUB_RETRY !== '0';
const WS_ZOMBIE_RETRY_MS = () => {
  const n = Number(process.env.WS_ZOMBIE_RETRY_MS ?? '60000');
  return Number.isFinite(n) && n >= 0 ? n : 60000;
};
const WS_ZOMBIE_MAX_ATTEMPTS = () => {
  const n = Number(process.env.WS_ZOMBIE_MAX_ATTEMPTS ?? '5');
  return Number.isFinite(n) && n > 0 ? n : 5;
};
// Владелец состояния — kernel-carried EVM runtime (evm/runtime.ts): 60с-окна кадров, охлад троттлера, sid-зомби.
const wsFrames = evmRuntime.ws.frames;
const wsZombies = evmRuntime.ws.zombies;
function noteWsFrameCommon(now: number): void {
  wsFrames.noteGlobal(now);
}
function noteEvmWsFrame(chainId: string, subId: unknown, now = Date.now()): void {
  const st = evmWsChains[chainId];
  const targetKey = st && typeof subId === 'string' ? st.subIdToTarget.get(subId) : undefined;
  // v9.32av: провайдер тарифицирует кадр ВСЕГДА (см. billAlchemyWsFrame в handleEvmWsMessage).
  // Ранний выход здесь только исключает кадр из ОКНА троттлера / chatty-счётчика —
  // не из CU-счёта. Неизвестный sid → зомби-переотписка (AV2).
  if (!targetKey) return;
  noteWsFrameCommon(now);
  wsFrameStats.count++;
  // v9.32am HOTFIX+ (MD2): per-minute счётчик болтливости → миграция на getLogs (после 10 sustained минут)
  if (targetKey) { wsHotPoll.noteChattyMinute(targetKey, now); maybeMigrateChattyPool(targetKey, now); }
  // v9.32am HOTFIX+ (MD2): кадры тарифицируются ОТДЕЛЬНЫМ бакетом — вызывной (ak) не трогаем
  // (A/B: WS_CU_PER_FRAME=0 — не списывать; WS_FRAME_CU_PER_SEC=0 — кадровый бакет без потолка)
  const per = WS_CU_PER_FRAME();
  if (per > 0 && WS_FRAME_CU_PER_SEC() > 0) {
    wsFrameCuRefill(now);
    wsFrameCuState.tokens -= per;
    if (wsFrameCuState.tokens < 0) maybeWsThrottle(now); // кадровый бакет в долге — давление на троттлер
  }
  if (targetKey) wsFrames.noteTarget(targetKey, now);
  maybeWsThrottle(now);
}
function noteSolWsFrame(now = Date.now()): void {
  noteWsFrameCommon(now);
  wsFrameStats.count++;
  maybeWsThrottle(now);
}
function rememberZombieSid(chainId: string, sid: string, now: number, targetKey: string | undefined, sentOk: boolean): void {
  wsZombies.remember(chainId, sid, now, targetKey, sentOk);
}
function maybeRetryZombieUnsub(chainId: string, subId: unknown, now = Date.now()): void {
  if (!WS_ZOMBIE_UNSUB_RETRY()) return;
  if (typeof subId !== 'string' || !subId) return;
  const z = wsZombies.ensure(chainId, subId, now); // copy: the owner records the attempt below
  if (z.targetKey && z.targetKey.endsWith(':settlement')) return; // RD3/AR3: solo-доставка неприкосновенна
  if (z.attempts >= WS_ZOMBIE_MAX_ATTEMPTS()) return;
  if (z.lastTryMs > 0 && now - z.lastTryMs < WS_ZOMBIE_RETRY_MS()) return;
  const st = evmWsChains[chainId];
  if (!st?.socket || !st.connected) return;
  try {
    st.socket.send(JSON.stringify({ jsonrpc: '2.0', id: ++st.reqId, method: 'eth_unsubscribe', params: [subId] }));
  } catch { return; }
  const attempts = wsZombies.markAttempt(chainId, subId, now);
  wsZombies.noteRetry(attempts >= WS_ZOMBIE_MAX_ATTEMPTS());
  if (attempts === 1) log(`[ws-zombie] re-unsub ${chainId} sid=${subId} (provider still delivering after local drop)`);
}
// Проход троттлинга: бюджет окна исчерпан → отписываем самых болтливых (до 3 за проход,
// settlement-роль неприкосновенна). Цель — вернуть окно под бюджет, не рвя solo-доставку.
function maybeWsThrottle(now: number): void {
  const budget = WS_FRAME_BUDGET_PER_MIN();
  if (!(budget > 0) || wsFrames.globalWindowLength() <= budget) return;
  const candidates = wsFrames.targetCounts(now)
    .filter((c) => c.n > 0 && !c.key.endsWith(':settlement') && wsFrames.throttleUntil(c.key) <= now)
    .sort((a, b) => b.n - a.n);
  let throttledNow = 0;
  for (const c of candidates) {
    if (throttledNow >= 3) break;
    const i = c.key.indexOf(':');
    const j = c.key.lastIndexOf(':');
    if (i <= 0 || j <= i) continue;
    const chainId = c.key.slice(0, i);
    const addr = c.key.slice(i + 1, j);
    const st = evmWsChains[chainId];
    if (!st || !st.socket || !st.connected) continue;
    let subId: string | undefined;
    for (const [sid, tk] of st.subIdToTarget) if (tk === c.key) { subId = sid; break; }
    if (!subId) continue;
    try {
      st.socket.send(JSON.stringify({ jsonrpc: '2.0', id: ++st.reqId, method: 'eth_unsubscribe', params: [subId] }));
    } catch { continue; }
    // NB: subIdToTarget сохраняем — гоночные кадры отписанной подписки должны маршрутизиться
    // в wsThrottleBlocks и отбрасываться БЕЗ тарификации (v9.32am AM4).
    st.confirmed.delete(c.key);
    st.subscribed.delete(addr);
    wsFrames.throttle(c.key, now + WS_THROTTLE_MS());
    wsFrames.dropTarget(c.key);
    wsFrameStats.throttled++;
    throttledNow++;
    log(`[wsframes] throttled chatty sub ${c.key} (${c.n} frames/min, window ${wsFrames.globalWindowLength()}/${budget}) — resub in ${Math.round(WS_THROTTLE_MS() / 1000)}s`);
  }
}
function wsThrottleBlocks(targetKey: string, now = Date.now()): boolean {
  return wsFrames.throttleUntil(targetKey) > now;
}
// v9.32an ND1: наблюдаемость settlement-подписок — прод должен ВИДЕТЬ, жив ли CoW-саб
export function evmWsSettlementSubs(chainId: string): Record<string, string> {
  const st = evmWsChains[chainId];
  const out: Record<string, string> = {};
  if (!st) return out;
  for (const key of st.confirmed) {
    if (!key.endsWith(':settlement')) continue;
    const j = key.lastIndexOf(':');
    out[key] = key.slice(0, j).slice(key.indexOf(':') + 1);
  }
  return out;
}
export function __evmWsSettlementSubsForTest(chainId: string): Record<string, string> { return evmWsSettlementSubs(chainId); }
export function __resetEvmWsFramesForTest(): void {
  wsFrameStats.count = 0; wsFrameStats.throttled = 0; wsFrameStats.resubbed = 0;
  wsBilled.known = 0; wsBilled.throttled = 0; wsBilled.unknown = 0;
  wsZombies.reset();
  wsFrames.reset();
  wsFrameCuState.tokens = -1; wsFrameCuState.lastMs = 0; wsFrameCuState.inited = false; // v9.32am HOTFIX+
}
export function __wsFrameStatsForTest(): { count: number; throttled: number; resubbed: number; windowNow: number; budgetPerMin: number } {
  const windowNow = wsFrames.pruneGlobal(Date.now());
  return { ...wsFrameStats, windowNow, budgetPerMin: WS_FRAME_BUDGET_PER_MIN() };
}
export function __wsBilledForTest(): { known: number; throttled: number; unknown: number } {
  return { ...wsBilled };
}
export function __wsZombieForTest(): {
  tracked: number; retried: number; gaveUp: number;
  sids: Array<{ key: string; chainId: string; sid: string; attempts: number; targetKey?: string }>;
} {
  return {
    tracked: wsZombies.size(),
    retried: wsZombies.stats().retried,
    gaveUp: wsZombies.stats().gaveUp,
    sids: wsZombies.entries().map(([key, z]) => ({
      key, chainId: z.chainId, sid: z.sid, attempts: z.attempts, targetKey: z.targetKey,
    })),
  };
}
export function __setWsThrottleUntilForTest(targetKey: string, untilMs: number): void {
  wsFrames.setThrottleUntil(targetKey, untilMs);
}
export function __migratePoolToPollForTest(targetKey: string, now = Date.now(), roleLabel?: string): void {
  migratePoolToPoll(targetKey, now, 999, roleLabel);
}

// ── v9.32am HOTFIX+ (MD2/AM3): кадровая революция — болтливые пулы уходят с WS на getLogs ──
// Инцидент (AM3): 371 855 WS-кадров/час = 1.5M CU/час; троттлинг AM4 лечит симптом (временная
// отписка), поток возвращается. Пул с sustained-потоком ≥ POOL_FRAMES_CHATTY(300)/мин в течение
// POOL_FRAMES_CHATTY_MINS(10) минут мигрирует НАВСЕГДА (до тишины): eth_unsubscribe → в
// hotPoll-хотлист (батч eth_getLogs ≈75 CU покрывает сотни событий — паттерн v9.32af FD1 для
// роутеров/токенов). Возврат при тишине в поллинге POOL_FRAMES_QUIET_MINS(30) мин. Settlement-роли
// и верифицированные якоря НЕ переводятся (solo-доставка неприкосновенна). Болтливость персистится
// (ws_pool_pools, fail-open): рестарт начинает с polled-режима для топ-POOL_WS_TO_POLL_TOP пожирателей.
const POOL_FRAMES_CHATTY = () => Number(process.env.POOL_FRAMES_CHATTY || '300');
// v9.32an ND3 (AN4): settlement-роль тоже может быть ПОЖИРАТЕЛЕМ по поведению — 0x498581ff…
// (1inch-base) = 240 кадров/мин ≈ 85% кадрового CU, «settlement» по реестру, роутер по сути.
// Порог выше пулингового: SETTLEMENT_FRAME_POLL_THRESHOLD (150/мин sustained 10 мин) →
// миграция на getLogs с меткой settlement-by-behavior; 0 = settlement'ы никогда не мигрируют.
const SETTLEMENT_FRAME_POLL_THRESHOLD = () => Number(process.env.SETTLEMENT_FRAME_POLL_THRESHOLD || '150');
const POOL_FRAMES_CHATTY_MINS = () => Number(process.env.POOL_FRAMES_CHATTY_MINS || '10');
const POOL_FRAMES_QUIET_MINS = () => Number(process.env.POOL_FRAMES_QUIET_MINS || '30');
const POOL_WS_TO_POLL_TOP = () => Number(process.env.POOL_WS_TO_POLL_TOP || '40');
const wsHotPoll = evmRuntime.ws.hotPoll; // hot-poll migration state: kernel-owned (evm/runtime.ts)
function wsPollBlocks(targetKey: string): boolean { return wsHotPoll.isMigrated(targetKey); }
// per-подписка телеметрия: топ-источников кадров по 60с-окну (для статуса wsFrames.top)
function wsFramesTop(n = 20): Array<{ key: string; perMin: number }> {
  const now = Date.now();
  return wsFrames.targetCounts(now)
    .map(({ key, n }) => ({ key, perMin: n }))
    .filter((e) => e.perMin > 0)
    .sort((a, b) => b.perMin - a.perMin)
    .slice(0, n);
}
// ленивый per-minute счётчик — состояние в runtime.ws.hotPoll (ролл минуты на первом кадре новой минуты)
function maybeMigrateChattyPool(targetKey: string, now: number): boolean {
  const mins = POOL_FRAMES_CHATTY_MINS();
  const settlementRole = targetKey.endsWith(':settlement');
  const thr = settlementRole ? SETTLEMENT_FRAME_POLL_THRESHOLD() : POOL_FRAMES_CHATTY(); // у settlement порог 0 = никогда
  const peak = wsHotPoll.chattyMigrationPeak(targetKey, mins, thr);
  if (peak === null) return false;
  migratePoolToPoll(targetKey, now, peak, settlementRole ? 'settlement-by-behavior' : undefined);
  return true;
}
function migratePoolToPoll(targetKey: string, now: number, peakPerMin: number, roleLabel?: string): void {
  const i = targetKey.indexOf(':');
  const j = targetKey.lastIndexOf(':');
  if (i <= 0 || j <= i) return;
  const chainId = targetKey.slice(0, i);
  const addr = targetKey.slice(i + 1, j);
  // solo-значимые не переводятся; ИСКЛЮЧЕНИЕ v9.32an ND3: settlement с поведенческим порогом
  // (SETTLEMENT_FRAME_POLL_THRESHOLD, метка settlement-by-behavior) — виновник 0x498581ff-класса
  if (targetKey.endsWith(':settlement') && roleLabel !== 'settlement-by-behavior') return;
  if (isEvmVerifiedAnchor(chainId, addr)) return; // якоря — тоже
  const st = evmWsChains[chainId];
  if (!st) return;
  for (const [sid, tk] of [...st.subIdToTarget]) {
    if (tk !== targetKey) continue;
    let sentOk = false;
    try { st.socket?.send(JSON.stringify({ jsonrpc: '2.0', id: ++st.reqId, method: 'eth_unsubscribe', params: [sid] })); sentOk = !!st.socket; } catch { /* сокет мёртв — sid в зомби-реестр */ }
    st.subIdToTarget.delete(sid); // v9.32av: sid не теряется — переносится в sidZombie. Провайдер тарифицирует гоночные кадры всегда.
    rememberZombieSid(chainId, sid, now, tk, sentOk);
  }
  st.confirmed.delete(targetKey);
  st.subscribed.delete(addr);
  wsHotPoll.dropChatty(targetKey);
  wsFrames.clearThrottleUntil(targetKey);
  const rec: WsPollMigratedRec = { targetKey, chainId, addr, role: roleLabel || targetKey.slice(j + 1), movedAt: now, framesPerMinPeak: peakPerMin, lastPollActivityAt: now };
  wsHotPoll.migrate(rec);
  wsPollStoreUpsert(rec);
  log(`[wsframes] chatty pool ${targetKey} → getLogs-поллинг (peak ${peakPerMin}/мин, ${POOL_FRAMES_CHATTY_MINS()} мин sustained) — CU вниз by design, не троттлингом`);
}
// возврат на WS: в поллинге тишина дольше POOL_FRAMES_QUIET_MINS → resubscribe + вывод из хотлиста
function maybeRestoreWsPoll(chainId: string, now = Date.now()): void {
  const quietMs = POOL_FRAMES_QUIET_MINS() * 60_000;
  if (!(quietMs > 0)) return;
  for (const rec of wsHotPoll.restoreCandidates(chainId, now, quietMs)) {
    wsHotPoll.restore(rec);
    wsPollStoreRemove(rec.targetKey);
    const st = evmWsChains[chainId];
    if (st?.socket && st.connected) {
      sendEvmSubscription(st, st.socket, { targetKey: rec.targetKey, eventSource: rec.addr, chainId, requestedAt: now, attempts: 1, topics: [[EVM_V2_SWAP_TOPIC, EVM_V3_SWAP_TOPIC]] }, 1);
    }
    log(`[wsframes] pool ${rec.targetKey} тих в поллинге ${POOL_FRAMES_QUIET_MINS()} мин → возврат на WS (restored=${wsHotPoll.movedCounts().restored})`);
  }
}
// ── персист болтливости (паттерн reserve_cache: fail-open, старт не блокируется) ──
interface WsPoolPollStore {
  loadAll(): Promise<Array<{ targetKey: string; chain: string; addr: string; framesPerMinPeak: number; movedAt: number }>>;
  upsert(rec: { targetKey: string; chain: string; addr: string; framesPerMinPeak: number; movedAt: number }): Promise<void>;
  remove(targetKey: string): Promise<void>;
}
let wsPollStoreImpl: WsPoolPollStore | null | undefined;
let wsPollLoadedOnce = false;
export function createWsPollPoolStore(): WsPoolPollStore | null {
  if (!process.env.DATABASE_URL) return null;
  let ensured = false;
  async function ensureTable(): Promise<void> {
    if (ensured) return;
    const { db } = await import('@/db');
    const { sql } = await import('drizzle-orm');
    await db.execute(sql`
      CREATE TABLE IF NOT EXISTS ws_pool_polls (
        target_key text PRIMARY KEY,
        chain_id text NOT NULL,
        addr text NOT NULL,
        frames_per_min_peak int NOT NULL,
        moved_at timestamptz DEFAULT now() NOT NULL
      )
    `);
    ensured = true;
  }
  return {
    async loadAll() {
      try {
        await ensureTable();
        const { db } = await import('@/db');
        const { sql } = await import('drizzle-orm');
        const result = await db.execute(sql`SELECT target_key AS "targetKey", chain_id AS "chain", addr, frames_per_min_peak AS "framesPerMinPeak", moved_at AS "movedAt" FROM ws_pool_polls`);
        return (result.rows as any[]).map((r) => ({ targetKey: String(r.targetKey), chain: String(r.chain), addr: String(r.addr), framesPerMinPeak: Number(r.framesPerMinPeak) || 0, movedAt: Number(r.movedAt) || 0 }));
      } catch { return []; } // fail-open
    },
    async upsert(rec) {
      try {
        await ensureTable();
        const { db } = await import('@/db');
        const { sql } = await import('drizzle-orm');
        await db.execute(sql`INSERT INTO ws_pool_polls (target_key, chain_id, addr, frames_per_min_peak, moved_at)
          VALUES (${rec.targetKey}, ${rec.chain}, ${rec.addr}, ${rec.framesPerMinPeak}, new Date(rec.movedAt).toISOString()})
          ON CONFLICT (target_key) DO UPDATE SET frames_per_min_peak = EXCLUDED.frames_per_min_peak, moved_at = EXCLUDED.moved_at`);
      } catch { /* fail-open */ }
    },
    async remove(targetKey: string) {
      try {
        await ensureTable();
        const { db } = await import('@/db');
        const { sql } = await import('drizzle-orm');
        await db.execute(sql`DELETE FROM ws_pool_polls WHERE target_key = ${targetKey}`);
      } catch { /* fail-open */ }
    },
  };
}
function wsPollStore(): WsPoolPollStore | null {
  if (wsPollStoreImpl !== undefined) return wsPollStoreImpl;
  wsPollStoreImpl = createWsPollPoolStore();
  return wsPollStoreImpl;
}
function wsPollStoreUpsert(rec: WsPollMigratedRec): void {
  wsPollStore()?.upsert({ targetKey: rec.targetKey, chain: rec.chainId, addr: rec.addr, framesPerMinPeak: rec.framesPerMinPeak, movedAt: rec.movedAt }).catch(() => { /* fail-open */ });
}
function wsPollStoreRemove(targetKey: string): void {
  wsPollStore()?.remove(targetKey).catch(() => { /* fail-open */ });
}
// бут: персист болтливости → топ-POOL_WS_TO_POLL_TOP пожирателей сразу в polled (без WS-подписки)
async function ensureWsPollLoaded(): Promise<number> {
  if (wsPollLoadedOnce) return wsHotPoll.movedCounts().polled;
  wsPollLoadedOnce = true;
  const store = wsPollStore();
  if (!store) return 0;
  try {
    const rows = await store.loadAll();
    const topN = Math.max(0, POOL_WS_TO_POLL_TOP());
    rows.sort((a, b) => b.framesPerMinPeak - a.framesPerMinPeak);
    let n = 0;
    for (const row of rows) {
      if (topN > 0 && n >= topN) break;
      if (wsHotPoll.isMigrated(row.targetKey)) continue;
      const now = Date.now();
      wsHotPoll.adopt({ targetKey: row.targetKey, chainId: row.chain, addr: row.addr, role: 'standard', movedAt: now, framesPerMinPeak: row.framesPerMinPeak, lastPollActivityAt: now });
      n++;
    }
    if (n > 0) log(`[wsframes] boot: ${n} болтливых пулов подняты из ws_pool_polls в polled-режим (top ${topN})`);
    return n;
  } catch { return 0; } // fail-open
}
export function __resetWsPollForTest(): void {
  wsHotPoll.reset();
  wsPollLoadedOnce = false;
}
export function __setWsPollStoreForTest(store: WsPoolPollStore | null): void { wsPollStoreImpl = store; wsPollLoadedOnce = false; }
export function __wsPollMigratedForTest(): WsPollMigratedRec[] { return wsHotPoll.migratedRecords().map(r => ({ ...r })); }
export function __wsFramesMovedForTest(): { movedToPoll: number; restored: number; polled: number } { return wsHotPoll.movedCounts(); }
export function __wsFramesTopForTest(): Array<{ key: string; perMin: number }> { return wsFramesTop(); }
export function __noteWsPollLogForTest(chainId: string, addr: string): void {
  wsHotPoll.recordPollActivity(chainId + ':' + addr.toLowerCase(), Date.now());
}
export function __maybeRestoreWsPollForTest(chainId: string): void { maybeRestoreWsPoll(chainId); }
export async function __loadWsPollForTest(): Promise<number> { return ensureWsPollLoaded(); }
function alchemyHttpUrl(chainId: string): string {
  if (!alchemyHttpEnabled(chainId)) return '';
  const slug = ALCHEMY_NETWORKS[chainId];
  const key = process.env.ALCHEMY_API_KEY || ALCHEMY_API_KEY;
  return slug && key ? `https://${slug}.g.alchemy.com/v2/${key}` : '';
}

const EVM_WS_SUPPORTED_CHAINS = Object.keys(CHAINS).filter(isEVMChain);
const evmWsChains: Record<string, EvmWsChainState> = {};
for (const c of EVM_WS_SUPPORTED_CHAINS) {
  evmWsChains[c] = createEvmWsChainState();
}
// v9.30 EVM-DEBUG F1: tiny helper to bump a per-chain reason counter that lives in the state object.
function bumpEvmWsReason(st: EvmWsChainState, key: string): void {
  st.noFoundByReason[key] = (st.noFoundByReason[key] || 0) + 1;
}

const EVM_WS_ENABLED = !!ALCHEMY_API_KEY;
// v9.30 CU-Smart: subscribe to top-N active EVM pools by 24h volume (default 50).
// Large trades and series (>= $1K / $30K) occur almost exclusively on top active pools.
// Limiting the active WebSocket listener pool count drastically cuts RPC analysis flood
// while retaining full real-time coverage for high-volume markets (mirrors Solana SOL_PUBSUB_MAX_POOLS=60).
// v9.32h: 30 organic pools/chain (audit 2026-08-26: top-volume gaps on BSC/Base with 12-slot cap).
const EVM_WS_MAX_POOLS = Number(process.env.EVM_WS_MAX_POOLS || '30');
// Coupled: 30 pools ⇒ more frames; 240/min keeps analyze-ahead-of-arrival without rateLimited drops.
const EVM_WS_ANALYZE_PER_MIN = Number(process.env.EVM_WS_ANALYZE_PER_MIN || '240');
const EVM_WS_ANALYZE_CAP_MAX = Number(process.env.EVM_WS_ANALYZE_CAP_MAX || String(EVM_WS_ANALYZE_CAP_MAX_DEFAULT));
function evmWsAnalyzeCapFor(chainId: string): number { return evmRuntime.limits.capFor(chainId); }
/** Те же числа для публикации в статусе — чтобы payload не разошёлся с гейтом. */
function evmWsAnalyzeCapsByChain(): Record<string, number> {
  return Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.map(c => [c, evmWsAnalyzeCapFor(c)]));
}
export function __resetEvmAnalyzeCapsForTest(): void { evmRuntime.limits.resetCaps(); }
export function __evmAnalyzeCapsParseCountForTest(): number { return evmRuntime.limits.parseCount(); }
export function __evmWsAnalyzeCapForChainForTest(chainId: string): number { return evmWsAnalyzeCapFor(chainId); }

const EVM_SYNC_TOPIC = '0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1';

// In-memory pre-filter for Uniswap v2 / v3, Algebra & PancakeSwap swap logs with sub-threshold amounts (<$300).
// Decodes raw swap amounts and checks the quote leg without making any HTTP RPC calls (0 CU cost).
// ─────────────── Native Pre-Filter Decision (TZ v9.31 Round 10) ───────────────
export interface NativePrefilterDecision {
  wouldSkip: boolean;
  actualSkip: boolean;
  mode: 'off' | 'shadow' | 'enforce';
  priceUsd: number | null;
  confidence: 'high' | 'medium' | 'low' | null;
  reason: string;
}

// Shadow metrics: tracks what shadow mode would have done vs what actually happened
const nativePrefilterShadowMetrics: Record<string, {
  evaluated: number;
  wouldSkip: number;
  actualSkip: number;
  lastPrice: number | null;
  lastConfidence: string | null;
  lastMode: string;
}> = {};

function evaluateNativePrefilterDecision(
  chainId: string,
  pureSkip: boolean,
  pureReason: string,
  meta?: EvmPoolMeta | null,
): NativePrefilterDecision {
  const mode: NativePrefilterMode = NATIVE_PREFILTER_MODE;

  if (!nativePrefilterShadowMetrics[chainId]) {
    nativePrefilterShadowMetrics[chainId] = { evaluated: 0, wouldSkip: 0, actualSkip: 0, lastPrice: null, lastConfidence: null, lastMode: mode };
  }
  const metrics = nativePrefilterShadowMetrics[chainId];

  // R11-G3: Only increment metrics for native quotes
  const isNativeQuote = meta?.quoteType === 'native' || isNativeQuoteSymbol(meta?.quoteSymbol);
  if (isNativeQuote) {
    metrics.evaluated++;
    metrics.lastMode = mode;
  }

  // Get native price snapshot
  const snapshot = getCachedNativePrice(chainId);
  const priceUsd = snapshot?.priceUsd ?? null;
  const confidence = snapshot?.confidence ?? null;
  if (isNativeQuote) {
    metrics.lastPrice = priceUsd;
    metrics.lastConfidence = confidence;
  }

  if (mode === 'off') {
    return { wouldSkip: false, actualSkip: false, mode, priceUsd, confidence, reason: 'mode_off' };
  }

  // wouldSkip is based on the pure evaluation (metadata + price available)
  const wouldSkip = pureSkip;
  if (wouldSkip && isNativeQuote) metrics.wouldSkip++;

  if (mode === 'shadow') {
    // Shadow: compute decision but never actually skip
    return { wouldSkip, actualSkip: false, mode, priceUsd, confidence, reason: wouldSkip ? 'shadow_would_skip' : pureReason };
  }

  // mode === 'enforce'
  // Only actually skip if ALL conditions met:
  // 1. pure evaluator says skip (metadata validated, amount below threshold)
  // 2. exact quoteType === 'native' (not just symbol match)
  // 3. exact canonical wrapped-native address for this chain
  // 4. exact decimals validated
  // 5. fresh high-confidence price
  if (!wouldSkip) {
    return { wouldSkip: false, actualSkip: false, mode, priceUsd, confidence, reason: pureReason };
  }
  // Exact metadata: quoteType must be 'native', not just symbol-based
  if (!meta || meta.quoteType !== 'native') {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_no_native_quoteType' };
  }
  // Exact canonical wrapped-native address check
  const canonicalWrapped = CANONICAL_WRAPPED_NATIVE[chainId];
  if (canonicalWrapped) {
    const quoteAddr = meta.quoteTokenIndex === 0 ? meta.token0 : meta.token1;
    if (!quoteAddr || lower(quoteAddr) !== lower(canonicalWrapped)) {
      return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_noncanonical_wrapped' };
    }
  }
  // Exact decimals must be present and correct for canonical wrapped-native (always 18)
  const quoteDecimals = meta.quoteTokenIndex === 0 ? meta.token0Decimals : meta.token1Decimals;
  if (quoteDecimals === undefined || isNaN(quoteDecimals)) {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_missing_decimals' };
  }
  if (canonicalWrapped && quoteDecimals !== 18) {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_wrong_decimals' };
  }
  // Metadata must be validated
  if (!meta.metadataValidatedAt) {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_unvalidated_meta' };
  }
  if (confidence !== 'high') {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_low_confidence' };
  }
  // Price must be fresh: < 60s (PRICE_TTL_MS equivalent), not 10min
  if (!snapshot || (Date.now() - snapshot.observedAt > 60_000)) {
    return { wouldSkip: true, actualSkip: false, mode, priceUsd, confidence, reason: 'enforce_stale_price' };
  }

  // All conditions met → actually skip
  if (isNativeQuote) metrics.actualSkip++;
  return { wouldSkip: true, actualSkip: true, mode, priceUsd, confidence, reason: 'enforce_skip' };
}

export function __getNativePrefilterShadowMetricsForTest(chainId: string) {
  return nativePrefilterShadowMetrics[chainId] || null;
}

function isClearlySubThresholdSwap(chainId: string, poolAddr: string, topic0: string, data: string): boolean {
  try {
    const meta = chainWatchMeta[chainId]?.[lower(poolAddr)];
    const decision = evaluatePrefilterPure(chainId, meta, topic0, data);

    // Update basic metrics
    const stats = evmPrefilterStats[chainId];
    if (stats) {
      stats.prefilterEvaluated++;
      if (decision.reason === 'missing_meta') {
        stats.prefilterFailOpenMissingMeta++;
      } else if (decision.reason === 'missing_price') {
        stats.prefilterFailOpenMissingPrice++;
      }
    }

    // Apply mode-aware native prefilter decision
    const nativeDecision = evaluateNativePrefilterDecision(chainId, decision.skip, decision.reason, meta);

    // For native quotes, ONLY use the mode-aware decision.
    // Shadow mode: actualSkip is always false, so we never drop.
    // Enforce mode: actualSkip is true only if high confidence + fresh price.
    const isNativeQuote = meta?.quoteType === 'native' || isNativeQuoteSymbol(meta?.quoteSymbol);
    if (isNativeQuote) {
      if (nativeDecision.actualSkip) {
        if (stats) stats.prefilterDropped++;
        return true;
      }
      return false;
    }

    // For non-native quotes (stable, other), use the pure decision directly
    if (decision.reason !== 'missing_price' && decision.reason !== 'missing_meta') {
      if (decision.skip) {
        if (stats) stats.prefilterDropped++;
        return true;
      }
    }

    return false;
  } catch {
    return false; // fail-open on any parsing issue
  }
}

function evmWsEnabled(chainId: string): boolean {
  return EVM_WS_ENABLED && !!evmWsUrl(chainId) && state.running;
}
export function selectEvmWsPools(
  pools: string[],
  anchors: string[],
  maxPools: number,
  metaMap?: Record<string, { vol24hUsd?: number | null }>
): string[] {
  const sorted = [...pools].sort((a, b) => {
    const va = metaMap?.[lower(a)]?.vol24hUsd || 0;
    const vb = metaMap?.[lower(b)]?.vol24hUsd || 0;
    return vb - va;
  });
  const topN = maxPools === 0 ? sorted : sorted.slice(0, maxPools > 0 ? maxPools : 25);
  return Array.from(new Set([...anchors, ...topN]));
}

function evmWsTopPools(chainId: string): string[] {
  const st = state.chain[chainId];
  const pools = st?.watchPools || [];
  const anchors = (ANCHOR_POOLS[chainId] || [])
    .map(a => a.pool)
    .filter(p => isValidEvmAddress(p))
    .map(a => normAddr(chainId, a));
  return selectEvmWsPools(pools, anchors, EVM_WS_MAX_POOLS, chainWatchMeta[chainId]);
}
// Subscribe to logs for new pools on the socket (called on connect + on watchlist growth).
function evmWsSubscribe(chainId: string, ws: any): void {
  const st = evmWsChains[chainId];
  if (!st || !ws) return;
  const now = Date.now();
  const targets = evmWsTopPools(chainId);
  let sent = 0;
  for (const pool of targets) {
    if (!isValidEvmAddress(pool)) continue;
    const targetKey = getEvmSubscriptionTargetKey(chainId, pool, 'standard');
    // v9.32am AM4: пер-адресный охлад троттлера — до его конца не переподписываемся
    // (болтливый адрес не должен тут же вернуться в бюджет), после — ресаб считается.
    if (wsThrottleBlocks(targetKey, now)) continue;
    if (wsPollBlocks(targetKey)) continue; // v9.32am HOTFIX+: мигрировавший на getLogs пул не возвращается на WS
    if (wsFrames.takeThrottledEver(targetKey)) wsFrameStats.resubbed++;
    if (st.confirmed.has(targetKey) || isTargetPending(st, targetKey)) continue;

    const reqId = sendEvmSubscription(st, ws, {
      targetKey,
      eventSource: pool,
      chainId,
      requestedAt: Date.now(),
      attempts: 1,
      topics: [[EVM_V2_SWAP_TOPIC, EVM_V3_SWAP_TOPIC]],
    }, 1);
    if (reqId !== null) { sent++; noteCoverage('evmWs', chainId + ':' + targetKey); }
  }

  // Subscribe to Uniswap v4 PoolManager (if this chain has v4 anchor pools)
  const v4Anchors = VERIFIED_ANCHOR_POOLS.filter(a => a.chainId === chainId && a.adapter === 'uniswap-v4');
  if (v4Anchors.length > 0) {
    const pmAddr = UNISWAP_V4_POOL_MANAGER_ETH;
    const targetKey = getEvmSubscriptionTargetKey(chainId, pmAddr, 'v4');
    if (!wsThrottleBlocks(targetKey, now)) { // v9.32am AM4
    if (!st.confirmed.has(targetKey) && !isTargetPending(st, targetKey)) {
      const reqId = sendEvmSubscription(st, ws, {
        targetKey,
        eventSource: pmAddr,
        chainId,
        requestedAt: Date.now(),
        attempts: 1,
        topics: [[UNISWAP_V4_SWAP_TOPIC]],
        isV4: true,
        poolIds: v4Anchors.map(a => a.poolId!).filter(Boolean),
        anchors: v4Anchors,
      }, 1);
      if (reqId !== null) { sent++; noteCoverage('evmWs', chainId + ':' + targetKey); }
    }
    }
  }

  // Subscribe to known intent/settlement contracts for this chain (CoW, 1inch Fusion, UniswapX, Velora)
  // v9.32af FD1: when hot-polling is ON, routers are NOT subscribed (polled via eth_getLogs
  // batches instead — WS delivery of router frames was the top CU burner, AF1); settlements and
  // reactors stay on WS (rare frames, AF7).
  // v9.32ao OD2 (AO2): hot-poll режим — settlements/reactors/allowance-holder/solver-helper на WS
  // (роутеры поллятся, FD1); обычный — весь getSubscribedAddrs.
  const settlements = evmHotPollEnabled() ? getWsSubscribedAddrs(chainId) : getSubscribedAddrs(chainId);
  for (const sAddr of settlements) {
    if (!isValidEvmAddress(sAddr)) continue;
    const targetKey = getEvmSubscriptionTargetKey(chainId, sAddr, 'settlement');
    if (wsThrottleBlocks(targetKey, now)) continue; // v9.32am AM4 (settlement троттлится последним — см. maybeWsThrottle)
    if (wsPollBlocks(targetKey)) continue; // v9.32an ND3: поведенчески мигрированный settlement не возвращается на WS
    if (st.confirmed.has(targetKey) || isTargetPending(st, targetKey)) continue;

    const reqId = sendEvmSubscription(st, ws, {
      targetKey,
      eventSource: sAddr,
      chainId,
      requestedAt: Date.now(),
      attempts: 1,
      topics: [],
    }, 1);
    if (reqId !== null) { sent++; noteCoverage('evmWs', chainId + ':' + targetKey); }
  }
  const tokN = EVM_TOKEN_SUBS();
  if (tokN > 0) {
    const meta = chainWatchMeta[chainId] || {};
    const toks = rankWlMintsByVol(Object.values(meta).map((e) => ({ subjectToken: e.subjectToken, vol24hUsd: e.vol24hUsd })), tokN);
    for (const tok of toks) {
      if (!isValidEvmAddress(tok)) continue;
      // v9.32ag GD2 (AG4): token contracts are REGISTERED ONLY — strict Phase A + token budget
      // in the battle path + the hotPoll hotlist. They are NEVER WS-subscribed anymore: the
      // v9.32af hot-poll left a legacy branch (EVM_HOT_POLL_MS=0) that resurrected push subs,
      // and the ambiguous evmTokenSubs.frames counter made dual coverage invisible while every
      // hot event was paid twice (frame ~12-15 CU + getLogs). hotPoll is the single path now.
      evmTokenSubAddrs.add(chainId + ':' + lower(tok));
      noteCoverage('evmWs', chainId + ':' + getEvmSubscriptionTargetKey(chainId, tok, 'token'));
    }
    evmTokenSubs.tokens = [...evmTokenSubAddrs].filter((k) => k.startsWith(chainId + ':')).length;
  }
  if (sent) log(`[evmws] ${chainId}: subscribed ${sent} new pool/settlement/token logs (total confirmed ${st.confirmed.size}, pending ${st.pending.size})`);
}
// Handle an incoming eth_subscription log
let analyzeEvmTxHandleImpl: ((chainId: string, txHash: string) => Promise<any>) | null = null;
export function __setAnalyzeEvmTxHandleForTest(fn: typeof analyzeEvmTxHandleImpl): void {
  analyzeEvmTxHandleImpl = fn;
}
const EVM_ROUTER_FRAME_BUDGET = () => Number(process.env.EVM_ROUTER_FRAME_BUDGET ?? '30');
const EVM_TOKEN_SUBS = () => Number(process.env.EVM_TOKEN_SUBS ?? '8');
const EVM_HOT_POLL_INCLUDE_ROUTERS = () => process.env.EVM_HOT_POLL_INCLUDE_ROUTERS === '1'; // v9.32ak AK2
const EVM_HOT_GETLOGS_INCAPABLE_MS = () => Number(process.env.EVM_HOT_GETLOGS_INCAPABLE_MS || '43200000'); // 12ч
const EVM_HOT_CANARY_MS = () => Number(process.env.EVM_HOT_CANARY_MS || '300000'); // 5 мин
const EVM_TOKEN_FRAME_BUDGET = () => Number(process.env.EVM_TOKEN_FRAME_BUDGET ?? '60');
let routerBudgetDropped = 0;
const evmTokenSubAddrs = new Set<string>();
const evmTokenSubs = { tokens: 0 }; // v9.32ag GD2: delivery counters removed — hotPoll.* is the metric
export function __noteEvmTokenSubForTest(chainId: string, token: string): void {
  evmTokenSubAddrs.add(chainId + ':' + lower(token));
  evmTokenSubs.tokens = evmTokenSubAddrs.size;
}
export function __evmTokenSubsForTest() { return { ...evmTokenSubs }; }
export function __resetEvmTokenSubsForTest(): void {
  evmTokenSubAddrs.clear();
  evmTokenSubs.tokens = 0;
}
export function __routerBudgetDroppedForTest(): number { return routerBudgetDropped; }
export function __resetRouterBudgetForTest(): void { routerBudgetDropped = 0; }

// ── v9.32ao OD1 (AO1/AO3): value-priority — суммы важнее счётных очередей на ОБЕИХ цепях ──
// Solana: прод-порядок убивал fast-lane (кадр раньше резервного знания) — дроп-под-кап
// сигнатуры ждут noteSolSoloFastLane (re-dispatch, см. выше). EVM: кадры, дропнутые
// бюджетом (token/router) или суб-solo фильтром, получают дешёвый receipt-префикс
// (≤10/мин × 16 CU): stable-нога ≥ EVM_VALUE_EXEMPT_MIN_USD → полный анализ мимо
// бюджета/фильтра (value-exempt). Шум остаётся дропнутым — анализ не растёт.
const EVM_VALUE_EXEMPT_MIN_USD = () => Number(process.env.EVM_VALUE_EXEMPT_MIN_USD || '5000');
// v9.32bm1e E4a: литералы TTL (60 с) и полосы (10/мин) вынесены в константы БЕЗ изменения поведения —
// разминирование для E4b (решение владельца: bm2-Q6/C3). Дефолты = прежним литералам.
const EVM_VALUE_RETRY_TTL_MS = () => Number(process.env.EVM_VALUE_RETRY_TTL_MS || '60000'); // v9.32bm1g G-c: лениво, как SOL_SOLO_RETRY_TTL_MS
const EVM_VALUE_PREFETCH_PER_MIN = () => Number(process.env.EVM_VALUE_PREFETCH_PER_MIN || '10'); // v9.32bm1g G-c: лениво (A/B без пересборки)
// v9.32bm1e E4a: valueRetryDropped — сумма четырёх смыслов (TTL / нет логов / нога мельче порога / ошибка
// RPC); прод-отношение dropped/queued 0.56 ничего не меряло. Ниже — разбивка ВНУТРИ, старый счётчик
// остаётся суммой (его читают прод и тесты). Тождество дверей: attempted = queued + rejectedFull + rejectedDup.
const valuePriorityStats = {
  soloFastRecovered: 0, valueExempt: 0, valueRetryQueued: 0, valueRetryDropped: 0,
  valueRetryEnqueueAttempted: 0, valueRetryRejectedFull: 0, valueRetryRejectedDup: 0,
  valueRetryExpired: 0, valueRetryNoLogs: 0, valueRetryBelowFloor: 0, valueRetryFetchFailed: 0,
  valueRetryBandTruncated: 0, valueRetryProcessed: 0, solSoloRetryExpired: 0,
  solSoloAttempted: 0, solSoloQueued: 0, solSoloRejectedFull: 0, solSoloRejectedDup: 0, solSoloPruned: 0, // v9.32bm1g G-b
};
const evmValueRetry = new Map<string, { chainId: string; pool: string; at: number }>();
let evmValueRetryTimer: ReturnType<typeof setTimeout> | null = null;
function evmEnqueueValueRetry(chainId: string, txHash: string, pool: string): void {
  if (!(EVM_VALUE_EXEMPT_MIN_USD() > 0)) return;
  valuePriorityStats.valueRetryEnqueueAttempted++; // v9.32bm1e E4a: после выключателя — чтобы тождество дверей сходилось
  const now = Date.now();
  if (evmValueRetry.size >= 200) {
    for (const [k, v] of evmValueRetry) if (now - v.at > EVM_VALUE_RETRY_TTL_MS()) evmValueRetry.delete(k);
    if (evmValueRetry.size >= 200) { valuePriorityStats.valueRetryRejectedFull++; return; } // давление шума — не копим
  }
  if (evmValueRetry.has(txHash)) { valuePriorityStats.valueRetryRejectedDup++; return; }
  evmValueRetry.set(txHash, { chainId, pool: String(pool || ''), at: now });
  valuePriorityStats.valueRetryQueued++;
  if (!evmValueRetryTimer) {
    evmValueRetryTimer = setTimeout(() => { evmValueRetryTimer = null; void evmValueRetryProcess(); }, 50);
    (evmValueRetryTimer as unknown as { unref?: () => void }).unref?.();
  }
}
async function evmValueRetryProcess(): Promise<void> {
  for (const [txHash, job] of [...evmValueRetry]) {
    if (!state.running || state.paused) { evmValueRetry.delete(txHash); continue; }
    if (Date.now() - job.at > EVM_VALUE_RETRY_TTL_MS()) { evmValueRetry.delete(txHash); valuePriorityStats.valueRetryDropped++; valuePriorityStats.valueRetryExpired++; continue; }
    if (!rateAllow('evm-value-prefetch', EVM_VALUE_PREFETCH_PER_MIN(), 60_000)) { valuePriorityStats.valueRetryBandTruncated++; return; } // ≤N receipt-префиксов/мин; обрыв прохода — считаем (E4a)
    evmValueRetry.delete(txHash);
    try {
      const receipt = await evmValuePrefetchReceipt(job.chainId, txHash);
      valuePriorityStats.valueRetryProcessed++;
      if (!receipt || !Array.isArray(receipt.logs)) { valuePriorityStats.valueRetryDropped++; valuePriorityStats.valueRetryNoLogs++; continue; }
      const bound = evmReceiptUsdBound(job.chainId, receipt);
      if (bound >= EVM_VALUE_EXEMPT_MIN_USD()) {
        valuePriorityStats.valueExempt++;
        log(`[value] ${job.chainId} ${shortAddr(txHash)}: budget-дроп, но stable-нога $${Math.round(bound)} ≥ $${EVM_VALUE_EXEMPT_MIN_USD()} — полный анализ (value-exempt)`);
        await withEnrichGate(() => Promise.resolve((analyzeEvmTxHandleImpl || analyzeEvmTx)(job.chainId, txHash, undefined, {
          pool: job.pool,
          skipSubSoloFilter: true, // v9.32ao: ценность доказана receipt-префиксом
        })), { high: true });
      } else {
        valuePriorityStats.valueRetryDropped++; valuePriorityStats.valueRetryBelowFloor++;
      }
    } catch { valuePriorityStats.valueRetryDropped++; valuePriorityStats.valueRetryFetchFailed++; }
  }
}
async function evmValuePrefetchReceipt(chainId: string, txHash: string): Promise<{ logs?: Array<{ address?: string; topics?: string[]; data?: string }> } | null> {
  const url = RPC_ENDPOINTS[chainId];
  if (!url) return null;
  const init: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [txHash] }) };
  try {
    const res = await evmAnalyzeFetch(url, init, 8000);
    const j = await (res as Response).json();
    return (j?.result ?? null) as { logs?: Array<{ address?: string; topics?: string[]; data?: string }> } | null;
  } catch { return null; }
}
export function __valuePriorityStatsForTest() { return { ...valuePriorityStats, queueLen: evmValueRetry.size, soloRetryLen: solSoloRetry.size }; }
export function __resetValuePriorityForTest(): void {
  for (const k of Object.keys(valuePriorityStats) as Array<keyof typeof valuePriorityStats>) valuePriorityStats[k] = 0;
  evmValueRetry.clear();
  solSoloRetry.clear();
  // v9.32bm1e E4a: бакет полосы — один на 5 сетей; без сброса соседние кейсы съедают слоты (200 → 193)
  for (const k of [...rateMap.keys()]) if (k.startsWith('evm-value-prefetch')) rateMap.delete(k);
}
export function __evmEnqueueValueRetryForTest(chainId: string, txHash: string, pool: string): void { evmEnqueueValueRetry(chainId, txHash, pool); }
export function __evmValueRetryProcessForTest(): Promise<void> { return evmValueRetryProcess(); }
export function __rateAllowForTest(key: string, limit: number, windowMs: number): boolean { return rateAllow(key, limit, windowMs); }
export function __handleEvmWsLogForTest(chainId: string, txHash: string, emitter: string, origin: EvmFrameOrigin = 'ws'): Promise<void> { return handleEvmWsLog(chainId, txHash, emitter, origin); }
// ТЗ bm2n6: швы для тестов атрибуции «эмиттер ≠ пул».
export function __classifyEvmEmitterForTest(chainId: string, addr: string, subjectAddr?: string | null): EvmEmitterClass {
  return classifyEvmEmitter(chainId, addr, subjectAddr);
}
export function __extractSwapEmitterPoolForTest(
  chainId: string,
  logs: Array<{ address?: string; topics?: string[] }>,
  subjectAddr?: string | null,
): string | null {
  return extractSwapEmitterPool(chainId, logs, subjectAddr);
}
// v9.32bm1h O7a: фаззер гонит тысячи кадров за секунды — полоса evmws-analyze (240/мин) сбрасывается между фикстурами,
// кроме блока cap_band, где полоса и есть предмет проверки.
export function __resetEvmWsAnalyzeBandForTest(chainId?: string): void {
  for (const k of [...rateMap.keys()]) if (k.startsWith('evmws-analyze-') && (!chainId || k === `evmws-analyze-${chainId}`)) rateMap.delete(k);
}

// ─────────────── v9.32af FD1: hot-address polling (eth_getLogs batches instead of WS subs) ───────────────
// Prod (AF1/AF2/AF6): ~69 500 WS frames / 13 min ≈ 1M CU — Alchemy bills DELIVERY (~12-15 CU per
// event per subscription); local frame budgets can't cut that. Hot addresses (token contracts from
// v9.32ad + routers from v9.32y) move to pull-polling: one batched eth_getLogs (address in [...],
// topics=[Transfer], cursor..latest) ≈ 75 CU covers hundreds of events (~60× cheaper). WS stays
// for rare addresses (pools/settlements/reactors — AF7). Latency ≤ EVM_HOT_POLL_MS is acceptable
// for hot addresses (not warmup-dependent). EVM_HOT_POLL_MS=0 → legacy push subscriptions.
// v9.32bg BG1 (AR2): дефолт 6000 (было 15000). Свежесть: s420p 710 → 31, bsc stale 976 → 0.
// Ниже 2000 мс не опускаем даже с warmup-фактором — см. effectiveHotPollIntervalMs.
const EVM_HOT_POLL_MS = () => Number(process.env.EVM_HOT_POLL_MS || '6000');
const EVM_HOT_POLL_BATCH = () => Number(process.env.EVM_HOT_POLL_BATCH || '10');
// v9.32ah HD3 (AH4): fail-aware устойчивость hotPoll. (1) Деградация chunk→1 срабатывает на
// ЛЮБОЙ повторяющийся тип фейла (не только 420/400-отклонение мульти-фильтра): когда доля
// фейлов цикла превышает HOT_POLL_MAX_FAIL_RATE. (2) Эндпоинт с 3 фейлами подряд уходит в
// кулдаун на 5 минут — ротация по паблик-пулу ПРОЖДЕ Alchemy (AH4: 2 004 getLogs на Alchemy —
// платили за публичные фейлы). (3) Дополнительные getLogs-совместимые паблики (live-проверка
// 2026-08-31: eth — drpc.org; bsc — bsc.publicnode.com; dataseed-семейство режет -32005).
const HOT_POLL_MAX_FAIL_RATE = () => Number(process.env.HOT_POLL_MAX_FAIL_RATE ?? '0.5');
const HOT_POLL_URL_FAIL_STREAK = 3;
// v9.32aq B3 (AQ2): 5-мин cooldown слепил цепи (каскад 3×5мин = до 15 мин без эндпоинта).
// 75с ±20% jitter — рассинхронизирует эндпоинты, слепые окна ≤ cooldown; легаси через env.
const EVM_HOT_RETRY_AFTER_MAX_MS = () => finiteEnvNumber(process.env.EVM_HOT_RETRY_AFTER_MAX_MS, 600_000, 0); // W3 T7; нечисловое/<0 ⇒ 10 мин
export const __evmHotRetryAfterMaxMsForTest = (): number => EVM_HOT_RETRY_AFTER_MAX_MS();
const HOT_POLL_COOLDOWN_MS = () => Number(process.env.HOTPOLL_COOLDOWN_MS || '75000');
function hotPollCooldownJittered(): number {
  const base = HOT_POLL_COOLDOWN_MS();
  return Math.round(base * (0.8 + 0.4 * Math.random()));
}
// v9.32bc BC1: деградация chunk→1 обратима. GD3 остаётся («кадры не теряем»), но через
// HOTPOLL_CHUNK_RECOVERY_MS пробуем снова EVM_HOT_POLL_BATCH. bsc вне скоупа (RD2).
const HOTPOLL_CHUNK_RECOVERY_MS = () => Number(process.env.HOTPOLL_CHUNK_RECOVERY_MS || '600000');
// v9.32bc BC2: скользящее окно успешности. <50% на ≥20 вызовах → кул ENDPOINT_COOLDOWN_MS.
const ENDPOINT_COOLDOWN_MS = () => Number(process.env.ENDPOINT_COOLDOWN_MS || '300000');
const ENDPOINT_WINDOW = 20;
// v9.32be BE1 (AR1/AR2): пустая ротация (все эндпоинты цепи в куле) больше не тупик —
// через EMERGENCY_RESET_MS кулдауны сбрасываются, цикл продолжается. Виден счётчик.
const EMERGENCY_RESET_MS = () => Number(process.env.EMERGENCY_RESET_MS || '60000');
// v9.32be BE3 (AR5/AR6): нода обрезала историю → курсор откатывается к head - N.
const PRUNED_REWIND_BLOCKS = () => Number(process.env.PRUNED_REWIND_BLOCKS || '128');
// v9.32be BE4 (AR7b): официальная нода сети — первый приоритет ротации (эталон arb1: fails 0).
const OFFICIAL_HOT_NODES: Record<string, string> = {
  ethereum: 'https://ethereum-rpc.publicnode.com',
  bsc: 'https://bsc-rpc.publicnode.com',
  base: 'https://mainnet.base.org',
  arbitrum: 'https://arb1.arbitrum.io/rpc',
  polygon: 'https://polygon-bor-rpc.publicnode.com',
};
function isOfficialHotUrl(url: string, chainId: string): boolean {
  const off = OFFICIAL_HOT_NODES[chainId];
  return !!off && rpcHostOf(url) === rpcHostOf(off);
}
// v9.32be BE2 (AR3/AR4): эндпоинт, отвергающий мульти-адресный getLogs (-32601/-32602/
// "method not available"), НЕ исключается — он опрашивается поадресно (chunk=1) внутри
// evmHotRpc и не переводит всю сеть в degraded (1rpc.io: одиночный адрес → 203 лога).
function isMultiAddrIncapable(url: string): boolean { return endpointState.multiAddrHas(url); }
export function __multiAddrIncapableForTest(): string[] { return [...endpointState.multiAddrKeys()]; }
function isMultiAddrRejection(err: { code?: number; message?: string } | null | undefined): boolean {
  if (!err) return false;
  const code = Number(err.code);
  const msg = String(err.message || '').toLowerCase();
  if (code === -32601 || code === -32602) return true;
  return msg.includes('method not available') || msg.includes('method not found')
    || msg.includes('not supported') || msg.includes('unsupported method');
}
function isPrunedHistory(err: { message?: string } | null | undefined): boolean {
  const msg = String(err?.message || '').toLowerCase();
  return msg.includes('has been pruned') || msg.includes('history has been pruned')
    || msg.includes('missing trie node') || msg.includes('state is not available')
    // v9.32bf BF3: "archive request" — та же природа: запрошенная глубина недоступна ноде.
    || msg.includes('archive requests require');
}
// v9.32bf BF3 (AR8): нода назвала свой предел ДИАПАЗОНА — сузить окно, а не считать отказом.
// Живые формулировки 2026-09-07: "log query range must not exceed 25 blocks" (blockrazor),
// "eth_getLogs is limited to 0 - 50 blocks range" (1rpc).
// ВАЖНО: код -32005 сюда НЕ входит. dataseed отдаёт -32005 "limit exceeded" и при span=3
// (живая проверка) — это троттлинг эндпоинта, а не ширина окна; сужать span бессмысленно,
// и HD3-деградация на такой фейл обязана срабатывать по-прежнему.
function isRangeTooWide(err: { code?: number; message?: string } | null | undefined): boolean {
  const msg = String(err?.message || '').toLowerCase();
  return msg.includes('query range') || msg.includes('blocks range') || msg.includes('block range')
    || msg.includes('range must not exceed') || msg.includes('exceed maximum block range')
    || msg.includes('too many results') || msg.includes('response size') || msg.includes('query returned more than');
}
// ТЗ 2026-10-09 §3.1 req 5: последняя классифицированная причина отказа по цепи. Журнал покрытия
// пишет её в gap, чтобы «дыра» была с причиной (auth_required / rate_limited / timeout / …),
// а не с безликим «failed».
const evmHotLastFailure: Record<string, { outcome: GetLogsOutcome; detail: string } | null> = {};
export function __evmHotLastFailureForTest(chainId: string): { outcome: GetLogsOutcome; detail: string } | null {
  return evmHotLastFailure[chainId] || null;
}
const evmHotPrunedSignal: Record<string, boolean> = {};
const evmHotRangeSignal: Record<string, boolean> = {}; // v9.32bf BF3: нода отбила диапазон по ширине
// ТЗ 2026-10-20 (#257 root-cause Fix 2): URL, хоть раз успешно ответивший на eth_getLogs в
// этом процессе — он доказал способность читать логи, его лимит ширины информативен. Нода с
// нулём успехов (прод-кейс bsc-dataseed: -32005 на ЛЮБОМ диапазоне, GD3) не имеет права
// схлопывать chain-global span, пока ширину обслуживает хотя бы один доказавший пэр.
const evmHotGetLogsCapableUrls = new Set<string>();
const evmHotRangeIncapableOnly: Record<string, boolean> = {};
const HOTPOLL_SPAN_NARROW_CAPABLE_ONLY = () => (process.env.HOTPOLL_SPAN_NARROW_CAPABLE_ONLY ?? '1') !== '0';
function takeEvmHotRangeIncapableOnly(chainId: string): boolean {
  const v = evmHotRangeIncapableOnly[chainId] === true;
  evmHotRangeIncapableOnly[chainId] = false;
  return v;
}
const evmHotAllCooledSince: Record<string, number> = {};
const ENDPOINT_MIN_SUCCESS = 0.5;
let hotPollNowOverride: number | null = null;
function hotPollNow(): number { return hotPollNowOverride ?? Date.now(); }
export function __setHotPollNowForTest(n: number | null): void { hotPollNowOverride = n; }
const evmHotChunkDegradedAt: Record<string, number> = {};
const endpointState = wsHotPoll.endpoints; // endpoint health state: owned by evm runtime, accessed through methods only
const EVM_HOT_POLL_EXTRA_DEFAULT: Record<string, string> = {
  // v9.32bd BD1: eth.drpc.org снят (free-plan timeout на getLogs). Пул ethereum =
  // primary publicnode + 1rpc + nodies (live multi-address getLogs 2026-09-06, 331 logs).
  ethereum: 'https://1rpc.io/eth,https://eth-pokt.nodies.app',
  // v9.32bf BF4 (AR10): живая проверка 2026-09-07 при span=20 — publicnode OK 578,
  // blockrazor OK 578, 1rpc OK 578. Отказы bd/be ("personal token", "limit exceeded")
  // были следствием ширины диапазона, а не закрытого getLogs: пул bsc жив, Alchemy не нужен.
  // dataseed-семейство по-прежнему режет -32005 на любом диапазоне (GD3) — в пул не берём.
  bsc: 'https://bsc-rpc.publicnode.com,https://bsc.blockrazor.xyz,https://1rpc.io/bnb',
};
// v9.32bd BD2: потолок Alchemy eth_getLogs. Исчерпан → пропуск цикла, не оплата (RD2).
const ALCHEMY_GETLOGS_PER_MIN = () => Number(process.env.ALCHEMY_GETLOGS_PER_MIN || '20');
const alchemyGetLogsAt: number[] = [];
function pruneAlchemyGetLogsWindow(): number {
  const now = hotPollNow();
  const kept = alchemyGetLogsAt.filter(t => now - t < 60_000);
  alchemyGetLogsAt.length = 0;
  alchemyGetLogsAt.push(...kept);
  return kept.length;
}
function alchemyGetLogsCapReached(): boolean {
  const cap = ALCHEMY_GETLOGS_PER_MIN();
  if (!(cap > 0)) return false;
  return pruneAlchemyGetLogsWindow() >= cap;
}
function noteAlchemyGetLogsCall(): void {
  alchemyGetLogsAt.push(hotPollNow());
}
// Method capability is independent of transient transport health. A blockNumber
// success (or an emergency transport probe) cannot authorize eth_getLogs.
function hotPollUrlCooled(url: string, now: number, method = 'eth_getLogs'): boolean {
  const h = endpointState.healthGet(url);
  return (h?.until ?? 0) > now || (h?.providerRetryAfterUntil ?? 0) > now
    || (method === 'eth_getLogs' && (h?.getLogsAuthUntil ?? 0) > now);
}
// A BSC filter can be throttled while another filter is still served by the same public URL.
// Retain only bounded, hashed URL+filter identities (not raw URLs, keys, addresses or topics).
const bscGetLogsRateByRoute = new Map<string, number>();
// Only a complete same-request public-pool 429 is evidence to pause *all* BSC filters.
// A single endpoint's 429 remains scoped to its filter so another address/holder can recover.
let bscAllPublicRateUntil = 0;
let bscHolderRescueEpochUntil = 0; // one bounded rescue per pool-wide cooldown window
let bscHolderRescueProbes = 0;
let bscHolderRescueSplitRoot: { from: number; to: number } | null = null;
const BSC_HOLDER_SPLIT_RESCUE_MAX_PROBES = 32; // still below the 64-attempt default cycle cap
const BSC_GETLOGS_RATE_ROUTE_MAX = 512;
function bscGetLogsRateRoute(url: string, params: unknown[]): string {
  const filter = params[0];
  const identity = filter && typeof filter === 'object' && !Array.isArray(filter)
    ? Object.keys(filter).filter(k => k !== 'fromBlock' && k !== 'toBlock').sort()
      .map(k => [k, (filter as Record<string, unknown>)[k]])
    : params;
  return createHash('sha256').update(url).update('\0').update(JSON.stringify(identity).toLowerCase()).digest('hex');
}
function isBscHolderTopicFilter(params: unknown[]): boolean {
  const filter = params[0] as { address?: unknown; topics?: unknown } | undefined;
  const topics = filter?.topics;
  return filter?.address === undefined && Array.isArray(topics) && topics.length === 3
    && Array.isArray(topics[0]) && topics[0].length === 1 && topics[0][0] === TRANSFER_TOPIC
    && Array.isArray(topics[1]) && topics[1].length === 0
    && Array.isArray(topics[2]) && topics[2].length === 1;
}
function bscHolderFilterRange(params: unknown[]): { from: number; to: number } | null {
  const f = params[0] as { fromBlock?: unknown; toBlock?: unknown } | undefined;
  if (typeof f?.fromBlock !== 'string' || typeof f.toBlock !== 'string'
      || !/^0x[0-9a-f]+$/i.test(f.fromBlock) || !/^0x[0-9a-f]+$/i.test(f.toBlock)) return null;
  const from = Number.parseInt(f.fromBlock, 16), to = Number.parseInt(f.toBlock, 16);
  return Number.isSafeInteger(from) && Number.isSafeInteger(to) && to >= from ? { from, to } : null;
}
function hotPollRequestCooled(chainId: string, url: string, now: number, method: string, params: unknown[]): boolean {
  if (hotPollUrlCooled(url, now, method)) return true;
  // Do not let local optimization change paid-reserve or legacy fail-policy routing.
  return method === 'eth_getLogs' && chainId === 'bsc' && !alchemyHttpEnabled(chainId)
    && HOT_POLL_MAX_FAIL_RATE() < 1
    && (bscAllPublicRateUntil > now
      || (bscGetLogsRateByRoute.get(bscGetLogsRateRoute(url, params)) ?? 0) > now);
}
// An eth_blockNumber success does not demonstrate that an endpoint can serve BSC logs.
// Keep a short, method-only cooldown for a classified getLogs rate limit without Retry-After;
// an explicit provider deadline remains the floor. If a paid reserve is configured, preserve
// the old routing: cooling all publics earlier would otherwise unlock an extra paid request.
function coolBscRateLimitedGetLogs(chainId: string, url: string, params: unknown[]): void {
  if (chainId !== 'bsc' || alchemyHttpEnabled(chainId) || HOT_POLL_MAX_FAIL_RATE() >= 1) return;
  // A multi-address rejection may still be served by the existing per-address fallback.
  // Do not cool its endpoints before that legacy retry path can run.
  const address = (params[0] as { address?: unknown } | undefined)?.address;
  if (Array.isArray(address) && address.length > 1) return;
  const rec = endpointState.healthGet(url) ?? { fails: 0, until: 0 };
  rec.getLogsRateUntil = Math.max(rec.getLogsRateUntil ?? 0, rec.providerRetryAfterUntil ?? 0,
    hotPollNow() + hotPollCooldownJittered()); // last rate-limit evidence, not a URL-wide routing gate
  endpointState.healthSet(url, rec);
  for (const [key, until] of bscGetLogsRateByRoute) if (until <= hotPollNow()) bscGetLogsRateByRoute.delete(key);
  const route = bscGetLogsRateRoute(url, params);
  bscGetLogsRateByRoute.delete(route);
  while (bscGetLogsRateByRoute.size >= BSC_GETLOGS_RATE_ROUTE_MAX)
    bscGetLogsRateByRoute.delete(bscGetLogsRateByRoute.keys().next().value!);
  bscGetLogsRateByRoute.set(route, rec.getLogsRateUntil);
}
function clearHotPollTransportHealth(url: string): void {
  const h = endpointState.healthGet(url);
  const now = hotPollNow();
  const authUntil = (h?.getLogsAuthUntil ?? 0) > now ? h!.getLogsAuthUntil : undefined;
  const retryUntil = (h?.providerRetryAfterUntil ?? 0) > now ? h!.providerRetryAfterUntil : undefined;
  const rateUntil = (h?.getLogsRateUntil ?? 0) > now ? h!.getLogsRateUntil : undefined;
  if (authUntil || retryUntil || rateUntil) {
    endpointState.healthSet(url, { fails: 0, until: retryUntil ?? 0,
      getLogsAuthUntil: authUntil, providerRetryAfterUntil: retryUntil, getLogsRateUntil: rateUntil });
  } else endpointState.healthDelete(url);
}
function noteHotPollRetryAfter(url: string, response: Response): void {
  if (response.status !== 429) return;
  const seconds = parseRetryAfterSeconds(response.headers?.get?.('retry-after') ?? null, hotPollNow());
  if (seconds === null || seconds <= 0) return;
  const rec = endpointState.healthGet(url) ?? { fails: 0, until: 0 };
  const until = hotPollNow() + Math.min(seconds * 1000, EVM_HOT_RETRY_AFTER_MAX_MS());
  rec.providerRetryAfterUntil = Math.max(rec.providerRetryAfterUntil ?? 0, until);
  rec.until = Math.max(rec.until, rec.providerRetryAfterUntil);
  endpointState.healthSet(url, rec);
}
function hotPollAnyPublicLive(chainId: string): boolean {
  const now = hotPollNow();
  return evmHotPollRpcUrls(chainId).some(u => !isAlchemyHotUrl(u, chainId) && !hotPollUrlCooled(u, now));
}
function evmHotPollEnabled(): boolean { return EVM_HOT_POLL_MS() > 0; }
// v9.32ag GD3: публичные BSC-ноды не принимают multi-address eth_getLogs (bsc fails 64/65),
// поэтому chunk-размер батча конфигурируется пер-цепочечно: EVM_HOT_POLL_{CHAIN}_CHUNK
// (дефолт bsc=1 — пер-адресно; прочие цепочки = EVM_HOT_POLL_BATCH). Отказ мульти-адресного
// батча (420/400/пустой result) → автореградация до 1 + лог + пер-адресный ретрай в том же
// цикле (кадры не теряем). Состояние видно в статусе: hotPoll.{chain}.{chunkSize,degraded}.
const evmHotChunkOverride: Record<string, number> = {}; // runtime-деградация: chainId → эффективный chunk
function evmHotChunkConfigured(chainId: string): number {
  const raw = process.env['EVM_HOT_POLL_' + chainId.toUpperCase() + '_CHUNK'];
  if (raw !== undefined && raw !== '') {
    const n = Number(raw);
    if (Number.isFinite(n) && n >= 1) return Math.floor(n);
  }
  return chainId === 'bsc' ? 1 : Math.max(1, EVM_HOT_POLL_BATCH() || 1);
}
function evmHotChunkEffective(chainId: string): number {
  const o = evmHotChunkOverride[chainId];
  return o !== undefined ? o : evmHotChunkConfigured(chainId);
}
function evmHotChunkDegraded(chainId: string): boolean {
  return evmHotChunkOverride[chainId] !== undefined && evmHotChunkOverride[chainId] < evmHotChunkConfigured(chainId);
}
type EvmHotStepProgress = {
  from: number; to: number;
  /**
   * Review #5 P1: обязательство диапазона — union всех адресов, бывших в списке, пока диапазон
   * незавершён. Смена membership прогресс НЕ сбрасывает: новые адреса добавляются в обязательство
   * (done их не содержит — будут реально опрошены), выбывшие остаются в обязательстве, пока не
   * прочитаны (слепо «зачесть» выбывшего нельзя — он дочитывается для замороженного диапазона,
   * даже вне текущего hot-list). Порядок списка значения не имеет.
   */
  requiredAddrs: Set<string>; requiredHolders: Set<string>;
  /** Адреса, чьи чанки для ЭТОГО диапазона уже реально прочитаны (межцикловый прогресс шага). */
  done: Set<string>; doneHolders: Set<string>;
};
const evmHotPollState: Record<string, { cursor: number | null; timer: NodeJS.Timeout | null; stepProgress?: EvmHotStepProgress | null }> = {};
// ТЗ 2026-10-09 §3.1 req 1: счётчики исходов ЛОГИЧЕСКИХ вызовов eth_getLogs по цепям. Единица —
// попытка вызова, НЕ сделка. Прежде рантайм знал шесть значений, и auth-отказ (прод polygon:
// «personal token required», getLogsFailed=258) был неотличим от таймаута и от 429.
const evmHotOutcomeCounts: Record<string, Partial<Record<GetLogsOutcome, number>>> = {};
function noteGetLogsOutcome(chainId: string, o: GetLogsOutcome): void {
  const m = evmHotOutcomeCounts[chainId] || (evmHotOutcomeCounts[chainId] = {});
  m[o] = (m[o] || 0) + 1;
}
export function __noteGetLogsOutcomeForTest(chainId: string, o: GetLogsOutcome, n = 1): void {
  for (let i = 0; i < n; i++) noteGetLogsOutcome(chainId, o);
}
// ТЗ 2026-10-09 §3.1 req 3/4/5: per-chain последний непрерывно обработанный диапазон + bounded
// unresolved ranges. Checkpoint не продвигается за failed/unknown range; невосстановимый диапазон
// публикуется как явный coverage_gap.
const evmHotCoverage = createCoverageLedger({
  maxUnresolved: Math.max(1, Number(process.env.EVM_COVERAGE_MAX_UNRESOLVED || '24')),
  maxRetries: Math.max(1, Number(process.env.EVM_COVERAGE_MAX_RETRIES || '5')),
  retryBaseMs: Math.max(1000, Number(process.env.EVM_COVERAGE_RETRY_BASE_MS || '15000')),
  retryMaxMs: Math.max(1000, Number(process.env.EVM_COVERAGE_RETRY_MAX_MS || '600000')),
  maxGapAgeMs: Math.max(1000, Number(process.env.EVM_COVERAGE_GAP_AGE_MS || '3600000')),
});
export function __evmHotCoverageForTest() { return evmHotCoverage; }
// ТЗ 2026-10-14 §3-M1: durable-журнал обязательств/доказательств. Версия политики фиксируется
// явно (смена правил = новая версия, старые обязательства не «доказываются» новыми чтениями).
// Хранилище включается только при DATABASE_URL; без него журнал работает в RAM-режиме и честно
// сообщает `measurementUnknownStorage=true` (durable-улики нет — «0 missed» не заявляется).
const FILTER_EPOCH_POLICY_VERSION = process.env.FILTER_EPOCH_POLICY_VERSION || 'v1';
const filterEpochBoot = process.env.RAILWAY_DEPLOYMENT_ID || process.env.HOTPOLL_BOOT_ID || 'unknown';
const filterEpochSha = process.env.RAILWAY_GIT_COMMIT_SHA || process.env.GIT_SHA || 'unknown';
function makeEvmFilterEpoch(store: FilterEpochStore | null): FilterEpochRuntime {
  return createFilterEpochJournal({
    store, now: hotPollNow, policyVersion: FILTER_EPOCH_POLICY_VERSION,
    boot: filterEpochBoot, sha: filterEpochSha,
    maxRows: Math.max(16, Number(process.env.FILTER_EPOCH_MAX_ROWS || '5000')),
    maxOpenPerChain: Math.max(2, Number(process.env.FILTER_EPOCH_MAX_OPEN_PER_CHAIN || '256')),
    // TTL читается здесь один раз на конструкцию журнала: смена FILTER_EPOCH_PROVEN_TTL_MS
    // применяется только рестартом процесса (capacity-коллектор читает env живьём и показывает
    // действующее значение — расхождение без рестарта видно в его ответе provenTtlMs).
    provenTtlMs: provenTtlMs(),
  });
}
let evmFilterEpoch = makeEvmFilterEpoch(createFilterEpochStore());
export function __evmFilterEpochForTest(): FilterEpochRuntime { return evmFilterEpoch; }
export function __setEvmFilterEpochStoreForTest(store: FilterEpochStore | null): void {
  evmFilterEpoch = makeEvmFilterEpoch(store);
}
/** Восстановление после рестарта: открытые обязательства и доказательства из durable-журнала. */
export async function __hydrateEvmFilterEpochForTest(): Promise<{ obligations: number; proven: number }> {
  return hydrateFilterEpochObligations('test');
}
// ТЗ 2026-10-13 §4 (P1): обязательство диапазона не теряется при снятии прогресса шага.
// Снятие прогресса (head rewind / диапазон ушёл за курсор) раньше выбрасывало union
// `requiredAddrs/requiredHolders`; после этого успех окна с УЖЕ УРЕЗАННЫМ (текущим) списком
// закрывал диапазон по блочному покрытию, хотя выбывший-непрочитанный адрес не запрашивался.
// Хранилище держит потерянные обязательства per-chain; журнал помечает такие диапазоны
// `proofRequired` и не закрывает их блочным покрытием — только доказанным чтением ТОГО ЖЕ диапазона.
const EVM_HOT_OBLIGATION_MAX = 8;
const evmHotObligationStore: Record<string, Map<string, EvmHotStepProgress>> = {};
const evmHotObligationKey = (from: number, to: number): string => `${from}:${to}`;

/**
 * Ревью #264 D1: есть ли в прогрессе НЕПРОчитанный обязательный фильтр. `isReplay` проверяет
 * только старый блочный checkpoint и не доказывает, что required-набор ЭТОГО шага читался: новый
 * адрес мог появиться в hot-list уже после покрытия блоков (и ни разу не запрашиваться).
 * Молчаливое снятие прогресса допустимо лишь при пустом результате этой проверки.
 */
function stepProgressUnproven(prog: EvmHotStepProgress): boolean {
  return [...prog.requiredAddrs].some(a => !prog.done.has(a))
    || [...prog.requiredHolders].some(h => isValidEvmAddress(h) && !prog.doneHolders.has(h));
}

/** Переносит необработанные фильтры снятого прогресса в хранилище и помечает дыры журнала. */
function dropStepProgressObligation(chainId: string, prog: EvmHotStepProgress, reason: string): void {
  const unprovenAddrs = [...prog.requiredAddrs].filter(a => !prog.done.has(a));
  const unprovenHolders = [...prog.requiredHolders].filter(h => isValidEvmAddress(h) && !prog.doneHolders.has(h));
  if (!unprovenAddrs.length && !unprovenHolders.length) return; // обязательство исполнено — терять нечего
  const store = evmHotObligationStore[chainId] || (evmHotObligationStore[chainId] = new Map());
  const key = evmHotObligationKey(prog.from, prog.to);
  const prev = store.get(key);
  store.set(key, {
    from: prog.from, to: prog.to,
    requiredAddrs: new Set([...(prev?.requiredAddrs ?? []), ...prog.requiredAddrs]),
    requiredHolders: new Set([...(prev?.requiredHolders ?? []), ...prog.requiredHolders]),
    done: new Set([...(prev?.done ?? []), ...prog.done]),
    doneHolders: new Set([...(prev?.doneHolders ?? []), ...prog.doneHolders]),
  });
  while (store.size > EVM_HOT_OBLIGATION_MAX) store.delete(store.keys().next().value as string);
  // Ревью #264 EVICT: журнал получает НЕ только диапазон, но и требуемые фильтры. Вытеснение записи
  // из RAM-хранилища (лимит EVM_HOT_OBLIGATION_MAX) больше не позволяет закрыть обязательство
  // доказательствами чужих окон: запись о нём живёт в журнале до доказательства того же диапазона
  // с надмножеством фильтров (или до решения владельца о backfill/reproof).
  evmHotCoverage.noteProofPending(chainId, prog.from, prog.to, `obligation-unproven:${reason}`, hotPollNow(), {
    addrs: unprovenAddrs, holders: unprovenHolders,
  });
  // ТЗ 2026-10-14 §3-M1: то же обязательство — durable (persist-before-attempt для шага уже был; здесь
  // фиксируется его потеря с причиной). Ошибки записи не бросаются наружу: они видны в summary как
  // measurementUnknownStorage/failed, а обязательство остаётся видимым в coverage ledger.
  // Раунд 2 (находка R3): исключённые цепи (Polygon) не пишутся и здесь — иначе реальный отказ
  // чтения создавал бы durable-строку, которая после рестарта воспроизвелась бы M1-артефактами.
  if (needsDurableEpoch(chainId)) {
    void evmFilterEpoch.noteObligation({
      chainId, fromBlock: prog.from, toBlock: prog.to,
      addrs: unprovenAddrs, holders: unprovenHolders,
      reason: `unproven:${reason}`, membershipSources: ['drop'],
    }).catch(() => { /* fail state already recorded in summary */ });
  }
}

/**
 * Достижимое потерянное обязательство для живого окна, начинающегося на fromNum: диапазон обязан
 * начинаться ПОЗАДИ окна (иначе это не возврат, а захват живого префикса — WP-E2/P2 control),
 * пересекать его и не лежать позади курсора. Дальние и уехавшие вперёд диапазоны не возвращаются:
 * их судьба — видимая дыра и решение владельца (ТЗ §4/§8), а не молчаливая потеря.
 */
function storedObligationForWindow(chainId: string, fromNum: number, cursor: number | null): EvmHotStepProgress | null {
  const store = evmHotObligationStore[chainId];
  if (!store) return null;
  for (const e of store.values()) {
    if (!(e.from < fromNum && e.to >= fromNum)) continue;
    if (cursor != null && e.to <= cursor) continue;
    return e;
  }
  return null;
}
// 'skipped-cooled' (ТЗ 2026-10-12 WP-D1): попытки eth_getLogs НЕ БЫЛО — все эндпоинты цепи в
// кулдауне и Alchemy для цепи не настроен. Это НЕ провал транспорта: вызов не коснулся сети,
// поэтому логический исход обязан попадать в skip-класс, а не растить getLogsFailed
// (прод polygon: 663 failed / 0 logs без единого класса отказа в outcome-окне).
export type EvmHotLogicalOutcome = 'ok-nonempty' | 'ok-empty' | 'neutral-range' | 'neutral-pruned' | 'failed' | 'skipped-cap' | 'skipped-cooled' | 'skipped-budget';
// One signal belongs to one logical eth_getLogs operation. Endpoint retries stay inside
// evmHotRpc; the caller consumes exactly one signal after the logical operation returns.
const evmHotLogicalOutcome: Record<string, EvmHotLogicalOutcome | null> = {};
type BscWidthEvidenceRow = {
  slot: string; filterKind: 'address-1' | 'address-many' | 'holder';
  attempts: number; success: number; tooWide: number; rateLimited: number; authRequired: number; other: number;
  maxSuccessWidth: number | null; minTooWideWidth: number | null; lastAt: number;
};
type BscWidthEvidence = {
  unit: 'provider_attempt'; widthUnit: 'block'; scope: 'evmHotRpc';
  rows: BscWidthEvidenceRow[]; unattributed: number; partial: boolean;
};
type EvmHotPollMetrics = {
  cycles: number; logs: number; txs: number; calls: number; fails: number;
  admissionDeferredCycles: number; admissionTerminalCycles: number; rangeStepFailures: number;
  /** ТЗ 2026-10-14 (H2/H5): циклы, в которых шаг не читался из-за неготовой durable-улики. */
  filterEpochBlocked?: number;
  /** ТЗ 2026-10-14 (F4): перекрывающиеся запуски цикла, пропущенные из-за незавершённого предыдущего. */
  cyclesSkippedBusy?: number;
  rangeAdmission: { state: 'idle' | 'admitted' | 'deferred' | 'provider-deferred' | 'terminal'; fromBlock?: number; toBlock?: number; nextRetryAt?: number | null; detail?: string | null };
  counterUnits: { getLogsAttempts: 'provider_attempt'; calls: 'logical_call'; rangeStepFailures: 'failed_range_step';
    admissionDeferredCycles: 'poll_cycle'; admissionTerminalCycles: 'poll_cycle'; getLogsOutcomes: 'classification_event' };
  // D4-R additive logical-call counters. They are cumulative for the boot.
  getLogsOk: number; getLogsEmpty: number; getLogsFailed: number;
  getLogsNeutral: number; getLogsSkipped: number;
  chunkSize: number; degraded: boolean; endpointOk: number; endpointFail: number;
  cursor: number | null; blocksScanned: number; lastLogAt: number; addrs: number; getLogsIncapable: number;
  endpoints?: Record<string, { ok: number; fail: number }>;
  /** BSC-only, bounded, secret-free observations of existing provider attempts (not candidate counts). */
  getLogsWidthEvidence?: BscWidthEvidence;
  allowanceHolder?: { calls: number; logs: number };
  blindMs: number; blindSince: number; chunkRecoveryAt?: number; endpointsCooled?: string[];
  alchemyShare?: number; getLogsAttempts?: number; getLogsAlchemy?: number; alchemyCapSkipped?: number;
  cursorBehind?: number; catchUpCycles?: number; spanBlocks?: number; spanNarrows?: number; spanWidens?: number;
  emergencyResets?: number; prunedRewinds?: number; multiAddrIncapable?: string[]; addrList?: string[];
  // ТЗ 2026-10-09 §3.1 req 1/5: исходы логических вызовов и журнал покрытия. Единицы:
  // getLogsOutcomes — legacy classification events (attempts and aggregate outcomes); gaps — ranges.
  // calls/getLogsOk/Failed/Neutral/Skipped — logical calls; getLogsAttempts — provider attempts.
  getLogsOutcomes?: Partial<Record<GetLogsOutcome, number>>;
  coverage?: {
    checkpoint: number | null; checkpointUnit: 'block';
    verdict: 'ok' | 'coverage_gap';
    gaps: Array<{ fromBlock: number; toBlock: number; outcome: GetLogsOutcome; detail: string;
      attempts: number; state: string; nextRetryAt: number | null;
      /**
       * ТЗ 2026-10-13 §4: диапазон потерял обязательство по фильтрам до доказательства — закрытие
       * возможно только реальным чтением ТОГО ЖЕ диапазона со всем union-набором, а не блочным
       * покрытием соседних окон. Отсутствует у обычных дыр.
       */
      proofRequired?: boolean;
      /** Ревью #264 EVICT: включённые в дыру неисполненные обязательства (без невосстановимого маркера). */
      pendingProofs?: number;
      /** Ревью #264 EVICT: состав обязательств невосстановим (переполнение) — backfill/reproof. */
      proofSticky?: boolean }>;
    /** ТЗ 2026-10-14 §3-M1: состояние durable-журнала обязательств (без адресов и секретов). */
    filterEpoch?: {
      recovery?: ReturnType<typeof planFilterEpochRecovery>;
      policyVersion: string; configured: boolean; storage: 'ok' | 'unavailable' | 'failed';
      measurementUnknownStorage: boolean; storageError: string | null;
      openObligations: number; provenObligations: number; hydrated: boolean;
      capExceeded: boolean; proofConflicts: number; journalFailures: number;
      boot: string; sha: string;
    };
    evicted: number;
    /**
     * Момент первого наблюдения за цепью в ЭТОМ процессе; null — наблюдение не началось.
     * Журнал не персистится намеренно (курс hotPoll тоже in-memory), поэтому рестарт обязан быть
     * виден: «gaps: []» при свежем observingSince — это «ещё не наблюдали», а не «покрытие есть».
     */
    observingSince: number | null;
    units: { gaps: 'range'; attempts: 'failed_range_step' };
  };
};
function setEvmHotLogicalOutcome(chainId: string, outcome: EvmHotLogicalOutcome): void {
  evmHotLogicalOutcome[chainId] = outcome;
}
function takeEvmHotLogicalOutcome(chainId: string): EvmHotLogicalOutcome | null {
  const outcome = evmHotLogicalOutcome[chainId] || null;
  evmHotLogicalOutcome[chainId] = null;
  return outcome;
}
function noteEvmHotLogicalOutcome(chainId: string, outcome: EvmHotLogicalOutcome): void {
  const m = evmHotM(chainId);
  if (outcome === 'ok-empty') { m.getLogsOk++; m.getLogsEmpty++; }
  else if (outcome === 'ok-nonempty') m.getLogsOk++;
  else if (outcome === 'failed') m.getLogsFailed++;
  else if (outcome === 'skipped-cap' || outcome === 'skipped-cooled' || outcome === 'skipped-budget') m.getLogsSkipped++;
  else m.getLogsNeutral++;
}
function classifyEvmHotLogicalOutcome(
  chainId: string,
  result: Array<{ transactionHash?: string; address?: string }> | null,
): EvmHotLogicalOutcome {
  const signalled = takeEvmHotLogicalOutcome(chainId);
  if (signalled) return signalled;
  if (Array.isArray(result)) return result.length > 0 ? 'ok-nonempty' : 'ok-empty';
  return 'failed';
}

function degradeHotChunk(chainId: string, m: EvmHotPollMetrics): void {
  evmHotChunkOverride[chainId] = 1;
  evmHotChunkDegradedAt[chainId] = hotPollNow();
  m.chunkSize = 1;
  m.degraded = evmHotChunkDegraded(chainId);
}
function maybeRecoverHotChunk(chainId: string): void {
  // AR4 / RD2: bsc вне восстановления — GD3 доказал fails 64/65, дефолт 1.
  if (chainId === 'bsc') return;
  if (!evmHotChunkDegraded(chainId)) return;
  const at = evmHotChunkDegradedAt[chainId] || 0;
  if (hotPollNow() - at < HOTPOLL_CHUNK_RECOVERY_MS()) return;
  delete evmHotChunkOverride[chainId];
  log(`[hotpoll] ${chainId}: probing chunk=${evmHotChunkConfigured(chainId)} after ${HOTPOLL_CHUNK_RECOVERY_MS()}ms`);
}
function refreshHotPollTelemetry(chainId: string): void {
  const m = evmHotM(chainId);
  const now = hotPollNow();
  const urls = evmHotPollRpcUrls(chainId);
  m.endpointsCooled = urls.filter(u => hotPollUrlCooled(u, now)).map(u => rpcHostOf(u));
  const attempts = m.getLogsAttempts || 0;
  const alch = m.getLogsAlchemy || 0;
  m.alchemyShare = attempts > 0 ? alch / attempts : 0;
  m.chunkRecoveryAt = evmHotChunkDegraded(chainId) ? (evmHotChunkDegradedAt[chainId] || 0) + HOTPOLL_CHUNK_RECOVERY_MS() : 0;
  m.multiAddrIncapable = urls.filter(u => isMultiAddrIncapable(u)).map(u => rpcHostOf(u)); // v9.32be AR3
  m.emergencyResets = m.emergencyResets || 0; // v9.32be AR2
  m.prunedRewinds = m.prunedRewinds || 0; // v9.32be AR6
  // ТЗ 2026-10-09 §3.1 req 1/5: причины и дыры видны в status по каждой цепи раздельно.
  m.getLogsOutcomes = { ...(evmHotOutcomeCounts[chainId] || {}) };
  const cov = evmHotCoverage.snapshotAt(chainId, hotPollNow());
  m.coverage = {
    checkpoint: cov.checkpoint, checkpointUnit: 'block', verdict: cov.verdict,
    gaps: cov.gaps.map(g => ({
      fromBlock: g.fromBlock, toBlock: g.toBlock, outcome: g.outcome, detail: g.detail,
      attempts: g.attempts, state: g.state, nextRetryAt: g.nextRetryAt,
      // ТЗ 2026-10-11 §4: terminal без причины не actionable. 'cursor-advanced' = живой поллер ушёл
      // за дыру, повтора не будет — закрытие требует решения владельца (backfill/доступ), а не ретрая.
      terminalReason: g.terminalReason ?? null,
      // ТЗ 2026-10-13 §4: видимость потерянного обязательства — статус не имеет права показывать
      // такую дыру как обычную, закрываемую блочным покрытием.
      proofRequired: g.proofRequired === true ? true : undefined,
      // Ревью #264 EVICT: сколько обязательств ещё не исполнено и восстановим ли их состав.
      // proofSticky=true — идентичности обязательств потеряны (переполнение журнала обязательств):
      // дыра видима и не закрывается чужими доказательствами, закрытие — решение владельца
      // (backfill/reproof), а не ретрай.
      pendingProofs: g.proofRequired === true ? (g.pendingProofs ?? []).filter(p => !p.sticky).length : undefined,
      proofSticky: (g.proofRequired === true && (g.pendingProofs ?? []).some(p => p.sticky)) ? true : undefined,
    })),
    // ТЗ 2026-10-14 §3-M1: durable-улика обязательств. Без хранилища/при отказе записи —
    // measurementUnknownStorage=true: отчёт не вправе называть это «0 missed».
    filterEpoch: (() => {
      const summary = evmFilterEpoch.summary();
      return { ...summary, recovery: planFilterEpochRecovery(evmFilterEpoch.openFor(chainId), summary, chainId) };
    })(),
    evicted: cov.evicted,
    observingSince: cov.observingSince,
    units: { gaps: 'range', attempts: 'failed_range_step' },
  };
}
const evmHotPollMetrics: Record<string, EvmHotPollMetrics> = {};
let evmHotAddrsOverride: Record<string, string[] | null> = {};
function padTopicAddress(addr: string): string {
  return '0x' + addr.replace(/^0x/i, '').toLowerCase().padStart(64, '0');
}
function evmHotM(chainId: string): EvmHotPollMetrics {
  if (!evmHotPollMetrics[chainId]) evmHotPollMetrics[chainId] = {
    cycles: 0, logs: 0, txs: 0, calls: 0, fails: 0,
    admissionDeferredCycles: 0, admissionTerminalCycles: 0, rangeStepFailures: 0, rangeAdmission: { state: 'idle' },
    counterUnits: { getLogsAttempts: 'provider_attempt', calls: 'logical_call', rangeStepFailures: 'failed_range_step',
      admissionDeferredCycles: 'poll_cycle', admissionTerminalCycles: 'poll_cycle', getLogsOutcomes: 'classification_event' },
    getLogsOk: 0, getLogsEmpty: 0, getLogsFailed: 0, getLogsNeutral: 0, getLogsSkipped: 0,
    chunkSize: evmHotChunkEffective(chainId), degraded: evmHotChunkDegraded(chainId), endpointOk: 0,
    endpointFail: 0, cursor: null, blocksScanned: 0, lastLogAt: 0, addrs: 0, getLogsIncapable: 0,
    endpoints: {}, allowanceHolder: { calls: 0, logs: 0 }, blindMs: 0, blindSince: 0,
  };
  return evmHotPollMetrics[chainId];
}
const HOTPOLL_TOP_TOKENS = () => Number(process.env.HOTPOLL_TOP_TOKENS ?? '12'); // v9.32aq B2
function evmHotAddrs(chainId: string): string[] {
  const o = evmHotAddrsOverride[chainId];
  if (o) return o;
  const meta = chainWatchMeta[chainId] || {};
  const topN = HOTPOLL_TOP_TOKENS();
  let toks: string[] = [];
  let pools: string[] = []; // v9.32aq B2: топ-пул каждого топ-токена (Transfer летит и с токена, и с пула)
  if (topN > 0) {
    // v9.32aq B2 (AQ1): хот-лист = топ-N WL-токенов по vol24h, СУБС-независимо (прод с
    // EVM_TOKEN_SUBS=2 поллил 2 адреса — FLORK/AERO/BLUECHIP-класс вне WS терялся).
    const by = new Map<string, { vol: number; pool: string }>();
    for (const e of Object.values(meta)) {
      const tok = String(e.subjectToken || '');
      if (!isValidEvmAddress(tok)) continue;
      const key = lower(tok);
      const vol = Number(e.vol24hUsd) || 0;
      const pool = isValidEvmAddress(e.address) ? e.address : '';
      const cur = by.get(key);
      if (!cur) by.set(key, { vol, pool });
      else { cur.vol += vol; if (!cur.pool && pool) cur.pool = pool; }
    }
    const top = [...by.entries()].sort((a, b) => b[1].vol - a[1].vol).slice(0, topN);
    toks = top.map(([t]) => t);
    pools = top.map(([, v]) => v.pool).filter(Boolean);
  } else {
    const tokN = EVM_TOKEN_SUBS(); // легаси-режим (HOTPOLL_TOP_TOKENS=0)
    toks = tokN > 0
      ? rankWlMintsByVol(Object.values(meta).map((e) => ({ subjectToken: e.subjectToken, vol24hUsd: e.vol24hUsd })), tokN)
      : [];
  }
  // v9.32ak AK2: роутеры не ERC20 — Transfer на адресе роутера не эмитится никогда;
  // фильтр address=router&topic=Transfer возвращал пустоту вечно (live 2026-09-01:
  // eth publicnode «только-1inch» 100 блоков → 0 логов при 3958 на WETH; прод bsc
  // ok=184 успешных вызова, logs=0). A/B назад: EVM_HOT_POLL_INCLUDE_ROUTERS=1.
  const routers = EVM_HOT_POLL_INCLUDE_ROUTERS() ? getRouterAddrs(chainId) : [];
  // v9.32am HOTFIX+ (MD2): болтливые пулы, мигрировавшие с WS — getLogs покрывает их с бута
  const migratedPools = wsHotPoll.migratedRecords().filter((m) => m.chainId === chainId).map((m) => m.addr);
  // v9.32aq B2×AG4 (GUARD-1): адрес с ЖИВОЙ WS-подпиской (confirmed ∪ pending) в hotPoll
  // не дублируется — иначе пулы топ-N токенов оплачивались бы дважды (бюджет-инвариант AQ).
  const wsSt = evmWsChains[chainId];
  const wsCovered = new Set<string>();
  if (wsSt) {
    for (const key of wsSt.confirmed) wsCovered.add(key);
    for (const key of wsSt.pendingByTarget.keys()) wsCovered.add(key);
  }
  return [...new Set([...toks, ...pools, ...routers, ...migratedPools])]
    .filter((a) => isValidEvmAddress(a) && !wsCovered.has(`${lower(chainId)}:${lower(a)}:standard`))
    .map((a) => normAddr(chainId, a));
}
const HOTPOLL_RPC_ROTATE = () => (process.env.HOTPOLL_RPC_ROTATE ?? '1') !== '0'; // v9.32ap PD4
function isAlchemyHotUrl(url: string, chainId: string): boolean {
  const alchemy = alchemyHttpUrl(chainId);
  return (!!alchemy && url === alchemy) || classifyRpcSource(url) === 'alchemy';
}
function hotUrlSuccessRate(url: string): number {
  const w = endpointState.windowGet(url);
  // Не переставлять, пока нет окна ≥20 — иначе 3-fail streak (HD3) не набирается.
  if (!w || w.length < ENDPOINT_WINDOW) return 1;
  let ok = 0;
  for (const r of w) if (r) ok++;
  return ok / w.length;
}
function evmHotPollRpcUrls(chainId: string): string[] {
  const alchemy = alchemyHttpUrl(chainId);
  const pubs = evmPublicRpcPoolBase(chainId).filter((u) => u && u !== alchemy); // v9.32bm1 S6: getLogs-путь без archive-резервов (BD1)
  const extraRaw = process.env['EVM_HOT_POLL_EXTRA_' + chainId.toUpperCase()] ?? EVM_HOT_POLL_EXTRA_DEFAULT[chainId] ?? '';
  const extras = String(extraRaw).split(',').map((x) => x.trim()).filter(Boolean)
    .filter((u) => u !== alchemy && !pubs.includes(u));
  const all = [...pubs, ...extras, ...(alchemy ? [alchemy] : [])];
  // v9.32ap PD4: HOTPOLL_RPC_ROTATE=0 — без ротации пабликов: primary → Alchemy (аварийный режим).
  const base = !HOTPOLL_RPC_ROTATE() ? [all[0], ...(alchemy ? [alchemy] : [])].filter(Boolean) : all;
  // v9.32bc BC3: стоимость (паблик=0, Alchemy=1), затем здоровье. Alchemy всегда последним рубежом (RD5).
  return [...base].sort((a, b) => {
    // v9.32be BE4 (AR7b): официальная нода → прочие паблики → Alchemy (только EVM_ALCHEMY_CHAINS).
    const ca = isAlchemyHotUrl(a, chainId) ? 2 : isOfficialHotUrl(a, chainId) ? 0 : 1;
    const cb = isAlchemyHotUrl(b, chainId) ? 2 : isOfficialHotUrl(b, chainId) ? 0 : 1;
    if (ca !== cb) return ca - cb;
    return hotUrlSuccessRate(b) - hotUrlSuccessRate(a);
  });
}
export function __evmHotPollRpcUrlsForTest(chainId: string): string[] { return evmHotPollRpcUrls(chainId); }
// public-first single JSON-RPC call (v9.32ac pool + health marking); goes through the
// injectable evmAnalyzeFetch transport so tests run without a network.
export function __evmHotUrlHealthForTest(): Record<string, EvmHotUrlHealth> {
  return Object.fromEntries([...endpointState.healthEntries()].map(([u, h]) => [u, { ...h }]));
}
export function __resetEvmHotUrlHealthForTest(): void {
  endpointState.healthClear(); endpointState.windowClear(); bscGetLogsRateByRoute.clear();
  bscAllPublicRateUntil = 0; bscHolderRescueEpochUntil = 0;
  bscHolderRescueProbes = 0; bscHolderRescueSplitRoot = null;
}
export function __setEvmHotUrlHealthForTest(url: string, rec: EvmHotUrlHealth): void {
  endpointState.healthSet(url, { ...rec });
}
function noteHotPollUrlAttempt(chainId: string, url: string, ok: boolean): void {
  const w = endpointState.windowGet(url) || [];
  w.push(ok);
  if (w.length > ENDPOINT_WINDOW) w.shift();
  endpointState.windowSet(url, w);
  const rec = endpointState.healthGet(url);
  if (!ok && rec?.rateCooled && rec.until <= hotPollNow() && w.length <= 1) {
    rec.until = hotPollNow() + ENDPOINT_COOLDOWN_MS();
    endpointState.healthSet(url, rec); // rec is a copy: persist the cooldown explicitly
    endpointState.windowSet(url, []);
    log(`[hotpoll] ${chainId}: endpoint ${url} probe failed → cooled ${Math.round(ENDPOINT_COOLDOWN_MS() / 1000)}s`);
    return;
  }
  if (w.length >= ENDPOINT_WINDOW) {
    let okN = 0;
    for (const r of w) if (r) okN++;
    if (okN / w.length < ENDPOINT_MIN_SUCCESS) {
      const cell = rec || { fails: 0, until: 0 };
      cell.until = hotPollNow() + ENDPOINT_COOLDOWN_MS();
      cell.rateCooled = true;
      endpointState.healthSet(url, cell);
      endpointState.windowSet(url, []);
      log(`[hotpoll] ${chainId}: endpoint ${url} cooled ${Math.round(ENDPOINT_COOLDOWN_MS() / 1000)}s (success ${okN}/${ENDPOINT_WINDOW} < 50%)`);
    }
  }
}
export function __noteHotPollEndpointResultForTest(chainId: string, url: string, ok: boolean): void {
  noteHotPollUrlAttempt(chainId, url, ok);
}
// v9.32be BE1 (AR1/AR2): пустая ротация дольше EMERGENCY_RESET_MS → сброс кулдаунов цепи.
function maybeEmergencyReset(chainId: string): boolean {
  const now = hotPollNow();
  const urls = evmHotPollRpcUrls(chainId);
  if (!urls.length) return false;
  const live = urls.filter(u => !hotPollUrlCooled(u, now));
  if (live.length > 0) { delete evmHotAllCooledSince[chainId]; return false; }
  const since = evmHotAllCooledSince[chainId];
  if (!since) { evmHotAllCooledSince[chainId] = now; return false; }
  if (now - since < EMERGENCY_RESET_MS()) return false;
  const resettable = urls.filter(u => {
    const h = endpointState.healthGet(u);
    return (h?.until ?? 0) > now && (h?.providerRetryAfterUntil ?? 0) <= now;
  });
  if (!resettable.length) return false; // auth-only: no transient recovery to perform
  for (const u of resettable) { clearHotPollTransportHealth(u); endpointState.windowSet(u, []); }
  delete evmHotAllCooledSince[chainId];
  const m = evmHotM(chainId);
  m.emergencyResets = (m.emergencyResets || 0) + 1;
  log(`[hotpoll] ${chainId}: ВСЕ эндпоинты были в куле ${Math.round((now - since) / 1000)}с → аварийный сброс кулдаунов (emergencyResets=${m.emergencyResets})`);
  return true;
}
export function __maybeEmergencyResetForTest(chainId: string): boolean { return maybeEmergencyReset(chainId); }
export function __hotPollPrunedSignalForTest(chainId: string): boolean { return !!evmHotPrunedSignal[chainId]; }
// v9.32be BE2 (AR3): поадресный обход одного эндпоинта, не принимающего мульти-адресный фильтр.
async function evmHotGetLogsPerAddress(chainId: string, url: string, params: unknown[], bscCycleAttemptCeiling?: number): Promise<unknown[] | null> {
  const filter = (params?.[0] || {}) as { address?: string[]; [k: string]: unknown };
  const addrs = Array.isArray(filter.address) ? filter.address : [];
  const out: unknown[] = [];
  const m = evmHotM(chainId);
  for (const a of addrs) {
    if (chainId === 'bsc' && bscCycleAttemptCeiling !== undefined
        && (m.getLogsAttempts ?? 0) >= bscCycleAttemptCeiling) {
      evmHotLastFailure[chainId] = { outcome: 'skipped', detail: 'cycle-provider-budget-exhausted' };
      noteGetLogsOutcome(chainId, 'skipped');
      return null; // partial address results cannot prove the whole multi-address filter
    }
    if (hotPollRequestCooled(chainId, url, hotPollNow(), 'eth_getLogs', [{ ...filter, address: [a] }])
        || (isAlchemyHotUrl(url, chainId) && alchemyGetLogsCapReached())) {
      evmHotLastFailure[chainId] = { outcome: 'skipped', detail: 'split-cooldown-or-cap' };
      noteGetLogsOutcome(chainId, 'skipped');
      return null;
    }
    m.getLogsAttempts = (m.getLogsAttempts || 0) + 1;
    if (isAlchemyHotUrl(url, chainId)) { m.getLogsAlchemy = (m.getLogsAlchemy || 0) + 1; noteAlchemyGetLogsCall(); }
    let status: number | null = null;
    let body: any = null;
    let decodeError = false;
    let threw: string | null = null;
    try {
      recordRpcCall('eth_getLogs', classifyRpcSource(url), 'hotpoll');
      const res = await evmAnalyzeFetch(url, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getLogs', params: [{ ...filter, address: [a] }] }),
      }, 10000);
      status = res.status;
      noteHotPollRetryAfter(url, res);
      try { body = await res.json(); } catch { decodeError = true; }
      if (res.ok && Array.isArray(body?.result)) {
        out.push(...body.result);
        noteGetLogsOutcome(chainId, body.result.length ? 'success_with_logs' : 'success_empty');
        continue;
      }
    } catch (e) { threw = String((e as Error).message || e); }
    const attempt = { status, body, decodeError, threw, timedOut: /timeout|timed out|aborted|abort/i.test(threw ?? ''), resultIsArray: false };
    const outcome = classifyGetLogsAttempt(attempt);
    if (outcome === 'invalid_range') {
      if (isPrunedHistory(body?.error)) evmHotPrunedSignal[chainId] = true;
      if (isRangeTooWide(body?.error)) evmHotRangeSignal[chainId] = true;
    }
    evmHotLastFailure[chainId] = { outcome, detail: getLogsFailureDetail(attempt, outcome) };
    noteGetLogsOutcome(chainId, outcome);
    if (outcome === 'rate_limited') coolBscRateLimitedGetLogs(chainId, url, [{ ...filter, address: [a] }]);
    if (outcome === 'auth_required') {
      endpointState.healthSet(url, {
        ...(endpointState.healthGet(url) ?? { fails: 0, until: 0 }),
        getLogsAuthUntil: hotPollNow() + EVM_HOT_GETLOGS_INCAPABLE_MS(),
      });
    }
    // A single missing address invalidates the whole range. Never return the successful prefix.
    return null;
  }
  evmHotLastFailure[chainId] = null;
  return addrs.length ? out : null;
}
// Passive BSC-only telemetry: no extra network calls, raw URLs, filter addresses, or error bodies.
// Slot numbering is based on sorted configured URLs, NOT dynamic health/rotation order.
function bscWidthEvidence(m: EvmHotPollMetrics): BscWidthEvidence {
  return m.getLogsWidthEvidence ??= {
    unit: 'provider_attempt', widthUnit: 'block', scope: 'evmHotRpc', rows: [], unattributed: 0, partial: false,
  };
}
function markBscWidthPartial(chainId: string, m: EvmHotPollMetrics): void {
  if (chainId === 'bsc') bscWidthEvidence(m).partial = true;
}
function noteBscGetLogsWidth(
  chainId: string, method: string, m: EvmHotPollMetrics, urls: string[], url: string,
  params: unknown[], outcome: 'success' | 'tooWide' | 'rateLimited' | 'authRequired' | 'other',
): void {
  if (chainId !== 'bsc' || method !== 'eth_getLogs') return;
  const evidence = bscWidthEvidence(m);
  const slot = [...new Set(urls)].sort().indexOf(url);
  const f = params[0] as { fromBlock?: unknown; toBlock?: unknown; address?: unknown } | null;
  const from = f?.fromBlock, to = f?.toBlock;
  if (slot < 0 || slot >= 8 || typeof from !== 'string' || typeof to !== 'string'
      || !/^0x[0-9a-f]+$/i.test(from) || !/^0x[0-9a-f]+$/i.test(to)) {
    evidence.unattributed++; evidence.partial = true; return;
  }
  const start = Number.parseInt(from, 16), end = Number.parseInt(to, 16);
  if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
    evidence.unattributed++; evidence.partial = true; return;
  }
  const width = end - start + 1;
  const addresses = f?.address;
  const filterKind: BscWidthEvidenceRow['filterKind'] = typeof addresses === 'string'
    ? 'address-1' : Array.isArray(addresses)
      ? addresses.length === 1 ? 'address-1' : 'address-many' : 'holder';
  const key = `slot-${slot + 1}`;
  let row = evidence.rows.find(r => r.slot === key && r.filterKind === filterKind);
  if (!row) {
    if (evidence.rows.length >= 24) { evidence.unattributed++; evidence.partial = true; return; }
    row = { slot: key, filterKind, attempts: 0, success: 0, tooWide: 0,
      rateLimited: 0, authRequired: 0, other: 0, maxSuccessWidth: null, minTooWideWidth: null, lastAt: 0 };
    evidence.rows.push(row);
  }
  row.attempts++;
  row[outcome]++;
  if (outcome === 'success') row.maxSuccessWidth = Math.max(row.maxSuccessWidth ?? 0, width);
  if (outcome === 'tooWide') row.minTooWideWidth = Math.min(row.minTooWideWidth ?? Infinity, width);
  row.lastAt = hotPollNow();
}
async function evmHotRpc<T>(chainId: string, method: string, params: unknown[], bscCycleAttemptCeiling?: number): Promise<T | null> {
  maybeEmergencyReset(chainId);
  if (method === 'eth_getLogs') { evmHotLogicalOutcome[chainId] = null; evmHotRangeIncapableOnly[chainId] = false; }
  // Fix 2 (#257): разделяем too-wide от доказавших способность нод и от «вечных» отказов.
  let capableTooWide = false;
  let incapableTooWide = false;
  const alchemy = alchemyHttpUrl(chainId);
  const m = evmHotM(chainId);
  const urls = evmHotPollRpcUrls(chainId);
  const now = hotPollNow();
  let capSkipped = false;
  let attemptedProvider = false;
  const publicRateLimitedThisCall = new Set<string>();
  let sawNeutralRange = false;
  let sawNeutralPruned = false;
  // ТЗ 2026-10-09 §3.1 req 1: признаки ПОСЛЕДНЕЙ попытки — по ним classifyGetLogsAttempt отличает
  // auth/429/timeout/decode от честного пустого ответа.
  let lastStatus: number | null = null;
  let lastBody: unknown = null;
  let lastDecodeError = false;
  let lastThrow: string | null = null;
  let lastTimedOut = false;
  let attemptClassified = false;
  // Address A's pool-wide 429 does not prove the mandatory holder topic unavailable.
  // Two free probes per cooldown epoch; only an explicit neutral too-wide outcome opens
  // bounded split probes (max 32 total). Provider deadlines and the cycle cap still win.
  const holderFilterDuringPoolPause = method === 'eth_getLogs' && chainId === 'bsc' && !alchemyHttpEnabled(chainId)
    && HOT_POLL_MAX_FAIL_RATE() < 1 && bscAllPublicRateUntil > now && isBscHolderTopicFilter(params);
  if (holderFilterDuringPoolPause && bscHolderRescueEpochUntil !== bscAllPublicRateUntil) {
    bscHolderRescueEpochUntil = bscAllPublicRateUntil;
    bscHolderRescueProbes = 0;
    bscHolderRescueSplitRoot = null;
  }
  const holderRange = holderFilterDuringPoolPause ? bscHolderFilterRange(params) : null;
  const root = bscHolderRescueSplitRoot;
  const narrowerSplitRange = !!(root && holderRange && holderRange.from >= root.from && holderRange.to <= root.to
    && (holderRange.from > root.from || holderRange.to < root.to));
  const rescueLimit = narrowerSplitRange ? BSC_HOLDER_SPLIT_RESCUE_MAX_PROBES : 2;
  const holderRescue = holderFilterDuringPoolPause && bscHolderRescueProbes < rescueLimit;
  let holderRescueAttempts = 0;
  const requestCooled = (url: string): boolean => {
    if (!holderRescue) return hotPollRequestCooled(chainId, url, now, method, params);
    return isAlchemyHotUrl(url, chainId) || holderRescueAttempts >= 2
      || bscHolderRescueProbes >= rescueLimit || hotPollUrlCooled(url, now, method)
      || (bscGetLogsRateByRoute.get(bscGetLogsRateRoute(url, params)) ?? 0) > now;
  };
  // v9.32aq B3: blind-окно = все эндпоинты цепи в cooldown (SLA < 30с/час)
  const anyLive = urls.some(u => !requestCooled(u));
  if (!anyLive && !m.blindSince) m.blindSince = now;
  // v9.32bc BC3: Alchemy для eth_getLogs только когда все паблики в куле (RD5: резерв, не основной).
  const anyPublicLive = urls.some(u => !isAlchemyHotUrl(u, chainId) && !requestCooled(u));
  for (const url of urls) {
    // ТЗ 2026-10-11 §5 (WP-W3): поля попытки принадлежат ТОЛЬКО текущему эндпоинту. Раньше они
    // жили через итерации цикла, и классификация собиралась из смеси: 401 одного хоста делал
    // сетевой отказ следующего `auth_required` (с 12-часовым getLogs-кулдауном этому хосту),
    // 429 — `rate_limited`. Прод-след: bsc gap {outcome:'timeout', detail:'log query range must not
    // exceed 25 blocks'} — чужое тело при своём throw. Сброс на каждой итерации.
    lastStatus = null; lastBody = null; lastDecodeError = false; lastThrow = null; lastTimedOut = false;
    if (requestCooled(url)) continue; // HD3: scoped rate limits and URL-wide provider deadlines
    if (method === 'eth_getLogs' && chainId === 'bsc' && bscCycleAttemptCeiling !== undefined
        && (m.getLogsAttempts ?? 0) >= bscCycleAttemptCeiling) { capSkipped = true; break; }
    if (method === 'eth_getLogs' && isAlchemyHotUrl(url, chainId) && anyPublicLive) continue;
    if (method === 'eth_getLogs' && isAlchemyHotUrl(url, chainId) && alchemyGetLogsCapReached()) {
      m.alchemyCapSkipped = (m.alchemyCapSkipped || 0) + 1;
      capSkipped = true;
      continue;
    }
    // v9.32al CK1: alchemy-фолбэк — через CU-бакет (getLogs 75 / blockNumber 9); шум fail-closed
    if (url === alchemy && !(await alchemyCuGate(alchemyCuCost(method), { src: alchemyCuSourceOf(method) }))) { m.endpointFail++; continue; }
    if (holderRescue) { holderRescueAttempts++; bscHolderRescueProbes++; }
    // v9.32be AR3: эндпоинт уже помечен multiAddrIncapable — сразу поадресно, он в ротации.
    if (method === 'eth_getLogs' && isMultiAddrIncapable(url)
        && Array.isArray((params?.[0] as { address?: string[] })?.address)
        && ((params[0] as { address: string[] }).address.length > 1)) {
      markBscWidthPartial(chainId, m); // per-address fallback attempts are outside this scope
      const split = await evmHotGetLogsPerAddress(chainId, url, params, bscCycleAttemptCeiling);
      if (split) {
        const eps = m.endpoints || (m.endpoints = {});
        const host = rpcHostOf(url);
        const cell = eps[host] || (eps[host] = { ok: 0, fail: 0 });
        cell.ok++;
        noteHotPollUrlAttempt(chainId, url, true);
        clearHotPollTransportHealth(url);
        if (m.blindSince) { m.blindMs += hotPollNow() - m.blindSince; m.blindSince = 0; }
        m.endpointOk++;
        if (method === 'eth_getLogs') { evmHotGetLogsCapableUrls.add(url); setEvmHotLogicalOutcome(chainId, split.length > 0 ? 'ok-nonempty' : 'ok-empty'); }
        return split as T;
      }
      attemptedProvider = true;
      attemptClassified = true;
      if (evmHotLastFailure[chainId]?.outcome === 'rate_limited' && !isAlchemyHotUrl(url, chainId))
        publicRateLimitedThisCall.add(url);
      sawNeutralPruned ||= evmHotPrunedSignal[chainId] === true;
      sawNeutralRange ||= evmHotRangeSignal[chainId] === true;
      continue;
    }
    let ok = false;
    if (method === 'eth_getLogs') {
      m.getLogsAttempts = (m.getLogsAttempts || 0) + 1;
      if (isAlchemyHotUrl(url, chainId)) {
        m.getLogsAlchemy = (m.getLogsAlchemy || 0) + 1;
        noteAlchemyGetLogsCall();
      }
    }
    try {
      attemptedProvider = true;
      recordRpcCall(method, classifyRpcSource(url), 'hotpoll');
      const res = await evmAnalyzeFetch(url, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
      }, 10000);
      lastStatus = (res as Response).status;
      noteHotPollRetryAfter(url, res as Response);
      if (url === alchemy) noteAlchemyHttpStatus((res as Response).status); // v9.32al CK5
      let j: any = null;
      // ТЗ §3.1 req 1: 200 с не-JSON телом (HTML-заглушка провайдера) — это decode_error,
      // а не «успешный пустой ответ». Раньше исключение тонуло в общем catch и читалось как fail.
      try { j = await (res as Response).json(); } catch (e) {
        lastDecodeError = true;
        lastThrow = String((e as Error)?.message || e);
      }
      lastBody = j;
      if (method === 'eth_getLogs') ok = Array.isArray(j?.result);
      else if (j && j.result !== undefined && j.result !== null) ok = true;
      if (ok) {
        if (url !== alchemy) markEvmPublicRpcResult(url, true, undefined, 'poll');
        { // v9.32ap PD4: per-endpoint телеметрия в hotPoll-метриках (статус владельцу)
          const eps = m.endpoints || (m.endpoints = {});
          const host = rpcHostOf(url);
          const cell = eps[host] || (eps[host] = { ok: 0, fail: 0 });
          cell.ok++;
        }
        noteHotPollUrlAttempt(chainId, url, true);
        clearHotPollTransportHealth(url); // здоровье эндпоинта восстановлено
        if (m.blindSince) { m.blindMs += hotPollNow() - m.blindSince; m.blindSince = 0; } // v9.32aq B3
        m.endpointOk++;
        if (method === 'eth_getLogs') {
          const logs = j.result as unknown[];
          evmHotGetLogsCapableUrls.add(url); // Fix 2 (#257): нода доказала способность читать логи
          setEvmHotLogicalOutcome(chainId, logs.length > 0 ? 'ok-nonempty' : 'ok-empty');
          noteGetLogsOutcome(chainId, logs.length > 0 ? 'success_with_logs' : 'success_empty');
          noteBscGetLogsWidth(chainId, method, m, urls, url, params, 'success');
          evmHotLastFailure[chainId] = null;
          attemptClassified = true;
        }
        return j.result as T;
      }
      // v9.32ak AK2: publicnode-эндпоинт требует personal token для getLogs — неспособен
      // навсегда (не на 5 мин): long-cooldown, чтобы циклы не жгли попытки (прод 15:10-15:17).
      if (String(j?.error?.message || '').includes('personal token')) {
        noteBscGetLogsWidth(chainId, method, m, urls, url, params, 'authRequired');
        m.endpointFail++;
        m.getLogsIncapable++;
        // ТЗ §3.1 req 1 + Acceptance: прод polygon (getLogsFailed=258) обязан читаться как
        // auth_required, а не как пустой диапазон и не как безликий failed.
        if (method === 'eth_getLogs') {
          noteGetLogsOutcome(chainId, 'auth_required');
          attemptClassified = true;
          evmHotLastFailure[chainId] = {
            outcome: 'auth_required',
            detail: getLogsFailureDetail({ status: lastStatus, body: j }, 'auth_required'),
          };
        }
        noteHotPollUrlAttempt(chainId, url, false);
        const until = hotPollNow() + EVM_HOT_GETLOGS_INCAPABLE_MS();
        endpointState.healthSet(url, method === 'eth_getLogs'
          ? { ...(endpointState.healthGet(url) ?? { fails: 0, until: 0 }), getLogsAuthUntil: until }
          : { ...endpointState.healthGet(url), fails: 99, until });
        log(`[hotpoll] ${chainId}: ${url} неспособен к eth_getLogs (personal token required) → cooldown ${Math.round(EVM_HOT_GETLOGS_INCAPABLE_MS() / 3600000)}ч`);
        continue;
      }
      // v9.32be BE2 (AR3/AR4): отказ ИМЕННО от мульти-адресного фильтра — не сетевой сбой.
      // Эндпоинт остаётся в ротации, помечается multiAddrIncapable и опрашивается поадресно.
      if (method === 'eth_getLogs' && isMultiAddrRejection(j?.error)
          && Array.isArray((params?.[0] as { address?: string[] })?.address)
          && ((params[0] as { address: string[] }).address.length > 1)) {
        if (!isMultiAddrIncapable(url)) {
          endpointState.multiAddrSet(url, hotPollNow());
          m.multiAddrIncapable = [...new Set([...(m.multiAddrIncapable || []), rpcHostOf(url)])];
          log(`[hotpoll] ${chainId}: ${rpcHostOf(url)} не принимает мульти-адресный getLogs (${j?.error?.code ?? ''} ${String(j?.error?.message || '').slice(0, 60)}) → поадресный режим для эндпоинта, остаётся в ротации`);
        }
        noteBscGetLogsWidth(chainId, method, m, urls, url, params, 'other');
        markBscWidthPartial(chainId, m); // split requests are not observed as full-range successes
        const split = await evmHotGetLogsPerAddress(chainId, url, params, bscCycleAttemptCeiling);
        if (split) {
          const eps = m.endpoints || (m.endpoints = {});
          const host = rpcHostOf(url);
          const cell = eps[host] || (eps[host] = { ok: 0, fail: 0 });
          cell.ok++;
          noteHotPollUrlAttempt(chainId, url, true);
          clearHotPollTransportHealth(url);
          if (m.blindSince) { m.blindMs += hotPollNow() - m.blindSince; m.blindSince = 0; }
          m.endpointOk++;
          if (method === 'eth_getLogs') { evmHotGetLogsCapableUrls.add(url); setEvmHotLogicalOutcome(chainId, split.length > 0 ? 'ok-nonempty' : 'ok-empty'); }
          return split as T;
        }
        attemptClassified = true;
        if (evmHotLastFailure[chainId]?.outcome === 'rate_limited' && !isAlchemyHotUrl(url, chainId))
          publicRateLimitedThisCall.add(url);
        sawNeutralPruned ||= evmHotPrunedSignal[chainId] === true;
        sawNeutralRange ||= evmHotRangeSignal[chainId] === true;
        continue; // split failed: keep classified cause and try the next endpoint
      }
      // v9.32bf BF3 (AR8): диапазон шире, чем принимает нода — не отказ эндпоинта.
      // Сигнал наверх: следующий цикл сузит окно; эндпоинт остаётся в ротации.
      if (method === 'eth_getLogs' && isRangeTooWide(j?.error)) {
        noteBscGetLogsWidth(chainId, method, m, urls, url, params, 'tooWide');
        // Fix 2 (#257): лимит ноды, уже доказавшей способность читать логи, информативен;
        // «вечный» отказ ноды с нулём успехов не имеет права сужать глобальный span (GD3).
        if (evmHotGetLogsCapableUrls.has(url)) capableTooWide = true;
        else incapableTooWide = true;
        evmHotRangeSignal[chainId] = true;
        sawNeutralRange = true;
        noteGetLogsOutcome(chainId, 'invalid_range');
        attemptClassified = true;
        evmHotLastFailure[chainId] = {
          outcome: 'invalid_range',
          detail: getLogsFailureDetail({ status: lastStatus, body: j }, 'invalid_range'),
        };
        continue;
      }
      // v9.32be BE3 (AR5): нода обрезала историю — курсор отстал, это не отказ эндпоинта.
      if (isPrunedHistory(j?.error)) {
        noteBscGetLogsWidth(chainId, method, m, urls, url, params, 'other');
        evmHotPrunedSignal[chainId] = true;
        if (method === 'eth_getLogs') {
          sawNeutralPruned = true;
          noteGetLogsOutcome(chainId, 'invalid_range');
          attemptClassified = true;
          evmHotLastFailure[chainId] = {
            outcome: 'invalid_range',
            detail: getLogsFailureDetail({ status: lastStatus, body: j }, 'invalid_range'),
          };
        }
        log(`[hotpoll] ${chainId}: ${rpcHostOf(url)} history pruned → откат курсора (не fail)`);
        continue;
      }
      if (url !== alchemy) markEvmPublicRpcResult(url, false, undefined, 'poll');
    } catch (e) {
      lastThrow = String((e as Error)?.message || e);
      lastTimedOut = /timeout|timed out|aborted|abort/i.test(lastThrow);
      if (url !== alchemy) markEvmPublicRpcResult(url, false, undefined, 'poll');
    }
    // Reaching the ordinary endpoint-failure accounting path means this provider did not yield
    // a usable result and did not match one of the neutral classifications above.
    { // v9.32ap PD4: фейл эндпоинта виден per-host
      const eps = m.endpoints || (m.endpoints = {});
      const host = rpcHostOf(url);
      const cell = eps[host] || (eps[host] = { ok: 0, fail: 0 });
      cell.fail++;
    }
    m.endpointFail++;
    if (method === 'eth_getLogs') {
      const attempt = { status: lastStatus, body: lastBody, decodeError: lastDecodeError, threw: lastThrow, timedOut: lastTimedOut, resultIsArray: false };
      const o = classifyGetLogsAttempt(attempt);
      if (o === 'rate_limited') {
        coolBscRateLimitedGetLogs(chainId, url, params);
        if (!isAlchemyHotUrl(url, chainId)) publicRateLimitedThisCall.add(url);
      }
      noteBscGetLogsWidth(chainId, method, m, urls, url, params,
        o === 'rate_limited' ? 'rateLimited' : o === 'auth_required' ? 'authRequired' : 'other');
      noteGetLogsOutcome(chainId, o);
      attemptClassified = true;
      evmHotLastFailure[chainId] = { outcome: o, detail: getLogsFailureDetail(attempt, o) };
      if (o === 'auth_required') {
        noteHotPollUrlAttempt(chainId, url, false);
        endpointState.healthSet(url, {
          ...(endpointState.healthGet(url) ?? { fails: 0, until: 0 }),
          getLogsAuthUntil: hotPollNow() + EVM_HOT_GETLOGS_INCAPABLE_MS(),
        });
        continue;
      }
    }
    noteHotPollUrlAttempt(chainId, url, false);
    const rec = endpointState.healthGet(url) || { fails: 0, until: 0 };
    rec.fails++;
    // v9.32bd AR7: кул по streak только после полного окна (publicnode 1/1 больше не выпадает).
    const samples = (endpointState.windowGet(url) || []).length;
    if (rec.fails >= HOT_POLL_URL_FAIL_STREAK && samples >= ENDPOINT_WINDOW && rec.until <= hotPollNow()) {
      const cd = hotPollCooldownJittered();
      rec.until = hotPollNow() + cd;
      log(`[hotpoll] ${chainId}: endpoint ${url} охлаждён на ${Math.round(cd / 1000)}с (${rec.fails} фейлов подряд, jitter) — ротация внутри паблик-пула до Alchemy`);
    }
    endpointState.healthSet(url, rec);
  }
  if (method === 'eth_getLogs') {
    if (chainId === 'bsc' && !alchemyHttpEnabled(chainId) && HOT_POLL_MAX_FAIL_RATE() < 1) {
      const publicUrls = urls.filter(u => !isAlchemyHotUrl(u, chainId));
      if (publicUrls.length > 0 && publicUrls.every(u => publicRateLimitedThisCall.has(u))) {
        // A fresh 429 from *every* public URL on this filter indicates a pool-wide outage.
        // Retry when the first local deadline expires; provider Retry-After remains URL-wide.
        bscAllPublicRateUntil = Math.max(bscAllPublicRateUntil,
          Math.min(...publicUrls.map(u => endpointState.healthGet(u)?.getLogsRateUntil ?? now)));
      }
    }
    // Neutral is valid only when every provider response was neutral. A range/pruned response
    // mixed with an ordinary provider failure remains a real failed logical call.
    // ТЗ 2026-10-12 WP-D1: без единой попытки (все эндпоинты в кулдауне, Alchemy не настроен или
    // пул пуст) логический исход — skip-класс, а не 'failed': «транспорт не проверялся» не
    // является «попытки были, ни одного успеха». Отличаем кап (skipped-cap) от кулдауна пула
    // (skipped-cooled); оба растят getLogsSkipped и дают честный 'no-source' в deliveryHealth.
    const terminal: EvmHotLogicalOutcome =
      !attemptedProvider && capSkipped ? 'skipped-cap'
        : !attemptedProvider ? 'skipped-cooled'
          : sawNeutralRange ? 'neutral-range'
            : sawNeutralPruned ? 'neutral-pruned'
              : 'failed';
    setEvmHotLogicalOutcome(chainId, terminal);
    // Fix 2 (#257): нейтральный too-wide ядовит для глобального span только если ширину отбила
    // доказавшая способность нода. Отказы одних лишь «вечно неспособных» при живом способном пэре
    // (в т.ч. когда пэр временно в кулдауне) больше не схлопывают окно.
    if (terminal === 'neutral-range') {
      evmHotRangeIncapableOnly[chainId] = HOTPOLL_SPAN_NARROW_CAPABLE_ONLY()
        && !capableTooWide && incapableTooWide
        && urls.some(u => evmHotGetLogsCapableUrls.has(u));
    }
    if (holderRescue && terminal === 'neutral-range' && !bscHolderRescueSplitRoot && holderRange)
      bscHolderRescueSplitRoot = holderRange;
    // ТЗ §3.1 req 1: итог операции виден раздельно. Прод polygon: auth-отказ отправил эндпоинт в
    // кулдаун 12 ч, и остальные чанки цикла не выполнились вовсе — это `skipped` (getLogsSkipped=90),
    // а не ещё один `failed`. Так attempts не читаются как число потерянных сделок.
    if (!attemptClassified && (terminal === 'failed' || terminal === 'skipped-cap' || terminal === 'skipped-cooled')) {
      noteGetLogsOutcome(chainId, attemptedProvider ? 'retry_exhausted' : 'skipped');
    }
  }
  return null;
}
const evmHotCanaryAt: Record<string, number> = {};
export function __setEvmHotCanaryAtForTest(n: number): void {
  for (const k of Object.keys(evmHotCanaryAt)) delete evmHotCanaryAt[k];
  if (n > 0) for (const c of EVM_WS_SUPPORTED_CHAINS) evmHotCanaryAt[c] = n;
}
async function evmHotCanaryCheck(chainId: string, block: number): Promise<void> {
  const m = evmHotM(chainId);
  const alchemy = alchemyHttpUrl(chainId);
  const url = evmHotPollRpcUrls(chainId).find(u => u !== alchemy && !hotPollUrlCooled(u, hotPollNow()));
  const wrapped = CHAINS[chainId]?.wrapped;
  if (!url || !wrapped) return;
  try {
    const from = '0x' + Math.max(1, block - 30).toString(16);
    const to = '0x' + Math.max(1, block - 1).toString(16);
    const res = await evmAnalyzeFetch(url, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getLogs', params: [{ address: [wrapped], topics: [[TRANSFER_TOPIC]], fromBlock: from, toBlock: to }] }),
    }, 10000);
    const j: any = await (res as Response).json();
    if (Array.isArray(j?.result) && j.result.length === 0) {
      m.getLogsIncapable++;
      endpointState.healthSet(url, { fails: 99, until: Date.now() + EVM_HOT_GETLOGS_INCAPABLE_MS() });
      log(`[hotpoll] ${chainId}: ${url} тихо-пустые getLogs (канарейка ${wrapped.slice(0, 10)}… за 30 блоков = 0) → cooldown ${Math.round(EVM_HOT_GETLOGS_INCAPABLE_MS() / 3600000)}ч`);
    }
  } catch { /* канарейка best-effort: фейл сети ≠ тихо-пусто */ }
}
/**
 * ТЗ 2026-10-14 (находка F4, раунд 4): цикл ОДНОЙ цепи не пересекается сам с собой. Раньше таймер
 * запускал следующий цикл, не дожидаясь предыдущего: пока первый держал durable-запись proof,
 * второй успевал зарегистрировать расширенный union — proof попадал на уже изменённый ряд
 * (proof-conflict) и/или регистрация нового фильтра «терялась». Перекрывающийся запуск теперь
 * пропускается и виден счётчиком `cyclesSkippedBusy`; churn обработает следующий цикл.
 */
const evmHotCycleBusy: Record<string, boolean> = {};
async function evmHotPollCycle(chainId: string): Promise<void> {
  if (evmHotCycleBusy[chainId]) {
    const m = evmHotM(chainId);
    m.cyclesSkippedBusy = (m.cyclesSkippedBusy || 0) + 1;
    return;
  }
  evmHotCycleBusy[chainId] = true;
  try {
    await evmHotPollCycleInner(chainId);
  } finally {
    evmHotCycleBusy[chainId] = false;
  }
}
async function evmHotPollCycleInner(chainId: string): Promise<void> {
  if (!evmHotPollEnabled()) return;
  // v9.32am AM3: пауза — hotPoll не поллит (нет getLogs за замороженный анализ), курсоры
  // живут в памяти: resume продолжает с ограниченным lookback (HOTPOLL_MAX_LOOKBACK_BLOCKS).
  if (state.paused) { pausedStats.hotPollSkipped++; getPauseLedger().noteHotPollSkipped(); return; }
  maybeRecoverHotChunk(chainId);
  const m = evmHotM(chainId);
  m.rangeAdmission = { state: 'idle' };
  const st = evmHotPollState[chainId] || (evmHotPollState[chainId] = { cursor: null, timer: null });
  const addrs = evmHotAddrs(chainId);
  const holders = getAllowanceHolderAddrs(chainId); // v9.32ap PD2: энтрипоинты — отдельный topic-фильтр
  if (!addrs.length && !holders.length) { (engineKernel.evmHotPauseInfo ??= {})[chainId] = { cursor: st.cursor, empty: true }; return; } // W2 T3: пустой хот-лист — покрывать нечем
  // v9.32bd BD2: нет живых пабликов и потолок Alchemy getLogs исчерпан — пропуск цикла,
  // курсор на месте, не fails (AR5/AR6). Лучше задержка, чем сгоревшая квота (RD2).
  if (!hotPollAnyPublicLive(chainId) && alchemyGetLogsCapReached()) {
    m.alchemyCapSkipped = (m.alchemyCapSkipped || 0) + 1;
    noteEvmHotLogicalOutcome(chainId, 'skipped-cap');
    refreshHotPollTelemetry(chainId);
    return;
  }
  m.cycles++;
  const bn = await evmHotRpc<string | number>(chainId, 'eth_blockNumber', []);
  const block = typeof bn === 'number' ? bn : parseInt(String(bn ?? ''), 16);
  if (!Number.isFinite(block) || block <= 0) { m.fails++; return; }
  m.addrs = addrs.length; // v9.32ak AK2: видимость размера хотлиста (роутеры исключены)
  m.addrList = addrs.slice(0, 20); // v9.32bm1g H7: «кого поллим» — первые 20, число addrs прежнее
  if (m.addrs !== (m as { prevAddrs?: number }).prevAddrs) {
    (m as { prevAddrs?: number }).prevAddrs = m.addrs;
    log(`[hotpoll] ${chainId}: hotlist ${m.addrs} addrs (top tokens by vol24h)`); // v9.32aq B2
  }
  // v9.32ak AK2: канарейка тихо-пустых getLogs — нода отвечает OK [] там, где обязаны быть
  // сотни логов (live 2026-09-01: bsc.publicnode/arb1/polygon-bor → 0 на WBNB/WETH/WPOL
  // за 30-500 блоков). Проверка wrapped-native Transfer за 30 блоков раз в EVM_HOT_CANARY_MS.
  // override-хотлист = тестовый режим старых сьютов (точный подсчёт getLogs-вызовов) — канарейку не добавляем
  if (!evmHotAddrsOverride[chainId] && Date.now() - (evmHotCanaryAt[chainId] || 0) >= EVM_HOT_CANARY_MS()) {
    evmHotCanaryAt[chainId] = Date.now();
    await evmHotCanaryCheck(chainId, block);
  }
  // v9.32ai ID2 (AI3/AI4): курсор пишется с blockNumber ОДНОГО эндпоинта, а getLogs уходит в
  // ДРУГОЙ при ротации — их latest расходятся на 1-2 блока, from = cursor+1 > latest обслуживающего
  // даёт честный ПУСТОЙ ответ (fails: 0, logs: 0 — прод 11:49 на всех 5 цепях). Инвариант:
  // from ≤ latest текущего цикла; cursor ≥ latest → рескан последнего блока (seenTxs-dedup защищает).
  // v9.32aj JD2 (AJ5): clamp ai брал block с ТОГО ЖЕ «ahead»-эндпоинта — при отставании
  // обслуживающей ноды на 1-2 блока окно сжималось в пустоту (logs: 0 при fails: 0). Lookback-
  // окно покрывает скью; дубликаты съедает seenTxs-dedup боевого пути.
  const lookback = Math.max(0, Number(process.env.EVM_HOT_POLL_LOOKBACK_BLOCKS ?? '4'));
  // v9.32al CK2: старт (cursor==null) — boot-gap окно ≤ HOTPOLL_MAX_LOOKBACK_BLOCKS, история
  // не догоняется (инцидент: polygon-hotPoll 147K логов при старте); 'latest' пропускал
  // брешь бута — теперь ограниченное окно её покрывает без цунами.
  let fromNum = st.cursor == null
    ? Math.max(1, block - HOTPOLL_MAX_LOOKBACK_BLOCKS())
    : Math.max(1, Math.min(st.cursor + 1, block) - lookback);
  // v9.32bf BF3 (AR8): toBlock = min(head, from + span) вместо 'latest' — окно запроса
  // остаётся в пределах, которые публичные ноды отдают.
  // v9.32bg BG5 (AR8): окно двигается НЕСКОЛЬКО раз за цикл, пока курсор не догонит голову.
  // Один шаг за цикл не спасал: arbitrum делает ~4 блока/с, за 6 с цикла это ~24 блока,
  // а отставание было 3978 — при одном шаге span=120 догон занял бы сотни циклов.
  const span = evmHotSpan(chainId);
  m.spanBlocks = span;
  let fromBlock = fromNum == null ? 'latest' : '0x' + fromNum.toString(16);
  let toNum = fromNum == null ? block : Math.min(block, fromNum + span);
  let toBlock = '0x' + toNum.toString(16);
  // Review PR #263 #2 P1 + #4/#5: незавершённый из-за бюджета шаг ЗАМОРОЖЕН. Следующий цикл
  // продолжает именно этот диапазон с первого непрочитанного адреса (межцикловый прогресс ниже),
  // а не начинает список с нуля и не даёт диапазону уехать из lookback при движущейся голове.
  // Идентичность прогресса — диапазон, а НЕ список: перестановка и смена membership заморозку
  // не снимают; состав обязательства (required union) разбирается при создании/слиянии прогресса шага.
  {
    const prog = st.stepProgress;
    if (prog) {
      if ((st.cursor == null || prog.to > st.cursor) && prog.to <= block) {
        fromNum = prog.from;
        toNum = prog.to;
        fromBlock = '0x' + fromNum.toString(16);
        toBlock = '0x' + toNum.toString(16);
      } else {
        // ТЗ 2026-10-13 §4: снятие прогресса шага не вправе молча выбросить необработанное
        // обязательство. Ревью #264 D1: одного `isReplay` НЕДОСТАТОЧНО — он опирается на старый
        // блочный checkpoint и не доказывает, что required-набор ЭТОГО шага читался (адрес,
        // добавленный в hot-list после покрытия блоков, не запрашивался ни разу). Снятие без
        // переноса допустимо лишь когда прочитаны ВСЕ обязательные фильтры шага, а диапазон при
        // этом ещё и покрыт журналом; иначе union+done уходят в хранилище, а пересекающиеся дыры
        // помечаются `proofRequired`: успех соседнего окна с другим набором фильтров их не закроет.
        if (stepProgressUnproven(prog) || !evmHotCoverage.isReplay(chainId, prog.from, prog.to)) {
          dropStepProgressObligation(chainId, prog, prog.to > block ? 'head-rewind' : 'range-behind-cursor');
        }
        st.stepProgress = null;
      }
    } else {
      // ТЗ 2026-10-13 §4: потерянное обязательство, которое ещё достижимо живьём, возвращается в
      // план — доказательство возможно только чтением ТОГО ЖЕ диапазона, а журнал не закрывает
      // `proofRequired`-дыру блочным покрытием соседних окон. Возврат не захватывает живой
      // префикс и не перепрыгивает курсор (см. storedObligationForWindow).
      const stored = storedObligationForWindow(chainId, fromNum, st.cursor);
      if (stored && stored.from <= block) {
        // Ревью #264 CCF: возврат обязательства — только в пределах наблюдаемой головы. Запрос
        // блоков выше head либо получит ошибку узла, либо пустой ответ, а «доказательство» ещё не
        // существующих блоков станет ложной непрерывностью (в тестах stub вернул бы [] и закрыл
        // диапазон выше head). Частичный префикс обязательства доказательством не считается —
        // оно остаётся в хранилище до полного чтения своего диапазона.
        fromNum = stored.from;
        toNum = Math.min(block, stored.to);
        fromBlock = '0x' + fromNum.toString(16);
        toBlock = '0x' + toNum.toString(16);
      }
    }
  }
  const behind = fromNum == null ? 0 : Math.max(0, block - toNum);
  m.cursorBehind = behind; // AR9: видно, догоняет ли сеть
  if (behind > 0) m.catchUpCycles = (m.catchUpCycles || 0) + 1;
  m.cursor = st.cursor;
  let chunkSize = evmHotChunkEffective(chainId);
  m.chunkSize = chunkSize;
  m.degraded = evmHotChunkDegraded(chainId);
  const consumeLogs = async (logs: Array<{ transactionHash?: string; address?: string }>): Promise<void> => {
    m.logs += logs.length;
    if (logs.length > 0) m.lastLogAt = Date.now(); // v9.32ai ID3: страж тишины hotPoll
    const seenTx = new Set<string>();
    for (const lg of logs) {
      // v9.32am HOTFIX+ (MD2): активность мигрировавших пулов в поллинге (для возврата на WS при тишине)
      if (wsHotPoll.addrIndexSize() > 0 && lg?.address) {
        wsHotPoll.recordPollActivity(chainId + ':' + String(lg.address).toLowerCase(), Date.now());
      }
      const tx = String(lg?.transactionHash || '');
      if (!tx || seenTx.has(tx)) continue; // intra-batch dedup: same tx × N subscribed addresses
      seenTx.add(tx);
      m.txs++;
      // existing battle path: seenTxs dedup → analyze budgets → strict Phase A for token/router
      // ТЗ bm2n6: origin=hotpoll — кадр пришёл из eth_getLogs-цикла (эмиттер = адрес из хотлиста)
      void handleEvmWsLog(chainId, tx, String(lg.address || ''), 'hotpoll').catch(() => { /* counted in evmWs stats */ });
    }
  };
  let cycleCalls = 0; let cycleFails = 0; // v9.32ah HD3: fail-rate цикла для деградации chunk
  let prunedThisCycle = false; // v9.32be BE3 (AR5): pruned-история ≠ fail
  let rangeTooWideThisCycle = false; // v9.32bf BF3 (AR8)
  let lastCallNeutral = false; // W3 T3
  let lastCallSkipped = false;
  let stepFailure: { outcome: GetLogsOutcome; detail: string } | null = null; // W3 T3: причина ПЕРВОГО отказа шага (success соседа затирает evmHotLastFailure)
  evmHotPrunedSignal[chainId] = false;
  evmHotRangeSignal[chainId] = false;
  // ТЗ 2026-10-13 B1 + review PR #263 (P1×3, P2): too-wide делится ВНУТРИ того же непрочитанного
  // диапазона и того же цикла — но только для согласованных в ТЗ цепей. Polygon из scope исключён:
  // для него этот путь недостижим (иначе общий EVM-фикс молча менял бы его поведение).
  // Инварианты драйвера:
  //  - уже прочитанные логи успешного префикса НЕ теряются при отказе позднего листа: они
  //    возвращаются как `partial` и уходят в consumeLogs, но шаг полным не считается, курсор
  //    не продвигается, gap остаётся открытым;
  //  - наверх уходит причина именно блокирующего листа (`failure`), а не первый промежуточный
  //    too-wide, который удалось преодолеть;
  //  - общий бюджет ЦИКЛА по ФАКТИЧЕСКИМ provider attempts (m.getLogsAttempts: ротация по
  //    endpoints, per-address split, chunks и holder — всё внутри него). Исчерпание = честный
  //    deferred (skipped), а не фиктивный success_empty и не рост оплачиваемых лимитов.
  const TOO_WIDE_SPLIT_MAX_SUBCALLS = 32;
  const TOO_WIDE_SPLIT_CHAINS: ReadonlySet<string> = new Set(['ethereum', 'bsc', 'base', 'arbitrum']);
  const CYCLE_PROVIDER_ATTEMPT_BUDGET = (): number => {
    const n = Number(process.env.HOTPOLL_CYCLE_MAX_PROVIDER_ATTEMPTS || '64');
    return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 64;
  };
  const cycleAttemptBudget = CYCLE_PROVIDER_ATTEMPT_BUDGET();
  const cycleAttemptsAtStart = m.getLogsAttempts ?? 0;
  // BSC free holder rescue can try more than one URL per logical call: cap each provider attempt.
  // Other chains, including Polygon, keep their existing routing semantics.
  const bscCycleAttemptCeiling = chainId === 'bsc' ? cycleAttemptsAtStart + cycleAttemptBudget : undefined;
  const bscPublicHolderPath = chainId === 'bsc' && !alchemyHttpEnabled(chainId) && HOT_POLL_MAX_FAIL_RATE() < 1;
  const cycleBudgetExhausted = (): boolean => TOO_WIDE_SPLIT_CHAINS.has(chainId)
    && (m.getLogsAttempts ?? 0) - cycleAttemptsAtStart >= cycleAttemptBudget;
  type HotLogAttempt = {
    /** Полный результат диапазона; null — диапазон прочитан не целиком. */
    logs: Array<{ transactionHash?: string; address?: string }> | null;
    /** Логи успешно прочитанных поддиапазонов при logs===null: доставить, но не засчитывать покрытие. */
    partial: Array<{ transactionHash?: string; address?: string }>;
    outcome: EvmHotLogicalOutcome;
    /** Причина именно блокирующего листа при logs===null. */
    failure: { outcome: GetLogsOutcome; detail: string } | null;
  };
  // Review PR #263 #2: budget exhaustion — отдельный исход 'skipped-budget' и точная причина,
  // а не маскировка под cooldown ('skipped-cooled'), который мог и не наступать.
  const BUDGET_DETAIL = 'cycle-provider-budget-exhausted';
  const budgetDeferredAttempt = (): HotLogAttempt => ({
    logs: null, partial: [], outcome: 'skipped-budget',
    failure: { outcome: 'skipped', detail: BUDGET_DETAIL },
  });
  const noteBudgetSkipped = (out: HotLogAttempt): void => {
    if (out.failure?.detail !== BUDGET_DETAIL) return;
    noteGetLogsOutcome(chainId, 'skipped');
    noteEvmHotLogicalOutcome(chainId, 'skipped-budget');
  };
  const splitTooWideRange = async (
    f0: number, t0: number,
    attempt: (f: number, t: number) => Promise<HotLogAttempt>,
  ): Promise<HotLogAttempt> => {
    if (!TOO_WIDE_SPLIT_CHAINS.has(chainId)) return attempt(f0, t0); // вне согласованного scope: поведение как до PR
    let subcalls = 0;
    const rec = async (f: number, t: number): Promise<HotLogAttempt> => {
      if (cycleBudgetExhausted()) return budgetDeferredAttempt();
      const a = await attempt(f, t);
      if (a.outcome !== 'neutral-range' || t <= f || subcalls + 2 > TOO_WIDE_SPLIT_MAX_SUBCALLS) return a;
      const mid = f + Math.floor((t - f) / 2);
      subcalls += 2;
      const left = await rec(f, mid);
      if (!Array.isArray(left.logs)) return left; // left.partial уже несёт прочитанное
      const right = await rec(mid + 1, t);
      if (!Array.isArray(right.logs)) {
        // Прочитанный префикс не теряем, но и полным диапазон не объявляем (review P1).
        return { logs: null, partial: [...left.logs, ...right.partial], outcome: right.outcome, failure: right.failure };
      }
      return { logs: [...left.logs, ...right.logs], partial: [], outcome: right.outcome, failure: null };
    };
    return rec(f0, t0);
  };
  const getLogsFor = async (targets: string[]): Promise<HotLogAttempt> => {
    const startFrom = Number.parseInt(fromBlock, 16);
    const startTo = Number.parseInt(toBlock, 16);
    const out = await splitTooWideRange(startFrom, startTo, async (f, t) => {
      m.calls++;
      cycleCalls++;
      const r = await evmHotRpc<Array<{ transactionHash?: string; address?: string }>>(chainId, 'eth_getLogs', [
        { address: targets, topics: [[TRANSFER_TOPIC]], fromBlock: '0x' + f.toString(16), toBlock: '0x' + t.toString(16) },
      ], bscCycleAttemptCeiling);
      const outcome = classifyEvmHotLogicalOutcome(chainId, r);
      noteEvmHotLogicalOutcome(chainId, outcome);
      lastCallSkipped = outcome === 'skipped-cap' || outcome === 'skipped-cooled' || outcome === 'skipped-budget';
      lastCallNeutral = outcome === 'neutral-range' || outcome === 'neutral-pruned'; // W3 T3: признак ЭТОГО вызова, не липкий флаг цикла
      // P2 (review): stepFailure здесь НЕ захватываем — первый промежуточный too-wide не должен
      // перекрывать причину блокирующего листа; её вернёт драйвер в `failure`.
      if (outcome === 'neutral-range') {
        // Fix 2 (#257): отказы только никогда не способных нод не сужают глобальный span.
        if (!takeEvmHotRangeIncapableOnly(chainId)) rangeTooWideThisCycle = true;
        evmHotRangeSignal[chainId] = false;
        cycleCalls = Math.max(0, cycleCalls - 1); // AR8: ширина окна ≠ нездоровье эндпоинта
      } else if (outcome === 'neutral-pruned') {
        prunedThisCycle = true;
        evmHotPrunedSignal[chainId] = false;
        cycleCalls = Math.max(0, cycleCalls - 1); // AR5: не влияет на fail-rate → нет ложной деградации
      } else if (outcome === 'failed') {
        cycleFails++;
      }
      const lf = evmHotLastFailure[chainId];
      return {
        logs: r, partial: [], outcome,
        failure: Array.isArray(r) ? null
          : outcome === 'skipped-cap' || outcome === 'skipped-cooled' ? { outcome: 'skipped' as GetLogsOutcome, detail: outcome }
            : lf ? { outcome: lf.outcome, detail: lf.detail } : null,
      };
    });
    noteBudgetSkipped(out); // синтетический пропуск учитывается как skipped, а не теряется (review #2 P2)
    // Флаги шага описывают ИТОГ логической операции, а не последнюю промежуточную попытку split.
    // Review #3 P1: skipped-budget — пропуск, а не отказ multi-address батча: иначе chunk зря
    // деградировал до 1 (восстановление 10 мин) и плодил per-address «ретраи» при пустом бюджете.
    lastCallNeutral = out.outcome === 'neutral-range' || out.outcome === 'neutral-pruned';
    lastCallSkipped = out.outcome === 'skipped-cap' || out.outcome === 'skipped-cooled' || out.outcome === 'skipped-budget';
    return out;
  };
  let anyOk = false;
  // ТЗ 2026-10-09 §3.1 req 3: окно считается обработанным только когда ВСЕ его логические вызовы
  // успешны. Прежний флаг `anyOk` двигал курсор по ОДНОМУ успешному чанку, и упавшие чанки того же
  // окна терялись молча — разрыв покрытия становился невидимым (прод polygon cursor=null при
  // getLogsFailed=258; base getLogsFailed=805 при deliveryHealth.overall='ok').
  let anyFailedRange = false;
  let admissionBlocked = false;
  const maxSteps = fromNum == null ? 1 : Math.max(1, HOTPOLL_CATCHUP_STEPS());
  let steps = 0;
  for (;;) {
  // ТЗ 2026-10-11 §1/§5 (WP-E2): выборка ниже берёт дыры только по ФАКТИЧЕСКОМУ пересечению
  // с живым окном, значит дыра, чей конец строго левее `fromNum`, не достанется ни одному
  // будущему окну. PR #256 закрыл ту же ложь для пути recordSuccess, но recordSuccess не
  // вызывается, когда шаг окна сам упал — тогда границу уносит вперёд голова цепи
  // (прод bsc 13:34:05Z: 18 дыр ниже окна 127028175 с nextRetryAt, просроченным на 11–41 мин).
  // Дыра остаётся открытой: verdict/checkpoint/границы не трогаем, снимаем только ложное
  // обещание повтора. Переход ОБРАТИМ: `block` приходит по RPC и при ротации/охлаждении
  // endpoint может наблюдаться ниже на 1-2 блока (см. комментарий выше про расхождение latest),
  // поэтому вернувшаяся в окно дыра возобновляет повтор — иначе terminal-обход увёл бы окно за
  // реальный диапазон навсегда. Стоит до выборки, чтобы учитывать и переприцеленное окно.
  evmHotCoverage.noteLiveWindow(chainId, fromNum, toNum, hotPollNow());
  const overlaps = evmHotCoverage.snapshotAt(chainId, hotPollNow()).gaps
    .filter(g => g.fromBlock <= toNum && g.toBlock >= fromNum);
  const terminalOverlaps = overlaps.filter(g => g.state === 'terminal');
  const deferred = overlaps.filter(g => g.state === 'unresolved' && (g.nextRetryAt === null || g.nextRetryAt > hotPollNow()));
  if (deferred.length) {
    admissionBlocked = true;
    m.rangeAdmission = { state: 'deferred', fromBlock: fromNum, toBlock: toNum,
      nextRetryAt: deferred.some(g => g.nextRetryAt === null) ? null : Math.max(...deferred.map(g => g.nextRetryAt!)) };
    m.admissionDeferredCycles++;
    break; // No request, no failure, no retry consumption and no cursor jump over this step.
  }
  if (terminalOverlaps.length) {
    // ТЗ 2026-10-11 (post-#252) W3: terminal-дыра не запрашивается и не скрывается, но не
    // должна навсегда останавливать живое покрытие цепи. Прежний безусловный break оставлял
    // курсор перед дырой, каждое следующее окно снова её пересекало — цепь слепла до рестарта.
    // Окно переводится ЗА дыру (или читается живой префикс перед ней). Чекпоинт журнала при
    // этом не двигается: он считается журналом только по проверенным прогонам (frontierOf),
    // дыра остаётся видимой, verdict цепи — coverage_gap.
    m.rangeAdmission = { state: 'terminal', fromBlock: fromNum, toBlock: toNum, nextRetryAt: null };
    m.admissionTerminalCycles++;
    const firstTerminalFrom = Math.min(...terminalOverlaps.map(g => g.fromBlock));
    if (fromNum < firstTerminalFrom) {
      toNum = firstTerminalFrom - 1; // живой префикс перед дырой; саму дыру не трогаем
      toBlock = '0x' + toNum.toString(16);
    } else {
      fromNum = Math.max(...terminalOverlaps.map(g => g.toBlock)) + 1;
      if (fromNum > block) { admissionBlocked = true; break; } // за дырой до головы блоков нет
      toNum = Math.min(block, fromNum + span);
      fromBlock = '0x' + fromNum.toString(16);
      toBlock = '0x' + toNum.toString(16);
    }
    m.cursorBehind = Math.max(0, block - toNum);
    continue; // admission re-checks the re-aimed window; the terminal range itself is never requested
  }
  m.rangeAdmission = { state: 'admitted', fromBlock: fromNum, toBlock: toNum };
  m.blocksScanned += Math.max(0, toNum - fromNum + 1);
  steps++;
  const stepProviderAttempts = m.getLogsAttempts ?? 0;
  let stepOk = false;
  let stepFailed = false;
  stepFailure = null;
  // ТЗ 2026-10-14 (находки ревью #266 H2/H5): strict persist-before-attempt. Гидратация обязательств
  // прошлого boot обязана завершиться ПОЛНЫМ чтением ДО первого RPC — иначе «доказательство»
  // строилось бы на неполном union и не было бы восстановимо после рестарта. Пока не готова —
  // диапазон не читается, фиксируется видимая дыра measurement-unknown-storage (не молчание).
  if (needsDurableEpoch(chainId) && !(await ensureFilterEpochReady())) {
    evmHotCoverage.recordDeferred(chainId, fromNum, toNum, 'measurement-unknown-storage', hotPollNow());
    m.filterEpochBlocked = (m.filterEpochBlocked || 0) + 1;
    log(`[hotpoll] ${chainId}: filter-epoch hydrate не завершена — шаг ${fromNum}..${toNum} не читается (measurement-unknown-storage)`);
    break;
  }
  // Межцикловый прогресс ЭТОГО шага: адреса, реально прочитанные в прошлых циклах, повторно не
  // запрашиваются и бюджет не тратят. Шаг завершён только когда прочитаны ВСЕ адреса + holder.
  const prog: EvmHotStepProgress = (() => {
    const cur = st.stepProgress;
    if (cur && cur.from === fromNum && cur.to === toNum) {
      // Тот же диапазон: обязательство расширяется текущим списком, прочитанное не теряется.
      for (const a of addrs) cur.requiredAddrs.add(a);
      for (const h of holders) cur.requiredHolders.add(h);
      return cur;
    }
    // ТЗ 2026-10-13 §4: обязательства, чьи диапазоны шаг накрывает целиком, обязаны войти в
    // required-набор — иначе текущий hot-list выдал бы за «полное покрытие» диапазон, прочитанный
    // без выбывших фильтров. `done` восстанавливается только для ТОЧНОГО диапазона: прогресс по
    // другому диапазону доказательством для этого шага не является.
    const store = evmHotObligationStore[chainId];
    const exact = store?.get(evmHotObligationKey(fromNum, toNum));
    const coveredObligations = store ? [...store.values()].filter(e => e.from >= fromNum && e.to <= toNum) : [];
    const fresh: EvmHotStepProgress = {
      from: fromNum, to: toNum,
      requiredAddrs: new Set([...addrs, ...coveredObligations.flatMap(e => [...e.requiredAddrs])]),
      requiredHolders: new Set([...holders, ...coveredObligations.flatMap(e => [...e.requiredHolders])]),
      done: new Set(exact?.done ?? []), doneHolders: new Set(exact?.doneHolders ?? []),
    };
    st.stepProgress = fresh;
    return fresh;
  })();
  // Рабочий список шага: текущий hot-list + ещё не прочитанные выбывшие адреса обязательства
  // (они дочитываются именно для этого диапазона, а не объявляются покрытыми молча).
  // ТЗ 2026-10-14 §3-M1: обязательство фиксируется durable ДО попытки чтения: состав фильтров
  // (union, включая выбывших), диапазон и версия политики. Churn расширяет union идемпотентно.
  // holders — только валидные адреса: ровно тот же нормализующий фильтр, что и в
  // `recordProvenCoverage`, иначе невалидный holder сделал бы обязательство вечно недоказуемым.
  const durableHolders = () => [...prog.requiredHolders].filter(h => isValidEvmAddress(h));
  const durableObligation = needsDurableEpoch(chainId)
    ? await evmFilterEpoch.noteObligation({
        chainId, fromBlock: fromNum, toBlock: toNum,
        addrs: prog.requiredAddrs, holders: durableHolders(),
        reason: 'step-open', membershipSources: ['hotlist', 'obligation-store'],
      })
    : true; // Polygon: RAM-семантика как до M1 (ТЗ §1 — его поведение не меняется)
  noteFilterEpochCompactions(hotPollNow());
  if (!durableObligation) {
    // H5: durable-обязательство не зафиксировано → RPC запрещён (иначе попытка чтения и бюджет
    // тратятся без улики, которую можно было бы восстановить после рестарта).
    evmHotCoverage.recordDeferred(chainId, fromNum, toNum, 'measurement-unknown-storage', hotPollNow());
    m.filterEpochBlocked = (m.filterEpochBlocked || 0) + 1;
    log(`[hotpoll] ${chainId}: durable-обязательство не записано (${evmFilterEpoch.summary().storageError ?? 'storage'}) — шаг ${fromNum}..${toNum} не читается`);
    break;
  }
  const stepAddrs = [...addrs, ...[...prog.requiredAddrs].filter(a => !addrs.includes(a)).sort()];
  const stepHolders = [...holders, ...[...prog.requiredHolders].filter(h => !holders.includes(h)).sort()];
  let stepBudgetIncomplete = false;
  for (let i = 0; i < stepAddrs.length; i += chunkSize) {
    const chunk = stepAddrs.slice(i, i + chunkSize);
    if (chunk.every(a => prog.done.has(a))) continue; // уже реально прочитан в прошлых циклах — бюджет не тратим
    const res = await getLogsFor(chunk);
    const logs = res.logs;
    if (!Array.isArray(logs)) {
      // Review PR #263 P1: уже прочитанный префикс split доставляем в анализ (как логи соседнего
      // успешного чанка), но шаг полным не считаем: stepFailed, курсор стоит, gap открыт.
      if (res.partial.length > 0) { anyOk = true; await consumeLogs(res.partial); }
      // Review P2: причина шага — блокирующий лист этой логической операции, а не первый too-wide.
      if (res.failure) stepFailure = stepFailure ?? res.failure;
      if (res.failure?.detail === BUDGET_DETAIL) stepBudgetIncomplete = true;
      // AR5/AR8: нейтральный исход — не fail эндпоинта (fails/degrade не трогаем), НО диапазон шага не
      // прочитан целиком: stepFailed → ledger получает gap, курсор не уходит за шаг (W3 T3; проба 2026-10-10:
      // успех соседнего чанка двигал курсор при invalid_range соседа).
      if (lastCallNeutral || lastCallSkipped) { stepFailed = true; continue; }
      // GD3: мульти-адресный батч отклонён (420/400) → автореградация + пер-адресный ретрай
      if (chunk.length > 1 && evmHotChunkEffective(chainId) > 1) {
        degradeHotChunk(chainId, m);
        chunkSize = 1; // остаток цикла пер-адресно (кадры не теряем, AR2)
        log(`[hotpoll] ${chainId}: multi-address eth_getLogs отклонён нодой → пер-адресный режим (chunk=1), ретрай в этом же цикле`);
        for (const a of chunk) {
          const one = await getLogsFor([a]);
          if (Array.isArray(one.logs)) { anyOk = true; stepOk = true; prog.done.add(a); await consumeLogs(one.logs); }
          else {
            if (one.partial.length > 0) { anyOk = true; await consumeLogs(one.partial); }
            if (one.failure) stepFailure = stepFailure ?? one.failure;
            if (one.failure?.detail === BUDGET_DETAIL) stepBudgetIncomplete = true;
            if (!lastCallSkipped) m.fails++; stepFailed = true;
          }
        }
        continue;
      }
      m.fails++;
      stepFailed = true;
      continue;
    }
    anyOk = true; stepOk = true;
    for (const a of chunk) prog.done.add(a);
    await consumeLogs(logs);
  }
  // v9.32ap PD2 (AP6): AllowanceHolder-свопы. address=holder мёртв принципиально (holder не
  // ERC20 — Transfer эмитит токен; урок AK2 о роутерах), поэтому ловим ТОПИКОМ to=holder:
  // каждая сделка через 0x AllowanceHolder доставляет sellToken holder'у (12 пропусков base,
  // $12K BNKR-класс 2026-09-03 00:30–01:59 — свопы были, кадров 0: WS мёртв после паузы AP7).
  // Один getLogs на цепь на цикл (~75 CU на Alchemy, паблики — бесплатно через ротацию PD4).
  if (stepHolders.length) {
    for (const h of stepHolders) {
      if (!isValidEvmAddress(h) || prog.doneHolders.has(h)) continue;
      m.allowanceHolder = m.allowanceHolder || { calls: 0, logs: 0 };
      m.allowanceHolder.calls++;
      // ТЗ 2026-10-13 B1: тот же too-wide split, что у адресных чанков — holder читает этот же шаг.
      let holderTooWide = false;
      const holderRes = await splitTooWideRange(fromNum, toNum, async (f, t) => {
        m.calls++;
        const r = await evmHotRpc<Array<{ transactionHash?: string; address?: string }>>(chainId, 'eth_getLogs', [
          { topics: [[TRANSFER_TOPIC], [], [padTopicAddress(h)]], fromBlock: '0x' + f.toString(16), toBlock: '0x' + t.toString(16) },
        ], bscCycleAttemptCeiling);
        const outcome = classifyEvmHotLogicalOutcome(chainId, r);
        noteEvmHotLogicalOutcome(chainId, outcome);
        if (outcome === 'neutral-range') { if (!takeEvmHotRangeIncapableOnly(chainId)) holderTooWide = true; evmHotRangeSignal[chainId] = false; }
        if (outcome === 'neutral-pruned') { prunedThisCycle = true; evmHotPrunedSignal[chainId] = false; }
        const lf = evmHotLastFailure[chainId];
        return {
          logs: r, partial: [], outcome,
          failure: Array.isArray(r) ? null
            : outcome === 'skipped-cap' || outcome === 'skipped-cooled' ? { outcome: 'skipped' as GetLogsOutcome, detail: outcome }
              : lf ? { outcome: lf.outcome, detail: lf.detail } : null,
        };
      });
      const hLogs = holderRes.logs;
      const holderOutcome = holderRes.outcome;
      // BSC public providers accept wider address filters than topic-only holder filters.
      // A recovered split (or a budget-deferred one) does not disprove the address span.
      // Keep real unresolved width failures and every legacy/paid-reserve path unchanged.
      const scopedHolderSplit = bscPublicHolderPath
        && (Array.isArray(hLogs) || holderRes.failure?.detail === BUDGET_DETAIL);
      if (holderTooWide && !scopedHolderSplit) rangeTooWideThisCycle = true;
      noteBudgetSkipped(holderRes);
      if (holderRes.failure?.detail === BUDGET_DETAIL) stepBudgetIncomplete = true;
      if (holderOutcome === 'failed') cycleFails++;
      // Review P1: прочитанный holder-префикс тоже доставляется, полным шаг от этого не становится.
      if (!Array.isArray(hLogs) && holderRes.partial.length > 0) {
        m.allowanceHolder.logs += holderRes.partial.length;
        await consumeLogs(holderRes.partial);
      }
      // W3 T4: holder-вызов, не вернувший массив (failed / neutral-range / neutral-pruned / skipped), — диапазон шага
      // не прочитан. Раньше это влияло только на cycleFails: ledger не получал gap, курсор уходил за шаг.
      if (!Array.isArray(hLogs)) {
        stepFailed = true;
        if (holderRes.failure) stepFailure = stepFailure ?? holderRes.failure;
      }
      if (Array.isArray(hLogs)) {
        prog.doneHolders.add(h);
        m.allowanceHolder.logs += hLogs.length;
        await consumeLogs(hLogs);
      }
    }
  }
  // ТЗ §3.1 req 3/4/5: записываем окно в журнал ДО решения о курсоре. Success — только когда шаг
  // прошёл целиком; иначе unresolved-диапазон с причиной, и курсор за него не уходит.
  if (fromNum != null) {
    const ledgerNow = hotPollNow();
    // Межцикловое завершение: в этом цикле могли дочитаться только остававшиеся адреса.
    const allDone = [...prog.requiredAddrs].every(a => prog.done.has(a)) && [...prog.requiredHolders].every(h => !isValidEvmAddress(h) || prog.doneHolders.has(h));
    if (!stepFailed && allDone) {
      stepOk = true;
      // A BSC resume may only finish the holder: address reads succeeded in an earlier cycle.
      // The fully read step must advance its cursor even without a new address chunk this cycle.
      if (bscPublicHolderPath) anyOk = true;
    }
    if (stepFailed) {
      const f = stepFailure ?? evmHotLastFailure[chainId];
      // Review #3 P2: реальный блокирующий отказ шага старше бюджетной отсрочки. Если в том же
      // шаге позже исчерпался бюджет, gap обязан нести реальную причину и сжечь реальную попытку
      // (recordFailure ниже), а не подменяться skipped/attempts=0. Бюджетный хвост при этом
      // остаётся виден в getLogsOutcomes.skipped, а диапазон целиком накрыт реальным gap.
      const realFailure = stepFailure && stepFailure.outcome !== 'skipped' ? stepFailure : null;
      if (realFailure) {
        m.rangeStepFailures++;
        evmHotCoverage.recordFailure(chainId, fromNum, toNum, realFailure.outcome, realFailure.detail, ledgerNow);
        // ТЗ 2026-10-13 §4 (P1): реальный отказ НЕ снимает обязательство. Прежний `stepProgress=null`
        // терял union `requiredAddrs/requiredHolders`, и следующий успех окна с уже урезанным
        // hot-list закрывал диапазон, ни разу не запросив выбывший-непрочитанный адрес. Сохраняем
        // union+done в хранилище и метим дыру `proofRequired`; окно при этом НЕ пришпиливается —
        // дальнейшую достижимость решает admission-gate (WP-E2/P2 control не ломаются).
        dropStepProgressObligation(chainId, prog, 'real-failure');
        st.stepProgress = null;
      } else if (f?.detail === BUDGET_DETAIL || stepBudgetIncomplete) {
        // Review #2 P1/P2: бюджетное исчерпание — видимый deferred-gap с точной причиной,
        // а не зелёный ok; retry-бюджет провайдера не сжигается, курсор не двигается.
        evmHotCoverage.recordDeferred(chainId, fromNum, toNum, BUDGET_DETAIL, ledgerNow);
        m.rangeAdmission = { state: 'provider-deferred', fromBlock: fromNum, toBlock: toNum, nextRetryAt: null, detail: BUDGET_DETAIL };
        m.admissionDeferredCycles++;
      } else if ((m.getLogsAttempts ?? 0) === stepProviderAttempts || !stepFailure || f?.outcome === 'skipped') {
        m.rangeAdmission = { state: 'provider-deferred', fromBlock: fromNum, toBlock: toNum, nextRetryAt: null, detail: f?.detail ?? null };
        m.admissionDeferredCycles++;
        // ТЗ 2026-10-13 §4 (P1): cap/cooled-отсрочка без провайдерской попытки НЕ снимает
        // обязательство и НЕ сбрасывает прогресс шага: следующая попытка продолжает список с
        // первого непрочитанного, иначе диапазон закроется после оживания источника, не дочитав выбывшего.
      } else {
        m.rangeStepFailures++;
        evmHotCoverage.recordFailure(chainId, fromNum, toNum, f?.outcome ?? 'provider_error', f?.detail ?? 'unknown', ledgerNow);
        // ТЗ 2026-10-13 §4 (P1): отказ без классифицированного leaf-исхода — тот же инвариант,
        // что и выше: union+done уходят в хранилище, дыра метится proofRequired, окно не пришпилено.
        dropStepProgressObligation(chainId, prog, 'unclassified-failure');
        st.stepProgress = null;
      }
      anyFailedRange = true;
    } else if (stepOk) {
      // ТЗ 2026-10-14 §3-M1: доказательство диапазона durable-фиксируется ДО закрытия обязательств.
      // Если хранилище СКОНФИГУРИРОВАНО, но запись не удалась — fail-closed: обязательства не
      // дренируются, покрытие не объявляется доказанным (никакого «0 missed» из тишины БД).
      const durableProven = await evmFilterEpoch.noteProven({
        chainId, fromBlock: fromNum, toBlock: toNum,
        addrs: prog.requiredAddrs, holders: durableHolders(),
      });
      const epochSummary = evmFilterEpoch.summary();
      if (!durableProven && epochSummary.configured) {
        evmHotCoverage.noteProofPending(chainId, fromNum, toNum,
          `durable-proof-write-failed:${epochSummary.storageError ?? 'storage'}`, ledgerNow,
          { addrs: [...prog.requiredAddrs], holders: [...prog.requiredHolders] });
      }
      evmHotCoverage.recordSuccess(chainId, fromNum, toNum, ledgerNow);
      // ТЗ 2026-10-13 §4: доказательство принимается только для диапазона, ФАКТИЧЕСКИ прочитанного
      // со всем union-набором обязательств (allDone выше). Каждое накрытое обязательство
      // закрывается своим recordProvenCoverage и снимается с хранения; частично накрытые остаются
      // открытыми — их доказательство требует возврата к их собственному диапазону.
      const store = evmHotObligationStore[chainId];
      if (store) {
        for (const [key, e] of [...store.entries()]) {
          if (e.from >= fromNum && e.to <= toNum) {
            // Доказательство объявляет, ЧТО именно прочитано: диапазон обязательства и его фильтры
            // (allDone гарантирует, что весь union-набор этого обязательства реально прочитан).
            // Находка H1: durable-доказательство запрашивается ДЛЯ ЭТОГО обязательства (его диапазон
            // и его union), иначе доказательство окна не исполняло бы восстановленный поддиапазон.
            const entryHolders = [...e.requiredHolders].filter(h => isValidEvmAddress(h));
            const entryProven = needsDurableEpoch(chainId)
              ? await evmFilterEpoch.noteProven({
                  chainId, fromBlock: e.from, toBlock: e.to,
                  addrs: e.requiredAddrs, holders: entryHolders,
                })
              : false;
            if (entryProven || !epochSummary.configured || !needsDurableEpoch(chainId)) {
              evmHotCoverage.recordProvenCoverage(chainId, e.from, e.to, ledgerNow, {
                addrs: [...e.requiredAddrs],
                holders: entryHolders,
              });
              store.delete(key);
            }
          }
        }
      }
      noteFilterEpochCompactions(ledgerNow);
      // Диапазон доказан и durable-зафиксирован — прогресс больше не нужен. При отказе durable
      // записи (сконфигурированное хранилище) прогресс НЕ снимается: обязательство остаётся живым.
      if (durableProven || !epochSummary.configured) st.stepProgress = null;
    }
  }
  // v9.32bg BG5: шаг удался и голова ещё впереди — двигаем окно и повторяем в этом же цикле.
  // ТЗ §3.1 req 3: catch-up прерывается на первом необработанном окне — bounded и без пропуска дыры.
  if (!stepOk || anyFailedRange || fromNum == null || toNum >= block || steps >= maxSteps) break;
  st.cursor = toNum; // фиксируем прогресс: падение следующего шага не откатывает догон
  const nextFrom = Math.min(toNum + 1, block);
  toNum = Math.min(block, nextFrom + span);
  fromNum = nextFrom; // ledger and RPC must describe the same catch-up step
  fromBlock = '0x' + nextFrom.toString(16);
  toBlock = '0x' + toNum.toString(16);
  m.cursorBehind = Math.max(0, block - toNum);
  }
  // ТЗ §3.1 req 3: курсор = конец последнего НЕПРЕРЫВНО обработанного окна. `anyOk` больше не
  // достаточен: успешный чанк при упавшем соседе дыру не закрывает.
  if (anyOk && !anyFailedRange && !admissionBlocked) st.cursor = toNum;
  // v9.32be BE3 (AR5/AR6): нода отдала "History has been pruned" — курсор отстал за доступную
  // глубину. Откатываем его к head - PRUNED_REWIND_BLOCKS, чтобы следующий цикл попал в окно.
  if (rangeTooWideThisCycle) {
    narrowHotSpan(chainId, m); // success of a sibling chunk does not make this range acceptable
    evmHotSpanCleanCycles[chainId] = 0; // Fix 1 (#257): любое сужение обнуляет чистую серию
  } else {
    recoverHotSpan(chainId, m, anyOk); // Fix 1 (#257): симметричное восстановление окна
  }
  let rewoundTo: number | null = null; // W2 T3
  if (prunedThisCycle && !anyOk) {
    const rewound = Math.max(1, block - PRUNED_REWIND_BLOCKS());
    if (st.cursor == null || st.cursor < rewound) {
      st.cursor = rewound;
      rewoundTo = rewound;
      m.prunedRewinds = (m.prunedRewinds || 0) + 1;
      log(`[hotpoll] ${chainId}: history pruned → курсор откачен к ${rewound} (head ${block} − ${PRUNED_REWIND_BLOCKS()}), prunedRewinds=${m.prunedRewinds}`);
    }
  }
  m.cursor = st.cursor; // v9.32ai ID2: телеметрия курсора в статусе
  (engineKernel.evmHotPauseInfo ??= {})[chainId] = { cursor: st.cursor, empty: false }; // W2 T3
  settlePauseHotlist(chainId, block, rewoundTo); // W2 T3: исход паузы по доказательству журнала
  // v9.32ah HD3 (AH4): деградация на ЛЮБОЙ повторяющийся тип фейлов — не только явный отказ
  // мульти-фильтра: bsc fails 1099/1106 при degraded:false (dataseed -32005) остались незамеченными.
  // v9.32am HOTFIX+ (MD2): цикл завершился — проверяем тишину мигрировавших (возврат на WS)
  maybeRestoreWsPoll(chainId);
  if (cycleCalls > 0 && cycleFails / cycleCalls > HOT_POLL_MAX_FAIL_RATE() && evmHotChunkEffective(chainId) > 1) {
    degradeHotChunk(chainId, m);
    log(`[hotpoll] ${chainId}: ${cycleFails}/${cycleCalls} getLogs-вызовов цикла упали (> ${Math.round(HOT_POLL_MAX_FAIL_RATE() * 100)}%) → пер-адресный режим (chunk=1)`);
  }
  refreshHotPollTelemetry(chainId);
}
/**
 * ТЗ 2026-10-14 (находки ревью #266 H1/H6): агрегат компакции журнала обязательств — состав
 * фильтров невосстановим. Он обязан быть ВИДИМЫМ и НЕ закрываемым доказательствами чужих чтений
 * (`unprovable` ⇒ sticky в coverage ledger); решение о backfill/reproof принимает владелец.
 */
function noteFilterEpochCompactions(now: number): void {
  for (const agg of evmFilterEpoch.takeCompactions()) {
    evmHotCoverage.noteProofPending(agg.chainId, agg.fromBlock, agg.toBlock,
      `filter-epoch-compacted:${agg.compactedCount ?? 0}`, now, { addrs: [], holders: [], unprovable: true });
    log(`[hotpoll] ${agg.chainId}: filter-epoch compaction → недоказуемый агрегат ${agg.fromBlock}..${agg.toBlock} (${agg.compactedCount ?? 0} обязательств)`);
  }
}

/**
 * H1: обязательство прошлого boot возвращается НЕ ТОЛЬКО видимой дырой, но и В ПЛАН ЧТЕНИЯ
 * (`evmHotObligationStore`): иначе «доказательство» A+B могло быть записано чтением только A.
 * `done` пуст — весь union обязан быть перечитан для этого диапазона.
 */
function seedEvmHotObligation(r: {
  chainId: string; fromBlock: number; toBlock: number; addrs: string[]; holders: string[];
}): void {
  const store = evmHotObligationStore[r.chainId] || (evmHotObligationStore[r.chainId] = new Map());
  const key = evmHotObligationKey(r.fromBlock, r.toBlock);
  const prev = store.get(key);
  store.set(key, {
    from: r.fromBlock, to: r.toBlock,
    requiredAddrs: new Set([...(prev?.requiredAddrs ?? []), ...r.addrs]),
    requiredHolders: new Set([...(prev?.requiredHolders ?? []), ...r.holders]),
    done: new Set(prev?.done ?? []),
    doneHolders: new Set(prev?.doneHolders ?? []),
  });
  // RAM-план чтения ограничен тем же лимитом, что и прежнее хранилище обязательств: вытесняются
  // самые старые диапазоны, но их видимые proof-дыры уже созданы `noteProofPending` — потеря
  // плана не превращается в потерю улики (закрытие возможно лишь доказательством своего диапазона).
  while (store.size > EVM_HOT_OBLIGATION_MAX) store.delete(store.keys().next().value as string);
}

/**
 * ТЗ 2026-10-14 §3-M1: восстановление обязательств после рестарта. Возвращает durable-состояние в
 * RAM-журнал покрытия (видимые дыры) и в план чтения; не переиспользует `done`/`proven` другого
 * диапазона или эпохи. Отказ чтения БД — не «пустой журнал»: состояние UNKNOWN, гидратация не
 * завершена (H2), доказательства и чтение при этом запрещены драйвером.
 */
async function hydrateFilterEpochObligations(source: string): Promise<{ obligations: number; proven: number }> {
  const counts = await evmFilterEpoch.hydrate();
  const summary = evmFilterEpoch.summary();
  if (summary.configured && summary.hydration !== 'ok') {
    log(`[hotpoll] filter-epoch hydrate(${source}): FAILED (${summary.storageError ?? 'unknown'}) — обязательства не восстановлены`);
    return counts;
  }
  const now = hotPollNow();
  const openRows = [...evmFilterEpoch.openForAll()].sort((a, b) => a.fromBlock - b.fromBlock || a.toBlock - b.toBlock);
  for (const r of openRows) {
    if (isCompactedRow(r)) {
      evmHotCoverage.noteProofPending(r.chainId, r.fromBlock, r.toBlock,
        `restart-compacted:${source}:${r.compactedCount ?? 0}`, now, { addrs: [], holders: [], unprovable: true });
      continue;
    }
    evmHotCoverage.noteProofPending(r.chainId, r.fromBlock, r.toBlock,
      `restart-replay:${source}:${r.reason}`, now, { addrs: r.addrs, holders: r.holders });
    seedEvmHotObligation(r);
  }
  noteFilterEpochCompactions(now);
  if (counts.obligations > 0 || counts.proven > 0) {
    log(`[hotpoll] filter-epoch hydrate(${source}): open=${counts.obligations} proven=${counts.proven} → видимые дыры + план чтения`);
  }
  return counts;
}

/**
 * ТЗ 2026-10-14 §1: Polygon в знаменателе, но его поведение не меняется — он исключён из
 * durable-контракта `filter_epoch` (никаких новых записей в БД, никакой блокировки чтения
 * недоступным хранилищем). Раунд 2 (находка R3): политика цепей живёт в ОДНОМ месте
 * (`FILTER_EPOCH_EXCLUDED_CHAINS` в журнале) и обязана применяться во ВСЕХ точках — регистрация
 * шага, регистрация потерянного обязательства, доказательство, гидратация.
 */
function needsDurableEpoch(chainId: string): boolean {
  return filterEpochAppliesTo(chainId);
}

/**
 * H2/H5: strict persist-before-attempt. Пока гидратация не завершена ПОЛНЫМ чтением, чтение
 * диапазонов запрещено: иначе «доказательство» строилось бы на неполном union (обязательства
 * прошлого boot ещё не возвращены), а сам proof не был бы восстановим после рестарта.
 * Повторные попытки — не чаще FILTER_EPOCH_HYDRATE_RETRY_MS, чтобы сбой БД не превращался в
 * шторм из SELECT-ов; до успеха цикл фиксирует видимую дыру `measurement-unknown-storage`.
 */
function filterEpochHydrateRetryMs(): number {
  return Math.max(5_000, Number(process.env.FILTER_EPOCH_HYDRATE_RETRY_MS || '30000'));
}
let filterEpochHydrateAttemptAt = 0;
async function ensureFilterEpochReady(): Promise<boolean> {
  const s = evmFilterEpoch.summary();
  if (!s.configured) return true; // RAM-режим: durable-контракта нет (честный флаг в summary)
  if (s.hydration === 'ok') return true;
  const now = hotPollNow();
  if (s.hydration === 'failed' && now - filterEpochHydrateAttemptAt < filterEpochHydrateRetryMs()) return false;
  filterEpochHydrateAttemptAt = now;
  await hydrateFilterEpochObligations('cycle');
  return evmFilterEpoch.summary().hydration === 'ok';
}
function startEvmHotPollers(): void {
  if (!evmHotPollEnabled()) return;
  // ТЗ 2026-10-14 §3-M1: рестарт ≠ покрытие. Открытые обязательства из durable-журнала возвращаются
  // как ВИДИМЫЕ proof-дыры (и не «доказываются» чтениями другого диапазона); доказанные — не
  // переоткрываются. Без хранилища гидратация no-op, но summary честно помечает отсутствие улики.
  void hydrateFilterEpochObligations('start');
  for (const c of EVM_WS_SUPPORTED_CHAINS) {
    const st = evmHotPollState[c] || (evmHotPollState[c] = { cursor: null, timer: null });
    if (st.timer) continue;
    st.timer = setInterval(() => { void evmHotPollCycle(c).catch(() => { /* metrics hold failures */ }); }, effectiveHotPollIntervalMs()); // v9.32al CK2: ×2 в warmup
    if (typeof st.timer === 'object' && st.timer && 'unref' in st.timer) (st.timer as NodeJS.Timeout).unref();
    log(`[hotpoll] ${c}: polling hot addresses every ${effectiveHotPollIntervalMs()}ms (chunk ${evmHotChunkEffective(c)}${evmHotChunkDegraded(c) ? ', degraded' : ''}${analysisWarmupActive() ? ', warmup ×2' : ''})`);
  }
  // v9.32al CK2: конец warmup — перевзвод таймеров на базовый интервал
  if (analysisWarmupActive() && !warmupRearmTimer) {
    const remain = Math.max(1000, evmRuntime.limits.warmup().until - Date.now());
    warmupRearmTimer = setTimeout(() => {
      warmupRearmTimer = null;
      for (const c of EVM_WS_SUPPORTED_CHAINS) {
        const st = evmHotPollState[c];
        if (st?.timer) { clearInterval(st.timer); st.timer = null; }
      }
      startEvmHotPollers();
      log('[hotpoll] warmup завершён — интервалы вернулись к базовым');
    }, remain);
    if (typeof warmupRearmTimer === 'object' && warmupRearmTimer && 'unref' in warmupRearmTimer) (warmupRearmTimer as NodeJS.Timeout).unref();
  }
}
let warmupRearmTimer: ReturnType<typeof setTimeout> | null = null;
function stopEvmHotPollers(): void {
  for (const c of Object.keys(evmHotPollState)) {
    const st = evmHotPollState[c];
    if (st.timer) { clearInterval(st.timer); st.timer = null; }
  }
}
/** ТЗ 2026-10-14 (H1/H6): видимость RAM-плана чтения восстановленных обязательств (bounded). */
export function __evmHotObligationStoreForTest(chainId: string): { size: number; ranges: string[] } {
  const store = evmHotObligationStore[chainId];
  return { size: store?.size ?? 0, ranges: store ? [...store.keys()] : [] };
}
export function __setEvmHotAddrsOverrideForTest(chainId: string, addrs: string[] | null): void {
  evmHotAddrsOverride[chainId] = addrs;
}
export async function __evmHotPollCycleForTest(chainId: string): Promise<void> { return evmHotPollCycle(chainId); }
export async function __evmHotRpcForTest<T>(chainId: string, method: string, params: unknown[]): Promise<T | null> { return evmHotRpc<T>(chainId, method, params); }
// v9.32bm1 S8: тест-хук состояния (без логики) — подмена счётчиков hotPoll сети
export function __setEvmHotPollMetricsForTest(chainId: string, patch: Partial<EvmHotPollMetrics>): void {
  Object.assign(evmHotM(chainId), patch);
}
export function __evmHotPollMetricsForTest(): Record<string, EvmHotPollMetrics> {
  const out: Record<string, EvmHotPollMetrics> = {};
  for (const k of Object.keys(evmHotPollMetrics)) out[k] = { ...evmHotPollMetrics[k] };
  return out;
}
// v9.32ag GD2: CI guard helper — the intersection of WS-subscribed addresses and the hotPoll
// hotlist MUST be empty (AG4 dual coverage). Pure, exported for the guard test.
export function __evmDualCoverageOverlapForTest(subscribed: string[], hotlist: string[]): string[] {
  const hot = new Set(hotlist.map((a) => lower(String(a))));
  return [...new Set(subscribed.map((a) => lower(String(a))))].filter((a) => hot.has(a));
}
export function __evmHotAddrsForTest(chainId: string): string[] { return [...evmHotAddrs(chainId)]; }

// ── v9.32ao OD2 (AO2в): CI-страж покрытия ролей ───────────────────────────
// Каждая enabled-роль на каждой сети обязана быть: subscribed (WS-набор) ∪ hotlist
// (getLogs-поллинг) ∪ no-events (allowlist с обоснованием — контракт не эмитит
// пользовательски-полезных событий в нашей модели). Страж не даёт новой роли
// («permit2», «infrastructure», …) молча появиться в registry без решения о покрытии.
const ROLE_GUARD_ROLES = ['settlement', 'reactor', 'router', 'allowance-holder', 'solver-helper', 'pool-manager'] as const;
const ROLE_NO_EVENTS: Record<string, string> = {
  'pool-manager': 'v4-пул-менеджер покрыт v4-анкор-веткой (UNISWAP_V4_POOL_MANAGER_ETH, Swap-подписки пулId) — голый address=poolManager даёт только Transfer-шум',
};
export function __roleCoverageForTest(chainId: string): Array<{ addr: string; role: string; covered: string; reason: string }> {
  const ws = new Set([...getSubscribedAddrs(chainId), ...getWsSubscribedAddrs(chainId), ...getSettlementAddrs(chainId)].map(a => lower(a)));
  const hot = new Set(evmHotAddrs(chainId).map(a => lower(a)));
  const out: Array<{ addr: string; role: string; covered: string; reason: string }> = [];
  for (const role of ROLE_GUARD_ROLES) {
    const addrs = getEnabledByRole(chainId, role);
    if (!addrs.length) {
      out.push({ addr: '-', role, covered: 'no-events', reason: 'роль не задеплоена (enabled) на этой сети — событий нет' });
      continue;
    }
    for (const a of addrs) {
      const al = lower(a);
      if (ws.has(al)) out.push({ addr: a, role, covered: 'subscribed', reason: 'WS-подписка (evmWs)' });
      else if (hot.has(al)) out.push({ addr: a, role, covered: 'hotlist', reason: 'getLogs-поллинг (hot-poll hotlist)' });
      else if (ROLE_NO_EVENTS[role]) out.push({ addr: a, role, covered: 'no-events', reason: ROLE_NO_EVENTS[role] });
      else out.push({ addr: a, role, covered: 'uncovered', reason: 'ни подписки, ни поллинга, ни allowlist-обоснования' });
    }
  }
  return out;
}
export function __resetEvmHotPollForTest(): void {
  for (const k of Object.keys(evmHotPollState)) {
    const st = evmHotPollState[k];
    if (st.timer) { clearInterval(st.timer); st.timer = null; }
    st.cursor = null;
    st.stepProgress = null;
    delete evmHotPollMetrics[k];
    delete evmHotChunkOverride[k]; // GD3: сброс автореградации chunk
    delete evmHotChunkDegradedAt[k];
  }
  // Direct evmHotRpc test calls can create metrics without an evmHotPollState entry.
  for (const k of Object.keys(evmHotPollMetrics)) delete evmHotPollMetrics[k];
  evmHotAddrsOverride = {};
  endpointState.healthClear(); // v9.32ah HD3: сброс кулдаунов эндпоинтов
  bscGetLogsRateByRoute.clear();
  bscAllPublicRateUntil = 0;
  bscHolderRescueEpochUntil = 0;
  bscHolderRescueProbes = 0;
  bscHolderRescueSplitRoot = null;
  endpointState.windowClear();
  endpointState.multiAddrClear(); // v9.32be BE2
  for (const k of Object.keys(evmHotPrunedSignal)) delete evmHotPrunedSignal[k];
  for (const k of Object.keys(evmHotLastFailure)) delete evmHotLastFailure[k];
  for (const k of Object.keys(evmHotOutcomeCounts)) delete evmHotOutcomeCounts[k];
  for (const k of Object.keys(evmHotCycleBusy)) delete evmHotCycleBusy[k]; // F4: сериализация цикла
  evmHotCoverage.__reset(); // ТЗ §3.1: журнал покрытия живёт вместе с курсорами
  for (const k of Object.keys(evmHotObligationStore)) delete evmHotObligationStore[k]; // ТЗ 2026-10-13 §4: RAM-обязательства умирают вместе с процессом
  engineKernel.evmHotPauseInfo = {}; engineKernel.pauseCursors = {}; engineKernel.pauseHeadsAtResume = {}; // W2 T3
  for (const k of Object.keys(evmHotRangeSignal)) delete evmHotRangeSignal[k];
  for (const k of Object.keys(evmHotLogicalOutcome)) delete evmHotLogicalOutcome[k];
  for (const k of Object.keys(evmHotSpanOverride)) delete evmHotSpanOverride[k];
  for (const k of Object.keys(evmHotSpanCleanCycles)) delete evmHotSpanCleanCycles[k]; // Fix 1 (#257)
  for (const k of Object.keys(evmHotRangeIncapableOnly)) delete evmHotRangeIncapableOnly[k]; // Fix 2 (#257)
  evmHotGetLogsCapableUrls.clear(); // Fix 2 (#257)
  for (const k of Object.keys(evmHotAllCooledSince)) delete evmHotAllCooledSince[k];
  hotPollNowOverride = null;
}

// ТЗ bm2n6: `emitter` — адрес контракта-эмиттера кадра (lg.address / result.address); пулом он
// НЕ является по определению — пул вычисляется ниже классификацией эмиттера + Swap-эмиттером чека.
export async function handleEvmWsLog(chainId: string, txHash: string, emitter: string, origin: EvmFrameOrigin = 'ws'): Promise<void> {
  if (!txHash || !isValidChainTxHash(chainId, txHash)) return;
  if (state.paused) { pausedStats.dropped++; getPauseLedger().noteDropped('evm:' + chainId); const stP = evmWsChains[chainId]; if (stP) stP.dropped++; return; } // v9.32am AM3
  const st = evmWsChains[chainId];
  const generation = st.generation;
  const isCurrent = () => evmWsChains[chainId] === st && st.generation === generation;
  if (st.seenTxs.has(txHash)) { st.dedup++; return; }
  st.seenTxs.add(txHash);
  if (!rateAllow(`evmws-analyze-${chainId}`, evmWsAnalyzeCapFor(chainId), 60000)) {
    st.rateLimited++; st.dropped++;
    getOracleRuntime().recordFrameDrop({ chain: chainId, tx: txHash, emitter: lower(emitter), reason: 'cap_analyze', at: Date.now() }); // bm2s (c): улика дропа, поведение гейта прежнее
    return;
  } // v9.32al CK2: warmup ×0.5; bm2d: лимит посетевой
  const budget = EVM_ROUTER_FRAME_BUDGET();
  if (budget > 0 && isRouterAddress(chainId, emitter)) {
    if (!rateAllow(`evm-router-${chainId}-${lower(emitter)}`, budget, 60000)) {
      routerBudgetDropped++;
      st.dropped++;
      getOracleRuntime().recordFrameDrop({ chain: chainId, tx: txHash, emitter: lower(emitter), reason: 'router_budget', at: Date.now() }); // bm2s (d)
      evmEnqueueValueRetry(chainId, txHash, emitter); // v9.32ao OD1: ценный кадр не гибнет в счётной очереди
      return;
    }
  }
  const tokenKey = chainId + ':' + lower(emitter);
  const isTokenSub = evmTokenSubAddrs.has(tokenKey);
  if (isTokenSub) {
    // v9.32ag GD2: processing budget stays (analysis cost control); delivery counters are gone
    const tBudget = EVM_TOKEN_FRAME_BUDGET();
    if (tBudget > 0 && !rateAllow(`evm-token-${tokenKey}`, tBudget, 60000)) {
      st.dropped++;
      evmEnqueueValueRetry(chainId, txHash, emitter); // v9.32ao OD1 (AO3: $100K-кадр может стать 61-м)
      return;
    }
  }

  if (isTokenSub) {
    const probe: ExtractedSubject = {
      subjectAddr: emitter, subjectSym: '?', side: 'sell', amountUsd: 0,
      fromAddr: emitter, fromSym: '?', fromAmount: 0, toAddr: emitter, toSym: '?', toAmount: 0,
      trader: '', txHash, blockTs: Math.floor(Date.now() / 1000), hasSourceBlockTs: true,
      subjectAmount: 0, selfSwap: false,
    };
    if (rejectCheapPreEnrich(chainId, probe)) return;
  }

  st.analyzed++;
  // v9.32an ND1(AN3): intent-кадры (settlement/reactor/solver-helper) — high-lane: low-lane
  // очередь (ENRICH_QUEUE_MAX) под нагрузкой дрóпала их ДО анализа — счётчики ам-механики
  // оставались пустыми при живых жертвах. Они редки и дóроги — терять нельзя.
  const intentFrame = isIntentExecutorAddr(chainId, emitter);
  await withEnrichGate(async () => {
    if (!isCurrent()) return;
    // ТЗ 2026-10-12 (WP-D2): улика анализа конвейера по ЭТОМУ кадру — захватывается в момент
    // обработки, поэтому связка «проба → анализ → RPC-попытки» не требует сопоставления по хешу.
    let analysisDiag: ArbitrumAnalysisDiagnostics | null = null;
    const analysis = await (analyzeEvmTxHandleImpl || analyzeEvmTx)(chainId, txHash, undefined, {
      pool: emitter, // v9.32al CK3: parole-контекст для суб-solo фильтра (контекст кадра = эмиттер)
      strictPhaseA: isRouterAddress(chainId, emitter) || isTokenSub,
      requireDexSwap: isTokenSub,
      onDiagnostics: (d) => { analysisDiag = d; },
    });
    if (!isCurrent()) return;
    noteEvmPoolAnalyzed(chainId, emitter);
    if (analysis?.found) {
      noteEvmPoolAlert(chainId, emitter);
    }
    if (!analysis || !analysis.found || !analysis.subject) {
      // v9.32ao OD1: суб-solo дроп при доказуемой ценности (stable-нога ≥ порога) — value-retry
      if ((analysis?.error as string | undefined) === 'sub_solo_dropped') {
        getOracleRuntime().recordFrameDrop({ chain: chainId, tx: txHash, emitter: lower(emitter), reason: 'sub_solo', at: Date.now() }); // bm2s (e)
        evmEnqueueValueRetry(chainId, txHash, emitter);
      }
      // v9.30 EVM-DEBUG F1: bucket the failure reason so we can tell from /api/status whether most
      // frames are filtered at the AnTC layer (RPC, receipt) vs the EVM swap detector (no_swap_legs,
      // tx_not_swap, self_swap, contract_from, price_oracle_invalid).
      const reason = (analysis?.error as string | null | undefined) || 'unknown';
      // ТЗ 2026-10-12 (WP-D2): rpcOk=false — транспорт анализа не ответил (receipt_missing /
      // tx_missing после ретраев): это названная граница отказа RPC для КОНКРЕТНОГО кадра.
      // Пишем улику в кольцо дропов с diagnostics анализа — оракул получает причинную связку,
      // а «rpc_err из агрегатной дельты» перестаёт быть ничейным. Поведение гейта не меняется.
      // F1-C (фикс ревью 2026-10-12): rpcOk=false бывает и при валидном пустом ответе
      // (200 result:null → empty_result) и при отсутствии RPC-источника (rpc_unavailable).
      // Раньше любой rpcOk=false писался как rpc_error, ошибочно приписывая исправному
      // транспорту отказ. Теперь различаем (для всех EVM):
      //   - rpc_error: есть хотя бы одна попытка с именованным отказом (http_error и т.п.) — есть улика
      //   - rpc_no_result: валидный пустой ответ без результата (empty_result) — есть улика
      //   - rpc_unavailable: нет настроенного RPC — явный случай
      //   - rpc_unknown: без per-tx диагностики нельзя отличить 503 от 200 null — честный нейтральный класс,
      //     не утверждает ни отказ транспорта, ни валидный пустой ответ. not_observed также не доказывает пустой результат.
      // Если отказов не было, не выдаём causally_supported (см. evmTraceAttribution).
      if (analysis && analysis.rpcOk === false) {
        const diag = analysisDiag as { outcomes?: string[]; attemptsStarted?: number } | null;
        const diagOutcomes: string[] = diag?.outcomes ?? [];
        const diagStarted = diag?.attemptsStarted;
        const isFullTrace = typeof diagStarted === 'number' && diagStarted === diagOutcomes.length;
        const hasErrorOutcome = diagOutcomes.some((o: string) =>
          o === 'http_error' || o === 'json_rpc_error' || o === 'timeout' || o === 'network_error' || o === 'malformed_response'
        );
        const allEmpty = diagOutcomes.length > 0 && diagOutcomes.every((o: string) => o === 'empty_result');
        let dropReason: 'rpc_error' | 'rpc_no_result' | 'rpc_unavailable' | 'rpc_unknown' = 'rpc_unknown';
        if ((analysis.error as string) === 'rpc_unavailable') {
          dropReason = 'rpc_unavailable';
        } else if (hasErrorOutcome) {
          // Есть улика отказа — даже если смешанный след с empty или усечённый, классифицируем как rpc_error,
          // но атрибуция не станет causally_supported без проверки терминала (см. tx-trace).
          dropReason = 'rpc_error';
        } else if (allEmpty && isFullTrace) {
          // Только валидные пустые ответы и полный след (attemptsStarted === outcomes.length) — есть улика,
          // что транспорт исправен и все ответы пустые. При усечении (2→1) утверждать «только пустые» нельзя.
          dropReason = 'rpc_no_result';
        } else if (diagOutcomes.length > 0) {
          // Есть диагностики, но нет явной ошибки и не только empty, или усечённый след без сохранённой ошибки:
          // например, только not_observed, empty+not_observed, или ['empty_result'] при started=2 (вытеснена ошибка).
          // Нельзя утверждать ни отказ, ни валидный пустой ответ. Честный класс — unknown.
          // not_observed не доказывает валидный пустой результат.
          dropReason = 'rpc_unknown';
        } else {
          // Нет per-tx диагностики (не-arbitrum, старая форма, или полностью вытесненные попытки 1→0).
          // Без улики нельзя отличить 503 от 200 null — нужен нейтральный класс.
          dropReason = (analysis.error as string) === 'rpc_unavailable' ? 'rpc_unavailable' : 'rpc_unknown';
        }
        getOracleRuntime().recordFrameDrop({
          chain: chainId, tx: txHash, emitter: lower(emitter),
          reason: dropReason,
          at: Date.now(),
          ...(analysisDiag ? { diagnostics: analysisDiag } : {}),
        });
      }
      bumpEvmWsReason(st, reason);
      st.dropped++;
      return;
    }
    const sym = analysis.subject.symbol || '?';
    const extracted: ExtractedSubject = {
      subjectAddr: analysis.subject.address, subjectSym: sym, side: analysis.side,
      amountUsd: analysis.usd,
      fromAddr: analysis.subject.address, fromSym: sym,
      fromAmount: analysis.side === 'buy' ? analysis.subjectAmount : 0,
      toAddr: analysis.subject.address, toSym: sym,
      toAmount: analysis.side === 'sell' ? analysis.subjectAmount : 0,
      trader: analysis.trader, txHash,
      blockTs: analysis.blockTs || Math.floor(Date.now() / 1000),
      hasSourceBlockTs: !!analysis.blockTs, subjectAmount: analysis.subjectAmount,
      selfSwap: false, // v9.32an ND1: found-вердикт анализа уже исключил self-swap (error 'self_swap' выше); эвристика fromAddr==toAddr здесь ложна по конструкции экстрактора
    };
    st.parsed++;
    const sc = state.chain[chainId];
    if (sc) { sc.seenEvents++; sc.lastEventAt = Date.now(); }
    // ── ТЗ bm2n6 п.1–2: эмиттер ≠ пул. В rawData.pool уходит ТОЛЬКО настоящий пул ──
    //   • kind=pool — эмиттер и есть вотчпул (подписка/поллинг по пулу);
    //   • иначе — адрес, извлечённый из Swap-эмиттера чека (п.3, уже провалидирован в analyzeEvmTx);
    //   • нет ни того, ни другого — честный null (шаги D/E в processExtractedEvent — страховка).
    // Эмиттер при этом не теряется: он уезжает в отдельное диагностическое поле объекта
    // атрибуции (`attrib`), не ломая существующие поля pool/pool_id/pa.
    const emitterClass = classifyEvmEmitter(chainId, emitter, analysis.subject.address);
    const swapEmitterPool = (analysis as { swapEmitterPool?: string | null }).swapEmitterPool ?? null;
    // bm2n7 Фаза B: v4 PoolManager — синглтон-эмиттер, не рынок. kind=pool из isPoolManager
    // не должен класть менеджер в карточку; рынок = poolId из Swap-лога чека.
    const emitterIsV4Manager = isPoolManager(chainId as SupportedChain, emitter);
    const attributedPool = emitterClass.kind === 'pool' && !emitterIsV4Manager ? emitter : swapEmitterPool;
    await processExtractedEvent(chainId, extracted, 'evmws', {
      tx: txHash,
      pool: attributedPool,
      attrib: {
        origin, emitter: lower(emitter), emitterKind: emitterClass.kind,
        inWatchPools: emitterClass.inWatchPools, inAnchors: emitterClass.inAnchors,
        swapEmitter: swapEmitterPool,
      },
    }, true, 60);
  }, { high: intentFrame });
}

async function handleEvmWsMessage(chainId: string, raw: string): Promise<void> {
  const st = evmWsChains[chainId];
  if (!st) return;
  st.frames++;
  st.lastFrameAt = Date.now();
  try {
    const msg = JSON.parse(raw);
    // Subscription confirmation or error {id, result} / {id, error}
    if (msg && typeof msg.id === 'number') {
      handleEvmWsRpcResponse(st, msg);
      return;
    }
    // Log notification {method: "eth_subscription", params: {subscription, result}}
    if (msg?.method === 'eth_subscription') {
      // v9.32av AV1: провайдер тарифицирует КАЖДЫЙ доставленный кадр. Списываем ПОСЛЕ
      // резолва tKey, чтобы разложить wsMsg на known/throttled/unknown (сумма = wsMsg).
      const subId = msg?.params?.subscription;
      const tKey = typeof subId === 'string' ? st.subIdToTarget.get(subId) : undefined;
      if (tKey && wsThrottleBlocks(tKey)) {
        billAlchemyWsFrame('throttled', chainId);
        st.dropped++;
        return;
      }
      if (tKey) billAlchemyWsFrame('known', chainId);
      else {
        billAlchemyWsFrame('unknown', chainId);
        maybeRetryZombieUnsub(chainId, subId);
      }
      noteEvmWsFrame(chainId, subId);
      const result = msg?.params?.result;
      if (!result) { st.dropped++; return; }
      const poolAddr = String(result.address || '');
      const txHash = String(result.transactionHash || '');
      const topics = result.topics as string[] || [];
      const data = String(result.data || '');
      if (!poolAddr || !txHash) { st.dropped++; return; }

      // Uniswap v4 PoolManager check
      if (poolAddr.toLowerCase() === UNISWAP_V4_POOL_MANAGER_ETH.toLowerCase()) {
        if (topics[0]?.toLowerCase() === UNISWAP_V4_SWAP_TOPIC.toLowerCase()) {
          const poolId = topics[1]?.toLowerCase();
          const v4Match = VERIFIED_ANCHOR_POOLS.find(
            a => a.chainId === chainId && a.adapter === 'uniswap-v4' && a.poolId?.toLowerCase() === poolId
          );
          if (v4Match) {
            await handleEvmWsLog(chainId, txHash, v4Match.poolId!, 'ws');
          }
        }
        return;
      }

      // v9.30 CU-Smart: on Uniswap v2 / PancakeSwap pools, a swap emits BOTH Sync and Swap logs.
      // Sync log carries only raw reserve balances, while Swap carries the trade. Skipping Sync
      // eliminates duplicate trigger attempts without missing the swap event (which arrives via Swap topic).
      if (topics[0]?.toLowerCase() === EVM_SYNC_TOPIC.toLowerCase()) {
        return;
      }

      if (evmTokenSubAddrs.has(chainId + ':' + lower(poolAddr))) {
        await handleEvmWsLog(chainId, txHash, poolAddr, 'ws');
        return;
      }
      // If from a standard AMM pool (not a settlement contract), apply in-memory pre-filter for sub-$300 noise
      if (topics[0] && !isSettlementAddr(chainId, poolAddr) && isClearlySubThresholdSwap(chainId, poolAddr, topics[0], data)) {
        st.dropped++;
        return;
      }

      await handleEvmWsLog(chainId, txHash, poolAddr, 'ws');
      return;
    }
  } catch { st.dropped++; }
}
const EVM_WS_RECONNECT_BASE_MS = () => Number(process.env.EVM_WS_RECONNECT_BASE_MS || '3000');
const EVM_WS_RECONNECT_MAX_MS = () => Number(process.env.EVM_WS_RECONNECT_MAX_MS || '300000');
const EVM_WS_STAGGER_MS = () => Number(process.env.EVM_WS_STAGGER_MS || '2000');
export function planEvmWsChainStarts(chains: string[]): Array<{ chain: string; delayMs: number }> {
  const step = EVM_WS_STAGGER_MS();
  return chains.map((chain, k) => ({ chain, delayMs: k * (Number.isFinite(step) ? step : 0) }));
}
function startEvmWsStaggered(chains: string[]): void {
  for (const { chain, delayMs } of planEvmWsChainStarts(chains)) {
    if (delayMs <= 0) startEvmWs(chain);
    else {
      const t = setTimeout(() => startEvmWs(chain), delayMs);
      if (typeof t === 'object' && t && 'unref' in t) (t as NodeJS.Timeout).unref();
    }
  }
}
const EVM_WS_STABLE_MS = 60_000;
const evmWsReconnect = {} as Record<string, { attempts: number; openedAt: number; lastDelay: number }>;
function evmWsRec(chainId: string): { attempts: number; openedAt: number; lastDelay: number } {
  if (!evmWsReconnect[chainId]) evmWsReconnect[chainId] = { attempts: 0, openedAt: 0, lastDelay: 0 };
  return evmWsReconnect[chainId];
}
type EvmWsTransport = (chainId: string, url: string) => { on: (ev: string, cb: (e?: any) => void) => void; close: () => void };
let evmWsTransportImpl: EvmWsTransport | null = null;
let evmWsNowImpl: (() => number) | null = null;
function evmWsNow(): number { return evmWsNowImpl ? evmWsNowImpl() : Date.now(); }
export function __setEvmWsTransportForTest(fn: EvmWsTransport | null): void { evmWsTransportImpl = fn; }
export function __setEvmWsNowForTest(n: number | null): void { evmWsNowImpl = n == null ? null : () => n; }
export function __startEvmWsForTest(chainId: string): void { startEvmWs(chainId); }
export function __evmWsLastDelayForTest(chainId: string): number { return evmWsRec(chainId).lastDelay; }
export function __clearEvmWsReconnectTimerForTest(chainId: string): void {
  const st = evmWsChains[chainId];
  if (!st) return;
  if (st.reconnectTimer) { clearTimeout(st.reconnectTimer); st.reconnectTimer = null; }
  st.socket = null;
}
export function evmWsBackoffMs(attempts: number): number {
  const base = EVM_WS_RECONNECT_BASE_MS();
  const max = EVM_WS_RECONNECT_MAX_MS();
  const exp = base * Math.pow(2, Math.max(0, attempts - 1));
  return Math.min(max, exp);
}
export function __resetEvmWsReconnectForTest(): void {
  for (const k of Object.keys(evmWsReconnect)) delete evmWsReconnect[k];
}
export function __evmWsNoteOpenForTest(chainId: string, now: number): void {
  const r = evmWsRec(chainId);
  r.openedAt = now;
}
export function __evmWsNoteCloseForTest(chainId: string, opts: { now: number; error?: string }): number {
  const st = evmWsChains[chainId] || (evmWsChains[chainId] = createEvmWsChainState());
  if (opts.error) st.lastError = opts.error;
  return noteEvmWsDisconnect(chainId, opts.now);
}
export function __setEvmWsHealthForTest(chainId: string, opts: { connected: boolean; lastError: string | null }): void {
  const st = evmWsChains[chainId] || (evmWsChains[chainId] = createEvmWsChainState());
  st.connected = opts.connected;
  st.lastError = opts.lastError;
}
function noteEvmWsDisconnect(chainId: string, now: number): number {
  const rec = evmWsRec(chainId);
  const st = evmWsChains[chainId];
  const is403 = !!(st?.lastError || '').includes('403');
  if (is403) return 30 * 60 * 1000;
  const lived = rec.openedAt > 0 ? now - rec.openedAt : 0;
  if (lived >= EVM_WS_STABLE_MS) rec.attempts = 0;
  rec.attempts++;
  rec.openedAt = 0;
  return evmWsBackoffMs(rec.attempts);
}
function scheduleEvmWsReconnect(chainId: string, delayMs = 3000): void {
  const st = evmWsChains[chainId];
  // v9.32bb BB3: idle 403 (сеть не включена в Alchemy App) — не крутим реконнект.
  if ((st?.lastError || '').includes('403')) {
    evmWsRec(chainId).lastDelay = 0;
    log(`[evmws] ${chainId} idle 403 — not reconnecting`);
    return;
  }
  evmWsRec(chainId).lastDelay = delayMs;
  if (state.paused) return; // v9.32ao-RC2 BP1: пауза — транспорт вниз, не переподнимаем
  if ((!evmWsTransportImpl && !evmWsEnabled(chainId)) || st.reconnectTimer || st.socket) return;
  const timer = setTimeout(() => {
    if (st.reconnectTimer !== timer) return;
    st.reconnectTimer = null;
    startEvmWs(chainId);
  }, delayMs);
  st.reconnectTimer = timer;
  if (typeof st.reconnectTimer === 'object' && st.reconnectTimer && 'unref' in st.reconnectTimer) {
    (st.reconnectTimer as NodeJS.Timeout).unref();
  }
}
function attachEvmWsHandlers(chainId: string, ws: { on: (ev: string, cb: (e?: any) => void) => void }): void {
  const st = evmWsChains[chainId];
  st.socket = ws;
  st.lastError = null;
  ws.on('open', () => {
    if (st.socket !== ws) return;
    st.connected = true;
    st.deadSince = 0; // v9.32ap PD3: цепь жива — страж не тикнет
    st.seenTxs.clear();
    evmWsRec(chainId).openedAt = evmWsNow();
    log(`[evmws] ${chainId} connected`);
    evmWsSubscribe(chainId, ws);

    if (st.retryTimer) clearInterval(st.retryTimer);
    st.retryTimer = setInterval(() => {
      if (st.socket === ws && st.connected) {
        pumpDueEvmSubscriptionRetries(st, st.socket);
      }
    }, 1000);
    if (typeof st.retryTimer === 'object' && st.retryTimer && 'unref' in st.retryTimer) {
      (st.retryTimer as NodeJS.Timeout).unref();
    }
  });
  ws.on('message', (data: any) => {
    if (st.socket !== ws) return;
    void handleEvmWsMessage(chainId, String(data));
  });
  ws.on('close', () => {
    // v9.32ap PD3: close-эвент СТАРОГО сокета не должен убивать стейт НОВОГО (пауза-резюм гонка:
    // resume поднял новый сокет, потом пришёл close старого — reset вытирал новый стейт).
    if (st.socket !== ws) return;
    const delay = noteEvmWsDisconnect(chainId, evmWsNow());
    st.deadSince = evmWsNow(); // v9.32ap PD3: страж мёртвой цепи отсюда
    resetEvmWsChainStateOnReconnect(st);
    st.reconnects++;
    if (evmWsTransportImpl || evmWsEnabled(chainId)) scheduleEvmWsReconnect(chainId, delay);
  });
  ws.on('error', (e: any) => {
    if (st.socket !== ws) return;
    const msg = (e?.message || String(e));
    st.lastError = msg;
    const is403 = msg.includes('403');
    if (is403) {
      log(`[evmws] ${chainId} 403 Forbidden (network not enabled in Alchemy App key) — backing off 30m`);
    } else {
      log(`[evmws] ${chainId} error: ${msg}`);
    }
  });
}
function startEvmWs(chainId: string): void {
  if (!evmWsTransportImpl && !evmWsEnabled(chainId)) return;
  const st = evmWsChains[chainId];
  if (!st || st.socket) return;
  const url = evmWsUrl(chainId) || 'wss://test.invalid/evmws';
  if (evmWsTransportImpl) {
    try {
      attachEvmWsHandlers(chainId, evmWsTransportImpl(chainId, url));
    } catch (e: any) {
      st.lastError = (e?.message || String(e));
      log(`[evmws] ${chainId} start error: ${st.lastError}`);
    }
    return;
  }
  if (!url || url.includes('test.invalid')) return;
  import('ws').then(({ default: WS }) => {
    try {
      attachEvmWsHandlers(chainId, new WS(url));
    } catch (e: any) {
      st.lastError = (e?.message || String(e));
      log(`[evmws] ${chainId} start error: ${st.lastError}`);
    }
  });
}
function stopEvmWs(chainId?: string): void {
  const chains = chainId ? [chainId] : EVM_WS_SUPPORTED_CHAINS;
  for (const c of chains) {
    const st = evmWsChains[c];
    if (!st) continue;
    if (st.socket) { try { (st.socket as any).close(); } catch { /* ignore */ } st.socket = null; }
    resetEvmWsChainStateOnReconnect(st);
    if (st.reconnectTimer) { clearTimeout(st.reconnectTimer); st.reconnectTimer = null; }
  }
}
// Resubscribe on watchlist growth (called after discovery adds pools).
function evmWsResubIfConnected(chainId: string): void {
  const st = evmWsChains[chainId];
  if (st && st.socket && st.connected) evmWsSubscribe(chainId, st.socket);
}
// Test hooks
export function __evmWsEnabledForTest(): boolean { return EVM_WS_ENABLED; }
export function __evmWsMaxPoolsForTest(): number { return EVM_WS_MAX_POOLS; }
export async function __handleEvmWsMessageForTest(chainId: string, raw: string): Promise<void> {
  return handleEvmWsMessage(chainId, raw);
}
export function __evmWsSubscribeForTest(chainId: string, ws: any): void {
  evmWsSubscribe(chainId, ws);
}
export function __getEvmWsChainStateForTest(chainId: string): EvmWsChainState | null {
  return evmWsChains[chainId] || null;
}
export function __isClearlySubThresholdSwapForTest(chainId: string, poolAddr: string, topic0: string, data: string): boolean {
  return isClearlySubThresholdSwap(chainId, poolAddr, topic0, data);
}
export function __isClearlySubThresholdV2SwapForTest(chainId: string, poolAddr: string, data: string): boolean {
  return isClearlySubThresholdSwap(chainId, poolAddr, EVM_V2_SWAP_TOPIC, data);
}
export function __evmWsStateForTest(chainId: string) {
  const st = evmWsChains[chainId];
  if (!st) return null;
  return {
    connected: st.connected, frames: st.frames, parsed: st.parsed, dropped: st.dropped,
    subs: st.subscribed.size, lastError: st.lastError, reconnects: st.reconnects,
      settlementSubs: Object.keys(evmWsSettlementSubs(chainId)).length, // v9.32an ND1: живость CoW/intent-сабов
    analyzed: st.analyzed, rateLimited: st.rateLimited, topTopicMismatch: st.topTopicMismatch,
    dedup: st.dedup, noFoundByReason: { ...st.noFoundByReason },
  };
}


// ─────────────── Chain Init (ТЗ v4 Block 4.1: retries + self-heal) ───────────────
const WATCHLIST_RETRY_BACKOFFS = [2000, 5000, 15000, 30000];
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

async function initChainWithTimeout(chainId: string, timeoutMs = 30000, phase: 'base' | 'full' | 'cursor' = 'full'): Promise<void> {
  const st = state.chain[chainId];
  if (!st.enabled) { st.initStatus = 'disabled'; return; }

  // Self-heal: if we already have a populated watchlist, never report a stale failure.
  if (st.watchPools.length > 0) {
    st.initStatus = 'ready';
    st.lastError = null;
  }

  for (let attempt = 0; attempt <= WATCHLIST_RETRY_BACKOFFS.length; attempt++) {
    if (attempt > 0) st.watchlistRetries = (st.watchlistRetries || 0) + 1;

    const timeoutPromise = new Promise<never>((_, reject) => {
      setTimeout(() => reject(new Error('Discovery timeout')), timeoutMs);
    });

    try {
      const pools = await Promise.race([discoverPoolsForChain(chainId, phase), timeoutPromise]) as string[];
      if (pools.length > 0) {
        st.watchPools = pools;
        st.initStatus = 'ready';
        st.lastError = null;
        log(`[init] ${chainId} OK after ${attempt} retries`);
        return;
      }
      if (st.watchPools.length > 0) {
        // Self-heal: pools already exist, keep them despite failed refresh
        st.initStatus = 'ready';
        st.lastError = null;
        return;
      }
    } catch (e: unknown) {
      if (st.watchPools.length > 0) {
        st.initStatus = 'ready';
        st.lastError = null;
        return;
      }
      log(`[init] ${chainId} attempt ${attempt} error: ${(e as Error).message}`);
    }

    // Backoff before retry (2s → 5s → 15s → 30s), then fall through to normal cycle.
    if (attempt < WATCHLIST_RETRY_BACKOFFS.length) {
      await sleep(WATCHLIST_RETRY_BACKOFFS[attempt]);
    }
  }

  if (st.watchPools.length === 0) {
    st.initStatus = 'failed';
    st.lastError = st.lastError || 'No pools discovered';
  }
  log(`[init] ${chainId} giving up retries, status=${st.initStatus} (pools=${st.watchPools.length})`);
}

export function planDiscoveryPhases(chains: readonly string[]): Array<{ phase: 'base' | 'cursor'; chain: string }> {
  const out: Array<{ phase: 'base' | 'cursor'; chain: string }> = [];
  for (const c of chains) out.push({ phase: 'base', chain: c });
  for (const c of chains) out.push({ phase: 'cursor', chain: c });
  return out;
}

const startup = {
  startedAt: 0,
  snapshotRestoredAt: 0,
  snapshot: 'disabled:no-db' as 'enabled' | 'disabled' | 'disabled:no-db' | 'disabled:env-flag',
  snapshotRestoredPools: 0,
  socketsStartedAt: 0,
  pollWorkersStartedAt: 0,
  discoveryStartedAt: 0,
  initDoneAt: {} as Record<string, number>,
  gtBanDuringInit: false,
};
export function __setStartupSnapshotForTest(kind: 'enabled' | 'disabled', pools = 0): void {
  startup.snapshot = kind;
  startup.snapshotRestoredPools = pools;
}
let parserStartPhase: 'idle' | 'workers-started' | 'init-done' = 'idle';
export function __parserStartPhaseForTest(): string { return parserStartPhase; }
type InitAllFn = () => Promise<void>;
let initAllChainsImpl: InitAllFn | null = null;
export function __setInitAllChainsForTest(fn: InitAllFn | null): void { initAllChainsImpl = fn; }

async function initAllChains(): Promise<void> {
  await waitDiscoveryDelay();
  startup.discoveryStartedAt = Date.now();
  log('[init] Starting chain initialization (two-phase: base layer for all chains, then CEX cursor)...');
  let enabledChains = Object.keys(CHAINS).filter(c => settings.networks[c]);
  // Discover solana FIRST so its per-token expansion gets a fresh GeckoTerminal budget
  // (it is the most token-dense chain and needs the most per-token fetches).
  enabledChains = [...enabledChains.filter(c => c === 'solana'), ...enabledChains.filter(c => c !== 'solana')];
  // Sequential so the shared GeckoTerminal rate budget isn't blown by a parallel burst.
  for (const { phase, chain: c } of planDiscoveryPhases(enabledChains)) {
    await initChainWithTimeout(c, DISC_CYCLE_SEC * 1000 + 30000, phase);
    runtime.lastDiscovery[c] = Date.now();
    const st = state.chain[c];
    startup.initDoneAt[c] = Date.now();
    startup.gtBanDuringInit = startup.gtBanDuringInit || (typeof gtBan !== 'undefined' && Date.now() < gtBan.until);
    if (phase === 'base') log(`[init] ${c}: base layer ready (pools=${st.watchPools.length})`);
    else log(`[init] ${c}: status=${st.initStatus}, pools=${st.watchPools.length}`);
  }

  log('[sources] sse start staggered');
  const chainsForSse = enabledChains.filter(c => state.chain[c].watchPools.length > 0);
  for (let i = 0; i < chainsForSse.length; i++) {
    const chainId = chainsForSse[i];
    const delay = i * 2000;
    setTimeout(() => {
      if (state.running && settings.sseEnabled) {
        // v9.13 F1: ALL chains (EVM + Solana) use the DexPaprika reserves push when a key is set.
        tryConnectReservesSSE(chainId).catch(e => {
          log(`[sse] ${chainId}: staggered conn failed: ${(e as Error).message}`);
        });
      }
    }, delay);
  }
}

// v9.13 F1: auto-reconnect — after a reserves stream ends/errors, schedule a retry per-chain with
// exponential backoff (1/2/5/10/30s). Runs only while the parser is running and SSE is enabled.
const sseReconnectTimers: Record<string, ReturnType<typeof setTimeout> | null> = {};
function scheduleSseReconnect(chainId: string): void {
  if (!DP_RESERVES_SSE_ENABLED) return; // v9.24 F1: don't schedule reconnects when reserves SSE is off
  if (sseReconnectTimers[chainId]) return;
  const backoff = Math.max(sseState.backoffMs[chainId] || 1000, 1000);
  sseReconnectTimers[chainId] = setTimeout(() => {
    sseReconnectTimers[chainId] = null;
    if (!state.running || !settings.sseEnabled) return;
    const st = state.chain[chainId];
    if (!st || !st.enabled) return;
    if (st.sseConnected) return; // already reconnected elsewhere
    tryConnectReservesSSE(chainId).catch(() => { /* handled inside */ });
  }, backoff);
}

// ─────────────── Main Loop ───────────────
let globalQueueIdx = 0;

async function tick() {
  if (runtime.ticking) return;
  runtime.ticking = true;
  try {
    await refreshCexCache();

    const enabledChains = Object.keys(CHAINS).filter(c => settings.networks[c]);
    if (enabledChains.length === 0) { runtime.ticking = false; return; }

    for (const chainId of Object.keys(CHAINS)) state.chain[chainId].enabled = !!settings.networks[chainId];

    if (globalPollQueue.length === 0 || Date.now() - lastQueueRebuild > 5 * 60 * 1000) rebuildPollQueue();

    // P1 (v9.5): discovery decoupled from the tick — run it in the BACKGROUND so it never blocks
    // polling. The tick now returns fast and the poll worker pool (see pollWorkerLoop) drains the
    // queue continuously. Cursor advances one batch per discovery cycle; never silently stalls.
    const discIntervalMs = DISC_CYCLE_SEC * 1000;
    for (const chainId of enabledChains) {
      const st = state.chain[chainId];
      const lastDisc = runtime.lastDiscovery[chainId] || 0;
      const overdue = Date.now() - lastDisc;
      if (st.watchPools.length === 0 || overdue > discIntervalMs) {
        // stall detection: only count a stall when NO discovery is already in-flight for the chain
        // (a slow-but-active discovery competing with the poll workers is not a silent stall), and
        // the chain is >2 cycles overdue.
        if (st.watchPools.length > 0 && overdue > discIntervalMs * 2 && !runtime.initPromises.has(chainId)) {
          st.discoveryStalledCycles = (st.discoveryStalledCycles || 0) + 1;
          st.lastDiscoveryError = `discovery stalled ${Math.round(overdue / 1000)}s (>2 cycles)`;
        }
        // background discovery (guarded against concurrent runs per chain) — non-blocking
        if (!runtime.initPromises.has(chainId)) {
          const p = (async () => {
            try {
              await initChainWithTimeout(chainId, discIntervalMs + 30000);
              runtime.lastDiscovery[chainId] = Date.now();
              // a completed discovery means the chain is not silently stalled — reset the counter
              // (it reflects current health, not historical transients from the poll/discovery
              // budget contention during startup).
              st.discoveryStalledCycles = 0;
              rebuildPollQueue();
            } catch (e) {
              addError(`[discovery] ${chainId}: ${(e as Error).message}`);
            } finally {
              runtime.initPromises.delete(chainId);
            }
          })();
          runtime.initPromises.set(chainId, p);
        }
      }
    }

    cleanBudget();
    // Polling is handled entirely by the background poll worker pool (POLL_WORKERS parallel
    // workers, GT-first + DS-freshness fallback). Ensure the queue is populated.
    if (globalPollQueue.length === 0) rebuildPollQueue();

    if (!process.env.NODE_TEST_CONTEXT) {
      for (const chainId of enabledChains) {
        for (const a of (ANCHOR_POOLS[chainId] || [])) {
          if (!a.subject) continue;
          void refreshHotTokenPools(chainId, a.subject);
        }
      }
      void runDiscoveryDsHot();
      // v9.32aj JD1: воронки SLA-дискавери — по тику; НЕ зависят от GT-бана
      if (Date.now() - discoveryFunnelsAt.newPairs >= DISCOVERY_NEW_PAIRS_EVERY_MS()) {
        discoveryFunnelsAt.newPairs = Date.now();
        void runDiscoveryNewPairs().catch(() => { /* best-effort */ });
      }
      if (Date.now() - discoveryFunnelsAt.trend >= DISCOVERY_TREND_POLL_MS()) {
        discoveryFunnelsAt.trend = Date.now();
        void runDiscoveryTrendAdopt().catch(() => { /* best-effort */ });
      }
      // v9.32ar RD1: coverage-audit — рынок vs покрытие (замыкает обратную связь pools:0)
      if (COVERAGE_AUDIT_EVERY_MS() > 0 && Date.now() - coverageAuditAt >= COVERAGE_AUDIT_EVERY_MS()) {
        coverageAuditAt = Date.now();
        void runCoverageAudit().catch(() => { /* best-effort: счётчики внутри */ });
      }
    }

    cleanSeriesBuffer();

    // v9.15 F-AC: periodic credit-tier evaluation (downgrades on depleting credits, auto-restore).
    try { evaluateCreditTier(); } catch { /* never break the tick */ }

    // v9.14 F1: resource observability — log memory + enrichment-gate pressure once per minute so
    // a memory leak / CPU stall is visible in Service Logs before it becomes a crash.
    if (Date.now() - lastResourceLogAt > 60_000) {
      lastResourceLogAt = Date.now();
      try {
        const mem = process.memoryUsage();
        log(`[res] rss=${Math.round(mem.rss / 1024 / 1024)}MB heapUsed=${Math.round(mem.heapUsed / 1024 / 1024)}MB enrichActive=${enrichActive} enrichQueued=${enrichQueuedTotal()} (high=${enrichHighQueued}) enrichDropped=${enrichDroppedTotal()} highDropped=${enrichHighDropped} pollActive=${pollWorkersActive}`);
      } catch { /* ignore */ }
    }
    // v9.14 F1: per-chain SSE summary (rate-limited) so stream health is visible.
    if (Date.now() - lastSseSummaryAt > 300_000) {
      lastSseSummaryAt = Date.now();
      try {
        const conns = Object.entries(dpReservesSse).map(([c, mm]) => `${c}:${mm.connected ? 'ON' : 'off'}(f=${mm.frames})`).join(' ');
        log(`[sse-summary] ${conns || 'none'}`);
      } catch { /* ignore */ }
    }
  } catch (e: unknown) {
    addError('Tick error: ' + (e as Error).message);
  } finally {
    runtime.ticking = false;
  }
}

// ─────────────── Public API ───────────────
export function getSettings(): Settings {
  return cloneSettings(settings); // v9.32as SD1: копия, мутация результата не трогает ядро
}

export function updateSettings(patch: Partial<Settings> | { settings: Partial<Settings> }): Settings {
  let actualPatch: Partial<Settings>;
  if ('settings' in patch && typeof (patch as { settings: unknown }).settings === 'object' && (patch as { settings: unknown }).settings !== null) {
    actualPatch = (patch as { settings: Partial<Settings> }).settings;
    log('[settings] Unwrapped settings from wrapper format');
  } else {
    actualPatch = patch as Partial<Settings>;
  }
  const oldNetworks = { ...settings.networks };
  const next: Settings = { ...settings, ...actualPatch };
  if (actualPatch.networks) next.networks = { ...DEFAULT_SETTINGS.networks, ...actualPatch.networks };
  setSettings(next); // v9.32as SD1: запись в ядро (globalThis), не в модульную копию
  for (const chainId of Object.keys(CHAINS)) {
    const wasEnabled = oldNetworks[chainId];
    const nowEnabled = settings.networks[chainId];
    if (!wasEnabled && nowEnabled && state.running) runtime.lastDiscovery[chainId] = 0;
    state.chain[chainId].enabled = !!nowEnabled;
  }
  // R2: after any successful update we persist to DB — the saved row now defines provenance, so
  // settingsSource must flip to 'persisted' (even if the value happens to equal a default).
  settingsSource = 'memory';
  void saveSettingsToDb(settings).then(ok => {
    if (ok) settingsSource = 'persisted';
    else log('[settings] Failed to persist — settingsSource=memory');
  }).catch(e => { log(`[settings] Failed to persist: ${(e as Error).message}`); settingsSource = 'memory'; });
  log('[settings] Updated: ' + JSON.stringify(actualPatch).slice(0, 200));
  return cloneSettings(next); // v9.32as SD1: копия, не прокси и не ссылка на ядро
}

export function getBlacklist(): string[] {
  return [...blacklist];
}

export function addToBlacklist(addr: string): void {
  if (!blacklist.some(x => lower(x) === lower(addr))) blacklist.push(addr);
}

export function removeFromBlacklist(addr: string): void {
  blacklist = blacklist.filter(x => lower(x) !== lower(addr));
}

export interface AnchorRuntimeValidation {
  status: 'unknown' | 'pending' | 'valid' | 'invalid' | 'unavailable';
  checkedAt?: number;
  error?: string;
  nextValidationAt?: number;
  consecutiveUnavailable?: number;
  lastUnavailableAt?: number;
  unavailableReason?: AnchorUnavailableReason;
}

const ANCHOR_UNAVAILABLE_BACKOFF_MS = [5 * 60 * 1000, 15 * 60 * 1000, 60 * 60 * 1000, 6 * 60 * 60 * 1000] as const;

export function anchorUnavailableBackoffMs(streak: number): number {
  const normalizedStreak = Number.isFinite(streak) ? Math.max(1, Math.floor(streak)) : 1;
  return ANCHOR_UNAVAILABLE_BACKOFF_MS[Math.min(normalizedStreak, ANCHOR_UNAVAILABLE_BACKOFF_MS.length) - 1];
}

export function classifyAnchorUnavailableReason(error?: string): AnchorUnavailableReason {
  if (!error) return 'unknown';
  const message = String(error).toLowerCase();
  const normalized = message.replace(/[_-]+/g, ' ');

  if (/\bmissing\s+rpc\b|\bno\s+rpc\s+(?:configured|available)\b|\brpc(?:\s+url)?\s+(?:is\s+)?missing\b/.test(normalized)) {
    return 'missing_rpc';
  }
  if (/\b(?:timeout|timed\s*out|etimedout|abort(?:ed|error)?|abort\s+(?:ed|error))\b/.test(normalized)) {
    return 'rpc_timeout';
  }
  if (/\bhttp(?:\s+(?:status|error|code))?(?:\s+code)?\s*[:=]?\s*[45]\d{2}\b|\bhttp\s+error\b|\bstatus(?:\s+code)?\s*[:=]?\s*[45]\d{2}\b/.test(normalized)) {
    return 'http_error';
  }
  if (/\b(?:fetch|network|econn[a-z]*|eai\s+again|enotfound|ehostunreach|transport|socket|connection\s+(?:refused|reset|closed))\b/.test(normalized)) {
    return 'transport_error';
  }
  if (/\bjson\s*rpc\b|\brpc\s+error\b|\bprovider\s+error\b|\berror\s+code\s*-?\d{3,}\b/.test(normalized)) {
    return 'rpc_error';
  }
  if (/\b(?:empty|null|no)\b.{0,40}\b(?:result|receipt)\b|\b(?:result|receipt)\b.{0,40}\b(?:empty|null|missing)\b|\bevidence\s+unavailable\b/.test(normalized)) {
    return 'empty_result';
  }
  return 'unknown';
}

const anchorRuntimeValidation = new Map<string, AnchorRuntimeValidation>();

export function getAnchorRuntimeValidation(chainId: string, poolAddr: string): AnchorRuntimeValidation {
  const k = `${chainId.toLowerCase()}:${poolAddr.toLowerCase()}`;
  return anchorRuntimeValidation.get(k) || { status: 'unknown' };
}

export function setAnchorRuntimeValidation(chainId: string, poolAddr: string, status: 'unknown' | 'pending' | 'valid' | 'invalid' | 'unavailable', error?: string): void {
  const k = `${chainId.toLowerCase()}:${poolAddr.toLowerCase()}`;
  const now = Date.now();
  const previous = anchorRuntimeValidation.get(k);

  if (status === 'pending') {
    const pendingState: AnchorRuntimeValidation = {
      status,
      checkedAt: now,
    };
    if (previous?.consecutiveUnavailable) {
      pendingState.consecutiveUnavailable = previous.consecutiveUnavailable;
      pendingState.lastUnavailableAt = previous.lastUnavailableAt;
      pendingState.unavailableReason = previous.unavailableReason;
    }
    anchorRuntimeValidation.set(k, pendingState);
    return;
  }

  let nextValidationAt: number | undefined;
  if (status === 'valid') {
    nextValidationAt = now + 6 * 60 * 60 * 1000; // 6 hours TTL
  } else if (status === 'unavailable') {
    const consecutiveUnavailable = (previous?.consecutiveUnavailable || 0) + 1;
    nextValidationAt = now + anchorUnavailableBackoffMs(consecutiveUnavailable);
    anchorRuntimeValidation.set(k, {
      status,
      checkedAt: now,
      error,
      nextValidationAt,
      consecutiveUnavailable,
      lastUnavailableAt: now,
      unavailableReason: classifyAnchorUnavailableReason(error),
    });
    return;
  } else if (status === 'invalid') {
    nextValidationAt = now + 24 * 60 * 60 * 1000; // 24 hours for manual/long TTL
  }
  anchorRuntimeValidation.set(k, { status, checkedAt: now, error, nextValidationAt });
}

export interface AnchorValidationSummary {
  configured: number;
  validated: number;
  pending: number;
  invalid: number;
  unavailable: number;
  lastError: string | null;
  lastUnavailableAt: number | null;
  lastUnavailableReason: AnchorUnavailableReason | null;
  nextAnchorValidationAt: number | null;
}

export function getAnchorValidationSummary(chainId: string): AnchorValidationSummary {
  const anchors = getAnchorPoolsForChain(chainId);
  let validated = 0;
  let pending = 0;
  let invalid = 0;
  let unavailable = 0;
  let lastError: string | null = null;
  let lastUnavailableAt: number | null = null;
  let lastUnavailableReason: AnchorUnavailableReason | null = null;
  let nextAnchorValidationAt: number | null = null;
  const now = Date.now();

  for (const a of anchors) {
    const st = getAnchorRuntimeValidation(chainId, a.pool);
    if (st.status === 'valid') validated++;
    else if (st.status === 'invalid') {
      invalid++;
      if (st.error) lastError = st.error;
    } else if (st.status === 'unavailable') {
      unavailable++;
      if (st.error) lastError = st.error;
    } else {
      pending++;
    }

    if (st.lastUnavailableAt !== undefined && st.unavailableReason &&
      (lastUnavailableAt === null || st.lastUnavailableAt > lastUnavailableAt)) {
      lastUnavailableAt = st.lastUnavailableAt;
      lastUnavailableReason = st.unavailableReason;
    }
    if (st.nextValidationAt !== undefined && st.nextValidationAt > now &&
      (nextAnchorValidationAt === null || st.nextValidationAt < nextAnchorValidationAt)) {
      nextAnchorValidationAt = st.nextValidationAt;
    }
  }

  return {
    configured: anchors.length,
    validated,
    pending,
    invalid,
    unavailable,
    lastError,
    lastUnavailableAt,
    lastUnavailableReason,
    nextAnchorValidationAt,
  };
}

// Runtime anchor validation worker: validates all configured anchors in background
// and updates anchorRuntimeValidation state as results come in.
let anchorValidationWorkerTimer: ReturnType<typeof setTimeout> | null = null;
let anchorValidationWorkerRunning = false;

export async function runRuntimeAnchorValidation(
  options?: {
    validator?: (anchor: import('./anchor-manifest').TypedAnchorPool) => Promise<import('./anchor-validation').AnchorValidationResult>;
    concurrency?: number;
    signal?: AbortSignal;
  }
): Promise<void> {
  if (anchorValidationWorkerRunning) return;
  anchorValidationWorkerRunning = true;
  anchorValidationTotalCalls++;

  const concurrency = options?.concurrency || 3;
  const signal = options?.signal || runtime.rootAbortController?.signal;
  const allAnchors = VERIFIED_ANCHOR_POOLS;
  const now = Date.now();

  // Filter to only due anchors
  const dueAnchors = allAnchors.filter(anchor => {
    const state = getAnchorRuntimeValidation(anchor.chainId, anchor.pool);
    if (state.status === 'unknown') return true;
    if (state.status === 'pending') return true;
    if (!state.nextValidationAt) return true;
    return state.nextValidationAt <= now;
  });

  // Mark due anchors as pending
  for (const anchor of dueAnchors) {
    setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'pending');
  }

  // If no due anchors, just reschedule and return
  if (dueAnchors.length === 0) {
    anchorValidationWorkerRunning = false;
    scheduleAnchorRevalidation();
    return;
  }

  // Process in bounded concurrency batches
  const queue = [...dueAnchors];
  const active: Promise<void>[] = [];

  const processNext = async (): Promise<void> => {
    while (queue.length > 0) {
      // R11-G5: Check if aborted before processing next anchor
      if (signal?.aborted) {
        break;
      }

      const anchor = queue.shift()!;
      try {
        let result: import('./anchor-validation').AnchorValidationResult;
        if (options?.validator) {
          result = await options.validator(anchor);
        } else {
          result = await validateSingleAnchor(anchor);
        }

        // R11-G5: Check if aborted before mutating state
        if (signal?.aborted) {
          break;
        }

        if (result.ok) {
          setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'valid');
        } else {
          const cls = result.classification || 'invalid';
          if (cls === 'unavailable') {
            setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'unavailable', result.error || 'unavailable');
          } else {
            setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'invalid', result.error || 'validation_failed');
          }
        }
      } catch (err) {
        // R11-G5: Don't mutate state if aborted
        if (signal?.aborted) {
          break;
        }

        const errorMsg = err instanceof Error ? err.message : 'unknown_error';
        const isNetworkError = /timeout|fetch|network|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|Failed to fetch/i.test(errorMsg);
        if (isNetworkError) {
          setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'unavailable', errorMsg);
        } else {
          setAnchorRuntimeValidation(anchor.chainId, anchor.pool, 'invalid', errorMsg);
        }
      }
    }
  };

  for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
    active.push(processNext());
  }

  await Promise.all(active);
  anchorValidationWorkerRunning = false;

  // Schedule revalidation for next due anchor
  scheduleAnchorRevalidation();
}

function scheduleAnchorRevalidation(): void {
  if (anchorValidationWorkerTimer) {
    clearTimeout(anchorValidationWorkerTimer);
    anchorValidationWorkerTimer = null;
  }

  let nearest: number | null = null;
  for (const entry of anchorRuntimeValidation.values()) {
    if (entry.nextValidationAt) {
      if (nearest === null || entry.nextValidationAt < nearest) {
        nearest = entry.nextValidationAt;
      }
    }
  }

  if (nearest === null) return;

  const delay = Math.max(0, nearest - Date.now());
  anchorValidationWorkerTimer = setTimeout(() => {
    anchorValidationWorkerTimer = null;
    void runRuntimeAnchorValidation({ signal: runtime.rootAbortController?.signal }).catch(e => {
      if (e.name !== 'AbortError') {
        log(`[anchor-validation] scheduled worker error: ${(e as Error).message}`);
      }
    });
  }, delay);
  if (typeof anchorValidationWorkerTimer === 'object' && 'unref' in anchorValidationWorkerTimer) {
    (anchorValidationWorkerTimer as NodeJS.Timeout).unref();
  }
}

export function stopAnchorValidationScheduler(): void {
  if (anchorValidationWorkerTimer) {
    clearTimeout(anchorValidationWorkerTimer);
    anchorValidationWorkerTimer = null;
  }
  anchorValidationWorkerRunning = false;
}

export function __resetAnchorValidationForTest(): void {
  anchorRuntimeValidation.clear();
  stopAnchorValidationScheduler();
  anchorValidationTotalCalls = 0;
}

export function __isAnchorValidationWorkerRunningForTest(): boolean {
  return anchorValidationWorkerRunning;
}

let anchorValidationTotalCalls = 0;

export function __getAnchorValidationStateForTest(): { totalCalls: number } {
  return { totalCalls: anchorValidationTotalCalls };
}

// v9.32bh BH4 / bh1 / v9.32bj BJ6: фактический охват разрешённых резервов — ПЕРЕСЕЧЕНИЕ
// solReserveResolved с текущим вотчлистом (размер карты сам по себе охватом не является:
// прод bh показывал 457 записей против 203 watchPools → 170%).
function solReserveCoverageSnapshot() {
  const watch = state.chain['solana']?.watchPools || [];
  let resolved = 0;
  for (const p of watch) if (solReserveResolved.has(p)) resolved++;
  const now = Date.now();
  const subsPerMin = reserveEventRate(solReserveSubEvents, now);
  const failedPerMin = reserveEventRate(solReserveFailEvents, now);
  const confirmedPools = new Set([...solReserveSubIdToPool.values()].map(v => v.pool)).size;
  const resolvedCached = watch.filter(p => solReserveState.get(p)?.cacheRestored).length;
  const resolvedLive = resolved - resolvedCached;
  return {
    resolvedPools: resolved,
    watchPools: watch.length,
    subsPerMin, failedPerMin,
    etaFullMin: subsPerMin > 0 ? +((Math.max(0, watch.length - confirmedPools)) / subsPerMin).toFixed(1) : null,
    resolvedCached, resolvedLive,
    mentionSubs: { confirmed: countSolMentionConfirmed(), pending: solLogsReqToMint.size,
      timeoutShare: solPubsubMetrics.mentionsConfirmTimeout / Math.max(1, solPubsubMetrics.mentionsConfirmTimeout + countSolMentionConfirmed()) },
    pct: watch.length > 0 ? Math.round(resolved / watch.length * 100) : 0,
    resolvedTotal: solReserveResolved.size, // включая пулы вне текущего вотчлиста
  };
}
export function getStatus() {
  // v9.32ao-RC1 BP1: этот бандль не запускал движок, а ядро хранит свежий (≤60с) снапшот
  // работающего инстанса — отдаём его: дашборд/роуты не врут нулями при autostart-буте.
  if (!localEngineStarted && engineKernel.statusSnapshot && Date.now() - engineKernel.statusFreshAt < 60_000) {
    // пауза/running — всегда ЖИВЫЕ из ядра (кнопка дашборда не должна видеть 10-с устаревание)
    const snap = { ...engineKernel.statusSnapshot } as Record<string, unknown>;
    snap.running = state.running;
    snap.paused = state.paused;
    snap.pausedAt = state.pausedAt;
    snap.pausedDropped = pausedStats.dropped;
    snap.pausedHotPollSkipped = pausedStats.hotPollSkipped;
    snap.pause = pauseStatusDoc(Date.now()); // W2: интервалы паузы — живые из ядра
    snap.stoppedManually = stoppedManuallyRef.get();
    // ТЗ 2026-10-13 D1: вложенный shadowScan.oracleSourceHealth.readiness в снимке мог быть посчитан
    // ДО паузы (снапшот обновляется раз в 10 с). Топ-левел paused уже живой, а readiness.paused=false
    // с вердиктом not_ready_* врал, что окно судимо. Пересчитываем ТОЛЬКО readiness из живого
    // pause-контекста и полей самого снимка; source counters/coverage снимка не трогаем (они раннера,
    // а не этого бандла). Клонируем вложенные объекты, чтобы не мутировать снимок раннера.
    {
      const ss = snap.shadowScan as Record<string, any> | undefined;
      const health = ss?.oracleSourceHealth as Record<string, any> | undefined;
      const prev = health?.readiness as Record<string, any> | undefined;
      if (ss && health && prev) {
        const now = Date.now();
        const windowMs = Number(health.window?.ms) > 0 ? Number(health.window.ms) : 1_800_000;
        const ctx = pauseReadinessContext(now - windowMs, now, null);
        if (ctx.paused || ctx.pauseOverlapMs > 0 || ctx.previousProcessUnknown) {
          const readiness = evaluateOracleReadiness({
            oracleHealthy: Boolean(prev.oracleHealthy ?? ss.oracleHealthy),
            sourceLevel: health.level,
            sourceState: health.state,
            sourceMode: health.sourceMode ?? prev.sourceMode ?? 'none',
            sampleCompleteness: prev.sampleCompleteness ?? 'not_measured',
            sampleCompletenessRatio: null, // в pause-ветке вердикт от ratio не зависит
            windowComplete: health.window?.complete ?? true,
            paused: ctx.paused,
            pauseOverlapMs: ctx.pauseOverlapMs,
            previousProcessUnknown: ctx.previousProcessUnknown,
            missed: 0,
          });
          snap.shadowScan = { ...ss, oracleSourceHealth: { ...health, readiness } };
        }
      }
    }
    return snap as any;
  }
  ensureAutostartWatchdog();
  const dpElapsed = Date.now() - runtime.dpRestStarted;
  const dpDays = dpElapsed / (1000 * 60 * 60 * 24);
  const dpMonthEstimate = dpDays > 0.01 ? Math.round((runtime.dpRestCount / dpDays) * 30) : 0;
  // b12 R4/R5: считаем один раз, чтобы число и его источник в одном снапшоте не разъехались.
  // B4.1: dexPaprika.credits собирается из ОДНОГО снапшота контроллера — поля не могут взяться из
  // разных чтений (runway, счётчики и сигналы сняты одновременно). Округление остаётся здесь.
  const creditSnap = creditController().snapshot();
  // B4.2: geckoDiscovery собирается из ОДНОГО снапшота канала — один snapshot на сериализацию.
  const anonymousGtSnap = anonymousGtChannel().snapshot();
  const fallbackSnapshot = solFallbackDegradationSnapshot();
  const restoreSnapshot = solRestoreSnapshot();

  return {
    watchlistRefresh: { ...watchlistRefresh },
    netEnrich: { ...netEnrich },
    discoveryDsHot: { ...discoveryDsHot, skippedReasons: { ...discoveryDsHot.skippedReasons } },
    // v9.32aj JD1: воронки SLA-дискавери + метрика времени до покрытия
    discoveryNewPairs: __discoveryNewPairsForTest(),
    discoveryAdopt: __discoveryAdoptForTest(),
    discoverySla: discoverySlaSnapshot(),
    running: state.running,
    // v9.32am AM3: пауза видна статусом + честный счётчик замороженных событий.
    paused: state.paused,
    pausedAt: state.pausedAt,
    pausedForMs: state.paused ? Date.now() - state.pausedAt : 0,
    pausedDropped: pausedStats.dropped,
    pausedHotPollSkipped: pausedStats.hotPollSkipped,
    pause: pauseStatusDoc(Date.now()), // W2: интервалы паузы; счётчики здесь — единицы event/poll_cycle, scope process_boot
    stoppedManually: stoppedManuallyRef.get(), // v9.32ao-RC1 BP1: из ядра
    networksHealth: (() => {
      const evm = (cid: string) => {
        const st = evmWsChains[cid];
        const err = String(st?.lastError || '');
        let evmWs: 'ok' | 'disabled:403' | 'down' | 'reconnecting' = 'down';
        if (err.includes('403')) evmWs = 'disabled:403';
        else if (st?.connected) evmWs = 'ok';
        // v9.32ap PD3: цепь включена и не в паузе, но не подключена — она «переподключается»
        // (lift при resume / страж), а не «мертва навсегда».
        else if (state.running && !state.paused && (st?.reconnectTimer || st?.socket || (st?.deadSince ?? 0) > 0)) evmWs = 'reconnecting';
        return { evmWs, lastError: st?.lastError || null, since: st?.lastFrameAt || 0, deadSince: st?.deadSince || 0 };
      };
      const pub = solanaPublicRpc();
      const h = solRpcEp(pub);
      return {
        ethereum: evm('ethereum'),
        bsc: evm('bsc'),
        base: evm('base'),
        arbitrum: evm('arbitrum'),
        polygon: evm('polygon'),
        paroledPools: evmParoledPools(),
        solana: {
          publicRpc: { endpoint: pub, ok: h.deadUntil <= Date.now(), fails1m: h.fails1m },
          helius: helius429State(),
        },
      };
    })(),
    startedAt: state.startedAt,
    startup: { ...startup, initDoneAt: { ...startup.initDoneAt } },
    uptime: state.startedAt ? Math.floor((Date.now() - state.startedAt) / 1000) : 0, // ТЗ v5: seconds
    notifsTotal: state.notifs.length,
    notifsDedupDropped,
    // v9.10 F1: monitoring of alert-worthiness gate rejections (shouldNotify denials).
    skippedReasons: { ...skippedReasonsStats, total: Object.values(skippedReasonsStats).reduce((s, n) => s + n, 0) },
    // v9.11 F2: notifications skipped before send (phantom tx etc.).
    notifSkipped: { ...notifSkipped },
    chains: Object.fromEntries(Object.entries(state.chain).map(([id, st]) => [id, {
      ...st,
      watchPools: st.watchPools.length,
      lastEventAgeMs: st.lastEventAt > 0 ? Math.max(0, Date.now() - st.lastEventAt) : -1, // v9.33bm1k Task 4: −1 = событий не было
      // ТЗ v6: single source of truth + discovery metrics
      watchPoolsDiscovery: (st.watchPoolsDiscovery || []).length,
      // R7: report the ACTUAL number of anchor pools present in the visible watchlist (matches the
      // /api/debug/watchlist count), not the raw configured-anchor count which can drift.
      watchPoolsAnchors: countAnchorPools(id),
      watchPoolsTotal: visibleWatchPools(id).length, // == /api/debug/watchlist count (zero rows hidden)
      discoveryTokensTried: st.discoveryTokensTried || 0,
      discoveryTokensWithPools: st.discoveryTokensWithPools || 0,
      discovery429s: st.discovery429s || 0,
      // ТЗ v7 metrics
      discoveryCandidates: st.discoveryCandidates || 0,
      discoveryCursorPos: st.discoveryCursorPos || 0,
      discoveryDsFallbacks: st.discoveryDsFallbacks || 0,
      poolPollEmptySubjects: st.poolPollEmptySubjects || 0,
      priorityTokensQueued: st.priorityTokensQueued || 0,
      // Д1 SLA metrics
      coverageSlaSec95: computeCoverageSlaSec95(id),
      priorityMisses: computePriorityMisses(id),
      // F6 per-cycle provider counters
      discoveryGtAttempted: st.discoveryGtAttempted || 0,
      discoveryGtOk: st.discoveryGtOk || 0,
      discoveryGt429: st.discoveryGt429 || 0,
      discoveryGtBudgetSkip: st.discoveryGtBudgetSkip || 0,
      discoveryDsAttempted: st.discoveryDsAttempted || 0,
      discoveryDsOk: st.discoveryDsOk || 0,
      discoveryDsEmpty: st.discoveryDsEmpty || 0,
      discoveryDsEmptyFresh: st.discoveryDsEmptyFresh || 0,
      discoveryDsEmptyRescan: st.discoveryDsEmptyRescan || 0,
      discoveryCandidatesFromDs: st.discoveryCandidatesFromDs || 0,
      discoveryQuarantined: discoveryQuarantinedCount(id),
      discoveryPoolsAdded24h: discoveryPoolsAdded24h(id),
      discoveryEmptyRatio: (st.discoveryDsAttempted || 0) > 0 ? (st.discoveryDsEmpty || 0) / (st.discoveryDsAttempted || 1) : 0,
      discoveryCycles: st.discoveryCycles || 0,
      discoveryStalledCycles: st.discoveryStalledCycles || 0,
      lastDiscoveryAt: st.lastDiscoveryAt || 0,
      lastDiscoveryError: st.lastDiscoveryError ?? null,
      watchStablePools: st.watchStablePools || 0,
      pollTicks1m: (st.pollTicks1m || []).filter(t => t > Date.now() - 60000).length,
    }])),
    cexCache: { size: cexCache.tokenCount, tokensWithChains: Object.keys(cexCache.byAddress).length, updatedAt: cexCache.updatedAt, chainsWithAddresses: cexCache.chainsWithAddresses },
    cexFilter: {
      cacheSize: cexCache.tokenCount, tokensWithChains: Object.keys(cexCache.byAddress).length,
      matchHits: cexFilterStats.matchHits, exempted: cexExempted, minUsd: CEX_FILTER_MIN_USD(),
      matchMisses: cexFilterStats.matchMisses, passedEvents: cexFilterStats.passedEvents, skippedCex: cexFilterStats.skippedCex,
      symbolFallbackHits: cexFilterStats.symbolFallbackHits, wrappedNativeHits: cexFilterStats.wrappedNativeHits, lastHits: cexFilterStats.lastHits,
      sources: { ...cexSourceStats },
    },
    usdUnresolved,
    enrichQueueDepth: enrichQueuedTotal(),
    enrichWaitP50Ms: percentileMs(enrichWaitMs, 0.5),
    enrichWaitP95Ms: percentileMs(enrichWaitMs, 0.95),
    staleByAgeBucket: { ...staleByAgeBucket },
    priceFromLastKnown,
    // v9.32ay AY3: честная телеметрия резолва; dsPriceFallback — алиас на один релиз (RD4).
    priceResolve: { ok: priceResolve.ok, fail: priceResolve.fail, bySource: { ...priceResolve.bySource }, notCached: priceResolve.notCached },
    dsPriceFallback: { ok: priceResolve.bySource.ds, fail: priceResolve.fail }, // ok: старый смысл = успехи DS (непрерывность ряда)
    settingsSource,
    telegram: { configured: !!(settings.telegramToken && settings.telegramChatId), ...state.tgStatus, queued: tgRetryQueue.length, tgRetriedOk: tgRetryStats.retriedOk, tgFailed: tgRetryStats.failed },
    restBudget: { dpRestTotal: runtime.dpRestCount, dexpaprikaMonthEstimate: dpMonthEstimate, geckoPerMinUsed: requestBudget.gecko.filter(t => t > Date.now() - 60000).length, geckoLimit: GECKO_RATE_LIMIT, geckoPollLimit: GECKO_POLL_LIMIT, geckoEnrichReserve: GECKO_ENRICH_RESERVE, geckoDiscoveryReserve: GECKO_DISCOVERY_RESERVE, geckoDiscoveryLimit: GECKO_DISCOVERY_LIMIT, geckoBaseLayerLimit: GECKO_BASELAYER_LIMIT, dsPerMinUsed: requestBudget.dexscreener.filter(t => t > Date.now() - 60000).length, dsPerChainPerMin: DS_PER_CHAIN_PER_MIN, dpPerMinUsed: requestBudget.dexpaprika.filter(t => t > Date.now() - 60000).length },
    // bm2r D9: DS-давление и дисциплина 429 видны глазом. Счётчики — из dsGateway;
    // ключи и URL внешних API сюда НЕ попадают (тест R6).
    dsGateway: {
      perMinUsed: requestBudget.dexscreener.filter(t => t > Date.now() - 60000).length,
      perMinLimit: DS_POOL_RATE_MIN,
      priorityReserve: DS_PRIORITY_RESERVE(),
      backoff: {
        active: dsBackoffActive(),
        until: dsGateway.until,
        level: dsGateway.level,
        last429At: dsGateway.last429At,
        lastReason: dsGateway.lastReason,
        retryAfterSec: dsGateway.retryAfterMs > 0 ? Math.round(dsGateway.retryAfterMs / 1000) : Math.max(0, Math.round((dsGateway.until - dsNow()) / 1000)),
      },
      levelChanges: dsGateway.levelChanges,
      probeAttempts: dsGateway.probeAttempts,
      probeOk: dsGateway.probeOk,
      byEndpointClass: dsGateway.byEndpointClass,
      suppressed: { backoff: dsGateway.suppressedBackoff, budget: dsGateway.suppressedBudget },
      bySource: dsGateway.bySource,
    },
    // v9.7 I1: DexPaprika REST fallback-source observability.
    dexPaprika: {
      enabled: settings.dexPaprika?.enabled ?? false,
      apiEnabled: settings.dexPaprika?.apiEnabled ?? true,
      // bm2r A10: baseUrl — URL внешнего API, а URLs в /api/status не публикуются.
      // Внутреннее поле settings.dexPaprika.baseUrl для сборки запросов не тронуто.
      calls: dpCalls1m.filter(t => t > Date.now() - 60000).length,
      received: dpReceived1m.filter(t => t > Date.now() - 60000).length,
      perMinUsed: requestBudget.dexpaprika.filter(t => t > Date.now() - 60000).length,
      perMinLimit: getDexPaprikaApiKey() ? DEXPAPRIKA_REST_PER_MIN_KEYED : DEXPAPRIKA_REST_PER_MIN,
      keyed: !!getDexPaprikaApiKey(),
      gates: { symbol: DP_SYMBOL_ENABLED, price: DP_PRICE_ENABLED, cross: PRICE_SANITY_DP_CROSS_ENABLED, oracleUsesKey: ORACLE_DP_USE_KEY },
      fallbackMarked: Date.now() < dpFallbackUntil,
      fallbackUntil: dpFallbackUntil,
      gtQuotaSkipRecent: (Date.now() - lastGtQuotaSkipAt) < 60000,
      totalCalls: runtime.dpRestCount,
      dedupSize: dpSeenTxs.size,
      accepted: dpAccepted,
      pollCalls: dpPollStats.pollCalls,
      poll429: dpPollStats.poll429,
      pollEmpty: dpPollStats.pollEmpty,
      pollAccepted: dpPollStats.pollAccepted,
      staleFiltered: dpPollStats.staleFiltered,
      pollRestActive: gtBanState().banned,
      pollRotationSize: dpPollRotationSize,
      // v9.15 F-AC: adaptive credit management metrics (see AC #6).
      // B4.1: значения приходят из ОДНОГО creditSnap (см. начало getStatus()); facade отвечает только
      // за публикацию: округление и null-маппинг остались ровно теми же, что и до извлечения.
      credits: {
        tier: creditSnap.tier,
        tierCoveragePercent: creditSnap.tierCoveragePercent,
        usedDailyToday: creditSnap.usedDailyToday,
        leftDailyToday: creditSnap.leftDailyToday,
        monthlyCreditCap: creditSnap.monthlyCreditCap,
        // v9.17 F1: used/left month derive from the authoritative header when fresh (matches dashboard).
        usedMonth: creditSnap.usedMonth,
        leftMonth: creditSnap.leftMonth,
        hasAuthoritativeRemaining: creditSnap.hasAuthoritativeRemaining,
        // b12 R2/R4: remainingFractionPct — то же имя (его читает дашборд), но теперь это худшая из
        // двух долей: суточной (остаток суток/дневной бюджет) и месячной (остаток периода/кап). Обе
        // части публикуются рядом, чтобы «приклеенные 100 %» нельзя было принять за здоровье.
        remainingFractionPct: Math.round(creditSnap.remainingFraction * 100),
        dailyRemainingPct: Math.round(creditSnap.dailyRemainingFraction * 100),
        monthRemainingPct: Math.round(creditSnap.monthRemainingFraction * 100),
        // b12 R3/R4: устойчивый темп месячного капа (cap/30) — база, относительно которой forecastTier
        // решает mid/low. Видим в статусе, иначе «почему тариф снизился» опять читается только в коде.
        sustainableDailyCredits: Math.round(creditSnap.sustainableDailyCredits),
        // b12 R5: null на неполном дне + источник — вместо скачущих 1431/715/3991 между деплоями.
        estimatedRunwayDays: creditSnap.runwayDays === null ? null : Math.round(creditSnap.runwayDays * 10) / 10,
        runwaySource: creditSnap.runwaySource,
        // v9.16 F-CE: smart-forecast observability.
        recentBurnRatePerMin: Math.round(creditSnap.recentBurnRatePerMin * 10) / 10,
        // bm2e: кто именно потратил учтённые кредиты (полный словарь, deep copy).
        usedDailyBySource: creditSnap.usedDailyBySource,
        // bm2e: расход, уходящий провайдеру мимо getJSON (оракул, стартовая проверка ключа).
        // В usedDailyToday НЕ входит — иначе сдвинулись бы пороги freeze/fallback.
        uncountedDailyBySource: creditSnap.uncountedDailyBySource,
        forecastTier: creditSnap.forecastTier,
        evalIntervalSec: creditSnap.evalIntervalSec,
        evmTopN: creditSnap.evmTopN,
        solanaTopN: creditSnap.solanaTopN,
        lastDowngradeAt: creditSnap.lastDowngradeAt || null,
        lastUpgradeAt: creditSnap.lastUpgradeAt || null,
        lastEvalAt: creditSnap.lastEvalAt || null,
        last402At: creditSnap.last402At || null,
        last429At: creditSnap.last429At || null,
        lastRestLimitHeader: creditSnap.lastRestLimitHeader >= 0 ? creditSnap.lastRestLimitHeader : null,
        lastRestLimitHeaderAt: creditSnap.lastRestLimitHeaderAt || null,
        // bm4a: чем подтверждён учёт. Пока these поля null/'default', авторитетного остатка НЕТ, и
        // usedMonth/leftMonth — локальная оценка, а не цифры провайдера.
        creditsLimit: creditSnap.lastCreditsLimit >= 0 ? creditSnap.lastCreditsLimit : null,
        creditLimitSource: creditSnap.monthlyCreditCapSource,
        authoritativeSource: creditSnap.lastRemainingFrom,
        rateLimitRemaining: creditSnap.lastRateLimitRemaining >= 0 ? creditSnap.lastRateLimitRemaining : null,
        apiPlan: creditSnap.lastApiPlan,
        creditsResetAt: creditSnap.lastRestLimitResetAt || null,
        fallbackAtDailyLimitPercent: creditSnap.fallbackAtDailyLimitPercent,
        freezeAtDailyLimitPercent: creditSnap.freezeAtDailyLimitPercent,
        restoreTier: creditSnap.restoreTier,
      },
    },
    pollQueue: {
      length: globalPollQueue.length,
      currentIdx: globalQueueIdx,
      lastRebuild: lastQueueRebuild,
      // P1 (v9.5): observability metrics for the parallel poll worker pool
      pollWorkers: POLL_WORKERS,
      pollWorkersActive,
      tasksCompletedPerMin: pollTasksCompletedAt.filter(t => t > Date.now() - 60000).length,
      topPoolsPerChain: POLL_TOP_POOLS_PER_CHAIN(),
      // v9.32k: full-pass estimate vs the 180s freshness gate — THE number that exposed this incident.
      estFullPassSec: (() => {
        const rate = pollTasksCompletedAt.filter(t => t > Date.now() - 60000).length;
        return rate > 0 ? Math.round(globalPollQueue.length / rate * 60) : null;
      })(),
      anchorPools: (() => {
        let n = 0;
        for (const item of globalPollQueue) {
          const vis = visibleWatchPools(item.chain);
          if (!vis.length) continue;
          const addr = vis[item.poolIdx % vis.length];
          if ((state.chain[item.chain]?.watchPoolsAnchors || []).some(a => lower(a) === lower(addr))) n++;
        }
        return n;
      })(),
      anchorGtPolled1m: (() => {
        const cutoff = Date.now() - 60000;
        let n = 0;
        for (const [k, at] of lastGtPollAt) {
          if (at < cutoff) continue;
          const chain = k.split(':')[0];
          const addr = k.slice(chain.length + 1);
          if ((state.chain[chain]?.watchPoolsAnchors || []).some(a => lower(a) === addr)) n++;
        }
        return n;
      })(),
      anchorGtCadenceMsP95: (() => {
        const ages: number[] = [];
        const now = Date.now();
        for (const c of Object.keys(CHAINS)) {
          for (const a of (state.chain[c]?.watchPoolsAnchors || [])) {
            const at = lastGtPollAt.get(gtPollKey(c, a));
            if (at) ages.push(now - at);
          }
        }
        if (!ages.length) return null;
        ages.sort((x, y) => x - y);
        return ages[Math.min(ages.length - 1, Math.floor(ages.length * 0.95))];
      })(),
    },
    notifRate: { sent1m: notifRate.globalSent1m.filter(t => t > Date.now() - 60000).length, queued: notifRate.digestQueue.length, droppedByRate: notifRate.droppedByRate, maxGlobal: settings.maxNotifPerMinGlobal, maxPerChain: settings.maxNotifPerMinPerChain },
    rateDigest: { pending: pendingSolo.length, digestSent: digestSentTotal, lastDigestAt: notifRate.lastDigestAt || null },
    // v9.32as Task5: counters SD1–SD5 + settings instance (singleton check, BP1 v9.32ao-RC1)
    v932as: {
      aggregatorAttributed: notifSkipped.aggregatorAttributed,
      aggregatorStillNoLegs: notifSkipped.aggregatorStillNoLegs,
      aggregatorSkippedBelowMin: notifSkipped.aggregatorSkippedBelowMin,
      blacklistSubjectSwapped: notifSkipped.blacklistSubjectSwapped,
      poolQuoteLegsValuations: solanaValuationSourceCounts.pool_quote_legs,
      settingsInstance: engineKernel.settingsInstanceId,
    },
    // v9.32au AU1/AU3: opaque aggregator rejects + CEX-gate drop reasons (observability).
    v932au: {
      aggregatorRejects: { ...notifSkipped.aggregatorRejects },
      aggregatorRejectsByRouter: Object.fromEntries([...aggregatorRejectsByRouter.entries()].map(([k, v]) => [k, { ...v }])), // v9.32bm1g H7
      cexRejects: Object.fromEntries(Object.entries(cexRejects).map(([c, r]) => [c, { ...r }])),
      aggregatorIsContractRetry: AGGREGATOR_ISCONTRACT_RETRY(),
      cexGateSampleEvery: CEX_GATE_SAMPLE_EVERY(),
    },
    // v9.32aq B6: бюджет Helius глазами владельца (день ≤ cap → темп 1M/мес с запасом)
    heliusSpend: __heliusSpendForTest(),
    // TZ-b2 R4: «сколько сегодня потрачено денег на Alchemy и какой кап» — новый объект рядом с
    // heliusSpend (ФОРМЫ cuGuard/alchemyCu*/heliusSpend не меняются). Без секретов: числа, dayKey, fp 4+4.
    alchemySpend: __alchemySpendForTest(),
    // v9.32ar RD1–RD3: качество покрытия — breaches видимы, breachRate/slaViolations с честным знаменателем
    coverageAudit: coverageAuditStatus(),
    sseState: { activeConnections: Object.values(state.chain).filter(c => c.sseConnected).length, maxConcurrent: sseState.maxConcurrentSse, backoffs: Object.fromEntries(Object.entries(sseState.backoffMs).filter(([, v]) => v > 0).map(([k, v]) => [k, Math.round(v / 1000)])) },
    // v9.8 F2: DexPaprika SSE reserves push per-chain metrics (I2 for Solana).
    sse: { dexpaprika: Object.fromEntries(Object.entries(dpReservesSse).map(([c, m]) => [c, { ...m }])) },
    errors: state.errors,
    lastRealTrade: state.lastRealTrade,
    lastNoSubjectSamples: getLastNoSubjectSamples(),
    lastAggregatorRejects: lastAggregatorRejects.slice(),
    noSubjectByChain: Object.fromEntries(analyzeTelemetry.noSubjectEntries().map(([k, v]) => [k, v])),
    traderResolve: traderResolveStats,
    priceFetch: {
      dpCrossEnabled: PRICE_SANITY_DP_CROSS_ENABLED,
      hits: priceFetchStats.hits,
      misses: priceFetchStats.misses,
      negHits: priceFetchStats.negHits,
      rateSkipped: { ...priceFetchStats.rateSkipped },
      gateBackoff: { ...priceFetchStats.gateBackoff },
      gateOpenIn: { ds: priceGateOpenInMs('ds'), gt: priceGateOpenInMs('gt'), dp: priceGateOpenInMs('dp') },
      cacheTtlMs: Number(process.env.PRICE_CACHE_TTL_MS || '60000'),
      negTtlMs: Number(process.env.PRICE_NEG_CACHE_TTL_MS || '30000'),
      perMinPerSource: Number(process.env.PRICE_FETCH_PER_MIN_PER_SOURCE || '20'),
      dpSanityRejected: priceFetchStats.dpSanityRejected,
      priceCorrectedByContract: priceFetchStats.priceCorrectedByContract,
      priceDroppedNoContract: priceFetchStats.priceDroppedNoContract,
      // v9.32bb BB5: alias = A+B (не трактовать как потери — 96% ветка A это фикс)
      symbolFallbackBlocked: priceFetchStats.priceCorrectedByContract + priceFetchStats.priceDroppedNoContract,
    },
    geckoDiscovery: {
      perMin: GECKO_DISC_PER_MIN(),
      basePerMin: GECKO_DISC_BASE_PER_MIN(),
      localSkipped: geckoDiscStats.localSkipped,
      baseLocalSkipped: geckoDiscStats.baseLocalSkipped,
      baseLayerCacheHits: geckoDiscStats.baseLayerCacheHits,
      http429ByPath: anonymousGtSnap.http429ByPath,
      gtPoll429,
      gtPoll429Cooldown,
      banBackoffMs: gtBanState().backoffMs,
      banAttempts: gtBan.attempts,
      bannedNow: gtBanState().banned,
      banSkipped: gtBan.banSkipped,
      gtDead: anonymousGtSnap.dead,
      gtDeadStrikes: anonymousGtSnap.strikes, gtDeadReason: anonymousGtSnap.deadReason, gtRequestsPerCycle,
      gtSkippedDead: anonymousGtSnap.skipped,
      // ТЗ b2 R6: без этого среза следующий раунд диагностики снова гадает, ПОЧЕМУ gtSkippedDead
      // растёт и чем канал был снят. Только счётчики/флаги — ни URL, ни ключей (R5).
      gtDeadChannel: {
        dead: anonymousGtSnap.dead, deadReason: anonymousGtSnap.deadReason,
        deadUntil: anonymousGtSnap.deadUntil, deadInMs: anonymousGtSnap.deadInMs,
        cooldownMs: anonymousGtSnap.cooldownMs, strikes: anonymousGtSnap.strikes,
        strikeWindowMs: anonymousGtSnap.strikeWindowMs, strikeLimit: anonymousGtSnap.strikeLimit,
        probeInFlight: anonymousGtSnap.probeInFlight, probes: anonymousGtSnap.probes,
        recoveredBy: anonymousGtSnap.recoveredBy, recoveries: anonymousGtSnap.recoveries,
        skipped: anonymousGtSnap.skipped, lastProbePath: anonymousGtSnap.lastProbePath,
        lastRecoveryPath: anonymousGtSnap.lastRecoveryPath,
      },
      anonymousGt: {
        paths: anonymousGtSnap.byPath,
        lastProbePath: anonymousGtSnap.lastProbePath,
        lastRecoveryPath: anonymousGtSnap.lastRecoveryPath,
      },
      warmupSkipped,
    },
    series: { pending: seriesStats.pending, fired: seriesStats.fired, suppressedByCooldown: seriesStats.suppressedByCooldown, netSuppressed: seriesNetSuppressed, noDataCandidates: seriesStats.noDataCandidates, expiredWithoutFire: seriesStats.expiredWithoutFire }, // bm2x S3
    solo: { instant: soloStats.instant },
    alertSettings: { soloAmountUsd: settings.soloAmountUsd, seriesTxMinUsd: settings.seriesTxMinUsd, seriesMinTx: settings.seriesMinTx, seriesWindowSec: settings.seriesWindowSec, seriesCooldownMin: settings.seriesCooldownMin },
    freshness: {
      maxAlertAgeSec: settings.maxEventAgeSec,
      droppedStaleTotal,
      droppedStaleBySource: { ...droppedStaleBySource }, // v9.32bm1g H4: ws + poll == droppedStaleTotal
      droppedNoBlockTs,
      // v9.13 F3: cold-start catch-up stale counter.
      startupCatchUpStale,
      startupSeededByChain: Object.fromEntries(Object.entries(state.chain).map(([id, st]) => [id, { seeded: st.startupSeeded, done: st._seedDone }])),
    },
    contractExecFilter: {
      mode: CONTRACT_EXEC_FILTER,
      inventoryThresholdUsd: CONTRACT_INVENTORY_USD,
      contractExec: contractExecSkipped,
      safeAllowed,
      contractTransient,
      contractExecUnknown,
    },
    blacklist: {
      rules: blacklistRules.map(r => ({ ...r })),
      skipped: { ...blacklistSkipped },
      totalSkipped: Object.values(blacklistSkipped).reduce((s, n) => s + n, 0),
      counterLeg: blacklistCounterLeg,
    },
    solTxCache: { hits: solTxCacheStats.hits, misses: solTxCacheStats.misses, size: solTxCache.size, ttlMs: SOL_TX_CACHE_TTL_MS() },
    heliusRetryFails,
    // ТЗ v5 Defect 2: watchlist caps surfaced for verification.
    watchlist: {
      perToken: WATCH_POOLS_PER_TOKEN,
      perChain: WATCH_CHAIN_CAP === null ? null : WATCH_CHAIN_CAP,
      perChainLabel: WATCH_CHAIN_CAP === null ? 'unlimited (no chain-level cut)' : String(WATCH_CHAIN_CAP),
      minReserveUsd: POOL_MIN_RESERVE_USD,
      min24hVolumeUsd: POOL_MIN_24H_VOLUME_USD,
      topTokensPerChain: WATCH_TOP_TOKENS,
    },
    // v9.26 F2: Solana real-time pubsub observability.
    // v9.32ae ED4: list = the ACTUAL sent subscription set (verifiable against ws frames);
    // pools/confirmed/frames are facts, not wishes.
    solMintMentions: { ...solMintMentions, list: [...solMintMentionSubs].sort() },
    // v9.32ag GD1: per-mint analyze budgets — top-10 by activity (AG3 lottery made visible)
    analyzeMints: __analyzeMintsForTest(),
    analyzeMintsWindowSec: 600, // v9.32bm1g H3: 10-мин tumbling-окно — «dropped: 0» без окна читается как «потерь нет»
    hotMints: { ...hotMintsStats, cached: getSolanaPubsub().hotMintsCache.length },
    subscriptions: coverageStatus(),
    // v9.32bj BJ6 (AR7): дубль solPubsub.solReserveCoverage на верхнем уровне. Живой замер
    // ТЗ читал `.solReserveCoverage` и получал null — путь не существовал, сама метрика в
    // solPubsub была жива (прод f8c7a37: 710/2108, 34%). Регресса bh1 нет; чтобы оба пути
    // отдавали одно и то же, объект вычисляется одной функцией.
    solReserveCoverage: solReserveCoverageSnapshot(),
    solPubsub: {
      enabled: SOLANA_PUBSUB_ENABLED,
      connected: solPubsubMetrics.connected,
      // D4-R: transport/open state is intentionally separate from provider-confirmed restore state.
      restore: restoreSnapshot,
      frames: solPubsubMetrics.frames,
      parsed: solPubsubMetrics.parsed,
      dropped: solPubsubMetrics.dropped,
      lastError: solPubsubMetrics.lastError,
      lastFrameAt: solPubsubMetrics.lastFrameAt,
      reconnects: solPubsubMetrics.reconnects,
      reconnectAttempts: solPubsubReconnect.attempts,
      backoffMs: solPubsubReconnect.lastDelay,
      // bm1s: шторм 429 виден в статусе без логов — серия подряд идущих отказов рукопожатия,
      // момент последнего и запланированный ретрай (0 = попытка не запланирована).
      consecutive429: solPubsubReconnect.consecutive429,
      lastHandshake429At: solPubsubReconnect.lastHandshake429At,
      nextRetryMs: solPubsubReconnect.nextRetryMs,
      // v9.32bj BJ3 (AR5): живучесть сокета за последний час — см. solPubsubLiveness.
      ...(() => { const lv = solPubsubLiveness(solPubsubNow()); return { aliveRatio: lv.aliveRatio, flapping: lv.flapping, sessionsLastHour: lv.sessions, ingestDown: lv.ingestDown, ingestDownSinceMs: lv.ingestDownSinceMs, ingestDownReason: lv.ingestDownReason, degradedBlindUntil: lv.blindUntilMs ? new Date(lv.blindUntilMs).toISOString() : null }; })(), // v9.32bm1 S3; v9.32bm1e E2: degradedBlindUntil
      // v9.32bi BI2: прод показывал subscribedPools 45 при 273 реально покрытых пулах —
      // поле считало mint-подписки, тогда как при SOL_RESERVE_ACCOUNTSUB=1 покрытие даёт
      // solReserveResolved. Три величины разведены явно.
      subscribedPools: (() => {
        const watch = state.chain['solana']?.watchPools || [];
        let n = 0;
        for (const p of watch) if (solReserveResolved.has(p)) n++;
        return n;
      })(),
      mintSubs: solPubsubMetrics.subscribedPools,
      mentionLifecycle: {
        mint: {
          desired: solMintMentionSubs.size,
          sentOrPossiblyActive: [...solPubsubSubscribed].filter(k => solMintMentionSubs.has(k)).length,
          pending: [...solLogsReqToMint.values()].filter(r => r.track === 'mint').length,
          confirmedKeys: countSolMentionConfirmed(),
          activeServerSubIds: [...solLogsSubToMint.values()].filter(r => r.track === 'mint').length,
        },
        fallbackPool: {
          sentOrPossiblyActive: [...solPubsubSubscribed].filter(k => !solMintMentionSubs.has(k)).length,
          pending: [...solLogsReqToMint.values()].filter(r => r.track === 'fallbackPool').length,
          confirmedPools: [...solLogsSubToMint.values()].filter(r => r.track === 'fallbackPool').length,
          activeServerSubIds: [...solLogsSubToMint.values()].filter(r => r.track === 'fallbackPool').length,
          // bm2z-fix O1: «провайдер молчит» ≠ «провайдер отказал». Общие mentionsConfirmTimeout и
          // solMintMentions.nacked смешивают обе ноги, поэтому прод m09 (queued=11, confirmed=0) не
          // отвечал на вопрос, виноват admission или провайдер. Атрибуция только треку fallbackPool.
          confirmTimeouts: solProgramFallbackMetrics.fallbackConfirmTimeouts,
          nacked: solProgramFallbackMetrics.fallbackNacked,
        },
        fallbackAdmission: {
          // bm2z-fix RC1: потребление БЮДЖЕТА слота, а не mentionOutstanding. Устаревшие tombstone
          // больше не занимают слот, поэтому их присутствие здесь читалось бы как `inflight > max`
          // при полностью исправном admission. Они видны ниже: lateTombstones / tombstonesByTrack /
          // oldestLateTombstoneAgeMs. Форма объекта не меняется — по нему есть deepEqual в тестах.
          inflight: fallbackAwaitingProvider('fallbackPool'),
          maxInflight: SOL_WS_FALLBACK_INFLIGHT_MAX(),
          blockedRequests: solMentionTombstoneStats.fallbackAdmissionBlocked,
        },
        mintAdmission: {
          replacementHeld: solMentionTombstoneStats.mintReplacementHeld,
          reservePendingConfirmations: solReservePendingAccount.size,
          reserveRateLimitedUntil: getSolanaPubsub().solReserveRateLimitedUntil > Date.now() ? getSolanaPubsub().solReserveRateLimitedUntil : 0,
          deferredRotations: solMentionTombstoneStats.mintDeferredRotations,
        },
        total: { activeServerSubIds: solLogsSubToMint.size },
        lateTombstones: solLogsReqToUnsubscribe.size,
        tombstonesByTrack: {
          mint: [...solLogsReqToUnsubscribe.values()].filter(r => r.track === 'mint').length,
          fallbackPool: [...solLogsReqToUnsubscribe.values()].filter(r => r.track === 'fallbackPool').length,
        },
        oldestLateTombstoneAgeMs: (() => { const oldest = [...solLogsReqToUnsubscribe.values()].reduce((m, r) => Math.min(m, r.at), Infinity); return oldest === Infinity ? 0 : Math.max(0, solPubsubNow() - oldest); })(),
        // Kept as zero-valued compatibility fields. Tombstone GC never resets the session.
        tombstoneTtlResets: solMentionTombstoneStats.ttlResets,
        tombstoneCapacityResets: solMentionTombstoneStats.capacityResets,
        fallbackCircuit: {
          ...solMentionTombstoneStats.fallbackCircuit,
          // This is a local tombstone-recovery deadline, not the provider circuitUntil field.
          // Keep it address-free and observational: status reads never arm or advance recovery.
          recoveryDueAt: fallbackTombstoneRecovery.dueAt || null,
          recoveryInFlight: fallbackTombstoneRecovery.inFlight,
          recoveryCount: fallbackTombstoneRecovery.count,
          lastRecoveryAt: fallbackTombstoneRecovery.lastAt,
          lastRecoveryReason: fallbackTombstoneRecovery.lastReason,
          lastRecoveryResult: fallbackTombstoneRecovery.lastResult,
        },
        mintRetryBlocked: solMentionTombstoneStats.mintRetryBlocked,
        directLateCleanupFailures: solMentionTombstoneStats.directLateCleanupFailures,
        // bm2z-fix Task 2: bounded, address-free response taxonomy. Empty track counters + zero
        // unknown (за всё время) = «провайдер не отвечает на fallback-reqId вовсе»; ненулевой
        // unclassified/unknown при idType≠number = «ответ есть, но не классифицирован».
        responseDiag: {
          knownPendingByTrack: { ...solPubsubResponseDiag.knownPendingByTrack },
          confirmedByTrack: { ...solPubsubResponseDiag.confirmedByTrack },
          confirmedStaleSessionByTrack: { ...solPubsubResponseDiag.confirmedStaleSessionByTrack },
          errorByTrack: { ...solPubsubResponseDiag.errorByTrack },
          lateByTrack: { ...solPubsubResponseDiag.lateByTrack },
          unknownRequestId: solPubsubResponseDiag.unknownRequestId,
          nonSubscriptionResponses: solPubsubResponseDiag.nonSubscriptionResponses,
          reserveResponses: solPubsubResponseDiag.reserveResponses,
          staleSessionResponses: solPubsubResponseDiag.staleSessionResponses,
          idType: { ...solPubsubResponseDiag.idType },
          resultType: { ...solPubsubResponseDiag.resultType },
          confirmTtlMs: SOL_WS_MENTION_CONFIRM_MS(),
          samples: solPubsubResponseDiag.samples.map(s => ({ ...s })),
        },
      },
      reserveSubs: solReserveActiveAccount.size,
      solSubPacerPending: getSolanaPubsub().solSubPacer.pending(), // bm1r: отложенные в очередь темпа (0 между волнами; рост = берст упирается в интервал)
      coveragePct: (() => {
        const vis = visibleWatchPools('solana').length;
        const n = Math.min(vis, SOL_PUBSUB_MAX_POOLS());
        return vis > 0 ? Math.round(n / vis * 100) : 0;
      })(),
      // v9.32bh BH4 (RD6): фактический охват РАЗРЕШЁННЫХ резервов. coveragePct выше —
      // теоретический потолок подписок (min(watch, cap)/watch), он не отражает, по скольким
      // пулам детектор реально способен разобрать своп: BH1 работает только там, где
      // solReserveResolved заполнен. Прод показывал 51 из 528 — цифра для следующего цикла.
      // v9.32bh BH4 fix: считаем ПЕРЕСЕЧЕНИЕ с текущим вотчлистом. solReserveResolved живёт
      // дольше вотчлиста (restore из кэша + пулы прошлых ротаций: прод показал 457 записей
      // против 203 watchPools → pct 170%). Размер карты сам по себе охватом не является.
      solReserveCoverage: solReserveCoverageSnapshot(),
      reasons: { ...solPubsubMetrics.reasons },
      analyzeTxFailedReasons: { ...solPubsubMetrics.analyzeTxFailedReasons }, // v9.33bm1k Task 7 → bm1n: custom-<n>/варианты, Σ == reasons.analyzeTxFailed
      analyzeTxFailedDetail: { ...solPubsubMetrics.analyzeTxFailedReasons }, // bm1m Task 7: ключ сохранён (payload — только добавления); bm1n — копия того же словаря
      analyzeTxFailedPrograms: { ...solPubsubMetrics.analyzeTxFailedPrograms }, // v9.35bm1p: base58-программы упавших tx из logs кадра (unknown — неразрешимое, other — переполнение), Σ == reasons.analyzeTxFailed
      // v9.32bm1i T4: инвариант dropped == Σ терминальных причин; unattributedDropped > 0 = слепой выход (дефект).
      reasonsSum: solPubsubDropReasonsSum(),
      unattributedDropped: solPubsubMetrics.dropped - solPubsubDropReasonsSum(),
      stageReasonsSum: solPubsubStageReasonsSum(),
      // v9.32ae ED3: analyze-bucket split — wlAnalyzed must grow under load while
      // anchorRateDropped absorbs the anchor noise (checklist #5).
      wlAnalyzed: solPubsubMetrics.wlAnalyzed,
      anchorAnalyzed: solPubsubMetrics.anchorAnalyzed,
      wlRateDropped: solPubsubMetrics.wlRateDropped,
      anchorRateDropped: solPubsubMetrics.anchorRateDropped,
      wlBorrowed: solPubsubMetrics.wlBorrowed, // v9.32bk BK1
      wlBucketUsed: rateCount('solpubsub-analyze-wl', 60000), // v9.32bk BK1: утилизация глобальной полосы за 60 с
      analyzePerMinCap: SOL_PUBSUB_ANALYZE_PER_MIN(),
      // v9.32bm1g H2 (солана-близнец F1): три подкапа и занятость anchor-бакета — иначе «60/мин» видно только в коде
      analyzePerMinCapAnchor: SOL_ANALYZE_ANCHOR_PER_MIN(),
      analyzePerMinCapPerMint: SOL_ANALYZE_PER_MINT(),
      analyzeWlFloorPerMin: SOL_ANALYZE_WL_FLOOR(),
      anchorBucketUsed: rateCount('solpubsub-analyze-anchor', 60000),
      useHelius: !!getSolanaPubsub().HELIUS_API_KEY,
      // v9.33bm1k Task 6: расход Helius (окно 1 ч) + отпечаток ключа (4+4, полный ключ в payload не попадает) + стоп лимитера
      heliusCuLastHour: heliusCuMeter().lastHour(solPubsubNow()),
      heliusCuPerMin: heliusCuMeter().perMin(solPubsubNow()),
      // bm1m Task 4: короткая шкала рядом с часовой (не вместо). Прод 171d1941: 18 651 CU/мин мгновенно при heliusCuPerMin 2 209.
      heliusCuPerMinShort: Math.round(heliusCuMeter().perMinShort(solPubsubNow())),
      heliusCuLast2Min: heliusCuMeter().lastWindow(solPubsubNow(), 120_000),
      heliusCuDayUsed: heliusCuMeter().dayUsed(solPubsubNow()),
      heliusKeyFingerprint: heliusCuMeter().fingerprint(),
      reserveStoppedBy: heliusReserveLimiter.stoppedBy,
      reserveLimiter: {
        stops: heliusReserveLimiter.stops, stoppedAt: heliusReserveLimiter.stoppedAt, lastStopCu: heliusReserveLimiter.lastStopCu, cuHourMax: HELIUS_RESERVE_CU_HOUR_MAX(), maxMinutes: HELIUS_RESERVE_MAX_MINUTES(),
        // bm1m Task 5: прогноз и путь возврата. Прод 171d1941 s7→s11: 16 мин reserveStoppedBy без единого поля «когда/почему снимется».
        // canResumeNow отвечает, чего именно ждём: кулдаун (canResumeAt в будущем) или часовое окно ≥ cuHourMax/2.
        forecastExhaustionMin: forecastExhaustionMin(heliusCuMeter(), HELIUS_CU_DAY_MAX(), solPubsubNow()),
        canResumeAt: heliusReserveLimiter.stoppedAt ? heliusReserveLimiter.stoppedAt + HELIUS_RESERVE_MAX_MINUTES() * 60_000 : 0,
        canResumeNow: canResumeReserve(heliusCuMeter(), heliusReserveLimiter.stoppedAt, heliusReserveLimiterCfg(), solPubsubNow()),
      },
      // v9.32bl BL1: фактический провайдер сокета + статистика пробного возврата на publicnode
      // bm1m Task 7b: 'publicnode' для первого узла сохранён (сравнение срезов), другие бесплатные — 'free'.
      wsProvider: getSolanaPubsub().currentSolWsUrl ? (isHeliusUrl(getSolanaPubsub().currentSolWsUrl) ? 'helius' : getSolanaPubsub().currentSolWsUrl === getSolanaPubsub().SOLANA_FREE_WS_URLS[0] ? 'publicnode' : 'free') : null,
      wsFreeIndex: getSolanaPubsub().solFreeWsIndex,
      wsFreeUrls: getSolanaPubsub().SOLANA_FREE_WS_URLS.length,
      wsFailsByNode: [...getSolanaPubsub().solFreeWsFails],
      wsFreeHost: solFreeWsHost(getSolanaPubsub().SOLANA_FREE_WS_URLS[getSolanaPubsub().solFreeWsIndex]),
      // bm2c: имена из ТЗ рядом с существующими ключами bm1m (wsFreeIndex/wsFreeHost).
      // Старые НЕ переименованы и не удалены — на них смотрят прежние срезы и приёмки.
      wsUrlIndex: getSolanaPubsub().solFreeWsIndex,
      wsUrlHost: solFreeWsHost(getSolanaPubsub().SOLANA_FREE_WS_URLS[getSolanaPubsub().solFreeWsIndex]),
      rotations: getSolanaPubsub().solFreeWsRotations,        // bm2c Task 3: сколько раз канал сменил узел
      banMaxMs: SOL_WS_BAN_MAX_MS(),        // bm2c Task 1: действующий потолок 429-лестницы
      ...solPubsubHeliusStayEval(solPubsubNow()), // v9.32bm1c T5: heliusStayMs / heliusStayCu — сколько времени и CU платим на резерве
      publicnodeReturn: { ...solPubsubPublicnodeReturn, skipReasons: { ...solPubsubPublicnodeReturn.skipReasons }, retryMs: SOL_WS_PUBLICNODE_RETRY_MS(), frameWaitMs: SOL_WS_PROBE_FRAME_MS(), probeFrames: getSolanaPubsub().solPubsubProbeFrames },
      probeSkipReasons: { ...solPubsubPublicnodeReturn.skipReasons }, // v9.32bm1 S1 (S-AR4)
      maxPools: SOL_PUBSUB_MAX_POOLS(),
      lastDisconnectAt: solPubsubMetrics.lastDisconnectAt,
      lastDisconnectReason: solPubsubMetrics.lastDisconnectReason,
      connectedMs: solPubsubMetrics.connectedMs,
      silentResubs: solPubsubMetrics.silentResubs,
      // v9.32bm1g H1: подтверждения mention-подписок (confirmed — только по solLogsSubToMint, не subscribedPools)
      mentionsPending: solLogsReqToMint.size,
      mentionsConfirmed: countSolMentionConfirmed(),
      mentionsConfirmTimeout: solPubsubMetrics.mentionsConfirmTimeout,
    },
    helius: helius429State(),
    solRpc429ByPath: solRpc429ByPathSnapshot(),
    solRpc429Totals: { global: helius429Totals.global, byPath: { ...helius429Totals.byPath } },
    // v9.32af FD3 (AF3): delivered-frames CU estimate (frames x 15 CU) — eye-level convergence
    // with the provider dashboard. After Task 1 hot-address frames disappear from here.
    wsCuEstimate: (rpcSpend.alchemy.wsMsg + rpcSpend.helius.wsMsg) * 15,
    // v9.32bf BF2 (AR6): фактический расход Alchemy с разбивкой по источнику — 60с-окно.
    // До bf «главным расходником» считали eth_getLogs (42 % ВЫЗОВОВ), тогда как в CU это
    // 2 % от общего; 82 % давала доставка кадров. Теперь стоимость видна, а не доля вызовов.
    alchemyCuPerSec: alchemyCuPerSecNow(),
    alchemyCuSource: alchemyCuSourceWindow(),
    alchemyCuSourceTotal: evmRuntime.spend.window().totals,
    // AR7: кадры публичных сокетов больше не считаются оплаченными — они здесь, отдельно.
    wsMsgPublic: { ...wsBilledPublic },
    alchemyWsChains: EVM_WS_SUPPORTED_CHAINS.filter(c => chainWsIsAlchemy(c)),
    alchemyHttpChains: [...new Set([...evmAlchemyChainSet(), ...evmAlchemyHttpChainSet()])]
      .filter(c => EVM_WS_SUPPORTED_CHAINS.includes(c)),
    rpcSpend: {
      helius: { ...rpcSpend.helius },
      alchemy: { ...rpcSpend.alchemy },
      solana: { ...rpcSpend.solana },
      evm: rpcSpendEvmSnapshot(), // v9.32bm1 S7; includes bounded Arbitrum attempt trail
      ...rpcAccountingSnapshot(),
      routerBudgetDropped,
    },
    evmAnalyze: { ...evmAnalyze, rpcStarved: rpcRouting.analyzeStarvedCount() }, // v9.32bm1e E3: analyze-срез без двух здоровых хостов
    // v9.32al CK1: CU-предохранитель Alchemy (токен-бакет; расход предсказуем из статуса)
    cuGuard: (() => {
      const c = __alchemyCuForTest();
      const f = __wsFrameStatsForTest();
      return {
        tokensLeft: Math.round(c.tokens), dropped: c.dropped, soloWaited: c.soloWaited, soloTimeoutPassed: c.soloTimeoutPassed, pauses429: evmRuntime.spend.pause().pauses, pause429Until: evmRuntime.spend.pause().pauseUntil, blocked429: evmRuntime.spend.pause().blocked,
        // v9.32am HOTFIX+ (MD2): соло-коридор + отдельный кадровый бакет (вызовы ≠ кадры)
        soloCorridor: evmRuntime.spend.soloCorridor(),
        // v9.32bm1g H6: при burst ≤ 0 (WS_FRAME_BUDGET_PER_MIN=0 на проде) бакет клампится нулём и «долг» — фикция → null;
        // wsThrottleEnabled повторяет условие выхода maybeWsThrottle. `??` в бюджете не трогать (A/B «0 = не списывать»).
        frameCu: (() => { const b = WS_FRAME_CU_BURST(); return { tokensLeft: b > 0 ? Math.round(__wsFrameCuForTest().tokens) : null, ratePerSec: WS_FRAME_CU_PER_SEC(), burst: b }; })(),
        wsFrameBudgetPerMin: WS_FRAME_BUDGET_PER_MIN(),
        wsThrottleEnabled: WS_FRAME_BUDGET_PER_MIN() > 0,
        // v9.32am AM4: кадры под гаром — окно 60с, троттлы/ресабы видимы
        wsFrames: {
          count: f.count, windowNow: f.windowNow, budgetPerMin: f.budgetPerMin, cuPerFrame: WS_CU_PER_FRAME(), throttled: f.throttled, resubbed: f.resubbed,
          // v9.32am HOTFIX+ (MD2): кадровая революция — телеметрия источников + миграции
          movedToPoll: wsHotPoll.movedCounts().movedToPoll, restored: wsHotPoll.movedCounts().restored,
          polledNotSubscribed: wsHotPoll.migratedKeys(), // пометка polled-not-subscribed
          top: wsFramesTop(20),
          // v9.32av AV1/AV3: честный разбор тарификации + зомби-реестр
          billedKnown: wsBilled.known, billedThrottled: wsBilled.throttled, billedUnknown: wsBilled.unknown,
          zombies: { tracked: wsZombies.size(), retried: wsZombies.stats().retried, gaveUp: wsZombies.stats().gaveUp },
        },
      };
    })(),
    warmup: evmRuntime.limits.warmup(), // v9.32al CK2
    subSoloDropped, // v9.32al CK3
    intentExecutorForced, // v9.32am AM5: intent-кадры форсируют фазу-B (CRV/ENA-класс восстановлен)
    intentNoSubject, // v9.32am HOTFIX+ (MD3): executor netting без владельца — НЕ self_swap
    // v9.32ao OD1: value-priority — суммы важнее счётных очередей (обе цепи)
    valuePriority: { ...valuePriorityStats, queueLen: evmValueRetry.size, soloRetryLen: solSoloRetry.size, exemptMinUsd: EVM_VALUE_EXEMPT_MIN_USD(), evmTtlMs: EVM_VALUE_RETRY_TTL_MS(), prefetchPerMin: EVM_VALUE_PREFETCH_PER_MIN() },
    // v9.26 F3: Solana pool-reserve accountSubscribe real-time observability.
    solReserve: {
      // bm2w: delta-readable per boot. Stages overlap; do NOT sum them as terminal drops.
      notificationFlow: {
        ...structuredClone(engineKernel.metrics!.solNotificationFlow),
        seenEvents: state.chain.solana?.seenEvents || 0,
        notified: state.chain.solana?.notified || 0,
        skipped: state.chain.solana?.skipped || 0,
        skipReasons: { ...state.chain.solana?.skipReasons },
        pubsubReasons: { ...solPubsubMetrics.reasons },
        pubsubDropped: solPubsubMetrics.dropped,
      },
      enabled: SOL_RESERVE_ACCOUNTSUB(),
      connected: solPubsubMetrics.connected,
      pools: solReserveMetrics.pools,
      subs: solReserveMetrics.subs,
      resolved: solReserveMetrics.resolved,
      failed: solReserveMetrics.failed,
      failedUniquePools: solReserveFailedEver.size,
      failedByReason: { ...solReserveFailReasons },
      reserveQuarantined: solReserveQuarantined.size,
      // bm2z A: program-level fallback (aggregates only; no pool addresses/signatures/URLs)
      programFallback: solProgramFallbackSnapshot(),
      // bm2z C: deferred retryable RPC failures (not in failed/failedByReason until exhausted)
      rpcDeferred: solReserveRpcDeferMetrics.deferred,
      rpcExhausted: solReserveRpcDeferMetrics.exhausted,
      rpcDeferredExpired: solReserveRpcDeferMetrics.expired,
      rpcDeferredQueue: solReserveRpcDeferred.size,
      rpcDeferCycles: SOL_RESERVE_RPC_DEFER_CYCLES,
      lastRpcDeferredReason: solReserveRpcDeferMetrics.lastReason,
      lastRpcDeferredDrop: solReserveRpcDeferMetrics.lastDrop,
      coverageDefinition: 'Unique Solana watchlist pools with at least one confirmed reserve-account subscription or pool-level logsSubscribe; resolved-only pools are excluded.',
      coverageNow: getOracleRuntime().pipelineCoverageSnapshot().coverage.solana || 0,
      coverageGap: (() => {
        const watch = solPubsubTopPools();
        return {
          resolvePending: watch.filter(p => !solReserveResolved.has(p) && !solReserveFailed.has(p) && !solReserveQuarantined.has(p)).length,
          rebuildQueued: getSolanaPubsub().solReserveTrickleQueue.length,
          resolveFailed: solReserveFailed.size,
        };
      })(),
      decodeLayoutMissByProgram: Object.entries(decodeLayoutMissByProgram)
        .map(([programId, misses]) => ({ programId, misses }))
        .sort((a, b) => b.misses - a.misses || a.programId.localeCompare(b.programId)).slice(0, 3),
      reResolved: solReserveMetrics.reResolved,
      swaps: solReserveMetrics.swaps,
      lastSwap: solReserveMetrics.lastSwap,
      lastError: solReserveMetrics.lastError,
      fallbackPools: solReserveMetrics.fallbackPools,
      indexed: { ...solReserveMetrics.indexed }, // v9.32bm1 S4
      coveragePct: (() => {
        const vis = visibleWatchPools('solana').length;
        const n = Math.min(vis, SOL_PUBSUB_MAX_POOLS());
        return vis > 0 ? Math.round(n / vis * 100) : 0;
      })(),
      missingSig: solReserveMetrics.missingSig,
      // W1-T8: единицы и окно — чтобы агрегаты не читались как уникальные потери.
      missingSigUnit: 'attempt', // инкремент на попытку резолва (ретрай жжёт второй)
      txVersionUnsupported: solReserveMetrics.txVersionUnsupported, // единица: кандидат, отбитый по версии
      txVersionUnsupportedUnit: 'candidate',
      maxSupportedTransactionVersion: SOL_TX_MAX_VERSION(),
      // W1-T8: окно каждого ряда — чтобы cumulative не читался как per-cycle (спека §3.5/§3.7).
      metricsWindow: 'lifetime-since-boot', // solReserveMetrics: сброс только вместе с резетом рантайма
      diagWindowMs: solReserveDiagSnapshot().windowMs, // reserveStages (sigFailed/sigRecovered): скользящее окно
      wrongSig: solReserveMetrics.wrongSig,
      sigScanned: solReserveMetrics.sigScanned,
      sigFallbackNewest: solReserveMetrics.sigFallbackNewest,
      sigFallbackDeduped: solReserveMetrics.sigFallbackDeduped,
      // bm2x Task 3: отложенная повторная попытка восстановления подписи (queued/recovered/failed/droppedFull)
      sigRetry: { queued: solReserveMetrics.sigRetryQueued, recovered: solReserveMetrics.sigRetryRecovered, failed: solReserveMetrics.sigRetryFailed, droppedFull: solReserveMetrics.sigRetryDroppedFull, droppedRate: solReserveMetrics.sigRetryDroppedRate, delayMs: SOL_RESERVE_SIG_RETRY_MS(), max: SOL_RESERVE_SIG_RETRY_MAX(), perMin: SOL_RESERVE_SIG_RETRY_PER_MIN(),
        // Единица — сделка (swap-событие), в отличие от queued/recovered/failed/missingSig (попытки). missing = recoveredByRetry + exhausted + pending.
        trades: { unit: 'swap_event' as const, missing: solReserveMetrics.sigTradesMissing, recoveredByRetry: solReserveMetrics.sigTradesRecoveredByRetry, exhausted: solReserveMetrics.sigTradesExhausted, pending: Math.max(0, solReserveMetrics.sigTradesMissing - solReserveMetrics.sigTradesRecoveredByRetry - solReserveMetrics.sigTradesExhausted) } },
      // v9.32ap PD1: reject-счётчики + доля fallback в эмитах (прод-инцидент: 116% скрыто)
      fallbackRejected: {
        failed: solReserveMetrics.fallbackRejectedFailed,
        empty: solReserveMetrics.fallbackRejectedEmpty,
        scale: solReserveMetrics.fallbackRejectedScale,
      },
      fallbackShare: fallbackSnapshot.share,
      fallbackShareWindowMs: fallbackSnapshot.windowMs,
      fallbackShareObservedAt: fallbackSnapshot.observedAt,
      // W1-T8: единица ряда — доля ЭМИТОВ пула (fallback/(direct+fallback)), а не доля сделок:
      // 0.725 в проде читалось как «72% сделок потеряно», чем оно не является (спека §1/§4).
      fallbackShareUnit: 'share_of_pool_emits',
      fallbackDegraded: fallbackSnapshot.degraded,
      // ТЗ 2026-10-09 §3.3 req 2/4: ПОЧЕМУ fallback выше порога и когда будет следующий повтор.
      // state='insufficient_evidence' после рестарта — сигнал не считается снятым без доказательств.
      fallbackDegradation: fallbackSnapshot,
      subscribeQueue: {
        targetCount: visibleWatchPools('solana').length,
        queued: restoreSnapshot.pendingAccountSubscriptions,
        terminal: restoreSnapshot.terminalAccountSubscriptions,
        retainedRequestIdentities: solReserveReqToPool.size,
        sent: solReserveMetrics.subs,
        nacked: solReserveMetrics.subNacked,
        pendingAddresses: restoreSnapshot.pendingAccountSubscriptions,
        retainedAdmissionClaims: solReservePendingAccount.size,
        rateLimitCircuitOpens: solReserveMetrics.rateLimitCircuitOpens,
        rateLimitedUntil: getSolanaPubsub().solReserveRateLimitedUntil,
      },
      // v9.32ae ED2: pairs that outlived even the TTL window while a signature retry was in
      // 429-backoff — visible today (the pre-fix behavior was a SILENT drop, AE5).
      pairMs: SOL_RESERVE_PAIR_MS,
      pairTtlMs: SOL_RESERVE_PAIR_TTL_MS(),
      pairExpiredTtl: solReserveMetrics.pairExpiredTtl,
      // v9.32am AM1: персист резерва — рестарт подписывает из кэша, HTTP-резолвы ≈ 0.
      cache: {
        hits: reserveCache.hits, misses: reserveCache.misses,
        revalidate: reserveCache.revalidate, restores: reserveCache.restores,
        rows: reserveCacheMem.size, ttlMs: RESERVE_CACHE_TTL_MS(),
      },
      // v9.32am AM2: эшелоны подписок — волна-1 ≤60с, полный WL ≤15 мин, паузы на 429.
      trickle: {
        wave1Ms: trickle.wave1Ms, backgroundDone: trickle.backgroundDone,
        pending: trickle.pending, paused: trickle.paused,
        firstN: RESERVE_TRICKLE_FIRST_N(), ratePerMin: RESERVE_TRICKLE_RATE(),
      },
    },
    // v9.32ag GD4: 10-минутное кольцо стадий конвейера резерва (нотификации → пара → подпись →
    // эмит) + top-5 пулов: какая стадия умирает — видно без разбора логов.
    reserveDiag: (() => {
      const snap = solReserveDiagSnapshot();
      return { ...snap, health: reserveDiagHealth(snap, { pools: Math.max(solReserveActiveAccount.size, solReserveState.size), lastNotificationAt: solReserveMetrics.lastNotificationAt, now: Date.now() }) }; // v9.32an-RC1: заявленные тоже считаются
    })(),
    // v9.32ai ID3: страж доставки обеих ветвей — мёртвая доставка видна СТАТУСОМ, а не тишиной.
    deliveryHealth: deliveryHealthSnapshot(),
    // v9.32am HOTFIX+ (MD1): авто-восстановление резерва — видно в статусе (AM2: поля не молчат)
    reserveRecovery: {
      attempts: reserveRecovery.attempts, recoveries: reserveRecovery.recoveries,
      lastAt: reserveRecovery.lastAt, lastReason: reserveRecovery.lastReason,
      resumeResubOnStart: RESUME_RESUB_ON_START(), cooldownMs: RESERVE_RECOVERY_COOLDOWN_MS(),
    },
    // v9.26 F1: real-time OnchainTrade WS observability.
    gtWs: {
      enabled: GT_ONCHAIN_WS_ENABLED,
      hasKey: !!process.env.COINGECKO_PRO_API_KEY,
      connected: gtWsMetrics.connected,
      frames: gtWsMetrics.frames,
      parsed: gtWsMetrics.parsed,
      dropped: gtWsMetrics.dropped,
      lastError: gtWsMetrics.lastError,
      lastFrameAt: gtWsMetrics.lastFrameAt,
      reconnects: gtWsMetrics.reconnects,
      subscribedPools: gtWsMetrics.subscribedPools,
    },
    // D4: how many env-provided anchors (ANCHOR_POOLS_<chain>) are active per chain.
    anchorsLoaded: Object.fromEntries(Object.keys(CHAINS).map(id => [id, (ENV_ANCHOR_POOLS[id] || []).length])),
    // v9.32bb BB3: не молчать, когда WS мёртв и ingest идёт только через hotPoll.
    wsHealth: Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.map(c => {
      const st = evmWsChains[c];
      const err = String(st?.lastError || '');
      const forbidden = err.includes('403');
      const connected = !!st?.connected;
      const provider = alchemyWsUrl(c) ? 'alchemy' : (PUBLICNODE_WS[c] ? 'publicnode' : 'none');
      return [c, {
        provider,
        connected,
        lastError: st?.lastError || null,
        forbidden,
        degradedIngest: !connected,
      }];
    })),
    degradedIngest: Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.map(c => [c, !evmWsChains[c]?.connected])),
    // v9.29: EVM Alchemy WebSocket real-time observability.
    evmWs: {
      enabled: EVM_WS_ENABLED,
      chains: Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.map(c => {
        const st = evmWsChains[c];
        const valSummary = getAnchorValidationSummary(c);
        const subStatus = getEvmWsSubscriptionStatus(c, st, {
          configuredAnchors: valSummary.configured,
          validatedAnchors: valSummary.validated,
          validationPending: valSummary.pending,
          invalidAnchors: valSummary.invalid,
          unavailableAnchors: valSummary.unavailable,
          lastValidationError: valSummary.lastError,
          lastUnavailableAt: valSummary.lastUnavailableAt,
          lastUnavailableReason: valSummary.lastUnavailableReason,
          nextAnchorValidationAt: valSummary.nextAnchorValidationAt,
        });
        return [c, {
          connected: st.connected,
          attempts: evmWsRec(c).attempts,
          backoffMs: evmWsBackoffMs(Math.max(1, evmWsRec(c).attempts)),
          frames: st.frames,
          parsed: st.parsed,
          dropped: st.dropped,
          subscribedPools: st.subscribed.size,
          confirmedSubscriptions: subStatus.confirmedSubscriptions,
          pendingSubscriptions: subStatus.pendingSubscriptions,
          retryingSubscriptions: subStatus.retryingSubscriptions,
          rejectedSubscriptions: subStatus.rejectedSubscriptions,
          configuredAnchors: subStatus.configuredAnchors,
          validatedAnchors: subStatus.validatedAnchors,
          validationPending: subStatus.validationPending,
          invalidAnchors: subStatus.invalidAnchors,
          unavailableAnchors: subStatus.unavailableAnchors,
          lastUnavailableAt: subStatus.lastUnavailableAt,
          lastUnavailableReason: subStatus.lastUnavailableReason,
          nextAnchorValidationAt: subStatus.nextAnchorValidationAt,
          lastValidationError: subStatus.lastValidationError,
          lastError: st.lastError,
          lastFrameAt: st.lastFrameAt,
          reconnects: st.reconnects,
          // v9.30 EVM-DEBUG F1: pipeline diagnostics — explains why `parsed` stays low. Each counter
          // identifies a specific failure mode so we can tell from prod whether most frames are
          // dedup'd, rate-limit'd, filtered by Topic0 mismatch, or rejected at the analyze layer.
          diagnostics: {
            analyzed: st.analyzed,
            rateLimited: st.rateLimited,
            topTopicMismatch: st.topTopicMismatch,
            dedup: st.dedup,
            noFoundByReason: { ...st.noFoundByReason },
            phaseA: { ...(evmPhaseAByChain[c] || { checked: 0, dropped: 0 }) },
            analyzeRpc: rpcRouting.analyzeRpcCellSnapshot(c), // v9.32bm1 S7; bm2a: failBy — все ключи, deep copy
          },
        }];
      })),
      maxPoolsPerChain: EVM_WS_MAX_POOLS <= 0 ? 'unlimited (all watched pools)' : EVM_WS_MAX_POOLS,
      analyzePerMinCap: EVM_WS_ANALYZE_PER_MIN,
      // v9.32bm1g F1: действующий потолок гейта handleEvmWsLog (та же формула; в warmup = 120) — иначе по
      // статусу не отличить «полоса упёрлась намеренно» от «полоса мала навсегда».
      analyzePerMinCapEffective: Math.max(1, Math.floor(EVM_WS_ANALYZE_PER_MIN * analysisWarmupFactor())),
      // bm2d: действующий потолок ПО СЕТЯМ (после обрезки CAP_MAX и warmup). Прежние два ключа
      // выше — номинал и общий effective — оставлены как есть: на них смотрит F1-тест и дашборд.
      analyzePerMinByChain: evmWsAnalyzeCapsByChain(),
      // bm2s §5.4: живое окно кольца дропов кадра (TTL 10 мин) — публикация улик, поведение гейтов не тронуто
      frameDrops: getOracleRuntime().frameDrops(),
    },
    solanaValuation: {
      sources: { ...solanaValuationSourceCounts },
      lastSource: lastSolanaValuationSource,
      lastKnownUsd: solanaLastKnownUsd,
    },
    evmPrefilter: Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.map(c => [c, { ...(evmPrefilterStats[c] || {}) }])),
    // v9.32af FD1: hot-address getLogs polling (replaces token/router WS delivery).
    hotPoll: Object.fromEntries(Object.keys(evmHotPollMetrics).map(c => [c, { ...evmHotPollMetrics[c] }])),
    // v9.32ao-RC3 П5: видимость покрытия hotPoll — активный лимит токен-подписок (env может
    // резать дефолт 8!) и первые адресы хотлиста. Только чтение, поведение не меняет.
    hotPollCoverage: {
      tokenSubsLimit: EVM_TOKEN_SUBS(),
      perChain: Object.fromEntries(EVM_WS_SUPPORTED_CHAINS.filter(c => evmWsChains[c] || evmHotPollMetrics[c]).map(c => [c, {
        hotAddrs: evmHotAddrs(c).length,
        top: evmHotAddrs(c).slice(0, 3),
      }])),
    },
    // v9.14 F1: stability/health observability — enrichment-gate pressure + process memory, so the
    // operator can see whether the event loop is under load (enrichQueued>0) or dropping work
    // (enrichDropped>0) BEFORE the service becomes unresponsive.
    // v9.32ae ED1: lane breakdown — highDropped must ALWAYS be 0 (invariant), lowDropped is the
    // honest graceful-degradation counter (alert on it only if it grows catastrophically).
    stability: {
      enrichConcurrency: ENRICH_CONCURRENCY(),
      enrichQueued: enrichQueuedTotal(),
      enrichDropped: enrichDroppedTotal(),
      enrichLanes: {
        highSlots: enrichHighSlots,
        highActive: enrichHighActive,
        highQueued: enrichHighQueued,
        highDropped: enrichHighDropped,
        lowQueued: enrichLowQueued,
        lowDropped: enrichLowDropped,
      },
      evmSseEnabled: EVM_SSE_ENABLED,
      dpReservesSseEnabled: DP_RESERVES_SSE_ENABLED,
      mem: (() => { try { const m = process.memoryUsage(); return { rssMb: Math.round(m.rss / 1024 / 1024), heapUsedMb: Math.round(m.heapUsed / 1024 / 1024) }; } catch { return null; } })(),
      uptimeSec: process.uptime(),
    },
    // v9.30: Automated on-chain shadow scan observability.
    shadowScan: getOracleRuntime().status(creditSnap),
    // bm2n: контур валидации выпущенных сделок (Фаза 1) — приёмка на проде по
    // cycles>0 / sampled>0 / unverified<30%.
    dealValidation: getDealValidationStatus(),
  };
}

export function getSseSample(chainId: string) {
  const st = state.chain[chainId];
  if (!st) return null;
  return { chain: chainId, connected: st.sseConnected, stats: st.sse, lastRawFrame: st.sse.lastRawFrame, lastParseSteps: st.sse.lastParseSteps };
}

export function getNotifications(): Notification[] {
  // v9.32ao-RC2 BP3: ретеншн на ЧТЕНИИ (источник дашборда = этот буфер через
  // /api/notifications). Раньше TTL-purge был только на вставке: в тишине/паузе
  // старьё лежало вечно и владелец видел «копление». Дефолты env-независимы (24ч/500).
  const now = Date.now();
  const ttlMs = NOTIFS_RETENTION_HOURS() * 3600_000;
  if (state.notifs.length > 0 && now - state.notifs[state.notifs.length - 1].ts > ttlMs) {
    state.notifs = state.notifs.filter(n => now - n.ts <= ttlMs);
  }
  const cap = NOTIFS_MAX_STORED();
  if (state.notifs.length > cap) state.notifs = state.notifs.slice(0, cap);
  return state.notifs;
}

export function clearNotifications(): void {
  state.notifs = [];
}
export function __pushNotificationForTest(notif: Notification): void { addNotification(notif); }
export function __notifsRetentionForTest(): { hours: number; maxStored: number } {
  return { hours: NOTIFS_RETENTION_HOURS(), maxStored: NOTIFS_MAX_STORED() };
}
// v9.32ao-RC3 П4: retention-мета для владельца — слой = ЭТОТ буфер (in-memory engine),
// /api/notifications отдаёт её рядом с items: count/oldestTs видно curl-ом без дебага.
export function getNotificationsRetentionMeta(): { hours: number; maxStored: number; count: number; oldestTs: number | null } {
  getNotifications(); // purge-on-read сначала — мета согласована с тем, что видит владелец
  return {
    hours: NOTIFS_RETENTION_HOURS(),
    maxStored: NOTIFS_MAX_STORED(),
    count: state.notifs.length,
    oldestTs: state.notifs.length ? state.notifs[state.notifs.length - 1].ts : null,
  };
}

export async function testTelegram(): Promise<{ ok: boolean; error?: string }> {
  return tgSend('🧪 Тест DEX Parser v3.9.8\n✅ Telegram подключён\n\nНовая модель алертов:\n• 🔴 SOLO: сделка ≥$30K — мгновенный алерт\n• ⚡ СЕРИЯ: 3+ сделок ≥$1K / 3мин — сводная карточка\n• Solana порог серий: $1K (единый)\n• Cooldown серии: 10мин / (chain,trader,token,side)\n\n✅ blockTs из источника (block_timestamp/block_executed_at)');
}

export function getLogs(): string[] {
  return logs;
}

let watchlistDirty = false;
let watchlistRestoring = false;
let watchlistSnapTimer: ReturnType<typeof setInterval> | null = null;
let watchlistStore: WatchlistSnapshotStore | null = null;
function markWatchlistDirty(): void {
  if (!watchlistRestoring) watchlistDirty = true;
}
async function flushWatchlistSnapshot(): Promise<void> {
  if (!watchlistStore) return;
  if (!watchlistDirty) return;
  watchlistDirty = false;
  void pruneAndFlushHotMints();
  try {
    const rows = buildSnapshot(
      Object.fromEntries(Object.keys(CHAINS).map(c => [c, state.chain[c]?.watchPools || []])),
      chainWatchMeta as never,
      Date.now(),
    );
    if (rows.length < 20) {
      log(`[snapshot] skip save: only ${rows.length} rows (guard against wiping a good snapshot)`);
      return;
    }
    await watchlistStore.saveAll(rows);
    log(`[snapshot] saved ${rows.length} pools`);
  } catch (e) {
    log(`[snapshot] save failed: ${(e as Error).message}`);
  }
}
function scheduleWatchlistSnapshotSave(store: WatchlistSnapshotStore): void {
  watchlistStore = store;
  if (watchlistSnapTimer) clearInterval(watchlistSnapTimer);
  watchlistSnapTimer = setInterval(() => { void flushWatchlistSnapshot(); }, 120_000);
  if (typeof watchlistSnapTimer === 'object' && watchlistSnapTimer && 'unref' in watchlistSnapTimer) {
    (watchlistSnapTimer as NodeJS.Timeout).unref();
  }
}

export function ensureBaseTablesSql(): string[] {
  return [
    `CREATE TABLE IF NOT EXISTS app_settings (key text PRIMARY KEY, value jsonb NOT NULL, updated_at timestamptz DEFAULT now() NOT NULL)`,
    `CREATE TABLE IF NOT EXISTS blacklist_rules (id text PRIMARY KEY, type text NOT NULL, chain text NOT NULL, value text NOT NULL, note text, enabled text DEFAULT 'true' NOT NULL, hits text DEFAULT '0' NOT NULL, updated_at timestamptz DEFAULT now() NOT NULL)`,
  ];
}
export async function ensureBaseTables(): Promise<void> {
  if (!process.env.DATABASE_URL) return;
  try {
    const { db } = await import('@/db');
    const { sql } = await import('drizzle-orm');
    for (const stmt of ensureBaseTablesSql()) {
      await db.execute(sql.raw(stmt));
    }
  } catch (e) {
    log(`[db] ensureBaseTables failed: ${(e as Error).message}`);
  }
}
const PARSER_AUTOSTART = () => (process.env.PARSER_AUTOSTART ?? '1') !== '0';
const PARSER_AUTOSTART_WATCHDOG_MS = () => Number(process.env.PARSER_AUTOSTART_WATCHDOG_MS || '60000');
// v9.32ao-RC1 BP1: оба флага в ядре — stop/watchdog любого бандля виден остальным
const stoppedManuallyRef = { get: () => engineKernel.stoppedManually, set: (v: boolean) => { engineKernel.stoppedManually = v; } };
let startParserHookForTest: (() => Promise<void>) | null = null;
export function __setStartParserHookForTest(fn: (() => Promise<void>) | null): void { startParserHookForTest = fn; }
export function __stoppedManuallyForTest(): boolean { return stoppedManuallyRef.get(); }
export function parserAutostartDecision(): 'start' | 'skip-running' | 'skip-manual' | 'skip-off' {
  if (!PARSER_AUTOSTART()) return 'skip-off';
  if (state.running) return 'skip-running';
  if (stoppedManuallyRef.get()) return 'skip-manual';
  return 'start';
}
export async function parserAutostartWatchdogTick(): Promise<boolean> {
  if (parserAutostartDecision() !== 'start') return false;
  log('[watchdog] started parser after boot-hook miss');
  if (startParserHookForTest) { await startParserHookForTest(); return true; }
  await startParser();
  return true;
}
export async function __parserAutostartWatchdogTickForTest(): Promise<boolean> {
  return parserAutostartWatchdogTick();
}
export function ensureAutostartWatchdog(): void {
  if (process.env.NODE_TEST_CONTEXT && process.env.WATCHDOG_ALLOW_IN_TEST !== '1') return;
  if (engineKernel.autostartWatchdogTimer) return; // v9.32ao-RC1 BP1: один страж на процесс
  const ms = Number(process.env.WATCHDOG_INTERVAL_MS || process.env.PARSER_AUTOSTART_WATCHDOG_MS || '60000');
  if (!(ms > 0)) return;
  engineKernel.autostartWatchdogTimer = setInterval(() => { void parserAutostartWatchdogTick(); }, ms);
  engineKernel.autostartWatchdogTimer.unref?.();
}
export function __stopAutostartWatchdogForTest(): void {
  if (engineKernel.autostartWatchdogTimer) { clearInterval(engineKernel.autostartWatchdogTimer); engineKernel.autostartWatchdogTimer = null; }
}
export async function bootParserAutostart(): Promise<void> {
  ensureAutostartWatchdog();
  if (!PARSER_AUTOSTART()) return;
  if (state.running) return;
  log('[autostart] parser started at boot');
  await startParser();
}
// v9.32bm1c T4 (bl BM4): тело health-таймера — отдельная функция, чтобы «фичу перестали вызывать»
// ловил тест проводки (test/solana-ws-timer-wiring.test.ts), а не 3 часа слепого канала на проде.
function solRpcHealthTick(nowMs?: number): void {
  const now = nowMs ?? solPubsubNow();
  void solanaRpcHealthPing();
  solPubsubSilentProbe();
  if (state.running && !state.paused) expireSolMentionPending(now); // v9.32bm1g H1: подписки без подтверждения — переподписать
  solPubsubPublicnodeReturnTick(now);
  heliusReserveLimiterTick(now); // v9.33bm1k Task 6: стоп резерва по CU/времени + WARN 60/80 % дня
  noteSolPubsubFrameSample(now); // v9.32bm1e E1: единственное место записи сэмплера скорости кадров
  if (state.running && !state.paused) solSoloRetrySweep(now); // v9.32bm1e E4a: TTL-дрен очереди re-dispatch
  solPubsubLiveness(now); // v9.32bm1 S3: ingestDown оценивается и без опроса /api/status
}
export function __solRpcHealthTickForTest(nowMs?: number): void { solRpcHealthTick(nowMs); }

// ── bm2n: контур валидации выпущенных сделок (Фаза 1, телеметрия без гейтов) ─────
// Специка: docs/superpowers/specs/2026-09-22-emitted-deal-validation-design.md.
// Модуль src/lib/deal-validation.ts автономный — ниже только адаптеры к транспортам
// движка и таймер. Поведение — только наблюдение: вердикты и счётчики, никакого
// влияния на выпуск алертов; сбой контура не должен задевать конвейер.
// §3.8: никаких setInterval < 300 с — период клампится снизу, как у оракула.
const DEAL_VALIDATION_MS = () => Math.max(300_000, Number(process.env.DEAL_VALIDATION_MS || '300000') || 300_000);

const DEAL_VALIDATION_TX_RE = /^0x[0-9a-fA-F]{64}$/;

// EVM: ресит (Transfer-логи + Swap-эмиттеры) и тело сделки (from/value для нативной
// монеты) — двумя вызовами по существующему транспорту анализа. Нет данных → null
// (модуль даст unverified, это не ошибка конвейера).
async function dealValidationEvmTx(chainId: string, txHash: string): Promise<EvmTxBare | null> {
  const url = RPC_ENDPOINTS[chainId];
  if (!url || !DEAL_VALIDATION_TX_RE.test(txHash)) return null;
  const call = async (id: number, method: string): Promise<any> => {
    const res = await evmAnalyzeFetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id, method, params: [txHash] }),
    }, 8000);
    const j = await (res as Response).json();
    return j?.result ?? null;
  };
  try {
    const [receipt, txd] = await Promise.all([
      call(1, 'eth_getTransactionReceipt'),
      call(2, 'eth_getTransactionByHash'),
    ]);
    if (!receipt?.logs) return null;
    return {
      from: String(txd?.from ?? ''),
      to: txd?.to ? String(txd.to) : null,
      value: typeof txd?.value === 'string' ? txd.value : '0x0',
      logs: (receipt.logs as Array<{ address?: string; topics?: string[]; data?: string }>).map((l) => ({
        address: String(l?.address ?? ''),
        topics: (l?.topics ?? []).map(String),
        data: String(l?.data ?? '0x'),
      })),
    };
  } catch {
    return null;
  }
}

function dealValidationSolBalances(list: unknown): SolTokenBalanceBare[] {
  if (!Array.isArray(list)) return [];
  return list.map((raw) => {
    const b = raw as { owner?: string; mint?: string; uiTokenAmount?: { uiAmount?: number | null; decimals?: number } };
    return {
      owner: b?.owner ? String(b.owner) : undefined,
      mint: String(b?.mint ?? ''),
      uiTokenAmount: {
        uiAmount: typeof b?.uiTokenAmount?.uiAmount === 'number' ? b.uiTokenAmount.uiAmount : null,
        decimals: Number(b?.uiTokenAmount?.decimals ?? 0),
      },
    };
  });
}

// Solana: существующий паттерн движка — jsonParsed getTransaction через solPubsubPost.
async function dealValidationSolTx(sig: string): Promise<SolTxBare | null> {
  const r = await solPubsubPost('getTransaction', [sig, { encoding: 'jsonParsed', maxSupportedTransactionVersion: SOL_TX_MAX_VERSION(), commitment: 'confirmed' }], 8000);
  const tx = r?.ok === false ? null : r?.result;
  if (!tx?.meta) return null;
  const keys = (tx?.transaction?.message?.accountKeys ?? []) as Array<{ pubkey?: string }>;
  return {
    meta: {
      preTokenBalances: dealValidationSolBalances(tx.meta.preTokenBalances),
      postTokenBalances: dealValidationSolBalances(tx.meta.postTokenBalances),
    },
    feePayer: keys[0]?.pubkey ? String(keys[0].pubkey) : undefined,
  };
}

// Маркеры модуля: 'NATIVE' → нативная монета запрошенной цепи, 'SOL' → SOL; иначе
// адрес/минт через существующий резолвер. Нет цены → null (сверка usd пропускается).
async function dealValidationPriceUsd(chainId: string, token: string): Promise<number | null> {
  const isSol = chainId.toLowerCase() === 'solana';
  const marker = token === 'NATIVE' || token === 'SOL';
  try {
    let p = 0;
    if (isSol && marker) p = await getSolPriceUsd();
    else if (isSol) p = await getSolTokenPriceUsd(token);
    else if (marker) p = getCachedNativePriceUsd(chainId);
    else p = (await fetchEvmTokenMeta(chainId, token)).priceUsd;
    return Number.isFinite(p) && p > 0 ? p : null;
  } catch {
    return null;
  }
}

// bm2n2: регистрация вынесена в экспорт — бандль роутов отдельный от бандля движка
// (см. ветку engineKernel.statusSnapshot в getStatus()), поэтому /api/debug/validate-deal
// обязан зарегистрировать зависимости сам: иначе deps остаются заглушками и эндпоинт
// отдаёт unverified на любой сделке.
//
// Дефолт, а не приказ: если deps уже зарегистрированы явно (тестовая инъекция),
// не перетираем их — иначе роут ломает тесты, подменяющие транспорт на фикстуры.
export function ensureDealValidationDeps(): void {
  if (isDealValidationDepsRegistered()) return;
  registerDealValidationDeps({
    evmTx: dealValidationEvmTx,
    solTx: dealValidationSolTx,
    priceUsd: dealValidationPriceUsd,
  });
}

// bm2n3 (спека Фазы 2, п. 2.2): для СЕРИЙ usd берётся из series[] по txHash сэмпла,
// а не агрегатом всей серии — реконструкция одна по последней сделке (n.txHash =
// lastCandidate.txHash), и сравнение её с суммой трёх сделок давало ложный
// mismatch:usd. Нетто-направление серии против одной сделки несравнимо → side=''
// (пустое ожидание модуль уже пропускает).
export function dealValidationSampleFromNotification(n: Notification): DealSample {
  const seriesTxCount = n.seriesTxCount ?? 0;
  const multiTx = seriesTxCount > 1;
  let usd = n.usd;
  let usdNoSeriesEntry = false;
  if (multiTx) {
    const entry = n.series?.find((s) => s.txHash === n.txHash);
    if (entry) usd = entry.usd;
    else usdNoSeriesEntry = true; // series[] нет/без этой сделки → сверяем как есть (агрегат)
  }
  return {
    ts: n.ts, chain: n.chain, txHash: n.txHash, side: multiTx ? '' : n.side, symbol: n.symbol,
    token: n.token, trader: n.trader, usd, pool: n.pool ?? null,
    seriesTxCount, usdNoSeriesEntry,
  };
}

function dealValidationSamples(): DealSample[] {
  // Сэмплы — последние выпущенные нотификации: кольцо уже содержит все нужные поля.
  return getNotifications().map(dealValidationSampleFromNotification);
}

async function dealValidationTick(): Promise<void> {
  try {
    await runDealValidationCycle(dealValidationSamples());
  } catch (e) {
    log(`[deal-validation] cycle error: ${(e as Error).message}`);
  }
}

export async function startParser(): Promise<void> {
  stoppedManuallyRef.set(false);
  if (state.running) return; // v9.32ao-RC1 BP1: running общий — второй бандль не поднимает второй движок
  localEngineStarted = true;
  // v9.32ao-RC2 BP1: этот бандль — бегун; публикуем транспортные хуки в kernel,
  // чтобы pause/resume из роут-бандлей доходил до НАШИХ сокетов.
  engineKernel.pauseTransport = pauseDisconnectAllWs;
  engineKernel.resumeTransport = liftWsAfterPause;
  ensureEvmWsDeadChainGuard(); // v9.32ap PD3: страж мёртвых evmWs-цепей
  state.running = true;
  getPauseLedger().closeUnknown(Date.now(), 'restarted-while-paused'); // W2 T2: no-op без открытого интервала
  state.paused = false; // v9.32am AM3: PARSER_AUTOSTART=1 — старт контейнера снимает паузу
  state.pausedAt = 0;
  state.startedAt = Date.now();
  evmRuntime.limits.startWarmup(); // v9.32al CK2: warmup-окно от старта парсера
  parserStartPhase = 'idle';
  startup.startedAt = Date.now();
  startup.initDoneAt = {};

  // R11-G5: Create root AbortController for this parser run
  runtime.rootAbortController = new AbortController();

  // R11-J3: Increment native price generation to invalidate stale in-flight requests
  incrementNativePriceGeneration();

  log('🚀 Parser v3.9.8 started (Two-Level Alerts: solo≥$30K + series 3×$1K+/3min)');
  void ensureBaseTables();
  logAlchemyKeyFingerprint(process.env.ALCHEMY_API_KEY || ALCHEMY_API_KEY);
  await initSettings();
  await initBlacklist();
  // v9.32f: restore organic watchlist from snapshot BEFORE discovery — post-restart coverage
  // in seconds (incident: $39.7K CVX missed 6.6 min after restart with watchlist at 113/… pools).
  const snapStore = createWatchlistSnapshotStore();
  if (snapStore) {
    try {
      watchlistRestoring = true;
      const rows = await snapStore.loadAll();
      const res = applySnapshot(rows, null, { now: Date.now(), ttlMs: 48 * 3600_000, capPerChain: 250, chains: Object.keys(CHAINS) },
        (_acc, chain, pool, meta) => {
          const m = (meta && typeof meta === 'object') ? meta as Record<string, unknown> : {};
          seedWatchPool(
            chain,
            pool,
            String(m.subjectToken || ''),
            String(m.subjectSymbol || '?'),
            String(m.quoteSymbol || '?'),
            Number(m.reserveUsd) || 0,
            Number(m.vol24hUsd) || 0,
          );
        });
      startup.snapshotRestoredAt = Date.now();
      log(`[snapshot] restored ${res.applied} pools (ttl-skipped ${res.skippedTtl}, cap-skipped ${res.skippedCap})`);
    } catch (e) {
      log(`[snapshot] load failed (continuing cold): ${(e as Error).message}`);
    } finally {
      watchlistRestoring = false;
    }
    scheduleWatchlistSnapshotSave(snapStore);
  } else {
    startup.snapshotRestoredAt = Date.now();
    startup.snapshot = process.env.WATCHLIST_SNAPSHOT_DISABLED === '1' ? 'disabled:env-flag' : 'disabled:no-db';
    startup.snapshotRestoredPools = 0;
    log('[snapshot] disabled (no DATABASE_URL or WATCHLIST_SNAPSHOT_DISABLED=1)');
  }
  try { await loadHotMintsAtBoot(); } catch (e) { log(`[hotmints] load failed (continuing cold): ${(e as Error).message}`); }
  if (!process.env.NODE_TEST_CONTEXT) await initTelegramFromEnv();
  // v9.32e: real-time sockets FIRST — anchors are static (manifest), subscriptions are
  // anchor-first (selectSolPubsubPools / evmWsTopPools), so they don't need discovery to warm up.
  // Discovery then grows watchlists incrementally (resub hooks already exist). initAllChains stays
  // sequential AFTER this to respect GT rate limits — it only feeds the ORGANIC watchlist now.
  if (!process.env.NODE_TEST_CONTEXT) {
    // v9.32ao-RC1 BP3: персист миграций (ws_pool_polls, fail-open ≤1 SELECT) загружается ДО
    // первых EVM-WS-подписок — иначе рестарт заново платит 10-минутное окно миграции для
    // болтливых адресов (0x498581ff…: 240 кадров/мин ≈ 85% кадрового CU на каждом рестарте).
    await ensureWsPollLoaded();
    startSolPubsub().catch(e => log(`[solpubsub] early start failed: ${(e as Error).message}`));
    for (const c of Object.keys(CHAINS).filter(isEVMChain)) if (evmWsEnabled(c)) startEvmWs(c);
    // v9.32af FD1: hot-address pollers run independently of the WS connection — they REPLACE
    // the token/router push subscriptions, so they must start even if evmWs is disabled.
    startEvmHotPollers();
  }
  cexCache.updatedAt = 0;
  if (!process.env.NODE_TEST_CONTEXT) {
    await refreshCexCache();
    if (settings.cexFilterEnabled) runCexSelfTestInternal();
    try { await diagnoseCreditTierAtStartup(); } catch { /* never block startup */ }
  }
  if (process.env.NODE_TEST_CONTEXT) cexCache.updatedAt = Date.now();
  // v9.32o: poll workers MUST start before initAllChains (incident FOLD $10K in the 18-min
  // init window). Snapshot restore already populated the watchlist; tick builds the queue.
  if (process.env.NODE_TEST_CONTEXT) rebuildPollQueue();
  else await tick();
  startPollWorkers();
  parserStartPhase = 'workers-started';
  startup.pollWorkersStartedAt = Date.now();
  startup.socketsStartedAt = startup.socketsStartedAt || Date.now();
  if (!process.env.NODE_TEST_CONTEXT) {
    void (initAllChainsImpl || initAllChains)()
      .then(() => { parserStartPhase = 'init-done'; })
      .catch(e => log(`[init] background init failed: ${(e as Error).message}`));
    // D4: resolve subject/quote for env anchors in the background (non-blocking).
    resolveEnvAnchorSubjects().catch(e => log(`[anchor] env subject resolve error: ${(e as Error).message}`));
    runtime.timer = setInterval(() => { tick(); }, settings.pollIntervalSec * 1000);
  }
  // v9.26 F1: real-time OnchainTrade WebSocket (gated by env + key; no-op when off).
  if (!process.env.NODE_TEST_CONTEXT) {
    startGtOnchainWs().catch(e => log(`[gtws] start at parser start failed: ${(e as Error).message}`));
    startSolPubsub().catch(e => log(`[solpubsub] start at parser start failed: ${(e as Error).message}`));
    startEvmWsStaggered(EVM_WS_SUPPORTED_CHAINS.filter(c => evmWsEnabled(c)));
  }
  const pingMs = Number(process.env.SOL_RPC_HEALTH_PING_MS || '60000');
  if (!process.env.NODE_TEST_CONTEXT) {
  // v9.31 Round 5: runtime anchor validation worker (non-blocking background)
  void runRuntimeAnchorValidation({ signal: runtime.rootAbortController?.signal }).catch(e => {
    if (e.name !== 'AbortError') {
      log(`[anchor-validation] worker error: ${(e as Error).message}`);
    }
  });
  // v9.31 Round 8: preload native prices for all EVM chains (non-blocking)
  void preloadNativePrices().catch(e => log(`[native-price] preload error: ${(e as Error).message}`));
  // Periodic native price refresh (every 55 seconds, before 60s TTL expires)
  runtime.nativePriceRefreshTimer = setInterval(() => {
    void preloadNativePrices({ forceRefresh: true }).catch(() => {});
  }, 55_000);
  runtime.cexRefreshTimer = setInterval(async () => {
    cexCache.updatedAt = 0;
    await refreshCexCache();
  }, 6 * 60 * 60 * 1000);
  getOracleRuntime().start(); // B4.3: the Oracle runtime owns its single interval and unref lifecycle
  // bm2n: контур валидации выпущенных сделок (Фаза 1) — рядом с оракулом, свой таймер.
  // Оракул мерит полноту («снаружи внутрь»), этот контур — корректность выпущенного
  // («изнутри наружу»). Телеметрия без гейтов: выпуск алертов не затрагивается.
  ensureDealValidationDeps(); // bm2n2: та же регистрация, что зовёт роут validate-deal
  runtime.dealValidationTimer = setInterval(() => {
    void dealValidationTick();
  }, DEAL_VALIDATION_MS());
  runtime.dealValidationTimer.unref?.();
  // v9.32am HOTFIX+ (MD1): периодический страж доставки — dead-reserve чинится сам, без оператора
  if (!runtime.deliveryWatchTimer) {
    runtime.deliveryWatchTimer = setInterval(() => deliveryRecoveryTick(), 60_000);
    runtime.deliveryWatchTimer.unref?.();
  }
  ensureSolReserveAlive('start');
  // v9.32ao-RC1 BP1: этот инстанс — бегун: публикуем снапшот статуса для других бандлей
  runtime.statusSnapshotTimer = setInterval(() => {
    try { engineKernel.statusSnapshot = getStatus(); engineKernel.statusFreshAt = Date.now(); } catch { /* fail-open */ }
  }, 10_000);
  runtime.statusSnapshotTimer.unref?.();
  }
  if (!process.env.NODE_TEST_CONTEXT) {
    await loadHeliusDayCu(); // v9.32bm1c T1: кап дня обязан «знать» вчерашний/сегодняшний расход до первого кадра
    heliusSpendPersist.timer = setInterval(() => { void flushHeliusSpend(); }, HELIUS_SPEND_FLUSH_MS());
    heliusSpendPersist.timer.unref?.();
    if (process.listenerCount('SIGTERM') === 0) process.once('SIGTERM', () => { void flushHeliusSpend(true); });
    // TZ-b2 R3: тот же контракт для денежного дня Alchemy — рестарт не обнуляет сутки.
    await evmRuntime.startSpendPersistence();
  }
  if (pingMs > 0 && !process.env.NODE_TEST_CONTEXT) {
    runtime.solRpcHealthTimer = setInterval(() => { solRpcHealthTick(); }, pingMs); // v9.32bm1c T4: тело вынесено — проводка стала тестируемой (bl BM4); v9.32bl BL1, v9.32bm1 S3
    runtime.solRpcHealthTimer.unref?.();
  }
}

// ─────────────── Shadow Scan Oracle (v9.30) ───────────────
// Continuously compares on-chain DEX trade execution vs the parser engine to identify any
// subtle discrepancy or false negative before users notice.
export async function runShadowScanCycle(): Promise<{
  checked: number;
  matches: number;
  validFiltered: number;
  discrepancies: number;
  entries: OracleShadowScanEntry[];
}> {
  return getOracleRuntime().runCycle();
}
/** Test hook (bm1l): прямой доступ к runtime-owned реестру покрытия для unit-тестов. */
export function __isPoolCoveredForTest(chainId: string, pool: string): boolean {
  return getOracleRuntime().test.isPoolCovered(chainId, pool);
}
function makeOracleRuntimeDeps(): OracleRuntimeDeps {
  function readCoverageState(mode: 'pool'): OracleCoverageState;
  function readCoverageState(mode: 'pipeline'): OraclePipelineCoverageState;
  function readCoverageState(mode: 'pool' | 'pipeline'): OracleCoverageState | OraclePipelineCoverageState {
    const chainIds = mode === 'pipeline' ? Object.keys(state.chain) : null;
    const coverageState: OracleCoverageState = {
      solanaResolvedPools: solReserveResolved,
      solanaActiveReserveAccounts: solReserveActiveAccount,
      solanaFallbackPools: solCoveredPools,
      solanaSubscribedPools: solPubsubSubscribed,
      evmWsChains,
    };
    return mode === 'pipeline' ? { chainIds: chainIds ?? [], ...coverageState } : coverageState;
  }
  return {
    now: () => Date.now(),
    sleep: ms => new Promise<void>(resolve => {
      const timer = setTimeout(resolve, ms);
      (timer as { unref?: () => void }).unref?.();
    }),
    setInterval: (callback, ms) => setInterval(callback, ms),
    clearInterval: handle => clearInterval(handle),
    env: name => process.env[name],
    log: message => log(message),
    addError: message => addError(message),
    listEnabledChains: () => {
      getPauseLedger();
      return Object.keys(CHAINS).filter(chainId => state.chain[chainId]?.enabled);
    },
    readChainConfig: chainId => {
      const chain = CHAINS[chainId];
      if (!chain) return null;
      return {
        dpNetwork: chain.dpNetwork,
        gecko: chain.gecko,
        ds: chain.ds,
        isEvm: isEVMChain(chainId),
        rpcEndpoint: RPC_ENDPOINTS[chainId],
      };
    },
    readWatchPools: chainId => state.chain[chainId]?.watchPools ?? null,
    readSettingValue: name => Reflect.get(settings, name) as unknown,
    readPoolDexId: (chainId, pool) => poolInfoCache.get(chainId + ':' + lower(pool))?.dexId
      || poolInfoCache.get(lower(pool))?.dexId
      || null,
    readFirstPass: (chainId, txHash) => firstPassByKey.get(firstPassKey(chainId, txHash)) || null,
    readOldestFirstPass: () => {
      const oldestKey = firstPassOrder[0];
      const oldest = oldestKey ? firstPassByKey.get(oldestKey) : undefined;
      return oldest ? { at: oldest.at } : null;
    },
    readFirstPassCapacity: () => FIRST_PASS_LOG_SIZE(),
    readFirstPassCount: () => firstPassOrder.length,
    arbitrumDiagnosticEpoch: () => analyzeTelemetry.arbEpoch(),
    readOracleCounters: () => oracleCounterSnapshot(),
    readEvmPoolAnalyzed: (chainId, pool) => analyzeTelemetry.readPoolAnalyzed(chainId, pool),
    readEvmPoolMeta: (chainId, pool) => chainWatchMeta[chainId]?.[lower(pool)],
    cacheGtPool: input => cacheGtPool(input.chainId, input.pool, input.included, input.address, input.volume24h, input.reserveUsd),
    isPoolInvalidForAlert: (chainId, pool) => poolIsInvalidForAlert(chainId, pool),
    normalizePoolAddress: (chainId, address) => normAddr(chainId, address),
    whyTx: (chainId, txHash) => whyTx(chainId, txHash),
    // W1-T7: оракул читает следы конвейера по подписи. Журнал bounded (cap + TTL), пишут его
    // сами Solana-ветки — ручной список tx для диагностики больше не нужен.
    readSolanaTxTrace: (txHash) => {
      const recs = solTxAttempts.attemptsFor(txHash, Date.now());
      if (!recs.length) return null;
      return {
        analysisIds: Array.from(new Set(recs.map(r => r.analysisId))),
        rpcAttemptIds: recs.map(r => r.attemptId),
        attempts: recs.map(r => ({ attempt: r.attempt, outcome: r.outcome })),
      };
    },
    isHeliusBudgetExceeded: () => heliusBudgetExceeded(),
    solanaPublicRpc: () => solanaPublicRpc(),
    evmPublicRpcPoolBase: chainId => evmPublicRpcPoolBase(chainId),
    evmParoleWindowMs: () => analyzeTelemetry.paroleWindowMs(),
    readCoverageState,
    fetchHttp: (target, init, timeoutMs) => fetchWithTimeout(
      typeof target === 'string' ? target
        : target.kind === 'dexpaprika-pool'
          ? `https://api.dexpaprika.com/networks/${encodeURIComponent(target.network)}/pools/${encodeURIComponent(target.pool)}`
          : `https://api.dexscreener.com/latest/dex/pairs/${target.network}/${target.pool}`,
      init,
      timeoutMs,
    ),
    withAnonymousGtLatch: (path, request) => anonymousGtChannel().run(path, request),
    fetchPrimaryResponse: input => fetchWithTimeout(
      `https://api.dexpaprika.com/networks/${input.dpNetwork}/pools/${input.pool}/transactions?limit=100`,
      { headers: oraclePrimaryDpAuthHeaders() },
      4000,
    ),
    readCreditSnapshot: () => {
      const snapshot = creditController().snapshot();
      return {
        lastApiPlan: snapshot.lastApiPlan,
        uncountedDailyBySource: { oracle: snapshot.uncountedDailyBySource.oracle },
      };
    },
    recordOracleCredit: () => recordUncountedCreditUse(1, 'oracle'),
    readCoinGeckoDemoAccess: () => {
      const key = coingeckoDemoApiKey();
      return {
        configured: key.length > 0,
        fingerprint: key ? coingeckoDemoKeyFp(key) : null,
        fetchTrades: input => fetchWithTimeout(
          coingeckoTradesUrl(input.network, input.pool),
          { headers: coingeckoDemoAuthHeaders(key) },
          4000,
        ),
      };
    },
    withDsOtherGate: async request => {
      const gate = dsGate('other');
      if (!gate.allowed) return null;
      const result = await request();
      dsNoteResult('other', result ? 200 : 0);
      return result;
    },
    readLegacyGtAuthMode: () => process.env.COINGECKO_PRO_API_KEY ? 'key' : 'anon',
    readPauseReadinessContext: (windowStartMs, windowEndMs, earliestEventAt) => {
      const ledger = getPauseLedger();
      ledger.tick(windowEndMs);
      const cur = ledger.current();
      const snap = ledger.snapshot(windowEndMs);
      const paused = Boolean(state.paused || cur !== null);
      let pauseOverlapMs = 0;
      let hasUnknownOutcomeInWindow = false;
      for (const iv of snap.intervals) {
        if (iv.outcome === 'backfilled') continue;
        const ivStart = iv.pausedAt;
        const ivEnd = iv.resumedAt ?? windowEndMs;
        if (ivEnd >= windowStartMs && ivStart <= windowEndMs) {
          const overlap = Math.max(1, Math.min(windowEndMs, ivEnd) - Math.max(windowStartMs, ivStart));
          pauseOverlapMs += overlap;
          if (iv.outcome === 'unknown') hasUnknownOutcomeInWindow = true;
        }
      }
      if (paused && pauseOverlapMs === 0) {
        const start = state.pausedAt > 0 ? state.pausedAt : (cur?.pausedAt ?? windowEndMs);
        pauseOverlapMs = Math.max(1, windowEndMs - Math.max(windowStartMs, start));
      }
      const windowPredatesObserving = earliestEventAt !== null && earliestEventAt < snap.observingSince;
      const previousProcessUnknown = snap.previousProcessUnknown && (
        paused || pauseOverlapMs > 0 || hasUnknownOutcomeInWindow || windowPredatesObserving
      );
      return {
        paused,
        pauseOverlapMs,
        previousProcessUnknown,
      };
    },
  };
}

/** Test hook (bm1l): установить содержимое Solana-реестров покрытия целиком (резервы, меншены). */
export function __setSolCoverageForTest(resolved: string[], subscribed: string[], coveredFallback: string[] = [], activeReserves: string[] = []): void {
  solReserveResolved.clear();
  solPubsubSubscribed.clear();
  solCoveredPools.clear(); // bm1x: реконнект/сброс — покрытие обнуляется вместе с ws-дедупом
  // bm1y: адрес резерва = сам адрес пула (упрощение для тестов правила «резолв × подписка»)
  for (const p of resolved) solReserveResolved.set(p, {
    baseReserve: p, quoteReserve: p, quoteMint: '', baseMint: '', baseDec: 0, quoteDec: 0, quotePriceUsd: 0,
  });
  for (const p of subscribed) solPubsubSubscribed.add(p);
  for (const p of coveredFallback) solCoveredPools.add(p);
  __setSolReserveActiveForTest(activeReserves);
}
/** Test hook (bm1y): резолв-запись с явными адресами резервов (base/quote могут различаться). */
export function __setSolReserveRecForTest(pool: string, baseReserve: string, quoteReserve: string): void {
  solReserveResolved.set(pool, {
    baseReserve, quoteReserve, quoteMint: '', baseMint: '', baseDec: 0, quoteDec: 0, quotePriceUsd: 0,
  });
}
/** Test hook (bm1y): сидинг активных reserve-подписок (subId фейковые — важно только членство). */
export function __setSolReserveActiveForTest(addrs: string[]): void {
  solReserveActiveAccount.clear();
  for (const a of addrs) solReserveActiveAccount.set(a, 900000 + solReserveActiveAccount.size);
}
/** Test hook (bm1y): модель обрыва сокета — активные подписки сняты, резолвы живут. */
export function __clearSolReserveActiveForTest(): void { solReserveActiveAccount.clear(); }
/** Test hook (bm1y): сидинг подтверждённых EVM-подписок (ключи chain:addr:standard, lower — как прод). */
export function __setEvmConfirmedForTest(chainId: string, addrs: string[]): void {
  const st = evmWsChains[chainId] || (evmWsChains[chainId] = createEvmWsChainState());
  st.confirmed.clear();
  for (const a of addrs) st.confirmed.add(getEvmSubscriptionTargetKey(chainId, a, 'standard'));
}
/** Test hook (bm1x): прямой сидинг реестра покрытых fallback-пулов. */
export function __setSolCoveredPoolsForTest(pools: string[]): void {
  solCoveredPools.clear();
  for (const p of pools) solCoveredPools.add(p);
}
export function __setPoolDexForTest(chainId: string, pool: string, dexId: string | null): void {
  const key = chainId + ':' + lower(pool);
  if (dexId === null) { poolInfoCache.delete(key); poolInfoCache.delete(lower(pool)); return; }
  const info: PoolInfo = { address: pool, baseToken: { address: '', symbol: '?', name: '?' }, quoteToken: { address: '', symbol: '?', name: '?' }, liquidityUsd: 0, fdv: 0, cachedAt: Date.now(), dexId };
  poolInfoCache.set(key, info);
}

/** Read-only observation export: never starts a scan or supplies a reference denominator. */
export function getOracleCandidateCapture() { return getOracleRuntime().candidateCapture(); }

export function __frameDropRingForTest() { return getOracleRuntime().test.frameDropRing(); }
export function __frameDropsSnapshotForTest() { return getOracleRuntime().test.frameDropsSnapshot(); }
export function __attributedByDropForTest(): Record<string, number> { return getOracleRuntime().test.attributedByDrop(); }
export function __seedOracleMissForTest(
  chain: string,
  tx: string,
  rec: Parameters<OracleRuntime['test']['seedMiss']>[2],
): void { getOracleRuntime().test.seedMiss(chain, tx, rec); }
export function __seedOraclePendingLogForTest(
  record: Parameters<OracleRuntime['test']['seedPendingLog']>[0],
): void { getOracleRuntime().test.seedPendingLog(record); }
export function __oracleNoteTooOldForTest(chain: string, ageMs: number): void { getOracleRuntime().test.noteTooOld(chain, ageMs); }
export function __oracleSampleJudgableForTest(
  blockTs: number | null | undefined,
  now: number,
): ReturnType<OracleRuntime['test']['sampleJudgable']> { return getOracleRuntime().test.sampleJudgable(blockTs, now); }
export function __oracleMissLedgerForTest(chain: string, tx: string) { return getOracleRuntime().test.missLedgerGet(chain, tx); }
export function __setOraclePolicyExcludesForTest(excludes: string[] | null): void { getOracleRuntime().test.setPolicyExcludes(excludes); }
export function __setOraclePolicyForTest(raw: string | null): void { getOracleRuntime().test.setPolicy(raw); }
export function __runShadowScanCycleForTest(): ReturnType<typeof runShadowScanCycle> { return runShadowScanCycle(); }
export function __coverageForTest() { return getOracleRuntime().test.coverage(); }
export function __shadowScanIntervalMsForTest(): number { return getOracleRuntime().test.intervalMs(); }
export function __resetShadowScanForTest(): void { getOracleRuntime().resetForTest(); }
export function __oraclePoolStatsForTest() { return getOracleRuntime().test.poolStats(); }
export function __seedOraclePoolStatsForTest(chainId: string, count: number): void { getOracleRuntime().test.seedPoolStats(chainId, count); }
export function __setOraclePoolStatForTest(stat: Parameters<OracleRuntime['test']['setPoolStat']>[0]): void {
  getOracleRuntime().test.setPoolStat(stat);
}
export function __shadowScanStateForTest() { return getOracleRuntime().test.shadowScanState(); }
export function __dpPlanGateForTest() { return getOracleRuntime().test.dpPlanGate(); }
export function __resetDpPlanGateForTest(): void { getOracleRuntime().test.resetDpPlanGate(); }
export function __gtDemoFallbackForTest() { return getOracleRuntime().test.gtDemoFallback(); }
export function __resetGtDemoFallbackForTest(): void { getOracleRuntime().test.resetGtDemoFallback(); }

function runCexSelfTestInternal() {
  log('[cex-selftest] Running CEX filter self-test on startup...');
  getCexSelfTest();
}

// ─────────────── Enrich Test (debug endpoint) ───────────────
// BUG C FIX: Accept trader address parameter for position check.
// If no trader provided, position is skipped (returns null with explanation).
// balanceUsd=0 → pctSupply=0 (no mismatch).
export async function fetchEnrichTest(chainId: string, tokenAddr: string, traderAddr?: string): Promise<{
  chain: string; token: string; trader: string | null; liquidityInfo: unknown; positionInfo: unknown;
  enrichmentErrors: string[]; durationMs: number;
  note?: string;
}> {
  const t0 = Date.now();
  const errors: string[] = [];
  let liqInfo = null;
  let posInfo = null;
  const effectiveTrader = traderAddr && traderAddr.length >= 10 ? traderAddr : null;

  try {
    liqInfo = await fetchLiquidityInfo(chainId, tokenAddr);
    if (!liqInfo) errors.push(`No liquidity data returned for ${tokenAddr} on ${chainId}`);
  } catch (e) {
    errors.push(`fetchLiquidityInfo error: ${(e as Error).message}`);
  }

  if (effectiveTrader) {
    try {
      posInfo = await fetchPositionInfo(chainId, tokenAddr, effectiveTrader);
      // BUG C FIX: If balance=0, position is null (not {balanceUsd:0, pctSupply:non-zero})
      // fetchPositionInfo already returns null when balRawBig===0, so this is safe.
      if (!posInfo) {
        errors.push(`No position: trader ${shortAddr(effectiveTrader)} holds 0 tokens of ${shortAddr(tokenAddr)} on ${chainId}`);
      }
    } catch (e) {
      errors.push(`fetchPositionInfo error: ${(e as Error).message}`);
    }
  }

  return {
    chain: chainId,
    token: tokenAddr,
    trader: effectiveTrader,
    liquidityInfo: liqInfo,
    positionInfo: posInfo,
    enrichmentErrors: errors,
    durationMs: Date.now() - t0,
    note: !effectiveTrader ? 'Add &trader=0x... to check position for a specific trader address' : undefined,
  };
}

// ── v9.32am AM3 (LD4/AL3/AL6): пауза вместо стопа. ──
// Анализ (соло/series-конвейер, evmWs-кадры, reserve-пары, hotPoll-поллинг) заморожен;
// события паузы НЕ дропаются молча — счётчик pausedStats.dropped; WS-подписки, кэши и
// hotPoll-курсоры живут. Reserve-нотификации на паузе обновляют baseline сырых сумм —
// после resume дельты считаются от актуального baseline (нет фантомных «LP add/remove»).
// Resume = полная работа без пере-разогрева (подписки не рвались).
const pausedStats = engineKernel.pausedStats; // v9.32ao-RC1 BP1: счётчики паузы общие для бандлей
// W2: журнал интервалов паузы (src/lib/pause-ledger.ts). Только текущий процесс; персистентности нет (D-W2-2).
const PAUSE_LEDGER_MAX_INTERVALS = 20;
// Нечисловое/нефинитное/<1000 мс ⇒ дефолт (ревью PR #251): иначе NaN выключал бы переход open→unknown и показ gap-ов.
const PAUSE_SETTLE_MAX_MS = () => finiteEnvNumber(process.env.PAUSE_SETTLE_MAX_MS, 1_800_000, 1000);
const PAUSE_GAP_RETENTION_MS = () => finiteEnvNumber(process.env.PAUSE_GAP_RETENTION_MS, 86_400_000, 1000);
export const __pauseLedgerCfgForTest = (): { settleMaxMs: number; gapRetentionMs: number } =>
  ({ settleMaxMs: PAUSE_SETTLE_MAX_MS(), gapRetentionMs: PAUSE_GAP_RETENTION_MS() });
/**
 * ТЗ 2026-10-13 D1: ЕДИНЫЙ расчёт pause-контекста readiness. Его используют и runner
 * (deps.readPauseReadinessContext при свежем расчёте), и cached-status ветка getStatus(),
 * которая обязана немедленно перепубликовать паузу внутри снимка другого бандла.
 * backfilled-интервалы окно не портят; unknown/open с пересечением — делают его несудимым.
 */
function pauseReadinessContext(windowStartMs: number, windowEndMs: number, earliestEventAt: number | null): { paused: boolean; pauseOverlapMs: number; previousProcessUnknown: boolean } {
  const ledger = getPauseLedger();
  ledger.tick(windowEndMs);
  const cur = ledger.current();
  const snap = ledger.snapshot(windowEndMs);
  const paused = Boolean(state.paused || cur !== null);
  let pauseOverlapMs = 0;
  let hasUnknownOutcomeInWindow = false;
  for (const iv of snap.intervals) {
    if (iv.outcome === 'backfilled') continue;
    const ivStart = iv.pausedAt;
    const ivEnd = iv.resumedAt ?? windowEndMs;
    if (ivEnd >= windowStartMs && ivStart <= windowEndMs) {
      const overlap = Math.max(1, Math.min(windowEndMs, ivEnd) - Math.max(windowStartMs, ivStart));
      pauseOverlapMs += overlap;
      if (iv.outcome === 'unknown') hasUnknownOutcomeInWindow = true;
    }
  }
  if (paused && pauseOverlapMs === 0) {
    const start = state.pausedAt > 0 ? state.pausedAt : (cur?.pausedAt ?? windowEndMs);
    pauseOverlapMs = Math.max(1, windowEndMs - Math.max(windowStartMs, start));
  }
  const windowPredatesObserving = earliestEventAt !== null && earliestEventAt < snap.observingSince;
  const previousProcessUnknown = snap.previousProcessUnknown && (
    paused || pauseOverlapMs > 0 || hasUnknownOutcomeInWindow || windowPredatesObserving
  );
  return { paused, pauseOverlapMs, previousProcessUnknown };
}

function getPauseLedger(): PauseLedger {
  return (engineKernel.pauseLedger ??= createPauseLedger({
    maxIntervals: PAUSE_LEDGER_MAX_INTERVALS, settleMaxMs: PAUSE_SETTLE_MAX_MS(), gapRetentionMs: PAUSE_GAP_RETENTION_MS(),
  }, Date.now()));
}
function pauseStatusDoc(now: number) {
  const ledger = getPauseLedger();
  return { current: ledger.current(), ...ledger.snapshot(now) };
}
// W2 T3: источники покрытия интервала паузы и их начальный исход.
//  - evm:<c>:hotlist: getLogs-догон от курсора на момент паузы; исход решается в evmHotPollCycle (settlePauseHotlist);
//  - evm:<c>:ws: пулы с живой WS-подпиской исключены из хот-листа (dual-coverage GD2) и при паузе отписаны —
//    кадры окна не реплеятся нигде → coverage_gap с временными границами;
//  - solana:pubsub: у pubsub нет replay → coverage_gap (backfill Solana — вне W2).
function pauseResumeSources(): Array<Omit<import('./pause-ledger').PauseSourceCoverage, 'fromMs' | 'toMs'>> {
  const out: Array<Omit<import('./pause-ledger').PauseSourceCoverage, 'fromMs' | 'toMs'>> = [];
  const cursors = engineKernel.pauseCursors ?? {};
  for (const c of EVM_WS_SUPPORTED_CHAINS) {
    const hot = { source: `evm:${c}:hotlist`, kind: 'evm_hotlist_getlogs' as const, toBlock: null };
    if (!evmHotPollEnabled()) out.push({ ...hot, fromBlock: null, outcome: 'unknown', detail: 'hotpoll-disabled' });
    else {
      const info = cursors[c];
      if (info?.empty) out.push({ ...hot, fromBlock: null, outcome: 'backfilled', detail: 'hotlist-empty' });
      else if (!info || info.cursor === null) out.push({ ...hot, fromBlock: null, outcome: 'unknown', detail: 'no-cursor-at-pause' });
      else out.push({ ...hot, fromBlock: info.cursor + 1, outcome: 'open', detail: '' });
    }
    if (evmWsTransportImpl || evmWsEnabled(c)) {
      out.push({ source: `evm:${c}:ws`, kind: 'evm_ws_pools', fromBlock: null, toBlock: null, outcome: 'coverage_gap', detail: 'ws-only-pools-not-replayed' });
    }
  }
  if (getSolanaPubsub().__solPubsubEnabledForTest()) {
    out.push({ source: 'solana:pubsub', kind: 'solana_pubsub', fromBlock: null, toBlock: null, outcome: 'coverage_gap', detail: 'pubsub-no-replay' });
  }
  return out;
}
// W2 T3: вызывается в конце evmHotPollCycle. Закрывает источник `evm:<c>:hotlist` ТОЛЬКО по доказательству журнала покрытия.
function settlePauseHotlist(chainId: string, head: number, rewoundTo: number | null): void {
  const ledger = getPauseLedger();
  const source = `evm:${chainId}:hotlist`;
  const refs = ledger.openSourceRefs(source);
  if (refs.length === 0) return;
  const heads = (engineKernel.pauseHeadsAtResume ??= {});
  const snap = evmHotCoverage.snapshotAt(chainId, hotPollNow());
  const now = Date.now();
  for (const ref of refs) {
    const key = `${ref.intervalId}:${chainId}`;
    if (heads[key] === undefined) heads[key] = head;
    const headAtResume = heads[key];
    const from = ref.fromBlock ?? 0;
    let outcome: 'backfilled' | 'coverage_gap' | null = null;
    let detail = '';
    let toBlock: number | null = null;
    if (rewoundTo !== null && ref.fromBlock !== null && rewoundTo >= ref.fromBlock) {
      outcome = 'coverage_gap'; detail = 'cursor-rewound'; toBlock = rewoundTo;
    } else {
      const terminal = snap.gaps.find(g => g.state === 'terminal' && g.toBlock >= from && g.fromBlock <= headAtResume);
      const openInRange = snap.gaps.some(g => g.toBlock >= from && g.fromBlock <= headAtResume);
      if (terminal) { outcome = 'coverage_gap'; detail = `ledger-gap:${terminal.fromBlock}-${terminal.toBlock}`; toBlock = null; }
      else if (!openInRange && snap.checkpoint !== null && snap.checkpoint >= headAtResume) { outcome = 'backfilled'; detail = 'checkpoint>=head-at-resume'; toBlock = snap.checkpoint; }
    }
    if (outcome) {
      ledger.settle(ref.intervalId, source, outcome, detail, toBlock, now);
      delete heads[key];
    }
  }
}
export function pauseParser(meta?: { reason?: string; actor?: string }): boolean {
  if (!state.running || state.paused) return state.paused;
  state.paused = true;
  state.pausedAt = Date.now();
  getPauseLedger().open(state.pausedAt, { reason: meta?.reason ?? 'manual', actor: meta?.actor ?? 'api' });
  engineKernel.pauseCursors = JSON.parse(JSON.stringify(engineKernel.evmHotPauseInfo ?? {}));
  engineKernel.pauseHeadsAtResume = {};
  // v9.32ao-RC2 BP1: закрываем транспорт ЛОКАЛЬНО и, если бегун — другой бандл (его
  // сокеты нам не видны), дёргаем его kernel-хук. Иначе пауза из роута глушит только
  // свой пустой стейт, а движок продолжает платить CU (поймано живым RC-чеком).
  pauseDisconnectAllWs();
  if (engineKernel.pauseTransport && engineKernel.pauseTransport !== pauseDisconnectAllWs) engineKernel.pauseTransport();
  log('⏸ Parser paused (analysis frozen, WS unsubscribed — pause costs ~0 CU; resume instead of stop)');
  return true;
}
// v9.32ao-RC2 BP1: пауза глушит ИСТОЧНИК, а не только обработку. Прод-инцидент RC1:
// 2 368 кадров/мин при paused:true — Alchemy тарифицирует доставку (~15 CU/кадр),
// 2M CU за ран. Пауза = shutdown всех transport'ов; реконнекты в паузе запрещены
// (guards в scheduleEvmWsReconnect/scheduleSolPubsubReconnect/solPubsubEnabled).
function pauseDisconnectAllWs(): void {
  for (const chainId of Object.keys(evmWsChains)) {
    const st = evmWsChains[chainId];
    if (st.reconnectTimer) { clearTimeout(st.reconnectTimer); st.reconnectTimer = null; }
    if (st.retryTimer) { clearInterval(st.retryTimer); st.retryTimer = null; }
    if (st.socket) { try { (st.socket as { close: () => void }).close(); } catch { /* уже мёртв */ } }
    // v9.32ap PD3: НЕ ждём асинхронный close-эвент — стейт обнуляем сами. Иначе висящий
    // CONNECTING/мёртвый сокет оставлял st.socket truthy и resume не поднимал цепь
    // (прод: conn=false на всех 5 сетях после pause→resume, attempts=1, кадров 0).
    st.socket = null;
    st.connected = false;
    resetEvmWsChainStateOnReconnect(st);
  }
  getSolanaPubsub().clearReconnectTimer();
  stopSolPubsub();
}
// v9.32ao-RC1 BP1: живые pause-поля из ядра — роуты паузы не должны читать 10-с снапшот
export function getPauseState(): { paused: boolean; pausedAt: number; pausedDropped: number } {
  return { paused: state.paused, pausedAt: state.pausedAt, pausedDropped: pausedStats.dropped };
}
export function resumeParser(): boolean {
  if (!state.paused) return false;
  state.paused = false;
  parserResumedAt = Date.now(); // v9.33bm1k: возраст событий для правила ingest считается от резюма
  // W2 T3: исход по источникам. Reconnect/resubscribe покрытие НЕ закрывает — только settle в hot-poll-цикле.
  const resumedInterval = getPauseLedger().close(parserResumedAt, pauseResumeSources());
  if (resumedInterval) {
    const doc = resumedInterval.sources.map(x => `${x.source}=${x.outcome}${x.detail ? '(' + x.detail + ')' : ''}`).join(' ');
    log(`[pause] interval #${resumedInterval.id} closed: ${doc}`);
  }
  log(`▶ Parser resumed (paused ${state.pausedAt ? Math.round((Date.now() - state.pausedAt) / 1000) : 0}s, dropped ${pausedStats.dropped} events)`);
  state.pausedAt = 0;
  liftWsAfterPause();
  if (engineKernel.resumeTransport && engineKernel.resumeTransport !== liftWsAfterPause) engineKernel.resumeTransport();
  return true;
}
// v9.32am HOTFIX+ (MD1) → v9.32ao-RC2 BP1: подъём транспорта после паузы-отписки.
// Идемпотентно; вызывается и локально, и kernel-хуком бегуна (другой бандл).
function liftWsAfterPause(): void {
  ensureSolReserveAlive('resume');
  let reup = 0;
  for (const c of Object.keys(CHAINS).filter(isEVMChain)) {
    if (!(evmWsTransportImpl || evmWsEnabled(c))) continue;
    let st = evmWsChains[c];
    // v9.32ap PD3: висящий CONNECTING/мёртвый сокет — форс-закрыть и поднять заново:
    // старый код skip-ал по st.socket truthy и цепь умирала до рестарта (AP7).
    if (st?.socket && !st.connected) {
      try { (st.socket as { close: () => void }).close(); } catch { /* уже мёртв */ }
      st.socket = null;
    }
    if (!st) { evmWsChains[c] = createEvmWsChainState(); st = evmWsChains[c]; }
    if (!st.socket && !st.reconnectTimer) { startEvmWs(c); reup++; }
  }
  if (reup) log(`[evmws] resume: переподнимаю ${reup} соединений после паузы-отписки`);
}

// v9.32ap PD3: страж мёртвой цепи — running && !paused && conn=false дольше 2 мин → форс-подъём.
// Покрывает класс «висящий CONNECTING» (нет ни error, ни open) и любые незамеченные смерти.
const EVM_WS_DEAD_CHAIN_GUARD_MS = () => Number(process.env.EVM_WS_DEAD_CHAIN_GUARD_MS || '120000');
let evmWsDeadChainGuardTimer: ReturnType<typeof setInterval> | null = null;
export async function evmWsDeadChainGuardTick(): Promise<number> {
  if (!state.running || state.paused) return 0;
  let revived = 0;
  for (const c of EVM_WS_SUPPORTED_CHAINS) {
    if (!(evmWsTransportImpl || evmWsEnabled(c))) continue;
    const st = evmWsChains[c];
    if (!st || st.connected) continue;
    const dead = st.deadSince || 0;
    if (!dead || Date.now() - dead <= EVM_WS_DEAD_CHAIN_GUARD_MS()) continue;
    log(`[evmws] ⚠️ ${c}: conn=false уже ${Math.round((Date.now() - dead) / 1000)}с при running — форс-подъём (PD3-страж)`);
    if (st.socket) { try { (st.socket as { close: () => void }).close(); } catch { /* мёртв */ } st.socket = null; }
    if (st.reconnectTimer) { clearTimeout(st.reconnectTimer); st.reconnectTimer = null; }
    st.deadSince = 0; // рестарт отсчёта (новая попытка либо поднимет, либо снова состарится)
    startEvmWs(c);
    revived++;
  }
  return revived;
}
export function __evmWsDeadChainGuardTickForTest(): Promise<number> { return evmWsDeadChainGuardTick(); }
export function __setEvmWsDeadSinceForTest(chainId: string, ts: number): void {
  const st = evmWsChains[chainId] || (evmWsChains[chainId] = createEvmWsChainState());
  st.deadSince = ts;
}
function ensureEvmWsDeadChainGuard(): void {
  if (process.env.NODE_TEST_CONTEXT && process.env.DEAD_CHAIN_GUARD_ALLOW_IN_TEST !== '1') return;
  if (evmWsDeadChainGuardTimer) return;
  evmWsDeadChainGuardTimer = setInterval(() => { void evmWsDeadChainGuardTick(); }, 30_000);
  evmWsDeadChainGuardTimer.unref?.();
}
function stopEvmWsDeadChainGuard(): void {
  if (evmWsDeadChainGuardTimer) { clearInterval(evmWsDeadChainGuardTimer); evmWsDeadChainGuardTimer = null; }
}
export function __pausedStatsForTest(): { dropped: number; hotPollSkipped: number } { return { ...pausedStats }; }
export function __resetPausedForTest(): void {
  state.paused = false; state.pausedAt = 0;
  pausedStats.dropped = 0; pausedStats.hotPollSkipped = 0;
  getPauseLedger().__reset();
  engineKernel.pauseLedger = undefined;
}
// v9.32bm1i T6 test hook: backdate the pause start (deliveryHealth 'paused' verdict needs > 5 min).
export function __setPausedAtForTest(ms: number): void { state.pausedAt = ms; }
// v9.32ao-RC2 BP1: тест-доступ к транспортным kernel-хукам (пин: пауза из «роут-бандла»
// обязана дёрнуть хук бегуна — иначе CU течёт при живых сокетах другого инстанса).
export function __getKernelPauseTransportForTest(): (() => void) | null { return engineKernel.pauseTransport; }
export function __setKernelPauseTransportForTest(fn: (() => void) | null): void { engineKernel.pauseTransport = fn; }
export function __getKernelResumeTransportForTest(): (() => void) | null { return engineKernel.resumeTransport; }
export function __setKernelResumeTransportForTest(fn: (() => void) | null): void { engineKernel.resumeTransport = fn; }

export function stopParser(): void {
  stopEvmHotPollers(); // v9.32af FD1
  if (state.running) stoppedManuallyRef.set(true);
  if (!state.running) return;
  // v9.32ao-RC2 BP1: бегун уходит — снимаем транспортные хуки, если они наши
  if (engineKernel.pauseTransport === pauseDisconnectAllWs) engineKernel.pauseTransport = null;
  if (engineKernel.resumeTransport === liftWsAfterPause) engineKernel.resumeTransport = null;
  stopEvmWsDeadChainGuard(); // v9.32ap PD3
  getPauseLedger().closeUnknown(Date.now(), 'stopped-while-paused'); // W2 T2: интервал не остаётся open навсегда
  state.running = false;
  state.paused = false; // v9.32am AM3: stopped — пауза не имеет смысла
  state.pausedAt = 0;
  log('⏹ Parser stopped');

  // R11-G5: Abort root controller to cancel all in-flight operations
  if (runtime.rootAbortController) {
    runtime.rootAbortController.abort();
    runtime.rootAbortController = null;
  }

  // R11-J3: Increment native price generation to invalidate stale in-flight requests
  incrementNativePriceGeneration();

  if (runtime.timer) { clearInterval(runtime.timer); runtime.timer = null; }
  if (runtime.deliveryWatchTimer) { clearInterval(runtime.deliveryWatchTimer); runtime.deliveryWatchTimer = null; } // v9.32am HOTFIX+
  if (runtime.cexRefreshTimer) { clearInterval(runtime.cexRefreshTimer); runtime.cexRefreshTimer = null; }
  getOracleRuntime().stop(); // B4.3: clear the kernel-owned Oracle interval from any facade bundle
  if (runtime.dealValidationTimer) { clearInterval(runtime.dealValidationTimer); runtime.dealValidationTimer = null; } // bm2n
  if (runtime.solRpcHealthTimer) { clearInterval(runtime.solRpcHealthTimer); runtime.solRpcHealthTimer = null; }
  if (heliusSpendPersist.timer) { clearInterval(heliusSpendPersist.timer); heliusSpendPersist.timer = null; } // v9.32bm1c T1
  void flushHeliusSpend(true);
  evmRuntime.stopSpendPersistence();
  void evmRuntime.spend.flush(true); // хвост денежного дня не теряется на остановке парсера
  if (runtime.nativePriceRefreshTimer) { clearInterval(runtime.nativePriceRefreshTimer); runtime.nativePriceRefreshTimer = null; }
  for (const chainId of Object.keys(runtime.sseAbortControllers)) runtime.sseAbortControllers[chainId]?.abort();
  // v9.26 F1: close the real-time WS on stop.
  stopGtOnchainWs();
  // v9.26 F2: close the Solana pubsub on stop.
  stopSolPubsub();
  // v9.29: close the EVM Alchemy WS on stop.
  stopEvmWs();
  // v9.31 Round 7: clear metadata enrichment retry timer and anchor revalidation timer
  stopEvmMetaEnrichmentQueue();
  stopAnchorValidationScheduler();
  if (watchlistSnapTimer) { clearInterval(watchlistSnapTimer); watchlistSnapTimer = null; }
  void flushWatchlistSnapshot();
}

// ─────────────── Verify Freshness (ТЗ debug endpoint) ───────────────
export async function verifyFreshness(n?: number): Promise<Array<{
  txHash: string; chain: string; symbol: string; side: string;
  blockTsCard: number | null; blockTsChain: number | null;
  diffSec: number | null; pass: boolean; error?: string;
}>> {
  const notifs = state.notifs.slice(0, n || 10);
  const results = [];
  for (const notif of notifs) {
    const cardBlockTs = notif.blockTs ?? (notif.series?.[0]?.blockTs ?? null);
    let chainBlockTs: number | null = null;
    let error: string | undefined;
    try {
      const chain = notif.chain;
      const txHash = notif.txHash;
      if (chain !== 'solana' && txHash && RPC_ENDPOINTS[chain]) {
        const rpc = RPC_ENDPOINTS[chain];
        const res = await fetchWithTimeout(rpc, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionByHash', params: [txHash] }),
        }, 8000);
        const txData = await res.json();
        const blockNum = txData?.result?.blockNumber;
        if (blockNum) {
          const blkRes = await fetchWithTimeout(rpc, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'eth_getBlockByNumber', params: [blockNum, false] }),
          }, 8000);
          const blkData = await blkRes.json();
          const ts = blkData?.result?.timestamp;
          if (ts) chainBlockTs = parseInt(ts, 16);
        }
      }
    } catch (e) {
      error = (e as Error).message;
    }
    const diffSec = (cardBlockTs !== null && chainBlockTs !== null) ? Math.abs(cardBlockTs - chainBlockTs) : null;
    results.push({
      txHash: notif.txHash,
      chain: notif.chain,
      symbol: notif.symbol,
      side: notif.side,
      blockTsCard: cardBlockTs,
      blockTsChain: chainBlockTs,
      diffSec,
      pass: diffSec !== null ? diffSec <= 5 : false,
      error,
    });
  }
  return results;
}

// ─────────────── Trade Sample (Bug 3 fix: GET /api/debug/trade-sample) ───────────────
export function getTradeSample(chainId?: string): {
  chain: string;
  rawTrade: Record<string, unknown>;
  mappedEvent: Record<string, unknown>;
  note?: string;
} | null {
  if (chainId) {
    // Try per-chain storage first
    const byChain = state.lastRealTradeByChain[chainId];
    if (byChain) return { chain: byChain.chain, rawTrade: byChain.trade, mappedEvent: byChain.parsed };
    // Fall back to global if chain matches
    const entry = state.lastRealTrade;
    if (entry && entry.chain === chainId) return { chain: entry.chain, rawTrade: entry.trade, mappedEvent: entry.parsed };
    // Return global with note that requested chain has no trades yet
    if (state.lastRealTrade) return {
      chain: state.lastRealTrade.chain,
      rawTrade: state.lastRealTrade.trade,
      mappedEvent: state.lastRealTrade.parsed,
      note: `No trade seen yet for chain '${chainId}', showing last trade from chain '${state.lastRealTrade.chain}'`
    };
    return null;
  }
  const entry = state.lastRealTrade;
  if (!entry) return null;
  return { chain: entry.chain, rawTrade: entry.trade, mappedEvent: entry.parsed };
}

// ─────────────── Watchlist debug endpoint (ТЗ v5 Defect 2) ───────────────
let watchlistBuildAt: Record<string, number> = {};
const WATCHLIST_BUILD_TTL = 5 * 60 * 1000;

// Ensure a chain's watchlist is populated (self-heal for debug/why-tx when a prior
// discovery was rate-limited). Bounded and cached to respect GeckoTerminal limits.
async function ensureWatchlistBuilt(chainId: string): Promise<void> {
  const st = state.chain[chainId];
  if (!st) return;
  ensureAnchorsApplied(chainId); // anchors always present regardless of GT state
  const now = Date.now();
  const metaEmpty = !chainWatchMeta[chainId] || Object.keys(chainWatchMeta[chainId]).length === 0;
  const stale = (watchlistBuildAt[chainId] || 0) < now - WATCHLIST_BUILD_TTL;
  if (!metaEmpty && !stale) return;
  watchlistBuildAt[chainId] = now;
  try {
    const pools = await discoverPoolsForChain(chainId);
    if (pools.length > 0) {
      st.watchPools = pools;
      st.initStatus = 'ready';
      st.lastError = null;
    }
  } catch { /* keep previous */ }
}

// Seed/pin a pool into a chain's watchlist + metadata (used for deterministic testing and
// manual pinning). Real discovery augments this set; this guarantees coverage independently.
export function seedWatchPool(
  chainId: string,
  address: string,
  subjectToken: string,
  subjectSymbol: string,
  quoteSymbol: string,
  reserveUsd: number,
  volume24h: number,
): void {
  const addr = normAddr(chainId, address);
  if (!chainWatchMeta[chainId]) chainWatchMeta[chainId] = {};
  markWatchlistDirty();
  chainWatchMeta[chainId][lower(addr)] = {
    address: addr,
    subjectToken: normAddr(chainId, subjectToken),
    subjectSymbol,
    quoteSymbol,
    reserveUsd,
    vol24hUsd: volume24h,
    source: reserveUsd > 0 ? 'both' : 'volume',
  };
  const st = state.chain[chainId];
  if (st && !st.watchPools.some(a => lower(a) === lower(addr))) {
    st.watchPools.push(addr);
  }
  if (isEVMChain(chainId) && isValidEvmAddress(address)) {
    queueEvmPoolMetaEnrichment(chainId, address, undefined, subjectToken, quoteSymbol);
  }
}

// v9.32ak AK4: атрибуция пула settlement-сделки по Transfer-логам чека. CoW/Fusion/UniswapX
// исполняют через промежуточные хранилища и не эмитят Swap-события на вотчпулах — но quote
// приходит В вотчпул (buy) или уходит ИЗ него (sell). Приоритет: stable-quote нога.
export function attributeWatchPoolFromReceiptLogs(
  chainId: string,
  logs: Array<{ address: string; topics: string[]; data: string }>,
  subjectAddr: string,
): { pool: string; side: 'buy' | 'sell' } | null {
  const pools = new Set(watchedPoolsForSubject(chainId, subjectAddr).map(p => lower(p)));
  if (!pools.size) return null;
  let fallback: { pool: string; side: 'buy' | 'sell' } | null = null;
  for (const lg of logs) {
    const t = lg.topics || [];
    if (t.length < 3 || lower(t[0]) !== lower(TRANSFER_TOPIC)) continue;
    const from = lower('0x' + String(t[1]).slice(26));
    const to = lower('0x' + String(t[2]).slice(26));
    const stableLeg = isStablecoin(lower(lg.address || ''), '');
    if (pools.has(to)) { const hit = { pool: to, side: 'buy' as const }; if (stableLeg) return hit; if (!fallback) fallback = hit; }
    if (pools.has(from)) { const hit = { pool: from, side: 'sell' as const }; if (stableLeg) return hit; if (!fallback) fallback = hit; }
  }
  return fallback;
}
// Watched pools of a given subject token (used by why-tx subjectWatchlistPools).
function watchedPoolsForSubject(chainId: string, subjectAddr: string): string[] {
  const meta = chainWatchMeta[chainId] || {};
  const watch = state.chain[chainId]?.watchPools || [];
  const out: string[] = [];
  for (const addr of watch) {
    const m = meta[lower(addr)];
    if (m && m.subjectToken && lower(m.subjectToken) === lower(subjectAddr)) out.push(m.address);
  }
  return out;
}

/** Test hooks for TZ v6 unit tests (deterministic, no network). */
export function __resetWatchlistForTest(chainId: string): void {
  const st = state.chain[chainId];
  if (st) {
    st.watchPools = [];
    st.watchPoolsDiscovery = [];
    st.watchPoolsAnchors = [];
    st.discoveryTokensTried = 0;
    st.discoveryTokensWithPools = 0;
    st.discovery429s = 0;
    st.discoveryCandidates = 0;
    st.discoveryCursorPos = 0;
    st.discoveryDsFallbacks = 0;
    st.poolPollEmptySubjects = 0;
    st.priorityTokensQueued = 0;
    st.discoveryGtAttempted = 0;
    st.discoveryGtOk = 0;
    st.discoveryGt429 = 0;
    st.discoveryGtBudgetSkip = 0;
    st.discoveryDsAttempted = 0;
    st.discoveryDsOk = 0;
    st.discoveryDsEmpty = 0;
    st.discoveryDsEmptyFresh = 0;
    st.discoveryDsEmptyRescan = 0;
    st.discoveryCandidatesFromDs = 0;
    st.discoveryCycles = 0;
    for (const k of [...discoveryQuarantine.keys()]) if (k.startsWith(chainId + ':')) discoveryQuarantine.delete(k);
    for (let i = discoveryPoolsAdded.length - 1; i >= 0; i--) if (discoveryPoolsAdded[i].chainId === chainId) discoveryPoolsAdded.splice(i, 1);
    discoveryDsCandidateHits.length = 0;
    for (const k of [...discoverySeenTokens]) if (k.startsWith(chainId + ':')) discoverySeenTokens.delete(k);
    resetDsEmptyLogForTest();
    st.discoveryStalledCycles = 0;
    st.lastDiscoveryAt = 0;
    st.lastDiscoveryError = null;
  }
  chainWatchMeta[chainId] = {};
  watchlistBuildAt[chainId] = 0;
  for (const k of [...lastGtPollAt.keys()]) if (k.startsWith(chainId + ':')) lastGtPollAt.delete(k);
  poolInfoCache.clear();
  dsPoolCache.clear();
  // v9.32j: the base-layer TTL cache is per-chain global state — without this, the next test in the
  // same process silently takes the cached path and never calls the trending_pools mock.
  baseLayerCache.delete(chainId);
}
export function __watchlistPoolsForSubject(chainId: string, subjectAddr: string): string[] {
  return watchedPoolsForSubject(chainId, subjectAddr);
}
/** Test hook: run one discovery cycle directly (advances the round-robin cursor). */
export async function __runDiscoveryCycleForTest(chainId: string): Promise<string[]> {
  return discoverPoolsForChain(chainId);
}
/** Test hook: re-run settings init (uses injected DB settings) — simulates a process restart. */
export async function __reinitSettingsForTest(): Promise<string> {
  await initSettings();
  return settingsSource;
}
/** Test hook: seed a recent real trade for a subject (drives priority ordering in v8). */
export function __setLastTradeForTest(chainId: string, subjectAddr: string): void {
  state.lastRealTradeByChain[chainId] = {
    chain: chainId,
    trade: {},
    parsed: { subjectAddr: normAddr(chainId, subjectAddr) } as unknown as Record<string, unknown>,
  };
}
export function __rebuildPollQueueForTest(): number {
  rebuildPollQueue();
  return globalPollQueue.length;
}
// P1 (v9.5) test hooks for the parallel poll worker pool.
export function __nextPollItemForTest(opts?: { gtOnly?: boolean }): { chain: string; poolIdx: number } | null {
  return nextPollItem(opts);
}
export function __setWatchPoolsAnchorsForTest(chainId: string, anchors: string[]): void {
  const st = state.chain[chainId];
  if (st) st.watchPoolsAnchors = anchors.map(a => normAddr(chainId, a));
}
export function __setLastGtPollAtForTest(chainId: string, pool: string, at: number): void {
  lastGtPollAt.set(gtPollKey(chainId, pool), at);
}
export function __pollWorkersForTest(): number {
  return POLL_WORKERS;
}
export function __pollDsLimitForTest(): number {
  return DS_POLL_LIMIT;
}
export function __pollGtCeilingForTest(): number {
  return POLL_GT_CEILING;
}
export function __pollPerChainLimitForTest(): number {
  return POLL_PER_CHAIN_PER_MIN;
}
export function __seedChainPollTicksForTest(chain: string, count: number): void {
  const st = state.chain[chain];
  if (!st) return;
  const now = Date.now();
  st.pollTicks1m = [];
  for (let i = 0; i < count; i++) st.pollTicks1m.push(now);
}
export function __pollGtBudgetAvailableForTest(): boolean {
  return pollGtBudgetAvailable();
}
// v9.23 F1 test hooks: per-chain discovery DS fair-share + GT base-layer ceiling.
export function __dsPerChainLimitForTest(): number { return DS_PER_CHAIN_PER_MIN; }
export function __dsChainAvailableForTest(chainId: string): boolean { return dsChainBudgetAvailable(chainId); }
export function __saturateDsChainForTest(chainId: string): void {
  const now = Date.now();
  dsChainBudget[chainId] = [];
  for (let i = 0; i < DS_PER_CHAIN_PER_MIN; i++) dsChainBudget[chainId].push(now);
}
export function __clearDsChainForTest(chainId: string): void { dsChainBudget[chainId] = []; }
export function __geckoBaseLayerLimitForTest(): number { return GECKO_BASELAYER_LIMIT; }
export function __saturatePollDsForTest(): void {
  const now = Date.now();
  pollDsBudget.length = 0;
  for (let i = 0; i < DS_POLL_LIMIT; i++) pollDsBudget.push(now);
}
export function __clearPollDsForTest(): void {
  pollDsBudget.length = 0;
}
export function __pollDsSaturatedForTest(): boolean {
  cleanPollDsBudget();
  return pollDsBudget.length >= DS_POLL_LIMIT;
}
export function __pollTasksCompletedForTest(): number {
  return pollTasksCompletedAt.filter(t => t > Date.now() - 60000).length;
}
export function __recordPollTaskForTest(): void {
  recordPollTask();
}
// v9.7 I1 test hooks: DexPaprika REST fallback source.
// v9.9 FK1: the effective per-min REST limit is keyed-limit when a DEXPAPRIKA_API_KEY is present,
// else the keyless 30/min cap.
export function __dpPerMinLimitForTest(): number {
  return getDexPaprikaApiKey() ? DEXPAPRIKA_REST_PER_MIN_KEYED : DEXPAPRIKA_REST_PER_MIN;
}
export function __dpPerMinLimitKeyedForTest(): number {
  return DEXPAPRIKA_REST_PER_MIN_KEYED;
}
export function __dpExtractRestForTest(tx: Record<string, unknown>, chainId: string) {
  return extractSubjectFromDpRestTransaction(tx, chainId);
}
export function __setDpEnabledForTest(enabled: boolean): void {
  setSettings({ ...settings, dexPaprika: { ...settings.dexPaprika, enabled } }); // v9.32as SD1
}
export function __dpRateUsedForTest(): number {
  cleanBudget();
  cleanDpMetrics();
  return requestBudget.dexpaprika.length;
}
export function __saturateDpForTest(): void {
  const now = Date.now();
  requestBudget.dexpaprika.length = 0;
  const limit = getDexPaprikaApiKey() ? DEXPAPRIKA_REST_PER_MIN_KEYED : DEXPAPRIKA_REST_PER_MIN;
  for (let i = 0; i < limit; i++) requestBudget.dexpaprika.push(now);
}
export function __dpFallbackMarkedForTest(): boolean {
  return Date.now() < dpFallbackUntil;
}
export function __setDpFallbackForTest(untilMs: number): void {
  dpFallbackUntil = untilMs;
}
export function __dpCallsReceivedForTest(): { calls: number; received: number } {
  cleanDpMetrics();
  return { calls: dpCalls1m.length, received: dpReceived1m.length };
}
export function __setLastGtQuotaSkipForTest(ts: number): void {
  lastGtQuotaSkipAt = ts;
}
export function __resetDpStateForTest(): void {
  dpFallbackUntil = 0;
  lastGtQuotaSkipAt = 0;
  requestBudget.dexpaprika.length = 0;
  dpCalls1m = [];
  dpReceived1m = [];
  dpSeenTxs.clear();
  dpAccepted = 0;
}
// v9.8 F2 test hooks: DexPaprika SSE reserves (Solana push).
export function __dpExtractReserveForTest(frame: Record<string, unknown>, chainId: string) {
  return extractSubjectFromDpReserve(frame, chainId);
}
export function __buildReserveSubsForTest(chainId: string, pools: string[]) {
  return buildReserveSubscriptions(chainId, pools);
}
export function __solanaSseConstantsForTest(): { topPools: number; minVolUsd: number; maxAgeSec: number } {
  return { topPools: SOLANA_SSE_TOP_POOLS, minVolUsd: SOLANA_SSE_MIN_VOL_USD, maxAgeSec: SOLANA_SSE_MAX_AGE_SEC };
}
export function __dpReservesMetricsForTest(chainId: string) {
  return getDpReservesMetrics(chainId);
}
export function __resetDpReservesForTest(): void {
  for (const k of Object.keys(dpReservesSse)) delete dpReservesSse[k];
  if (state.chain['solana']) {
    state.chain['solana'].sseConnected = false;
    state.chain['solana'].source = 'poll';
  }
}
// v9.13 F1/F2/F3 test hooks.
export function __dpSseConfigForTest(): { topN: number; refreshMs: number } {
  return { topN: settings.dexPaprika?.sse?.topN ?? 25, refreshMs: settings.dexPaprika?.sse?.refreshIntervalMs ?? 0 };
}
export function __parallelWorkersForTest(): number {
  return PARALLEL_WORKERS;
}
export function __pollInFlightForTest(chainId: string): number {
  return pollInFlightPerChain[chainId] || 0;
}
export function __isColdStartCatchUpStaleForTest(blockTsSec: number): boolean {
  return isColdStartCatchUpStale(blockTsSec);
}
export function __startupCatchUpStaleForTest(): number {
  return startupCatchUpStale;
}
export function __setStartedAtForTest(tsMs: number): void {
  state.startedAt = tsMs;
}
export function __resetStartupCatchUpForTest(): void {
  startupCatchUpStale = 0;
  state.startedAt = Date.now();
}
export function __sseReconnectTimerForTest(chainId: string): boolean {
  return !!sseReconnectTimers[chainId];
}
// v9.18 F1: expose the reserves-SSE connect attempt for a regression test. It is driven through the
// real tryConnectReservesSSE with global.fetch mocked, so the test proves that on a 200 SSE response
// the connect completes (sets connected=true) instead of hanging on res.text() of the event stream.
export async function __tryConnectReservesSSEForTest(chainId: string): Promise<void> {
  return tryConnectReservesSSE(chainId);
}
// v9.20: expose free-tier-limit detection + state for tests.
export function __isFreeTierSseRateLimitForTest(status: number, bodyText: string): boolean {
  return isFreeTierSseRateLimit(status, bodyText);
}
export function __freeTierLimitedForTest(chainId: string): boolean {
  return !!sseState.freeTierLimited[chainId];
}
export function __resetSseStateForTest(): void {
  sseState.backoffMs = {};
  sseState.lastConnectAttempt = {};
  sseState.freeTierLimited = {};
}
// v9.14 F1 test hooks: enrichment-gate config + pressure metrics.
export function __enrichConfigForTest(): { concurrency: number; queueMax: number; highSlots: number } {
  return { concurrency: ENRICH_CONCURRENCY(), queueMax: ENRICH_QUEUE_MAX(), highSlots: enrichHighSlots };
}
export function __enrichPressureForTest(): { active: number; queued: number; dropped: number } {
  return { active: enrichActive, queued: enrichQueuedTotal(), dropped: enrichDroppedTotal() };
}
// v9.32ae ED1: lane-level stats (spec: enrichStats.{highQueued, highActive, lowDropped, highDropped}).
export function __enrichLaneStatsForTest(): {
  highSlots: number; highActive: number; highQueued: number; highDropped: number; lowQueued: number; lowDropped: number;
} {
  return {
    highSlots: enrichHighSlots, highActive: enrichHighActive, highQueued: enrichHighQueued,
    highDropped: enrichHighDropped, lowQueued: enrichLowQueued, lowDropped: enrichLowDropped,
  };
}
export function __resetEnrichForTest(): void {
  enrichHighSlots = computeEnrichHighSlots(); // re-read env (tests flip ENRICH_HIGH_LANE_RATIO)
  enrichActive = 0; enrichHighActive = 0;
  enrichHighQueued = 0; enrichHighDropped = 0;
  enrichLowQueued = 0; enrichLowDropped = 0;
  enrichHighWaiters.length = 0;
  enrichLowWaiters.length = 0;
}
export async function __withEnrichGateForTest<T>(fn: () => Promise<T>, opts?: { high?: boolean }): Promise<T | undefined> {
  return withEnrichGate(fn, opts);
}

export function __evmSseEnabledForTest(): boolean {
  return EVM_SSE_ENABLED;
}
export function __dpReservesSseEnabledForTest(): boolean {
  return DP_RESERVES_SSE_ENABLED;
}
export function __setDpReservesSseForTest(on: boolean): void {
  DP_RESERVES_SSE_ENABLED = on;
}
// v9.15 F-AC test hooks: credit budget state, tier, and pickers. Tests run under node:test
// (NODE_TEST_CONTEXT set) so the in-memory budget is fully isolated per test file.
// B4.1: состояние переехало в credit-controller, поэтому каждый шов — ТОНКИЙ делегат в единственный
// контроллер процесса (engineKernel.credit). Имена, сигнатуры и смыслы не изменились; нового
// production-API facade не добавляет (внутренний test-API контроллера наружу не экспортируется).
export function __creditTierForTest(): DexPaprikaCreditTier { return creditController().test.getTier(); }
export function __setCreditTierForTest(t: DexPaprikaCreditTier): void { creditController().test.setTier(t); }
export function __creditResetForTest(): void { creditController().resetForTest(); }
export function __creditUseForTest(kind: 'rest' | 'sse', count: number, source?: CreditSource): void { recordCreditUse(kind, count, source); }
// bm2e: доступ к разбивке и сброс дневного бакета для детерминированных тестов.
export function __creditBySourceForTest(): Record<CreditSource, number> { return creditController().test.bySource(); }
export function __creditUncountedBySourceForTest(): Record<CreditSource, number> { return creditController().test.uncountedBySource(); }
export function __creditSpendTotalsForTest(): { restCalls: number; sseEvents: number } { return creditController().test.totals(); }
export function __resetCreditSpendForTest(): void { creditController().test.clearSpend(); }
export function __uncountedCreditUseForTest(count: number, source: CreditSource): void { recordUncountedCreditUse(count, source); }
// v9.20 F1: seed a PRIOR day's spend so we can verify usedDailyToday rolls to 0 when the UTC date
// changes. Mirrors the internal daily bucket used by todayCreditSpend()/creditsUsedToday().
export function __creditSeedPriorDayForTest(dateKey: string, restCalls: number, sseEvents: number): void {
  creditController().test.seedPriorDay(dateKey, restCalls, sseEvents);
}
export function __creditDateKeyForTest(): string { return creditController().test.dateKey(); }
export function __creditsUsedTodayForTest(): number { return creditController().test.usedToday(); }
export function __creditsLeftTodayForTest(): number { return creditController().test.leftToday(); }
export function __creditsFractionForTest(): number { return creditController().test.remainingFraction(); }
export function __creditRunwayForTest(): number | null { return creditController().test.runwayDays(); }
// b12 R5: шов «сколько часов живёт процесс» — без него ветку «полные сутки» в тесте не достать.
export function __setProcessStartedAtForTest(ts: number): void { processStartedAt = ts; }
export function __processStartedAtForTest(): number { return processStartedAt; }
export function __setRateLimitHeaderForTest(remaining: number): void { creditController().test.setRateLimitHeader(remaining); }
export function __creditEvalForTest(force = false): void { evaluateCreditTier(force); }
export function __creditHardErrorForTest(status: number, remaining?: number): void { onCreditHardError(status, remaining); }
export function __creditConfigForTest(): { cap: number; dailyBudget: number; fallbackPct: number; freezePct: number; restoreTier: string } {
  return creditController().test.config();
}
export function __setMonthlyCreditCapForTest(cap: number): void {
  setSettings({ ...settings, dexPaprika: { ...settings.dexPaprika, monthlyCreditCap: cap } }); // v9.32as SD1
}
export function __evmTopNForTest(): number { return creditController().snapshot().evmTopN; }
export function __solanaTopNForTest(): number { return creditController().snapshot().solanaTopN; }
export function __pickSolanaPoolsForTest(): string[] { return pickSolanaReservePools(); }
// v9.15 F-AC: pure chunking helper for the ≤25-subscription SSE limit (unit-tested).
export function __chunkSseSubsForTest(pools: string[], max: number): string[][] { return chunkSseSubscriptions(pools, max); }
// v9.16 F-CE: test hooks for the smart forecast + burn-rate + tx-hash validator.
export function __burnRateForTest(): number { return creditController().test.burnRatePerMin(); }
export function __clearBurnForTest(): void { creditController().test.clearBurn(); }
export function __evalTierSmartForTest(credits: number, rate: number, cap: number): DexPaprikaForecastTier {
  return evalTierSmart(credits, rate, cap);
}
export function __lastBurnRateForTest(): number { return creditController().test.lastBurnRatePerMin(); }
export function __lastForecastForTest(): DexPaprikaForecastTier { return creditController().test.lastForecastTier(); }
export function __setCreditEvalAtForTest(ts: number): void { creditController().test.setEvalAt(ts); }
export function __setCreditDowngradeAtForTest(ts: number): void { creditController().test.setDowngradeAt(ts); }
export function __setCreditUpgradeAtForTest(ts: number): void { creditController().test.setUpgradeAt(ts); }
export function __isValidChainTxHashForTest(chainId: string, txHash: string): boolean {
  return isValidChainTxHash(chainId, txHash);
}
export function __invalidTxFormatCountForTest(): number { return notifSkipped.invalid_tx_format; }
export function __invalidTxFormatBreakdownForTest(): { total: number; synthetic: number; other: number; regex_evm: number; regex_solana: number; non_hex: number } {
  return {
    total: notifSkipped.invalid_tx_format,
    synthetic: notifSkipped.invalid_tx_format_synthetic,
    other: notifSkipped.invalid_tx_format_other,
    regex_evm: notifSkipped.invalid_tx_format_regex_evm,
    regex_solana: notifSkipped.invalid_tx_format_regex_solana,
    non_hex: notifSkipped.invalid_tx_format_non_hex,
  };
}
export function __classifyInvalidTxHashForTest(chainId: string, txHash: string): InvalidTxFormatReason {
  return classifyInvalidTxHash(chainId, txHash);
}
// v9.17 F1 test hooks.
export function __parseRemainingFromBodyForTest(text: string, header: number): number | undefined {
  return parseRemainingFromBody(text, header);
}
export function __hasAuthoritativeForTest(): boolean { return creditController().test.hasAuthoritative(); }
export function __monthUsedForTest(): number { return creditController().test.monthUsed(); }
export function __monthLeftForTest(): number { return creditController().test.monthLeft(); }
// bm4a (2026-10-04): учёт кредитов обязан читаться теми же кодами, что читает прод. Наружу отдаётся
// сам экстрактор сигналов и ТОНКИЕ обёртки над реальными production-вызовами (getJSON,
// diagnoseCreditTierAtStartup) — не копия логики для теста.
export function __readDpLimitSignalsForTest(h: Headers | null | undefined): DpLimitSignals {
  return readLimitSignalsFromHeaders(h, Date.now());   // B4.1: тот же код, что читает прод
}
export async function __dpGetJsonForTest<T>(
  url: string, opts: RequestInit = {}, source?: CreditSource,
): Promise<FetchResult<T>> {
  return getJSON<T>(url, opts, source);
}
export async function __diagnoseCreditTierAtStartupForTest(): Promise<void> {
  await diagnoseCreditTierAtStartup();
}
// v9.16 F-TX: seed synthetic + real-hash SSE reserve frames and confirm extraction disambiguation.
export function __dpExtractReserveForTestV916(frame: Record<string, unknown>, chainId: string) {
  return extractSubjectFromDpReserve(frame, chainId);
}

// v9.16 F-CE: live smoke-test helper for the simulate-burn endpoint. Injects `ratePerMin` burn
// timestamps spread across the last burn window, reduces the daily/monthly spend accordingly, and
// (optionally) force-evaluates the tier so the forecast drop is visible immediately. Returns a small
// observable snapshot for the acceptance curl.
// B4.1: вся арифметика инжекта живёт в контроллере (у него и burn-кольцо с расходом суток); facade
// сохранил сигнатуру, форму возврата и границы (Math.random без сида) — эндпоинт
// /api/test/credits/simulate-burn читает ровно этот контракт.
export function simulateCreditBurn(ratePerMin: number, forceEval = false): {
  tier: DexPaprikaCreditTier;
  forecastTier: DexPaprikaForecastTier;
  burnRatePerMin: number;
  creditsRemaining: number;
  remainingFractionPct: number;
} {
  return creditController().simulateBurn(ratePerMin, forceEval);
}

export async function getWatchlist(chainId: string): Promise<{
  chain: string; count: number; pools: WatchPoolEntry[];
}> {
  // ТЗ v8 Defect 2: read the COMMITTED watchlist only — do NOT trigger a rebuild here. Rebuilding
  // on read (via ensureWatchlistBuilt) mutated watchPools and broke status.watchPoolsTotal == count.
  // Discovery runs in the background (startup + every cycle). Anchors are still applied (idempotent).
  ensureAnchorsApplied(chainId);
  const meta = chainWatchMeta[chainId] || {};
  // ТЗ v7 req.4: zero ?/0/0 entries are hidden from the output.
  const visible = visibleWatchPools(chainId);
  const pools: WatchPoolEntry[] = visible.map(addr => {
    const m = meta[lower(addr)];
    if (m) return { ...m };
    return { address: addr, subjectToken: '', subjectSymbol: '?', quoteSymbol: '?', reserveUsd: null, vol24hUsd: null, source: 'volume' };
  });
  return { chain: chainId, count: pools.length, pools };
}

// WHY-TX debug endpoint (ТЗ v4 Block 4.3) ───────────────
export interface WhyTxCheck { name: string; ok: boolean; detail: string; }
export interface WhyTxResult {
  // Д2: found — true/false normally; null + error:'rpc_unavailable' when RPC context could not be obtained.
  found: boolean | null;
  error?: string;
  chain: string;
  txHash: string;
  blockTs: number | null;
  poolAddress: string | null;
  poolInWatchlist: boolean | null;
  subject: { address: string; symbol: string | null } | null;
  side: string;
  usd: number;
  subjectAmount?: number;
  traderResolved: string;
  resolvedBy: string;
  wouldTriggerSolo: boolean;
  wouldTriggerSeriesProgress: { count: number; windowSec: number } | null;
  skipReason: string | null;
  checks: WhyTxCheck[];
  // ТЗ v5 Defect 2: watched pools of the subject token (for audit even without pool attribution)
  subjectWatchlistPools: string[];
  poolDetail?: string;
  firstPass?: FirstPassRec | null;
  divergence?: string | null;
}
export interface FirstPassRec {
  at: number;
  usd: number;
  skipReason: string | null;
  priceResolved: boolean;
  gateClosed: boolean;
}

// Fetch a token's GT pools (address, reserve, volume) for watch-status checks.
// Cached 60s + multi-page + retry for reliability (GT rate-limits bursty debug calls).
const gtTokenPoolsCache = new Map<string, { pools: Array<{ address: string; reserveUsd: number; volume24h: number; name: string }>; cachedAt: number }>();
const GT_TOKEN_POOLS_TTL = 60 * 1000;

async function fetchGtTokenPools(chainId: string, tokenAddr: string): Promise<Array<{ address: string; reserveUsd: number; volume24h: number; name: string }>> {
  const gecko = CHAINS[chainId]?.gecko;
  if (!gecko) return [];
  const cacheKey = `${chainId}:${lower(tokenAddr)}`;
  const cached = gtTokenPoolsCache.get(cacheKey);
  if (cached && Date.now() - cached.cachedAt < GT_TOKEN_POOLS_TTL) return cached.pools;

  const parse = (res: unknown): Array<{ address: string; reserveUsd: number; volume24h: number; name: string }> => {
    const data = (res as { data?: Array<{ id?: string; attributes?: { address?: string; reserve_in_usd?: string; volume_usd?: { h24?: string }; name?: string } }> })?.data;
    if (!data) return [];
    return data.map(p => {
      const a = p.attributes || {};
      return {
        address: a.address || p.id?.split('_').pop() || '',
        reserveUsd: parseFloat(String(a.reserve_in_usd || '0')) || 0,
        volume24h: parseFloat(String(a.volume_usd?.h24 || '0')) || 0,
        name: a.name || '',
      };
    }).filter(x => x.address);
  };

  let out: Array<{ address: string; reserveUsd: number; volume24h: number; name: string }> = [];
  let blockedByLatch = false;
  for (let attempt = 0; attempt < 3 && out.length === 0 && !blockedByLatch; attempt++) {
    for (let page = 1; page <= 2; page++) {
      try {
        const gated = await withAnonymousGtLatch('token-pools', async () => {
          const response = await getJSON<{ data?: Array<{ id?: string; attributes?: Record<string, unknown> }> }>(
            anonymousGtUrl(`/api/v2/networks/${gecko}/tokens/${tokenAddr}/pools?page=${page}`)
          );
          const parsed = parse(response.data);
          return {
            value: parsed, status: response.status, ok: response.ok,
            useful: response.ok && parsed.length > 0,
          };
        });
        if (gated.blocked) { blockedByLatch = true; break; }
        const parsed = gated.value ?? [];
        if (parsed.length === 0) break;
        out = out.concat(parsed);
      } catch { break; }
      if (out.length >= 40) break;
    }
  }
  const dedup = new Map<string, typeof out[number]>();
  for (const p of out) if (!dedup.has(lower(p.address))) dedup.set(lower(p.address), p);
  const result = Array.from(dedup.values());
  if (result.length > 0) gtTokenPoolsCache.set(cacheKey, { pools: result, cachedAt: Date.now() });
  return result;
}
export async function __fetchGtTokenPoolsForTest(chainId: string, tokenAddr: string): Promise<Array<{ address: string; reserveUsd: number; volume24h: number; name: string }>> {
  return fetchGtTokenPools(chainId, tokenAddr);
}
export function __resetGtTokenPoolsForTest(): void {
  gtTokenPoolsCache.clear();
}

const tokenDecimalsCache = new Map<string, { dec: number; cachedAt: number }>();
const TOKEN_DECIMALS_TTL = 24 * 60 * 60 * 1000;

// Fetch an ERC-20 token's decimals() (cached). Default 18 on failure — but callers that need
// accuracy (why-tx valuation) fall back to the quote/stable leg so a wrong default doesn't zero a trade.
async function getEvmTokenDecimals(
  chainId: string,
  addr: string,
  rpcFetcher?: (method: string, params: unknown[]) => Promise<any>
): Promise<number> {
  const a = lower(addr);
  // Address-only 6-dec stables (BSC USDT/USDC are 18 — never key off the symbol).
  const STABLE_6 = new Set([
    '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
    '0xdac17f958d2ee523a2206206994597c13d831ec7',
    '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
    '0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca',
    '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
    '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9',
    '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359',
    '0xc2132d05d31c914a87c6611c10748aeb04b58e8f',
    '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8',
    '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
  ]);
  if (STABLE_6.has(a)) {
    tokenDecimalsCache.set(chainId + ':' + a, { dec: 6, cachedAt: Date.now() });
    return 6;
  }
  const key = chainId + ':' + a;
  const cached = tokenDecimalsCache.get(key);
  if (cached && Date.now() - cached.cachedAt < TOKEN_DECIMALS_TTL) return cached.dec;

  if (rpcFetcher) {
    try {
      const res = await rpcFetcher('eth_call', [{ to: addr, data: '0x313ce567' }, 'latest']);
      const hex = res?.result as string | undefined;
      let dec = 18;
      if (hex && hex !== '0x') {
        const v = parseInt(hex, 16);
        if (!isNaN(v) && v >= 0 && v <= 30) dec = v;
      }
      tokenDecimalsCache.set(key, { dec, cachedAt: Date.now() });
      return dec;
    } catch {
      return 18;
    }
  }

  const rpc = RPC_ENDPOINTS[chainId];
  if (!rpc) return 18;
  try {
    const res = await fetchWithTimeout(rpc, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: addr, data: '0x313ce567' }, 'latest'] }),
    }, 5000);
    const hex = (await res.json())?.result as string | undefined;
    let dec = 18;
    if (hex && hex !== '0x') {
      const v = parseInt(hex, 16);
      if (!isNaN(v) && v >= 0 && v <= 30) dec = v;
    }
    tokenDecimalsCache.set(key, { dec, cachedAt: Date.now() });
    return dec;
  } catch {
    return 18;
  }
}

// bm2f: третий параметр — явный источник расхода кредита. Моки в тестах объявляют один-два
// параметра; лишние аргументы в JS игнорируются, поэтому подмена транспорта не ломается.
type TokenPriceFetch = (url: string, opts?: RequestInit, source?: CreditSource) => Promise<FetchResult<any>>;
let tokenPriceFetchImpl: TokenPriceFetch = (url, opts, source) => getJSON<any>(url, opts, source);
// v9.32g: per-token price cache + fetch metrics (incident FOLD-2 429-storm → usd=0).
const tokenPriceCache = new Map<string, { priceUsd: number; symbol: string; at: number; neg: boolean }>();
/** Test hook: единый транспорт для всех источников цены (маршрутизация по URL в моке). */
export function __setTokenPriceTransportForTest(fn: TokenPriceFetch | null): void {
  tokenPriceFetchImpl = fn || ((url, opts, source) => getJSON<any>(url, opts, source));
  // Transport swap invalidates cached quotes (v9.32d fallback tests isolate per-mock).
  tokenPriceCache.clear();
}
/** Test hook: прямой доступ к функции для RED/GREEN-тестов. */
export function __fetchEvmTokenMetaForTest(chainId: string, addr: string) { return fetchEvmTokenMeta(chainId, addr); }

const priceFetchStats = { hits: 0, misses: 0, negHits: 0, rateSkipped: { ds: 0, gt: 0, dp: 0 }, gateBackoff: { ds: 0, gt: 0, dp: 0 }, dpSanityRejected: 0, symbolFallbackBlocked: 0, priceCorrectedByContract: 0, priceDroppedNoContract: 0 };
const priceGateOpenAt = new Map<string, number>(); // v9.32az RD1: source:chainId → epoch ms окна, НЕ цена
const lastKnownTokenPrice = new Map<string, { priceUsd: number; at: number }>();
const DP_SANITY_MAX_MULT = () => Number(process.env.PRICE_DP_SANITY_MAX_MULT || '10');
export function __clearTokenPriceCacheForTest(): void {
  tokenPriceCache.clear();
  lastKnownTokenPrice.clear();
  priceFetchStats.hits = 0;
  priceFetchStats.misses = 0;
  priceFetchStats.negHits = 0;
  priceFetchStats.rateSkipped = { ds: 0, gt: 0, dp: 0 };
  priceFetchStats.gateBackoff = { ds: 0, gt: 0, dp: 0 };
  priceFetchStats.dpSanityRejected = 0;
  priceFetchStats.symbolFallbackBlocked = 0;
  priceFetchStats.priceCorrectedByContract = 0;
  priceFetchStats.priceDroppedNoContract = 0;
  priceGateOpenAt.clear();
  priceFromLastKnown = 0;
  lastTokenMetaExtra = {};
  priceResolve.ok = 0;
  priceResolve.fail = 0;
  priceResolve.bySource = { ds: 0, gt: 0, dp: 0 };
  priceResolve.notCached = 0;
  rateMap.delete('price-ds');
  rateMap.delete('price-gt');
  rateMap.delete('price-dp');
}
export function __tokenPriceCacheHasForTest(chainId: string, addr: string): boolean {
  return tokenPriceCache.has(chainId + ':' + lower(addr));
}
export function __setLastKnownPriceForTest(chainId: string, addr: string, priceUsd: number, at: number): void {
  lastKnownTokenPrice.set(chainId + ':' + lower(addr), { priceUsd, at });
}
export function __priceFetchStatsForTest(): {
  hits: number; misses: number; negHits: number;
  rateSkipped: { ds: number; gt: number; dp: number };
  gateBackoff: { ds: number; gt: number; dp: number };
  priceCorrectedByContract: number; priceDroppedNoContract: number; symbolFallbackBlocked: number;
} {
  return {
    hits: priceFetchStats.hits, misses: priceFetchStats.misses, negHits: priceFetchStats.negHits,
    rateSkipped: { ...priceFetchStats.rateSkipped },
    gateBackoff: { ...priceFetchStats.gateBackoff },
    priceCorrectedByContract: priceFetchStats.priceCorrectedByContract,
    priceDroppedNoContract: priceFetchStats.priceDroppedNoContract,
    symbolFallbackBlocked: priceFetchStats.priceCorrectedByContract + priceFetchStats.priceDroppedNoContract,
  };
}
const pricePerMinSource = () => Number(process.env.PRICE_FETCH_PER_MIN_PER_SOURCE || '20');
const PRICE_GATE_WINDOW_MS = 60000;
let lastPriceGateClosed = false;
let lastTokenMetaExtra: { confidence?: 'low'; priceAge?: number } = {};
function priceGateOpenInMs(source: 'ds' | 'gt' | 'dp'): number {
  const now = Date.now();
  let max = 0;
  for (const [k, t] of priceGateOpenAt) {
    if (k.startsWith(source + ':')) max = Math.max(max, t - now);
  }
  return Math.max(0, max);
}
/** v9.32az: закрытый вентиль — не дёргать rateAllow до открытия окна. Не кэш цены (RD1). */
function priceSourceOpen(source: 'ds' | 'gt' | 'dp', chainId: string): boolean {
  if (source === 'dp' && !DP_PRICE_ENABLED) return false;
  const gkey = source + ':' + chainId;
  const rkey = 'price-' + source;
  const limit = pricePerMinSource();
  const until = priceGateOpenAt.get(gkey) || 0;
  if (Date.now() < until) {
    if (!(limit > 0 && rateCount(rkey, PRICE_GATE_WINDOW_MS) < limit)) {
      priceFetchStats.gateBackoff[source]++;
      return false;
    }
    priceGateOpenAt.delete(gkey);
  }
  if (rateAllow(rkey, limit, PRICE_GATE_WINDOW_MS)) {
    priceGateOpenAt.delete(gkey);
    return true;
  }
  priceFetchStats.rateSkipped[source]++;
  const arr = (rateMap.get(rkey) || []).filter(x => x > Date.now() - PRICE_GATE_WINDOW_MS);
  const openAt = arr.length ? Math.min(...arr) + PRICE_GATE_WINDOW_MS : Date.now() + PRICE_GATE_WINDOW_MS;
  priceGateOpenAt.set(gkey, openAt);
  return false;
}
function rememberTokenPrice(key: string, symbol: string, priceUsd: number, neg: boolean): { symbol: string; priceUsd: number } {
  tokenPriceCache.set(key, { priceUsd, symbol, at: Date.now(), neg });
  if (!neg && priceUsd > 0) lastKnownTokenPrice.set(key, { priceUsd, at: Date.now() });
  return { symbol, priceUsd };
}

async function fetchEvmTokenMeta(chainId: string, addr: string): Promise<{ symbol: string; priceUsd: number; confidence?: 'low' | 'high'; priceAge?: number }> {
  // R11 D-3 (a): deterministic price wall — HUITA_MOCK_PRICES=1 reads canonical prices from
  // test/fixtures/v931/prices.json (0 network calls). Missing token → price 0 (deterministic).
  if (mockPricesEnabled()) {
    return { symbol: mockTokenSymbol(chainId, addr) || '', priceUsd: mockPriceUsd(chainId, addr) || 0 };
  }
  // v9.32g: per-token price cache — incident FOLD-2 (429-storm made every live valuation usd=0).
  const cacheKey = chainId + ':' + lower(addr);
  const hit = tokenPriceCache.get(cacheKey);
  const posTtl = Number(process.env.PRICE_CACHE_TTL_MS || '60000');
  const negTtl = Number(process.env.PRICE_NEG_CACHE_TTL_MS || '30000');
  if (hit) {
    const ttl = hit.neg ? negTtl : posTtl;
    if (Date.now() - hit.at < ttl) {
      priceFetchStats[hit.neg ? 'negHits' : 'hits']++;
      return { symbol: hit.symbol, priceUsd: hit.priceUsd };
    }
    tokenPriceCache.delete(cacheKey);
  }
  priceFetchStats.misses++;
  lastPriceGateClosed = false;
  // v9.32ay AY1: был ли отправлен хотя бы один запрос. rate-skip (закрытый вентиль)
  // запросом не считается — такой исход нельзя кэшировать как «цены нет».
  let anySourceAttempted = false;
  // v9.32ay AY2: хотя бы один источник ОТВЕТИЛ (res.ok). throw/!ok — факт о канале,
  // только ответ без цены — факт о токене, достойный neg-кэша.
  let anySourceAnswered = false;
  let dsRememberedSymbol: string | undefined;
  const dsChain = CHAINS[chainId]?.ds;
  const dsHeaders = { headers: { 'User-Agent': DS_BROWSER_UA, 'Accept': 'application/json' } };
  if (dsChain && priceSourceOpen('ds', chainId)) {
    anySourceAttempted = true;
    try {
      type DsPair = { chainId?: string; baseToken?: { address?: string; symbol?: string }; quoteToken?: { address?: string; symbol?: string }; priceUsd?: string; liquidity?: { usd?: number } };
      const pickCanonical = (pairs: DsPair[] | undefined): DsPair | undefined => {
        if (!pairs?.length) return undefined;
        const lowerToken = lower(addr);
        const valid = pairs.filter(p => {
          const matchChain = (lower(p.chainId || '') === dsChain || lower(p.chainId || '') === chainId);
          const matchBase = lower(p.baseToken?.address || '') === lowerToken;
          const qAddr = lower(p.quoteToken?.address || '');
          const isCanonicalQuote = STABLE_ADDRS.has(qAddr) || !!WRAPPED_TO_NATIVE[qAddr] || qAddr === SOLANA_WSOL_MINT.toLowerCase();
          const pUsd = parseFloat(p.priceUsd || '0');
          return matchChain && matchBase && isCanonicalQuote && pUsd > 0 && isFinite(pUsd);
        });
        if (!valid.length) return undefined;
        valid.sort((a, b) => (Number(b.liquidity?.usd || 0)) - (Number(a.liquidity?.usd || 0)));
        return valid[0];
      };
      // v9.32ax AX5: chain-scoped /tokens/v1 (User-Agent — иначе 403). Один DS-слот на попытку.
      let dsPairs: DsPair[] | undefined;
      let usedV1 = false;
      try {
        // bm2r D5: слот занимает единый шлюз (он же следит за 429-бэкоффом);
        // инжектируемый транспорт tokenPriceFetchImpl (priceGate сверху) не трогаем.
        const gateV1 = dsGate('price');
        if (!gateV1.allowed) throw new Error('suppressed');
        const v1 = await tokenPriceFetchImpl(
          `https://api.dexscreener.com/tokens/v1/${dsChain}/${addr}`,
          dsHeaders,
        ) as FetchResult<DsPair[] | { pairs?: DsPair[] }>;
        dsNoteResult('price', v1.status, v1.retryAfterMs);
        usedV1 = true;
        if (v1.ok) anySourceAnswered = true;
        dsPairs = Array.isArray(v1.data) ? v1.data : v1.data?.pairs;
        const v1hit = v1.ok ? pickCanonical(dsPairs) : undefined;
        if (v1hit) {
          priceResolve.ok++; priceResolve.bySource.ds++;
          return rememberTokenPrice(cacheKey, v1hit.baseToken?.symbol || '?', parseFloat(v1hit.priceUsd || '0') || 0, false);
        }
      } catch { /* fall through to latest */ }
      if (!usedV1) {
        const gateLate = dsGate('price');
        if (!gateLate.allowed) throw new Error('suppressed');
        const res = await tokenPriceFetchImpl(
          `https://api.dexscreener.com/latest/dex/tokens/${addr}`,
          dsHeaders,
        ) as FetchResult<{ pairs?: DsPair[] }>;
        dsNoteResult('price', res.status, res.retryAfterMs);
        if (res.ok) anySourceAnswered = true;
        dsPairs = res.ok ? res.data?.pairs : dsPairs;
        const lateHit = res.ok ? pickCanonical(dsPairs) : undefined;
        if (lateHit) {
          priceResolve.ok++; priceResolve.bySource.ds++; // v9.32ay: latest — тоже DS
          return rememberTokenPrice(cacheKey, lateHit.baseToken?.symbol || '?', parseFloat(lateHit.priceUsd || '0') || 0, false);
        }
      }
      if (dsPairs?.length) {
        const anyPair = dsPairs.find(p => lower(p.baseToken?.address || '') === lower(addr));
        if (anyPair) dsRememberedSymbol = anyPair.baseToken?.symbol || dsRememberedSymbol;
      }
    } catch { /* ignore */ }
  } else if (dsChain) {
    lastPriceGateClosed = true;
  }

  // L4: Geckoterminal fallback (3s timeout, same pattern as native-price-service)
  const geckoPlatform = chainId === 'ethereum' ? 'eth' : chainId === 'bsc' ? 'bsc' : chainId === 'base' ? 'base' : chainId === 'arbitrum' ? 'arbitrum' : chainId === 'polygon' ? 'polygon_pos' : null;
  if (geckoPlatform && priceSourceOpen('gt', chainId)) {
    anySourceAttempted = true;
    try {
      const gated = await withAnonymousGtLatch('token-price', async () => {
        const response = await tokenPriceFetchImpl(
          anonymousGtUrl(`/api/v2/simple/networks/${geckoPlatform}/token_price/${addr}`),
          { headers: { 'Accept': 'application/json' } },
        ) as FetchResult<{ data?: { attributes?: { token_prices?: Record<string, string> } } }>;
        const priceStr = response.data?.data?.attributes?.token_prices?.[lower(addr)];
        const price = priceStr ? parseFloat(priceStr) : NaN;
        return {
          value: response, status: response.status, ok: response.ok,
          useful: response.ok && !!priceStr && !isNaN(price) && price > 0,
        };
      });
      if (!gated.blocked) {
        const gtRes = gated.value;
        if (gtRes?.ok) anySourceAnswered = true;
        if (gtRes?.ok && gtRes.data?.data?.attributes?.token_prices) {
          const priceStr = gtRes.data.data.attributes.token_prices[lower(addr)];
          if (priceStr) {
            const price = parseFloat(priceStr);
            if (!isNaN(price) && price > 0) {
              priceResolve.ok++; priceResolve.bySource.gt++;
              const sym = await resolveTokenSymbol(chainId, addr);
              return rememberTokenPrice(cacheKey, sym, price, false);
            }
          }
        }
      }
    } catch { /* ignore */ }
  } else if (geckoPlatform) {
    lastPriceGateClosed = true;
  }

  // v9.32d: третий источник — DexPaprika (keyed, 120 req/min; кредит-трекинг и 402/429 — внутри getJSON).
  // Инцидент FOLD 2026-08-26: DS+GT одновременно 429 с прода → usd=0 → ниже порога → потеря сделки $24.6K.
  // verified 2026-08-26: GET /networks/ethereum/tokens/0xe172…af904 → top-level .summary.price_usd ≈ 0.101
  const dpNet = CHAINS[chainId]?.dpNetwork;
  if (dpNet && priceSourceOpen('dp', chainId)) {
    anySourceAttempted = true;
    try {
      const dpRes = await tokenPriceFetchImpl(
        `https://api.dexpaprika.com/networks/${dpNet}/tokens/${lower(addr)}`,
        undefined,
        'tm_meta' // bm2f: резолвер цены; зовётся из 14 мест, дробить дальше преждевременно
      ) as FetchResult<{ summary?: { price_usd?: number } }>;
      if (dpRes.ok) {
        const price = Number(dpRes.data?.summary?.price_usd);
        if (price > 0 && isFinite(price)) {
          const known = lastKnownTokenPrice.get(cacheKey);
          const mult = DP_SANITY_MAX_MULT();
          if (known && Date.now() - known.at < 24 * 3600_000 && known.priceUsd > 0) {
            const ratio = price / known.priceUsd;
            if (ratio > mult || ratio < 1 / mult) {
              priceFetchStats.dpSanityRejected++;
              log(`[price] DP sanity reject ${chainId} ${shortAddr(addr)}: ${price} vs lastKnown ${known.priceUsd} (×${ratio.toFixed(1)})`);
              // v9.32ay (reconciled): sanity-reject — недоверенный ответ, а не «цены нет».
              // Фактом не считаем, иначе отравим neg-кэш нулем для честных источников.
            } else {
              priceResolve.ok++; priceResolve.bySource.dp++;
              const sym = dsRememberedSymbol || await resolveTokenSymbol(chainId, addr);
              return rememberTokenPrice(cacheKey, sym, price, false);
            }
          } else {
            priceResolve.ok++; priceResolve.bySource.dp++;
            const sym = dsRememberedSymbol || await resolveTokenSymbol(chainId, addr);
            return rememberTokenPrice(cacheKey, sym, price, false);
          }
        } else {
          anySourceAnswered = true; // ok без цены — факт о токене
        }
      }
    } catch { /* ignore */ }
  } else if (dpNet) {
    lastPriceGateClosed = true;
  }

  const sym = dsRememberedSymbol || await resolveTokenSymbol(chainId, addr);
  if ((!anySourceAttempted || !anySourceAnswered) && lastPriceGateClosed) {
    const known = lastKnownTokenPrice.get(cacheKey);
    const maxAge = Number(process.env.PRICE_STALE_MAX_MS || '300000');
    if (known && known.priceUsd > 0 && Date.now() - known.at < maxAge) {
      priceFromLastKnown++;
      const age = Date.now() - known.at;
      lastTokenMetaExtra = { confidence: 'low', priceAge: age };
      return { symbol: sym || '?', priceUsd: known.priceUsd, confidence: 'low' as const, priceAge: age };
    }
  }
  priceResolve.fail++;
  if (!anySourceAttempted || !anySourceAnswered) {
    // v9.32ay AY1+AY2: запрос не отправлен (rate-skip) либо никто не ответил
    // (throw/!ok) — это факт о нашем лимите/канале, а не о токене. Возврат без
    // записи в кэш, иначе серия сделок 30с получает usd=0 из neg-кэша (MARSCOIN).
    priceResolve.notCached++;
    return { symbol: sym, priceUsd: 0 };
  }
  return rememberTokenPrice(cacheKey, sym, 0, true);
}
async function tryFillUsdFromDs(chainId: string, addr: string, amount: number): Promise<number> {
  if (!(amount > 0) || !addr) return 0;
  try {
    const meta = await fetchEvmTokenMeta(chainId, addr);
    if (meta.priceUsd > 0) {
      if ((meta as { confidence?: string }).confidence === 'low') {
        lastTokenMetaExtra = { confidence: 'low', priceAge: (meta as { priceAge?: number }).priceAge };
      }
      return amount * meta.priceUsd;
    }
  } catch { /* ignore */ }
  return 0;
}
export async function __fillUsdFromDsForTest(chainId: string, addr: string, amount: number): Promise<number> {
  return tryFillUsdFromDs(chainId, addr, amount);
}

// v9.30 FIX: pure helpers for native-quoted Solana swaps (CARDS→SOL / SOL→CARDS). Extracted from
// analyzeSolanaTx so the native-SOL-delta recognition is deterministic and unit-testable with the
// real on-chain fixture (see test/parser-v9-30-solana-native.test.ts). A swap requires the trader to
// BOTH send and receive; when the quote leg is NATIVE SOL that delta lives in preBalances/postBalances
// (lamports), NOT in postTokenBalances, so without this the SOL side was invisible and the swap was
// dropped as no_swap_legs.
// Computes the trader's net native-SOL delta (in SOL). The fee is added back ONLY when the trader is
// the fee-payer (accountKeys[0]) — fee-payers pay the fee in SOL, so it must not count as a "receive".
function computeTraderSolDelta(
  accountKeys: SolanaAccountKey[],
  preBalances: number[] | undefined,
  postBalances: number[] | undefined,
  trader: string,
  feePayer: string,
  fee: number | undefined,
): number {
  let delta = 0;
  const idx = accountKeys.findIndex(k => String(k.pubkey) === trader);
  if (idx >= 0 && preBalances && postBalances) {
    const pre = preBalances[idx] || 0;
    const post = postBalances[idx] || 0;
    delta = (post - pre) / 1e9;
    if (trader === feePayer && fee) delta += fee / 1e9;
  }
  return delta;
}

// Aggregates the trader's SPL-token in/out legs, then folds the native-SOL delta into the right leg
// (dust threshold 0.001 SOL). Returns {traderIn, traderOut}; a real swap requires both > 0.
function solanaSwapLegs(
  deltasByOwnerMint: Record<string, number>,
  trader: string,
  traderSolDelta: number,
): { traderIn: number; traderOut: number } {
  let traderIn = 0, traderOut = 0;
  for (const [key, delta] of Object.entries(deltasByOwnerMint)) {
    const [owner] = key.split('|');
    if (owner !== trader) continue;
    if (delta > 0) traderIn += delta;
    if (delta < 0) traderOut += -delta;
  }
  if (traderSolDelta > 0.001) traderIn += traderSolDelta;
  if (traderSolDelta < -0.001) traderOut += -traderSolDelta;
  return { traderIn, traderOut };
}

// Count results at the actual analyzer boundary, keeping why-tx and optional enrichment
// separate from terminal live fallback failures. Never store signatures, URLs or errors.
export async function analyzeSolanaTx(txHash: string, origin: SolAnalysisOrigin = 'diagnostic') {
  const counts = engineKernel.metrics!.solNotificationFlow.analysis[origin];
  counts.attempts++;
  const result = await analyzeSolanaTxImpl(txHash);
  if (result.found) counts.found++;
  else {
    const reason = result.error || 'unknown'; // implementation returns only fixed reason literals
    counts.rejected[reason] = (counts.rejected[reason] || 0) + 1;
  }
  return result;
}

async function analyzeSolanaTxImpl(txHash: string): Promise<{
  found: boolean; trader: string; resolvedBy: string; subject: { address: string; symbol: string | null } | null;
  side: string; usd: number; subjectAmount: number; blockTs: number | null;
  accounts: string[]; error?: string | null;
  selfSwap?: boolean; // v9.32bm1i T3: явный round-trip вердикт (только на success-возврате)
}> {
  const fail = { found: false, trader: '', resolvedBy: '', subject: null, side: '', usd: 0, subjectAmount: 0, blockTs: null, accounts: [] as string[], error: null as string | null } as const;
  try {
    // v9.28 F1: retry getTransaction across RPC sources — mainnet-beta RPC intermittently returns
    // null AND only retains recent history (older acceptance fixtures rot → receipt_missing). When
    // getSolanaPubsub().HELIUS_API_KEY is configured, fall back to the Helius RPC (extended tx history) so why-tx can
    // still resolve old txs instead of hard-failing. Without a key only the primary RPC is used.
    // v9.32l: shared multi-RPC getTransaction (public then Helius) — same sources/attempts as before.
    const fetchInfo: { reason?: SolTxFetchReason } = {};
    const tx = await solanaGetTransactionMultiRpc(txHash, fetchInfo);
    if (!tx || !tx.meta) {
      // W1-T3 (WR4): версия — отдельная терминальная причина (§3.2 спеки W1), а не «расписки нет».
      if (fetchInfo.reason === 'tx_version_unsupported') return { ...fail, found: false, error: 'tx_version_unsupported' };
      return { ...fail, found: false, error: 'receipt_missing' };
    }
    const accountKeys: SolanaAccountKey[] = tx.transaction?.message?.accountKeys || [];

    // ── ТЗ v5 Defect 2: collect ALL involved accounts (incl. ALT loaded addresses, inner
    //    instruction accounts, vault owners) for pool attribution against the watchlist.
    const allKeys: string[] = accountKeys.map(k => String(k.pubkey));
    const loaded = (tx.meta.loadedAddresses?.writable || []).concat(tx.meta.loadedAddresses?.readonly || []);
    allKeys.push(...loaded);
    for (const ii of tx.meta.innerInstructions || []) {
      for (const instr of ii.instructions || []) {
        for (const idx of instr.accounts || []) {
          const k = accountKeys[idx]?.pubkey;
          if (k) allKeys.push(String(k));
        }
      }
    }
    for (const b of (tx.meta.postTokenBalances || [])) if (b.owner) allKeys.push(b.owner);
    for (const b of (tx.meta.preTokenBalances || [])) if (b.owner) allKeys.push(b.owner);
    const accounts = Array.from(new Set(allKeys.map(k => String(k))));

    const signers = new Set<string>();
    for (const k of accountKeys) if (k.signer) signers.add(String(k.pubkey));
    const feePayer = accountKeys.length ? String(accountKeys[0].pubkey) : '';

    const { deltasByOwnerMint } = buildSolanaTokenDeltas(tx.meta.preTokenBalances || [], tx.meta.postTokenBalances || []);
    const isQuoteMint = (m: string) => STABLE_ADDRS.has(lower(m)) || !!WRAPPED_TO_NATIVE[lower(m)];
    // trader = signer with largest total |delta| over non-quote mints
    let trader = ''; let traderScore = -1;
    for (const s of signers) {
      let total = 0;
      for (const [key, delta] of Object.entries(deltasByOwnerMint)) {
        const [owner, mint] = key.split('|');
        if (owner !== s || isQuoteMint(mint)) continue;
        total += Math.abs(delta);
      }
      if (total > traderScore) { traderScore = total; trader = s; }
    }
    if (!trader) trader = feePayer;
    // R2 (v9.4): subject = TERMINAL token the trader received (buy), falling back to the alt sold
    // (sell) — multi-hop aware (Jupiter/Orca/Meteora/…), never just the max |delta| mint.
    const subj = pickSolanaTerminalSubject(deltasByOwnerMint, trader);
    const subjectMint = subj.mint; const subjectDelta = subj.delta;
    if (!subjectMint) return { ...fail, found: false, trader, resolvedBy: 'fee_payer_fallback', accounts, error: 'tx_not_swap' };
    // v9.12 F3 + v9.30 FIX: a real swap requires the trader to BOTH send and receive token balances.
    // Include native SOL balance deltas (preBalances/postBalances) so native-quoted swaps
    // (e.g. CARDS→SOL or SOL→CARDS) are recognized as swaps, not dropped by no_swap_legs.
    const traderSolDelta = computeTraderSolDelta(accountKeys, tx.meta.preBalances, tx.meta.postBalances, trader, feePayer, tx.meta.fee);
    const { traderIn, traderOut } = solanaSwapLegs(deltasByOwnerMint, trader, traderSolDelta);

    if (!(traderIn > 0 && traderOut > 0)) {
      notifSkipped.no_swap_legs++;
      return { ...fail, found: false, trader, resolvedBy: 'fee_payer_fallback', accounts, error: 'no_swap_legs' };
    }
    const side = subjectDelta >= 0 ? 'buy' : 'sell';
    const subjectAmount = Math.abs(subjectDelta);
    // v9.32bm1i T3: round-trip self-swap (A→B→A в одной tx) оставляет трейдеру нулевую нетто-дельту
    // subject; реальный своп — ненулевую. Тот же предикат, что и у detectSolPoolSwapFromTx (0 RPC).
    const selfSwapVerdict = solanaSubjectNetZero(subjectAmount);
    // usd from quote leg (stable≈1, native→SOL price); multi-hop-aware (full value from any stable leg)
    const solPrice = await resolveTrustedSolPriceUsd();
    const valResult = computeSolanaUsdDetailed(deltasByOwnerMint, trader, subjectMint, solPrice, traderSolDelta);
    let usd = valResult.usd;
    // v9.32as SD3/Task5: valuation diagnostics — count the source and log the dust case explicitly
    recordSolanaValuationSource(valResult.source);
    if (valResult.source === 'pool_quote_legs') {
      log(`[solana-valuation] pool_quote_legs: trader quote delta was dust — usd from pool legs $${valResult.usd.toFixed(2)} (quote=${String(valResult.quoteMint).slice(0, 4)}… amount=${valResult.quoteAmount?.toFixed(6)})`);
    }

    // Check if trader had a WSOL or SOL leg
    const traderHasWsolOrSol = (Math.abs(traderSolDelta) > 0.0005) || Object.keys(deltasByOwnerMint).some(k => {
      const [owner, mint] = k.split('|');
      return owner === trader && isSolanaWsol(mint) && Math.abs(deltasByOwnerMint[k]) > 0;
    });

    if (usd === 0) {
      // v9.32aq B5 (AQ5): ранний return при WSOL/SOL-ноге глотал lastKnown-фоллбэк (v9.32p
      // механизм существовал, но был недостижим при живой SOL-ноге — ANSEM-пара получила
      // usd_unresolved при цене в кэше). Фоллбэк-цепочка идёт ДО отказа; терминал прежний.
      const freshKnown = (mint: string): number => {
        const known = lastKnownTokenPrice.get('solana:' + lower(mint));
        if (known && known.priceUsd > 0 && Date.now() - known.at < 24 * 3600_000) return known.priceUsd;
        return 0;
      };
      let lk = freshKnown(subjectMint);
      let lkAmt = subjectAmount;
      if (!(lk > 0)) {
        for (const [key, delta] of Object.entries(deltasByOwnerMint)) {
          const [owner, mint] = key.split('|');
          if (owner !== trader || !(Math.abs(delta) > 0)) continue;
          const p = freshKnown(mint);
          if (p > 0) { lk = p; lkAmt = Math.abs(delta); break; }
        }
      }
      if (lk > 0) {
        usd = lkAmt * lk;
        recordSolanaValuationSource('lastknown');
        solanaLastKnownUsd++;
      } else {
        const meta = await fetchEvmTokenMeta('solana', subjectMint);
        if (meta.priceUsd > 0) {
          usd = subjectAmount * meta.priceUsd;
        } else {
          return { ...fail, found: false, trader, resolvedBy: 'fee_payer_fallback', accounts, error: 'usd_unresolved' };
        }
      }
    }
    const meta = await fetchEvmTokenMeta('solana', subjectMint);
    const readableSym = resolveReadableSymbol('solana', subjectMint)
      || (meta.symbol && !meta.symbol.includes('…') ? meta.symbol : null);
    const resolvedBy = signers.has(trader) ? 'token_movement' : (trader === feePayer ? 'fee_payer_fallback' : 'token_movement');
    return { found: true, trader, resolvedBy, subject: { address: subjectMint, symbol: readableSym }, side, usd, subjectAmount, blockTs: tx.blockTime || null, accounts, selfSwap: selfSwapVerdict };
  } catch { return { ...fail, found: false, error: 'rpc_unavailable' }; }
}

// ── v9.32as SD2: fallback attribution for aggregator swaps (token-movement path, same as why-tx) ──
// When the pipeline's "trader" (tx.from / recovered owner) has zero own ERC-20 legs but the
// receipt carries a multi-hop route (aggregator/front-end routed the real wallet's trade), the
// old code dropped the whole tx as aggregator_no_legs (2 228 lost events in prod). We recover
// the real wallet from the SAME already-collected logs: a wallet is a confirmed-EOA address that
// (a) has a non-zero net delta on at least one trade token (non-stable/non-native/non-major) and
// (b) touches the route entry — tx.from/tx.to or a registered router/settlement/entrypoint.
// Subject/USD are then taken from evaluateEvmSwap(wallet) — the exact path why-tx resolves with
// (resolvedBy: token_movement). Conservative by design: contract wallets (pools/routers) are
// excluded (isContract === true), unknown addresses (isContract fail-open null) are NOT
// attributed; if no candidate yields subject + usd > 0 the tx is dropped with the old label.
const AGGREGATOR_ATTRIBUTION = () => process.env.AGGREGATOR_ATTRIBUTION !== '0';
const AGGREGATOR_MIN_USD = () => {
  const n = Number(process.env.AGGREGATOR_MIN_USD ?? '100');
  return Number.isFinite(n) && n >= 0 ? n : 100;
};
// v9.32au AU2: one extra isContract RPC only when the first call returned null (unresolved).
const AGGREGATOR_ISCONTRACT_RETRY = () => process.env.AGGREGATOR_ISCONTRACT_RETRY !== '0';
const AGGREGATOR_REJECT_SAMPLE_EVERY = () => {
  const n = Number(process.env.AGGREGATOR_REJECT_SAMPLE_EVERY ?? '20');
  return n > 0 ? n : 20;
};
let aggregatorRejectSampleN = 0;
const AGGREGATOR_REJECT_RING = 20;
type AggregatorRejectSample = { ts: number; chainId: string; txHash: string; reject: string; cands: number };
const lastAggregatorRejects: AggregatorRejectSample[] = [];
function recordAggregatorReject(chainId: string, txHash: string, reject: string, cands: number): void {
  lastAggregatorRejects.unshift({ ts: Date.now(), chainId, txHash: txHash || '-', reject, cands });
  if (lastAggregatorRejects.length > AGGREGATOR_REJECT_RING) lastAggregatorRejects.length = AGGREGATOR_REJECT_RING;
}
// v9.32bm1g H7: реджекты по роутеру (tx.to) — прод даёт причины (no_candidates 2 016 / is_contract 5 073),
// но не «чей» это роутер. Cap 20 ключей, LRU: обращение переставляет ключ в конец Map, лишний — самый давний.
const AGGREGATOR_REJECT_ROUTERS_MAX = 20;
const aggregatorRejectsByRouter = new Map<string, Partial<Record<AggregatorRejectReason, number>>>();
function noteAggregatorRejectByRouter(to: string | undefined, reject: AggregatorRejectReason): void {
  const key = lower(String(to || '')) || '-';
  const cur = aggregatorRejectsByRouter.get(key) || {};
  aggregatorRejectsByRouter.delete(key); // LRU: переставить в конец
  cur[reject] = (cur[reject] || 0) + 1;
  aggregatorRejectsByRouter.set(key, cur);
  while (aggregatorRejectsByRouter.size > AGGREGATOR_REJECT_ROUTERS_MAX) {
    const oldest = aggregatorRejectsByRouter.keys().next().value;
    if (oldest === undefined) break;
    aggregatorRejectsByRouter.delete(oldest);
  }
}
const AGGREGATOR_REJECT_PRIORITY: AggregatorRejectReason[] = [
  'getcode_unresolved', 'is_contract', 'below_min', 'usd_zero', 'non_wallet', 'no_candidates',
];
const ZERO_EVM_ADDR = '0x0000000000000000000000000000000000000000';
const DEAD_EVM_ADDR = '0x000000000000000000000000000000000000dead';

// AT1: form-based deny-list. Zero/dead/native/chain-token can never be a wallet.
// RPC isContract is NOT consulted here (empty code on 0x0 looks like an EOA).
function isNonWalletAddr(addr: string): boolean {
  const a = lower(addr);
  if (!a) return true;
  if (a === ZERO_EVM_ADDR || a === DEAD_EVM_ADDR) return true;
  if (/^0x0+$/.test(a)) return true;
  if (NATIVE_OR_WRAPPED.has(a)) return true;
  for (const c of Object.values(CHAINS)) {
    if (c.nativeContract && lower(c.nativeContract) === a) return true;
    if (c.wrapped && lower(c.wrapped) === a) return true;
  }
  return false;
}

interface AggregatorWalletCandidate {
  addr: string;
  score: bigint; // sum of |raw delta| over trade tokens (ranking only)
}

function pickAggregatorWalletCandidates(
  chainId: string,
  logs: EvmTransferLog[],
  trader: string,
  txd: { from?: string; to?: string },
): AggregatorWalletCandidate[] {
  const traderL = lower(trader);
  const txFromL = txd.from ? lower(txd.from) : '';
  const txToL = txd.to ? lower(txd.to) : '';
  const tokenAddrs = new Set<string>();
  const counterparties = new Map<string, Set<string>>();
  const netByAddrTok = new Map<string, bigint>(); // 'addr|tok' -> net raw delta
  const sent = new Set<string>();
  const recv = new Set<string>();
  const link = (a: string, b: string) => {
    if (!counterparties.has(a)) counterparties.set(a, new Set());
    counterparties.get(a)!.add(b);
  };
  for (const l of logs) {
    const tok = lower(l.token);
    const from = lower(l.from);
    const to = lower(l.to);
    if (!tok || !from || !to) continue;
    tokenAddrs.add(tok);
    link(from, to);
    link(to, from);
    sent.add(from);
    recv.add(to);
    const raw = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
    const keyOut = `${from}|${tok}`;
    const keyIn = `${to}|${tok}`;
    netByAddrTok.set(keyOut, (netByAddrTok.get(keyOut) || BigInt(0)) - raw);
    netByAddrTok.set(keyIn, (netByAddrTok.get(keyIn) || BigInt(0)) + raw);
  }
  // Route-entry partners: registered routers/settlements/entrypoints + tx.to (the called contract).
  // AT2: tx.from is the signer — NOT a partner. It is the most likely owner of an aggregator swap.
  const partners = new Set<string>();
  if (txToL) partners.add(txToL);
  for (const a of routerAddrs(chainId)) partners.add(lower(a));
  for (const a of settlementAddrs(chainId)) partners.add(lower(a));
  for (const a of getUserEntrypointAddrs(chainId)) partners.add(lower(a));

  // AT2: unregistered multi-hop (Jupiter/1inch intermediate contracts) — treat pass-through
  // hubs as route neighbours without an RPC isContract call.
  const contractLike = new Set<string>(tokenAddrs);
  for (const a of sent) if (recv.has(a)) contractLike.add(a);
  for (const [addr, cps] of counterparties) if (cps.size >= 2) contractLike.add(addr);

  const byAddr = new Map<string, Map<string, bigint>>();
  for (const [key, v] of netByAddrTok) {
    const [addr, tok] = key.split('|');
    if (!byAddr.has(addr)) byAddr.set(addr, new Map());
    const m = byAddr.get(addr)!;
    m.set(tok, (m.get(tok) || BigInt(0)) + v);
  }

  const tradeScore = (toks: Map<string, bigint>): { trades: boolean; score: bigint } => {
    let score = BigInt(0);
    let trades = false;
    for (const [tok, delta] of toks) {
      if (isStablecoin(tok, resolveSymFromAddr(tok)) || isNativeOrWrapped(tok) || isMajorToken(chainId, tok)) continue;
      if (delta !== BigInt(0)) { trades = true; score += delta > BigInt(0) ? delta : -delta; }
    }
    return { trades, score };
  };

  const cands: AggregatorWalletCandidate[] = [];
  let txFromCand: AggregatorWalletCandidate | null = null;
  for (const [addr, toks] of byAddr) {
    if (isNonWalletAddr(addr)) continue;
    if (tokenAddrs.has(addr)) continue; // burn-to-token / token contract is not a wallet
    const { trades, score } = tradeScore(toks);
    if (!trades) continue;

    // AT2: tx.from with a real trade delta is the priority candidate (signer = owner).
    // Neighbor/infra/tx.to filters do NOT apply to the signer; isContract is checked later.
    if (txFromL && addr === txFromL) {
      txFromCand = { addr, score };
      continue;
    }
    if (txToL && addr === txToL) continue; // the called contract is never a wallet
    if (isInfra(addr)) continue;
    if (addr === traderL) continue;
    if (partners.has(addr)) continue; // registered router/settlement/entrypoint
    const cps = counterparties.get(addr);
    const onRoute = !!cps && [...cps].some(c => partners.has(c) || contractLike.has(c));
    if (!onRoute) continue;
    cands.push({ addr, score });
  }
  cands.sort((a, b) => (b.score > a.score ? 1 : b.score < a.score ? -1 : 0));
  const ordered = txFromCand ? [txFromCand, ...cands.filter(c => c.addr !== txFromCand!.addr)] : cands;
  return ordered.slice(0, 3);
}

async function attributeAggregatorSwap(
  chainId: string,
  logs: EvmTransferLog[],
  trader: string,
  txd: { from?: string; to?: string },
  evalArgs: readonly [string, (t: string) => Promise<number>, (t: string, s: string) => boolean, (t: string) => Promise<number>, string | undefined, string | undefined],
): Promise<{ wallet: string; ev: Awaited<ReturnType<typeof evaluateEvmSwap>> } | { reject: AggregatorRejectReason; cands: number }> {
  const minUsd = AGGREGATOR_MIN_USD();
  const cands = pickAggregatorWalletCandidates(chainId, logs, trader, txd);
  const seen = new Set<AggregatorRejectReason>();
  if (cands.length === 0) return { reject: 'no_candidates', cands: 0 };
  for (const cand of cands) {
    // AT1: form-deny beats isContract (0x0 has empty code → "not a contract").
    if (isNonWalletAddr(cand.addr)) { seen.add('non_wallet'); continue; }
    if (txd.to && lower(cand.addr) === lower(txd.to)) continue;
    // AT4: evaluate USD before isContract so dust does not burn getCode RPC.
    const ev = await evaluateEvmSwap(evalArgs[0], cand.addr, logs, evalArgs[1], evalArgs[2], evalArgs[3], evalArgs[4], evalArgs[5]);
    if (!(ev.subjectTok && ev.usd > 0)) { seen.add('usd_zero'); continue; }
    if (ev.usd < minUsd) {
      // AU4: +1 per below-min candidate, not once per tx.
      notifSkipped.aggregatorSkippedBelowMin++;
      seen.add('below_min');
      continue;
    }
    // Only CONFIRMED EOAs are wallets — pools/routers are contracts; a fail-open null means
    // "unknown" and we do NOT attribute (conservative: same drop as before, no false alerts).
    // AU2: one retry on null only (alternative RPC / cache bypass). true → no extra RPC.
    let code = await isContract(chainId, cand.addr);
    if (code === null && AGGREGATOR_ISCONTRACT_RETRY()) {
      code = await isContract(chainId, cand.addr, { retry: true });
    }
    if (code === true) { seen.add('is_contract'); continue; }
    if (code === null) { seen.add('getcode_unresolved'); continue; }
    return { wallet: cand.addr, ev };
  }
  for (const r of AGGREGATOR_REJECT_PRIORITY) {
    if (seen.has(r)) return { reject: r, cands: cands.length };
  }
  return { reject: 'no_candidates', cands: cands.length };
}

export function __isNonWalletAddrForTest(addr: string): boolean {
  return isNonWalletAddr(addr);
}
export function __pickAggregatorWalletCandidatesForTest(
  chainId: string,
  logs: EvmTransferLog[],
  trader: string,
  txd: { from?: string; to?: string },
): AggregatorWalletCandidate[] {
  return pickAggregatorWalletCandidates(chainId, logs, trader, txd);
}
export function __aggregatorMinUsdForTest(): number {
  return AGGREGATOR_MIN_USD();
}

// ТЗ v8 point 9 + M1: pure EVM swap valuation from Transfer logs — testable without RPC.
// decimalsOf is injectable so a fixture can simulate Polygon v3/Algebra (non-18 decimals).
// M1: multi-hop aware — subject = TERMINAL token (what the trader ends up with), and usd = the FULL
// trade amount even when the stable/quote leg never touches the trader directly (held by a router).
export interface EvmTransferLog { token: string; from: string; to: string; data: string; }
export async function evaluateEvmSwap(
  chainId: string,
  trader: string,
  logs: EvmTransferLog[],
  decimalsOf: (token: string) => Promise<number>,
  blacklistCheck: (tok: string, sym: string) => boolean,
  priceUsdOf: (tok: string) => Promise<number>,
  txValueWei?: string,
  txFrom?: string,
): Promise<{ balanceDelta: Record<string, number>; subjectTok: string; subjectDelta: number; side: string; subjectAmount: number; usd: number; selfSwap: boolean; valuation?: EvmValuationResult }> {
  const balanceDelta: Record<string, number> = {};
  const allTokens = new Set<string>();
  for (const l of logs) {
    const token = lower(l.token);
    if (!isStablecoin(token, resolveSymFromAddr(token)) && !isNativeOrWrapped(token)) allTokens.add(token);
    const raw = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
    const decimals = await decimalsOf(token);
    const value = Number(raw) / Math.pow(10, decimals);
    if (lower(l.from) === trader) balanceDelta[token] = (balanceDelta[token] || 0) - value;
    if (lower(l.to) === trader) balanceDelta[token] = (balanceDelta[token] || 0) + value;
  }
  // Blacklisted tokens take priority (so a blacklisted subject is never missed even if delta==0).
  let subjectTok = ''; let subjectDelta = 0;
  for (const tok of allTokens) {
    if (blacklistCheck(tok, '')) { subjectTok = tok; subjectDelta = balanceDelta[tok] || 0; break; }
  }
  if (!subjectTok) {
    // M1: terminal subject = non-stable/non-major token the trader RECEIVES (positive delta). If the
    // trader only sells alts (terminal is stable), fall back to the largest alt the trader sold.
    let recvTok = ''; let recvDelta = 0;
    let sentTok = ''; let sentDelta = 0;
    for (const [tok, delta] of Object.entries(balanceDelta)) {
      if (isStablecoin(tok, resolveSymFromAddr(tok)) || isNativeOrWrapped(tok) || isMajorToken(chainId, tok)) continue;
      if (delta > 0 && delta > recvDelta) { recvTok = tok; recvDelta = delta; }
      if (delta < 0 && -delta > sentDelta) { sentTok = tok; sentDelta = delta; }
    }
    if (recvTok) { subjectTok = recvTok; subjectDelta = recvDelta; }
    else { subjectTok = sentTok; subjectDelta = sentDelta; }

    // R11-G1: Fallback for major-token → native/stable swaps
    // If no subject found and trader is selling a major token for native/stable, use major token as subject
    if (!subjectTok) {
      let majorSentTok = ''; let majorSentDelta = 0;
      let hasNativeRecv = false;
      let hasStableRecv = false;

      for (const [tok, delta] of Object.entries(balanceDelta)) {
        if (isMajorToken(chainId, tok) && delta < 0 && -delta > majorSentDelta) {
          majorSentTok = tok;
          majorSentDelta = -delta;
        }
        if (isNativeOrWrapped(tok) && delta > 0) {
          hasNativeRecv = true;
        }
        if (isStablecoin(tok, resolveSymFromAddr(tok)) && delta > 0) {
          hasStableRecv = true;
        }
      }

      // If trader sent major token and received native/stable, use major as subject
      if (majorSentTok && (hasNativeRecv || hasStableRecv)) {
        subjectTok = majorSentTok;
        subjectDelta = balanceDelta[majorSentTok] || 0;
      }
    }
  }
  const side = subjectDelta >= 0 ? 'buy' : 'sell';
  const subjectAmount = Math.abs(subjectDelta);
  // M1: full trade value. Use ONLY the trader's own balance delta (candidate-scoped).
  // Do NOT scan all logs — unrelated internal transfers must not inflate valuation.
  let usd = 0;
  let valuationSource: EvmValuationResult['source'] = 'unresolved';
  let valuationConfidence: EvmValuationResult['confidence'] = 'low';
  const diagnostics: string[] = [];

  // Priority 1: trader's own stable delta (highest confidence)
  for (const [tok, delta] of Object.entries(balanceDelta)) {
    if (isStablecoin(tok, resolveSymFromAddr(tok))) {
      const stableUsd = Math.abs(delta);
      if (stableUsd > usd) {
        usd = stableUsd;
        valuationSource = 'trader_stable';
        valuationConfidence = 'high';
      }
    }
  }
  if (usd > 0) {
    diagnostics.push(`Trader stable delta: $${usd.toFixed(2)}`);
  }

  // Priority 2: trader's own wrapped-native delta
  if (usd === 0) {
    for (const [tok, delta] of Object.entries(balanceDelta)) {
      if (!WRAPPED_TO_NATIVE[lower(tok)]) continue;
      const p = await priceUsdOf(tok);
      if (!(p > 0)) continue;
      const nativeUsd = Math.abs(delta) * p;
      if (nativeUsd > usd) {
        usd = nativeUsd;
        valuationSource = 'trader_wrapped_native';
        valuationConfidence = 'high';
        diagnostics.push(`Trader native delta: ${Math.abs(delta).toFixed(6)} × $${p} = $${nativeUsd.toFixed(2)}`);
      }
    }
  }

  // Priority 3: route-linked stable scan (multi-hop through router)
  // Only consider stable transfers between addresses that appear in trader's transfers.
  // This prevents unrelated internal transfers from inflating valuation.
  if (usd === 0) {
    const routeAddrs = new Set<string>();
    routeAddrs.add(lower(trader));
    for (const l of logs) {
      if (lower(l.from) === lower(trader) || lower(l.to) === lower(trader)) {
        routeAddrs.add(lower(l.from));
        routeAddrs.add(lower(l.to));
      }
    }

    // R11-K1: Safe 2-hop — only include transfers through pools/routers in trader's route
    // Rule: 2-hop transfer counts only if ONE end is a pool/router observed in trader's route
    // AND the other end is a counterparty of the same pool/router in another route transfer
    const extendedRouteAddrs = new Set(routeAddrs);

    // Step 1: Find all pools/routers in trader's route
    // A pool/router is an address that:
    // - Interacts with trader AND
    // - Participates in transfers with >= 2 different counterparties (excluding trader)
    // This distinguishes pools/routers (which route through multiple pools) from simple counterparties
    const addressCounterparties = new Map<string, Set<string>>();
    for (const l of logs) {
      const fromLower = lower(l.from);
      const toLower = lower(l.to);

      if (!addressCounterparties.has(fromLower)) addressCounterparties.set(fromLower, new Set());
      if (!addressCounterparties.has(toLower)) addressCounterparties.set(toLower, new Set());

      addressCounterparties.get(fromLower)!.add(toLower);
      addressCounterparties.get(toLower)!.add(fromLower);
    }

    const traderRoutePools = new Set<string>();
    const traderLower = lower(trader);

    for (const l of logs) {
      const fromLower = lower(l.from);
      const toLower = lower(l.to);

      // Check if from interacts with trader
      if (fromLower === traderLower) {
        // to is a candidate pool/router if it has >= 2 counterparties excluding trader
        const counterparties = addressCounterparties.get(toLower)!;
        const nonTraderCounterparties = new Set(counterparties);
        nonTraderCounterparties.delete(traderLower);
        if (nonTraderCounterparties.size >= 2) {
          traderRoutePools.add(toLower);
        }
      }

      // Check if to interacts with trader
      if (toLower === traderLower) {
        // from is a candidate pool/router if it has >= 2 counterparties excluding trader
        const counterparties = addressCounterparties.get(fromLower)!;
        const nonTraderCounterparties = new Set(counterparties);
        nonTraderCounterparties.delete(traderLower);
        if (nonTraderCounterparties.size >= 2) {
          traderRoutePools.add(fromLower);
        }
      }
    }

    // Step 2: For each pool/router in trader's route, find all its counterparties in ALL logs
    const poolCounterparties = new Map<string, Set<string>>();
    for (const pool of traderRoutePools) {
      poolCounterparties.set(pool, new Set());
    }

    for (const l of logs) {
      const fromLower = lower(l.from);
      const toLower = lower(l.to);

      // If from is a known pool/router, add to as counterparty
      if (traderRoutePools.has(fromLower)) {
        poolCounterparties.get(fromLower)!.add(toLower);
      }

      // If to is a known pool/router, add from as counterparty
      if (traderRoutePools.has(toLower)) {
        poolCounterparties.get(toLower)!.add(fromLower);
      }
    }

    // Step 3: For 2-hop transfers, add counterparties of known pools/routers
    for (const pool of traderRoutePools) {
      const counterparties = poolCounterparties.get(pool)!;
      for (const counterparty of counterparties) {
        extendedRouteAddrs.add(counterparty);
      }
    }

    // R11-K1 (г): Calculate trader's input for bound check
    let traderInputUsd = 0;
    for (const l of logs) {
      if (lower(l.from) === lower(trader)) {
        const tok = lower(l.token);
        const raw = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
        const value = Number(raw) / Math.pow(10, await decimalsOf(tok));
        const p = await priceUsdOf(tok);
        if (p > 0) {
          traderInputUsd += value * p;
        }
      }
    }

    // L1: If traderInputUsd = 0 (native input), use tx.value as bound
    if (traderInputUsd === 0 && txValueWei && txValueWei !== '0x' && txValueWei !== '0x0') {
      try {
        const valWei = BigInt(txValueWei);
        if (valWei > BigInt(0)) {
          const wrappedAddr = CHAINS[chainId]?.wrapped;
          if (wrappedAddr) {
            const p = await priceUsdOf(wrappedAddr);
            if (p > 0) {
              traderInputUsd = (Number(valWei) / 1e18) * p;
            }
          }
        }
      } catch { /* ignore */ }
    }

    const boundLimit = traderInputUsd > 0 ? traderInputUsd * 1.1 : Infinity; // 10% slippage

    // Now check stable transfers within extended route
    let linkedStableTotal = 0;
    for (const l of logs) {
      const tok = lower(l.token);
      if (!isStablecoin(tok, resolveSymFromAddr(tok))) continue;
      if (!extendedRouteAddrs.has(lower(l.from)) || !extendedRouteAddrs.has(lower(l.to))) continue;

      const raw = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
      const value = Number(raw) / Math.pow(10, await decimalsOf(tok));
      const p = await priceUsdOf(tok);
      const transferUsd = p > 0 ? value * p : 0;

      // R11-K1 (в): dust floor — ignore if < max($1, 0.1% of best candidate)
      const dustFloor = Math.max(1, usd * 0.001);
      if (transferUsd < dustFloor) continue;

      // R11-K1 (г): bound check — total linked stable ≤ trader input × 1.1
      if (linkedStableTotal + transferUsd > boundLimit) continue;

      linkedStableTotal += transferUsd;

      if (transferUsd > usd) {
        usd = transferUsd;
        valuationSource = 'linked_route';
        valuationConfidence = 'medium';
      }
    }
    if (usd > 0) {
      diagnostics.push(`Route-linked stable: $${usd.toFixed(2)}`);
    }
  }

  // Priority 4: user-signed tx.value (only when tx.from matches trader)
  // Priority 4: user-signed tx.value (only when tx.from === trader)
  // R11-G2: Don't count tx.value for settlement transactions where tx.from !== trader
  if (usd === 0 && txValueWei && txValueWei !== '0x' && txValueWei !== '0x0') {
    // Only use tx.value if tx.from matches trader (user-signed transaction)
    const shouldUseTxValue = !txFrom || lower(txFrom) === lower(trader);
    if (shouldUseTxValue) {
      try {
        const valWei = BigInt(txValueWei);
        if (valWei > BigInt(0)) {
          const wrappedAddr = CHAINS[chainId]?.wrapped;
          if (wrappedAddr) {
            const p = await priceUsdOf(wrappedAddr);
            if (p > 0) {
              const nativeUsd = (Number(valWei) / 1e18) * p;
              usd = nativeUsd;
              valuationSource = 'tx_value';
              valuationConfidence = 'medium';
              diagnostics.push(`tx.value: ${(Number(valWei) / 1e18).toFixed(6)} × $${p} = $${nativeUsd.toFixed(2)}`);
            }
          }
        }
      } catch { /* ignore */ }
    }
  }

  // M1: Priority 5 — subject × oracle price (ALWAYS computed when subject+amount known)
  // P5 is the ground-truth trade value for known-subject swaps. P3 (linked stable) may
  // capture only fees ($478 for a $2.4K WBTC trade), so usd = max(P3, P5) within bound.
  let p5Usd = 0;
  if (subjectTok && subjectAmount > 0) {
    try {
      const p = await priceUsdOf(subjectTok);
      if (p > 0) {
        p5Usd = subjectAmount * p;
        diagnostics.push(`Subject price estimate: ${subjectAmount.toFixed(4)} × $${p} = $${p5Usd.toFixed(2)}`);
      }
    } catch { /* ignore */ }
  }
  // N2: P5 override ONLY against medium-confidence sources (linked_route P3, tx_value P4).
  // NEVER override high-confidence sources (trader_stable P1, trader_wrapped_native P2).
  const p5CanOverride = valuationSource === 'linked_route'
    || valuationSource === 'tx_value'
    || valuationSource === 'unresolved';
  if (p5Usd > 0 && p5Usd > usd && p5CanOverride) {
    // P5 exceeds P3/P4 — use P5 as authoritative trade value
    const prevSource = valuationSource;
    const prevUsd = usd;
    usd = p5Usd;
    valuationSource = 'subject_price_estimate';
    valuationConfidence = 'medium';
    if (prevUsd > 0) {
      diagnostics.push(`P5 override: ${prevSource}=$${prevUsd.toFixed(2)} < P5=$${p5Usd.toFixed(2)}, using P5`);
    }
  } else if (p5Usd > 0 && usd === 0) {
    // No other source — use P5
    usd = p5Usd;
    valuationSource = 'subject_price_estimate';
    valuationConfidence = 'medium';
  }

  // v9.32ax AX3: цена контракта — истина. Linked-route/символ, расходящиеся с P5, отбрасываются.
  if (PRICE_STRICT_CONTRACT() && p5Usd > 0 && usd > 0) {
    const ratio = usd / p5Usd;
    if (ratio > 1.25 || ratio < 0.8) {
      priceFetchStats.priceCorrectedByContract++;
      diagnostics.push(`AX3 contract-strict: ${valuationSource}=$${usd.toFixed(2)} vs P5=$${p5Usd.toFixed(2)} → P5`);
      usd = p5Usd;
      valuationSource = 'subject_price_estimate';
      valuationConfidence = 'medium';
    }
  } else if (PRICE_STRICT_CONTRACT() && !(p5Usd > 0)
    && valuationSource !== 'trader_stable' && valuationSource !== 'trader_wrapped_native') {
    if (usd > 0) priceFetchStats.priceDroppedNoContract++;
    usd = 0;
    valuationSource = 'unresolved';
    diagnostics.push('AX3 no contract price → usd_unresolved');
  }

  if (usd === 0) {
    diagnostics.push('No trader-linked valuation source available');
  }

  // v9.11 F1: self-swap detection — a same-token round trip (e.g. UP→UP through intermediate pools).
  // R11-G1: Skip selfSwap check for major-token → native/stable swaps
  // The selfSwap check is for round-trip trades (UP→UP), not for selling major tokens
  const isMajorTokenSwap = subjectTok && isMajorToken(chainId, subjectTok) && subjectDelta < 0;
  const selfSwap = !isMajorTokenSwap && allTokens.size === 1 && Math.abs(balanceDelta[Array.from(allTokens)[0]] || 0) < 1e-6;

  return { balanceDelta, subjectTok, subjectDelta, side, subjectAmount, usd, selfSwap,
    valuation: { usd, source: valuationSource, confidence: valuationConfidence, diagnostics } as EvmValuationResult };
}

// B4.5b follow-up, slice A: счётчики исходов и минутный журнал ошибок — в analyzeTelemetry
// (src/lib/evm/analyze-telemetry.ts); поведение и ключ статуса не меняются.
export function __resetEvmAnalyzeForTest(): void { analyzeTelemetry.resetEvmAnalyze(); }
const noteEvmAnalyzeHttpErr = analyzeTelemetry.noteEvmAnalyzeHttpErr;
const evmPhaseAByChain: Record<string, { checked: number; dropped: number; getCode: number }> = {};
function evmPhaseA(chainId: string): { checked: number; dropped: number; getCode: number } {
  if (!evmPhaseAByChain[chainId]) evmPhaseAByChain[chainId] = { checked: 0, dropped: 0, getCode: 0 };
  return evmPhaseAByChain[chainId];
}
export function __resetEvmPhaseAForTest(): void {
  for (const k of Object.keys(evmPhaseAByChain)) delete evmPhaseAByChain[k];
}
const EVM_PHASE_A_FILTER = () => process.env.EVM_PHASE_A_FILTER !== '0';
// bm2a: классификация причины отказа analyze-RPC — только учёт, поток управления не меняется
// (исключения после учёта пробрасываются дальше, как раньше; см. тесты 2-5/2-6/2-7).
// Статус важнее тела: 429/5xx/прочие не-2xx классифицируются по статусу даже если тело не парсится;
// bad_json определён для 2xx. В режиме 'both' причина = первый не-empty_result из [receipt, tx]
// (receipt-ответ приоритетнее) — ровно одна метка на вызов tryRpc.
async function readAnalyzeBody(r: Response): Promise<{ parsed: boolean; json: any; err: unknown }> {
  try { return { parsed: true, json: await r.json(), err: null }; }
  catch (e) { return { parsed: false, json: null, err: e }; }
}
type EvmAnalyzeFetch = (url: string, init: RequestInit, timeoutMs?: number) => Promise<Response>;
let evmAnalyzeFetchImpl: EvmAnalyzeFetch | null = null;
export function __setEvmAnalyzeFetchForTest(fn: EvmAnalyzeFetch | null): void { evmAnalyzeFetchImpl = fn; }
async function evmAnalyzeFetch(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
  if (evmAnalyzeFetchImpl) return evmAnalyzeFetchImpl(url, init, timeoutMs);
  return fetchWithTimeout(url, init, timeoutMs);
}
// v9.32bm1 S6: тест-хуки состояния (без логики) — пауза между попытками analyze-пути
let evmAnalyzeSleepImpl: ((ms: number) => Promise<void>) | null = null;
export function __setEvmAnalyzeSleepForTest(fn: ((ms: number) => Promise<void>) | null): void { evmAnalyzeSleepImpl = fn; }

/** Phase A: drop only pure infra↔infra Transfer graphs. Fail-open on anything ambiguous. */
export function evmReceiptWorthAnalyzing(
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }> },
  chainId: string,
  extraKnown?: string[],
): boolean {
  if (!EVM_PHASE_A_FILTER()) return true;
  const known = new Set<string>();
  const add = (a: string) => { if (a) known.add(lower(a)); };
  for (const a of getSubscribedAddrs(chainId)) add(a);
  for (const a of getRouterAddrs(chainId)) add(a);
  for (const a of getSettlementAddrs(chainId)) add(a);
  for (const a of (state.chain[chainId]?.watchPools || [])) add(a);
  for (const a of extraKnown || []) add(a);
  const logs = receipt?.logs || [];
  if (!logs.length) return true;
  if (phaseATouchesIntentExecutor(receipt, chainId)) return true; // v9.32am HOTFIX+ (MD3)
  let sawTransfer = false;
  for (const logEntry of logs) {
    const topics = (logEntry.topics || []) as string[];
    if (!topics.length) continue;
    if (lower(topics[0]) === COW_TRADE_TOPIC) return true;
    if (topics.length < 3) continue;
    if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;
    sawTransfer = true;
    const token = lower(logEntry.address || '');
    const from = lower('0x' + String(topics[1]).slice(-40));
    const to = lower('0x' + String(topics[2]).slice(-40));
    if (isStablecoin(token, '')) return true;
    if (!known.has(from) || !known.has(to)) return true;
  }
  if (!sawTransfer) return true;
  return false;
}


const EVM_PHASE_A_GETCODE = () => process.env.EVM_PHASE_A_GETCODE !== '0';
const EVM_PHASE_A_GETCODE_MAX = () => Number(process.env.EVM_PHASE_A_GETCODE_MAX || '8');

function phaseAHasCowOrStableOrEmpty(
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }> },
  _chainId: string,
): boolean {
  const logs = receipt?.logs || [];
  if (!logs.length) return true;
  for (const logEntry of logs) {
    const topics = (logEntry.topics || []) as string[];
    if (!topics.length) continue;
    if (lower(topics[0]) === COW_TRADE_TOPIC) return true;
    if (topics.length < 3) continue;
    if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;
    const token = lower(logEntry.address || '');
    if (isStablecoin(token, '')) return true;
  }
  return false;
}

function phaseAUnknownCounterparties(
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }> },
  chainId: string,
): string[] {
  const known = new Set<string>();
  const add = (a: string) => { if (a) known.add(lower(a)); };
  for (const a of getSubscribedAddrs(chainId)) add(a);
  for (const a of getRouterAddrs(chainId)) add(a);
  for (const a of getSettlementAddrs(chainId)) add(a);
  for (const a of (state.chain[chainId]?.watchPools || [])) add(a);
  const out: string[] = [];
  const seen = new Set<string>();
  for (const logEntry of receipt?.logs || []) {
    const topics = (logEntry.topics || []) as string[];
    if (topics.length < 3) continue;
    if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;
    for (const raw of [topics[1], topics[2]]) {
      const addr = lower('0x' + String(raw).slice(-40));
      if (!addr || known.has(addr) || seen.has(addr)) continue;
      seen.add(addr);
      out.push(addr);
    }
  }
  return out;
}

/** v9.32am HOTFIX+ (MD3): intent-исполнители (эмиттер или контрагент) проходят Phase A без вопросов. */
export function phaseATouchesIntentExecutor(
  receipt: { logs?: Array<{ topics?: string[]; address?: string }> },
  chainId: string,
): boolean {
  for (const logEntry of receipt?.logs || []) {
    if (isIntentExecutorAddr(chainId, String(logEntry?.address || ''))) return true;
    const topics = (logEntry?.topics || []) as string[];
    if (topics.length >= 3 && lower(String(topics[0])) === TRANSFER_TOPIC.toLowerCase()) {
      for (const raw of [topics[1], topics[2]]) {
        if (isIntentExecutorAddr(chainId, '0x' + String(raw).slice(-40))) return true;
      }
    }
  }
  return false;
}

/** Phase A + getCode: unknown counterparties that are contracts join extraKnown. Fail-open otherwise. */
export async function evmReceiptWorthAnalyzingAsync(
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }> },
  chainId: string,
): Promise<boolean> {
  if (!EVM_PHASE_A_FILTER()) return true;
  if (phaseAHasCowOrStableOrEmpty(receipt, chainId)) return true;
  if (phaseATouchesIntentExecutor(receipt, chainId)) return true; // v9.32am HOTFIX+ (MD3)
  const unknown = phaseAUnknownCounterparties(receipt, chainId);
  if (unknown.length === 0) return evmReceiptWorthAnalyzing(receipt, chainId);
  if (!EVM_PHASE_A_GETCODE()) return true;
  if (unknown.length > EVM_PHASE_A_GETCODE_MAX()) return true;
  const extra: string[] = [];
  for (const addr of unknown) {
    const c = await isContract(chainId, addr);
    evmPhaseA(chainId).getCode++;
    if (c === null || c === false) return true;
    extra.push(addr);
  }
  return evmReceiptWorthAnalyzing(receipt, chainId, extra);
}

export function evmReceiptWorthAnalyzingStrict(
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }> },
  chainId: string,
  extraKnown?: string[],
): boolean {
  // Stricter: no Transfer graph at all → drop (paroled pools).
  const logs = receipt?.logs || [];
  let saw = false;
  for (const logEntry of logs) {
    const topics = (logEntry.topics || []) as string[];
    if (topics.length >= 3 && lower(topics[0]) === TRANSFER_TOPIC.toLowerCase()) saw = true;
    if (topics[0] && lower(topics[0]) === COW_TRADE_TOPIC) return true;
  }
  if (!saw) return false;
  return evmReceiptWorthAnalyzing(receipt, chainId, extraKnown);
}

// v9.32al CK3 (KD3): суб-solo фильтр суммы — после phase-A, ДО платных txByHash/getCode.
// Инцидент: 800 receipt + 620 txByHash + 690 getCode вызовов/мин, из них большинство — бот-микрострейды
// горячих мемкоинов. Оценка из receipt (без RPC): stable-ноги напрямую (dual-decimals 6/18 — реальный
// dec один из двух, берём max = fail-open к функции), wrapped-native — по cached-цене; иначе unknown.
// Порог SUBSOLO_MIN_USD, дефолт min(solo, seriesMin) — series-функция (события ≥ $1K) не режется.
let subSoloDropped = 0;
let intentExecutorForced = 0;
// v9.32am AM5 (LD5/AL5): intent-исполнители (settlement-реестр: CoW/Fusion/UniswapX/…) —
// форс-фаза-B: их кадры — почти всегда сделки, сумма из receipt часто недоказуема
// (intent-netting: stable-ноги владельца нет на чеке — CRV-инцидент 0x990dbe, $8 964 дроп).
// v9.32am HOTFIX+ (MD3): executor-класс расширен — settlement | reactor | solver-helper
// (роутеры НЕ входят: их кадры — основной поток, форс фазы-B для каждого = CU-взрыв;
// миграция Task 2 уже режет болтливые). Инцидент AM5: 0x506c… (кадр с ROUTER-подписки,
// stable-ноги владельца нет) и 3× self_swap от 0x1007…/0x6f12…/0xa926… (executor-контракты).
function isIntentExecutorAddr(chainId: string, addr: string | undefined): boolean {
  if (!addr) return false;
  return isSettlementAddr(chainId, addr) || isReactor(chainId, addr) || isSolverHelper(chainId, addr);
}
// форс и по ЭМИТТЕРАМ чека: Trade/fill события приходят ОТ settlement/reactor-контрактов —
// кадр мог приехать с роутер-/токен-подписки (opts.pool мимо реестра), а сделка — intent.
function receiptTouchesIntentExecutor(chainId: string, receipt: { logs?: Array<{ address?: string }> } | null, pool?: string): boolean {
  if (isIntentExecutorAddr(chainId, pool)) return true;
  if (!receipt?.logs || !Array.isArray(receipt.logs)) return false;
  for (const lg of receipt.logs) if (isIntentExecutorAddr(chainId, String(lg?.address || ''))) return true;
  return false;
}
let intentNoSubject = 0; // v9.32am HOTFIX+ (MD3): executor netting без владельца — НЕ self_swap
export function __intentExecutorForcedForTest(): number { return intentExecutorForced; }
export function __intentNoSubjectForTest(): number { return intentNoSubject; }
const EVM_SUBSOLO_FILTER = () => (process.env.EVM_SUBSOLO_FILTER ?? '1') === '1';
function evmSubSoloMinUsd(): number {
  const envRaw = Number(process.env.SUBSOLO_MIN_USD || '0');
  if (envRaw > 0) return envRaw;
  return Math.min(settings.soloAmountUsd, settings.seriesTxMinUsd);
}
export function evmReceiptUsdBound(chainId: string, receipt: { logs?: Array<{ address?: string; topics?: string[]; data?: string }> } | null): number {
  if (!receipt?.logs?.length) return 0;
  const wrapped = lower(CHAINS[chainId]?.wrapped || '');
  const natPrice = wrapped ? getCachedNativePriceUsd(chainId) : 0;
  let best = 0;
  let unknownWrapped = false; // v9.32al: wrapped-нога при холодной цене native — НЕ шум (FOLD-фикстура: реальная продажа $7 991); fail-open
  for (const lg of receipt.logs) {
    const t = lg.topics || [];
    if (t.length < 3 || lower(String(t[0] || '')) !== lower(TRANSFER_TOPIC)) continue;
    const token = lower(String(lg.address || ''));
    let raw = BigInt(0);
    try { raw = BigInt(String(lg.data || '0x') === '0x' ? 0 : String(lg.data)); } catch { continue; }
    if (isStablecoin(token, '')) {
      best = Math.max(best, Number(raw) / 1e6, Number(raw) / 1e18); // реальный dec один из двух — max = fail-open
    } else if (wrapped && token === wrapped) {
      if (natPrice > 0) best = Math.max(best, (Number(raw) / 1e18) * natPrice);
      else unknownWrapped = true;
    }
  }
  if (unknownWrapped && best < Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY; // цена неизвестна — сумму доказать нельзя → fail-open
  return best;
}
export function __subSoloDroppedForTest(): number { return subSoloDropped; }
// v4-flash accounting не даёт классических Transfer-ног пула, а верифицированные якоря —
// кураторский набор: их кадры не режутся эвристикой суммы (FOLD-фикстура: реальная продажа $7 991).
function isEvmVerifiedAnchor(chainId: string, pool: string): boolean {
  const p = lower(pool);
  return VERIFIED_ANCHOR_POOLS.some(a => a.chainId === chainId && ((a.pool && lower(a.pool) === p) || (a.poolId && a.poolId.toLowerCase() === p)));
}
// B4.5b follow-up, slice A: pool analyzed/alert stats and parole moved to analyzeTelemetry
// (src/lib/evm/analyze-telemetry.ts); exported names and behaviour are preserved.
export function noteEvmPoolAnalyzed(chainId: string, pool: string): void { analyzeTelemetry.notePoolAnalyzed(chainId, pool); }
export function noteEvmPoolAlert(chainId: string, pool: string): void { analyzeTelemetry.notePoolAlert(chainId, pool); }
export function __noteEvmPoolAnalyzedForTest(chainId: string, pool: string): void { analyzeTelemetry.notePoolAnalyzed(chainId, pool); }
export function __noteEvmPoolAlertForTest(chainId: string, pool: string): void { analyzeTelemetry.notePoolAlert(chainId, pool); }
export function __evmPoolParoledForTest(chainId: string, pool: string): boolean { return analyzeTelemetry.poolParoled(chainId, pool); }
export function __resetEvmPoolStatsForTest(): void { analyzeTelemetry.resetPoolStats(); }
export function evmParoledPools(): Array<{ chainId: string; pool: string; analyzed: number }> { return analyzeTelemetry.paroledPools(); }

export async function __analyzeEvmTxForTest(chainId: string, txHash: string, opts?: { strictPhaseA?: boolean; requireDexSwap?: boolean; pool?: string; skipSubSoloFilter?: boolean }): Promise<any> {
  return analyzeEvmTx(chainId, txHash, undefined, opts);
}
async function analyzeEvmTx(chainId: string, txHash: string, rpcOverride?: string, opts?: { strictPhaseA?: boolean; requireDexSwap?: boolean; pool?: string; skipSubSoloFilter?: boolean; onDiagnostics?: (d: ArbitrumAnalysisDiagnostics) => void }): Promise<{
  found: boolean; trader: string; resolvedBy: string; subject: { address: string; symbol: string | null } | null;
  side: string; usd: number; subjectAmount: number; blockTs: number | null; rpcOk: boolean; error?: string | null;
  attributedPool?: { pool: string; side: 'buy' | 'sell' } | null; // v9.32ak AK4
  swapEmitterPool?: string | null; // ТЗ bm2n6 п.3: валидированный пул из Swap-эмиттера чека
  otherToken?: { address: string; symbol: string | null; amount: number } | null; // v9.32at AT6
}> {
  const diagnostic = chainId === 'arbitrum' ? beginArbitrumAnalyzeDiagnostic(txHash) : null;
  const cullStarvedBeforeSelection = rpcRouting.cullStarvedCount();
  const alchemy = alchemyHttpUrl(chainId);
  const publicRpcs = rpcOverride ? [rpcOverride] : evmPublicRpcPool(chainId).filter(u => u && u !== alchemy);
  if (diagnostic) {
    diagnostic.usedCullFallback = rpcRouting.cullStarvedCount() > cullStarvedBeforeSelection;
    diagnostic.poolSelection = publicRpcs.map((rpc, position) => {
      const host = rpcHostOf(rpc);
      const health = rpcRouting.analyzeHealth(host);
      const total = health.ok + health.fail;
      return {
        endpointAlias: arbDiagnosticAlias(rpc, 'public_pool', position), position,
        failRate: total ? Math.round(health.fail / total * 1000) / 1000 : 0,
        culled: rpcRouting.isHostCulled(host),
        cullReason: rpcRouting.cullReasonOf(host),
      };
    });
  }
  const fail = { found: false, trader: '', resolvedBy: '', subject: null, side: '', usd: 0, subjectAmount: 0, blockTs: null, rpcOk: false, error: null as string | null } as const;
  const complete = <T extends { found: boolean; error?: string | null }>(result: T): T => {
    finishArbitrumAnalyzeDiagnostic(diagnostic, result.error || (result.found ? 'found' : 'not_found'));
    // ТЗ 2026-10-12 (WP-D2): к этому моменту все попытки ЭТОГО анализа завершены — отдаём их
    // пер-тx следовому колбэку (улика дропа кадра), пока кольцо диагностик их ещё хранит.
    // F1-A (фикс ревью 2026-10-12): attemptsStarted берём из живого экземпляра анализа,
    // чтобы вытеснение записи анализа из map (32) и кольца попыток (64) не делало неполный
    // след «полным». Телеметрия также сохраняет attemptsStarted в отдельном индексе.
    if (diagnostic && opts?.onDiagnostics) {
      const attempts = analyzeTelemetry.arbitrumAttemptOutcomesFor(diagnostic.analysisId);
      opts.onDiagnostics({
        analysisIds: [diagnostic.analysisId],
        rpcAttemptIds: attempts.rpcAttemptIds,
        outcomes: attempts.outcomes,
        attemptsStarted: diagnostic.attemptsStarted ?? attempts.attemptsStarted,
      });
    }
    return result;
  };
  if (!publicRpcs.length && !alchemy) return complete({ ...fail, found: false, error: 'rpc_unavailable' });

  let receipt: any = null;
  let txd: any = null;
  let usedRpc: string | null = null;
  let txCycled = false; // v9.32bm1 S5: rpcsCycle('tx') уже проходил по пулу — не повторять
  // v9.32bm1 S6 (S-AR13): 2 попытки по пулу (паузы 400/1200), таймаут настраивается.
  const EVM_ANALYZE_RETRY = () => { const n = Number(process.env.EVM_ANALYZE_RETRY ?? '2'); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 2; };
  const EVM_ANALYZE_TIMEOUT_MS = () => { const n = Number(process.env.EVM_ANALYZE_TIMEOUT_MS || '9000'); return Number.isFinite(n) && n > 0 ? n : 9000; };
  const EVM_ANALYZE_RETRY_SLEEPS = [400, 1200];
  const analyzeSleep = (ms: number) => (evmAnalyzeSleepImpl ? evmAnalyzeSleepImpl(ms) : sleep(ms));
  let diagnosticCycleSequence = 0;
  const diagnosticAttemptByResponse = new WeakMap<Response, ArbitrumRpcAttemptDiagnostic>();
  type AttemptRoute = { cycle: number; pass: number; stage: 'public_pool' | 'alchemy_fallback'; poolPosition: number | null };

  async function tryRpc(rpc: string, mode: 'both' | 'receipt' | 'tx' = 'both', route: AttemptRoute): Promise<boolean> {
    // v9.32al CK1: analysis-кадры — solo-класс: receipt/tx на alchemy-фолбэке ждут бакет ≤30с,
    // но НЕ дропаются (функция не режется; суб-solo-шум отрезается позже фильтром суммы CK3)
    if (rpc === alchemy) await alchemyCuGate(mode === 'tx' ? alchemyCuCost('eth_getTransactionByHash') : alchemyCuCost('eth_getTransactionReceipt'), { solo: true, src: mode === 'tx' ? 'txByHash' : 'receipt' });
    if (rpc === alchemy && mode !== 'tx') bumpRpcSpend('alchemy', 'analysis');
    evmAnalyze.tried++;
    const attemptIds: string[] = [];
    const decideAttempts = (decision: ArbitrumTryDecision): void => setArbitrumTryDecision(attemptIds, decision);
    const post = async (id: number, method: 'eth_getTransactionReceipt' | 'eth_getTransactionByHash') => {
      const attempt = diagnostic ? beginArbitrumRpcAttempt(diagnostic, {
        cycle: route.cycle, mode, pass: route.pass, method, stage: route.stage, rpc, poolPosition: route.poolPosition,
      }) : null;
      if (attempt) attemptIds.push(attempt.rpcAttemptId);
      recordRpcCall(method, classifyRpcSource(rpc), 'analysis');
      let r: Response;
      try {
        r = await evmAnalyzeFetch(rpc, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ jsonrpc: '2.0', id, method, params: [txHash] }),
        }, EVM_ANALYZE_TIMEOUT_MS());
      } catch (e) {
        if (attempt) finishArbitrumRpcAttempt(attempt, null, false, null, e);
        throw e;
      }
      if (attempt) { attempt.httpStatus = r.status; diagnosticAttemptByResponse.set(r, attempt); }
      if (rpc === alchemy) noteAlchemyHttpStatus((r as Response).status); // v9.32al CK5
      return r;
    };
    const finishResponseAttempt = (response: Response, parsed: boolean, json: unknown): void => {
      const attempt = diagnosticAttemptByResponse.get(response);
      if (attempt) finishArbitrumRpcAttempt(attempt, response, parsed, json);
    };
    const markUnobservedResponses = (): void => { analyzeTelemetry.markArbitrumAttemptsUnobserved(attemptIds); };
    // bm2a: единственная точка fail-учёта с причиной (guard по alchemy — как у прежних вызовов).
    // Исключения от post() и боди-парсинга классифицируются и ПРОБРАСЫВАЮТСЯ — поток ретраев
    // (catch в rpcsCycle → noteEvmAnalyzeHttpErr) не меняется; меняется только то, что отказ
    // теперь ещё и виден в fail/failBy, а не только в httpErr.
    const markFail = (reason: AnalyzeFailReason): void => { if (rpc !== alchemy) markEvmPublicRpcResult(rpc, false, chainId, 'analyze', reason); };
    if (mode === 'receipt') {
      let receiptRes: Response;
      try { receiptRes = await post(1, 'eth_getTransactionReceipt'); }
      catch (e) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeException(e)); throw e; }
      const rb = await readAnalyzeBody(receiptRes);
      finishResponseAttempt(receiptRes, !rb.err, rb.json);
      if (rb.err) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeResponse(receiptRes, false, null)); throw rb.err; }
      receipt = rb.json?.result;
      if (receipt) { decideAttempts('success'); if (rpc !== alchemy) markEvmPublicRpcResult(rpc, true, chainId, 'analyze'); usedRpc = rpc; return true; }
      decideAttempts('continue_after_response');
      markFail(classifyAnalyzeResponse(receiptRes, true, rb.json));
      return false;
    }
    if (mode === 'tx') {
      let txRes: Response;
      try { txRes = await post(2, 'eth_getTransactionByHash'); }
      catch (e) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeException(e)); throw e; }
      const tb = await readAnalyzeBody(txRes);
      finishResponseAttempt(txRes, !tb.err, tb.json);
      if (tb.err) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeResponse(txRes, false, null)); throw tb.err; }
      txd = tb.json?.result;
      if (txd) {
        decideAttempts('success');
        usedRpc = rpc;
        if (rpc === alchemy) evmAnalyze.alchemyUsed++;
        else { evmAnalyze.publicOk++; markEvmPublicRpcResult(rpc, true, chainId, 'analyze'); }
        return true;
      }
      decideAttempts('continue_after_response');
      markFail(classifyAnalyzeResponse(txRes, true, tb.json));
      return false;
    }
    let receiptRes: Response, txRes: Response;
    try {
      [receiptRes, txRes] = await Promise.all([
        post(1, 'eth_getTransactionReceipt'),
        post(2, 'eth_getTransactionByHash'),
      ]);
    } catch (e) {
      decideAttempts('continue_after_error');
      markUnobservedResponses();
      markFail(classifyAnalyzeException(e));
      throw e;
    }
    const rb = await readAnalyzeBody(receiptRes);
    const tb = await readAnalyzeBody(txRes);
    finishResponseAttempt(receiptRes, !rb.err, rb.json);
    finishResponseAttempt(txRes, !tb.err, tb.json);
    if (rb.err) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeResponse(receiptRes, false, null)); throw rb.err; }
    if (tb.err) { decideAttempts('continue_after_error'); markFail(classifyAnalyzeResponse(txRes, false, null)); throw tb.err; }
    // v9.32bm1 S5: уже полученный receipt/txd не затирается пустым ответом следующей ноды
    const rc = rb.json?.result;
    const tx = tb.json?.result;
    if (rc) receipt = rc;
    if (tx) txd = tx;
    if (receipt && txd) {
      decideAttempts('success');
      usedRpc = rpc;
      if (rpc === alchemy) evmAnalyze.alchemyUsed++;
      else { evmAnalyze.publicOk++; markEvmPublicRpcResult(rpc, true, chainId, 'analyze'); }
      return true;
    }
    decideAttempts('continue_after_response');
    const rr = classifyAnalyzeResponse(receiptRes, true, rb.json); // bm2a: receipt-ответ приоритетнее tx
    markFail(rr !== 'empty_result' ? rr : classifyAnalyzeResponse(txRes, true, tb.json));
    return false;
  }

  const rpcsCycle = async (mode: 'both' | 'receipt' | 'tx') => {
    const nPublic = EVM_ANALYZE_RETRY();
    const cycle = ++diagnosticCycleSequence;
    if (nPublic > 0) {
      for (let attempt = 0; attempt < nPublic; attempt++) {
        if (attempt > 0) await analyzeSleep(EVM_ANALYZE_RETRY_SLEEPS[Math.min(attempt - 1, EVM_ANALYZE_RETRY_SLEEPS.length - 1)]);
        for (const [poolPosition, rpc] of publicRpcs.entries()) {
          try { if (await tryRpc(rpc, mode, { cycle, pass: attempt + 1, stage: 'public_pool', poolPosition })) return true; } catch (e) { noteEvmAnalyzeHttpErr(chainId, rpc, mode, e); }
        }
      }
    }
    if (alchemy && !rpcOverride) {
      try { if (await tryRpc(alchemy, mode, { cycle, pass: nPublic + 1, stage: 'alchemy_fallback', poolPosition: null })) return true; } catch (e) { noteEvmAnalyzeHttpErr(chainId, alchemy, mode, e); }
    }
    return false;
  };

  if (EVM_PHASE_A_FILTER()) {
    await rpcsCycle('receipt');
    if (receipt) {
      const pa = evmPhaseA(chainId);
      pa.checked++;
      const keep = opts?.strictPhaseA
        ? evmReceiptWorthAnalyzingStrict(receipt, chainId)
        : await evmReceiptWorthAnalyzingAsync(receipt, chainId);
      if (!keep) {
        pa.dropped++;
        return complete({ ...fail, found: false, rpcOk: true, error: 'phaseA_dropped' });
      }
      if (opts?.requireDexSwap) {
        const known = new Set<string>();
        for (const a of getSubscribedAddrs(chainId)) known.add(lower(a));
        for (const a of getRouterAddrs(chainId)) known.add(lower(a));
        for (const a of getSettlementAddrs(chainId)) known.add(lower(a));
        for (const a of (state.chain[chainId]?.watchPools || [])) known.add(lower(a));
        // v9.32an ND1: intent-чек (эмиттер/контрагент — registry-executor) не обязан выглядеть
        // как dex-своп — solver-netting часто без стандартного pool-паттерна (AN1-AN3).
        if (!receiptTouchesIntentExecutor(chainId, receipt, opts?.pool) && !evmReceiptLooksLikeDexSwap(receipt, known)) {
          pa.dropped++;
          return complete({ ...fail, found: false, rpcOk: true, error: 'phaseA_dropped' });
        }
      }
      // v9.32al CK3: суб-solo фильтр — ДО платного txByHash (главный путь прод-кадров)
      if (EVM_SUBSOLO_FILTER() && !opts?.skipSubSoloFilter) {
        const paroled = opts?.pool ? analyzeTelemetry.poolParoled(chainId, opts.pool) : false;
        const anchor = opts?.pool ? isEvmVerifiedAnchor(chainId, opts.pool) : false;
        const executor = receiptTouchesIntentExecutor(chainId, receipt, opts?.pool); // v9.32am AM5 + HOTFIX+ MD3: и эмиттеры чека
        if (executor) {
          intentExecutorForced++;
        } else if (!paroled && !anchor && evmReceiptUsdBound(chainId, receipt) < evmSubSoloMinUsd()) {
          subSoloDropped++;
          return complete({ ...fail, found: false, rpcOk: true, error: 'sub_solo_dropped' });
        }
      }
      await rpcsCycle('tx');
      txCycled = true;
    }
  }
  // v9.32bm1 S5 (S-AR10): receipt, полученный выше, НЕ выбрасывается. Раньше `receipt = null;
  // rpcsCycle('both')` перезапрашивал receipt по тому же пулу, а провал писался как
  // receipt_missing (ложь: receipt был — потерян txd). Прод bl: base receipt_missing 22.3%.
  if (!receipt) {
    txd = null;
    await rpcsCycle('both');
    // v9.32al CK3: тот же фильтр на fallback-пути (receipt уже есть — платный путь дальше не идёт)
    if (receipt && EVM_SUBSOLO_FILTER() && !opts?.skipSubSoloFilter) {
      const paroled = opts?.pool ? analyzeTelemetry.poolParoled(chainId, opts.pool) : false;
      const anchor = opts?.pool ? isEvmVerifiedAnchor(chainId, opts.pool) : false;
      const executor = receiptTouchesIntentExecutor(chainId, receipt, opts?.pool); // v9.32am AM5 + HOTFIX+ MD3
      if (executor) {
        intentExecutorForced++;
      } else if (!paroled && !anchor && evmReceiptUsdBound(chainId, receipt) < evmSubSoloMinUsd()) {
        subSoloDropped++;
        return complete({ ...fail, found: false, rpcOk: true, error: 'sub_solo_dropped' });
      }
    }
  }
  if (receipt && !txd && !txCycled) { await rpcsCycle('tx'); txCycled = true; } // S5: только txd, receipt на месте

  if (!receipt) { evmAnalyze.failed++; return complete({ ...fail, found: false, rpcOk: false, error: 'receipt_missing' }); }
  if (!txd) { evmAnalyze.failed++; evmAnalyze.txMissing++; return complete({ ...fail, found: false, rpcOk: false, error: 'tx_missing' }); } // v9.32bm1 S5
  try {
    const r = await analyzeEvmTrade(chainId, txd, receipt, usedRpc, txHash);
    return complete({ ...fail, ...r, rpcOk: r.rpcOk });
  } catch { return complete({ ...fail, found: false, rpcOk: true, error: 'tx_not_swap' }); }
}

// M1/M2/M3: pure-ish analysis of an EVM swap from tx + receipt (no block fetch). Exposed as a
// separate function so multi-hop / contract-filter / settlement-owner logic is unit-testable with
// synthetic fixtures. `rpc` is optional (only needed for blockTs enrichment).
async function analyzeEvmTrade(
  chainId: string,
  txd: { from?: string; to?: string; hash?: string },
  receipt: { logs?: Array<{ topics?: string[]; address?: string; data?: string }>; blockNumber?: string; transactionHash?: string },
  rpc?: string | null,
  txHash?: string | null,
): Promise<{
  found: boolean; trader: string; resolvedBy: string; subject: { address: string; symbol: string | null } | null;
  side: string; usd: number; subjectAmount: number; blockTs: number | null; rpcOk: boolean; error?: string | null;
  attributedPool?: { pool: string; side: 'buy' | 'sell' } | null; // v9.32ak AK4
  swapEmitterPool?: string | null; // ТЗ bm2n6 п.3: валидированный пул из Swap-эмиттера чека
  otherToken?: { address: string; symbol: string | null; amount: number } | null; // v9.32at AT6
}> {
  const fail = { found: false, trader: '', resolvedBy: '', subject: null, side: '', usd: 0, subjectAmount: 0, blockTs: null, rpcOk: true, error: null as string | null };
  const logs: EvmTransferLog[] = [];
  let settlementOwnerFromEvent: string | null = null;
  for (const logEntry of receipt.logs || []) {
    const topics = logEntry.topics as string[] | undefined;
    if (!topics || topics.length < 1) continue;
    if (lower(topics[0]) === COW_TRADE_TOPIC && topics.length >= 2) {
      // Trade(owner, sellToken, buyToken, ...): owner = indexed topic[1]
      settlementOwnerFromEvent = '0x' + topics[1].slice(-40).toLowerCase();
    }
    if (topics.length < 3) continue;
    if (lower(topics[0]) !== TRANSFER_TOPIC.toLowerCase()) continue;
    logs.push({
      token: lower(logEntry.address || ''),
      from: '0x' + topics[1].slice(-40),
      to: '0x' + topics[2].slice(-40),
      data: logEntry.data || '0x0',
    });
  }

  // M3: detect intent/settlement protocols (CoW, ParaSwap/Velora, UniswapX, 1inch Fusion, ...).
  // The Trade event may be absent on some RPCs, so also treat it as a settlement when the transfer
  // stream involves any known settlement contract. For these, tx.from is the agent/filler (a
  // contract) — sender_eoa is FORBIDDEN; we resolve the real owner and require it to be an EOA.

  // R11-K2: Priority check — if txd.to is user entrypoint (AllowanceHolder) AND txd.from is EOA,
  // then flow remains sender_eoa (user-signed transaction through entrypoint).
  // Settlement flow should NOT activate for user entrypoint transactions.
  const isUserEntrypointTx = isUserEntrypoint(chainId, txd.to || '');
  const txFromIsEOA = txd.from ? !(await isContract(chainId, txd.from)) : false;
  const isUserSignedEntrypointTx = isUserEntrypointTx && txFromIsEOA;

  const streamInvolvesSettlement = logs.some(l => settlementAddrs(chainId).includes(lower(l.from)) || settlementAddrs(chainId).includes(lower(l.to)));
  const isSettlement = !isUserSignedEntrypointTx && (
    isSettlementAddr(chainId, txd.to || '') || isSettlementAddr(chainId, txd.from || '') ||
    settlementOwnerFromEvent !== null || streamInvolvesSettlement
  );

  // Effective trader: for a settlement (CoW, Fusion, UniswapX, Velora), resolve the real owner; otherwise the tx sender.
  let trader = lower(txd.from || '');
  let resolvedBy = 'sender_eoa';
  let settlementAddr: string | null = null;
  let settlementCandidate: SettlementTradeCandidate | null = null;
  if (isSettlement) {
    settlementAddr = settlementAddrs(chainId).find(a => a === lower(txd.to) || a === lower(txd.from))
      || logs.map(l => lower(l.from)).concat(logs.map(l => lower(l.to))).find(a => settlementAddrs(chainId).includes(a))
      || COW_SETTLEMENT;
    resolvedBy = 'settlement_owner';

    // R11-F7: Try verified protocol adapter first (CoW Trade event)
    const adapterCandidates = decodeSettlementTrades(chainId, '', receipt as any, routerAddrs(chainId), settlementAddrs(chainId));
    if (adapterCandidates.length > 0 && adapterCandidates[0].owner && adapterCandidates[0].confidence === 'high') {
      settlementCandidate = adapterCandidates[0];
      trader = settlementCandidate.owner!;
      resolvedBy = `protocol_adapter:${settlementCandidate.protocol}`;
    } else if (settlementOwnerFromEvent) {
      trader = settlementOwnerFromEvent;
      } else {
        const ownerGuess = await recoverSettlementOwnerAsync(chainId, logs, settlementAddr);
        if (ownerGuess) {
          trader = ownerGuess;
        } else if (isIntentExecutorAddr(chainId, txd.from)) {
          // v9.32am HOTFIX+ (MD3): registry-executor без восстановимого владельца — intent-netting,
          // НЕ «контракт-торговец»: честная метка intent_no_subject (не contract_from/self_swap).
          intentNoSubject++;
          return { ...fail, trader, resolvedBy: 'settlement_owner', error: 'intent_no_subject' };
        } else {
          return { ...fail, trader, resolvedBy: 'settlement_owner', error: 'contract_from' };
        }
      }
  } else if (isUserEntrypoint(chainId, txd.to || '') || isUserEntrypoint(chainId, txd.from || '')) {
    // User-signed transaction through a router or allowance-holder (e.g. 0x AllowanceHolder)
    const entrypoints = getUserEntrypointAddrs(chainId);
    settlementAddr = entrypoints.find(a => a === lower(txd.to) || a === lower(txd.from)) || null;
    resolvedBy = 'sender_eoa';
  }

  // M2/M3: contract sender / contract owner → do NOT detect (cuts MEV, market-makers, Safe, and
  // settlement owners that are contracts). Applied BEFORE swap detection (incl. before multi-hop).
  // getCode result is cached by address (contracts don't change), so this is cheap.
  const senderIsContract = await isContract(chainId, trader);
  if (senderIsContract === true) {
    // v9.32am HOTFIX+ (MD3): trader = сам registry-executor (владелец НЕ восстановлен) —
    // intent-netting; владелец-контракт (MM/Safe, trader ≠ from) режется как раньше.
    if (isIntentExecutorAddr(chainId, txd.from) && trader === lower(txd.from || '')) {
      intentNoSubject++;
      return { ...fail, trader, resolvedBy, error: 'intent_no_subject' };
    }
    return { ...fail, trader, resolvedBy, error: 'contract_from' };
  }
  // Fail-open (unresolved RPC): do not silently drop — keep analyzing; the swap gate below decides.

  // R11-K2: For AllowanceHolder batch settlement, parse calldata to determine subject
  // if trader doesn't participate in ERC20 transfers directly
  let allowanceHolderSubject: string | null = null;
  if (isUserSignedEntrypointTx && logs.length > 0 && (txd as any).input && (txd as any).input.length >= 138) {
    // Parse calldata for AllowanceHolder batch settlement
    // Function selector 0x2213bc0b = swap(address hub, address token, ...)
    const input = (txd as any).input;
    const selector = input.slice(0, 10);
    if (selector === '0x2213bc0b') {
      // Extract token address from ABI-encoded parameters
      // Each address is padded to 32 bytes (64 hex chars), address is in last 40 chars
      // Second param (token): input[74:138], address at input[98:138]
      allowanceHolderSubject = lower('0x' + input.slice(98, 138));
    }
  }

  // decode transfers; track ALL distinct traded tokens (for blacklist detection even when
  // the trader's balance delta is 0, e.g. routed swaps through a contract).
  // ТЗ v8 point 9: parse per-token decimals (v3/Algebra-style swaps on Polygon often use non-18
  // decimals) and value from the stable/quote leg — reuse the pure, testable evaluateEvmSwap.
  const evSwapArgs = [
    chainId,
    (tok: string) => getEvmTokenDecimals(chainId, tok),
    (tok: string, sym: string) => checkBlacklist(chainId, tok, sym).skip,
    async (tok: string) => (await fetchEvmTokenMeta(chainId, tok)).priceUsd,
    (txd as any).value,
    (txd as any).from,
  ] as const;
  let ev = await evaluateEvmSwap(
    evSwapArgs[0], trader, logs, evSwapArgs[1], evSwapArgs[2], evSwapArgs[3], evSwapArgs[4], evSwapArgs[5],
  );
  // v9.32t TD1: intent/CoW — tx.from (solver/entrypoint) has no ERC-20 legs; owner from Trade event does.
  // User-entrypoint short-circuit can skip the settlement block, so evaluateEvmSwap ran on tx.from.
  // One extra evaluateEvmSwap(owner) when owner ≠ from. INTENT_OWNER_DELTAS=0 disables.
  const intentOwnerDeltas = () => process.env.INTENT_OWNER_DELTAS !== '0';
  if (intentOwnerDeltas()) {
    if (!settlementCandidate) {
      const cands = decodeSettlementTrades(chainId, '', { ...(receipt as any), from: txd.from || '', to: txd.to || '' }, routerAddrs(chainId), settlementAddrs(chainId));
      if (cands[0]?.owner && cands[0].confidence === 'high') settlementCandidate = cands[0];
    }
    const ownerRetry = lower(settlementCandidate?.owner || settlementOwnerFromEvent || '');
    const fromL = lower(txd.from || '');
    const traderMoved0 = Object.values(ev.balanceDelta).some(d => Math.abs(d) > 1e-9);
    if (ownerRetry && ownerRetry !== fromL && ownerRetry !== lower(trader) && (!traderMoved0 || !ev.subjectTok)) {
      const ownerIsContract = await isContract(chainId, ownerRetry);
      if (ownerIsContract === true) {
        return { ...fail, trader: ownerRetry, resolvedBy: settlementCandidate?.protocol ? `protocol_adapter:${settlementCandidate.protocol}` : 'settlement_owner', error: 'contract_from' };
      }
      const evOwner = await evaluateEvmSwap(
        evSwapArgs[0], ownerRetry, logs, evSwapArgs[1], evSwapArgs[2], evSwapArgs[3], evSwapArgs[4], evSwapArgs[5],
      );
      if (evOwner.subjectTok) {
        ev = evOwner;
        trader = ownerRetry;
        resolvedBy = settlementCandidate?.protocol ? `protocol_adapter:${settlementCandidate.protocol}` : (settlementOwnerFromEvent ? 'settlement_owner' : resolvedBy);
      }
    }
  }
  let { subjectTok, subjectDelta, side, subjectAmount, usd, selfSwap, balanceDelta } = ev;

  // R11-K2: Use allowanceHolderSubject if subjectTok is empty (AllowanceHolder batch settlement)
  if (!subjectTok && allowanceHolderSubject) {
    subjectTok = allowanceHolderSubject;
    // L2: use known balanceDelta instead of -1/0
    const knownDelta = balanceDelta[allowanceHolderSubject];
    if (knownDelta !== undefined && Math.abs(knownDelta) > 1e-12) {
      subjectDelta = knownDelta;
      subjectAmount = Math.abs(knownDelta);
    } else {
      subjectDelta = -1;
      subjectAmount = 0;
    }
    side = subjectDelta >= 0 ? 'buy' : 'sell';
  }

  // L1: Cross-check P4 (tx_value) vs P5 (subject × oracle price)
  // If both are available and diverge >50%, use P5 (fail-open, don't drop the trade)
  if (ev.valuation?.source === 'tx_value' && subjectTok && subjectAmount > 0) {
    try {
      const oraclePrice = (await fetchEvmTokenMeta(chainId, subjectTok)).priceUsd;
      if (oraclePrice > 0) {
        const p5 = subjectAmount * oraclePrice;
        const p4 = usd;
        const maxP = Math.max(p4, p5);
        if (maxP > 0 && Math.abs(p4 - p5) / maxP > 0.5) {
          // Divergence >50%: use P5 (oracle-aligned), fail-open
          usd = p5;
          ev.valuation.source = 'subject_price_estimate';
          ev.valuation.diagnostics.push(
            `tx_value diverges from output leg (historical trade): P4=$${p4.toFixed(2)}, P5=$${p5.toFixed(2)}`
          );
        }
      }
    } catch { /* fail-open: keep P4 */ }
  }

  // M1: P5 override for allowanceHolderSubject (subject known from calldata, not from evaluateEvmSwap)
  // N2: P5 override ONLY against P3 (linked_route) and P4 (tx_value) — both medium confidence.
  // NEVER override P1 (trader_stable) or P2 (trader_wrapped_native) — high confidence.
  const p5OverrideAllowed = !ev.valuation?.source
    || ev.valuation.source === 'linked_route'
    || ev.valuation.source === 'tx_value'
    || ev.valuation.source === 'unresolved';
  if (subjectTok && subjectAmount > 0 && p5OverrideAllowed) {
    try {
      const oraclePrice = (await fetchEvmTokenMeta(chainId, subjectTok)).priceUsd;
      if (oraclePrice > 0) {
        const p5 = subjectAmount * oraclePrice;
        if (p5 > usd) {
          // P5 exceeds current usd — use P5 as authoritative trade value
          const prevUsd = usd;
          usd = p5;
          if (ev.valuation) {
            ev.valuation.source = 'subject_price_estimate';
            ev.valuation.diagnostics.push(
              `M1 P5 override: prev=$${prevUsd.toFixed(2)} < P5=${subjectAmount.toFixed(4)}×$${oraclePrice}=$${p5.toFixed(2)}`
            );
          }
        }
      }
    } catch { /* ignore */ }
  }

  // v9.12 F3: an approve / no-transfer tx (no Transfer logs at all) is not a swap.
  if (logs.length === 0) {
    notifSkipped.no_swap_legs++;
    return { ...fail, trader, resolvedBy, error: 'no_swap_legs' };
  }
  // v9.11 F1: a single-token self-swap (UP→UP) is not a real wallet trade — reject early with a
  // dedicated reason so it can never enter a series buffer or produce an alert.
  // v9.32am HOTFIX+ (MD3): НО если tx.from — registry-executor (settlement/reactor/solver-helper),
  // это не кошелёк-самосвоп, а solver-netting: owner-recovery уже попытался выше; владельца нет —
  // честная метка intent_no_subject (отдельный счётчик, НЕ смешивается с self_swap).
  if (selfSwap) {
    if (isIntentExecutorAddr(chainId, txd?.from)) {
      intentNoSubject++;
      return { ...fail, trader, resolvedBy, error: 'intent_no_subject' };
    }
    return { ...fail, trader, resolvedBy, error: 'self_swap' };
  }
  const traderMoved = Object.values(balanceDelta).some(d => Math.abs(d) > 1e-9);
  if (!traderMoved && logs.length > 1 && !subjectTok) {
    // v9.32as SD2: before discarding an aggregator-shaped tx, try to recover the real wallet
    // from the same logs (token-movement, the why-tx path). Gate AGGREGATOR_ATTRIBUTION=0
    // restores the old bit-for-bit behavior (emergency toggle).
    if (AGGREGATOR_ATTRIBUTION() && !selfSwap) {
      const attr = await attributeAggregatorSwap(chainId, logs, trader, { from: txd?.from, to: txd?.to }, evSwapArgs);
      if ('wallet' in attr) {
        notifSkipped.aggregatorAttributed++;
        // Adopt the wallet's view: subject/legs/USD from evaluateEvmSwap(wallet).
        trader = attr.wallet;
        resolvedBy = 'aggregator_attribution';
        subjectTok = attr.ev.subjectTok;
        subjectDelta = attr.ev.subjectDelta;
        side = attr.ev.side;
        subjectAmount = attr.ev.subjectAmount;
        usd = attr.ev.usd;
        balanceDelta = attr.ev.balanceDelta;
        if (attr.ev.valuation) ev.valuation = attr.ev.valuation;
        ev.balanceDelta = attr.ev.balanceDelta;
        log(`[aggregator-attribution] ${chainId}: wallet=${shortAddr(trader)} subject=${shortAddr(subjectTok)} usd=$${usd.toFixed(2)} (would be aggregator_no_legs)`);
      } else {
        // AT3: mutually exclusive with aggregatorNoLegs (that one is the pre-SD2 / toggle-off path).
        notifSkipped.aggregatorStillNoLegs++;
        notifSkipped.aggregatorRejects[attr.reject]++;
        noteAggregatorRejectByRouter(txd?.to, attr.reject); // v9.32bm1g H7
        const rejectTx = String(txHash || txd?.hash || receipt?.transactionHash || '');
        recordAggregatorReject(chainId, rejectTx || '-', attr.reject, attr.cands);
        aggregatorRejectSampleN++;
        if (aggregatorRejectSampleN % AGGREGATOR_REJECT_SAMPLE_EVERY() === 0) {
          log(`[aggregator-reject] ${chainId} ${attr.reject} cands=${attr.cands} tx=${rejectTx || '-'}`);
        }
        return { ...fail, trader, resolvedBy, error: 'aggregator_no_legs' };
      }
    } else {
      notifSkipped.aggregatorNoLegs++;
      return { ...fail, trader, resolvedBy, error: 'aggregator_no_legs' };
    }
  }
  if (!subjectTok) return { ...fail, trader, resolvedBy, error: 'tx_not_swap' };
  // v9.12 F3: a real swap requires the trader to BOTH send AND receive (a buy/sell has both legs).
  // A transfer/approve where the trader only sends (or only receives) is NOT a swap.
  const traderHasSend = Object.values(balanceDelta).some(d => d < -1e-9);
  const traderHasReceive = Object.values(balanceDelta).some(d => d > 1e-9);
  // Conservative: only reject the clear single-transfer deposit (no swap in/out through a
  // pool/router) and no-op approve/transfer. A trade where the user transfers to a router/settlement
  // and receives native ETH/BNB/POL/SOL has logs.length === 1 with only a send leg, but is a real swap.
  if ((!traderHasSend || !traderHasReceive) && logs.length === 1) {
    const isToRouter = isRouterAddr(chainId, txd.to || '') || isSettlementAddr(chainId, txd.to || '') || isUserEntrypoint(chainId, txd.to || '') || isRouterAddr(chainId, logs[0]?.to) || isSettlementAddr(chainId, logs[0]?.to) || isUserEntrypoint(chainId, logs[0]?.to);
    if (!isToRouter) {
      notifSkipped.no_swap_legs++;
      return { ...fail, trader, resolvedBy, error: 'no_swap_legs' };
    }
  }
  // M3 + v9.29 F3: dynamic route-pool pickup — if the settlement's OR router's route pool is
  // observed and the subject is a CEX candidate, pin the pool into the watchlist
  // (source='settlement_observed'). For user-signed routers (1inch/0x/Odos/…) this extends
  // real-time coverage to the exact pools they route through, without touching trader resolution.
  // R5: pass a READABLE subject symbol (never '?'/resolveSymFromAddr) so the pinned pool is a
  // visible, non-zero watchlist entry (else it would be hidden as a ?/0/0 row).
  if ((isSettlement || isRouterAddr(chainId, txd.to || '') || isRouterAddr(chainId, txd.from || '') || isUserEntrypoint(chainId, txd.to || '') || isUserEntrypoint(chainId, txd.from || '')) && settlementAddr) {
    const routePool = pickSettlementRoutePool(logs, settlementAddr, trader);
    let poolSubjectSym = resolveReadableSymbol(chainId, subjectTok) || '';
    if (!poolSubjectSym) { try { poolSubjectSym = (await fetchEvmTokenMeta(chainId, subjectTok)).symbol || ''; } catch { poolSubjectSym = ''; } }
    pickupSettlementRoutePool(chainId, subjectTok, poolSubjectSym, routePool);
  }
  // R4: resolve subject.symbol to a readable symbol (CEX cache / watchlist) — never a truncated
  // address. If no readable symbol is known, symbol=null instead of "0x232c…4ee2".
  const meta = await fetchEvmTokenMeta(chainId, subjectTok);
  const readableSym = resolveReadableSymbol(chainId, subjectTok)
    || (meta.symbol && !meta.symbol.includes('…') ? meta.symbol : null);

  // v9.12 F2: price sanity — reject USD values inflated by a bad price oracle (decimal mismatch /
  // wrong pool). Uses the shared checkPriceSanity helper (respects the test oracle mock and fail-open).
  if (usd > 0 && subjectAmount > 0) {
    const sanityReason = await checkPriceSanity(chainId, subjectTok, usd, subjectAmount, 0);
    if (sanityReason) {
      if (sanityReason === 'price_oracle_invalid') notifSkipped.price_oracle_invalid++;
      return { ...fail, trader, resolvedBy, subject: { address: subjectTok, symbol: readableSym }, error: sanityReason };
    }
  }
  let blockTs: number | null = null;
  if (rpc) {
    try {
      // v9.32an: блок-таймстамп через evmAnalyzeFetch — тот же транспорт, что весь анализ
      // (инжект мока покрывал только rpcsCycle; прямой fetchWithTimeout уходил в сеть).
      const blk = await evmAnalyzeFetch(rpc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'eth_getBlockByNumber', params: [receipt.blockNumber || 'latest', false] }) }, 3000); // v9.30 F2: aggressive timeout
      const ts = (await blk.json())?.result?.timestamp;
      if (ts) blockTs = parseInt(ts, 16);
    } catch { /* ignore */ }
  }
  // v9.32ak AK4: settlement-сделки не эмитят Swap на вотчцулах — пул атрибутируется по
  // Transfer from/to вотчпулов субъекта (инцидент 0x0ba60e…: USDC в 0x51c72…, AERO из него).
  const receiptLogs: Array<{ address: string; topics: string[]; data: string }> =
    (receipt?.logs || []).map((l: { address?: string; topics?: string[]; data?: string }) => ({ address: String(l.address || ''), topics: (l.topics || []) as string[], data: String(l.data || '0x') }));
  const attributedPool = attributeWatchPoolFromReceiptLogs(chainId, receiptLogs, subjectTok);
  // ТЗ bm2n6 п.3: пул из Swap-эмиттера чека — для кадров, пришедших НЕ от пула (token-эмиттер
  // hotPoll, settlement-эмиттер CoW/Fusion/UniswapX). Валидация внутри: ≠ subject, не settlement/
  // router, известный пул; иначе честный null.
  const swapEmitterPool = extractSwapEmitterPool(chainId, receiptLogs, subjectTok);
  // v9.32at AT6: counter-leg for whyTx blacklist-subject-swap (SPCXB⇄MarsCoin).
  let otherToken: { address: string; symbol: string | null; amount: number } | null = null;
  {
    let bestTok = '';
    let bestAbs = 0;
    for (const [tok, delta] of Object.entries(balanceDelta)) {
      if (lower(tok) === lower(subjectTok)) continue;
      const a = Math.abs(delta);
      if (a > bestAbs) { bestTok = tok; bestAbs = a; }
    }
    if (bestTok) {
      let otherSym = resolveReadableSymbol(chainId, bestTok);
      if (!otherSym) {
        try {
          const om = await fetchEvmTokenMeta(chainId, bestTok);
          if (om.symbol && om.symbol !== '?' && !om.symbol.includes('…')) otherSym = om.symbol;
        } catch { /* ignore */ }
      }
      otherToken = { address: bestTok, symbol: otherSym, amount: bestAbs };
    }
  }
  return { found: true, trader, resolvedBy, subject: { address: subjectTok, symbol: readableSym }, side, usd, subjectAmount, blockTs, rpcOk: true, attributedPool, swapEmitterPool, otherToken };
}

// F5/M3: recover a settlement owner from the transfer stream when no Trade event is present.
// The owner is the account that RECEIVES the bought (non-infra, non-stable) token from the
// settlement contract (a real trader, not the AMM pool which receives the stable leg). M3 makes it
// universal: prefer an account that BOTH sends a token to the settlement AND receives one from it
// (the real order owner) — works for any intent/settlement protocol, not just CoW.
async function recoverSettlementOwnerAsync(chainId: string, logs: EvmTransferLog[], settlement: string): Promise<string | null> {
  const sendsToSettlement = new Set<string>();
  const receivedAnywhere = new Map<string, Map<string, bigint>>();
  for (const l of logs) {
    const from = lower(l.from); const to = lower(l.to);
    if (to === settlement || isSettlementAddr(chainId, to)) sendsToSettlement.add(from);
    const amt = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
    const m = receivedAnywhere.get(to) || new Map();
    m.set(lower(l.token), (m.get(lower(l.token)) || BigInt(0)) + amt);
    receivedAnywhere.set(to, m);
  }

  // Priority 1: An EOA account that sends funds to settlement AND receives tokens in the tx (the true order owner).
  let bestOrderOwner: string | null = null;
  let bestOrderOwnerAmt = BigInt(0);
  for (const addr of sendsToSettlement) {
    if (addr === settlement || isSettlementAddr(chainId, addr) || isInfra(addr)) continue;
    if ((await isContract(chainId, addr)) === true) continue;
    const toks = receivedAnywhere.get(addr);
    if (!toks) continue;
    let totalAmt = BigInt(0);
    for (const [tok, amt] of toks) {
      if (!isInfra(tok)) totalAmt += amt;
    }
    if (totalAmt > bestOrderOwnerAmt) {
      bestOrderOwnerAmt = totalAmt;
      bestOrderOwner = addr;
    }
  }
  if (bestOrderOwner) return bestOrderOwner;

  // Priority 2: An EOA that sent subject token in the tx (e.g. 1inch Fusion sell order via solver)
  for (const l of logs) {
    const from = lower(l.from);
    if (from === settlement || isSettlementAddr(chainId, from) || isInfra(from)) continue;
    if ((await isContract(chainId, from)) === false) {
      return from;
    }
  }

  // Fallback: direct EOA receivers from settlement
  for (const l of logs) {
    const to = lower(l.to);
    if (to === settlement || isSettlementAddr(chainId, to) || isInfra(to)) continue;
    if ((await isContract(chainId, to)) === false) {
      return to;
    }
  }
  return null;
}

function recoverSettlementOwner(chainId: string, logs: EvmTransferLog[], settlement: string): string | null {
  const sendsToSettlement = new Set<string>();
  const receivedAnywhere = new Map<string, Map<string, bigint>>();
  for (const l of logs) {
    const from = lower(l.from); const to = lower(l.to);
    if (to === settlement || isSettlementAddr(chainId, to)) sendsToSettlement.add(from);
    const amt = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
    const m = receivedAnywhere.get(to) || new Map();
    m.set(lower(l.token), (m.get(lower(l.token)) || BigInt(0)) + amt);
    receivedAnywhere.set(to, m);
  }

  // Priority 1: An account that sends funds to settlement AND receives tokens in the tx (the true order owner, buy or sell).
  let bestOrderOwner: string | null = null;
  let bestOrderOwnerAmt = BigInt(0);
  for (const addr of sendsToSettlement) {
    if (addr === settlement || isSettlementAddr(chainId, addr) || isInfra(addr)) continue;
    const toks = receivedAnywhere.get(addr);
    if (!toks) continue;
    let totalAmt = BigInt(0);
    for (const [tok, amt] of toks) {
      if (!isInfra(tok)) totalAmt += amt;
    }
    if (totalAmt > bestOrderOwnerAmt) {
      bestOrderOwnerAmt = totalAmt;
      bestOrderOwner = addr;
    }
  }
  if (bestOrderOwner) return bestOrderOwner;

  // Priority 2: In a sell order via an intermediate solver/filler, find the external initiator (e.g. from Transfer logs)
  for (const l of logs) {
    const from = lower(l.from);
    if (from !== settlement && !isSettlementAddr(chainId, from) && !isInfra(from)) {
      return from;
    }
  }

  // Fallback: direct receivers from settlement (non-infra recipient)
  let bestDirect: string | null = null;
  let bestDirectAmt = BigInt(0);
  for (const l of logs) {
    if (lower(l.from) !== settlement && !isSettlementAddr(chainId, lower(l.from))) continue;
    const to = lower(l.to);
    if (to === settlement || isSettlementAddr(chainId, to) || isInfra(to)) continue;
    const amt = BigInt(l.data && l.data !== '0x' ? l.data : '0x0');
    if (amt > bestDirectAmt) {
      bestDirectAmt = amt;
      bestDirect = to;
    }
  }
  return bestDirect;
}

// F5 backward-compat alias (kept for tests).
function recoverCowOwnerFromTransfers(chainId: string, logs: EvmTransferLog[], settlement: string): string | null {
  return recoverSettlementOwner(chainId, logs, settlement);
}

// F5: identify the route (AMM) pool used by a CoW settlement from the transfer stream. It is the
// non-infra, non-stable, non-owner account that BOTH receives tokens from the settlement and sends
// tokens to it (an order-route AMM pool / settlement-internal balance).
function pickSettlementRoutePool(logs: EvmTransferLog[], settlement: string, owner: string): string | null {
  const sendsToSettlement = new Set<string>();
  const receivesFromSettlement = new Set<string>();
  for (const l of logs) {
    const from = lower(l.from); const to = lower(l.to);
    if (from === settlement) receivesFromSettlement.add(to);
    if (to === settlement) sendsToSettlement.add(from);
  }
  for (const addr of receivesFromSettlement) {
    if (addr === settlement || addr === lower(owner)) continue;
    if (isInfra(addr) || isStablecoin(addr, resolveSymFromAddr(addr))) continue;
    if (sendsToSettlement.has(addr)) return addr; // both sides → the AMM pool
  }
  return null;
}

// F5: if the settlement's route pool is observed AND the subject is a CEX candidate from the
// watchlist, pin the pool into the watchlist (source='settlement_observed') so the subject gets
// covered even when discovery hasn't reached it yet.
function pickupSettlementRoutePool(chainId: string, subjectAddr: string, subjectSymbol: string, poolAddr: string | null): void {
  if (!poolAddr || !subjectAddr) return;
  // only when subject is a CEX candidate (in cexCache) → real alert-worthy alt
  if (!cexCache.byAddress[chainId + ':' + lower(subjectAddr)]) return;
  const addr = normAddr(chainId, poolAddr);
  if (!chainWatchMeta[chainId]) chainWatchMeta[chainId] = {};
  const key = lower(addr);
  const existing = chainWatchMeta[chainId][key];
  if (existing) {
    // R5: pool already known (organic/anchor) → source becomes settlement_observed+<old> so the
    // settlement observation is visible; never duplicate the prefix.
    if (existing.source.includes('settlement_observed')) return;
    existing.source = `settlement_observed+${existing.source}`;
  } else {
    markWatchlistDirty();
    chainWatchMeta[chainId][key] = {
      address: addr,
      subjectToken: normAddr(chainId, subjectAddr),
      subjectSymbol: subjectSymbol || '?',
      quoteSymbol: '?',
      reserveUsd: null, vol24hUsd: null,
      source: 'settlement_observed',
    };
  }
  const st = state.chain[chainId];
  if (st && !st.watchPools.some(a => lower(a) === lower(addr))) st.watchPools.push(addr);
  if (isEVMChain(chainId) && isValidEvmAddress(addr)) {
    queueEvmPoolMetaEnrichment(chainId, addr, undefined, subjectAddr);
  }
}

const FIRST_PASS_LOG_SIZE = () => {
  const n = Number(process.env.FIRST_PASS_LOG_SIZE || '5000');
  return n > 0 ? n : 5000;
};
const firstPassByKey = new Map<string, FirstPassRec>();
const firstPassOrder: string[] = [];
function firstPassKey(chainId: string, txHash: string): string { return chainId + ':' + lower(txHash); }
function recordFirstPass(chainId: string, txHash: string, rec: Omit<FirstPassRec, 'at'> & { at?: number }): void {
  if (!txHash) return;
  const key = firstPassKey(chainId, txHash);
  if (firstPassByKey.has(key)) return; // первый проход не перезаписываем
  firstPassByKey.set(key, { at: rec.at ?? Date.now(), usd: rec.usd, skipReason: rec.skipReason, priceResolved: rec.priceResolved, gateClosed: rec.gateClosed });
  firstPassOrder.push(key);
  const cap = FIRST_PASS_LOG_SIZE();
  while (firstPassOrder.length > cap) {
    const oldKey = firstPassOrder.shift();
    if (oldKey) firstPassByKey.delete(oldKey);
  }
}
function attachFirstPass(chainId: string, txHash: string, result: WhyTxResult): WhyTxResult {
  const fp = firstPassByKey.get(firstPassKey(chainId, txHash)) || null;
  let divergence: string | null = null;
  if (fp) {
    if (fp.usd === 0 && result.usd > 0) divergence = 'first_pass_unresolved';
    else if (fp.skipReason && fp.skipReason !== (result.skipReason || null)) divergence = 'first_pass_differs';
  }
  return { ...result, firstPass: fp, divergence };
}
export function __getFirstPassForTest(chainId: string, txHash: string): FirstPassRec | null {
  return firstPassByKey.get(firstPassKey(chainId, txHash)) || null;
}
export function __clearFirstPassLogForTest(): void {
  firstPassByKey.clear();
  firstPassOrder.length = 0;
}
export function __recordFirstPassForTest(chainId: string, txHash: string, rec: Omit<FirstPassRec, 'at'> & { at?: number }): void {
  const key = firstPassKey(chainId, txHash);
  firstPassByKey.delete(key); // test seed overwrites
  const i = firstPassOrder.indexOf(key);
  if (i >= 0) firstPassOrder.splice(i, 1);
  recordFirstPass(chainId, txHash, rec);
}
export function __attachFirstPassForTest(chainId: string, txHash: string, usd: number, skipReason: string | null): WhyTxResult {
  return attachFirstPass(chainId, txHash, {
    found: true, chain: chainId, txHash, blockTs: null, poolAddress: null, poolInWatchlist: false,
    subject: null, side: '', usd, traderResolved: '', resolvedBy: '', wouldTriggerSolo: false,
    wouldTriggerSeriesProgress: null, skipReason, checks: [], subjectWatchlistPools: [],
  });
}
export async function whyTx(chainId: string, txHash: string): Promise<WhyTxResult> {
  return attachFirstPass(chainId, txHash, await whyTxCompute(chainId, txHash));
}
async function whyTxCompute(chainId: string, txHash: string): Promise<WhyTxResult> {
  const checks: WhyTxCheck[] = [];
  const push = (name: string, ok: boolean, detail: string) => checks.push({ name, ok, detail });

  if (!CHAINS[chainId]) return { found: false, chain: chainId, txHash, blockTs: null, poolAddress: null, poolInWatchlist: false, subject: null, side: '', usd: 0, traderResolved: '', resolvedBy: '', wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: null, checks, subjectWatchlistPools: [] };

  // v9.16 F-TX: a synthetic DexPaprika reserve id (dp-r-…) or a malformed hash is not a real chain
  // tx — reject up-front with a clear error instead of burning an RPC call that always misses.
  if (!isValidChainTxHash(chainId, txHash)) {
    return { found: false, error: 'tx_hash_invalid', chain: chainId, txHash, blockTs: null, poolAddress: null, poolInWatchlist: false, subject: null, side: '', usd: 0, traderResolved: '', resolvedBy: '', wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: null, checks, subjectWatchlistPools: [] };
  }

  // Ensure CEX cache is loaded for faithful cex filter checks
  try { if (cexCache.updatedAt === 0) await refreshCexCache(); } catch { /* ignore */ }
  await ensureBlacklistLoaded();

  const analysis = chainId === 'solana' ? await analyzeSolanaTx(txHash) : await analyzeEvmTx(chainId, txHash, undefined, { skipSubSoloFilter: true }); // v9.32al CK3: why-tx объясняет, фильтр конвейера не должен прятать причину
  // Д2/F4: RPC context unavailable → found:null + error:'rpc_unavailable' (not a silent found:false).
  if ('rpcOk' in analysis && analysis.rpcOk === false && !analysis.found) {
    const err = (analysis as { error?: string }).error || 'rpc_unavailable';
    return { found: null, error: err, chain: chainId, txHash, blockTs: null, poolAddress: null, poolInWatchlist: null, subject: null, side: '', usd: 0, traderResolved: '', resolvedBy: '', wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: null, checks, subjectWatchlistPools: [] };
  }
  if (!analysis.found || !analysis.subject) {
    // F4: never a silent found:false — classify the reason (tx_not_swap / receipt_missing / infra_only_no_trade).
    const err = (analysis as { error?: string }).error || 'tx_not_swap';
    return { found: false, error: err, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress: null, poolInWatchlist: false, subject: null, side: analysis.side, usd: analysis.usd, subjectAmount: analysis.subjectAmount, traderResolved: analysis.trader, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: null, checks, subjectWatchlistPools: [] };
  }

  let subject = analysis.subject;
  const traderResolved = analysis.trader;
  // R4: subject.symbol may be null (no readable symbol) — coerce to '' for checks that need a string.
  let subjectSym = subject.symbol || '';
  let analysisSide = analysis.side;
  let analysisUsd = analysis.usd;
  let analysisSubjectAmount = analysis.subjectAmount;

  // ТЗ v5 Defect 2: ensure watchlist built so subjectWatchlistPools/pool attribution are truthful.
  await ensureWatchlistBuilt(chainId);
  const watchPools = state.chain[chainId]?.watchPools || [];

  // ТЗ v5 Defect 2: subject's watched pools (always returned for audit, even if unattributed).
  const subjectWatchlistPools = watchedPoolsForSubject(chainId, subject.address);

  // ── Pool status ──
  let poolAddress: string | null = null;
  let poolInWatchlist: boolean | null = null;
  let poolDetail: string | undefined;
  if (chainId === 'solana') {
    // Strengthened attribution: scan all involved accounts (incl. ALT loaded addresses,
    // inner-instruction accounts, vault owners) and intersect with the watchlist.
    const solAccounts = (analysis as { accounts?: string[] }).accounts || [];
    const hit = solAccounts.find((a: string) => watchPools.some(w => lower(w) === lower(a)));
    if (hit) {
      poolAddress = hit;
      poolInWatchlist = true;
      poolDetail = 'attributed (on-chain account in watchlist)';
    } else {
      poolAddress = null;
      poolInWatchlist = null;
      poolDetail = 'unattributed (aggregator)';
    }
  } else {
    try {
      const pools = await fetchGtTokenPools(chainId, subject.address);
      for (const p of pools) {
        const inWatch = watchPools.some(w => lower(w) === lower(p.address));
        const qualifies = p.reserveUsd >= POOL_MIN_RESERVE_USD || p.volume24h >= POOL_MIN_24H_VOLUME_USD;
        if (inWatch || qualifies) { poolAddress = p.address; poolInWatchlist = true; poolDetail = inWatch ? 'watched' : 'qualifies (thresholds)'; break; }
      }
      if (!poolAddress && pools.length > 0) { poolAddress = pools[0].address; poolInWatchlist = false; poolDetail = 'not watched'; }
      // v9.32ak AK4: GT мёртв/пуст — settlement-сделка всё равно атрибутируется по чеку
      if (!poolAddress) {
        const attr = (analysis as { attributedPool?: { pool: string; side: string } | null }).attributedPool;
        if (attr && subjectWatchlistPools.some(w => lower(w) === lower(attr.pool))) {
          poolAddress = attr.pool; poolInWatchlist = true; poolDetail = 'attributed (watch-pool transfer)';
        }
      }
    } catch { /* ignore */ }
  }
  push('poolWatched', poolInWatchlist === true, poolDetail || (poolAddress ? `pool ${shortAddr(poolAddress)}` : 'no qualifying pool'));

  // Blacklist — v9.32at AT6: SD4 subject-swap before reporting a false drop.
  const bl = checkBlacklist(chainId, subject.address, subjectSym);
  if (bl.skip) {
    const otherToken = (analysis as { otherToken?: { address: string; symbol: string | null; amount: number } | null }).otherToken;
    const extractedForSwap: ExtractedSubject = {
      subjectAddr: subject.address,
      subjectSym,
      side: analysisSide,
      amountUsd: analysisUsd,
      fromAddr: analysisSide === 'sell' ? subject.address : (otherToken?.address || subject.address),
      fromSym: analysisSide === 'sell' ? subjectSym : (otherToken?.symbol || ''),
      fromAmount: analysisSide === 'sell' ? (analysisSubjectAmount || 0) : (otherToken?.amount || 0),
      toAddr: analysisSide === 'sell' ? (otherToken?.address || subject.address) : subject.address,
      toSym: analysisSide === 'sell' ? (otherToken?.symbol || '') : subjectSym,
      toAmount: analysisSide === 'sell' ? (otherToken?.amount || 0) : (analysisSubjectAmount || 0),
      trader: traderResolved,
      txHash,
      blockTs: analysis.blockTs,
      hasSourceBlockTs: !!analysis.blockTs,
      subjectAmount: analysisSubjectAmount || 0,
    };
    const swapped = otherToken ? tryBlacklistSubjectSwap(chainId, extractedForSwap) : null;
    if (swapped) {
      const prevSym = subjectSym || shortAddr(subject.address);
      const nextSym = swapped.subjectSym || shortAddr(swapped.subjectAddr);
      push('blacklistSubjectSwapped', true, `${prevSym} → ${nextSym}`);
      subject = { address: swapped.subjectAddr, symbol: swapped.subjectSym || null };
      subjectSym = swapped.subjectSym || '';
      analysisSide = swapped.side;
      analysisSubjectAmount = swapped.subjectAmount;
      push('blacklist', true, `swapped subject; no match on ${nextSym}`);
    } else {
      push('blacklist', false, `matched rule ${bl.ruleId}`);
      return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'blacklist', checks, subjectWatchlistPools, poolDetail };
    }
  } else {
    push('blacklist', true, 'no match');
  }

  // Stable
  if (isStablecoin(subject.address, subjectSym)) { push('stable', false, 'subject is stablecoin'); return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'stable', checks, subjectWatchlistPools, poolDetail }; }
  push('stable', true, 'not stable');

  // Major
  // M1: major token filter — skip only for buys (wrapping), allow sells (major→native/stable)
  // Selling WBTC for POL is a real trade the monitor should alert on.
  if (isMajorToken(chainId, subject.address) && analysisSide === 'buy') { push('major', false, 'subject is major wrapped native (buy)'); return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'major', checks, subjectWatchlistPools, poolDetail }; }
  push('major', true, 'not major');

  // CEX (v9.32ax AX1: без exempt)
  const cex = cexFilterDecision(chainId, subject.address, subjectSym, analysisUsd, traderResolved);
  if (!cex.pass) { push('cex', false, 'not CEX-listed'); return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'cexNotListed', checks, subjectWatchlistPools, poolDetail }; }
  push('cex', true, `listed via ${cex.method}`);

  if (getExchangeWalletLabel(traderResolved)) {
    push('exchange_transfer', false, `recipient ${getExchangeWalletLabel(traderResolved)}`);
    return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'exchange_transfer', checks, subjectWatchlistPools, poolDetail };
  }
  push('exchange_transfer', true, 'not exchange wallet');

  // contractExec (EVM only)
  if (chainId !== 'solana' && txHash.startsWith('0x')) {
    const exec = await checkContractExecFilter(chainId, txHash, subject.address, poolAddress);
    if (exec.decision === 'contractExec' && traderResolved && String(analysis.resolvedBy || '').startsWith('protocol_adapter:')) {
      // v9.32ak AK4: солвер ≠ piggy-bank — адаптер резолвнул EOA-трейдера
      push('contractExec', true, 'settlement adapter resolved EOA trader');
    } else if (exec.decision === 'contractExec') { push('contractExec', false, 'contract executor'); return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'contractExec', checks, subjectWatchlistPools, poolDetail }; }
    push('contractExec', true, `decision=${exec.decision}`);
  } else {
    push('contractExec', true, chainId === 'solana' ? 'n/a (solana)' : 'n/a');
  }

  // v9.32ax AX5: usd===0 — не belowThreshold
  if (!(analysisUsd > 0) && analysisSubjectAmount > 0 && subject.address) {
    const filled = await tryFillUsdFromDs(chainId, subject.address, analysisSubjectAmount);
    if (filled > 0) analysisUsd = filled;
  }
  if (!(analysisUsd > 0) || Number.isNaN(analysisUsd)) {
    push('thresholds', false, 'usd unresolved (price missing)');
    return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'usd_unresolved', checks, subjectWatchlistPools, poolDetail };
  }

  // Thresholds
  const solo = analysisUsd >= settings.soloAmountUsd;
  let wouldTriggerSolo = false;
  let wouldTriggerSeriesProgress: { count: number; windowSec: number } | null = null;

  if (solo) {
    let impact: number | null = null;
    try {
      const liq = await fetchLiquidityInfo(chainId, subject.address);
      if (liq && liq.totalUsd > 0) impact = (analysisUsd / liq.totalUsd) * 100;
    } catch { /* ignore */ }
    if (impact !== null && impact > IMPACT_MAX_PCT()) {
      push('impact', false, `impact ${impact.toFixed(3)}% > max ${IMPACT_MAX_PCT()}%`);
      return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'impact_anomaly', checks, subjectWatchlistPools, poolDetail };
    }
    push('impact', impact === null || impact >= SOLO_MIN_IMPACT_PCT, impact === null ? 'liquidity unavailable' : `impact ${impact.toFixed(3)}%`);
    wouldTriggerSolo = true;
    push('thresholds', true, `solo: usd ${fmtUsd(analysisUsd)} >= ${fmtUsd(settings.soloAmountUsd)}`);
  } else {
    push('impact', true, 'n/a (below solo threshold)');
    if (analysisUsd >= settings.seriesTxMinUsd) {
      wouldTriggerSeriesProgress = { count: 1, windowSec: settings.seriesWindowSec };
      push('thresholds', true, `series candidate: usd ${fmtUsd(analysisUsd)} >= ${fmtUsd(settings.seriesTxMinUsd)}, netGuard applied at fire time`);
    } else {
      push('thresholds', false, `usd ${fmtUsd(analysisUsd)} < series min ${fmtUsd(settings.seriesTxMinUsd)}`);
      return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo: false, wouldTriggerSeriesProgress: null, skipReason: 'belowThreshold', checks, subjectWatchlistPools, poolDetail };
    }
  }

  return { found: true, chain: chainId, txHash, blockTs: analysis.blockTs, poolAddress, poolInWatchlist, subject, side: analysisSide, usd: analysisUsd, subjectAmount: analysisSubjectAmount, traderResolved, resolvedBy: analysis.resolvedBy, wouldTriggerSolo, wouldTriggerSeriesProgress, skipReason: null, checks, subjectWatchlistPools, poolDetail };
}

// ─────────────── Net-flow guard pure helper (ТЗ v4 Block 5, unit-testable) ───────────────
export function computeNetGuard(
  buys: number[], sells: number[],
  minTx: number, minNetUsd: number, minRowUsd = 1000
): { suppressed: boolean; netSide: string | null; netUsd: number; grossBuy: number; grossSell: number; netSideCount: number } {
  const gb = buys.filter(x => x >= minRowUsd).reduce((s, x) => s + x, 0);
  const gs = sells.filter(x => x >= minRowUsd).reduce((s, x) => s + x, 0);
  const buyCount = buys.filter(x => x >= minRowUsd).length;
  const sellCount = sells.filter(x => x >= minRowUsd).length;
  if (Math.min(buyCount, sellCount) >= 1) {
    const netSide = gb >= gs ? 'buy' : 'sell';
    const netUsd = Math.abs(gb - gs);
    const netSideCount = netSide === 'buy' ? buyCount : sellCount;
    if ((minNetUsd > 0 && netUsd < minNetUsd) || netSideCount < minTx) {
      return { suppressed: true, netSide, netUsd, grossBuy: gb, grossSell: gs, netSideCount };
    }
    return { suppressed: false, netSide, netUsd, grossBuy: gb, grossSell: gs, netSideCount };
  }
  return { suppressed: false, netSide: gb >= gs ? 'buy' : 'sell', netUsd: Math.abs(gb - gs), grossBuy: gb, grossSell: gs, netSideCount: gb >= gs ? buyCount : sellCount };
}

// Suppress unused import warnings
void resolveSymFromAddr;
void isBaseToken;
void rateAllow;
void rateCount;
void top10Symbols;

export { CHAINS };

// ── B4.4: legacy exports stay available as delegate bindings onto the runtime ──
const HELIUS_CU_DAY_MAX = getSolanaPubsub().HELIUS_CU_DAY_MAX;
const HELIUS_RESERVE_CU_HOUR_MAX = getSolanaPubsub().HELIUS_RESERVE_CU_HOUR_MAX;
const HELIUS_RESERVE_MAX_MINUTES = getSolanaPubsub().HELIUS_RESERVE_MAX_MINUTES;
const HELIUS_SPEND_FLUSH_MS = getSolanaPubsub().HELIUS_SPEND_FLUSH_MS;
const RESERVE_CACHE_TTL_MS = getSolanaPubsub().RESERVE_CACHE_TTL_MS;
const RESERVE_RECOVERY_COOLDOWN_MS = getSolanaPubsub().RESERVE_RECOVERY_COOLDOWN_MS;
const RESERVE_TRICKLE_FIRST_N = getSolanaPubsub().RESERVE_TRICKLE_FIRST_N;
const RESERVE_TRICKLE_RATE = getSolanaPubsub().RESERVE_TRICKLE_RATE;
const RESUME_RESUB_ON_START = getSolanaPubsub().RESUME_RESUB_ON_START;
const SOLANA_PUBSUB_ENABLED = getSolanaPubsub().SOLANA_PUBSUB_ENABLED;
export const SOLPUBSUB_DROP_REASONS = getSolanaPubsub().SOLPUBSUB_DROP_REASONS;
export const SOLPUBSUB_STAGE_REASONS = getSolanaPubsub().SOLPUBSUB_STAGE_REASONS;
const SOL_ANALYZE_ANCHOR_PER_MIN = getSolanaPubsub().SOL_ANALYZE_ANCHOR_PER_MIN;
const SOL_ANALYZE_PER_MINT = getSolanaPubsub().SOL_ANALYZE_PER_MINT;
const SOL_ANALYZE_WL_FLOOR = getSolanaPubsub().SOL_ANALYZE_WL_FLOOR;
const SOL_PUBSUB_ANALYZE_PER_MIN = getSolanaPubsub().SOL_PUBSUB_ANALYZE_PER_MIN;
const SOL_PUBSUB_MAX_POOLS = getSolanaPubsub().SOL_PUBSUB_MAX_POOLS;
const SOL_RESERVE_ACCOUNTSUB = getSolanaPubsub().SOL_RESERVE_ACCOUNTSUB;
const SOL_RESERVE_PAIR_MS = getSolanaPubsub().SOL_RESERVE_PAIR_MS;
const SOL_RESERVE_PAIR_TTL_MS = getSolanaPubsub().SOL_RESERVE_PAIR_TTL_MS;
const SOL_RESERVE_RPC_DEFER_CYCLES = getSolanaPubsub().SOL_RESERVE_RPC_DEFER_CYCLES;
const SOL_RESERVE_SIG_RETRY_MAX = getSolanaPubsub().SOL_RESERVE_SIG_RETRY_MAX;
const SOL_RESERVE_SIG_RETRY_MS = getSolanaPubsub().SOL_RESERVE_SIG_RETRY_MS;
const SOL_RESERVE_SIG_RETRY_PER_MIN = getSolanaPubsub().SOL_RESERVE_SIG_RETRY_PER_MIN;
const SOL_WS_BAN_MAX_MS = getSolanaPubsub().SOL_WS_BAN_MAX_MS;
const SOL_WS_FALLBACK_INFLIGHT_MAX = getSolanaPubsub().SOL_WS_FALLBACK_INFLIGHT_MAX;
const SOL_WS_MENTION_CONFIRM_MS = getSolanaPubsub().SOL_WS_MENTION_CONFIRM_MS;
const SOL_WS_PROBE_FRAME_MS = getSolanaPubsub().SOL_WS_PROBE_FRAME_MS;
const SOL_WS_PUBLICNODE_RETRY_MS = getSolanaPubsub().SOL_WS_PUBLICNODE_RETRY_MS;
export function __analyzeMintsForTest(...args: Parameters<SolanaPubSubRuntime["__analyzeMintsForTest"]>): ReturnType<SolanaPubSubRuntime["__analyzeMintsForTest"]> { return getSolanaPubsub().__analyzeMintsForTest(...args); }
export function __applySolReserveSigDecisionForTest(...args: Parameters<SolanaPubSubRuntime["__applySolReserveSigDecisionForTest"]>): ReturnType<SolanaPubSubRuntime["__applySolReserveSigDecisionForTest"]> { return getSolanaPubsub().__applySolReserveSigDecisionForTest(...args); }
export function __b58encodeForTest(...args: Parameters<SolanaPubSubRuntime["__b58encodeForTest"]>): ReturnType<SolanaPubSubRuntime["__b58encodeForTest"]> { return getSolanaPubsub().__b58encodeForTest(...args); }
export function __bootstrappedHotMintsForTest(...args: Parameters<SolanaPubSubRuntime["__bootstrappedHotMintsForTest"]>): ReturnType<SolanaPubSubRuntime["__bootstrappedHotMintsForTest"]> { return getSolanaPubsub().__bootstrappedHotMintsForTest(...args); }
export function __bumpHeliusDayCuForTest(...args: Parameters<SolanaPubSubRuntime["__bumpHeliusDayCuForTest"]>): ReturnType<SolanaPubSubRuntime["__bumpHeliusDayCuForTest"]> { return getSolanaPubsub().__bumpHeliusDayCuForTest(...args); }
export function __bumpRpcSpendForTest(...args: Parameters<SolanaPubSubRuntime["__bumpRpcSpendForTest"]>): ReturnType<SolanaPubSubRuntime["__bumpRpcSpendForTest"]> { return getSolanaPubsub().__bumpRpcSpendForTest(...args); }
export function __clearSolReserveResolvedForTest(...args: Parameters<SolanaPubSubRuntime["__clearSolReserveResolvedForTest"]>): ReturnType<SolanaPubSubRuntime["__clearSolReserveResolvedForTest"]> { return getSolanaPubsub().__clearSolReserveResolvedForTest(...args); }
export function __collectSolWlMintsForTest(...args: Parameters<SolanaPubSubRuntime["__collectSolWlMintsForTest"]>): ReturnType<SolanaPubSubRuntime["__collectSolWlMintsForTest"]> { return getSolanaPubsub().__collectSolWlMintsForTest(...args); }
export function __decodeSolPoolStateForTest(...args: Parameters<SolanaPubSubRuntime["__decodeSolPoolStateForTest"]>): ReturnType<SolanaPubSubRuntime["__decodeSolPoolStateForTest"]> { return getSolanaPubsub().__decodeSolPoolStateForTest(...args); }
export function __deliveryRecoveryTickForTest(...args: Parameters<SolanaPubSubRuntime["__deliveryRecoveryTickForTest"]>): ReturnType<SolanaPubSubRuntime["__deliveryRecoveryTickForTest"]> { return getSolanaPubsub().__deliveryRecoveryTickForTest(...args); }
export function __detectSolPoolSwapForTest(...args: Parameters<SolanaPubSubRuntime["__detectSolPoolSwapForTest"]>): ReturnType<SolanaPubSubRuntime["__detectSolPoolSwapForTest"]> { return getSolanaPubsub().__detectSolPoolSwapForTest(...args); }
export function __drainFallbackAdmissionForTest(...args: Parameters<SolanaPubSubRuntime["__drainFallbackAdmissionForTest"]>): ReturnType<SolanaPubSubRuntime["__drainFallbackAdmissionForTest"]> { return getSolanaPubsub().__drainFallbackAdmissionForTest(...args); }
export function __evmHotPollIntervalDefaultForTest(...args: Parameters<SolanaPubSubRuntime["__evmHotPollIntervalDefaultForTest"]>): ReturnType<SolanaPubSubRuntime["__evmHotPollIntervalDefaultForTest"]> { return getSolanaPubsub().__evmHotPollIntervalDefaultForTest(...args); }
export function __expireSolMentionPendingForTest(...args: Parameters<SolanaPubSubRuntime["__expireSolMentionPendingForTest"]>): ReturnType<SolanaPubSubRuntime["__expireSolMentionPendingForTest"]> { return getSolanaPubsub().__expireSolMentionPendingForTest(...args); }
export function __fallbackTombstoneRecoverySnapshotForTest(...args: Parameters<SolanaPubSubRuntime["__fallbackTombstoneRecoverySnapshotForTest"]>): ReturnType<SolanaPubSubRuntime["__fallbackTombstoneRecoverySnapshotForTest"]> { return getSolanaPubsub().__fallbackTombstoneRecoverySnapshotForTest(...args); }
export function __fallbackTombstoneRecoveryWindowForTest(...args: Parameters<SolanaPubSubRuntime["__fallbackTombstoneRecoveryWindowForTest"]>): ReturnType<SolanaPubSubRuntime["__fallbackTombstoneRecoveryWindowForTest"]> { return getSolanaPubsub().__fallbackTombstoneRecoveryWindowForTest(...args); }
export function __flushHeliusSpendForTest(...args: Parameters<SolanaPubSubRuntime["__flushHeliusSpendForTest"]>): ReturnType<SolanaPubSubRuntime["__flushHeliusSpendForTest"]> { return getSolanaPubsub().__flushHeliusSpendForTest(...args); }
export function __forceHeliusReserveStopForTest(...args: Parameters<SolanaPubSubRuntime["__forceHeliusReserveStopForTest"]>): ReturnType<SolanaPubSubRuntime["__forceHeliusReserveStopForTest"]> { return getSolanaPubsub().__forceHeliusReserveStopForTest(...args); }
export function __forceSolPubsubReconnectSeriesForTest(...args: Parameters<SolanaPubSubRuntime["__forceSolPubsubReconnectSeriesForTest"]>): ReturnType<SolanaPubSubRuntime["__forceSolPubsubReconnectSeriesForTest"]> { return getSolanaPubsub().__forceSolPubsubReconnectSeriesForTest(...args); }
export function __handleSolPubsubMessageForTest(...args: Parameters<SolanaPubSubRuntime["__handleSolPubsubMessageForTest"]>): ReturnType<SolanaPubSubRuntime["__handleSolPubsubMessageForTest"]> { return getSolanaPubsub().__handleSolPubsubMessageForTest(...args); }
export function __handleSolPubsubSignatureForTest(...args: Parameters<SolanaPubSubRuntime["__handleSolPubsubSignatureForTest"]>): ReturnType<SolanaPubSubRuntime["__handleSolPubsubSignatureForTest"]> { return getSolanaPubsub().__handleSolPubsubSignatureForTest(...args); }
export function __handleSolReserveNotificationForTest(...args: Parameters<SolanaPubSubRuntime["__handleSolReserveNotificationForTest"]>): ReturnType<SolanaPubSubRuntime["__handleSolReserveNotificationForTest"]> { return getSolanaPubsub().__handleSolReserveNotificationForTest(...args); }
export function __handleSolReserveNotificationBigForTest(...args: Parameters<SolanaPubSubRuntime["__handleSolReserveNotificationBigForTest"]>): ReturnType<SolanaPubSubRuntime["__handleSolReserveNotificationBigForTest"]> { return getSolanaPubsub().__handleSolReserveNotificationBigForTest(...args); }
export function __helius429TryProbeForTest(...args: Parameters<SolanaPubSubRuntime["__helius429TryProbeForTest"]>): ReturnType<SolanaPubSubRuntime["__helius429TryProbeForTest"]> { return getSolanaPubsub().__helius429TryProbeForTest(...args); }
export function __heliusHttpGateForTest(...args: Parameters<SolanaPubSubRuntime["__heliusHttpGateForTest"]>): ReturnType<SolanaPubSubRuntime["__heliusHttpGateForTest"]> { return getSolanaPubsub().__heliusHttpGateForTest(...args); }
export function __heliusReserveLimiterForTest(...args: Parameters<SolanaPubSubRuntime["__heliusReserveLimiterForTest"]>): ReturnType<SolanaPubSubRuntime["__heliusReserveLimiterForTest"]> { return getSolanaPubsub().__heliusReserveLimiterForTest(...args); }
export function __heliusSpendForTest(...args: Parameters<SolanaPubSubRuntime["__heliusSpendForTest"]>): ReturnType<SolanaPubSubRuntime["__heliusSpendForTest"]> { return getSolanaPubsub().__heliusSpendForTest(...args); }
export function __heliusSpendStoreStatsForTest(...args: Parameters<SolanaPubSubRuntime["__heliusSpendStoreStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__heliusSpendStoreStatsForTest"]> { return getSolanaPubsub().__heliusSpendStoreStatsForTest(...args); }
export function __hotMintsCacheForTest(...args: Parameters<SolanaPubSubRuntime["__hotMintsCacheForTest"]>): ReturnType<SolanaPubSubRuntime["__hotMintsCacheForTest"]> { return getSolanaPubsub().__hotMintsCacheForTest(...args); }
export function __hotMintsStatsForTest(...args: Parameters<SolanaPubSubRuntime["__hotMintsStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__hotMintsStatsForTest"]> { return getSolanaPubsub().__hotMintsStatsForTest(...args); }
export function __hotPollSpanDefaultForTest(...args: Parameters<SolanaPubSubRuntime["__hotPollSpanDefaultForTest"]>): ReturnType<SolanaPubSubRuntime["__hotPollSpanDefaultForTest"]> { return getSolanaPubsub().__hotPollSpanDefaultForTest(...args); }
export function __isMentionExcludedSolMintForTest(...args: Parameters<SolanaPubSubRuntime["__isMentionExcludedSolMintForTest"]>): ReturnType<SolanaPubSubRuntime["__isMentionExcludedSolMintForTest"]> { return getSolanaPubsub().__isMentionExcludedSolMintForTest(...args); }
export function __lastSolPubsubLogForTest(...args: Parameters<SolanaPubSubRuntime["__lastSolPubsubLogForTest"]>): ReturnType<SolanaPubSubRuntime["__lastSolPubsubLogForTest"]> { return getSolanaPubsub().__lastSolPubsubLogForTest(...args); }
export function __lastSubsLogForTest(...args: Parameters<SolanaPubSubRuntime["__lastSubsLogForTest"]>): ReturnType<SolanaPubSubRuntime["__lastSubsLogForTest"]> { return getSolanaPubsub().__lastSubsLogForTest(...args); }
export function __loadHeliusDayCuForTest(...args: Parameters<SolanaPubSubRuntime["__loadHeliusDayCuForTest"]>): ReturnType<SolanaPubSubRuntime["__loadHeliusDayCuForTest"]> { return getSolanaPubsub().__loadHeliusDayCuForTest(...args); }
export function __loadHotMintsAtBootForTest(...args: Parameters<SolanaPubSubRuntime["__loadHotMintsAtBootForTest"]>): ReturnType<SolanaPubSubRuntime["__loadHotMintsAtBootForTest"]> { return getSolanaPubsub().__loadHotMintsAtBootForTest(...args); }
export function __markSolanaPublicRpcResultForTest(...args: Parameters<SolanaPubSubRuntime["__markSolanaPublicRpcResultForTest"]>): ReturnType<SolanaPubSubRuntime["__markSolanaPublicRpcResultForTest"]> { return getSolanaPubsub().__markSolanaPublicRpcResultForTest(...args); }
export function __mintYieldForTest(...args: Parameters<SolanaPubSubRuntime["__mintYieldForTest"]>): ReturnType<SolanaPubSubRuntime["__mintYieldForTest"]> { return getSolanaPubsub().__mintYieldForTest(...args); }
export function __noteCoverageForTest(...args: Parameters<SolanaPubSubRuntime["__noteCoverageForTest"]>): ReturnType<SolanaPubSubRuntime["__noteCoverageForTest"]> { return getSolanaPubsub().__noteCoverageForTest(...args); }
export function __noteHeliusDayCuForTest(...args: Parameters<SolanaPubSubRuntime["__noteHeliusDayCuForTest"]>): ReturnType<SolanaPubSubRuntime["__noteHeliusDayCuForTest"]> { return getSolanaPubsub().__noteHeliusDayCuForTest(...args); }
export function __noteSolMintAlertForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolMintAlertForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolMintAlertForTest"]> { return getSolanaPubsub().__noteSolMintAlertForTest(...args); }
export function __noteSolMintFrameForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolMintFrameForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolMintFrameForTest"]> { return getSolanaPubsub().__noteSolMintFrameForTest(...args); }
export function __noteSolPublicnodeWsOpenForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolPublicnodeWsOpenForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolPublicnodeWsOpenForTest"]> { return getSolanaPubsub().__noteSolPublicnodeWsOpenForTest(...args); }
export function __noteSolPubsubLifecycleForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolPubsubLifecycleForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolPubsubLifecycleForTest"]> { return getSolanaPubsub().__noteSolPubsubLifecycleForTest(...args); }
export function __noteSolReserveBackoffForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolReserveBackoffForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolReserveBackoffForTest"]> { return getSolanaPubsub().__noteSolReserveBackoffForTest(...args); }
export function __noteSolReserveEmitForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolReserveEmitForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolReserveEmitForTest"]> { return getSolanaPubsub().__noteSolReserveEmitForTest(...args); }
export function __noteSolReserveEventForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolReserveEventForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolReserveEventForTest"]> { return getSolanaPubsub().__noteSolReserveEventForTest(...args); }
export function __noteSolSoloFastLaneForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolSoloFastLaneForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolSoloFastLaneForTest"]> { return getSolanaPubsub().__noteSolSoloFastLaneForTest(...args); }
export function __noteSolWsFailForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolWsFailForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolWsFailForTest"]> { return getSolanaPubsub().__noteSolWsFailForTest(...args); }
export function __noteSolWsOpenForTest(...args: Parameters<SolanaPubSubRuntime["__noteSolWsOpenForTest"]>): ReturnType<SolanaPubSubRuntime["__noteSolWsOpenForTest"]> { return getSolanaPubsub().__noteSolWsOpenForTest(...args); }
export function __openSolReserveRateLimitCircuitForTest(...args: Parameters<SolanaPubSubRuntime["__openSolReserveRateLimitCircuitForTest"]>): ReturnType<SolanaPubSubRuntime["__openSolReserveRateLimitCircuitForTest"]> { return getSolanaPubsub().__openSolReserveRateLimitCircuitForTest(...args); }
export function __oracleCounterSnapshotForTest(...args: Parameters<SolanaPubSubRuntime["__oracleCounterSnapshotForTest"]>): ReturnType<SolanaPubSubRuntime["__oracleCounterSnapshotForTest"]> { return getSolanaPubsub().__oracleCounterSnapshotForTest(...args); }
export function __readSplTokenAmountForTest(...args: Parameters<SolanaPubSubRuntime["__readSplTokenAmountForTest"]>): ReturnType<SolanaPubSubRuntime["__readSplTokenAmountForTest"]> { return getSolanaPubsub().__readSplTokenAmountForTest(...args); }
export function __readSplTokenAmountBigForTest(...args: Parameters<SolanaPubSubRuntime["__readSplTokenAmountBigForTest"]>): ReturnType<SolanaPubSubRuntime["__readSplTokenAmountBigForTest"]> { return getSolanaPubsub().__readSplTokenAmountBigForTest(...args); }
export function __reseedHotMintsFromWlForTest(...args: Parameters<SolanaPubSubRuntime["__reseedHotMintsFromWlForTest"]>): ReturnType<SolanaPubSubRuntime["__reseedHotMintsFromWlForTest"]> { return getSolanaPubsub().__reseedHotMintsFromWlForTest(...args); }
export function __reserveCacheRowsForTest(...args: Parameters<SolanaPubSubRuntime["__reserveCacheRowsForTest"]>): ReturnType<SolanaPubSubRuntime["__reserveCacheRowsForTest"]> { return getSolanaPubsub().__reserveCacheRowsForTest(...args); }
export function __reserveCacheStatsForTest(...args: Parameters<SolanaPubSubRuntime["__reserveCacheStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__reserveCacheStatsForTest"]> { return getSolanaPubsub().__reserveCacheStatsForTest(...args); }
export function __reserveRecoveryStatsForTest(...args: Parameters<SolanaPubSubRuntime["__reserveRecoveryStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__reserveRecoveryStatsForTest"]> { return getSolanaPubsub().__reserveRecoveryStatsForTest(...args); }
export function __resetCoverageForTest(...args: Parameters<SolanaPubSubRuntime["__resetCoverageForTest"]>): ReturnType<SolanaPubSubRuntime["__resetCoverageForTest"]> { return getSolanaPubsub().__resetCoverageForTest(...args); }
export function __resetHelius429ForTest(...args: Parameters<SolanaPubSubRuntime["__resetHelius429ForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHelius429ForTest"]> { return getSolanaPubsub().__resetHelius429ForTest(...args); }
export function __resetHeliusDayCuForTest(...args: Parameters<SolanaPubSubRuntime["__resetHeliusDayCuForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHeliusDayCuForTest"]> { return getSolanaPubsub().__resetHeliusDayCuForTest(...args); }
export function __resetHeliusDayForTest(...args: Parameters<SolanaPubSubRuntime["__resetHeliusDayForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHeliusDayForTest"]> { return getSolanaPubsub().__resetHeliusDayForTest(...args); }
export function __resetHeliusHttpBucketForTest(...args: Parameters<SolanaPubSubRuntime["__resetHeliusHttpBucketForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHeliusHttpBucketForTest"]> { return getSolanaPubsub().__resetHeliusHttpBucketForTest(...args); }
export function __resetHeliusReserveLimiterForTest(...args: Parameters<SolanaPubSubRuntime["__resetHeliusReserveLimiterForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHeliusReserveLimiterForTest"]> { return getSolanaPubsub().__resetHeliusReserveLimiterForTest(...args); }
export function __resetHeliusSpendStoreForTest(...args: Parameters<SolanaPubSubRuntime["__resetHeliusSpendStoreForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHeliusSpendStoreForTest"]> { return getSolanaPubsub().__resetHeliusSpendStoreForTest(...args); }
export function __resetHotMintsForTest(...args: Parameters<SolanaPubSubRuntime["__resetHotMintsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetHotMintsForTest"]> { return getSolanaPubsub().__resetHotMintsForTest(...args); }
export function __resetReserveCacheForTest(...args: Parameters<SolanaPubSubRuntime["__resetReserveCacheForTest"]>): ReturnType<SolanaPubSubRuntime["__resetReserveCacheForTest"]> { return getSolanaPubsub().__resetReserveCacheForTest(...args); }
export function __resetReserveRecoveryForTest(...args: Parameters<SolanaPubSubRuntime["__resetReserveRecoveryForTest"]>): ReturnType<SolanaPubSubRuntime["__resetReserveRecoveryForTest"]> { return getSolanaPubsub().__resetReserveRecoveryForTest(...args); }
export function __resetRpcSpendForTest(...args: Parameters<SolanaPubSubRuntime["__resetRpcSpendForTest"]>): ReturnType<SolanaPubSubRuntime["__resetRpcSpendForTest"]> { return getSolanaPubsub().__resetRpcSpendForTest(...args); }
export function __resetSolAnalyzeRateForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolAnalyzeRateForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolAnalyzeRateForTest"]> { return getSolanaPubsub().__resetSolAnalyzeRateForTest(...args); }
export function __resetSolCoveredPoolsForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolCoveredPoolsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolCoveredPoolsForTest"]> { return getSolanaPubsub().__resetSolCoveredPoolsForTest(...args); }
export function __resetSolIndexedCapabilityForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolIndexedCapabilityForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolIndexedCapabilityForTest"]> { return getSolanaPubsub().__resetSolIndexedCapabilityForTest(...args); }
export function __resetSolMintMentionSubsForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolMintMentionSubsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolMintMentionSubsForTest"]> { return getSolanaPubsub().__resetSolMintMentionSubsForTest(...args); }
export function __resetSolMintMentionsForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolMintMentionsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolMintMentionsForTest"]> { return getSolanaPubsub().__resetSolMintMentionsForTest(...args); }
export function __resetSolPubsubDroppedForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubDroppedForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubDroppedForTest"]> { return getSolanaPubsub().__resetSolPubsubDroppedForTest(...args); }
export function __resetSolPubsubFrameSamplesForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubFrameSamplesForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubFrameSamplesForTest"]> { return getSolanaPubsub().__resetSolPubsubFrameSamplesForTest(...args); }
export function __resetSolPubsubIngestWatchForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubIngestWatchForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubIngestWatchForTest"]> { return getSolanaPubsub().__resetSolPubsubIngestWatchForTest(...args); }
export function __resetSolPubsubLifecycleForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubLifecycleForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubLifecycleForTest"]> { return getSolanaPubsub().__resetSolPubsubLifecycleForTest(...args); }
export function __resetSolPubsubPublicnodeReturnForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubPublicnodeReturnForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubPublicnodeReturnForTest"]> { return getSolanaPubsub().__resetSolPubsubPublicnodeReturnForTest(...args); }
export function __resetSolPubsubReasonsForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubReasonsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubReasonsForTest"]> { return getSolanaPubsub().__resetSolPubsubReasonsForTest(...args); }
export function __resetSolPubsubReconnectForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubReconnectForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubReconnectForTest"]> { return getSolanaPubsub().__resetSolPubsubReconnectForTest(...args); }
export function __resetSolPubsubStayForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolPubsubStayForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolPubsubStayForTest"]> { return getSolanaPubsub().__resetSolPubsubStayForTest(...args); }
export function __resetSolReserveForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolReserveForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolReserveForTest"]> { return getSolanaPubsub().__resetSolReserveForTest(...args); }
export function __solFallbackDegradationForTest() { return getSolanaPubsub().__solFallbackDegradationForTest(); }
export function __resetSolReserveIndexedForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolReserveIndexedForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolReserveIndexedForTest"]> { return getSolanaPubsub().__resetSolReserveIndexedForTest(...args); }
export function __resetSolRestoreCoordinatorForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolRestoreCoordinatorForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolRestoreCoordinatorForTest"]> { return getSolanaPubsub().__resetSolRestoreCoordinatorForTest(...args); }
export function __resetSolRpcHealthForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolRpcHealthForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolRpcHealthForTest"]> { return getSolanaPubsub().__resetSolRpcHealthForTest(...args); }
export function __resetSolSigDedupForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolSigDedupForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolSigDedupForTest"]> { return getSolanaPubsub().__resetSolSigDedupForTest(...args); }
export function __resetSolWsRotationForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolWsRotationForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolWsRotationForTest"]> { return getSolanaPubsub().__resetSolWsRotationForTest(...args); }
export function __resetSolanaRpcHealthForTest(...args: Parameters<SolanaPubSubRuntime["__resetSolanaRpcHealthForTest"]>): ReturnType<SolanaPubSubRuntime["__resetSolanaRpcHealthForTest"]> { return getSolanaPubsub().__resetSolanaRpcHealthForTest(...args); }
export function __resetStateErrorsForTest(...args: Parameters<SolanaPubSubRuntime["__resetStateErrorsForTest"]>): ReturnType<SolanaPubSubRuntime["__resetStateErrorsForTest"]> { return getSolanaPubsub().__resetStateErrorsForTest(...args); }
export function __resolveSolReservePoolForTest(...args: Parameters<SolanaPubSubRuntime["__resolveSolReservePoolForTest"]>): ReturnType<SolanaPubSubRuntime["__resolveSolReservePoolForTest"]> { return getSolanaPubsub().__resolveSolReservePoolForTest(...args); }
export function __rpcSpendForTest(...args: Parameters<SolanaPubSubRuntime["__rpcSpendForTest"]>): ReturnType<SolanaPubSubRuntime["__rpcSpendForTest"]> { return getSolanaPubsub().__rpcSpendForTest(...args); }
export function __runFallbackTombstoneRecoveryWakeForTest(...args: Parameters<SolanaPubSubRuntime["__runFallbackTombstoneRecoveryWakeForTest"]>): ReturnType<SolanaPubSubRuntime["__runFallbackTombstoneRecoveryWakeForTest"]> { return getSolanaPubsub().__runFallbackTombstoneRecoveryWakeForTest(...args); }
export function __runFallbackTombstoneRecoveryWatchdogForTest(...args: Parameters<SolanaPubSubRuntime["__runFallbackTombstoneRecoveryWatchdogForTest"]>): ReturnType<SolanaPubSubRuntime["__runFallbackTombstoneRecoveryWatchdogForTest"]> { return getSolanaPubsub().__runFallbackTombstoneRecoveryWatchdogForTest(...args); }
export function __seedSolMentionTombstonesForTest(...args: Parameters<SolanaPubSubRuntime["__seedSolMentionTombstonesForTest"]>): ReturnType<SolanaPubSubRuntime["__seedSolMentionTombstonesForTest"]> { return getSolanaPubsub().__seedSolMentionTombstonesForTest(...args); }
export function __seedSolPubsubFrameSamplesForTest(...args: Parameters<SolanaPubSubRuntime["__seedSolPubsubFrameSamplesForTest"]>): ReturnType<SolanaPubSubRuntime["__seedSolPubsubFrameSamplesForTest"]> { return getSolanaPubsub().__seedSolPubsubFrameSamplesForTest(...args); }
export function __seedSolReservePoolForTest(...args: Parameters<SolanaPubSubRuntime["__seedSolReservePoolForTest"]>): ReturnType<SolanaPubSubRuntime["__seedSolReservePoolForTest"]> { return getSolanaPubsub().__seedSolReservePoolForTest(...args); }
export function __seedSolReservePoolBigForTest(...args: Parameters<SolanaPubSubRuntime["__seedSolReservePoolBigForTest"]>): ReturnType<SolanaPubSubRuntime["__seedSolReservePoolBigForTest"]> { return getSolanaPubsub().__seedSolReservePoolBigForTest(...args); }
export function __setHelius429NowForTest(...args: Parameters<SolanaPubSubRuntime["__setHelius429NowForTest"]>): ReturnType<SolanaPubSubRuntime["__setHelius429NowForTest"]> { return getSolanaPubsub().__setHelius429NowForTest(...args); }
export function __setHeliusApiKeyForTest(...args: Parameters<SolanaPubSubRuntime["__setHeliusApiKeyForTest"]>): ReturnType<SolanaPubSubRuntime["__setHeliusApiKeyForTest"]> { return getSolanaPubsub().__setHeliusApiKeyForTest(...args); }
export function __setHeliusHttpNowForTest(...args: Parameters<SolanaPubSubRuntime["__setHeliusHttpNowForTest"]>): ReturnType<SolanaPubSubRuntime["__setHeliusHttpNowForTest"]> { return getSolanaPubsub().__setHeliusHttpNowForTest(...args); }
export function __setHeliusHttpSleepForTest(...args: Parameters<SolanaPubSubRuntime["__setHeliusHttpSleepForTest"]>): ReturnType<SolanaPubSubRuntime["__setHeliusHttpSleepForTest"]> { return getSolanaPubsub().__setHeliusHttpSleepForTest(...args); }
export function __setHeliusSpendStoreForTest(...args: Parameters<SolanaPubSubRuntime["__setHeliusSpendStoreForTest"]>): ReturnType<SolanaPubSubRuntime["__setHeliusSpendStoreForTest"]> { return getSolanaPubsub().__setHeliusSpendStoreForTest(...args); }
export function __setHotMintsStoreForTest(...args: Parameters<SolanaPubSubRuntime["__setHotMintsStoreForTest"]>): ReturnType<SolanaPubSubRuntime["__setHotMintsStoreForTest"]> { return getSolanaPubsub().__setHotMintsStoreForTest(...args); }
export function __setRecoverSolReserveSigForTest(...args: Parameters<SolanaPubSubRuntime["__setRecoverSolReserveSigForTest"]>): ReturnType<SolanaPubSubRuntime["__setRecoverSolReserveSigForTest"]> { return getSolanaPubsub().__setRecoverSolReserveSigForTest(...args); }
export function __setReserveCacheStoreForTest(...args: Parameters<SolanaPubSubRuntime["__setReserveCacheStoreForTest"]>): ReturnType<SolanaPubSubRuntime["__setReserveCacheStoreForTest"]> { return getSolanaPubsub().__setReserveCacheStoreForTest(...args); }
export function __setReserveDiagEmittedForTest(...args: Parameters<SolanaPubSubRuntime["__setReserveDiagEmittedForTest"]>): ReturnType<SolanaPubSubRuntime["__setReserveDiagEmittedForTest"]> { return getSolanaPubsub().__setReserveDiagEmittedForTest(...args); }
export function __setReserveDiagNotificationsForTest(...args: Parameters<SolanaPubSubRuntime["__setReserveDiagNotificationsForTest"]>): ReturnType<SolanaPubSubRuntime["__setReserveDiagNotificationsForTest"]> { return getSolanaPubsub().__setReserveDiagNotificationsForTest(...args); }
export function __setSolAnalyzeCapsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolAnalyzeCapsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolAnalyzeCapsForTest"]> { return getSolanaPubsub().__setSolAnalyzeCapsForTest(...args); }
export function __setSolAnalyzeHandleForTest(...args: Parameters<SolanaPubSubRuntime["__setSolAnalyzeHandleForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolAnalyzeHandleForTest"]> { return getSolanaPubsub().__setSolAnalyzeHandleForTest(...args); }
export function __setSolHttpFetchForTest(...args: Parameters<SolanaPubSubRuntime["__setSolHttpFetchForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolHttpFetchForTest"]> { return getSolanaPubsub().__setSolHttpFetchForTest(...args); }
export function __setSolLogsNackCooldownForTest(...args: Parameters<SolanaPubSubRuntime["__setSolLogsNackCooldownForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolLogsNackCooldownForTest"]> { return getSolanaPubsub().__setSolLogsNackCooldownForTest(...args); }
export function __setSolLogsSubForTest(...args: Parameters<SolanaPubSubRuntime["__setSolLogsSubForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolLogsSubForTest"]> { return getSolanaPubsub().__setSolLogsSubForTest(...args); }
export function __setSolMentionPendingForTest(...args: Parameters<SolanaPubSubRuntime["__setSolMentionPendingForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolMentionPendingForTest"]> { return getSolanaPubsub().__setSolMentionPendingForTest(...args); }
export function __setSolMintDecimalsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolMintDecimalsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolMintDecimalsForTest"]> { return getSolanaPubsub().__setSolMintDecimalsForTest(...args); }
export function __setSolPollSigHandlerForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPollSigHandlerForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPollSigHandlerForTest"]> { return getSolanaPubsub().__setSolPollSigHandlerForTest(...args); }
export function __setSolPublicnodeWsFailsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPublicnodeWsFailsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPublicnodeWsFailsForTest"]> { return getSolanaPubsub().__setSolPublicnodeWsFailsForTest(...args); }
export function __setSolPubsubFramesForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubFramesForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubFramesForTest"]> { return getSolanaPubsub().__setSolPubsubFramesForTest(...args); }
export function __setSolPubsubLastFrameAtForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubLastFrameAtForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubLastFrameAtForTest"]> { return getSolanaPubsub().__setSolPubsubLastFrameAtForTest(...args); }
export function __setSolPubsubNowForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubNowForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubNowForTest"]> { return getSolanaPubsub().__setSolPubsubNowForTest(...args); }
export function __setSolPubsubReconnectAttemptsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubReconnectAttemptsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubReconnectAttemptsForTest"]> { return getSolanaPubsub().__setSolPubsubReconnectAttemptsForTest(...args); }
export function __setSolPubsubReqIdForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubReqIdForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubReqIdForTest"]> { return getSolanaPubsub().__setSolPubsubReqIdForTest(...args); }
export function __setSolPubsubSocketForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubSocketForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubSocketForTest"]> { return getSolanaPubsub().__setSolPubsubSocketForTest(...args); }
export function __setSolPubsubTransportForTest(...args: Parameters<SolanaPubSubRuntime["__setSolPubsubTransportForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolPubsubTransportForTest"]> { return getSolanaPubsub().__setSolPubsubTransportForTest(...args); }
export function __setSolReserveAdmissionForTest(...args: Parameters<SolanaPubSubRuntime["__setSolReserveAdmissionForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolReserveAdmissionForTest"]> { return getSolanaPubsub().__setSolReserveAdmissionForTest(...args); }
export function __setSolReserveFailedForTest(...args: Parameters<SolanaPubSubRuntime["__setSolReserveFailedForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolReserveFailedForTest"]> { return getSolanaPubsub().__setSolReserveFailedForTest(...args); }
export function __setSolReserveRateLimitUntilForTest(...args: Parameters<SolanaPubSubRuntime["__setSolReserveRateLimitUntilForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolReserveRateLimitUntilForTest"]> { return getSolanaPubsub().__setSolReserveRateLimitUntilForTest(...args); }
export function __setSolReserveResolvedForTest(...args: Parameters<SolanaPubSubRuntime["__setSolReserveResolvedForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolReserveResolvedForTest"]> { return getSolanaPubsub().__setSolReserveResolvedForTest(...args); }
export function __setSolRpcPostForTest(...args: Parameters<SolanaPubSubRuntime["__setSolRpcPostForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolRpcPostForTest"]> { return getSolanaPubsub().__setSolRpcPostForTest(...args); }
export function __setSolSubGapMsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolSubGapMsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolSubGapMsForTest"]> { return getSolanaPubsub().__setSolSubGapMsForTest(...args); }
export function __setSolWsReconnectRndForTest(...args: Parameters<SolanaPubSubRuntime["__setSolWsReconnectRndForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolWsReconnectRndForTest"]> { return getSolanaPubsub().__setSolWsReconnectRndForTest(...args); }
export function __setSolanaExtraWsUrlsForTest(...args: Parameters<SolanaPubSubRuntime["__setSolanaExtraWsUrlsForTest"]>): ReturnType<SolanaPubSubRuntime["__setSolanaExtraWsUrlsForTest"]> { return getSolanaPubsub().__setSolanaExtraWsUrlsForTest(...args); }
export function __setSubNowForTest(...args: Parameters<SolanaPubSubRuntime["__setSubNowForTest"]>): ReturnType<SolanaPubSubRuntime["__setSubNowForTest"]> { return getSolanaPubsub().__setSubNowForTest(...args); }
export function __setTrustedSolPriceForTest(...args: Parameters<SolanaPubSubRuntime["__setTrustedSolPriceForTest"]>): ReturnType<SolanaPubSubRuntime["__setTrustedSolPriceForTest"]> { return getSolanaPubsub().__setTrustedSolPriceForTest(...args); }
export function __solCoveredPoolsForTest(...args: Parameters<SolanaPubSubRuntime["__solCoveredPoolsForTest"]>): ReturnType<SolanaPubSubRuntime["__solCoveredPoolsForTest"]> { return getSolanaPubsub().__solCoveredPoolsForTest(...args); }
export function __solFreeWsStateForTest(...args: Parameters<SolanaPubSubRuntime["__solFreeWsStateForTest"]>): ReturnType<SolanaPubSubRuntime["__solFreeWsStateForTest"]> { return getSolanaPubsub().__solFreeWsStateForTest(...args); }
export function __solIndexedCapableForTest(...args: Parameters<SolanaPubSubRuntime["__solIndexedCapableForTest"]>): ReturnType<SolanaPubSubRuntime["__solIndexedCapableForTest"]> { return getSolanaPubsub().__solIndexedCapableForTest(...args); }
export function __solLogsNackCooldownForTest(...args: Parameters<SolanaPubSubRuntime["__solLogsNackCooldownForTest"]>): ReturnType<SolanaPubSubRuntime["__solLogsNackCooldownForTest"]> { return getSolanaPubsub().__solLogsNackCooldownForTest(...args); }
export function __solLogsSubCountForTest(...args: Parameters<SolanaPubSubRuntime["__solLogsSubCountForTest"]>): ReturnType<SolanaPubSubRuntime["__solLogsSubCountForTest"]> { return getSolanaPubsub().__solLogsSubCountForTest(...args); }
export function __solMentionLateTombstonesForTest(...args: Parameters<SolanaPubSubRuntime["__solMentionLateTombstonesForTest"]>): ReturnType<SolanaPubSubRuntime["__solMentionLateTombstonesForTest"]> { return getSolanaPubsub().__solMentionLateTombstonesForTest(...args); }
export function __solMentionPendingForTest(...args: Parameters<SolanaPubSubRuntime["__solMentionPendingForTest"]>): ReturnType<SolanaPubSubRuntime["__solMentionPendingForTest"]> { return getSolanaPubsub().__solMentionPendingForTest(...args); }
export function __solMentionTombstoneStatsForTest(...args: Parameters<SolanaPubSubRuntime["__solMentionTombstoneStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__solMentionTombstoneStatsForTest"]> { return getSolanaPubsub().__solMentionTombstoneStatsForTest(...args); }
export function __solPollFallbackActiveForTest(...args: Parameters<SolanaPubSubRuntime["__solPollFallbackActiveForTest"]>): ReturnType<SolanaPubSubRuntime["__solPollFallbackActiveForTest"]> { return getSolanaPubsub().__solPollFallbackActiveForTest(...args); }
export function __solPollFallbackTickForTest(...args: Parameters<SolanaPubSubRuntime["__solPollFallbackTickForTest"]>): ReturnType<SolanaPubSubRuntime["__solPollFallbackTickForTest"]> { return getSolanaPubsub().__solPollFallbackTickForTest(...args); }
export function __solProgramFallbackPoolsForTest(...args: Parameters<SolanaPubSubRuntime["__solProgramFallbackPoolsForTest"]>): ReturnType<SolanaPubSubRuntime["__solProgramFallbackPoolsForTest"]> { return getSolanaPubsub().__solProgramFallbackPoolsForTest(...args); }
export function __solProgramFallbackSendAttemptsForTest(...args: Parameters<SolanaPubSubRuntime["__solProgramFallbackSendAttemptsForTest"]>): ReturnType<SolanaPubSubRuntime["__solProgramFallbackSendAttemptsForTest"]> { return getSolanaPubsub().__solProgramFallbackSendAttemptsForTest(...args); }
export function __solPublicnodeWsFailsForTest(...args: Parameters<SolanaPubSubRuntime["__solPublicnodeWsFailsForTest"]>): ReturnType<SolanaPubSubRuntime["__solPublicnodeWsFailsForTest"]> { return getSolanaPubsub().__solPublicnodeWsFailsForTest(...args); }
export function __solPubsubAnalyzeCapForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubAnalyzeCapForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubAnalyzeCapForTest"]> { return getSolanaPubsub().__solPubsubAnalyzeCapForTest(...args); }
export function __solPubsubEnabledForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubEnabledForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubEnabledForTest"]> { return getSolanaPubsub().__solPubsubEnabledForTest(...args); }
export function __solPubsubFrameRatePerMinForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubFrameRatePerMinForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubFrameRatePerMinForTest"]> { return getSolanaPubsub().__solPubsubFrameRatePerMinForTest(...args); }
export function __solPubsubLastDelayForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubLastDelayForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubLastDelayForTest"]> { return getSolanaPubsub().__solPubsubLastDelayForTest(...args); }
export function __solPubsubLivenessForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubLivenessForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubLivenessForTest"]> { return getSolanaPubsub().__solPubsubLivenessForTest(...args); }
export function __solPubsubMaxAgeForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubMaxAgeForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubMaxAgeForTest"]> { return getSolanaPubsub().__solPubsubMaxAgeForTest(...args); }
export function __solPubsubMaxPoolsForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubMaxPoolsForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubMaxPoolsForTest"]> { return getSolanaPubsub().__solPubsubMaxPoolsForTest(...args); }
export function __solPubsubMetricsForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubMetricsForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubMetricsForTest"]> { return getSolanaPubsub().__solPubsubMetricsForTest(...args); }
export function __solPubsubNoteCloseForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubNoteCloseForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubNoteCloseForTest"]> { return getSolanaPubsub().__solPubsubNoteCloseForTest(...args); }
export function __solPubsubNoteOpenForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubNoteOpenForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubNoteOpenForTest"]> { return getSolanaPubsub().__solPubsubNoteOpenForTest(...args); }
export function __solPubsubPostForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubPostForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubPostForTest"]> { return getSolanaPubsub().__solPubsubPostForTest(...args); }
export function __solPubsubProbeSkipReasonsForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubProbeSkipReasonsForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubProbeSkipReasonsForTest"]> { return getSolanaPubsub().__solPubsubProbeSkipReasonsForTest(...args); }
export function __solPubsubPublicPoolForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubPublicPoolForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubPublicPoolForTest"]> { return getSolanaPubsub().__solPubsubPublicPoolForTest(...args); }
export function __solPubsubPublicnodeReturnStateForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubPublicnodeReturnStateForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubPublicnodeReturnStateForTest"]> { return getSolanaPubsub().__solPubsubPublicnodeReturnStateForTest(...args); }
export function __solPubsubPublicnodeReturnTickForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubPublicnodeReturnTickForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubPublicnodeReturnTickForTest"]> { return getSolanaPubsub().__solPubsubPublicnodeReturnTickForTest(...args); }
export function __solPubsubRestoreStateForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubRestoreStateForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubRestoreStateForTest"]> { return getSolanaPubsub().__solPubsubRestoreStateForTest(...args); }
export function __solPubsubResubForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubResubForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubResubForTest"]> { return getSolanaPubsub().__solPubsubResubForTest(...args); }
export function __solPubsubRpcForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubRpcForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubRpcForTest"]> { return getSolanaPubsub().__solPubsubRpcForTest(...args); }
export function __solPubsubSilentProbeForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubSilentProbeForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubSilentProbeForTest"]> { return getSolanaPubsub().__solPubsubSilentProbeForTest(...args); }
export function __solPubsubStayForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubStayForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubStayForTest"]> { return getSolanaPubsub().__solPubsubStayForTest(...args); }
export function __solPubsubSubscribeNewForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubSubscribeNewForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubSubscribeNewForTest"]> { return getSolanaPubsub().__solPubsubSubscribeNewForTest(...args); }
export function __solPubsubUseHeliusForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubUseHeliusForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubUseHeliusForTest"]> { return getSolanaPubsub().__solPubsubUseHeliusForTest(...args); }
export function __solPubsubWsUrlForTest(...args: Parameters<SolanaPubSubRuntime["__solPubsubWsUrlForTest"]>): ReturnType<SolanaPubSubRuntime["__solPubsubWsUrlForTest"]> { return getSolanaPubsub().__solPubsubWsUrlForTest(...args); }
export function __solReserveActiveSubsForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveActiveSubsForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveActiveSubsForTest"]> { return getSolanaPubsub().__solReserveActiveSubsForTest(...args); }
export function __solReserveCoverageForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveCoverageForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveCoverageForTest"]> { return getSolanaPubsub().__solReserveCoverageForTest(...args); }
export function __solReserveDiagForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveDiagForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveDiagForTest"]> { return getSolanaPubsub().__solReserveDiagForTest(...args); }
export function __solReserveEffectiveTrickleRateForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveEffectiveTrickleRateForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveEffectiveTrickleRateForTest"]> { return getSolanaPubsub().__solReserveEffectiveTrickleRateForTest(...args); }
export function __solReserveEnabledForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveEnabledForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveEnabledForTest"]> { return getSolanaPubsub().__solReserveEnabledForTest(...args); }
export function __solReserveFailReasonsForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveFailReasonsForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveFailReasonsForTest"]> { return getSolanaPubsub().__solReserveFailReasonsForTest(...args); }
export function __solReserveFailedForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveFailedForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveFailedForTest"]> { return getSolanaPubsub().__solReserveFailedForTest(...args); }
export function __solReserveFallbackGuardForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveFallbackGuardForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveFallbackGuardForTest"]> { return getSolanaPubsub().__solReserveFallbackGuardForTest(...args); }
export function __solReserveMetricsForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveMetricsForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveMetricsForTest"]> { return getSolanaPubsub().__solReserveMetricsForTest(...args); }
export function __solReservePacingForTest(...args: Parameters<SolanaPubSubRuntime["__solReservePacingForTest"]>): ReturnType<SolanaPubSubRuntime["__solReservePacingForTest"]> { return getSolanaPubsub().__solReservePacingForTest(...args); }
export function __solReservePairMsForTest(...args: Parameters<SolanaPubSubRuntime["__solReservePairMsForTest"]>): ReturnType<SolanaPubSubRuntime["__solReservePairMsForTest"]> { return getSolanaPubsub().__solReservePairMsForTest(...args); }
export function __solReservePairTtlForTest(...args: Parameters<SolanaPubSubRuntime["__solReservePairTtlForTest"]>): ReturnType<SolanaPubSubRuntime["__solReservePairTtlForTest"]> { return getSolanaPubsub().__solReservePairTtlForTest(...args); }
export function __solReserveRateLimitTimerArmedForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveRateLimitTimerArmedForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveRateLimitTimerArmedForTest"]> { return getSolanaPubsub().__solReserveRateLimitTimerArmedForTest(...args); }
export function __solReserveRateLimitUntilRawForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveRateLimitUntilRawForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveRateLimitUntilRawForTest"]> { return getSolanaPubsub().__solReserveRateLimitUntilRawForTest(...args); }
export function __solReserveSubscribeAllForTest(...args: Parameters<SolanaPubSubRuntime["__solReserveSubscribeAllForTest"]>): ReturnType<SolanaPubSubRuntime["__solReserveSubscribeAllForTest"]> { return getSolanaPubsub().__solReserveSubscribeAllForTest(...args); }
export function __solRestoreCoordinatorForTest(...args: Parameters<SolanaPubSubRuntime["__solRestoreCoordinatorForTest"]>): ReturnType<SolanaPubSubRuntime["__solRestoreCoordinatorForTest"]> { return getSolanaPubsub().__solRestoreCoordinatorForTest(...args); }
export function __solRestoreStateForTest(...args: Parameters<SolanaPubSubRuntime["__solRestoreStateForTest"]>): ReturnType<SolanaPubSubRuntime["__solRestoreStateForTest"]> { return getSolanaPubsub().__solRestoreStateForTest(...args); }
export function __solRpcWaitMsForTest(...args: Parameters<SolanaPubSubRuntime["__solRpcWaitMsForTest"]>): ReturnType<SolanaPubSubRuntime["__solRpcWaitMsForTest"]> { return getSolanaPubsub().__solRpcWaitMsForTest(...args); }
export function __solSoloRetryEnqueueForTest(...args: Parameters<SolanaPubSubRuntime["__solSoloRetryEnqueueForTest"]>): ReturnType<SolanaPubSubRuntime["__solSoloRetryEnqueueForTest"]> { return getSolanaPubsub().__solSoloRetryEnqueueForTest(...args); }
export function __solSoloRetrySweepForTest(...args: Parameters<SolanaPubSubRuntime["__solSoloRetrySweepForTest"]>): ReturnType<SolanaPubSubRuntime["__solSoloRetrySweepForTest"]> { return getSolanaPubsub().__solSoloRetrySweepForTest(...args); }
export function __solSubGapMsForTest(...args: Parameters<SolanaPubSubRuntime["__solSubGapMsForTest"]>): ReturnType<SolanaPubSubRuntime["__solSubGapMsForTest"]> { return getSolanaPubsub().__solSubGapMsForTest(...args); }
export function __solSubPacerPendingForTest(...args: Parameters<SolanaPubSubRuntime["__solSubPacerPendingForTest"]>): ReturnType<SolanaPubSubRuntime["__solSubPacerPendingForTest"]> { return getSolanaPubsub().__solSubPacerPendingForTest(...args); }
export function __solWlBucketUsedForTest(...args: Parameters<SolanaPubSubRuntime["__solWlBucketUsedForTest"]>): ReturnType<SolanaPubSubRuntime["__solWlBucketUsedForTest"]> { return getSolanaPubsub().__solWlBucketUsedForTest(...args); }
export function __solWsStableMsForTest(...args: Parameters<SolanaPubSubRuntime["__solWsStableMsForTest"]>): ReturnType<SolanaPubSubRuntime["__solWsStableMsForTest"]> { return getSolanaPubsub().__solWsStableMsForTest(...args); }
export function __solanaDeltasByVaultForTest(...args: Parameters<SolanaPubSubRuntime["__solanaDeltasByVaultForTest"]>): ReturnType<SolanaPubSubRuntime["__solanaDeltasByVaultForTest"]> { return getSolanaPubsub().__solanaDeltasByVaultForTest(...args); }
export function __solanaPublicRpcForTest(...args: Parameters<SolanaPubSubRuntime["__solanaPublicRpcForTest"]>): ReturnType<SolanaPubSubRuntime["__solanaPublicRpcForTest"]> { return getSolanaPubsub().__solanaPublicRpcForTest(...args); }
export function __solanaRpcHealthPingForTest(...args: Parameters<SolanaPubSubRuntime["__solanaRpcHealthPingForTest"]>): ReturnType<SolanaPubSubRuntime["__solanaRpcHealthPingForTest"]> { return getSolanaPubsub().__solanaRpcHealthPingForTest(...args); }
export function __startSolPubsubForTest(...args: Parameters<SolanaPubSubRuntime["__startSolPubsubForTest"]>): ReturnType<SolanaPubSubRuntime["__startSolPubsubForTest"]> { return getSolanaPubsub().__startSolPubsubForTest(...args); }
export function __stateErrorsForTest(...args: Parameters<SolanaPubSubRuntime["__stateErrorsForTest"]>): ReturnType<SolanaPubSubRuntime["__stateErrorsForTest"]> { return getSolanaPubsub().__stateErrorsForTest(...args); }
export function __stopSolPubsubForTest(...args: Parameters<SolanaPubSubRuntime["__stopSolPubsubForTest"]>): ReturnType<SolanaPubSubRuntime["__stopSolPubsubForTest"]> { return getSolanaPubsub().__stopSolPubsubForTest(...args); }
export function __subscribeSolMentionForTest(...args: Parameters<SolanaPubSubRuntime["__subscribeSolMentionForTest"]>): ReturnType<SolanaPubSubRuntime["__subscribeSolMentionForTest"]> { return getSolanaPubsub().__subscribeSolMentionForTest(...args); }
export function __syncSolMintMentionsForTest(...args: Parameters<SolanaPubSubRuntime["__syncSolMintMentionsForTest"]>): ReturnType<SolanaPubSubRuntime["__syncSolMintMentionsForTest"]> { return getSolanaPubsub().__syncSolMintMentionsForTest(...args); }
export function __trickleStatsForTest(...args: Parameters<SolanaPubSubRuntime["__trickleStatsForTest"]>): ReturnType<SolanaPubSubRuntime["__trickleStatsForTest"]> { return getSolanaPubsub().__trickleStatsForTest(...args); }
export function __unsubscribeSolMentionForTest(...args: Parameters<SolanaPubSubRuntime["__unsubscribeSolMentionForTest"]>): ReturnType<SolanaPubSubRuntime["__unsubscribeSolMentionForTest"]> { return getSolanaPubsub().__unsubscribeSolMentionForTest(...args); }
const acquireHeliusHttpSlot = getSolanaPubsub().acquireHeliusHttpSlot;
export function applyNetEnrichment(...args: Parameters<SolanaPubSubRuntime["applyNetEnrichment"]>): ReturnType<SolanaPubSubRuntime["applyNetEnrichment"]> { return getSolanaPubsub().applyNetEnrichment(...args); }
export function buildNetEventFromAnalysis(...args: Parameters<SolanaPubSubRuntime["buildNetEventFromAnalysis"]>): ReturnType<SolanaPubSubRuntime["buildNetEventFromAnalysis"]> { return getSolanaPubsub().buildNetEventFromAnalysis(...args); }
const bumpRpcSpend = getSolanaPubsub().bumpRpcSpend;
const bumpSolRpcMethod = getSolanaPubsub().bumpSolRpcMethod;
const collectSolWlMints = getSolanaPubsub().collectSolWlMints;
const countSolMentionConfirmed = getSolanaPubsub().countSolMentionConfirmed;
const coverageStatus = getSolanaPubsub().coverageStatus;
const decodeLayoutMissByProgram = getSolanaPubsub().decodeLayoutMissByProgram;
const deliveryRecoveryTick = getSolanaPubsub().deliveryRecoveryTick;
const ensureSolReserveAlive = getSolanaPubsub().ensureSolReserveAlive;
const expireSolMentionPending = getSolanaPubsub().expireSolMentionPending;
const fallbackAwaitingProvider = getSolanaPubsub().fallbackAwaitingProvider;
const fallbackTombstoneRecovery = getSolanaPubsub().fallbackTombstoneRecovery;
const flushHeliusSpend = getSolanaPubsub().flushHeliusSpend;
const getSolPriceUsd = getSolanaPubsub().getSolPriceUsd;
const getSolTokenPriceUsd = getSolanaPubsub().getSolTokenPriceUsd;
const helius429AcquireGlobalProbe = getSolanaPubsub().helius429AcquireGlobalProbe;
export function helius429Banned(...args: Parameters<SolanaPubSubRuntime["helius429Banned"]>): ReturnType<SolanaPubSubRuntime["helius429Banned"]> { return getSolanaPubsub().helius429Banned(...args); }
const helius429GloballyBlocked = getSolanaPubsub().helius429GloballyBlocked;
export function helius429Mark(...args: Parameters<SolanaPubSubRuntime["helius429Mark"]>): ReturnType<SolanaPubSubRuntime["helius429Mark"]> { return getSolanaPubsub().helius429Mark(...args); }
export function helius429MarkPath(...args: Parameters<SolanaPubSubRuntime["helius429MarkPath"]>): ReturnType<SolanaPubSubRuntime["helius429MarkPath"]> { return getSolanaPubsub().helius429MarkPath(...args); }
const helius429PathAcquire = getSolanaPubsub().helius429PathAcquire;
export function helius429PathBanned(...args: Parameters<SolanaPubSubRuntime["helius429PathBanned"]>): ReturnType<SolanaPubSubRuntime["helius429PathBanned"]> { return getSolanaPubsub().helius429PathBanned(...args); }
const helius429PathBlocked = getSolanaPubsub().helius429PathBlocked;
const helius429PathNoteOk = getSolanaPubsub().helius429PathNoteOk;
export function helius429State(...args: Parameters<SolanaPubSubRuntime["helius429State"]>): ReturnType<SolanaPubSubRuntime["helius429State"]> { return getSolanaPubsub().helius429State(...args); }
const helius429Totals = getSolanaPubsub().helius429Totals;
const heliusBudgetExceeded = getSolanaPubsub().heliusBudgetExceeded;
const heliusCuMeter = getSolanaPubsub().heliusCuMeter;
const heliusReserveLimiter = getSolanaPubsub().heliusReserveLimiter;
const heliusReserveLimiterCfg = getSolanaPubsub().heliusReserveLimiterCfg;
const heliusReserveLimiterTick = getSolanaPubsub().heliusReserveLimiterTick;
const heliusRpcUrl = getSolanaPubsub().heliusRpcUrl;
const heliusSpendPersist = getSolanaPubsub().heliusSpendPersist;
const hotMintsStats = getSolanaPubsub().hotMintsStats;
const isHeliusUrl = getSolanaPubsub().isHeliusUrl;
export function isRetryableSolRpc(...args: Parameters<SolanaPubSubRuntime["isRetryableSolRpc"]>): ReturnType<SolanaPubSubRuntime["isRetryableSolRpc"]> { return getSolanaPubsub().isRetryableSolRpc(...args); }
const isSolRpcRateLimitPayload = getSolanaPubsub().isSolRpcRateLimitPayload;
const loadHeliusDayCu = getSolanaPubsub().loadHeliusDayCu;
const loadHotMintsAtBoot = getSolanaPubsub().loadHotMintsAtBoot;
export function markSolanaPublicRpcResult(...args: Parameters<SolanaPubSubRuntime["markSolanaPublicRpcResult"]>): ReturnType<SolanaPubSubRuntime["markSolanaPublicRpcResult"]> { return getSolanaPubsub().markSolanaPublicRpcResult(...args); }
const netEnrich = getSolanaPubsub().netEnrich;
const noteCoverage = getSolanaPubsub().noteCoverage;
const noteSolMintAlert = getSolanaPubsub().noteSolMintAlert;
export function noteSolPublicnodeWsFail(...args: Parameters<SolanaPubSubRuntime["noteSolPublicnodeWsFail"]>): ReturnType<SolanaPubSubRuntime["noteSolPublicnodeWsFail"]> { return getSolanaPubsub().noteSolPublicnodeWsFail(...args); }
export function noteSolPublicnodeWsOk(...args: Parameters<SolanaPubSubRuntime["noteSolPublicnodeWsOk"]>): ReturnType<SolanaPubSubRuntime["noteSolPublicnodeWsOk"]> { return getSolanaPubsub().noteSolPublicnodeWsOk(...args); }
export function noteSolPublicnodeWsOpen(...args: Parameters<SolanaPubSubRuntime["noteSolPublicnodeWsOpen"]>): ReturnType<SolanaPubSubRuntime["noteSolPublicnodeWsOpen"]> { return getSolanaPubsub().noteSolPublicnodeWsOpen(...args); }
const noteSolPubsubFrameSample = getSolanaPubsub().noteSolPubsubFrameSample;
export function noteSolanaHotMint(...args: Parameters<SolanaPubSubRuntime["noteSolanaHotMint"]>): ReturnType<SolanaPubSubRuntime["noteSolanaHotMint"]> { return getSolanaPubsub().noteSolanaHotMint(...args); }
const percentile50 = getSolanaPubsub().percentile50;
export function prioritizeSolReservePools(...args: Parameters<SolanaPubSubRuntime["prioritizeSolReservePools"]>): ReturnType<SolanaPubSubRuntime["prioritizeSolReservePools"]> { return getSolanaPubsub().prioritizeSolReservePools(...args); }
const pruneAndFlushHotMints = getSolanaPubsub().pruneAndFlushHotMints;
export function recoverSolReserveSwapSignature(...args: Parameters<SolanaPubSubRuntime["recoverSolReserveSwapSignature"]>): ReturnType<SolanaPubSubRuntime["recoverSolReserveSwapSignature"]> { return getSolanaPubsub().recoverSolReserveSwapSignature(...args); }
const reserveCache = getSolanaPubsub().reserveCache;
const reserveCacheMem = getSolanaPubsub().reserveCacheMem;
export function reserveDiagHealth(...args: Parameters<SolanaPubSubRuntime["reserveDiagHealth"]>): ReturnType<SolanaPubSubRuntime["reserveDiagHealth"]> { return getSolanaPubsub().reserveDiagHealth(...args); }
const reserveEventRate = getSolanaPubsub().reserveEventRate;
const reserveRecovery = getSolanaPubsub().reserveRecovery;
export function resolveTrustedSolPriceUsd(...args: Parameters<SolanaPubSubRuntime["resolveTrustedSolPriceUsd"]>): ReturnType<SolanaPubSubRuntime["resolveTrustedSolPriceUsd"]> { return getSolanaPubsub().resolveTrustedSolPriceUsd(...args); }
const retryAfterHeaderMs = getSolanaPubsub().retryAfterHeaderMs;
const rpcHostOf = getSolanaPubsub().rpcHostOf;
const rpcSpend = getSolanaPubsub().rpcSpend;
export function selectSolMintMentionTargets(...args: Parameters<SolanaPubSubRuntime["selectSolMintMentionTargets"]>): ReturnType<SolanaPubSubRuntime["selectSolMintMentionTargets"]> { return getSolanaPubsub().selectSolMintMentionTargets(...args); }
export function selectSolPubsubPools(...args: Parameters<SolanaPubSubRuntime["selectSolPubsubPools"]>): ReturnType<SolanaPubSubRuntime["selectSolPubsubPools"]> { return getSolanaPubsub().selectSolPubsubPools(...args); }
const solCoveredPools = getSolanaPubsub().solCoveredPools;
const solFallbackDegraded = getSolanaPubsub().solFallbackDegraded;
// ТЗ 2026-10-09 §3.3 req 4: причина деградации + следующий retry / terminal reason.
const solFallbackDegradationSnapshot = getSolanaPubsub().solFallbackDegradationSnapshot;
const solFreeWsHost = getSolanaPubsub().solFreeWsHost;
export function solKnownLayoutOwner(...args: Parameters<SolanaPubSubRuntime["solKnownLayoutOwner"]>): ReturnType<SolanaPubSubRuntime["solKnownLayoutOwner"]> { return getSolanaPubsub().solKnownLayoutOwner(...args); }
const solLogsReqToMint = getSolanaPubsub().solLogsReqToMint;
const solLogsReqToUnsubscribe = getSolanaPubsub().solLogsReqToUnsubscribe;
const solLogsSubToMint = getSolanaPubsub().solLogsSubToMint;
const solMentionTombstoneStats = getSolanaPubsub().solMentionTombstoneStats;
const solMintMentionSubs = getSolanaPubsub().solMintMentionSubs;
const solMintMentions = getSolanaPubsub().solMintMentions;
const solMintSlotsEffective = getSolanaPubsub().solMintSlotsEffective;
export function solProgramAdmissionFor(...args: Parameters<SolanaPubSubRuntime["solProgramAdmissionFor"]>): ReturnType<SolanaPubSubRuntime["solProgramAdmissionFor"]> { return getSolanaPubsub().solProgramAdmissionFor(...args); }
const solProgramFallbackMetrics = getSolanaPubsub().solProgramFallbackMetrics;
const solProgramFallbackSnapshot = getSolanaPubsub().solProgramFallbackSnapshot;
export function solProgramShouldFallbackOnly(...args: Parameters<SolanaPubSubRuntime["solProgramShouldFallbackOnly"]>): ReturnType<SolanaPubSubRuntime["solProgramShouldFallbackOnly"]> { return getSolanaPubsub().solProgramShouldFallbackOnly(...args); }
export function solPubsubBackoffMs(...args: Parameters<SolanaPubSubRuntime["solPubsubBackoffMs"]>): ReturnType<SolanaPubSubRuntime["solPubsubBackoffMs"]> { return getSolanaPubsub().solPubsubBackoffMs(...args); }
export function solPubsubDropReasonsSum(...args: Parameters<SolanaPubSubRuntime["solPubsubDropReasonsSum"]>): ReturnType<SolanaPubSubRuntime["solPubsubDropReasonsSum"]> { return getSolanaPubsub().solPubsubDropReasonsSum(...args); }
const solPubsubHeliusStayEval = getSolanaPubsub().solPubsubHeliusStayEval;
export function solPubsubLiveness(...args: Parameters<SolanaPubSubRuntime["solPubsubLiveness"]>): ReturnType<SolanaPubSubRuntime["solPubsubLiveness"]> { return getSolanaPubsub().solPubsubLiveness(...args); }
const solPubsubMetrics = getSolanaPubsub().solPubsubMetrics;
const solPubsubNow = getSolanaPubsub().solPubsubNow;
const solPubsubPost = getSolanaPubsub().solPubsubPost;
const solPubsubPublicnodeReturn = getSolanaPubsub().solPubsubPublicnodeReturn;
const solPubsubPublicnodeReturnTick = getSolanaPubsub().solPubsubPublicnodeReturnTick;
const solPubsubReconnect = getSolanaPubsub().solPubsubReconnect;
const solPubsubResponseDiag = getSolanaPubsub().solPubsubResponseDiag;
const solPubsubResubIfConnected = getSolanaPubsub().solPubsubResubIfConnected;
export function solPubsubSilentProbe(...args: Parameters<SolanaPubSubRuntime["solPubsubSilentProbe"]>): ReturnType<SolanaPubSubRuntime["solPubsubSilentProbe"]> { return getSolanaPubsub().solPubsubSilentProbe(...args); }
const solPubsubStageReasonsSum = getSolanaPubsub().solPubsubStageReasonsSum;
const solPubsubSubscribed = getSolanaPubsub().solPubsubSubscribed;
const solPubsubTopPools = getSolanaPubsub().solPubsubTopPools;
const solReserveActiveAccount = getSolanaPubsub().solReserveActiveAccount;
const solReserveDiagSnapshot = getSolanaPubsub().solReserveDiagSnapshot;
const solReserveFailEvents = getSolanaPubsub().solReserveFailEvents;
const solReserveFailReasons = getSolanaPubsub().solReserveFailReasons;
const solReserveFailed = getSolanaPubsub().solReserveFailed;
const solReserveFailedEver = getSolanaPubsub().solReserveFailedEver;
const solReserveMetrics = getSolanaPubsub().solReserveMetrics;
const solReservePendingAccount = getSolanaPubsub().solReservePendingAccount;
const solReserveQuarantined = getSolanaPubsub().solReserveQuarantined;
const solReserveReqToPool = getSolanaPubsub().solReserveReqToPool;
const solReserveResolved = getSolanaPubsub().solReserveResolved;
export function solReserveRetryDelayMs(...args: Parameters<SolanaPubSubRuntime["solReserveRetryDelayMs"]>): ReturnType<SolanaPubSubRuntime["solReserveRetryDelayMs"]> { return getSolanaPubsub().solReserveRetryDelayMs(...args); }
const solReserveRpcDeferMetrics = getSolanaPubsub().solReserveRpcDeferMetrics;
const solReserveRpcDeferred = getSolanaPubsub().solReserveRpcDeferred;
const solReserveState = getSolanaPubsub().solReserveState;
const solReserveSubEvents = getSolanaPubsub().solReserveSubEvents;
const solReserveSubIdToPool = getSolanaPubsub().solReserveSubIdToPool;
const solRestoreSnapshot = getSolanaPubsub().solRestoreSnapshot;
const solRpc429ByPathSnapshot = getSolanaPubsub().solRpc429ByPathSnapshot;
const solRpc429LaneAttempts = getSolanaPubsub().solRpc429LaneAttempts;
const solRpcEp = getSolanaPubsub().solRpcEp;
const solSoloRetry = getSolanaPubsub().solSoloRetry;
const solSoloRetrySweep = getSolanaPubsub().solSoloRetrySweep;
export function solanaPublicRpc(...args: Parameters<SolanaPubSubRuntime["solanaPublicRpc"]>): ReturnType<SolanaPubSubRuntime["solanaPublicRpc"]> { return getSolanaPubsub().solanaPublicRpc(...args); }
export function solanaRpcHealthPing(...args: Parameters<SolanaPubSubRuntime["solanaRpcHealthPing"]>): ReturnType<SolanaPubSubRuntime["solanaRpcHealthPing"]> { return getSolanaPubsub().solanaRpcHealthPing(...args); }
const startSolPubsub = getSolanaPubsub().startSolPubsub;
const stopSolPubsub = getSolanaPubsub().stopSolPubsub;
const trickle = getSolanaPubsub().trickle;

// ── B4.4: the composition root implements the runtime dependency contract ───────────────
function makeSolanaPubSubDeps(): SolanaPubSubRuntimeDeps {
  return {
    env: (name) => process.env[name],
    log: (message) => log(message),
    addError: (message) => addError(message),
    sleep: (ms) => sleep(ms),
    clock: {
      now: () => Date.now(),
      setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
      clearTimeout: (handle) => clearTimeout(handle ?? undefined),
      setInterval: (handler, delayMs) => setInterval(handler, delayMs),
      clearInterval: (handle) => clearInterval(handle ?? undefined),
    },
    pausedStats: pausedStats as { dropped: number; hotPollSkipped: number },
    deliveryWindow: deliveryHealthWindow as { reserveNotificationsTotal: number },
    state: {
      get running() { return state.running; },
      get paused() { return state.paused; },
      get startedAt() { return state.startedAt ?? 0; },
      get notifs() { return state.notifs; },
      get errors() { return state.errors; },
      get chain() { return state.chain as unknown as SolanaPubSubChainStateMap; },
    },
    settings: settings as { soloAmountUsd: number; seriesTxMinUsd: number },
    rate: {
      allow: (key, limit, windowMs) => rateAllow(key, limit, windowMs),
      count: (key, windowMs) => rateCount(key, windowMs),
      map: rateMap as unknown as Map<string, unknown>,
    },
    metrics: {
      valuePriority: () => valuePriorityStats,
      noteAccountNotification: () => { engineKernel.metrics!.solNotificationFlow.accountNotifications++; },
      noteLogsNotification: () => { engineKernel.metrics!.solNotificationFlow.logsNotifications++; },
      noteReserveStage: (stage) => { (engineKernel.metrics!.solNotificationFlow.reserveStages as Record<string, number>)[stage]++; },
      oracleCounterSnapshot: () => oracleCounterSnapshot(),
      evmAnalyzeRpcByChain,
      evmAnalyzeRpcRotateCursor,
      resetArbitrumAnalyzeDiagnostics: () => resetArbitrumAnalyzeDiagnosticsForTest(),
      rpcSpendEvmSnapshot: () => rpcSpendEvmSnapshot(),
      hotPollIntervalMs: () => EVM_HOT_POLL_MS(),
      hotPollMaxSpanBlocks: () => HOTPOLL_MAX_SPAN_BLOCKS(),
    },
    chains: {
      anchorsMap: ANCHOR_POOLS,
      watchMetaMap: chainWatchMeta as unknown as Record<string, Record<string, SolanaPubSubWatchMeta>>,
      solanaPublicRpcPool: () => solanaPublicRpcPool(),
      subjectForWatchedPool: (chainId, pool) => subjectForWatchedPool(chainId, pool),
      cachedNativePriceUsd: (chainId) => getCachedNativePriceUsd(chainId),
      defaultRpc: () => SOLANA_RPC_DEFAULT,
      wrappedToNative: WRAPPED_TO_NATIVE,
      stableSyms: STABLE_SYMS,
      coveragePendingSolSlots,
      mintSlotsMax: () => MINT_SLOTS_MAX(),
      isValidTxHash: (chainId, txHash) => isValidChainTxHash(chainId, txHash),
      shortAddr: (a) => shortAddr(a),
      lower: (s) => lower(s),
      resolveSymFromAddr: (addr) => resolveSymFromAddr(addr),
      isStablecoin: (addr, symbol) => isStablecoin(addr, symbol),
      isMajorToken: (chainId, addr) => isMajorToken(chainId, addr),
      isNativeOrWrapped: (addr) => isNativeOrWrapped(addr),
      solanaSubjectNetZero: (amount) => solanaSubjectNetZero(amount),
    },
    pipeline: {
      processExtractedEvent: (chainId, extracted, source, rawData, skipSeenIncrement, maxAgeSecOverride) =>
        processExtractedEvent(chainId, extracted, source, rawData as Record<string, unknown> | undefined, skipSeenIncrement, maxAgeSecOverride),
      enqueueExtracted: (chainId, extracted, source, rawData, skipSeenIncrement, maxAgeSecOverride, high) =>
        enqueueExtracted(chainId, extracted, source, rawData as Record<string, unknown> | undefined, skipSeenIncrement, maxAgeSecOverride, high),
      analyzeSolanaTx: (txHash, origin) => analyzeSolanaTx(txHash, origin as SolAnalysisOrigin),
      noteSolWsFrame: (now) => noteSolWsFrame(now),
      withEnrichGate: (fn, opts) => withEnrichGate(fn, opts),
      withAnonymousGtLatch: (path, request) => withAnonymousGtLatch(path as AnonymousGtPath, request) as Promise<{ value: never | null; blocked?: boolean }>,
      reserveCoverageSnapshot: () => solReserveCoverageSnapshot(),
      deliveryHealthSnapshot: () => deliveryHealthSnapshot(),
    },
    http: {
      fetchWithTimeout: (url, opts, timeoutMs) => fetchWithTimeout(url, opts, timeoutMs),
      getJSON: async (url, opts) => (await getJSON(url, opts)) as SolanaPubSubFetchResult<never>,
      dsFetch: async (source, url, opts) => (await dsFetch(source as DsSource, url, opts)) as SolanaPubSubFetchResult<never>,
      fetchEvmTokenMeta: (chainId, addr) => fetchEvmTokenMeta(chainId, addr),
    },
    socket: {
      create: async (url) => {
        const { default: WS } = await import('ws');
        return new WS(url) as unknown as SolanaPubSubTransportSocket;
      },
    },
    store: { reserveCacheStore: () => createReserveCacheStore() },
  };
}

// B4.4: the reserve-cache DB adapter stays here (drizzle + `reserve_cache` table); the runtime
// receives the constructed store through deps.store.
getSolanaPubsub(); // B4.4: instantiate once at load (constructor is inert); then the B4.3 oracle.
getOracleRuntime();

export function createReserveCacheStore(): ReserveCacheStore | null {
  if (!process.env.DATABASE_URL) return null;
  let ensured = false;
  async function ensureTable(): Promise<void> {
    if (ensured) return;
    const { db } = await import('@/db');
    const { sql } = await import('drizzle-orm');
    await db.execute(sql`
      CREATE TABLE IF NOT EXISTS reserve_cache (
        pool text PRIMARY KEY,
        base_reserve text NOT NULL,
        quote_reserve text NOT NULL,
        base_mint text NOT NULL,
        quote_mint text NOT NULL,
        base_dec int NOT NULL,
        quote_dec int NOT NULL,
        updated_at timestamptz DEFAULT now() NOT NULL
      )
    `);
    ensured = true;
  }
  return {
    async loadAll(): Promise<ReserveCacheRow[]> {
      try {
        await ensureTable();
        const { db } = await import('@/db');
        const { sql } = await import('drizzle-orm');
        const result = await db.execute(sql`
          SELECT pool, base_reserve, quote_reserve, base_mint, quote_mint, base_dec, quote_dec,
                 (EXTRACT(EPOCH FROM updated_at) * 1000)::bigint AS updated_at
          FROM reserve_cache
        `);
        const raw = (result as { rows?: Array<Record<string, unknown>> }).rows
          ?? (Array.isArray(result) ? result as Array<Record<string, unknown>> : []);
        return raw.map((r) => ({
          pool: String(r.pool || ''), baseReserve: String(r.base_reserve || ''), quoteReserve: String(r.quote_reserve || ''),
          baseMint: String(r.base_mint || ''), quoteMint: String(r.quote_mint || ''),
          baseDec: Number(r.base_dec ?? 0), quoteDec: Number(r.quote_dec ?? 0), updatedAt: Number(r.updated_at ?? 0),
        })).filter((r) => r.pool && r.baseReserve && r.quoteReserve);
      } catch {
        return []; // БД недоступна на старте — холодный резолв (fail-open), старт не умирает
      }
    },
    async save(rows: ReserveCacheRow[]): Promise<void> {
      await ensureTable();
      const { db } = await import('@/db');
      const { sql } = await import('drizzle-orm');
      for (const r of rows) {
        await db.execute(sql`
          INSERT INTO reserve_cache (pool, base_reserve, quote_reserve, base_mint, quote_mint, base_dec, quote_dec, updated_at)
          VALUES (
            ${r.pool}, ${r.baseReserve}, ${r.quoteReserve}, ${r.baseMint}, ${r.quoteMint},
            ${r.baseDec}, ${r.quoteDec}, to_timestamp(${r.updatedAt / 1000.0})
          )
          ON CONFLICT (pool) DO UPDATE
          SET base_reserve = EXCLUDED.base_reserve, quote_reserve = EXCLUDED.quote_reserve,
              base_mint = EXCLUDED.base_mint, quote_mint = EXCLUDED.quote_mint,
              base_dec = EXCLUDED.base_dec, quote_dec = EXCLUDED.quote_dec, updated_at = now()
        `);
      }
    },
  };
}
src/app/page.tsx
Dashboard UI (React)
1045 строк · 39KB
"use client";

import { useState, useEffect, useCallback } from "react";

const SITE_URL =
  process.env.NEXT_PUBLIC_SITE_URL ||
  "https://3000-ijep6wg6r625g8dh98oog.e2b.app";

const fmtUsd = (v: number) => {
  v = Number(v) || 0;
  if (v >= 1e9) return "$" + (v / 1e9).toFixed(2) + "B";
  if (v >= 1e6) return "$" + (v / 1e6).toFixed(2) + "M";
  if (v >= 1e3) return "$" + (v / 1e3).toFixed(2) + "K";
  return "$" + v.toFixed(0);
};

const shortAddr = (a: string) =>
  a && a.length > 10 ? a.slice(0, 6) + "…" + a.slice(-4) : a || "?";

async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
  const response = await window.fetch(input, { ...init, credentials: "same-origin" });
  if (response.status !== 401 || init.method === "GET") return response;
  const token = window.prompt("Введите PARSER_API_TOKEN для управления парсером:");
  if (!token) return response;
  const auth = await window.fetch("/api/auth", {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
    credentials: "same-origin",
  });
  if (!auth.ok) {
    window.alert("Авторизация не прошла");
    return response;
  }
  return window.fetch(input, { ...init, credentials: "same-origin" });
}

interface ChainStatus {
  label: string;
  sseConnected: boolean;
  source: string;
  seenEvents: number;
  notified: number;
  watchPools: number;
  watchStablePools: number;
  pollTicks1m: number;
  lastError?: string | null;
  initStatus: string;
  skipReasons: Record<string, number>;
}

interface AlertSettings {
  soloAmountUsd: number;
  seriesTxMinUsd: number;
  seriesMinTx: number;
  seriesWindowSec: number;
  seriesCooldownMin: number;
}

interface Status {
  running: boolean;
  paused?: boolean; // v9.32an-RC2: пауза на дашборде (резерв живёт, анализ заморожен)
  startedAt: number | null;
  uptime: number;
  chains: Record<string, ChainStatus>;
  alertSettings?: AlertSettings;
  freshness?: {
    maxAlertAgeSec: number;
    droppedStaleTotal: number;
    droppedNoBlockTs: number;
  };
  solo?: { instant: number };
  series?: { fired: number; pending: number; suppressedByCooldown: number };
  telegram: { configured: boolean; ok: boolean; sent: number; lastError?: string | null };
  restBudget: { geckoPerMinUsed: number };
  cexFilter: { cacheSize: number };
  errors: { t: number; msg: string }[];
  errorsTotal?: number; // public status keeps only the last five redacted messages
  contractExecFilter?: {
    mode: string;
    inventoryThresholdUsd?: number;
    contractExec: number;
    safeAllowed: number;
    contractTransient?: number;
    contractExecUnknown: number;
  };
}

interface Notification {
  ts: number;
  chain: string;
  side: string;
  symbol: string;
  token: string;
  trader: string;
  txHash: string;
  usd: number;
  impact?: number | null;
  liquidity: number;
  via?: string | null;
  source: string;
  tgOk: boolean;
  tgError?: string | null;
  isContract?: boolean;
  kind: "solo" | "series";
  seriesTxCount?: number;
  seriesTotalUsd?: number;
  blockTs?: number;
  enrichmentErrors?: string[];
  links: {
    gmgn?: string;
    dex?: string;
    arkham?: string;
    tx?: string;
    explorer?: string;
  };
}

export default function Home() {
  const [status, setStatus] = useState<Status | null>(null);
  const [notifs, setNotifs] = useState<Notification[]>([]);
  const [showSettings, setShowSettings] = useState(false);
  const [tgResult, setTgResult] = useState("");
  const [form, setForm] = useState({
    token: "",
    chatId: "",
    poll: 10,
    maxPools: 25,
    cex: true,
    top10: false,
    stable: true,
    contracts: true,
    sse: true,
    soloAmountUsd: 30000,
    seriesTxMinUsd: 1000,
    seriesMinTx: 3,
    seriesWindowSec: 180,
    seriesCooldownMin: 10,
    networks: {
      ethereum: true,
      bsc: true,
      base: true,
      arbitrum: true,
      polygon: true,
      solana: true,
    },
  });
  const [blacklist, setBlacklist] = useState<
    Array<{ id: string; type: string; chain: string; value: string; note?: string; enabled: boolean; hits: number }>
  >([]);
  const [blForm, setBlForm] = useState({ type: "address", chain: "all", value: "", note: "" }); // v9.32ao-RC2 BP4: адреса глобальны — сеть не нужна

  const fetchStatus = useCallback(async () => {
    try {
      const r = await apiFetch("/api/status");
      if (r.ok) {
        const s: Status = await r.json();
        setStatus(s);
        if (s.alertSettings) {
          setForm((f) => ({
            ...f,
            soloAmountUsd: s.alertSettings?.soloAmountUsd ?? f.soloAmountUsd,
            seriesTxMinUsd: s.alertSettings?.seriesTxMinUsd ?? f.seriesTxMinUsd,
            seriesMinTx: s.alertSettings?.seriesMinTx ?? f.seriesMinTx,
            seriesWindowSec: s.alertSettings?.seriesWindowSec ?? f.seriesWindowSec,
            seriesCooldownMin: s.alertSettings?.seriesCooldownMin ?? f.seriesCooldownMin,
          }));
        }
      }
    } catch {}
  }, []);

  const fetchNotifs = useCallback(async () => {
    try {
      const r = await apiFetch("/api/notifications");
      if (r.ok) {
        const d = await r.json();
        setNotifs(d.items || []);
      }
    } catch {}
  }, []);

  const fetchBlacklist = useCallback(async () => {
    try {
      const r = await apiFetch("/api/blacklist");
      if (r.ok) {
        const d = await r.json();
        setBlacklist(d.rules || []);
      }
    } catch {}
  }, []);

  useEffect(() => {
    let active = true;
    const load = async () => {
      if (!active) return;
      await Promise.all([fetchStatus(), fetchNotifs(), fetchBlacklist()]);
    };
    void load();
    const id = setInterval(() => {
      void load();
    }, 8000);
    return () => {
      active = false;
      clearInterval(id);
    };
  }, [fetchStatus, fetchNotifs, fetchBlacklist]);

  const uptimeStr = status?.uptime
    ? (() => {
        const s = Math.floor(status.uptime / 1000);
        const h = Math.floor(s / 3600);
        const m = Math.floor((s % 3600) / 60);
        return h > 0 ? `${h}ч ${m}м` : `${m}м`;
      })()
    : "";

  async function handleStart() {
    await apiFetch("/api/parser/start", { method: "POST" });
    fetchStatus();
  }
  async function handleStop() {
    await apiFetch("/api/parser/stop", { method: "POST" });
    fetchStatus();
  }
  // v9.32an-RC2: пауза вместо стопа — подписки/кэши/reserve живут, resume ≤5с без разогрева
  async function handlePauseToggle() {
    await apiFetch(status?.paused ? "/api/parser/resume" : "/api/parser/pause", { method: "POST" });
    fetchStatus();
  }
  async function handleSaveSettings() {
    const body = {
      telegramToken: form.token,
      telegramChatId: form.chatId,
      pollIntervalSec: form.poll,
      maxPoolsPerChain: form.maxPools,
      cexFilterEnabled: form.cex,
      top10FilterEnabled: form.top10,
      stableFilterEnabled: form.stable,
      excludeContractsEnabled: form.contracts,
      sseEnabled: form.sse,
      soloAmountUsd: form.soloAmountUsd,
      seriesTxMinUsd: form.seriesTxMinUsd,
      seriesMinTx: form.seriesMinTx,
      seriesWindowSec: form.seriesWindowSec,
      seriesCooldownMin: form.seriesCooldownMin,
      networks: form.networks,
    };
    await apiFetch("/api/settings", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    setShowSettings(false);
    fetchStatus();
  }
  async function handleTestTelegram() {
    setTgResult("Отправка...");
    const r = await apiFetch("/api/test-telegram", { method: "POST" });
    const d = await r.json();
    setTgResult(d.ok ? "✅ Отправлено" : `❌ ${d.error || "Ошибка"}`);
  }
  async function handleClearNotifs() {
    await apiFetch("/api/notifications/clear", { method: "POST" });
    fetchNotifs();
  }
  async function handleAddBlacklist() {
    await apiFetch("/api/blacklist", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ type: blForm.type, chain: blForm.chain, value: blForm.value, note: blForm.note }),
    });
    setBlForm({ ...blForm, value: "", note: "" });
    fetchBlacklist();
  }
  async function handleToggleBlacklist(id: string, enabled: boolean) {
    await apiFetch("/api/blacklist/toggle", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id, enabled }),
    });
    fetchBlacklist();
  }
  async function handleDeleteBlacklist(id: string) {
    await apiFetch("/api/blacklist?id=" + encodeURIComponent(id), { method: "DELETE" });
    fetchBlacklist();
  }

  const inputStyle = {
    width: "100%",
    background: "#0e1219",
    color: "#d8dee9",
    border: "1px solid #2a3450",
    borderRadius: "8px",
    padding: "8px 10px",
    fontSize: "13px",
    outline: "none",
    boxSizing: "border-box" as const,
  };

  return (
    <div
      style={{
        background: "#0b0e14",
        color: "#d8dee9",
        fontFamily: "system-ui, sans-serif",
        minHeight: "100vh",
        padding: "20px",
        maxWidth: "1400px",
        margin: "0 auto",
      }}
    >
      {/* ── SITE URL BANNER (TOP) — ОБЯЗАТЕЛЬНАЯ ССЫЛКА ── */}
      <div
        style={{
          background: "linear-gradient(135deg, #1a1f2e 0%, #11151f 100%)",
          border: "3px solid #7aa2f7",
          borderRadius: "16px",
          padding: "16px 20px",
          marginBottom: "20px",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          flexWrap: "wrap",
          gap: "10px",
        }}
      >
        <div>
          <div
            style={{
              fontSize: "11px",
              color: "#7c869c",
              textTransform: "uppercase",
              letterSpacing: "0.1em",
              fontWeight: "bold",
              marginBottom: "4px",
            }}
          >
            🌐 Живой сайт / Live Site URL
          </div>
          <a
            href={SITE_URL}
            style={{
              color: "#7aa2f7",
              fontWeight: "bold",
              fontSize: "16px",
              textDecoration: "none",
            }}
            target="_blank"
            rel="noopener noreferrer"
          >
            {SITE_URL}
          </a>
        </div>
        <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
          <a
            href="/source"
            style={{
              background: "#9ece6a",
              color: "#000",
              padding: "6px 14px",
              borderRadius: "8px",
              textDecoration: "none",
              fontSize: "12px",
              fontWeight: "bold",
            }}
          >
            📦 Исходники
          </a>
          <a
            href="/api/status"
            style={{
              background: "#1e2635",
              color: "#7aa2f7",
              padding: "6px 14px",
              borderRadius: "8px",
              textDecoration: "none",
              fontSize: "12px",
            }}
          >
            /api/status
          </a>
          <a
            href="/api/debug/explain-tx?chain=bsc&tx=0xb67afff83876373895f5c1dd995f79c6b987f85da42b5b657eeede84939e2b63"
            style={{
              background: "#1e2635",
              color: "#f7768e",
              padding: "6px 14px",
              borderRadius: "8px",
              textDecoration: "none",
              fontSize: "12px",
            }}
          >
            explain-tx
          </a>
        </div>
      </div>

      {/* Header */}
      <div
        style={{
          background: "linear-gradient(135deg, #1a1f2e 0%, #11151f 100%)",
          borderRadius: "16px",
          padding: "24px",
          marginBottom: "20px",
          border: "1px solid #2a3450",
        }}
      >
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "12px" }}>
          <div>
            <div style={{ fontSize: "22px", fontWeight: "bold", marginBottom: "6px" }}>
              🔍 DEX Parser v3.9.8
            </div>
            <div style={{ color: "#7c869c", fontSize: "14px", marginBottom: "6px" }}>
              Мониторинг крупных сделок ·{" "}
              {status?.running ? (status?.paused ? "🟡 Пауза (резерв живёт)" : `🟢 Работает ${uptimeStr}`) : "🔴 Остановлен"}
            </div>
            <div style={{ color: "#9ece6a", fontSize: "12px", marginBottom: "4px" }}>
              ⚡ Solo ≥${(status?.alertSettings?.soloAmountUsd || 30000).toLocaleString()} мгновенно ·{" "}
              📊 Серии {status?.alertSettings?.seriesMinTx || 3}×≥$
              {(status?.alertSettings?.seriesTxMinUsd || 1000).toLocaleString()} /{" "}
              {status?.alertSettings?.seriesWindowSec || 180}с · Cooldown{" "}
              {status?.alertSettings?.seriesCooldownMin || 10}мин
            </div>
            {status?.freshness && (
              <div style={{ color: "#7c869c", fontSize: "11px" }}>
                🕐 Freshness gate: {status.freshness.maxAlertAgeSec}с · Stale dropped:{" "}
                {status.freshness.droppedStaleTotal} · NoBlockTs: {status.freshness.droppedNoBlockTs}
              </div>
            )}
            {status?.contractExecFilter && (
              <div style={{ color: "#e0af68", fontSize: "11px", marginTop: "4px" }}>
                🔍 ContractExec [{status.contractExecFilter.mode}]: cut={status.contractExecFilter.contractExec} · safe={status.contractExecFilter.safeAllowed} · transient={status.contractExecFilter.contractTransient ?? 0} · unknown={status.contractExecFilter.contractExecUnknown}
              </div>
            )}
          </div>
          <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
            {status?.running ? (
              <>
              <button
                onClick={handlePauseToggle}
                title={status?.paused ? "Анализ возобновится ≤5с, без разогрева" : "Анализ замораживается, подписки и reserve живут"}
                style={{
                  background: "#e0af68",
                  color: "#000",
                  border: "none",
                  borderRadius: "8px",
                  padding: "10px 20px",
                  cursor: "pointer",
                  fontWeight: "bold",
                  fontSize: "14px",
                }}
              >
                {status?.paused ? "▶ Продолжить" : "⏸ Пауза"}
              </button>
              <button
                onClick={handleStop}
                title="Полный стоп рвёт WS-подписки — после старта резерв требует разогрева. Для ночного режима используйте Паузу"
                style={{
                  background: "#f7768e",
                  color: "#000",
                  border: "none",
                  borderRadius: "8px",
                  padding: "10px 20px",
                  cursor: "pointer",
                  fontWeight: "bold",
                  fontSize: "14px",
                  opacity: 0.85,
                }}
              >
                ⏹ Стоп (полный)
              </button>
              {status?.paused && (
                <div style={{ color: "#e0af68", fontSize: "11px", width: "100%" }}>
                  ⚠ Пауза: кадры доставляются, алерты заморожены (pausedDropped растёт) — Продолжить вернёт всё ≤5с
                </div>
              )}
              </>
            ) : (
              <button
                onClick={handleStart}
                style={{
                  background: "#9ece6a",
                  color: "#000",
                  border: "none",
                  borderRadius: "8px",
                  padding: "10px 20px",
                  cursor: "pointer",
                  fontWeight: "bold",
                  fontSize: "14px",
                }}
              >
                ▶ Старт
              </button>
            )}
            <button
              onClick={() => setShowSettings(!showSettings)}
              style={{
                background: "#1e2635",
                color: "#7aa2f7",
                border: "1px solid #2a3450",
                borderRadius: "8px",
                padding: "10px 20px",
                cursor: "pointer",
                fontSize: "14px",
              }}
            >
              ⚙️ Настройки
            </button>
          </div>
        </div>
      </div>

      {/* Settings Panel */}
      {showSettings && (
        <div
          style={{
            background: "#1a1f2e",
            borderRadius: "16px",
            padding: "24px",
            marginBottom: "20px",
            border: "1px solid #2a3450",
          }}
        >
          <div style={{ fontSize: "18px", fontWeight: "bold", marginBottom: "16px" }}>
            ⚙️ Настройки
          </div>
          <div
            style={{
              display: "grid",
              gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
              gap: "12px",
              marginBottom: "16px",
            }}
          >
            <div>
              <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>
                Telegram Token
              </div>
              <input
                value={form.token}
                onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))}
                style={inputStyle}
                placeholder="123456:ABC..."
              />
            </div>
            <div>
              <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>
                Chat ID
              </div>
              <input
                value={form.chatId}
                onChange={(e) => setForm((f) => ({ ...f, chatId: e.target.value }))}
                style={inputStyle}
                placeholder="782821044"
              />
            </div>
            <div>
              <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>
                Polling (сек)
              </div>
              <input
                type="number"
                value={form.poll}
                onChange={(e) => setForm((f) => ({ ...f, poll: Number(e.target.value) }))}
                style={inputStyle}
                min={10}
              />
            </div>
            <div>
              <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>
                Пулов/сеть
              </div>
              <input
                type="number"
                value={form.maxPools}
                onChange={(e) => setForm((f) => ({ ...f, maxPools: Number(e.target.value) }))}
                style={inputStyle}
                min={1}
                max={50}
              />
            </div>
          </div>

          <div
            style={{
              background: "#0e1219",
              borderRadius: "12px",
              padding: "16px",
              marginBottom: "16px",
            }}
          >
            <div style={{ fontSize: "14px", fontWeight: "bold", marginBottom: "12px", color: "#e0af68" }}>
              ⚡ Двухуровневые алерты
            </div>
            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "10px" }}>
              {(
                [
                  { label: "Solo порог $", key: "soloAmountUsd" as const, min: 1000 },
                  { label: "Серия мин $", key: "seriesTxMinUsd" as const, min: 100 },
                  { label: "Серия мин TXs", key: "seriesMinTx" as const, min: 2 },
                  { label: "Серия окно (сек)", key: "seriesWindowSec" as const, min: 30 },
                  { label: "Cooldown (мин)", key: "seriesCooldownMin" as const, min: 1 },
                ] as const
              ).map(({ label, key, min }) => (
                <div key={key}>
                  <div style={{ fontSize: "11px", color: "#7c869c", marginBottom: "4px" }}>
                    {label}
                  </div>
                  <input
                    type="number"
                    value={form[key]}
                    onChange={(e) =>
                      setForm((f) => ({ ...f, [key]: Number(e.target.value) }))
                    }
                    style={{ ...inputStyle, background: "#141821" }}
                    min={min}
                  />
                </div>
              ))}
            </div>
          </div>

          <div style={{ display: "flex", flexWrap: "wrap", gap: "16px", marginBottom: "16px" }}>
            {(
              [
                { key: "cex" as const, label: "CEX-листинги" },
                { key: "top10" as const, label: "Исключать топ-10" },
                { key: "stable" as const, label: "Исключать стейблы" },
                { key: "contracts" as const, label: "Исключать контракты" },
                { key: "sse" as const, label: "SSE-стримы" },
              ] as const
            ).map(({ key, label }) => (
              <label key={key} style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "13px" }}>
                <input
                  type="checkbox"
                  checked={form[key]}
                  onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.checked }))}
                />
                {label}
              </label>
            ))}
          </div>

          <div style={{ marginBottom: "16px" }}>
            <div style={{ fontSize: "13px", color: "#7c869c", marginBottom: "8px" }}>Сети</div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: "12px" }}>
              {(["ethereum", "bsc", "base", "arbitrum", "polygon", "solana"] as const).map(
                (net) => (
                  <label key={net} style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "13px" }}>
                    <input
                      type="checkbox"
                      checked={form.networks[net]}
                      onChange={(e) =>
                        setForm((f) => ({
                          ...f,
                          networks: { ...f.networks, [net]: e.target.checked },
                        }))
                      }
                    />
                    {net}
                  </label>
                )
              )}
            </div>
          </div>

          <div
            style={{
              background: "#0e1219",
              borderRadius: "12px",
              padding: "16px",
              marginBottom: "16px",
            }}
          >
            <div style={{ fontSize: "14px", fontWeight: "bold", marginBottom: "12px", color: "#f7768e" }}>
              ⛔ Blacklist
            </div>
            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px", marginBottom: "12px" }}>
              <thead>
                <tr style={{ color: "#7c869c", textAlign: "left" }}>
                  <th style={{ padding: "4px 6px" }}>Тип</th>
                  <th style={{ padding: "4px 6px" }}>Сеть</th>
                  <th style={{ padding: "4px 6px" }}>Значение</th>
                  <th style={{ padding: "4px 6px" }}>Комментарий</th>
                  <th style={{ padding: "4px 6px" }}>Срабатываний</th>
                  <th style={{ padding: "4px 6px" }}></th>
                </tr>
              </thead>
              <tbody>
                {blacklist.map((r) => (
                  <tr key={r.id} style={{ borderTop: "1px solid #2a3450" }}>
                    <td style={{ padding: "6px" }}>{r.type}</td>
                    <td style={{ padding: "6px" }}>{r.chain}</td>
                    <td style={{ padding: "6px", fontFamily: "monospace", fontSize: "11px", wordBreak: "break-all" }}>{r.value}</td>
                    <td style={{ padding: "6px" }}>{r.note || ""}</td>
                    <td style={{ padding: "6px" }}>{r.hits}</td>
                    <td style={{ padding: "6px", whiteSpace: "nowrap" }}>
                      <label style={{ display: "inline-flex", alignItems: "center", gap: "4px", cursor: "pointer", marginRight: "8px" }}>
                        <input type="checkbox" checked={r.enabled} onChange={(e) => handleToggleBlacklist(r.id, e.target.checked)} />
                        {r.enabled ? "on" : "off"}
                      </label>
                      <button onClick={() => handleDeleteBlacklist(r.id)} style={{ background: "none", border: "none", color: "#f7768e", cursor: "pointer", fontSize: "12px" }}>
                        ✕
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
            <div style={{ display: "flex", gap: "8px", flexWrap: "wrap", alignItems: "center" }}>
              <select value={blForm.type} onChange={(e) => setBlForm((f) => ({ ...f, type: e.target.value }))} style={inputStyle}>
                <option value="address">address</option>
                <option value="symbol-regex">symbol-regex</option>
              </select>
              <input value={blForm.chain} onChange={(e) => setBlForm((f) => ({ ...f, chain: e.target.value }))} style={{ ...inputStyle, width: 120 }} placeholder="все сети (all)" title="Пусто/all — все сети; адресные правила глобальны" />
              <input value={blForm.value} onChange={(e) => setBlForm((f) => ({ ...f, value: e.target.value }))} style={{ ...inputStyle, flex: 1 }} placeholder="0x... или /regex/" />
              <input value={blForm.note} onChange={(e) => setBlForm((f) => ({ ...f, note: e.target.value }))} style={{ ...inputStyle, flex: 1 }} placeholder="комментарий" />
              <button onClick={handleAddBlacklist} style={{ background: "#f7768e", color: "#000", border: "none", borderRadius: "8px", padding: "8px 16px", cursor: "pointer", fontWeight: "bold" }}>
                + Добавить
              </button>
            </div>
          </div>

          <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
            <button
              onClick={handleSaveSettings}
              style={{
                background: "#9ece6a",
                color: "#000",
                border: "none",
                borderRadius: "8px",
                padding: "10px 20px",
                cursor: "pointer",
                fontWeight: "bold",
                fontSize: "14px",
              }}
            >
              💾 Сохранить
            </button>
            <button
              onClick={handleTestTelegram}
              style={{
                background: "#7aa2f7",
                color: "#000",
                border: "none",
                borderRadius: "8px",
                padding: "10px 20px",
                cursor: "pointer",
                fontSize: "14px",
              }}
            >
              📨 Тест Telegram
            </button>
            {tgResult && (
              <span style={{ padding: "10px", fontSize: "13px", color: tgResult.startsWith("✅") ? "#9ece6a" : "#f7768e" }}>
                {tgResult}
              </span>
            )}
          </div>
        </div>
      )}

      {/* Chain Status Grid */}
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
          gap: "12px",
          marginBottom: "20px",
        }}
      >
        {status &&
          Object.entries(status.chains).map(([id, chain]) => (
            <div
              key={id}
              style={{
                background: "#1a1f2e",
                borderRadius: "12px",
                padding: "14px",
                border: `1px solid ${chain.sseConnected ? "#9ece6a30" : "#2a3450"}`,
              }}
            >
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "8px" }}>
                <div style={{ fontWeight: "bold", fontSize: "14px" }}>{chain.label}</div>
                <div
                  style={{
                    fontSize: "10px",
                    background: chain.sseConnected ? "#9ece6a20" : "#2a3450",
                    color: chain.sseConnected ? "#9ece6a" : "#7c869c",
                    padding: "2px 6px",
                    borderRadius: "4px",
                  }}
                >
                  {chain.sseConnected ? "SSE" : chain.source}
                </div>
              </div>
              <div style={{ fontSize: "11px", color: "#7c869c", lineHeight: "1.8" }}>
                <div>Seen: {chain.seenEvents}</div>
                <div>Notified: {chain.notified}</div>
                <div>
                  Pools: {chain.watchPools}
                  {chain.watchStablePools > 0 && (
                    <span style={{ color: "#e0af68" }}> ({chain.watchStablePools}🔒)</span>
                  )}
                </div>
                <div>Ticks/min: {chain.pollTicks1m}</div>
                {chain.lastError && (
                  <div style={{ color: "#f7768e", fontSize: "10px" }}>
                    ⚠️ {chain.lastError.slice(0, 30)}
                  </div>
                )}
                <div style={{ color: chain.initStatus === "ready" ? "#9ece6a" : "#e0af68" }}>
                  {chain.initStatus}
                </div>
              </div>
            </div>
          ))}
      </div>

      {/* Alert Stats Row */}
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
          gap: "12px",
          marginBottom: "20px",
        }}
      >
        <div style={{ background: "#1a1f2e", borderRadius: "12px", padding: "16px", border: "1px solid #2a3450" }}>
          <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>⚡ Solo алерты</div>
          <div style={{ fontSize: "28px", fontWeight: "bold", color: "#9ece6a" }}>
            {status?.solo?.instant || 0}
          </div>
          <div style={{ fontSize: "11px", color: "#7c869c" }}>
            ≥${(status?.alertSettings?.soloAmountUsd || 30000).toLocaleString()} мгновенно
          </div>
        </div>
        <div style={{ background: "#1a1f2e", borderRadius: "12px", padding: "16px", border: "1px solid #2a3450" }}>
          <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>📊 Серии</div>
          <div style={{ fontSize: "28px", fontWeight: "bold", color: "#7aa2f7" }}>
            {status?.series?.fired || 0}{" "}
            <span style={{ fontSize: "14px" }}>выстрелено</span>
          </div>
          <div style={{ fontSize: "11px", color: "#7c869c" }}>
            {status?.series?.pending || 0} в очереди · {status?.series?.suppressedByCooldown || 0}{" "}
            cooldown
          </div>
        </div>
        <div style={{ background: "#1a1f2e", borderRadius: "12px", padding: "16px", border: "1px solid #2a3450" }}>
          <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>📬 Telegram</div>
          <div style={{ fontSize: "20px", fontWeight: "bold" }}>
            {status?.telegram.configured
              ? status?.telegram.ok
                ? "✅ OK"
                : "⚠️ Ошибка"
              : "❌ Не настроен"}
          </div>
          <div style={{ fontSize: "11px", color: "#7c869c" }}>
            Отправлено: {status?.telegram.sent || 0}
            {status?.telegram.lastError && (
              <span style={{ color: "#f7768e" }}>
                {" "}
                · {status.telegram.lastError.slice(0, 30)}
              </span>
            )}
          </div>
        </div>
        <div style={{ background: "#1a1f2e", borderRadius: "12px", padding: "16px", border: "1px solid #2a3450" }}>
          <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>📊 API Budget</div>
          <div style={{ fontSize: "28px", fontWeight: "bold", color: "#e0af68" }}>
            {status?.restBudget.geckoPerMinUsed || 0}
            <span style={{ fontSize: "14px" }}>/мин</span>
          </div>
          <div style={{ fontSize: "11px", color: "#7c869c" }}>
            Gecko: {status?.restBudget.geckoPerMinUsed || 0}/25 · CEX:{" "}
            {status?.cexFilter.cacheSize || 0} токенов
          </div>
        </div>
      </div>

      {/* Notifications */}
      <div
        style={{
          background: "#1a1f2e",
          borderRadius: "16px",
          padding: "20px",
          marginBottom: "20px",
          border: "1px solid #2a3450",
        }}
      >
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
          <div style={{ fontSize: "16px", fontWeight: "bold" }}>
            📋 Уведомления ({notifs.length}{notifs.length > 50 ? ` · показаны первые 50` : ''})
          </div>
          {notifs.length > 0 && (
            <button
              onClick={handleClearNotifs}
              style={{
                background: "#1e2635",
                color: "#f7768e",
                border: "1px solid #f7768e40",
                borderRadius: "8px",
                padding: "6px 14px",
                cursor: "pointer",
                fontSize: "12px",
              }}
            >
              🗑 Очистить
            </button>
          )}
        </div>
        {notifs.length === 0 ? (
          <div style={{ color: "#7c869c", fontSize: "14px", textAlign: "center", padding: "20px" }}>
            Пока нет уведомлений...
          </div>
        ) : (
          <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
            {notifs.slice(0, 50).map((n, i) => ( // v9.32ao AO4: рендерим ≤50 свежих — багровая лента не растёт бесконечно)
              <div
                key={i}
                style={{
                  background: "#0e1219",
                  borderRadius: "10px",
                  padding: "12px 14px",
                  border: `1px solid ${n.side === "buy" ? "#9ece6a30" : "#f7768e30"}`,
                }}
              >
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "6px" }}>
                  <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
                    {n.kind === "series" ? (
                      <span style={{ color: "#7aa2f7", fontWeight: "bold", fontSize: "13px" }}>
                        ⚡ {n.side === "buy" ? "🟢 ПОКУПКА" : "🔴 ПРОДАЖА"} серия
                      </span>
                    ) : (
                      <span style={{ color: n.side === "buy" ? "#9ece6a" : "#f7768e", fontWeight: "bold", fontSize: "13px" }}>
                        {n.side === "buy" ? "🟢 ПОКУПКА" : "🔴 ПРОДАЖА"}
                      </span>
                    )}
                    <span style={{ fontWeight: "bold", color: "#d8dee9" }}>
                      {fmtUsd(n.seriesTotalUsd || n.usd)}
                    </span>
                    <span style={{ background: "#2a3450", color: "#7aa2f7", padding: "1px 6px", borderRadius: "4px", fontSize: "10px" }}>
                      #{n.chain}
                    </span>
                    <span style={{ color: "#7c869c", fontSize: "11px" }}>
                      {n.kind === "series" ? `серия×${n.seriesTxCount}` : n.source}
                    </span>
                  </div>
                  <div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
                    {n.tgOk ? (
                      <span style={{ color: "#9ece6a", fontSize: "11px" }}>✓ TG</span>
                    ) : n.tgError ? (
                      <span style={{ color: "#f7768e", fontSize: "11px" }}>✗ TG</span>
                    ) : null}
                    <span style={{ color: "#7c869c", fontSize: "11px" }}>
                      {new Date(n.ts).toLocaleTimeString()}
                    </span>
                  </div>
                </div>
                <div style={{ fontSize: "12px", color: "#7c869c", marginBottom: "4px" }}>
                  <span style={{ color: "#d8dee9" }}>{n.symbol}</span>
                  {" "}· {shortAddr(n.token)}
                  {n.kind === "series" && n.seriesTxCount && (
                    <span style={{ color: "#7aa2f7", marginLeft: "8px" }}>
                      📊 {n.seriesTxCount} TXs / 3м, итого {fmtUsd(n.seriesTotalUsd || 0)}
                    </span>
                  )}
                </div>
                <div style={{ fontSize: "11px", color: "#7c869c" }}>
                  Trader: {shortAddr(n.trader)}
                  {n.via && <span style={{ color: "#e0af68" }}> (via {n.via})</span>}
                  {n.impact !== null && n.impact !== undefined && n.impact > 0 && (
                    <span style={{ marginLeft: "8px" }}>Impact: {n.impact.toFixed(2)}%</span>
                  )}
                  {n.liquidity > 0 && (
                    <span style={{ marginLeft: "8px" }}>Liq: {fmtUsd(n.liquidity)}</span>
                  )}
                  {n.blockTs && (
                    <span style={{ marginLeft: "8px", color: "#7c869c" }}>
                      blockTs: {new Date(n.blockTs * 1000).toLocaleTimeString()}
                    </span>
                  )}
                </div>
                {n.enrichmentErrors && n.enrichmentErrors.length > 0 && (
                  <div style={{ fontSize: "11px", color: "#e0af68", marginTop: "4px" }}>
                    ⚠️ {n.enrichmentErrors[0]}
                  </div>
                )}
                <div style={{ display: "flex", gap: "6px", marginTop: "6px", flexWrap: "wrap" }}>
                  {n.links.gmgn && (
                    <a href={n.links.gmgn} target="_blank" rel="noopener noreferrer"
                      style={{ fontSize: "11px", color: "#9ece6a", background: "#9ece6a15", padding: "2px 8px", borderRadius: "4px", textDecoration: "none" }}>
                      GMGN
                    </a>
                  )}
                  {n.links.dex && (
                    <a href={n.links.dex} target="_blank" rel="noopener noreferrer"
                      style={{ fontSize: "11px", color: "#7aa2f7", background: "#7aa2f715", padding: "2px 8px", borderRadius: "4px", textDecoration: "none" }}>
                      DexScreener
                    </a>
                  )}
                  {n.links.arkham && (
                    <a href={n.links.arkham} target="_blank" rel="noopener noreferrer"
                      style={{ fontSize: "11px", color: "#e0af68", background: "#e0af6815", padding: "2px 8px", borderRadius: "4px", textDecoration: "none" }}>
                      Arkham
                    </a>
                  )}
                  {n.links.tx && (
                    <a href={n.links.tx} target="_blank" rel="noopener noreferrer"
                      style={{ fontSize: "11px", color: "#7c869c", background: "#2a3450", padding: "2px 8px", borderRadius: "4px", textDecoration: "none" }}>
                      TX
                    </a>
                  )}
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Errors */}
      {status?.errors && (status.errorsTotal ?? status.errors.length) > 0 && (
        <div style={{ background: "#1a1f2e", borderRadius: "12px", padding: "16px", marginBottom: "20px", border: "1px solid #f7768e40" }}>
          <div style={{ fontSize: "14px", fontWeight: "bold", marginBottom: "8px", color: "#f7768e" }}>
            ⚠️ Ошибки ({status.errorsTotal ?? status.errors.length})
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
            {status.errors.slice(-5).map((e, i) => (
              <div key={i} style={{ fontSize: "11px", color: "#7c869c" }}>
                {new Date(e.t).toLocaleTimeString()}: {e.msg}
              </div>
            ))}
          </div>
        </div>
      )}

      {/* Footer */}
      <div style={{ textAlign: "center", color: "#7c869c", fontSize: "12px", paddingBottom: "20px" }}>
        DEX Parser v3.9.8+ · blockTs из источника · Freshness gate 180с · Inventory contractExec filter ·{" "}
        <a href="/source" style={{ color: "#7aa2f7" }}>📦 Исходники</a>
      </div>
    </div>
  );
}
src/app/layout.tsx
Root layout
12 строк · 0KB
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ru">
      <body>{children}</body>
    </html>
  );
}
src/app/globals.css
Tailwind CSS
2 строк · 0KB
@import "tailwindcss";
src/app/source/page.tsx
Эта страница (исходники)
202 строк · 13KB
import { readFileSync } from "fs";
import path from "path";
import Link from "next/link";

export const dynamic = "force-dynamic";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://3000-ijep6wg6r625g8dh98oog.e2b.app";

function escapeHtml(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function getFileContent(filePath: string): string {
  try {
    return readFileSync(filePath, "utf8");
  } catch {
    return "// File not found";
  }
}

export default function SourcePage() {
  const root = process.cwd();

  const files = [
    { name: "src/lib/parser-engine.ts", path: path.join(root, "src/lib/parser-engine.ts"), desc: "🔥 ЯДРО v3.9.8+: Двухуровневые алерты (solo≥$30K + серии 3×$1K+/3мин), blockTs из источника, freshness gate, inventory contractExec filter (ТЗ v2)" },
    { name: "src/app/page.tsx", path: path.join(root, "src/app/page.tsx"), desc: "Dashboard UI (React)" },
    { name: "src/app/layout.tsx", path: path.join(root, "src/app/layout.tsx"), desc: "Root layout" },
    { name: "src/app/globals.css", path: path.join(root, "src/app/globals.css"), desc: "Tailwind CSS" },
    { name: "src/app/source/page.tsx", path: path.join(root, "src/app/source/page.tsx"), desc: "Эта страница (исходники)" },
    { name: "src/app/api/health/route.ts", path: path.join(root, "src/app/api/health/route.ts"), desc: "GET /api/health" },
    { name: "src/app/api/status/route.ts", path: path.join(root, "src/app/api/status/route.ts"), desc: "GET /api/status — публичная сводка" },
    { name: "src/app/api/settings/route.ts", path: path.join(root, "src/app/api/settings/route.ts"), desc: "GET/POST /api/settings" },
    { name: "src/app/api/parser/start/route.ts", path: path.join(root, "src/app/api/parser/start/route.ts"), desc: "POST /api/parser/start" },
    { name: "src/app/api/parser/stop/route.ts", path: path.join(root, "src/app/api/parser/stop/route.ts"), desc: "POST /api/parser/stop" },
    { name: "src/app/api/test-telegram/route.ts", path: path.join(root, "src/app/api/test-telegram/route.ts"), desc: "POST /api/test-telegram" },
    { name: "src/app/api/notifications/route.ts", path: path.join(root, "src/app/api/notifications/route.ts"), desc: "GET /api/notifications" },
    { name: "src/app/api/notifications/clear/route.ts", path: path.join(root, "src/app/api/notifications/clear/route.ts"), desc: "POST /api/notifications/clear" },
    { name: "src/app/api/blacklist/route.ts", path: path.join(root, "src/app/api/blacklist/route.ts"), desc: "POST /api/blacklist" },
    { name: "src/app/api/debug/cex-selftest/route.ts", path: path.join(root, "src/app/api/debug/cex-selftest/route.ts"), desc: "GET /api/debug/cex-selftest" },
    { name: "src/app/api/debug/sse-sample/route.ts", path: path.join(root, "src/app/api/debug/sse-sample/route.ts"), desc: "GET /api/debug/sse-sample" },
    { name: "src/app/api/debug/verify-freshness/route.ts", path: path.join(root, "src/app/api/debug/verify-freshness/route.ts"), desc: "GET /api/debug/verify-freshness — ТЗ: сверка blockTs" },
    { name: "src/app/api/debug/trade-sample/route.ts", path: path.join(root, "src/app/api/debug/trade-sample/route.ts"), desc: "GET /api/debug/trade-sample?chain=X — raw + mapped trade" },
    { name: "src/app/api/debug/enrich-test/route.ts", path: path.join(root, "src/app/api/debug/enrich-test/route.ts"), desc: "GET /api/debug/enrich-test?chain=X&token=Y — test liquidity/position enrichment" },
    { name: "src/app/api/debug/explain-tx/route.ts", path: path.join(root, "src/app/api/debug/explain-tx/route.ts"), desc: "GET /api/debug/explain-tx?chain=X&tx=Y — inventory contractExec filter (ТЗ v2)" },
    { name: "src/app/api/logs/route.ts", path: path.join(root, "src/app/api/logs/route.ts"), desc: "GET /api/logs — авторизация обязательна" },
    { name: "src/db/index.ts", path: path.join(root, "src/db/index.ts"), desc: "Drizzle DB connection (lazy, in-memory mode без DATABASE_URL)" },
    { name: "src/db/schema.ts", path: path.join(root, "src/db/schema.ts"), desc: "DB schema (settings persistence)" },
  ];

  return (
    <div style={{ background: "#0b0e14", color: "#d8dee9", fontFamily: "system-ui, sans-serif", minHeight: "100vh", padding: "20px", maxWidth: "1400px", margin: "0 auto" }}>

      {/* SITE URL — самый верх */}
      <div style={{ background: "linear-gradient(135deg, #1a1f2e 0%, #11151f 100%)", border: "3px solid #7aa2f7", borderRadius: "16px", padding: "20px", marginBottom: "24px", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "12px" }}>
        <div>
          <div style={{ fontSize: "12px", color: "#7c869c", textTransform: "uppercase", letterSpacing: "0.1em", fontWeight: "bold", marginBottom: "4px" }}>🌐 Живой сайт / Live Site URL</div>
          <a href={SITE_URL} style={{ color: "#7aa2f7", fontWeight: "bold", fontSize: "18px", textDecoration: "none" }} target="_blank" rel="noopener noreferrer">
            {SITE_URL}
          </a>
        </div>
        <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
          <Link href="/" style={{ background: "#7aa2f7", color: "#000", padding: "8px 16px", borderRadius: "8px", textDecoration: "none", fontSize: "13px", fontWeight: "bold" }}>← Dashboard</Link>
          <a href="/api/status" style={{ background: "#1e2635", color: "#7aa2f7", padding: "8px 16px", borderRadius: "8px", textDecoration: "none", fontSize: "13px" }}>/api/status · публичная сводка</a>
          <a href="/api/debug/cex-selftest" style={{ background: "#1e2635", color: "#7aa2f7", padding: "8px 16px", borderRadius: "8px", textDecoration: "none", fontSize: "13px" }}>CEX Self-Test</a>
          <a href="/api/debug/verify-freshness" style={{ background: "#1e2635", color: "#7aa2f7", padding: "8px 16px", borderRadius: "8px", textDecoration: "none", fontSize: "13px" }}>Verify Freshness</a>
          <a href="/api/debug/explain-tx?chain=bsc&tx=0xb67afff83876373895f5c1dd995f79c6b987f85da42b5b657eeede84939e2b63" style={{ background: "#f7768e30", color: "#f7768e", padding: "8px 16px", borderRadius: "8px", textDecoration: "none", fontSize: "13px" }}>explain-tx (BEAT)</a>
        </div>
      </div>

      <h1 style={{ fontSize: "28px", marginBottom: "8px" }}>📦 DEX Parser v3.9.8+ — Полный исходный код</h1>
      <p style={{ color: "#7c869c", fontSize: "14px", marginBottom: "24px" }}>
        Все {files.length} файлов проекта. Скопируй их точь-в-точь для воспроизведения.
      </p>

      {/* Инструкция для AI */}
      <div style={{ background: "linear-gradient(135deg, #1a1f2e 0%, #11151f 100%)", border: "3px solid #7aa2f7", borderRadius: "16px", padding: "24px", marginBottom: "32px" }}>
        <h2 style={{ fontSize: "22px", marginBottom: "16px", color: "#7aa2f7" }}>
          🤖 ИНСТРУКЦИЯ ДЛЯ AI-АССИСТЕНТОВ (GPT, Claude, Gemini, etc.)
        </h2>

        <div style={{ background: "#f7768e20", border: "2px solid #f7768e", borderRadius: "12px", padding: "16px", marginBottom: "20px" }}>
          <p style={{ fontSize: "15px", color: "#f7768e", margin: 0, fontWeight: "bold" }}>
            ⚠️ ВАЖНО: Скопируй ВСЕ {files.length} файлов ниже ТОЧЬ-В-ТОЧЬ. Особенно <code style={{ background: "#0e1219", padding: "2px 6px", borderRadius: "4px" }}>src/lib/parser-engine.ts</code> — это ядро парсера (~3800 строк).
          </p>
        </div>

        <h3 style={{ fontSize: "17px", marginBottom: "12px", color: "#9ece6a" }}>🎯 Что это за проект:</h3>
        <p style={{ fontSize: "14px", lineHeight: 1.8, marginBottom: "20px" }}>
          <b>DEX Parser v3.9.8+</b> — полностью рабочий мониторинг крупных сделок на DEX (Ethereum, BSC, Base, Arbitrum, Polygon, Solana)
          с уведомлениями в Telegram. Использует <b>GeckoTerminal/CoinGecko API</b> для discovery пулов и CEX-фильтрации,
          <b> PostgreSQL</b> для персистентности настроек (опционально — работает и без БД).
          <br /><br />
          <b style={{ color: "#9ece6a" }}>МОДЕЛЬ АЛЕРТОВ (финальная версия v3.9.8+):</b>
          <br />
          • <b>⚡ Solo алерт:</b> сделка ≥$30 000 → мгновенная одиночная карточка
          <br />
          • <b>📊 Серийный алерт:</b> 3+ сделок ($1 000–$29 999) одного трейдера × токен × сторона за 180 сек
          <br />
          • Cooldown (chain,trader,token,side): 10 мин после серии
          <br /><br />
          <b style={{ color: "#f7768e" }}>✅ ТЗ v2: INVENTORY contractExec filter:</b>
          <br />
          • EOA → PASS (всегда)
          <br />
          • Gnosis Safe (getOwners) → PASS (safeAllowed+1)
          <br />
          • Контракт с балансом субъект-токена @block-1 ≥ $100 → CUT (piggy-bank) → wouldSkip=contractExec
          <br />
          • Контракт с балансом ≈0 → PASS (транзитный DEX-роутер) → contractTransient+1
          <br />
          • Нет архивных данных → PASS (fail-open) → contractExecUnknown+1
          <br /><br />
          <b style={{ color: "#7aa2f7" }}>✅ blockTs из источника (block_timestamp / block_executed_at), никогда now().</b>
          <br />
          <b style={{ color: "#7aa2f7" }}>✅ Freshness gate: события старше 180с скипаются (stale).</b>
          <br />
          <b style={{ color: "#7aa2f7" }}>✅ Lazy DB: работает без DATABASE_URL (in-memory режим).</b>
        </p>

        <h3 style={{ fontSize: "17px", marginBottom: "12px", color: "#9ece6a" }}>🚀 Быстрый старт:</h3>
        <pre style={{ background: "#0e1219", padding: "16px", borderRadius: "8px", fontSize: "12px", overflow: "auto", color: "#d8dee9", marginBottom: "20px" }}>{`# 1. Скопируй все файлы по путям ниже
# 2. Добавь в .env:
TELEGRAM_BOT_TOKEN=ваш_токен
TELEGRAM_CHAT_ID=ваш_chat_id
DATABASE_URL=postgresql://...  # опционально

# 3. Запусти:
npm install
npx drizzle-kit push  # опционально (если DATABASE_URL)
npm run build && npm start

# 4. Проверь:
curl http://localhost:3000/api/health     # {"ok":true}
curl http://localhost:3000/api/status    # публичная сводка (public-summary-v1)
# /api/status/details — полная диагностика только после авторизации; не вставляй токен в команду
curl -X POST http://localhost:3000/api/parser/start
curl -X POST http://localhost:3000/api/test-telegram

# 5. Диагностика contractExec (ТЗ v2):
curl "http://localhost:3000/api/debug/explain-tx?chain=bsc&tx=0xb67afff83876373895f5c1dd995f79c6b987f85da42b5b657eeede84939e2b63"
# → wouldSkip="contractExec", preBalanceUsd>100 (BEAT piggy-bank)

curl "http://localhost:3000/api/debug/explain-tx?chain=base&tx=0x62cfc83fcac1d2ac3cdca44cb15518a28dba7746365f71eb15cb08a080258def"
# → wouldSkip=null, preBalanceUsd≈0 (VIRTUAL transit contract)

curl "http://localhost:3000/api/debug/explain-tx?chain=base&tx=0x51a74c4e23188ffa9dcb0d91723f49ab5648b323fb6390b54b27ed606cdb65fd"
# → wouldSkip=null (cbBTC dust → transit)`}</pre>

        <h3 style={{ fontSize: "17px", marginBottom: "12px", color: "#9ece6a" }}>📋 Файлы для копирования ({files.length} штук):</h3>
        <div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
          {files.map(f => (
            <a key={f.name} href={`#file-${f.name.replace(/[/\.]/g, '-')}`}
              style={{ background: "#1e2635", color: "#7aa2f7", padding: "4px 10px", borderRadius: "6px", textDecoration: "none", fontSize: "11px" }}>
              {f.name}
            </a>
          ))}
        </div>
      </div>

      {/* File contents */}
      {files.map(f => {
        const content = getFileContent(f.path);
        const fileId = `file-${f.name.replace(/[/\.]/g, '-')}`;
        return (
          <div key={f.name} id={fileId} style={{ marginBottom: "32px" }}>
            <div style={{ background: "#1a1f2e", borderRadius: "12px 12px 0 0", padding: "14px 20px", border: "1px solid #2a3450", borderBottom: "none", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "8px" }}>
              <div>
                <div style={{ fontFamily: "monospace", fontWeight: "bold", color: "#7aa2f7", fontSize: "14px" }}>{f.name}</div>
                <div style={{ color: "#7c869c", fontSize: "12px", marginTop: "2px" }}>{f.desc}</div>
              </div>
              <div style={{ fontSize: "11px", color: "#7c869c" }}>
                {content.split('\n').length} строк · {Math.round(content.length / 1024)}KB
              </div>
            </div>
            <pre style={{
              background: "#0b0e14",
              border: "1px solid #2a3450",
              borderRadius: "0 0 12px 12px",
              padding: "16px 20px",
              overflow: "auto",
              fontSize: "11px",
              lineHeight: "1.6",
              color: "#c0caf5",
              margin: 0,
              maxHeight: "600px",
              whiteSpace: "pre-wrap",
              wordBreak: "break-all",
            }}>
              <code dangerouslySetInnerHTML={{ __html: escapeHtml(content) }} />
            </pre>
          </div>
        );
      })}

      <div style={{ textAlign: "center", color: "#7c869c", fontSize: "12px", paddingBottom: "40px" }}>
        DEX Parser v3.9.8+ · {files.length} файлов · Inventory contractExec filter (ТЗ v2) ·{" "}
        <Link href="/" style={{ color: "#7aa2f7" }}>← Dashboard</Link>
      </div>
    </div>
  );
}
src/app/api/health/route.ts
GET /api/health
17 строк · 0KB
export const dynamic = "force-dynamic";

export async function GET() {
  try {
    // Try DB check if available
    if (process.env.DATABASE_URL) {
      const { db } = await import("@/db");
      const { sql } = await import("drizzle-orm");
      await db.execute(sql`select 1`);
    }
    return Response.json({ ok: true, running: true });
  } catch {
    // If DB is unavailable, still return ok (in-memory mode)
    return Response.json({ ok: true, running: true, db: "unavailable" });
  }
}
src/app/api/status/route.ts
GET /api/status — публичная сводка
34 строк · 1KB
import { getStatus } from "@/lib/parser-engine";
import { publicStatus } from "@/lib/status/public-projection";
import { randomUUID } from "crypto";

export const dynamic = "force-dynamic";

// Boot ID — generated once per process lifetime
const BOOT_ID = randomUUID().slice(0, 8);

export async function GET() {
  const status = getStatus();
  return Response.json(publicStatus({
    ...status,
    deploy: {
      platform: process.env.RAILWAY_ENVIRONMENT
        ? "railway"
        : process.env.RENDER
        ? "render"
        : process.env.FLY_APP_NAME
        ? "fly"
        : "e2b-sandbox",
      bootId: BOOT_ID,
      region:
        process.env.RAILWAY_REGION ||
        process.env.RENDER_REGION ||
        process.env.FLY_REGION ||
        "local",
      nodeEnv: process.env.NODE_ENV,
      uptime: Math.floor(process.uptime()),
      commit: process.env.RAILWAY_GIT_COMMIT_SHA ?? null,
    },
  }));
}
src/app/api/settings/route.ts
GET/POST /api/settings
21 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { getSettings, updateSettings, getBlacklist } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET() {
  return Response.json({ settings: getSettings(), blacklist: getBlacklist() });
}

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  try {
    const body = await req.json();
    const appliedSettings = updateSettings(body);
    return Response.json({ ok: true, applied: appliedSettings });
  } catch {
    return Response.json({ ok: false, error: "Invalid body" }, { status: 400 });
  }
}
src/app/api/parser/start/route.ts
POST /api/parser/start
12 строк · 0KB
import { requireParserApiToken } from "@/lib/api-auth";
import { startParser } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  await startParser();
  return Response.json({ ok: true });
}
src/app/api/parser/stop/route.ts
POST /api/parser/stop
12 строк · 0KB
import { requireParserApiToken } from "@/lib/api-auth";
import { stopParser } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  stopParser();
  return Response.json({ ok: true });
}
src/app/api/test-telegram/route.ts
POST /api/test-telegram
12 строк · 0KB
import { requireParserApiToken } from "@/lib/api-auth";
import { testTelegram } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  const result = await testTelegram();
  return Response.json(result);
}
src/app/api/notifications/route.ts
GET /api/notifications
10 строк · 0KB
import { getNotifications, getNotificationsRetentionMeta } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

// v9.32ao-RC3 П4: retention-мета рядом с items — владелец проверяет TTL/cap curl-ом:
// count растёт в ране, но НЕ должен превышать maxStored; oldestTs не старше hours.
export async function GET() {
  return Response.json({ items: getNotifications(), retention: getNotificationsRetentionMeta() });
}
src/app/api/notifications/clear/route.ts
POST /api/notifications/clear
12 строк · 0KB
import { requireParserApiToken } from "@/lib/api-auth";
import { clearNotifications } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  clearNotifications();
  return Response.json({ ok: true });
}
src/app/api/blacklist/route.ts
POST /api/blacklist
84 строк · 4KB
import { requireParserApiToken } from "@/lib/api-auth";
import {
  getBlacklistRules,
  addBlacklistRule,
  removeBlacklistRule,
  toggleBlacklistRule,
} from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET() {
  return Response.json({ ok: true, rules: getBlacklistRules() });
}

export async function POST(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  try {
    const body = await req.json();
    if (body && typeof body === "object") {
      // Legacy address-only API kept for backward compatibility
      if (body.action === "add" && body.address) {
        const res = await addBlacklistRule("address", body.chain || "ethereum", body.address, body.note || "legacy");
        return Response.json(res.ok ? { ok: true, rules: getBlacklistRules() } : res, { status: res.ok ? 200 : 400 });
      }
      if (body.action === "remove" && body.address) {
        const rules = getBlacklistRules();
        const target = rules.find(r => r.type === "address" && r.value === body.address);
        if (target) {
          await removeBlacklistRule(target.id);
          return Response.json({ ok: true, rules: getBlacklistRules() });
        }
        return Response.json({ ok: false, error: "not found" }, { status: 404 });
      }
      // Rule-based API: { type, chain|'all', value, note }
      if (body.type && body.value) {
        const res = await addBlacklistRule(body.type, body.chain || body.scope || "all", body.value, body.note);
        // v9.32aq B1: ошибка БД — 5xx с текстом (раньше тихий ok при стёртой БД)
        let status = 200;
        if (!res.ok) status = res.error === "rule already exists" ? 409 : String(res.error || "").startsWith("db:") ? 503 : 400;
        return Response.json(res.ok ? { ok: true, rules: getBlacklistRules() } : { ok: false, error: res.error }, { status });
      }
    }
    return Response.json({ ok: false, error: "invalid body" }, { status: 400 });
  } catch {
    return Response.json({ ok: false }, { status: 400 });
  }
}

export async function PUT(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  try {
    const body = await req.json();
    if (body && typeof body === "object" && body.id !== undefined) {
      // R3: no explicit `enabled` → invert the current value; explicit → set (back-compat).
      const hasExplicit = body.enabled !== undefined;
      const res = await toggleBlacklistRule(String(body.id), hasExplicit ? !!body.enabled : undefined);
      return Response.json(res.ok ? { ok: true, rules: getBlacklistRules() } : { ok: false, error: res.error }, { status: res.ok ? 200 : 404 });
    }
    return Response.json({ ok: false, error: "invalid body" }, { status: 400 });
  } catch {
    return Response.json({ ok: false }, { status: 400 });
  }
}

export async function DELETE(req: Request) {
  const denied = req ? requireParserApiToken(req) : null;
  if (denied) return denied;
  try {
    // R8: accept id from BOTH query (?id=) and JSON body for consistency.
    const url = new URL(req.url);
    let id = url.searchParams.get("id");
    let body: Record<string, unknown> = {};
    try { body = await req.json(); } catch { /* no/invalid body */ }
    if (!id && body && typeof body === "object" && body.id !== undefined) id = String(body.id);
    if (!id) return Response.json({ ok: false, error: "missing id" }, { status: 400 });
    const res = await removeBlacklistRule(id);
    return Response.json(res.ok ? { ok: true, rules: getBlacklistRules() } : { ok: false, error: res.error }, { status: res.ok ? 200 : 404 });
  } catch {
    return Response.json({ ok: false }, { status: 400 });
  }
}
src/app/api/debug/cex-selftest/route.ts
GET /api/debug/cex-selftest
16 строк · 0KB
import { requireParserApiToken } from "@/lib/api-auth";
import { getCexSelfTest, testLinkGeneration } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const cexTest = getCexSelfTest();
  const linkTest = testLinkGeneration();
  return Response.json({
    cexFilter: cexTest,
    linkGeneration: linkTest,
  });
}
src/app/api/debug/sse-sample/route.ts
GET /api/debug/sse-sample
30 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { getSseSample, CHAINS } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const url = new URL(req.url);
  const chain = url.searchParams.get("chain") || "bsc";

  if (!CHAINS[chain]) {
    return Response.json(
      {
        error: `Unknown chain: ${chain}`,
        validChains: Object.keys(CHAINS),
      },
      { status: 400 }
    );
  }

  const sample = getSseSample(chain);

  if (!sample) {
    return Response.json({ error: "Chain not found" }, { status: 404 });
  }

  return Response.json(sample);
}
src/app/api/debug/verify-freshness/route.ts
GET /api/debug/verify-freshness — ТЗ: сверка blockTs
18 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { verifyFreshness } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const url = new URL(req.url);
  const n = parseInt(url.searchParams.get("n") || "10", 10);
  const results = await verifyFreshness(Math.min(n, 20));
  return Response.json({
    count: results.length,
    allPass: results.every((r) => r.pass),
    results,
  });
}
src/app/api/debug/trade-sample/route.ts
GET /api/debug/trade-sample?chain=X — raw + mapped trade
38 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { getTradeSample, CHAINS } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const url = new URL(req.url);
  const chain = url.searchParams.get("chain") || undefined;

  if (chain && !CHAINS[chain]) {
    return Response.json(
      {
        error: `Unknown chain: ${chain}`,
        validChains: Object.keys(CHAINS),
      },
      { status: 400 }
    );
  }

  const sample = getTradeSample(chain);

  if (!sample) {
    return Response.json(
      {
        error: "No trades seen yet. Start the parser and wait for data.",
        chain: chain || "any",
        rawTrade: null,
        mappedEvent: null,
      },
      { status: 200 }
    );
  }

  return Response.json(sample);
}
src/app/api/debug/enrich-test/route.ts
GET /api/debug/enrich-test?chain=X&token=Y — test liquidity/position enrichment
32 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { fetchEnrichTest, CHAINS } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const url = new URL(req.url);
  const chain = url.searchParams.get("chain") || "base";
  const token =
    url.searchParams.get("token") || "0x4200000000000000000000000000000000000006";
  const trader = url.searchParams.get("trader") || undefined;

  if (!CHAINS[chain]) {
    return Response.json(
      {
        error: `Unknown chain: ${chain}`,
        validChains: Object.keys(CHAINS),
      },
      { status: 400 }
    );
  }

  try {
    const result = await fetchEnrichTest(chain, token, trader);
    return Response.json(result);
  } catch (e) {
    return Response.json({ error: (e as Error).message }, { status: 500 });
  }
}
src/app/api/debug/explain-tx/route.ts
GET /api/debug/explain-tx?chain=X&tx=Y — inventory contractExec filter (ТЗ v2)
41 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { explainTx, CHAINS } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  const url = new URL(req.url);
  const chain = url.searchParams.get("chain") || "";
  const tx = url.searchParams.get("tx") || "";

  if (!chain || !CHAINS[chain]) {
    return Response.json(
      {
        error: `Unknown or missing chain. Use ?chain=<chain>&tx=<hash>`,
        validChains: Object.keys(CHAINS),
      },
      { status: 400 }
    );
  }

  if (!tx || !tx.startsWith("0x")) {
    return Response.json(
      { error: "Missing or invalid tx hash. Use ?tx=0x..." },
      { status: 400 }
    );
  }

  try {
    const result = await explainTx(chain, tx);
    return Response.json({
      chain,
      tx,
      ...result,
    });
  } catch (e) {
    return Response.json({ error: (e as Error).message }, { status: 500 });
  }
}
src/app/api/logs/route.ts
GET /api/logs — авторизация обязательна
20 строк · 1KB
import { requireParserApiToken } from "@/lib/api-auth";
import { getLogs } from "@/lib/parser-engine";

export const dynamic = "force-dynamic";

// A log entry can contain a configured RPC URL, including credentials in its path/query/host.
// Remove the entire URL-like token, not just selected query parameters or known hostnames.
function publicLogLine(line: string): string {
  return line.replace(/\b(?:https?|wss?):\/\/\S+/gi, "[redacted-url]");
}

export async function GET(req: Request) {
  const denied = requireParserApiToken(req);
  if (denied) return denied;
  return Response.json(
    { logs: getLogs().map(publicLogLine) },
    { headers: { "Cache-Control": "no-store", "Vary": "Cookie, Authorization" } },
  );
}
src/db/index.ts
Drizzle DB connection (lazy, in-memory mode без DATABASE_URL)
39 строк · 1KB
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

// Lazy initialization — never throw at module load time (fixes build without DATABASE_URL)
const globalForDb = globalThis as typeof globalThis & {
  __arenaNextJsPostgresqlPool?: Pool;
  __arenaNextJsDrizzle?: ReturnType<typeof drizzle>;
};

function getPool(): Pool {
  if (globalForDb.__arenaNextJsPostgresqlPool) {
    return globalForDb.__arenaNextJsPostgresqlPool;
  }
  const databaseUrl = process.env.DATABASE_URL;
  if (!databaseUrl) {
    throw new Error("DATABASE_URL is not configured");
  }
  const pool = new Pool({ connectionString: databaseUrl });
  if (process.env.NODE_ENV !== "production") {
    globalForDb.__arenaNextJsPostgresqlPool = pool;
  }
  return pool;
}

// Lazy proxy: only connects when actually used (inside runtime functions)
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
  get(_target, prop) {
    if (!globalForDb.__arenaNextJsDrizzle) {
      globalForDb.__arenaNextJsDrizzle = drizzle(getPool());
    }
    const instance = globalForDb.__arenaNextJsDrizzle;
    const value = instance[prop as keyof typeof instance];
    if (typeof value === "function") {
      return value.bind(instance);
    }
    return value;
  },
});
src/db/schema.ts
DB schema (settings persistence)
67 строк · 3KB
import { pgTable, text, timestamp, jsonb, primaryKey } from "drizzle-orm/pg-core";

// Settings persistence table for Bug 3 fix
export const appSettings = pgTable("app_settings", {
  key: text("key").primaryKey(),
  value: jsonb("value").notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// ТЗ v4 Block 3: Token Blacklist rules persistence table
export const blacklistRules = pgTable("blacklist_rules", {
  id: text("id").primaryKey(),
  type: text("type").notNull(),         // 'address' | 'symbol-regex'
  chain: text("chain").notNull(),       // chainId or 'all' (symbol-regex scope)
  value: text("value").notNull(),
  note: text("note"),
  enabled: text("enabled").default("true").notNull(), // 'true' | 'false'
  hits: text("hits").default("0").notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// v9.32f: watchlist snapshot for instant post-restart coverage
export const watchlistSnapshot = pgTable("watchlist_snapshot", {
  chain: text("chain").notNull(),
  pool: text("pool").notNull(),
  meta: jsonb("meta").notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
  pk: primaryKey({ columns: [t.chain, t.pool] }),
}));

// v9.32ab: hot solana mints that survive restart
export const hotMints = pgTable("hot_mints", {
  mint: text("mint").primaryKey(),
  symbol: text("symbol"),
  lastAlertAt: timestamp("last_alert_at").notNull(),
  usd: text("usd"),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

// ТЗ 2026-10-14 §3-M1: durable append-only журнал обязательств диапазонов (filter_epoch).
// Append-only: state 'open' не понижается, доказательство write-once (см. markProven в
// src/lib/evm/filter-epoch-ledger.ts). Заменяет перезаписываемый watchlist_snapshot как источник
// улик «какой набор фильтров был обязан быть прочитан и был ли он реально прочитан».
export const filterEpoch = pgTable("filter_epoch", {
  epochId: text("epoch_id").primaryKey(),
  chainId: text("chain_id").notNull(),
  fromBlock: text("from_block").notNull(),
  toBlock: text("to_block").notNull(),
  policyVersion: text("policy_version").notNull(),
  addrs: jsonb("addrs").notNull(),
  holders: jsonb("holders").notNull(),
  filtersDigest: text("filters_digest").notNull(),
  state: text("state").notNull(), // 'open' | 'proven'
  reason: text("reason").notNull(),
  membershipSources: jsonb("membership_sources").notNull(),
  firstSeenAt: text("first_seen_at").notNull(),
  lastSeenAt: text("last_seen_at").notNull(),
  boot: text("boot").notNull(),
  sha: text("sha").notNull(),
  proofDigest: text("proof_digest"),
  proofBoot: text("proof_boot"),
  proofSha: text("proof_sha"),
  provenAt: text("proven_at"),
});

DEX Parser v3.9.8+ · 23 файлов · Inventory contractExec filter (ТЗ v2) · ← Dashboard