{
  "schema_version": "1.0.0",
  "bot_id": "5.1",
  "bot_name": "WalletPermissionGuard",
  "slug": "walletpermissionguard",
  "layer": "Security",
  "layer_key": "sec",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Pause"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": true,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Security",
    "bot_class": "Guardrail",
    "authority": "Reject, Pause",
    "runs_before": "Any order signing or submission",
    "runs_after": "Strategy OrderIntent and Risk guardrails",
    "applies_to": "Every pending order before signature",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core"
  },
  "purpose": "Enforce that each strategy can only call the wallet methods the user has explicitly granted, scoped per session.",
  "why_it_matters": [
    {
      "failure": "Strategy calls an unauthorized wallet method",
      "consequence": "Unexpected asset movement or signing actions outside the user-granted scope, undermining non-custodial guarantees."
    },
    {
      "failure": "Method whitelist not enforced",
      "consequence": "A compromised strategy could sign arbitrary orders, draining pUSD balances."
    },
    {
      "failure": "Permission scope not session-bound",
      "consequence": "Stale grants from a previous session silently persist, violating least-privilege."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Pending order contract address and method",
      "source": "clob_auth",
      "required": true,
      "use": "Check that the target contract method is on the per-strategy whitelist."
    },
    {
      "input": "CTFExchangeV2 method signatures",
      "source": "onchain",
      "required": true,
      "use": "Validate that the called method exists and is recognised in the V2 ABI."
    }
  ],
  "internal_inputs": [
    {
      "input": "Per-strategy method whitelist and contract allowlist",
      "source": "Admin UI",
      "required": true,
      "use": "Authoritative grant set for each strategy session."
    },
    {
      "input": "Active session expiry timestamp",
      "source": "SessionKeyManager",
      "required": true,
      "use": "Reject calls from expired sessions."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Hard reject all calls when kill switch is active."
    }
  ],
  "raw_params": [
    "method_whitelist \u00b7 list",
    "contract_allowlist \u00b7 list",
    "max_per_call_size_usd \u00b7 int",
    "require_reapproval_h \u00b7 int"
  ],
  "parameters": [
    {
      "name": "method_whitelist",
      "default": "[]",
      "warning": "Non-empty but no CTFExchangeV2.matchOrders in list",
      "hard": "Empty list \u2014 fail-closed, no methods permitted",
      "controls": "List of wallet method identifiers the strategy is allowed to invoke.",
      "why_default_matters": "Default empty list is fail-closed; no signing is permitted until explicitly granted.",
      "threshold_logic": [
        {
          "condition": "method in method_whitelist",
          "action": "APPROVE \u2014 proceed to contract check"
        },
        {
          "condition": "method NOT in method_whitelist",
          "action": "REJECT \u2014 WALLET_PERMISSION_DENIED"
        }
      ],
      "dev_check": "if (!p.method_whitelist.includes(order.method)) return reject('WALLET_PERMISSION_DENIED');",
      "user_facing": "This action is not in your approved methods list."
    },
    {
      "name": "max_per_call_size_usd",
      "default": 1000,
      "warning": "Order size > 0.8 * max_per_call_size_usd",
      "hard": "Order size > max_per_call_size_usd",
      "controls": "Maximum pUSD value of a single signing call.",
      "why_default_matters": "A conservative default limits blast radius of a compromised session.",
      "threshold_logic": [
        {
          "condition": "size_usd <= max_per_call_size_usd",
          "action": "APPROVE"
        },
        {
          "condition": "size_usd > max_per_call_size_usd",
          "action": "REJECT \u2014 WALLET_PERMISSION_DENIED (size exceeded)"
        }
      ],
      "dev_check": "if (order.size_usd > p.max_per_call_size_usd) return reject('WALLET_PERMISSION_DENIED');",
      "user_facing": "This order exceeds the per-call size limit set for your session."
    }
  ],
  "default_config": {
    "bot_id": "sec.wallet_permission_guard",
    "version": "0.1.0",
    "mode": "hard_guard",
    "defaults": {
      "method_whitelist": [],
      "contract_allowlist": [],
      "max_per_call_size_usd": 1000,
      "require_reapproval_h": 24
    }
  },
  "implementation_flow": [
    "Receive pending order before signing.",
    "Check KillSwitch; if active, REJECT(KILL_SWITCH_ACTIVE).",
    "Check session expiry; if expired, REJECT(SESSION_KEY_EXPIRED).",
    "Verify order.method is in method_whitelist; if not, REJECT(WALLET_PERMISSION_DENIED).",
    "Verify order.contract_address is in contract_allowlist; if not, REJECT(WALLET_PERMISSION_DENIED).",
    "Verify order.size_usd <= max_per_call_size_usd; if exceeded, REJECT(WALLET_PERMISSION_DENIED).",
    "Emit RiskVote(APPROVE) with inputs_used and checked_at.",
    "Log decision to governance audit trail."
  ],
  "decision_logic": {
    "approve": "Method is whitelisted, contract is allowlisted, size within limit, and session is active.",
    "reshape_required": "Not applicable \u2014 this guard does not reshape; it only approves or rejects.",
    "reject": "Method not in whitelist, contract not in allowlist, size exceeded, session expired, or KillSwitch active.",
    "warning_only": "Warn when call size exceeds 80% of max_per_call_size_usd."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "vote_id": "sec.wallet_permission_guard.20260509T120000Z",
    "decision": "DENY",
    "reason_code": "WALLET_PERMISSION_DENIED",
    "evidence": {
      "method": "transfer",
      "in_whitelist": false
    },
    "checked_at": "2026-05-09T12:00:00Z"
  },
  "developer_log": {
    "bot_id": "sec.wallet_permission_guard",
    "decision": "DENY",
    "reason_code": "WALLET_PERMISSION_DENIED",
    "inputs_used": [
      "session.whitelist",
      "order.method"
    ],
    "checked_at": "2026-05-09T12:00:00Z"
  },
  "user_explanations": [
    {
      "situation": "Order blocked \u2014 method not permitted",
      "message": "This action is not in your approved methods list for the current session."
    },
    {
      "situation": "Order blocked \u2014 session expired",
      "message": "Your session has expired. Please re-authorise to continue trading."
    },
    {
      "situation": "Order blocked \u2014 size exceeded",
      "message": "This order is larger than the per-call limit set for your session."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "A strategy calling an out-of-scope wallet method because the whitelist check was bypassed or misconfigured.",
    "false_positive_risk": "Rejecting a legitimate order because the method whitelist was not updated after a strategy configuration change.",
    "false_negative_risk": "Approving an out-of-scope call if the whitelist is overly broad (e.g., wildcard entries).",
    "safe_fallback": "If the whitelist cannot be loaded, fail-closed: reject all calls with WALLET_PERMISSION_DENIED.",
    "required_dependencies": [
      "SessionKeyManager for session expiry",
      "Admin UI for whitelist config",
      "KillSwitch"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when method in whitelist and size within limit",
        "setup": "method='matchOrders', whitelist=['matchOrders'], size_usd=500",
        "expected": "APPROVE"
      },
      {
        "test": "Reject when method not in whitelist",
        "setup": "method='transfer', whitelist=['matchOrders']",
        "expected": "DENY(WALLET_PERMISSION_DENIED)"
      },
      {
        "test": "Reject when size exceeds max_per_call_size_usd",
        "setup": "size_usd=2000, max_per_call_size_usd=1000",
        "expected": "DENY(WALLET_PERMISSION_DENIED)"
      }
    ],
    "integration": [
      {
        "test": "Expired session triggers DENY before whitelist check",
        "expected": "DENY(SESSION_KEY_EXPIRED) without whitelist lookup"
      },
      {
        "test": "KillSwitch active short-circuits all checks",
        "expected": "DENY(KILL_SWITCH_ACTIVE)"
      }
    ],
    "property": [
      {
        "property": "Empty whitelist always produces DENY",
        "required": "Always true \u2014 fail-closed"
      },
      {
        "property": "Every DENY emits a security alert",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Enforce that each strategy can only call the wallet methods the user has explicitly granted, scoped per session.",
  "legacy_pm_signals": [
    "Per-strategy permission grant (method whitelist, contract allow-list, max size)",
    "Active session and its expiry",
    "Attempted out-of-scope calls (auto-rejected and logged)"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "risk_compliance",
    "governance_audit"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "clob_auth",
    "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": true,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Enforces per-session method whitelists scoped to CTFExchangeV2 on Polygon; pUSD size limits apply per call."
  },
  "reference_implementation": {
    "pseudocode": "// WalletPermissionGuard\nFUNCTION checkWalletPermission(pendingOrder, session):\n  // 0. KillSwitch\n  IF FETCH(internal.killswitch).active:\n    EMIT RiskVote(DENY, KILL_SWITCH_ACTIVE); RETURN\n  // 1. Session expiry\n  IF session.expires_at < NOW():\n    EMIT RiskVote(DENY, SESSION_KEY_EXPIRED); RETURN\n  // 2. Method whitelist\n  IF pendingOrder.method NOT IN session.method_whitelist:\n    EMIT RiskVote(DENY, WALLET_PERMISSION_DENIED); RETURN\n  // 3. Contract allowlist\n  IF pendingOrder.contract_address NOT IN session.contract_allowlist:\n    EMIT RiskVote(DENY, WALLET_PERMISSION_DENIED); RETURN\n  // 4. Size cap\n  IF pendingOrder.size_usd > params.max_per_call_size_usd:\n    EMIT RiskVote(DENY, WALLET_PERMISSION_DENIED); RETURN\n  // 5. Approve\n  EMIT RiskVote(APPROVE)\n  LOG(governance.audit, {decision, intent_id, method, session_id})",
    "sdk_calls": [
      "clob_auth.get_session(user_id)",
      "internal.killswitch.status()"
    ],
    "complexity": "O(n) where n = whitelist size (small constant)"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Permitted matchOrders call",
        "source": "internal",
        "payload": {
          "intent_id": "int_1a2b3c4d5e6f7a8b",
          "method": "matchOrders",
          "contract_address": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
          "size_usd": 400,
          "timestamp_ms": 1746768672000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 APPROVE",
        "payload": {
          "vote_id": "sec.wallet_permission_guard.20260509T120000Z",
          "decision": "APPROVE",
          "reason_code": null,
          "checked_at": "2026-05-09T12:00:00Z"
        }
      },
      {
        "label": "RiskVote \u2014 DENY (method not in whitelist)",
        "payload": {
          "vote_id": "sec.wallet_permission_guard.20260509T120100Z",
          "decision": "DENY",
          "reason_code": "WALLET_PERMISSION_DENIED",
          "checked_at": "2026-05-09T12: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": "SESSION_KEY_EXPIRED",
      "severity": "HARD_REJECT",
      "meaning": "The active session key has expired.",
      "action": "Return DENY; prompt user to re-authorise.",
      "user_message": "Your session has expired. Please re-authorise."
    },
    {
      "code": "WALLET_PERMISSION_DENIED",
      "severity": "HARD_REJECT",
      "meaning": "Method or contract not in session whitelist, or size cap exceeded.",
      "action": "Return DENY and emit security alert.",
      "user_message": "This action is not permitted in your current session."
    },
    {
      "code": "PERMISSION_SCOPE_WARN",
      "severity": "WARN",
      "meaning": "Order size is between 80% and 100% of max_per_call_size_usd.",
      "action": "Log warning; continue to next check.",
      "user_message": "This order is close to your per-call size limit."
    },
    {
      "code": "SESSION_ABOUT_TO_EXPIRE",
      "severity": "INFO",
      "meaning": "Session expires within require_reapproval_h hours.",
      "action": "Emit INFO; notify user to prepare re-authorisation.",
      "user_message": "Your session will expire soon. Consider re-authorising."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_sec_walletpermissionguard_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision",
          "reason_code"
        ],
        "meaning": "Total RiskVote decisions emitted."
      },
      {
        "name": "polytraders_sec_walletpermissionguard_denied_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "reason_code"
        ],
        "meaning": "Denied calls by reason."
      },
      {
        "name": "polytraders_sec_walletpermissionguard_session_expiry_s",
        "type": "gauge",
        "unit": "seconds",
        "labels": [
          "strategy_id"
        ],
        "meaning": "Seconds until current session expires per strategy."
      },
      {
        "name": "polytraders_sec_walletpermissionguard_eval_latency_ms",
        "type": "histogram",
        "unit": "ms",
        "labels": [],
        "meaning": "Wall-clock latency of permission check."
      }
    ],
    "alerts": [
      {
        "name": "WalletPermissionDenied",
        "condition": "rate(polytraders_sec_walletpermissionguard_denied_total[5m]) > 0",
        "severity": "P1",
        "runbook": "#runbook-walletpermission-denied"
      },
      {
        "name": "WalletPermissionSessionExpired",
        "condition": "polytraders_sec_walletpermissionguard_session_expiry_s < 300",
        "severity": "P2",
        "runbook": "#runbook-walletpermission-session"
      }
    ]
  },
  "state": {
    "store": "in-process cache + Admin UI config",
    "shape": "{strategy_id -> {whitelist, allowlist, max_size, session_expiry}}",
    "ttl": "session lifetime (max require_reapproval_h hours)",
    "recovery": "Reload whitelist from Admin UI on restart; fail-closed if unavailable.",
    "size_estimate": "< 10 KB per user session"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 500,
    "idempotency_key": "intent_id",
    "timeout_ms": 10,
    "backpressure": "drop newest",
    "locking": "read-only whitelist; no write locks needed"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate runs first.",
        "contract": "DENY(KILL_SWITCH_ACTIVE) short-circuits all checks."
      },
      {
        "bot_id": "sec.session_key_manager",
        "why": "Provides session expiry and scope.",
        "contract": "Session expiry triggers DENY(SESSION_KEY_EXPIRED)."
      }
    ],
    "emits_to": [
      {
        "bot_id": "gov.builder_attribution",
        "why": "Audit log every decision.",
        "contract": "GovernanceLog entry on each APPROVE or DENY."
      }
    ],
    "sibling": [
      "sec.contract_address_guard"
    ],
    "external": [
      {
        "service": "CLOB API (auth)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.9%",
        "failure_mode": "Fail-closed on unreachable session store."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Strategy attempting to call an unauthorized method to bypass fee caps",
      "Session token replay after expiry"
    ],
    "mitigations": [
      "Whitelist checked on every call; no caching of positive decisions",
      "Session expiry enforced by monotonic clock comparison"
    ]
  },
  "failure_injection": [
    {
      "scenario": "EMPTY_WHITELIST",
      "how_to_inject": "Start bot with method_whitelist=[]",
      "expected_behaviour": "DENY(WALLET_PERMISSION_DENIED) on all calls",
      "recovery": "Admin populates whitelist via signed action."
    },
    {
      "scenario": "SESSION_EXPIRED",
      "how_to_inject": "Set session.expires_at to past timestamp",
      "expected_behaviour": "DENY(SESSION_KEY_EXPIRED) before whitelist check",
      "recovery": "User re-authorises session."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behaviour": "DENY(KILL_SWITCH_ACTIVE) without any whitelist lookup",
      "recovery": "Manual KillSwitch reset."
    }
  ],
  "runbook": {
    "summary": "Every WALLET_PERMISSION_DENIED is a security event. Investigate whether the strategy is misconfigured or attempting scope escalation.",
    "oncall_actions": [
      {
        "alert": "WalletPermissionDenied",
        "first_action": "Examine denied method and strategy_id in the alert metadata.",
        "escalate_to": "Security pod lead if pattern of denials from same strategy."
      },
      {
        "alert": "WalletPermissionSessionExpired",
        "first_action": "Notify user to re-authorise their session.",
        "escalate_to": "N/A \u2014 user action required."
      }
    ],
    "manual_overrides": [
      {
        "name": "Reset session whitelist",
        "how": "polytraders admin reset-session sec.wallet_permission_guard --strategy <id>",
        "when": "Whitelist misconfigured after strategy update."
      }
    ],
    "healthcheck": "GET /internal/health/walletpermissionguard \u2192 green if Session store reachable; whitelist non-empty for active strategies.; red if Session store unreachable or all whitelists empty."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for all whitelist and session expiry paths",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Zero DENY alerts from legitimate strategy calls over 24h shadow",
        "how_measured": "Grafana WalletPermissionDenied alert history",
        "threshold": "0 false positives"
      }
    ],
    "to_general_live": [
      {
        "gate": "Session expiry injection test fires DENY correctly",
        "how_measured": "Failure injection test",
        "threshold": "Pass"
      }
    ]
  },
  "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": "Critical",
  "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"
  }
}