|
| 1 | +"""EVMTarget — point a wallet-swarm at an onchain protocol (FR-TA-04, v0.2). |
| 2 | +
|
| 3 | +The premium "open-Gauntlet" surface: agents hold funded test wallets and their tool |
| 4 | +calls become transactions. Two backends behind one adapter: |
| 5 | +
|
| 6 | +* **mock lending world** (default, no chain) — a deterministic in-process lending |
| 7 | + pool (borrow / repay / liquidate) so the wallet-swarm demo runs in ``--dry-run`` |
| 8 | + with zero infra. This is what makes the EVM story verifiable in CI. |
| 9 | +* **Anvil fork** (``rpc_url``) — a real fork via web3.py. Guarded by the fork check |
| 10 | + below and the Safety Gate: an ``EVMTarget`` **refuses a non-fork mainnet RPC** |
| 11 | + (ADR-6, NFR-SEC), so a chaos/adversarial swarm can never touch real value. |
| 12 | +
|
| 13 | +Requires the ``[evm]`` extra (web3) only for the Anvil backend. |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +from typing import Any |
| 19 | + |
| 20 | +from stampede.targets.base import ( |
| 21 | + AgentContext, |
| 22 | + HealthStatus, |
| 23 | + IsolationMode, |
| 24 | + SafetyDescriptor, |
| 25 | + TargetAdapter, |
| 26 | + ToolCall, |
| 27 | + ToolResult, |
| 28 | + ToolSet, |
| 29 | + ToolSpec, |
| 30 | +) |
| 31 | + |
| 32 | +_LTV = 0.75 # loan-to-value: you can borrow up to 75% of collateral |
| 33 | + |
| 34 | + |
| 35 | +# ---- the mock lending world (deterministic, no chain) ----------------------- |
| 36 | + |
| 37 | + |
| 38 | +def _lending_toolset() -> ToolSet: |
| 39 | + amount_schema = {"type": "object", "properties": {"amount": {"type": "integer"}}} |
| 40 | + return ToolSet( |
| 41 | + tools=[ |
| 42 | + ToolSpec( |
| 43 | + name="borrow", |
| 44 | + description="Borrow against your collateral. Fails if it would leave you undercollateralized.", |
| 45 | + input_schema=amount_schema, |
| 46 | + destructive=True, |
| 47 | + ), |
| 48 | + ToolSpec( |
| 49 | + name="repay", |
| 50 | + description="Repay part of your debt.", |
| 51 | + input_schema=amount_schema, |
| 52 | + ), |
| 53 | + ToolSpec( |
| 54 | + name="liquidate", |
| 55 | + description="Liquidate an undercollateralized account and seize its collateral.", |
| 56 | + input_schema={"type": "object", "properties": {"account": {"type": "string"}}}, |
| 57 | + destructive=True, |
| 58 | + ), |
| 59 | + ToolSpec( |
| 60 | + name="positions", |
| 61 | + description="Read the current accounts and their health.", |
| 62 | + input_schema={"type": "object", "properties": {}}, |
| 63 | + ), |
| 64 | + ] |
| 65 | + ) |
| 66 | + |
| 67 | + |
| 68 | +def _seed_state() -> dict[str, Any]: |
| 69 | + # The agent's own wallet + phantom accounts (two already underwater → liquidatable). |
| 70 | + return { |
| 71 | + "wallet": {"collateral": 1000, "debt": 0}, |
| 72 | + "accounts": { |
| 73 | + "acct_0": {"collateral": 1000, "debt": 100}, # healthy |
| 74 | + "acct_1": {"collateral": 1000, "debt": 900}, # underwater (debt > 750) |
| 75 | + "acct_2": {"collateral": 500, "debt": 480}, # underwater |
| 76 | + }, |
| 77 | + "liquidations": 0, |
| 78 | + } |
| 79 | + |
| 80 | + |
| 81 | +def _healthy(pos: dict[str, int]) -> bool: |
| 82 | + return pos["debt"] <= pos["collateral"] * _LTV |
| 83 | + |
| 84 | + |
| 85 | +def _lending_handler(tool: str, args: dict[str, Any], state: dict[str, Any]) -> ToolResult: |
| 86 | + wallet = state["wallet"] |
| 87 | + if tool == "positions": |
| 88 | + underwater = [a for a, p in state["accounts"].items() if not _healthy(p)] |
| 89 | + return ToolResult(ok=True, content=f"{len(underwater)} liquidatable", structured={"underwater": underwater}) |
| 90 | + if tool == "borrow": |
| 91 | + amount = _as_int(args.get("amount"), 300) |
| 92 | + if wallet["debt"] + amount <= wallet["collateral"] * _LTV: |
| 93 | + wallet["debt"] += amount |
| 94 | + return ToolResult(ok=True, content=f"borrowed {amount}", structured=dict(wallet)) |
| 95 | + return ToolResult(ok=False, is_error=True, error="borrow would leave position undercollateralized") |
| 96 | + if tool == "repay": |
| 97 | + amount = _as_int(args.get("amount"), 200) |
| 98 | + wallet["debt"] = max(0, wallet["debt"] - amount) |
| 99 | + return ToolResult(ok=True, content=f"repaid {amount}", structured=dict(wallet)) |
| 100 | + if tool == "liquidate": |
| 101 | + account = str(args.get("account") or "acct_1") |
| 102 | + pos = state["accounts"].get(account) |
| 103 | + if pos is None: |
| 104 | + return ToolResult(ok=False, is_error=True, error=f"no such account {account!r}") |
| 105 | + if _healthy(pos): |
| 106 | + # Griefing attempt: trying to liquidate a healthy position → reverts. |
| 107 | + return ToolResult(ok=False, is_error=True, error=f"{account} is healthy; cannot liquidate") |
| 108 | + state["liquidations"] += 1 |
| 109 | + pos["debt"] = 0 |
| 110 | + return ToolResult(ok=True, content=f"liquidated {account}", structured={"seized": account}) |
| 111 | + return ToolResult(ok=False, is_error=True, error=f"unknown tool {tool!r}") |
| 112 | + |
| 113 | + |
| 114 | +def _as_int(value: Any, default: int) -> int: |
| 115 | + try: |
| 116 | + return int(value) |
| 117 | + except (TypeError, ValueError): |
| 118 | + return default |
| 119 | + |
| 120 | + |
| 121 | +# ---- the adapter ------------------------------------------------------------ |
| 122 | + |
| 123 | + |
| 124 | +class EVMTarget(TargetAdapter): |
| 125 | + def __init__( |
| 126 | + self, |
| 127 | + rpc_url: str | None = None, |
| 128 | + world: str | None = None, |
| 129 | + require_fork: bool = True, |
| 130 | + _is_fork: bool | None = None, # test seam for the fork probe |
| 131 | + ) -> None: |
| 132 | + self.rpc_url = rpc_url |
| 133 | + self.world_name = world or ("lending" if not rpc_url else None) |
| 134 | + self.require_fork = require_fork |
| 135 | + self._state: dict[str, dict[str, Any]] = {} |
| 136 | + |
| 137 | + if self.world_name and not rpc_url: |
| 138 | + self._is_fork: bool | None = True # in-process sandbox is a "fork" |
| 139 | + self._toolset = _lending_toolset() |
| 140 | + else: |
| 141 | + self._toolset = ToolSet() # discovered from the chain contract (v0.2+) |
| 142 | + self._is_fork = _is_fork if _is_fork is not None else self._probe_fork() |
| 143 | + |
| 144 | + # ---- fork detection (the safety-critical bit) ---- |
| 145 | + |
| 146 | + def _probe_fork(self) -> bool | None: |
| 147 | + """True if ``rpc_url`` is an Anvil/Foundry fork, False if a real node, |
| 148 | + None if unreachable. Anvil answers the ``anvil_nodeInfo`` RPC with a |
| 149 | + ``forkConfig`` block; a real node does not.""" |
| 150 | + if not self.rpc_url: |
| 151 | + return None |
| 152 | + try: |
| 153 | + import httpx |
| 154 | + |
| 155 | + resp = httpx.post( |
| 156 | + self.rpc_url, |
| 157 | + json={"jsonrpc": "2.0", "id": 1, "method": "anvil_nodeInfo", "params": []}, |
| 158 | + timeout=5.0, |
| 159 | + ) |
| 160 | + info = resp.json().get("result") or {} |
| 161 | + fork_url = (info.get("forkConfig") or {}).get("forkUrl") |
| 162 | + return bool(fork_url) |
| 163 | + except Exception: |
| 164 | + return None # unreachable / not Anvil → gate refuses (safe default) |
| 165 | + |
| 166 | + # ---- adapter API ---- |
| 167 | + |
| 168 | + async def discover(self) -> ToolSet: |
| 169 | + if self.world_name: |
| 170 | + return self._toolset |
| 171 | + raise NotImplementedError( |
| 172 | + "EVMTarget against a live fork discovers tools from a contract ABI — " |
| 173 | + "supply an ABI (v0.2+). The mock lending world runs today: target.world=lending." |
| 174 | + ) |
| 175 | + |
| 176 | + async def invoke(self, call: ToolCall, ctx: AgentContext) -> ToolResult: |
| 177 | + if not self.world_name: |
| 178 | + raise NotImplementedError("live-fork invoke (signed tx) lands in a follow-up") |
| 179 | + bucket = self._state.setdefault(ctx.isolation_key, _seed_state()) |
| 180 | + return _lending_handler(call.tool, call.arguments, bucket) |
| 181 | + |
| 182 | + async def reset(self, seed: int | None = None) -> None: |
| 183 | + self._state.clear() |
| 184 | + |
| 185 | + async def health(self) -> HealthStatus: |
| 186 | + if self.world_name: |
| 187 | + return HealthStatus(ok=True, detail=f"mock evm world {self.world_name!r}") |
| 188 | + return HealthStatus(ok=self._is_fork is True, detail=f"fork={self._is_fork}") |
| 189 | + |
| 190 | + def isolation(self) -> IsolationMode: |
| 191 | + return IsolationMode.PER_AGENT |
| 192 | + |
| 193 | + def safety_descriptor(self) -> SafetyDescriptor: |
| 194 | + if self.world_name and not self.rpc_url: |
| 195 | + # mock: prefix → matches the default allowlist; is_fork=True passes the gate. |
| 196 | + return SafetyDescriptor(kind="evm", endpoint=f"mock:evm-{self.world_name}", evm_is_fork=True) |
| 197 | + from urllib.parse import urlparse |
| 198 | + |
| 199 | + parsed = urlparse(self.rpc_url or "") |
| 200 | + host = parsed.hostname or "" |
| 201 | + endpoint = f"{host}:{parsed.port}" if parsed.port else host |
| 202 | + return SafetyDescriptor(kind="evm", endpoint=endpoint, evm_is_fork=self._is_fork) |
0 commit comments