{
  "schema_version": "2.0.0",
  "bot_id": "4.7",
  "bot_name": "OnChainWatcher",
  "slug": "onchainwatcher",
  "layer": "Intelligence",
  "layer_key": "intel",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only"
  ],
  "status": "beta",
  "readiness": "Limited live",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Intelligence",
    "bot_class": "Signal Service",
    "authority": "Read-only",
    "runs_before": "Strategy layer, LiquidityGuard, OrderFlowAnalyzer",
    "runs_after": "Polygon RPC subscription established; watched_wallets config loaded",
    "applies_to": "All Polymarket markets where watched wallets hold or have recently changed positions",
    "default_mode": "limited_live",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Intelligence pod"
  },
  "purpose": "OnChainWatcher monitors Polygon on-chain events for a configured list of high-signal wallet addresses, detecting CTFExchangeV2 order fills, position transfers, and pUSD balance changes that exceed min_position_change_usd. It emits an ObservationReport for each qualifying wallet event after a publish_delay_s hold (to prevent front-running its own signals). Output feeds liquidity-aware strategies and the LiquidityGuard risk bot with smart-money flow intelligence. OnChainWatcher is strictly read-only \u2014 it never submits or signs orders.",
  "why_it_matters": [
    {
      "failure": "High-signal wallet entry not detected",
      "consequence": "Strategy misses a leading indicator; enters after the smart-money move has already re-priced the market, capturing reduced or negative edge."
    },
    {
      "failure": "publish_delay_s misconfigured to zero",
      "consequence": "OnChainWatcher signals are published immediately, allowing the trading infrastructure itself to front-run other participants using its own on-chain data feed."
    },
    {
      "failure": "Watched wallet list stale after wallet rotation",
      "consequence": "Previously high-signal addresses are no longer the active trading wallets; signals become irrelevant and may mislead strategies."
    },
    {
      "failure": "RPC outage causes missed position changes during high-activity period",
      "consequence": "Smart-money entries during the blackout are not detected; strategies enter a market after the signal window has closed."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "CTFExchangeV2 OrderFilled events for watched wallet addresses",
      "source": "onchain (Polygon RPC event log subscription)",
      "required": true,
      "use": "Primary detection of watched wallet entries and exits in Polymarket markets."
    },
    {
      "input": "pUSD ERC-20 Transfer events for watched wallets",
      "source": "onchain (Polygon RPC)",
      "required": false,
      "use": "Detect pUSD balance changes indicating position funding or profit-taking not captured by OrderFilled."
    },
    {
      "input": "Market condition_id from Polymarket Data API for transaction hash mapping",
      "source": "data_api",
      "required": true,
      "use": "Resolve CTFExchangeV2 token IDs to Polymarket condition_ids in ObservationReport payloads."
    }
  ],
  "internal_inputs": [
    {
      "input": "watched_wallets list",
      "source": "config / operator-managed registry",
      "required": true,
      "use": "Filter all Polygon on-chain events to only the configured high-signal wallet addresses."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Continue monitoring on-chain but suppress ObservationReport emissions when KillSwitch is active."
    }
  ],
  "raw_params": [
    "watched_wallets \u00b7 list",
    "min_position_change_usd \u00b7 int",
    "publish_delay_s \u00b7 int"
  ],
  "parameters": [
    {
      "name": "min_position_change_usd",
      "default": 500,
      "warning": 200,
      "hard": 50,
      "controls": "Minimum pUSD value of a single wallet position change to qualify for an ObservationReport emission.",
      "why_default_matters": "$500 filters out dust transactions and small test fills, ensuring only meaningful position changes generate signals.",
      "threshold_logic": [
        {
          "condition": "change \u2265 500 pUSD",
          "action": "Normal \u2014 emit ObservationReport"
        },
        {
          "condition": "200\u2013500 pUSD",
          "action": "WARN \u2014 small position; emit with ONCHAINWATCHER_SMALL_POSITION warning"
        },
        {
          "condition": "< 50 pUSD",
          "action": "Reject \u2014 dust threshold; do not emit"
        }
      ],
      "dev_check": "if (p.min_position_change_usd < p.hard) return; // silently skip dust",
      "user_facing": "Only meaningful wallet movements above a minimum size generate intelligence signals."
    },
    {
      "name": "publish_delay_s",
      "default": 15,
      "warning": 5,
      "hard": 0,
      "controls": "Seconds to hold a detected wallet event before emitting ObservationReport. Prevents the infrastructure from acting on its own signal faster than public latency.",
      "why_default_matters": "15 s gives on-chain event latency time to propagate broadly, ensuring the signal is not used for front-running relative to public market participants.",
      "threshold_logic": [
        {
          "condition": "delay \u2265 15 s",
          "action": "Normal"
        },
        {
          "condition": "5\u201315 s",
          "action": "WARN \u2014 reduced front-run protection window"
        },
        {
          "condition": "< 5 s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL; insufficient delay"
        }
      ],
      "dev_check": "if (p.publish_delay_s < 5) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "Wallet signals are held briefly before being published to ensure fairness in market access."
    },
    {
      "name": "poll_interval_s",
      "default": 12,
      "warning": 30,
      "hard": 60,
      "controls": "Seconds between Polygon RPC polls for new on-chain events. Aligns with Polygon block time.",
      "why_default_matters": "12 s matches the Polygon block interval, ensuring every block is checked without redundant polls.",
      "threshold_logic": [
        {
          "condition": "interval \u2264 12 s",
          "action": "Normal \u2014 one poll per block"
        },
        {
          "condition": "12\u201330 s",
          "action": "WARN \u2014 may miss events in high-throughput windows"
        },
        {
          "condition": "> 60 s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (p.poll_interval_s > p.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "On-chain events are checked every Polygon block to ensure no wallet movement is missed."
    }
  ],
  "default_config": {
    "bot_id": "intel.onchainwatcher",
    "version": "2.1.0",
    "mode": "limited_live",
    "defaults": {
      "min_position_change_usd": 500,
      "publish_delay_s": 15,
      "poll_interval_s": 12
    },
    "locked": {
      "min_position_change_usd": {
        "min": 50
      },
      "publish_delay_s": {
        "min": 5
      },
      "poll_interval_s": {
        "max": 60
      }
    }
  },
  "implementation_flow": [
    "On startup, subscribe to Polygon RPC event log for CTFExchangeV2 OrderFilled events and pUSD Transfer events, filtered to watched_wallets addresses.",
    "On each block (poll_interval_s), fetch new events since last_processed_block.",
    "For each event, check if wallet address is in watched_wallets. If not, skip.",
    "Resolve CTFExchangeV2 token_id to condition_id via data_api. If resolution fails, buffer event and retry on next block.",
    "Compute position_change_pusd from event size fields (denominated in pUSD).",
    "If position_change_pusd < min_position_change_usd hard floor (50 pUSD), discard as dust.",
    "If position_change_pusd < min_position_change_usd default (500 pUSD), flag ONCHAINWATCHER_SMALL_POSITION.",
    "Add event to publish_queue with emit_at = now_ms() + publish_delay_s * 1000.",
    "Check KillSwitch. If active, continue monitoring and queuing but suppress emissions.",
    "On each tick, drain publish_queue of events whose emit_at <= now_ms().",
    "For each drained event (if KillSwitch not active), emit ObservationReport with: report_id, trace_id, wallet_address, condition_id, side (BUY/SELL), position_change_pusd, price, block_number, tx_hash, warnings.",
    "Log per-block summary: events_detected, events_emitted, events_queued, dust_skipped, killswitch_active."
  ],
  "decision_logic": {
    "approve": "Not applicable \u2014 OnChainWatcher is read-only; it never approves or submits orders.",
    "reshape_required": "Not applicable.",
    "reject": "Events below the hard dust floor (50 pUSD) are silently discarded. Events are suppressed (not emitted) only when KillSwitch is active (KILL_SWITCH_ACTIVE).",
    "warning_only": "ONCHAINWATCHER_SMALL_POSITION is included as a warning when position_change_pusd is between the hard floor and the default threshold."
  },
  "decision_output_schema": "ObservationReport",
  "decision_output_example": {
    "report_id": "rep_ocw_0xf1a2_1746703000000",
    "trace_id": "trc_0xbeef0102030405060708",
    "bot_id": "intel.onchainwatcher",
    "kind": "ObservationReport",
    "wallet_address": "0xF1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0",
    "condition_id": "0xf1a2b30000000000000000000000000000000000000000000000000000000000",
    "side": "BUY",
    "position_change_pusd": 12500,
    "price": 0.68,
    "block_number": 72346001,
    "tx_hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
    "publish_delay_applied_s": 15,
    "warnings": [],
    "emitted_at_ms": 1746703015000
  },
  "developer_log": {
    "bot_id": "intel.onchainwatcher",
    "block_number": 72346001,
    "events_detected": 8,
    "events_emitted": 3,
    "events_queued": 2,
    "dust_skipped": 3,
    "condition_id_resolutions_failed": 0,
    "killswitch_active": false
  },
  "user_explanations": [
    {
      "situation": "Strategy increased size on a market after wallet signal",
      "message": "A tracked high-activity wallet made a significant move on this market. The system detected this as a potential informed signal and adjusted positioning accordingly."
    },
    {
      "situation": "Signal delay before strategy acts",
      "message": "Wallet signals are intentionally held briefly before being published to ensure no participant has an unfair advantage based on on-chain latency."
    },
    {
      "situation": "Strategy reduced size on a market after wallet exit signal",
      "message": "A tracked wallet reduced its position. This was treated as a potential exit signal, and exposure was trimmed as a precaution."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "RPC provider outage during a high-activity period causes OnChainWatcher to miss wallet fills, leading strategies to miss smart-money entry signals and potentially enter markets after the signal window has closed.",
    "false_positive_risk": "A wash-trade or self-transfer between two watched wallets is counted as a large position change, generating a spurious buy signal on a market where no genuine informed entry occurred.",
    "false_negative_risk": "A watched wallet operating through a multi-step proxy contract has its CTFExchangeV2 fills attributed to an intermediate address not in watched_wallets, causing the signal to be missed entirely.",
    "safe_fallback": "If RPC is unavailable for > 2\u00d7 poll_interval_s, emit STALE_DATA WARN and halt new ObservationReport emissions until RPC recovers. Do not emit from buffered events based on data older than 3 blocks.",
    "required_dependencies": [
      "Polygon RPC (event log access)",
      "Polymarket Data API for token_id \u2192 condition_id resolution",
      "KillSwitch active flag readable",
      "watched_wallets config non-empty"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Large wallet BUY above threshold emits ObservationReport after publish_delay_s",
        "setup": "OrderFilled event for watched wallet, position_change_pusd=12500, publish_delay_s=15",
        "expected": "ObservationReport emitted 15 s after event detection with side=BUY, position_change_pusd=12500"
      },
      {
        "test": "Dust event below hard floor discarded silently",
        "setup": "OrderFilled event, position_change_pusd=30 pUSD, hard floor=50",
        "expected": "No ObservationReport; dust_skipped counter incremented"
      },
      {
        "test": "Small position between hard floor and default emits with ONCHAINWATCHER_SMALL_POSITION warning",
        "setup": "position_change_pusd=300, min_position_change_usd default=500, hard=50",
        "expected": "ObservationReport emitted with warnings=['ONCHAINWATCHER_SMALL_POSITION']"
      },
      {
        "test": "KillSwitch suppresses emissions but queuing continues",
        "setup": "killswitch.active=true; large wallet BUY detected",
        "expected": "Event queued; no ObservationReport emitted; KILL_SWITCH_ACTIVE logged"
      },
      {
        "test": "RPC outage triggers STALE_DATA and halts emissions",
        "setup": "RPC unavailable for 30 s (> 2\u00d7 poll_interval_s=12)",
        "expected": "STALE_DATA WARN logged; no ObservationReports emitted during outage"
      },
      {
        "test": "Non-watched wallet event discarded",
        "setup": "OrderFilled event from address not in watched_wallets",
        "expected": "Event silently discarded; no metrics incremented"
      }
    ],
    "integration": [
      {
        "test": "Full lifecycle: wallet BUY detected, delayed, emitted, consumed by LiquidityGuard",
        "expected": "LiquidityGuard receives ObservationReport with correct condition_id, side=BUY, and position_change_pusd"
      },
      {
        "test": "RPC failover: primary RPC down, secondary takes over, missed blocks re-fetched",
        "expected": "Buffered events emitted after RPC recovery with correct block_number"
      },
      {
        "test": "Data API outage delays condition_id resolution; event eventually resolved and emitted",
        "expected": "Event buffered; ObservationReport emitted once data_api returns condition_id mapping"
      }
    ],
    "property": [
      {
        "property": "OnChainWatcher never submits, signs, or modifies any order",
        "required": "Always true"
      },
      {
        "property": "No ObservationReport emitted when KillSwitch is active",
        "required": "Always true"
      },
      {
        "property": "publish_delay_s >= 5 enforced; all emissions are delayed",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Watch high-signal wallets for entries / exits.",
  "legacy_pm_signals": [
    "Polymarket Data API position changes per watched wallet"
  ],
  "legacy_external_feeds": [
    "Polygon on-chain event stream"
  ],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "onchain",
    "data_api",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.0",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1",
      "to": "v2",
      "reason": "CLOB V2 cutover \u2014 CTFExchangeV2 contract address, pUSD denomination, and updated OrderFilled event ABI",
      "action_taken": "Updated on-chain subscription to CTFExchangeV2 (V1 CTFExchange removed). Position change amounts updated from USDC.e to pUSD denomination. OrderFilled ABI updated for V2 field names (removed nonce/feeRateBps from event). No signed-order plumbing in this bot."
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "OnChainWatcher subscribes to CTFExchangeV2 OrderFilled events on Polygon; all position_change_pusd values are denominated in pUSD. The V1 CTFExchange contract is no longer monitored."
  },
  "reference_implementation": {
    "summary": "Subscribes to Polygon RPC event log for CTFExchangeV2 OrderFilled events filtered to watched_wallets, resolves token IDs to condition_ids, applies dust filter and size threshold, holds events for publish_delay_s, then emits ObservationReports.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output.",
    "pseudocode": "FUNCTION watchBlock(block_number):\n  // --- 0. RPC health check ---\n  IF rpc.last_block_age_s > 2 * params.poll_interval_s:\n    EMIT WARN 'STALE_DATA \u2014 RPC unresponsive'\n    RETURN\n\n  // --- 1. Fetch new events since last_processed_block ---\n  events = rpc.getLogs(\n    address   = CTFEXCHANGEV2_ADDR,\n    topics    = [ORDER_FILLED_SIG, PUSD_TRANSFER_SIG],\n    fromBlock = last_processed_block + 1,\n    toBlock   = block_number\n  )\n\n  // --- 2. KillSwitch gate ---\n  ks = FETCH internal.killswitch.status\n\n  FOR event IN events:\n    // --- 3. Wallet filter ---\n    wallet = event.makerAddress OR event.takerAddress\n    IF wallet NOT IN params.watched_wallets:\n      CONTINUE\n\n    // --- 4. Resolve token_id -> condition_id ---\n    condition_id = data_api.GET('/token/' + event.tokenId + '/condition')\n    IF condition_id IS NULL:\n      buffer_for_retry(event)\n      CONTINUE\n\n    // --- 5. Dust filter ---\n    position_change_pusd = toPusdUnits(event.fillAmount)\n    IF position_change_pusd < params.min_position_change_usd.hard:\n      dust_skipped_counter += 1\n      CONTINUE\n\n    // --- 6. Warning threshold ---\n    warnings = []\n    IF position_change_pusd < params.min_position_change_usd.default:\n      warnings.append('ONCHAINWATCHER_SMALL_POSITION')\n\n    side = 'BUY' IF event.makerSide == 'SELL' ELSE 'SELL'\n\n    // --- 7. Queue with publish delay ---\n    publish_queue.add({\n      wallet_address:          wallet,\n      condition_id:            condition_id,\n      side:                    side,\n      position_change_pusd:    position_change_pusd,\n      price:                   event.price,\n      block_number:            block_number,\n      tx_hash:                 event.transactionHash,\n      emit_at_ms:              now_ms() + params.publish_delay_s * 1000,\n      warnings:                warnings\n    })\n\n  // --- 8. Drain publish queue ---\n  FOR item IN publish_queue.due_items(now_ms()):\n    // KillSwitch suppress\n    IF ks.active:\n      LOG INFO 'KILL_SWITCH_ACTIVE \u2014 suppressing ObservationReport'\n      CONTINUE\n\n    EMIT ObservationReport {\n      report_id:              'rep_ocw_' + item.condition_id[:6] + '_' + now_ms(),\n      trace_id:               new_trace_id(),\n      bot_id:                 'intel.onchainwatcher',\n      kind:                   'ObservationReport',\n      wallet_address:         item.wallet_address,\n      condition_id:           item.condition_id,\n      side:                   item.side,\n      position_change_pusd:   item.position_change_pusd,\n      price:                  item.price,\n      block_number:           item.block_number,\n      tx_hash:                item.tx_hash,\n      publish_delay_applied_s: params.publish_delay_s,\n      warnings:               item.warnings,\n      emitted_at_ms:          now_ms()\n    }\n\n  last_processed_block = block_number",
    "sdk_calls": [
      "rpc.getLogs(address=CTFEXCHANGEV2_ADDR, topics=[ORDER_FILLED_SIG], fromBlock, toBlock)",
      "data_api.GET('/token/<tokenId>/condition')",
      "toPusdUnits(fillAmount)"
    ],
    "complexity": "O(E) per block where E = OrderFilled events for watched wallets; typically O(1) in steady state"
  },
  "wire_examples": {
    "input": {
      "label": "Polygon RPC CTFExchangeV2 OrderFilled event for watched wallet",
      "source": "onchain",
      "payload": {
        "event": "OrderFilled",
        "makerAddress": "0xF1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0",
        "takerAddress": "0x000000000000000000000000000000000000dead",
        "tokenId": "87654321",
        "fillAmount": "12500000000",
        "price": "0.68",
        "transactionHash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
        "block_number": 72346001,
        "timestamp_ms": 1746703000000
      }
    },
    "output": {
      "label": "ObservationReport \u2014 high-signal wallet BUY detected",
      "payload": {
        "report_id": "rep_ocw_0xf1a2_1746703000000",
        "trace_id": "trc_0xbeef0102030405060708",
        "bot_id": "intel.onchainwatcher",
        "kind": "ObservationReport",
        "wallet_address": "0xF1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0",
        "condition_id": "0xf1a2b30000000000000000000000000000000000000000000000000000000000",
        "side": "BUY",
        "position_change_pusd": 12500,
        "price": 0.68,
        "block_number": 72346001,
        "tx_hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
        "publish_delay_applied_s": 15,
        "warnings": [],
        "emitted_at_ms": 1746703015000
      }
    }
  },
  "reason_codes": [
    {
      "code": "ONCHAINWATCHER_LARGE_WALLET_ENTRY",
      "severity": "WARN",
      "meaning": "Watched wallet made a position change >= min_position_change_usd on a Polymarket market.",
      "action": "Emit ObservationReport after publish_delay_s; downstream strategies consume as smart-money signal.",
      "user_message": "A significant wallet movement was detected on this market."
    },
    {
      "code": "ONCHAINWATCHER_SMALL_POSITION",
      "severity": "WARN",
      "meaning": "Wallet position change is between the hard floor (50 pUSD) and default threshold (500 pUSD).",
      "action": "Emit ObservationReport with ONCHAINWATCHER_SMALL_POSITION warning; strategies apply lower weight to this signal.",
      "user_message": ""
    },
    {
      "code": "ONCHAINWATCHER_RESOLUTION_RETRY",
      "severity": "WARN",
      "meaning": "token_id could not be resolved to a condition_id via data_api; event buffered for retry.",
      "action": "Buffer event; retry on next block; emit ObservationReport once resolved.",
      "user_message": ""
    },
    {
      "code": "STALE_DATA",
      "severity": "WARN",
      "meaning": "RPC provider unresponsive for > 2\u00d7 poll_interval_s; on-chain state may be stale.",
      "action": "Halt ObservationReport emissions until RPC recovers; alert on-call.",
      "user_message": ""
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch active; ObservationReport emissions suppressed.",
      "action": "Continue monitoring and queuing events but suppress all emissions.",
      "user_message": "Wallet monitoring is paused while trading is suspended system-wide."
    },
    {
      "code": "MARKET_CLOSED",
      "severity": "EXPLAIN",
      "meaning": "OrderFilled event received for a condition_id that is already closed or resolved.",
      "action": "Skip emission; log for audit trail only.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "A parameter change violates a locked bound (e.g. publish_delay_s < 5 or poll_interval_s > 60).",
      "action": "Reject config change; do not apply.",
      "user_message": ""
    },
    {
      "code": "ONCHAINWATCHER_EMPTY_WALLET_LIST",
      "severity": "WARN",
      "meaning": "watched_wallets config is empty; no on-chain events will be captured.",
      "action": "Log WARN at startup; emit no ObservationReports until wallets are configured.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_intel_onchainwatcher_events_detected_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "wallet_address"
        ],
        "meaning": "On-chain events detected for watched wallets per block cycle."
      },
      {
        "name": "polytraders_intel_onchainwatcher_observations_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "side"
        ],
        "meaning": "ObservationReports emitted after publish_delay_s, broken down by side (BUY/SELL)."
      },
      {
        "name": "polytraders_intel_onchainwatcher_dust_skipped_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Events discarded as dust (below min_position_change_usd hard floor)."
      },
      {
        "name": "polytraders_intel_onchainwatcher_rpc_block_lag_s",
        "type": "gauge",
        "unit": "seconds",
        "labels": [],
        "meaning": "Age of the most recently processed Polygon block."
      },
      {
        "name": "polytraders_intel_onchainwatcher_publish_queue_depth",
        "type": "gauge",
        "unit": "count",
        "labels": [],
        "meaning": "Number of detected events currently sitting in the publish queue awaiting publish_delay_s."
      },
      {
        "name": "polytraders_intel_onchainwatcher_resolution_retries_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Total token_id -> condition_id resolution retries due to data_api failures."
      }
    ],
    "alerts": [
      {
        "name": "OnChainWatcherRPCDown",
        "condition": "polytraders_intel_onchainwatcher_rpc_block_lag_s > 60",
        "severity": "page",
        "runbook": "#runbook-onchainwatcher-rpc-down"
      },
      {
        "name": "OnChainWatcherQueueBuildup",
        "condition": "polytraders_intel_onchainwatcher_publish_queue_depth > 50",
        "severity": "warn",
        "runbook": "#runbook-onchainwatcher-queue-buildup"
      },
      {
        "name": "OnChainWatcherStaleData",
        "condition": "rate(polytraders_intel_onchainwatcher_events_detected_total[10m]) == 0 AND polytraders_intel_onchainwatcher_rpc_block_lag_s < 60",
        "severity": "warn",
        "runbook": "#runbook-onchainwatcher-stale-data"
      },
      {
        "name": "OnChainWatcherHighResolutionRetries",
        "condition": "rate(polytraders_intel_onchainwatcher_resolution_retries_total[10m]) > 5",
        "severity": "warn",
        "runbook": "#runbook-onchainwatcher-resolution-retries"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Intelligence / OnChainWatcher wallet event rate and queue depth",
      "Grafana \u2014 Intelligence / per-wallet position change size distribution"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "publish_queue: sorted set keyed by emit_at_ms; last_processed_block: integer; resolution_retry_buffer: hash of tx_hash -> event. Per-wallet position snapshot for deduplication.",
    "ttl": "publish_queue entries expire after max(publish_delay_s * 3, 300) s; retry buffer expires after 60 s",
    "recovery": "On cold start, last_processed_block loaded from Redis. Missed blocks since last checkpoint re-fetched from RPC. publish_queue drained normally.",
    "size_estimate": "~1 KB per queued event; ~50 KB for 50 queued events; ~200 bytes per wallet in snapshot"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 1,
    "idempotency_key": "tx_hash + wallet_address",
    "timeout_ms": 5000,
    "backpressure": "drop-after-buffer \u2014 if publish_queue > 200 entries, oldest entries dropped with STALE_DATA warning",
    "locking": "Redis SETNX on tx_hash to prevent duplicate event processing across restarts"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate suppresses ObservationReport emissions."
      }
    ],
    "emits_to": [
      {
        "bot_id": "risk.liquidity_guard",
        "what": "ObservationReport with wallet side, position_change_pusd for smart-money depth adjustment"
      },
      {
        "bot_id": "strat.liquidity_aware_strategies",
        "what": "ObservationReport with wallet entry/exit signal, condition_id, and side for position sizing"
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "Polygon RPC (Alchemy / Infura)",
        "endpoint": "Polygon mainnet",
        "sla": "99.9% / 200 ms p99",
        "fallback": "Fail over to secondary RPC; emit STALE_DATA if both unavailable"
      },
      {
        "service": "Polymarket Data API (token_id resolution)",
        "endpoint": "https://data-api.polymarket.com",
        "sla": "99.9% / 500 ms p99",
        "fallback": "Buffer unresolved events; retry on next block; discard after 60 s"
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Adversary discovers watched_wallets list and wash-trades to inject false buy/sell signals",
      "RPC provider returns crafted event logs to fake large wallet fills for a specific condition_id"
    ],
    "mitigations": [
      "watched_wallets list is operator-confidential config; not exposed in ObservationReport payloads",
      "publish_delay_s minimum (5 s) prevents use as a sub-second front-run vector",
      "All ObservationReports are informational only \u2014 LiquidityGuard and strategies independently validate market state before acting on signals"
    ],
    "contract_calls": []
  },
  "failure_injection": [
    {
      "scenario": "RPC_OUTAGE",
      "how_to_inject": "Block TCP to primary Polygon RPC for 30 s",
      "expected_behaviour": "rpc_block_lag_s rises > 24 s; STALE_DATA WARN logged; OnChainWatcherRPCDown alert fires; no new ObservationReports emitted; queued events held",
      "recovery": "Automatic failover to secondary RPC; missed blocks re-fetched; queued events emitted after publish_delay_s"
    },
    {
      "scenario": "DATA_API_DOWN",
      "how_to_inject": "Return 503 from data_api for 60 s",
      "expected_behaviour": "token_id resolution fails; events buffered in retry queue; OnChainWatcherHighResolutionRetries alert fires; no ObservationReports for unresolved events",
      "recovery": "Once data_api recovers, buffered events resolved and emitted"
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true; inject large wallet BUY event",
      "expected_behaviour": "Event detected, delayed, queued; no ObservationReport emitted; KILL_SWITCH_ACTIVE logged",
      "recovery": "Queued events emitted on first drain tick after KillSwitch reset"
    },
    {
      "scenario": "DUST_FLOOD",
      "how_to_inject": "Inject 100 OrderFilled events of 10 pUSD each for watched wallets",
      "expected_behaviour": "All 100 events discarded as dust; dust_skipped counter = 100; no ObservationReports; no alert fired",
      "recovery": "Automatic; no action required"
    },
    {
      "scenario": "QUEUE_BUILDUP",
      "how_to_inject": "Inject 60 large wallet events while blocking emit drain for 30 s",
      "expected_behaviour": "publish_queue_depth = 60; OnChainWatcherQueueBuildup alert fires; oldest entries dropped after queue > 200",
      "recovery": "Once drain resumes, remaining queued events emitted normally"
    }
  ],
  "runbook": {
    "summary": "OnChainWatcher incidents are most commonly RPC outages or data_api failures preventing condition_id resolution. An RPC outage causes smart-money signals to be missed for the outage duration. Page immediately on RPC down.",
    "oncall_actions": [
      {
        "alert": "OnChainWatcherRPCDown",
        "first_action": "Check rpc_block_lag_s. Verify primary and secondary Polygon RPC endpoints. Trigger manual failover if auto-failover has not activated.",
        "escalate_to": "Infra on-call immediately; Intelligence pod lead within 5 minutes"
      },
      {
        "alert": "OnChainWatcherQueueBuildup",
        "first_action": "Check publish_queue_depth. Verify drain loop is running. Check for KillSwitch being active unexpectedly.",
        "escalate_to": "Intelligence pod lead within 10 minutes"
      },
      {
        "alert": "OnChainWatcherStaleData",
        "first_action": "Confirm RPC is healthy (rpc_block_lag_s). If healthy but no events detected, check watched_wallets config is non-empty and wallets are active.",
        "escalate_to": "Intelligence pod lead if no events for > 30 min during known high-activity period"
      },
      {
        "alert": "OnChainWatcherHighResolutionRetries",
        "first_action": "Check data_api availability. Review resolution_retry_buffer for stuck token_ids. Manual resolution possible via operator lookup.",
        "escalate_to": "Intelligence pod lead if data_api unavailable > 5 min"
      }
    ],
    "manual_overrides": [
      {
        "name": "force_publish_delay_bypass",
        "how": "Set config.publish_delay_s=5 temporarily for urgent signal debugging (minimum 5 s always enforced)",
        "when": "Debugging delayed signal delivery in staging environment only"
      }
    ],
    "healthcheck": "Endpoint: /internal/health/onchainwatcher | Green: rpc_block_lag_s < 24 AND Redis reachable AND publish_queue_depth < 50 AND watched_wallets non-empty | Red: rpc_block_lag_s > 60 OR Redis unreachable OR watched_wallets empty OR publish_queue_depth > 200"
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for dust filter, publish delay, KillSwitch suppression, and resolution retry",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "RPC integration test: OrderFilled event for watched wallet detected and queued correctly",
        "how_measured": "Integration test against Polygon testnet",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "rpc_block_lag_s p99 < 15 s over 48 h",
        "how_measured": "polytraders_intel_onchainwatcher_rpc_block_lag_s gauge",
        "threshold": "p99 < 15 s"
      },
      {
        "gate": "publish_delay_s enforced: no ObservationReport emitted before publish_delay_s after event detection",
        "how_measured": "emitted_at_ms - event.timestamp_ms > publish_delay_s * 1000 for all emissions",
        "threshold": "100% compliance"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero missed wallet events during 14-day soak (verified by post-hoc on-chain reconciliation)",
        "how_measured": "On-chain event log reconciliation script comparing detected vs on-chain fills",
        "threshold": "0 missed qualifying events"
      },
      {
        "gate": "KillSwitch suppression: zero ObservationReports when KillSwitch active",
        "how_measured": "Integration test",
        "threshold": "Pass"
      },
      {
        "gate": "Dust filter: zero ObservationReports for events < 50 pUSD",
        "how_measured": "Integration test with simulated dust events",
        "threshold": "Pass"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "ObservationReport"
    ],
    "topics": [
      "polytraders.reports.observation"
    ],
    "cadence": "every-event",
    "retention_class": "30d",
    "retention_notes": "Full fidelity for 30 d; rolled-up summary retained for 1 y",
    "sampling_rule": "emit-every",
    "bus_failure_action": "drop-after-buffer",
    "user_visible": "no",
    "consumes_kinds": []
  },
  "capital_impact": "Indirect",
  "v3_status": {
    "phase": 2,
    "phase_name": "Data normalisation",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}