{
  "schema_version": "1.0.0",
  "bot_id": "5.9",
  "bot_name": "WalletFundingGuard",
  "slug": "wallet_funding_guard",
  "layer": "Security",
  "layer_key": "sec",
  "bot_class": "Guardrail",
  "authority": [
    "Reject"
  ],
  "status": "planned",
  "readiness": "Spec ready",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Security",
    "bot_class": "Guardrail",
    "authority": "Reject",
    "runs_before": "exec.smart_router",
    "runs_after": "exec.order_lifecycle_manager",
    "applies_to": "Per OrderIntent",
    "default_mode": "shadow",
    "user_visible": "Yes",
    "developer_owner": "Security pod"
  },
  "purpose": "Rejects any OrderIntent whose required pUSD collateral cannot be covered by the funded balance of the assigned wallet, including a configurable buffer. Prevents the system from submitting orders that would fail at the exchange for insufficient funds, which burns latency and creates noisy reject metrics.",
  "why_it_matters": [
    {
      "failure": "Insufficient-funds rejects",
      "consequence": "Each one wastes a CLOB round-trip and pollutes monitoring with noise that hides real issues."
    },
    {
      "failure": "Funding-race conditions",
      "consequence": "Two strategies competing for the same wallet's collateral can both pass an unsynchronised check; the second order rejects on-chain."
    },
    {
      "failure": "Operational embarrassment",
      "consequence": "An incident where the system tried to trade on a wallet that was never funded looks worse than catching it cleanly."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "On-chain pUSD balance per wallet",
      "source": "ERC-20 contract",
      "required": true,
      "use": "Authoritative balance source."
    }
  ],
  "internal_inputs": [
    {
      "input": "Wallet \u2192 strategy assignment",
      "source": "Config",
      "required": true,
      "use": "Which wallet funds which OrderIntent."
    },
    {
      "input": "In-flight collateral reservation",
      "source": "exec.order_lifecycle_manager",
      "required": true,
      "use": "Collateral already committed to resting orders."
    }
  ],
  "raw_params": [
    "funding_buffer_usd \u00b7 0\u2013100000",
    "balance_cache_ttl_ms \u00b7 100\u201360000"
  ],
  "parameters": [
    {
      "name": "funding_buffer_usd",
      "default": 25,
      "warning": "25",
      "hard": "5",
      "controls": "Buffer that must remain free after this order is accounted for.",
      "why_default_matters": "$25 buffer covers worst-case fee + slippage on a normal Polymarket binary order.",
      "threshold_logic": [
        {
          "condition": "\u2265 $25",
          "action": "PASS"
        },
        {
          "condition": "< $25",
          "action": "REJECT"
        }
      ],
      "dev_check": "if (free - intent.size_usd < p.funding_buffer_usd) reject('SEC_FUNDING');",
      "user_facing": "We did not place this order because the wallet does not have enough money to cover it safely."
    },
    {
      "name": "balance_cache_ttl_ms",
      "default": 5000,
      "warning": "5000",
      "hard": "15000",
      "controls": "How long an on-chain balance read can be cached before it must be re-fetched.",
      "why_default_matters": "5 seconds is a reasonable trade-off for an active trading session.",
      "threshold_logic": [
        {
          "condition": "5000",
          "action": "Default"
        }
      ],
      "dev_check": "if (now - balanceCachedAt > p.balance_cache_ttl_ms) refresh();",
      "user_facing": "(Internal.)"
    }
  ],
  "default_config": {
    "funding_buffer_usd": 25,
    "balance_cache_ttl_ms": 5000
  },
  "flow": "Receive OrderIntent \u2192 look up assigned wallet \u2192 fetch balance (from cache or on-chain) \u2192 compute free = balance - reserved_collateral \u2192 if intent.size_usd > free - funding_buffer_usd: REJECT \u2192 else PASS and reserve the collateral.",
  "decision_logic": {
    "approve": "Compute free = balance - reserved. Reserve collateral on PASS.",
    "reshape_required": "This bot does not reshape orders.",
    "reject": "Reject if order would breach the funding buffer.",
    "warning_only": "No warn-only path defined."
  },
  "decision_output_example": {
    "vote": "REJECT",
    "reason_code": "SEC_FUNDING",
    "explain": "Wallet 0xabc has $80 free; order for $90 would breach $25 buffer."
  },
  "developer_log": "Per decision: intent_id, wallet_address, balance_usd, reserved_usd, intent.size_usd, vote, reason_code.",
  "user_explanations": [
    {
      "situation": "When this bot acts",
      "message": "We did not place this order because the wallet does not have enough money to cover it safely."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Stale balance cache misses a withdrawal that just settled.",
    "false_positive_risk": "Just-funded wallet read before the next cache refresh; mitigation: on-demand cache invalidation when an inbound transfer is observed.",
    "false_negative_risk": "Two intents racing on the same wallet both PASS because reservation lock is not held; mitigation: reservation is taken under a per-wallet mutex.",
    "safe_fallback": "If the balance cannot be fetched at all, REJECT \u2014 never assume funding."
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Free = $25 + intent.size \u2192 PASS.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Free = intent.size \u2192 REJECT.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Race two intents on the same wallet \u2192 exactly one PASS, one REJECT.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      }
    ],
    "integration": [
      {
        "test": "End-to-end: drain a wallet to $24 and assert all OrderIntents are rejected.",
        "expected": "End-to-end behaviour matches the spec without manual intervention."
      }
    ],
    "property": [
      {
        "property": "Sum of accepted intent sizes never exceeds (balance - funding_buffer_usd).",
        "required": "Always true across all generated inputs."
      }
    ]
  },
  "reference_implementation": {
    "language": "pseudocode",
    "pseudocode": "with wallet_lock(wallet):\n  bal = balance(wallet, ttl=p.balance_cache_ttl_ms)\n  free = bal - reserved(wallet)\n  if intent.size_usd > free - p.funding_buffer_usd: return reject('SEC_FUNDING')\n  reserve(wallet, intent.size_usd, intent.intent_id)\n  return pass_()"
  },
  "wire_examples": {
    "input": {
      "intent_id": "intent_005",
      "size_usd": 90,
      "wallet_address": "0xabc"
    },
    "output": {
      "vote": "REJECT",
      "reason_code": "SEC_FUNDING",
      "explain": "Wallet 0xabc has $80 free; order for $90 would breach $25 buffer."
    }
  },
  "reason_codes": [
    {
      "code": "SEC_FUNDING",
      "severity": "P1",
      "meaning": "Sec Funding",
      "action": "See decision output and developer log for context.",
      "user_message": "We did not place this order because the wallet does not have enough money to cover it safely."
    },
    {
      "code": "SEC_FUNDING_OK",
      "severity": "P1",
      "meaning": "Sec Funding Ok",
      "action": "See decision output and developer log for context.",
      "user_message": "We did not place this order because the wallet does not have enough money to cover it safely."
    },
    {
      "code": "SEC_FUNDING_RACE_LOST",
      "severity": "P1",
      "meaning": "Sec Funding Race Lost",
      "action": "See decision output and developer log for context.",
      "user_message": "We did not place this order because the wallet does not have enough money to cover it safely."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "funding_rejects_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Funding rejects total."
      },
      {
        "name": "funding_pass_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Funding pass total."
      },
      {
        "name": "balance_refresh_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Balance refresh total."
      },
      {
        "name": "reservation_race_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Reservation race total."
      }
    ],
    "alerts": [],
    "dashboards": [
      "5.9 overview dashboard"
    ]
  },
  "state": {
    "summary": "Per-wallet reserved collateral map (durable). Balance cache (in-memory, TTL).",
    "stores": [
      {
        "name": "wallet_funding_guard_state",
        "kind": "in-memory + fast KV mirror",
        "key": "bot_id",
        "value": "Per-wallet reserved collateral map (durable). Balance cache (in-memory, TTL).",
        "ttl": "24h",
        "durability": "crash-safe via KV mirror"
      }
    ],
    "recovery": "Cold-start hydrates from fast KV; missing keys default to safe fallback.",
    "on_restart": "All in-flight decisions are re-evaluated; no bot decision is trusted across restart without re-emit."
  },
  "concurrency": {
    "execution_model": "Per-wallet mutex. Read-modify-write on (balance, reserved).",
    "max_in_flight": 32,
    "idempotency_key": "order_intent_id",
    "replay_safe": true,
    "deduplication": "By idempotency_key within a 60s window.",
    "ordering_guarantees": "Per-market_id FIFO; cross-market unordered.",
    "timeout_ms": 250,
    "backpressure": "Bounded queue; oldest-dropped with metric increment when full.",
    "locking": "Per-market_id mutex; no global locks."
  },
  "dependencies": {
    "depends_on": [
      "exec.order_lifecycle_manager"
    ],
    "emits_to": [
      "exec.smart_router"
    ]
  },
  "graph": {
    "requires": [
      "exec.order_lifecycle_manager"
    ],
    "required_before": [
      "exec.smart_router"
    ],
    "consumes": [
      "OrderIntent",
      "WalletBalance",
      "ReservationMap"
    ],
    "emits": [
      "RiskVote"
    ],
    "blocks": true
  },
  "mode_support": [
    "off",
    "shadow",
    "advisory",
    "enforced",
    "quarantine"
  ],
  "latency_budget_ms": {
    "p50": 8,
    "p99": 60
  },
  "data_freshness": {
    "max_market_data_age_ms": 5000,
    "max_orderbook_age_ms": 5000,
    "max_external_feed_age_ms": 5000,
    "on_stale_data": "REJECT \u2014 never assume funding."
  },
  "ownership": {
    "owner": "Security pod",
    "on_call": "sec-oncall",
    "channel": "#polytraders-sec",
    "escalation": "Head of Security",
    "severity_class": "P1"
  },
  "human_override": {
    "allowed": true,
    "who": "Security on-call",
    "log_event": "SEC_FUNDING_OVERRIDE",
    "time_bound": "Per intent",
    "scope": "Single intent_id",
    "second_approval": true
  },
  "security_surfaces": {
    "summary": "Read-only RPC for ERC-20 balance.",
    "signing": "None \u2014 bot does not sign or submit.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [
      "Reservation map is internal only and writes are mutex-guarded."
    ],
    "mitigations": [
      "Rate-limit per source",
      "Audit-log every override",
      "Require role-based authz on admin paths"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "V2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": true,
    "multichain_ready": true,
    "sdk_used": "Polymarket CLOB V2 SDK",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Reads pUSD ERC-20 balance directly; no V1/V2 difference."
  },
  "version": {
    "current": "0.1.0",
    "contract_version": "1.0.0",
    "last_breaking_change": "none",
    "deprecation_window_days": 30
  },
  "migration_history": [],
  "runbook": {
    "summary": "If reject rate spikes from this bot, check whether a wallet was drained or whether the balance cache is failing to refresh.",
    "oncall_actions": [
      {
        "alert": "5.9_anomaly",
        "first_step": "Open the bot's reporting page and confirm the alert is real (not a metric hiccup).",
        "diagnosis": "Inspect developer log entries for the affected market_id over the last 30 minutes.",
        "mitigation": "Force-clear via Admin UI if the rule is clearly stale; otherwise leave engaged and notify owner.",
        "escalation": "Security pod"
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause 5.9",
        "effect": "Disables the bot's enforcement layer; downstream consumers fall back to safe defaults."
      }
    ],
    "healthcheck": "GET /healthz/wallet_funding_guard \u2192 200 if last successful evaluation < 60s ago."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Stub",
        "how_measured": "race tests pass.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow",
        "how_measured": "14 days; reservations tracked but not enforced.",
        "threshold": "Documented threshold met for the full window."
      },
      {
        "gate": "Advisory",
        "how_measured": "7 days.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_general_live": [
      {
        "gate": "Enforced",
        "how_measured": "Security Lead sign-off.",
        "threshold": "Documented threshold met for the full window."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "Drain a wallet during shadow and assert all subsequent orders REJECT",
      "how_to_inject": "Drain a wallet during shadow and assert all subsequent orders REJECT.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Disconnect the balance RPC and assert REJECT-on-stale fires",
      "how_to_inject": "Disconnect the balance RPC and assert REJECT-on-stale fires.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    }
  ],
  "capital_impact": "Direct",
  "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"
  }
}