Skip to content

Commit 16d4cad

Browse files
authored
Merge pull request #14 from swarmproof/feat/evm-target
feat: EVMTarget — wallet-swarm lending demo + anvil fork-guard
2 parents 9a9a71e + 56eddbe commit 16d4cad

4 files changed

Lines changed: 329 additions & 1 deletion

File tree

‎examples/evm_lending.yaml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Point a wallet-swarm at a lending protocol — the "open-Gauntlet" demo (FR-TA-04).
2+
# The mock world runs with zero infra: stampede run --dry-run -c examples/evm_lending.yaml
3+
target:
4+
type: evm
5+
world: lending # deterministic in-process lending pool (borrow/repay/liquidate)
6+
# For a REAL run against an Anvil fork instead (refuses non-fork mainnet RPCs):
7+
# rpc_url: "http://localhost:8545" # start with: anvil --fork-url <mainnet-rpc>
8+
9+
population:
10+
size: 50
11+
mix: { naive: 0.4, expert: 0.3, adversarial: 0.3 } # borrowers + liquidators + griefers
12+
models: [dry-run:heuristic]
13+
14+
concurrency: { curve: spike, peak: 50, hold: 10s } # a price-shock-style burst
15+
goals: { autogenerate: true, mode: template }
16+
seed: 42

‎src/stampede/targets/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,9 @@ def build_target(config: "object") -> TargetAdapter: # noqa: UP037
6464
transport=config.transport, command=config.command, url=config.url
6565
)
6666
if config.type == "evm":
67-
raise NotImplementedError("EVMTarget lands in v0.2 (FR-TA-04)")
67+
# The Safety Gate (config.safety.evm_require_fork) enforces the fork rule;
68+
# the target just reports whether it's a fork via its safety descriptor.
69+
from stampede.targets.evm import EVMTarget
70+
71+
return EVMTarget(rpc_url=config.rpc_url, world=config.world)
6872
raise ValueError(f"unknown target type: {config.type!r}")

‎src/stampede/targets/evm.py‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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)

‎tests/test_evm.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""EVMTarget — mock lending wallet-swarm + the fork-guard (FR-TA-04, ADR-6)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from stampede.config import SafetyConfig, StampedeConfig
8+
from stampede.run import run_simulation
9+
from stampede.targets.base import AgentContext, ToolCall
10+
from stampede.targets.evm import EVMTarget
11+
from stampede.targets.safety import SafetyGate, SafetyViolation
12+
13+
14+
def _ctx() -> AgentContext:
15+
return AgentContext(agent_id="a1", isolation_key="a1")
16+
17+
18+
async def _invoke(t: EVMTarget, tool: str, **args):
19+
return await t.invoke(ToolCall(tool=tool, arguments=args), _ctx())
20+
21+
22+
# ---- the mock lending world ----
23+
24+
25+
async def test_lending_toolset():
26+
ts = await EVMTarget(world="lending").discover()
27+
assert set(ts.names()) == {"borrow", "repay", "liquidate", "positions"}
28+
assert ts.get("liquidate").destructive
29+
30+
31+
async def test_borrow_respects_ltv():
32+
t = EVMTarget(world="lending")
33+
ok = await _invoke(t, "borrow", amount=300) # 300 <= 1000*0.75
34+
assert ok.ok and ok.structured["debt"] == 300
35+
over = await _invoke(t, "borrow", amount=600) # 300+600 > 750 → revert
36+
assert not over.ok and "undercollateralized" in over.error
37+
38+
39+
async def test_liquidate_underwater_but_not_healthy():
40+
t = EVMTarget(world="lending")
41+
good = await _invoke(t, "liquidate", account="acct_1") # underwater → seizable
42+
assert good.ok and good.structured["seized"] == "acct_1"
43+
grief = await _invoke(t, "liquidate", account="acct_0") # healthy → revert (griefing)
44+
assert not grief.ok and "healthy" in grief.error
45+
46+
47+
# ---- the fork-guard (ADR-6) ----
48+
49+
50+
def test_mock_evm_is_allowlisted_and_fork_ok():
51+
t = EVMTarget(world="lending")
52+
d = t.safety_descriptor()
53+
assert d.kind == "evm" and d.evm_is_fork is True and d.endpoint.startswith("mock:")
54+
SafetyGate(SafetyConfig()).check(d) # does not raise
55+
56+
57+
def test_non_fork_rpc_is_refused():
58+
t = EVMTarget(rpc_url="https://mainnet.example.com", _is_fork=False)
59+
with pytest.raises(SafetyViolation):
60+
SafetyGate(SafetyConfig()).check(t.safety_descriptor())
61+
62+
63+
def test_detected_fork_on_localhost_is_allowed():
64+
t = EVMTarget(rpc_url="http://localhost:8545", _is_fork=True)
65+
posture = SafetyGate(SafetyConfig()).check(t.safety_descriptor())
66+
assert posture.allowed
67+
68+
69+
def test_unreachable_rpc_treated_as_non_fork():
70+
t = EVMTarget(rpc_url="http://127.0.0.1:1/", _is_fork=None)
71+
with pytest.raises(SafetyViolation): # is_fork None → gate refuses
72+
SafetyGate(SafetyConfig()).check(t.safety_descriptor())
73+
74+
75+
# ---- end-to-end wallet-swarm (dry-run, no chain) ----
76+
77+
78+
async def test_wallet_swarm_runs_and_reports():
79+
cfg = StampedeConfig.from_dict(
80+
{
81+
"target": {"type": "evm", "world": "lending"},
82+
"population": {"size": 40, "mix": {"naive": 0.5, "expert": 0.3, "adversarial": 0.2}, "models": ["dry-run:heuristic"]},
83+
"seed": 42,
84+
}
85+
)
86+
result = await run_simulation(cfg, dry_run=True)
87+
d = result.report.to_dict()
88+
assert d["meta"]["safety_posture"] == "allowlisted"
89+
assert d["performance"]["tool_calls"] >= 40
90+
# The adversarial cohort reached the destructive liquidate path (griefing surface).
91+
assert d["adversarial"]["cohort_size"] > 0
92+
93+
94+
async def test_wallet_swarm_is_deterministic():
95+
def cfg():
96+
return StampedeConfig.from_dict(
97+
{
98+
"target": {"type": "evm", "world": "lending"},
99+
"population": {"size": 30, "mix": {"naive": 0.7, "adversarial": 0.3}, "models": ["dry-run:heuristic"]},
100+
"seed": 7,
101+
}
102+
)
103+
104+
a = (await run_simulation(cfg(), dry_run=True)).report.to_dict()
105+
b = (await run_simulation(cfg(), dry_run=True)).report.to_dict()
106+
assert a == b

0 commit comments

Comments
 (0)