{
  "schema_version": "1.0.0",
  "bot_id": "4.14",
  "bot_name": "Wallet Flow Classifier",
  "slug": "wallet-flow-classifier",
  "layer": "Intelligence",
  "layer_key": "intel",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Intelligence",
    "bot_class": "Signal Service",
    "authority": "Read-only",
    "runs_before": "",
    "runs_after": "",
    "applies_to": "",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core"
  },
  "purpose": "Score on-chain wallets on historical edge over a long window \u2014 emitted as a feature, never as a copy-trade signal.",
  "why_it_matters": [
    {
      "failure": "Wallet behaviour treated as a copy-trade signal",
      "consequence": "Naively echoing 'smart money' wallets is the textbook way to lose money \u2014 the apparent edge is survivor bias, the real wallets front-run, and the implementation amounts to copy-trading. Emitting a feature with explicit non-trigger guidance prevents that misuse.",
      "worked_example": {
        "setup": "A strategy author wires a new feature: `if any wallet in top_50_pnl placed an order in the last 60s, mirror it`. They run it for a week.",
        "without_bot": "Three of those wallets are MEV bots that front-run the public mempool. The strategy posts orders 800 ms after them and pays the spread the wallets just collected. Net effect: the strategy is the exit liquidity for the wallets it is trying to follow.",
        "with_bot": "Wallet Flow Classifier emits `{wallet: 0xab.., score: 0.72, primary_trigger_allowed: false, window: '180d'}`. The strategy must consume the score as a feature blended with its own thesis \u2014 not as a primary trigger \u2014 and no copy-trade behaviour is permitted by contract."
      }
    },
    {
      "failure": "Strategies build their own wallet scorers, inconsistently",
      "consequence": "Without a shared classifier, every strategy author writes a slightly different version. The library ends up with three inconsistent scores, none of them auditable."
    },
    {
      "failure": "No long-window context for short-term flows",
      "consequence": "A wallet's 24-hour activity is meaningless without its multi-month history. Centralising the long-window score gives all consumers the same baseline and prevents recency-driven misclassifications."
    },
    {
      "failure": "Compliance unable to flag suspicious counterparties",
      "consequence": "Sanctions and abuse reviews need a structured score per wallet. The classifier provides one consistent input that ComplianceGate and Governance can both read."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "CTFExchangeV2 OrderFilled events from Polygon on-chain",
      "source": "onchain",
      "required": true,
      "use": "Primary source of wallet fill data for flow classification."
    },
    {
      "input": "Historical wallet fill patterns",
      "source": "data",
      "required": false,
      "use": "Supplement recent fills with historical context for better classification accuracy."
    }
  ],
  "internal_inputs": [
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Suppress all emissions when KillSwitch is active."
    }
  ],
  "raw_params": [
    "min_sample_resolutions \u00b7 int",
    "lookback_days \u00b7 int",
    "publish_delay_s \u00b7 int",
    "never_emit_as_copy_signal \u00b7 bool"
  ],
  "parameters": [
    {
      "name": "institutional_threshold_pusd",
      "default": 10000,
      "warning": 5000,
      "hard": 1000,
      "controls": "Minimum pUSD fill volume per block for a wallet to be classified as institutional.",
      "why_default_matters": "10000 pUSD per block distinguishes institutional flow from retail with high confidence.",
      "threshold_logic": [
        {
          "condition": "volume >= 10000 pUSD",
          "action": "Label institutional"
        },
        {
          "condition": "1000\u201310000 pUSD",
          "action": "WARN \u2014 borderline; label retail unless arbitrage pattern"
        },
        {
          "condition": "< 1000 pUSD",
          "action": "Label retail"
        }
      ],
      "dev_check": "if (total_pusd >= p.institutional_threshold_pusd.default) label = 'institutional';",
      "user_facing": "Large wallet flows are classified separately to help strategies assess market participant composition."
    },
    {
      "name": "poll_interval_s",
      "default": 12,
      "warning": 30,
      "hard": 60,
      "controls": "Seconds between Polygon RPC polls for new OrderFilled events. Aligns with Polygon block time.",
      "why_default_matters": "12 s matches the Polygon block interval, ensuring every block is checked.",
      "threshold_logic": [
        {
          "condition": "interval <= 12 s",
          "action": "Normal \u2014 one poll per block"
        },
        {
          "condition": "12\u201330 s",
          "action": "WARN \u2014 may miss fills in high-throughput blocks"
        },
        {
          "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 fills are checked every Polygon block to ensure no wallet flow is missed."
    }
  ],
  "default_config": {
    "bot_id": "intel.wallet-flow-classifier",
    "version": "0.1.0",
    "mode": "planned",
    "defaults": {
      "institutional_threshold_pusd": 10000,
      "poll_interval_s": 12
    },
    "locked": {
      "institutional_threshold_pusd": {
        "min": 1000
      },
      "poll_interval_s": {
        "max": 60
      }
    }
  },
  "implementation_flow": [],
  "decision_logic": {
    "approve": "",
    "reshape_required": "",
    "reject": "",
    "warning_only": ""
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "report_id": "rep_wfc_0xa1b2_1746703000000",
    "trace_id": "trc_0xbeef0102030405060715",
    "bot_id": "intel.wallet-flow-classifier",
    "kind": "ObservationReport",
    "wallet_address": "0xA1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0",
    "flow_label": "institutional",
    "total_pusd": 50000,
    "block_number": 72346100,
    "warnings": [],
    "emitted_at_ms": 1746703010000
  },
  "developer_log": {
    "bot_id": "intel.wallet-flow-classifier",
    "block_number": 72346100,
    "fills_processed": 12,
    "wallets_classified": 5,
    "institutional_count": 1,
    "arbitrage_count": 0,
    "retail_count": 4,
    "killswitch_active": false
  },
  "user_explanations": [
    {
      "situation": "Strategy adjusted weighting after institutional flow detected",
      "message": "A large wallet made significant fills on this market. The system treats high-volume wallet flows as a market signal when sizing positions."
    },
    {
      "situation": "Wallet flow classified as arbitrage",
      "message": "Fill patterns consistent with arbitrage trading were detected. Arbitrage flow is treated as less directionally informative."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Polygon RPC outage causes WalletFlowClassifier to miss fills for one or more blocks, resulting in gaps in wallet flow classification during high-activity periods.",
    "false_positive_risk": "A single wallet conducting coordinated multi-leg arbitrage is classified as institutional by volume alone, overstating the directional signal from that wallet.",
    "false_negative_risk": "An institutional actor using many small wallets below the institutional_threshold evades classification and is labelled retail, understating the informed flow.",
    "safe_fallback": "If RPC is unavailable for > 2x poll_interval_s, emit STALE_DATA WARN and halt new classifications. Retain last classification labels for each wallet in Redis until fresh data is available.",
    "required_dependencies": [
      "Polygon RPC (CTFExchangeV2 OrderFilled events)",
      "KillSwitch active flag",
      "Redis for wallet state storage"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Large fill classified as institutional and ObservationReport emitted",
        "setup": "Single wallet, total_pusd=50000 in one block",
        "expected": "ObservationReport emitted with flow_label=institutional"
      },
      {
        "test": "Small fill classified as retail",
        "setup": "Single wallet, total_pusd=200 in one block",
        "expected": "ObservationReport emitted with flow_label=retail"
      },
      {
        "test": "KillSwitch suppresses emission",
        "setup": "killswitch.active=true; institutional fill present",
        "expected": "No ObservationReport; KILL_SWITCH_ACTIVE logged"
      }
    ],
    "integration": [
      {
        "test": "Full lifecycle: institutional fill detected on-chain \u2192 ObservationReport \u2192 strategy adjusts signal weight",
        "expected": "Strategy receives flow_label=institutional with total_pusd"
      },
      {
        "test": "RPC outage: STALE_DATA emitted; classification halted; last labels retained",
        "expected": "STALE_DATA WARN; WalletFlowClassifierRPCDown alert fires; last labels in Redis"
      }
    ],
    "property": [
      {
        "property": "WalletFlowClassifier never submits or signs orders",
        "required": "Always true"
      },
      {
        "property": "No ObservationReport emitted when KillSwitch is active",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Score on-chain wallets on historical edge over a long window \u2014 emitted as a feature, never as a copy-trade signal.",
  "legacy_pm_signals": [
    "Wallet trade history vs. realised resolutions",
    "Sample-size and time-window adjustments",
    "Cluster membership and address-reuse heuristics"
  ],
  "legacy_external_feeds": [
    "Polygon archive nodes"
  ],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "onchain",
    "data",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q3-2026"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "n/a",
      "to": "v2-spec",
      "reason": "Spec drafted post-CLOB-V2 cutover; bot not yet implemented",
      "action_taken": "Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain)"
    }
  ],
  "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": "Classifies wallet pUSD flow types (retail, institutional, arbitrage) from CTFExchangeV2 on-chain data. Read-only. No order signing."
  },
  "reference_implementation": {
    "pseudocode": "FUNCTION classifyWalletFlow(block_number):\n  // 0. KillSwitch check\n  IF FETCH internal.killswitch.status == ACTIVE:\n    RETURN\n\n  // 1. Fetch fills for block range\n  fills = onchain.getLogs(CTFExchangeV2, 'OrderFilled', block_number)\n  IF len(fills) == 0:\n    RETURN\n\n  // 2. Group fills by wallet address\n  wallet_fills = groupBy(fills, 'makerAddress')\n\n  // 3. Classify each wallet\n  FOR wallet, wfills IN wallet_fills:\n    total_pusd = sum(toPusdUnits(f.fillAmount) for f in wfills)\n    IF total_pusd < retail_threshold:\n      label = 'retail'\n    ELIF total_pusd > institutional_threshold:\n      label = 'institutional'\n    ELIF isArbitragePattern(wfills):\n      label = 'arbitrage'\n    ELSE:\n      label = 'unknown'\n\n    // 4. Emit ObservationReport\n    EMIT ObservationReport {\n      report_id: gen_id(),\n      kind: 'ObservationReport',\n      wallet_address: wallet,\n      flow_label: label,\n      total_pusd: total_pusd,\n      block_number: block_number,\n      emitted_at_ms: now_ms()\n    }",
    "sdk_calls": [
      "onchain.getLogs(CTFExchangeV2, 'OrderFilled', block_number)",
      "data.GET('/wallets/<address>/history')",
      "toPusdUnits(fillAmount)"
    ],
    "complexity": "O(F) per block where F = fills in block for tracked wallets"
  },
  "wire_examples": {
    "input": {
      "label": "CTFExchangeV2 OrderFilled log for wallet flow classification",
      "source": "onchain",
      "payload": {
        "makerAddress": "0xA1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0",
        "fillAmount": "50000000000",
        "tokenId": "99887766",
        "block_number": 72346100,
        "timestamp_ms": 1746703000000
      }
    },
    "output": {
      "label": "ObservationReport \u2014 wallet classified as institutional",
      "payload": {
        "report_id": "rep_wfc_0xa1b2_1746703000000",
        "trace_id": "trc_0xbeef0102030405060715",
        "bot_id": "intel.wallet-flow-classifier",
        "kind": "ObservationReport",
        "wallet_address": "0xA1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0",
        "flow_label": "institutional",
        "total_pusd": 50000,
        "block_number": 72346100,
        "emitted_at_ms": 1746703010000
      }
    }
  },
  "reason_codes": [
    {
      "code": "WALLETFLOWCLASSIFIER_LARGE_FLOW",
      "severity": "INFO",
      "meaning": "Wallet classified as institutional based on pUSD volume exceeding institutional_threshold.",
      "action": "Emit ObservationReport with flow_label=institutional; strategies may treat as high-signal.",
      "user_message": "A large institutional wallet flow was detected on this market."
    },
    {
      "code": "WALLETFLOWCLASSIFIER_ARBITRAGE_PATTERN",
      "severity": "WARN",
      "meaning": "Wallet fill pattern matches arbitrage heuristic (multi-market simultaneous fills).",
      "action": "Emit ObservationReport with flow_label=arbitrage; strategies apply reduced directional weight.",
      "user_message": ""
    },
    {
      "code": "STALE_DATA",
      "severity": "WARN",
      "meaning": "RPC provider unresponsive; on-chain fills may be stale.",
      "action": "Skip classification for this block; alert on-call if persists.",
      "user_message": ""
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch active; all WalletFlowClassifier emissions suppressed.",
      "action": "Continue classification internally; suppress ObservationReport emissions.",
      "user_message": "Wallet flow classification paused while trading is suspended system-wide."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_intel_walletflowclassifier_observations_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "flow_label"
        ],
        "meaning": "ObservationReports emitted per flow label (retail, institutional, arbitrage, unknown)."
      },
      {
        "name": "polytraders_intel_walletflowclassifier_total_pusd_classified",
        "type": "counter",
        "unit": "pUSD",
        "labels": [
          "flow_label"
        ],
        "meaning": "Total pUSD volume classified per flow label."
      },
      {
        "name": "polytraders_intel_walletflowclassifier_rpc_block_lag_s",
        "type": "gauge",
        "unit": "seconds",
        "labels": [],
        "meaning": "Age of the most recently processed Polygon block."
      }
    ],
    "alerts": [
      {
        "name": "WalletFlowClassifierRPCDown",
        "condition": "polytraders_intel_walletflowclassifier_rpc_block_lag_s > 60",
        "severity": "page",
        "runbook": "#runbook-walletflowclassifier-rpc-down"
      },
      {
        "name": "WalletFlowClassifierHighArbitrageRate",
        "condition": "rate(polytraders_intel_walletflowclassifier_observations_emitted_total{flow_label='arbitrage'}[10m]) > 5",
        "severity": "warn",
        "runbook": "#runbook-walletflowclassifier-high-arbitrage"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Intelligence / WalletFlowClassifier volume by flow label"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "Per wallet: flow_label, total_pusd_30d, last_classified_at_ms, fill_pattern_buffer (last 10 fills).",
    "ttl": "Per-wallet state expires after 7 d of inactivity",
    "recovery": "On cold start, re-classify from next block; historical backfill not required.",
    "size_estimate": "~2 KB per tracked wallet"
  },
  "concurrency": {
    "execution_model": "single-threaded per-block processing",
    "max_in_flight": 1,
    "idempotency_key": "wallet_address + block_number",
    "timeout_ms": 10000,
    "backpressure": "drop-after-buffer \u2014 skip block if previous block still processing at next poll",
    "locking": "Redis SETNX on wallet + block_number to prevent duplicate classification"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Suppress emissions when KillSwitch is active."
      },
      {
        "bot_id": "intel.onchainwatcher",
        "why": "Uses OnChainWatcher as upstream for initial wallet detection."
      }
    ],
    "emits_to": [
      {
        "bot_id": "strat.flow_aware_strategies",
        "what": "ObservationReport with flow_label and total_pusd for informed vs noise flow filtering"
      }
    ],
    "sibling": [
      "intel.onchainwatcher"
    ],
    "external": [
      {
        "service": "Polygon RPC (Alchemy / Infura)",
        "endpoint": "Polygon mainnet",
        "sla": "99.9% / 200 ms p99",
        "fallback": "Emit STALE_DATA if RPC unavailable; skip block classification"
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Adversary uses multiple wallets to fragment institutional flow and evade institutional classification",
      "Wash-trades between related wallets to generate artificial arbitrage pattern signals"
    ],
    "mitigations": [
      "Classification is probabilistic and informational only; strategies apply independent signal weighting",
      "Arbitrage detection requires multi-market simultaneous fills to reduce single-market spoofing"
    ]
  },
  "failure_injection": [
    {
      "scenario": "RPC_OUTAGE",
      "how_to_inject": "Block TCP to Polygon RPC for 30 s",
      "expected_behaviour": "rpc_block_lag_s > 30 s; STALE_DATA WARN; WalletFlowClassifierRPCDown alert fires; no classifications",
      "recovery": "Automatic on RPC recovery; classification resumes from next available block"
    },
    {
      "scenario": "INSTITUTIONAL_FLOW",
      "how_to_inject": "Inject mock fills of 50000 pUSD for a single wallet in one block",
      "expected_behaviour": "WALLETFLOWCLASSIFIER_LARGE_FLOW INFO; ObservationReport emitted with flow_label=institutional",
      "recovery": "Automatic; no action required"
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true during active classification",
      "expected_behaviour": "Classification continues; emissions suppressed; KILL_SWITCH_ACTIVE logged",
      "recovery": "Automatic on KillSwitch reset"
    }
  ],
  "runbook": {
    "summary": "WalletFlowClassifier incidents are most commonly RPC outages. High arbitrage rate alerts indicate unusual market conditions worth investigating but do not block trading.",
    "oncall_actions": [
      {
        "alert": "WalletFlowClassifierRPCDown",
        "first_step": "Check rpc_block_lag_s. Verify Polygon RPC endpoint health. Trigger failover if primary is down.",
        "diagnosis": "",
        "mitigation": "",
        "escalation": "Infra on-call immediately; Intelligence pod lead within 5 min"
      },
      {
        "alert": "WalletFlowClassifierHighArbitrageRate",
        "first_step": "Review arbitrage-labelled wallets. Check for known arbitrage bots or unusual market conditions.",
        "diagnosis": "",
        "mitigation": "",
        "escalation": "Intelligence pod lead if > 20 arbitrage classifications in 10 min"
      }
    ],
    "manual_overrides": [
      {
        "command": "reset_wallet_state",
        "effect": "DEL redis key wallet:<address> to reset classification state for a specific wallet \u2014 After known wallet rotation or operator-confirmed wallet re-use"
      }
    ],
    "healthcheck": "Endpoint: /internal/health/wallet-flow-classifier | Green: rpc_block_lag_s < 24 AND Redis reachable AND last classification < 30 s ago | Red: rpc_block_lag_s > 60 OR Redis unreachable"
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for retail/institutional/arbitrage classification and KillSwitch suppression",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Classification completes within one Polygon block interval (12 s) for p99 of blocks",
        "how_measured": "Integration test on Polygon testnet",
        "threshold": "p99 < 12 s per block"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero missed block classifications over 7-day soak",
        "how_measured": "Block audit log vs RPC event log",
        "threshold": "0 missed blocks"
      }
    ]
  },
  "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": "summary-only",
    "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"
  }
}