{
  "schema_version": "1.0.0",
  "bot_id": "5.2",
  "bot_name": "AllowanceMonitor",
  "slug": "allowancemonitor",
  "layer": "Security",
  "layer_key": "sec",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Pause"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Security",
    "bot_class": "Guardrail",
    "authority": "Reject, Pause",
    "runs_before": "Any order that requires an on-chain allowance",
    "runs_after": "Strategy OrderIntent",
    "applies_to": "All ERC-20 allowances on Polygon for the trading wallet",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core"
  },
  "purpose": "Track ERC-20 allowances per token and contract; alert and shrink to a tight ceiling on idle.",
  "why_it_matters": [
    {
      "failure": "Unbounded ERC-20 allowance left idle",
      "consequence": "A compromised or malicious contract can drain the wallet's pUSD balance at any future time."
    },
    {
      "failure": "Allowance not shrunk after idle period",
      "consequence": "Unlimited approvals compound risk; industry incidents show dormant allowances are frequently exploited."
    },
    {
      "failure": "No alert on unbounded approval",
      "consequence": "Operators remain unaware of excessive exposure until an incident occurs."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "ERC-20 allowance(owner, spender) on Polygon",
      "source": "onchain",
      "required": true,
      "use": "Read current pUSD allowance for CTFExchangeV2 and NegRiskAdapter."
    },
    {
      "input": "Block timestamp for last use",
      "source": "onchain",
      "required": true,
      "use": "Determine idle duration to trigger auto-shrink."
    }
  ],
  "internal_inputs": [
    {
      "input": "Configured max_allowance_usd and idle_revoke_h",
      "source": "Admin UI",
      "required": true,
      "use": "Thresholds for alert and auto-shrink decisions."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Halt allowance operations when kill switch is active."
    }
  ],
  "raw_params": [
    "max_allowance_usd \u00b7 int",
    "idle_revoke_h \u00b7 int",
    "auto_shrink \u00b7 bool",
    "alert_on_unbounded \u00b7 bool"
  ],
  "parameters": [
    {
      "name": "max_allowance_usd",
      "default": 500,
      "warning": "Current allowance > 0.9 * max_allowance_usd",
      "hard": "Current allowance > max_allowance_usd (unbounded detected)",
      "controls": "Maximum pUSD allowance tolerated for any (token, spender) pair.",
      "why_default_matters": "Conservative ceiling limits blast radius if the spender contract is compromised.",
      "threshold_logic": [
        {
          "condition": "allowance <= max_allowance_usd",
          "action": "APPROVE"
        },
        {
          "condition": "allowance > max_allowance_usd AND auto_shrink=true",
          "action": "Emit shrink tx, then APPROVE"
        },
        {
          "condition": "allowance > max_allowance_usd AND auto_shrink=false",
          "action": "REJECT \u2014 ALLOWANCE_EXCEEDS_CEILING"
        }
      ],
      "dev_check": "if (allowance > p.max_allowance_usd && !p.auto_shrink) return reject('ALLOWANCE_EXCEEDS_CEILING');",
      "user_facing": "Your pUSD approval for this contract exceeds the safety ceiling and will be reduced."
    },
    {
      "name": "auto_shrink",
      "default": true,
      "warning": "auto_shrink disabled and allowance > max_allowance_usd",
      "hard": "\u2014",
      "controls": "When true, automatically submit a shrink-to-exact transaction before signing.",
      "why_default_matters": "Auto-shrink on by default closes the window proactively without requiring manual intervention.",
      "threshold_logic": [
        {
          "condition": "auto_shrink=true AND allowance > max_allowance_usd",
          "action": "Submit approve(spender, exact_needed) on-chain before signing"
        },
        {
          "condition": "auto_shrink=false",
          "action": "Alert only; no on-chain action"
        }
      ],
      "dev_check": "if (p.auto_shrink && allowance > needed) await shrinkAllowance(token, spender, needed);",
      "user_facing": "Your approval has been automatically adjusted to the minimum required for this order."
    }
  ],
  "default_config": {
    "bot_id": "sec.allowance_monitor",
    "version": "0.1.0",
    "mode": "hard_guard",
    "defaults": {
      "max_allowance_usd": 500,
      "idle_revoke_h": 48,
      "auto_shrink": true,
      "alert_on_unbounded": true
    }
  },
  "implementation_flow": [
    "Receive order intent before submission.",
    "Check KillSwitch; if active, REJECT(KILL_SWITCH_ACTIVE).",
    "FETCH current pUSD allowance for (CTFExchangeV2, wallet) from Polygon RPC.",
    "If allowance > max_allowance_usd and auto_shrink=true: submit shrink tx, wait for confirmation.",
    "If allowance > max_allowance_usd and auto_shrink=false: REJECT(ALLOWANCE_EXCEEDS_CEILING).",
    "Check idle duration; if > idle_revoke_h: trigger revoke allowance for idle spenders.",
    "Emit RiskVote(APPROVE) with current allowance snapshot.",
    "Log result to governance audit trail."
  ],
  "decision_logic": {
    "approve": "Allowance is within max_allowance_usd or auto_shrink reduced it to the safe ceiling.",
    "reshape_required": "Not applicable \u2014 monitor either approves or rejects.",
    "reject": "Allowance exceeds ceiling and auto_shrink is disabled (ALLOWANCE_EXCEEDS_CEILING).",
    "warning_only": "Warn when allowance approaches 90% of max_allowance_usd."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "vote_id": "sec.allowance_monitor.20260509T130000Z",
    "decision": "APPROVE",
    "reason_code": null,
    "evidence": {
      "token": "pUSD",
      "spender": "CTFExchangeV2",
      "allowance_usd": 400,
      "ceiling_usd": 500,
      "shrunk": false
    },
    "checked_at": "2026-05-09T13:00:00Z"
  },
  "developer_log": {
    "bot_id": "sec.allowance_monitor",
    "decision": "APPROVE",
    "inputs_used": [
      "onchain.allowance",
      "config.max_allowance_usd"
    ],
    "checked_at": "2026-05-09T13:00:00Z"
  },
  "user_explanations": [
    {
      "situation": "Allowance shrunk automatically",
      "message": "Your pUSD approval was reduced to the minimum needed for this order to limit exposure."
    },
    {
      "situation": "Order blocked \u2014 allowance too high",
      "message": "Your pUSD approval is above the safety limit and auto-shrink is disabled. Please reduce it manually."
    },
    {
      "situation": "Allowance about to be revoked",
      "message": "Your approval for an inactive contract is being revoked after idle period."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Allowance check bypassed or RPC call fails, leaving unbounded approval in place.",
    "false_positive_risk": "Shrinking allowance during a burst of orders could cause the next order to fail if the shrink tx is not confirmed in time.",
    "false_negative_risk": "Stale RPC response showing a lower allowance than actual, causing an oversize order to proceed.",
    "safe_fallback": "If RPC is unreachable, fail-closed: reject orders until allowance can be verified.",
    "required_dependencies": [
      "Polygon RPC",
      "Admin UI config",
      "KillSwitch"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when allowance within ceiling",
        "setup": "allowance=400, max_allowance_usd=500, auto_shrink=true",
        "expected": "APPROVE without shrink"
      },
      {
        "test": "Auto-shrink and approve when allowance exceeds ceiling",
        "setup": "allowance=2000, max_allowance_usd=500, auto_shrink=true",
        "expected": "shrink tx submitted then APPROVE"
      },
      {
        "test": "Reject when allowance exceeds ceiling and auto_shrink=false",
        "setup": "allowance=2000, auto_shrink=false",
        "expected": "DENY(ALLOWANCE_EXCEEDS_CEILING)"
      }
    ],
    "integration": [
      {
        "test": "RPC unreachable triggers fail-closed",
        "expected": "DENY until RPC recovers"
      },
      {
        "test": "Idle revoke fires after idle_revoke_h",
        "expected": "revoke tx submitted for idle spender"
      }
    ],
    "property": [
      {
        "property": "auto_shrink=true never leaves allowance above ceiling post-check",
        "required": "Always true"
      },
      {
        "property": "Empty/unreachable RPC always produces DENY",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Track ERC-20 allowances per token and contract; alert and shrink to a tight ceiling on idle.",
  "legacy_pm_signals": [
    "Current allowance per (token, spender) pair",
    "Time since last use of each allowance",
    "Pending revocation transactions"
  ],
  "legacy_external_feeds": [
    "Polygon read RPC"
  ],
  "reporting_groups": [
    "risk_compliance",
    "governance_audit"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "onchain",
    "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": "Monitors pUSD allowances for CTFExchangeV2 and NegRiskAdapter on Polygon; auto-shrinks to exact-needed before signing."
  },
  "reference_implementation": {
    "pseudocode": "// AllowanceMonitor\nFUNCTION checkAllowance(pendingOrder, wallet):\n  IF FETCH(internal.killswitch).active:\n    EMIT RiskVote(DENY, KILL_SWITCH_ACTIVE); RETURN\n  spender = pendingOrder.contract_address  // CTFExchangeV2\n  allowance = FETCH(onchain.erc20.allowance(pUSD, wallet, spender))\n  IF allowance == null:  // RPC failure\n    EMIT RiskVote(DENY, STALE_DATA); RETURN\n  IF allowance > params.max_allowance_usd:\n    IF params.auto_shrink:\n      AWAIT onchain.approve(pUSD, spender, pendingOrder.size_usd)\n      allowance = pendingOrder.size_usd  // now safe\n    ELSE:\n      EMIT RiskVote(DENY, ALLOWANCE_EXCEEDS_CEILING); RETURN\n  // Idle revoke check\n  last_use = FETCH(onchain.last_transfer_event(spender))\n  idle_h = (NOW() - last_use) / 3600\n  IF idle_h > params.idle_revoke_h:\n    AWAIT onchain.approve(pUSD, spender, 0)  // revoke\n    EMIT RiskVote(DENY, ALLOWANCE_EXCEEDS_CEILING); RETURN\n  EMIT RiskVote(APPROVE)\n  LOG(governance.audit, {allowance, spender, wallet})",
    "sdk_calls": [
      "onchain.erc20.allowance(pUSD_ADDRESS, wallet, CTFExchangeV2_ADDRESS)",
      "onchain.approve(pUSD_ADDRESS, spender, amount)"
    ],
    "complexity": "O(1) per order \u2014 single RPC read"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Order with safe allowance",
        "source": "internal",
        "payload": {
          "intent_id": "int_2b3c4d5e6f7a8b9c",
          "contract_address": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
          "size_usd": 200,
          "timestamp_ms": 1746768672000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 APPROVE",
        "payload": {
          "vote_id": "sec.allowance_monitor.20260509T130000Z",
          "decision": "APPROVE",
          "reason_code": null,
          "evidence": {
            "allowance_usd": 400,
            "ceiling_usd": 500,
            "shrunk": false
          },
          "checked_at": "2026-05-09T13:00:00Z"
        }
      },
      {
        "label": "RiskVote \u2014 DENY (exceeds ceiling, auto_shrink off)",
        "payload": {
          "vote_id": "sec.allowance_monitor.20260509T130100Z",
          "decision": "DENY",
          "reason_code": "ALLOWANCE_EXCEEDS_CEILING",
          "checked_at": "2026-05-09T13:01:00Z"
        }
      }
    ]
  },
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch is active.",
      "action": "Immediately return DENY.",
      "user_message": "Trading is currently paused."
    },
    {
      "code": "ALLOWANCE_EXCEEDS_CEILING",
      "severity": "HARD_REJECT",
      "meaning": "ERC-20 allowance for a spender exceeds max_allowance_usd and auto_shrink is disabled.",
      "action": "Return DENY and emit alert.",
      "user_message": "Your pUSD approval is above the safety limit. Please reduce it."
    },
    {
      "code": "STALE_DATA",
      "severity": "HARD_REJECT",
      "meaning": "RPC call to read allowance failed or returned stale data.",
      "action": "Return DENY; retry on next order.",
      "user_message": "Could not verify your approval status. Please try again."
    },
    {
      "code": "ALLOWANCE_NEAR_CEILING",
      "severity": "WARN",
      "meaning": "Allowance is within 10% of max_allowance_usd.",
      "action": "Log warning; continue.",
      "user_message": "Your approval is near the safety ceiling."
    },
    {
      "code": "ALLOWANCE_SHRUNK",
      "severity": "INFO",
      "meaning": "auto_shrink triggered and approval reduced to exact order size.",
      "action": "Log info; proceed.",
      "user_message": "Your approval was adjusted to the minimum needed."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_sec_allowancemonitor_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision"
        ],
        "meaning": "Total RiskVote decisions."
      },
      {
        "name": "polytraders_sec_allowancemonitor_shrinks_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "spender"
        ],
        "meaning": "Number of auto-shrink transactions submitted."
      },
      {
        "name": "polytraders_sec_allowancemonitor_allowance_usd",
        "type": "gauge",
        "unit": "usd",
        "labels": [
          "spender"
        ],
        "meaning": "Current pUSD allowance per spender after last check."
      },
      {
        "name": "polytraders_sec_allowancemonitor_eval_latency_ms",
        "type": "histogram",
        "unit": "ms",
        "labels": [],
        "meaning": "Latency of the allowance check including RPC call."
      }
    ],
    "alerts": [
      {
        "name": "AllowanceExceedsCeiling",
        "condition": "rate(polytraders_sec_allowancemonitor_decisions_total{decision='DENY'}[5m]) > 0",
        "severity": "P1",
        "runbook": "#runbook-allowancemonitor-ceiling"
      },
      {
        "name": "AllowanceMonitorShrinkFailed",
        "condition": "rate(polytraders_sec_allowancemonitor_shrinks_total[5m]) == 0 AND allowance > ceiling",
        "severity": "P1",
        "runbook": "#runbook-allowancemonitor-shrink"
      }
    ]
  },
  "state": {
    "store": "in-process cache (last-known allowance per spender)",
    "shape": "{(token, spender) -> {allowance_usd, last_checked_at, last_use_at}}",
    "ttl": "refreshed on every order or every 60s",
    "recovery": "Re-read from Polygon RPC on restart; fail-closed if RPC unavailable.",
    "size_estimate": "< 1 KB"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 100,
    "idempotency_key": "intent_id",
    "timeout_ms": 500,
    "backpressure": "queue with max depth 50",
    "locking": "mutex on allowance cache write during shrink tx"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate before any on-chain read.",
        "contract": "DENY(KILL_SWITCH_ACTIVE) short-circuits."
      },
      {
        "bot_id": "sec.rpc_failover_manager",
        "why": "RPC endpoint health for allowance reads.",
        "contract": "Healthy RPC required; failover to secondary on error."
      }
    ],
    "emits_to": [
      {
        "bot_id": "gov.builder_attribution",
        "why": "Audit log every decision and shrink event.",
        "contract": "GovernanceLog entry on each event."
      }
    ],
    "sibling": [
      "sec.contract_address_guard"
    ],
    "external": [
      {
        "service": "Polygon RPC (read)",
        "endpoint": "Polygon RPC",
        "sla": "best-effort",
        "failure_mode": "Fail-closed on unreachable RPC."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "RPC returning stale allowance to prevent shrink from firing",
      "Rapid allowance inflation between check and order submission"
    ],
    "mitigations": [
      "Allowance re-read immediately before every signing action",
      "auto_shrink submits tx and waits for confirmation before approving"
    ]
  },
  "failure_injection": [
    {
      "scenario": "RPC_UNREACHABLE",
      "how_to_inject": "Block Polygon RPC endpoint",
      "expected_behaviour": "DENY(STALE_DATA) on all orders",
      "recovery": "Automatic when RPC recovers; RPCFailoverManager switches provider."
    },
    {
      "scenario": "UNBOUNDED_ALLOWANCE",
      "how_to_inject": "Set wallet allowance to uint256.max",
      "expected_behaviour": "auto_shrink tx submitted; allowance reduced to order size",
      "recovery": "Automatic after shrink tx confirmed."
    },
    {
      "scenario": "SHRINK_TX_FAILS",
      "how_to_inject": "Simulate on-chain tx revert during shrink",
      "expected_behaviour": "DENY(ALLOWANCE_EXCEEDS_CEILING); alert fired",
      "recovery": "Manual allowance management required."
    }
  ],
  "runbook": {
    "summary": "AllowanceExceedsCeiling alerts require immediate review \u2014 check auto_shrink status and RPC health.",
    "oncall_actions": [
      {
        "alert": "AllowanceExceedsCeiling",
        "first_action": "Check auto_shrink config and recent shrink tx status.",
        "escalate_to": "Security pod lead if shrink fails to reduce allowance."
      },
      {
        "alert": "AllowanceMonitorShrinkFailed",
        "first_action": "Check Polygon RPC connectivity and wallet gas balance.",
        "escalate_to": "On-call engineer to manually reduce allowance."
      }
    ],
    "manual_overrides": [
      {
        "name": "Force allowance revoke",
        "how": "polytraders admin revoke-allowance sec.allowance_monitor --spender CTFExchangeV2",
        "when": "Shrink tx fails and manual intervention is required."
      }
    ],
    "healthcheck": "GET /internal/health/allowancemonitor \u2192 green if All allowances at or below max_allowance_usd; last check within 60s.; red if Any allowance above ceiling for more than 120s."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for shrink and ceiling logic",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "auto_shrink fires correctly on simulated unbounded allowance",
        "how_measured": "Failure injection test on staging",
        "threshold": "Pass"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero AllowanceExceedsCeiling alerts in 48h shadow",
        "how_measured": "Grafana alert history",
        "threshold": "0 alerts"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "RiskVote"
    ],
    "topics": [
      "polytraders.reports.risk"
    ],
    "retention_class": "2y",
    "cadence": "every-event",
    "sampling_rule": "emit-every",
    "bus_failure_action": "fail-closed",
    "user_visible": "summary-only",
    "consumes_kinds": [
      "ObservationReport"
    ]
  },
  "capital_impact": "Direct",
  "mode_support": [
    "quarantine"
  ],
  "v3_status": {
    "phase": 5,
    "phase_name": "Execution rails",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}