uagent-testkit 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/.gitignore +2 -0
  2. uagent_testkit-0.1.0/README.md → uagent_testkit-0.1.1/PKG-INFO +84 -4
  3. uagent_testkit-0.1.0/PKG-INFO → uagent_testkit-0.1.1/README.md +55 -31
  4. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/pyproject.toml +10 -7
  5. uagent_testkit-0.1.1/tests/test_chain.py +227 -0
  6. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/__init__.py +11 -1
  7. uagent_testkit-0.1.1/uagent_testkit/chain.py +383 -0
  8. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/plugin.py +14 -0
  9. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/.github/workflows/ci.yml +0 -0
  10. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/LICENSE +0 -0
  11. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/__init__.py +0 -0
  12. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/quote_agents.py +0 -0
  13. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/test_quote_agents.py +0 -0
  14. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/__init__.py +0 -0
  15. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/conftest.py +0 -0
  16. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_chat_protocol.py +0 -0
  17. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_harness.py +0 -0
  18. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_network.py +0 -0
  19. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_no_network.py +0 -0
  20. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_verification.py +0 -0
  21. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_loop.py +0 -0
  22. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_recording.py +0 -0
  23. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_storage.py +0 -0
  24. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/harness.py +0 -0
  25. {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/network.py +0 -0
@@ -10,3 +10,5 @@ agent_storage.json
10
10
  grant-application.md
11
11
  .env
12
12
  .env.*
13
+ .mcp.json
14
+ PASTE-SHEET.md
@@ -1,5 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: uagent-testkit
3
+ Version: 0.1.1
4
+ Summary: Test Fetch.ai uAgents offline — handlers, agent-to-agent flows, and BNB Chain reads.
5
+ Project-URL: Homepage, https://github.com/pgalyen1987/uagent-testkit
6
+ Project-URL: Repository, https://github.com/pgalyen1987/uagent-testkit
7
+ Project-URL: Issues, https://github.com/pgalyen1987/uagent-testkit/issues
8
+ Author-email: Patrick Galyen <pgalyen87@gmail.com>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agent-launch,agentverse,asi,bnb-chain,bsc,fetchai,pytest,testing,uagents,web3
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: uagents>=0.22
23
+ Provides-Extra: test
24
+ Requires-Dist: aiohttp>=3.9; extra == 'test'
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
26
+ Requires-Dist: pytest>=8.0; extra == 'test'
27
+ Requires-Dist: requests>=2.31; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
1
30
  # uagent-testkit
2
31
 
32
+ [![PyPI](https://img.shields.io/pypi/v/uagent-testkit.svg)](https://pypi.org/project/uagent-testkit/)
33
+ [![CI](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml/badge.svg)](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml)
34
+
3
35
  Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
4
36
 
5
37
  ```python
@@ -21,14 +53,17 @@ resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled
21
53
  the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
22
54
  run in CI.
23
55
 
24
- Worse, `Agent._handle_message` catches every exception a handler raises and writes it to
25
- a log. A handler that crashes on every message still looks like a green test. The most
26
- common agent bug is invisible to the most common agent test.
56
+ There is also a subtler problem. `Agent._handle_message` catches every exception a
57
+ handler raises and writes it to a log. That is the right call at runtime — an agent
58
+ processes messages from peers it does not control, and one malformed message must not
59
+ take the process down. But the same containment applies under pytest, and there is no
60
+ strict mode to turn it off, so a handler that crashes on every message still looks
61
+ like a green test.
27
62
 
28
63
  `uagent-testkit` routes messages straight to the registered handler with a context that
29
64
  records instead of transmitting. No sockets, no registration, no waiting.
30
65
 
31
- - **Handler exceptions are raised**, not swallowed into a log.
66
+ - **Handler exceptions are raised** rather than contained into a log.
32
67
  - **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
33
68
  stock `KeyValueStore` writes to your working directory.
34
69
  - **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
@@ -37,6 +72,8 @@ records instead of transmitting. No sockets, no registration, no waiting.
37
72
  is testable without waiting an hour.
38
73
  - **Multi-agent conversations replay deterministically**, with a loop guard instead of
39
74
  a hung test.
75
+ - **Agents that read BNB Chain are testable offline** — `ChainDouble` answers BEP-20
76
+ and balance queries from state your test sets, and blocks any other outbound HTTP.
40
77
 
41
78
  ## Install
42
79
 
@@ -178,6 +215,49 @@ async def test_chat():
178
215
  assert result.replies(ChatMessage)[0].text() == "here is what we have"
179
216
  ```
180
217
 
218
+ ## Agents that read BNB Chain
219
+
220
+ The harness intercepts `ctx.send`, which covers agent-to-agent messaging. It does not
221
+ cover a handler that reaches out to an RPC endpoint — an agent reading a BEP-20 balance
222
+ still talks to the network during a test. `ChainDouble` closes that gap:
223
+
224
+ ```python
225
+ from uagent_testkit import ChainDouble, FET_BSC, harness
226
+
227
+ async def test_treasury_agent():
228
+ chain = ChainDouble()
229
+ chain.add_token(FET_BSC, symbol="FET", decimals=18,
230
+ balances={wallet: 5_500_000_000_000_000_000})
231
+
232
+ h = harness(treasury_agent)
233
+ with chain.install():
234
+ result = await h.deliver(CheckBalance(wallet=wallet))
235
+
236
+ assert result.reply(BalanceReport).amount == 5.5
237
+ chain.assert_called("eth_call")
238
+ ```
239
+
240
+ It patches whichever of `requests`, `httpx` and `aiohttp` are importable, so it works
241
+ whether the agent uses web3.py or raw async HTTP, and it understands web3.py's
242
+ JSON-encoded `data=` body as well as `json=`.
243
+
244
+ Supported: `eth_call` (BEP-20 `balanceOf`, `symbol`, `name`, `decimals`, `totalSupply`),
245
+ `eth_getBalance`, `eth_getCode`, `eth_blockNumber`, `eth_chainId`, batched requests.
246
+
247
+ Two deliberate refusals, both of which turn a silent pass into a loud failure:
248
+
249
+ ```python
250
+ # reading state the test never set up
251
+ with pytest.raises(UnstubbedCall):
252
+ ... # rather than quietly returning zero
253
+
254
+ # any non-JSON-RPC HTTP from inside a handler
255
+ with pytest.raises(NetworkCallBlocked):
256
+ ... # rather than hitting a real API in CI
257
+ ```
258
+
259
+ There's also a `bsc` fixture that gives you an installed double directly.
260
+
181
261
  ## pytest fixtures
182
262
 
183
263
  Installing the package registers two factory fixtures:
@@ -1,32 +1,8 @@
1
- Metadata-Version: 2.4
2
- Name: uagent-testkit
3
- Version: 0.1.0
4
- Summary: Test Fetch.ai uAgents without a network, a wallet, or a sleep().
5
- Project-URL: Homepage, https://github.com/pgalyen1987/fetchai_harness
6
- Project-URL: Repository, https://github.com/pgalyen1987/fetchai_harness
7
- Project-URL: Issues, https://github.com/pgalyen1987/fetchai_harness/issues
8
- Author-email: pgalyen1987 <pgalyen87@gmail.com>
9
- License-Expression: Apache-2.0
10
- License-File: LICENSE
11
- Keywords: agentverse,asi,fetchai,pytest,testing,uagents
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Framework :: Pytest
14
- Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: Apache Software License
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Programming Language :: Python :: 3.13
20
- Classifier: Topic :: Software Development :: Testing
21
- Requires-Python: >=3.10
22
- Requires-Dist: uagents>=0.22
23
- Provides-Extra: test
24
- Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
25
- Requires-Dist: pytest>=8.0; extra == 'test'
26
- Description-Content-Type: text/markdown
27
-
28
1
  # uagent-testkit
29
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/uagent-testkit.svg)](https://pypi.org/project/uagent-testkit/)
4
+ [![CI](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml/badge.svg)](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml)
5
+
30
6
  Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
31
7
 
32
8
  ```python
@@ -48,14 +24,17 @@ resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled
48
24
  the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
49
25
  run in CI.
50
26
 
51
- Worse, `Agent._handle_message` catches every exception a handler raises and writes it to
52
- a log. A handler that crashes on every message still looks like a green test. The most
53
- common agent bug is invisible to the most common agent test.
27
+ There is also a subtler problem. `Agent._handle_message` catches every exception a
28
+ handler raises and writes it to a log. That is the right call at runtime — an agent
29
+ processes messages from peers it does not control, and one malformed message must not
30
+ take the process down. But the same containment applies under pytest, and there is no
31
+ strict mode to turn it off, so a handler that crashes on every message still looks
32
+ like a green test.
54
33
 
55
34
  `uagent-testkit` routes messages straight to the registered handler with a context that
56
35
  records instead of transmitting. No sockets, no registration, no waiting.
57
36
 
58
- - **Handler exceptions are raised**, not swallowed into a log.
37
+ - **Handler exceptions are raised** rather than contained into a log.
59
38
  - **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
60
39
  stock `KeyValueStore` writes to your working directory.
61
40
  - **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
@@ -64,6 +43,8 @@ records instead of transmitting. No sockets, no registration, no waiting.
64
43
  is testable without waiting an hour.
65
44
  - **Multi-agent conversations replay deterministically**, with a loop guard instead of
66
45
  a hung test.
46
+ - **Agents that read BNB Chain are testable offline** — `ChainDouble` answers BEP-20
47
+ and balance queries from state your test sets, and blocks any other outbound HTTP.
67
48
 
68
49
  ## Install
69
50
 
@@ -205,6 +186,49 @@ async def test_chat():
205
186
  assert result.replies(ChatMessage)[0].text() == "here is what we have"
206
187
  ```
207
188
 
189
+ ## Agents that read BNB Chain
190
+
191
+ The harness intercepts `ctx.send`, which covers agent-to-agent messaging. It does not
192
+ cover a handler that reaches out to an RPC endpoint — an agent reading a BEP-20 balance
193
+ still talks to the network during a test. `ChainDouble` closes that gap:
194
+
195
+ ```python
196
+ from uagent_testkit import ChainDouble, FET_BSC, harness
197
+
198
+ async def test_treasury_agent():
199
+ chain = ChainDouble()
200
+ chain.add_token(FET_BSC, symbol="FET", decimals=18,
201
+ balances={wallet: 5_500_000_000_000_000_000})
202
+
203
+ h = harness(treasury_agent)
204
+ with chain.install():
205
+ result = await h.deliver(CheckBalance(wallet=wallet))
206
+
207
+ assert result.reply(BalanceReport).amount == 5.5
208
+ chain.assert_called("eth_call")
209
+ ```
210
+
211
+ It patches whichever of `requests`, `httpx` and `aiohttp` are importable, so it works
212
+ whether the agent uses web3.py or raw async HTTP, and it understands web3.py's
213
+ JSON-encoded `data=` body as well as `json=`.
214
+
215
+ Supported: `eth_call` (BEP-20 `balanceOf`, `symbol`, `name`, `decimals`, `totalSupply`),
216
+ `eth_getBalance`, `eth_getCode`, `eth_blockNumber`, `eth_chainId`, batched requests.
217
+
218
+ Two deliberate refusals, both of which turn a silent pass into a loud failure:
219
+
220
+ ```python
221
+ # reading state the test never set up
222
+ with pytest.raises(UnstubbedCall):
223
+ ... # rather than quietly returning zero
224
+
225
+ # any non-JSON-RPC HTTP from inside a handler
226
+ with pytest.raises(NetworkCallBlocked):
227
+ ... # rather than hitting a real API in CI
228
+ ```
229
+
230
+ There's also a `bsc` fixture that gives you an installed double directly.
231
+
208
232
  ## pytest fixtures
209
233
 
210
234
  Installing the package registers two factory fixtures:
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "uagent-testkit"
7
- version = "0.1.0"
8
- description = "Test Fetch.ai uAgents without a network, a wallet, or a sleep()."
7
+ version = "0.1.1"
8
+ description = "Test Fetch.ai uAgents offline — handlers, agent-to-agent flows, and BNB Chain reads."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  requires-python = ">=3.10"
12
- keywords = ["fetchai", "uagents", "agentverse", "testing", "pytest", "asi"]
13
- authors = [{ name = "pgalyen1987", email = "pgalyen87@gmail.com" }]
12
+ keywords = ["fetchai", "uagents", "agentverse", "asi", "agent-launch", "bnb-chain", "bsc", "web3", "testing", "pytest"]
13
+ authors = [{ name = "Patrick Galyen", email = "pgalyen87@gmail.com" }]
14
14
  classifiers = [
15
15
  "Development Status :: 4 - Beta",
16
16
  "Framework :: Pytest",
@@ -31,12 +31,15 @@ dependencies = [
31
31
  test = [
32
32
  "pytest>=8.0",
33
33
  "pytest-asyncio>=0.24",
34
+ # exercised by the ChainDouble tests, which drive real HTTP clients
35
+ "requests>=2.31",
36
+ "aiohttp>=3.9",
34
37
  ]
35
38
 
36
39
  [project.urls]
37
- Homepage = "https://github.com/pgalyen1987/fetchai_harness"
38
- Repository = "https://github.com/pgalyen1987/fetchai_harness"
39
- Issues = "https://github.com/pgalyen1987/fetchai_harness/issues"
40
+ Homepage = "https://github.com/pgalyen1987/uagent-testkit"
41
+ Repository = "https://github.com/pgalyen1987/uagent-testkit"
42
+ Issues = "https://github.com/pgalyen1987/uagent-testkit/issues"
40
43
 
41
44
  [project.entry-points.pytest11]
42
45
  uagent_testkit = "uagent_testkit.plugin"
@@ -0,0 +1,227 @@
1
+ """Agents that read BNB Chain, tested offline.
2
+
3
+ These drive real HTTP clients (requests / aiohttp) so the patching is exercised the
4
+ way an actual agent would exercise it, not through a hand-rolled shim.
5
+ """
6
+
7
+ import pytest
8
+ from uagents import Agent, Context, Model
9
+
10
+ from uagent_testkit import (
11
+ FET_BSC,
12
+ ChainDouble,
13
+ NetworkCallBlocked,
14
+ UnstubbedCall,
15
+ harness,
16
+ )
17
+
18
+ RPC = "https://bsc-dataseed.binance.org/"
19
+ WALLET = "0x8E57BFDE053dBb6862991759c19affC5F383d5D0"
20
+
21
+
22
+ def _rpc(method, params):
23
+ import requests
24
+
25
+ return requests.Session().request(
26
+ "POST", RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
27
+ ).json()
28
+
29
+
30
+ class CheckBalance(Model):
31
+ wallet: str
32
+
33
+
34
+ class BalanceReport(Model):
35
+ symbol: str
36
+ amount: float
37
+
38
+
39
+ def build_treasury_agent(seed: str) -> Agent:
40
+ """An agent whose whole job is reading a BEP-20 balance off BNB Chain."""
41
+ agent = Agent(name="treasury", seed=seed)
42
+
43
+ @agent.on_message(model=CheckBalance, replies=BalanceReport)
44
+ async def on_check(ctx: Context, sender: str, msg: CheckBalance):
45
+ import requests
46
+
47
+ session = requests.Session()
48
+
49
+ def call(data):
50
+ return session.request(
51
+ "POST",
52
+ RPC,
53
+ json={
54
+ "jsonrpc": "2.0",
55
+ "id": 1,
56
+ "method": "eth_call",
57
+ "params": [{"to": FET_BSC, "data": data}, "latest"],
58
+ },
59
+ ).json()["result"]
60
+
61
+ raw = int(call("0x70a08231" + "0" * 24 + msg.wallet[2:].lower()), 16)
62
+ decimals = int(call("0x313ce567"), 16)
63
+ symbol_hex = call("0x95d89b41")
64
+ length = int(symbol_hex[2 + 64 : 2 + 128], 16)
65
+ symbol = bytes.fromhex(symbol_hex[2 + 128 : 2 + 128 + length * 2]).decode()
66
+
67
+ ctx.storage.set("last_wallet", msg.wallet)
68
+ await ctx.send(
69
+ sender, BalanceReport(symbol=symbol, amount=raw / 10**decimals)
70
+ )
71
+
72
+ return agent
73
+
74
+
75
+ async def test_agent_reads_a_bep20_balance_offline():
76
+ chain = ChainDouble()
77
+ chain.add_token(
78
+ FET_BSC, symbol="FET", decimals=18, balances={WALLET: 5_500_000_000_000_000_000}
79
+ )
80
+ h = harness(build_treasury_agent("treasury seed one"))
81
+
82
+ with chain.install():
83
+ result = await h.deliver(CheckBalance(wallet=WALLET))
84
+
85
+ report = result.reply(BalanceReport)
86
+ assert report.symbol == "FET"
87
+ assert report.amount == 5.5
88
+ chain.assert_called("eth_call")
89
+
90
+
91
+ async def test_native_bnb_balance():
92
+ chain = ChainDouble().set_balance(WALLET, 2 * 10**18)
93
+
94
+ with chain.install():
95
+ got = _rpc("eth_getBalance", [WALLET, "latest"])
96
+
97
+ assert int(got["result"], 16) == 2 * 10**18
98
+
99
+
100
+ async def test_chain_metadata():
101
+ chain = ChainDouble().set_block_number(41_234_567)
102
+
103
+ with chain.install():
104
+ assert int(_rpc("eth_chainId", [])["result"], 16) == 56
105
+ assert int(_rpc("eth_blockNumber", [])["result"], 16) == 41_234_567
106
+
107
+
108
+ async def test_unstubbed_balance_raises_rather_than_returning_zero():
109
+ """A silent zero would let a test pass on data nobody set up."""
110
+ chain = ChainDouble()
111
+
112
+ with chain.install():
113
+ with pytest.raises(UnstubbedCall, match="never set"):
114
+ _rpc("eth_getBalance", [WALLET, "latest"])
115
+
116
+
117
+ async def test_unregistered_contract_raises():
118
+ chain = ChainDouble()
119
+
120
+ with chain.install():
121
+ with pytest.raises(UnstubbedCall, match="never registered"):
122
+ _rpc("eth_call", [{"to": FET_BSC, "data": "0x313ce567"}, "latest"])
123
+
124
+
125
+ async def test_unstubbed_holder_balance_raises():
126
+ chain = ChainDouble()
127
+ chain.add_token(FET_BSC, symbol="FET", balances={})
128
+
129
+ with chain.install():
130
+ with pytest.raises(UnstubbedCall, match="balance of"):
131
+ _rpc(
132
+ "eth_call",
133
+ [
134
+ {"to": FET_BSC, "data": "0x70a08231" + "0" * 24 + WALLET[2:].lower()},
135
+ "latest",
136
+ ],
137
+ )
138
+
139
+
140
+ async def test_non_rpc_http_is_blocked():
141
+ """An agent calling some other API mid-handler should fail the test, not the CI run."""
142
+ import requests
143
+
144
+ chain = ChainDouble()
145
+
146
+ with chain.install():
147
+ with pytest.raises(NetworkCallBlocked, match="non-JSON-RPC"):
148
+ requests.Session().request("GET", "https://example.com/prices")
149
+
150
+
151
+ async def test_web3_style_data_body_is_understood():
152
+ """web3.py posts a JSON-encoded body via data=, not json=."""
153
+ import json
154
+
155
+ import requests
156
+
157
+ chain = ChainDouble().set_balance(WALLET, 7)
158
+
159
+ with chain.install():
160
+ resp = requests.Session().request(
161
+ "POST",
162
+ RPC,
163
+ data=json.dumps(
164
+ {"jsonrpc": "2.0", "id": 1, "method": "eth_getBalance",
165
+ "params": [WALLET, "latest"]}
166
+ ),
167
+ )
168
+
169
+ assert int(resp.json()["result"], 16) == 7
170
+
171
+
172
+ async def test_batch_requests():
173
+ chain = ChainDouble().set_balance(WALLET, 3)
174
+
175
+ with chain.install():
176
+ import requests
177
+
178
+ resp = requests.Session().request(
179
+ "POST",
180
+ RPC,
181
+ json=[
182
+ {"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []},
183
+ {"jsonrpc": "2.0", "id": 2, "method": "eth_getBalance",
184
+ "params": [WALLET, "latest"]},
185
+ ],
186
+ )
187
+
188
+ body = resp.json()
189
+ assert len(body) == 2
190
+ assert int(body[1]["result"], 16) == 3
191
+
192
+
193
+ async def test_aiohttp_client_is_patched():
194
+ import aiohttp
195
+
196
+ chain = ChainDouble().set_balance(WALLET, 11)
197
+
198
+ with chain.install():
199
+ async with aiohttp.ClientSession() as session:
200
+ async with session.post(
201
+ RPC,
202
+ json={"jsonrpc": "2.0", "id": 1, "method": "eth_getBalance",
203
+ "params": [WALLET, "latest"]},
204
+ ) as resp:
205
+ body = await resp.json()
206
+
207
+ assert int(body["result"], 16) == 11
208
+
209
+
210
+ async def test_patches_are_removed_on_exit():
211
+ """Leaving the context must restore the real client, or later tests get poisoned."""
212
+ import requests
213
+
214
+ original = requests.Session.request
215
+ with ChainDouble().install():
216
+ assert requests.Session.request is not original
217
+ assert requests.Session.request is original
218
+
219
+
220
+ async def test_assert_no_calls():
221
+ chain = ChainDouble()
222
+ with chain.install():
223
+ pass
224
+ chain.assert_no_calls()
225
+
226
+ with pytest.raises(AssertionError, match="expected a eth_call call"):
227
+ chain.assert_called("eth_call")
@@ -10,6 +10,12 @@
10
10
  from ._loop import ensure_event_loop
11
11
  from ._recording import Sent
12
12
  from ._storage import InMemoryStore
13
+ from .chain import (
14
+ FET_BSC,
15
+ ChainDouble,
16
+ NetworkCallBlocked,
17
+ UnstubbedCall,
18
+ )
13
19
  from .harness import (
14
20
  ANY,
15
21
  DEFAULT_SENDER,
@@ -31,18 +37,22 @@ __all__ = [
31
37
  "ANY",
32
38
  "AgentHarness",
33
39
  "AgentNetwork",
40
+ "ChainDouble",
34
41
  "ConversationTooLong",
35
42
  "DEFAULT_SENDER",
36
43
  "Delivery",
37
44
  "Exchange",
45
+ "FET_BSC",
38
46
  "HandlerNotFound",
39
47
  "InMemoryStore",
48
+ "NetworkCallBlocked",
40
49
  "Sent",
41
50
  "Transcript",
51
+ "UnstubbedCall",
42
52
  "UnverifiedSender",
43
53
  "ensure_event_loop",
44
54
  "harness",
45
55
  "user_sender",
46
56
  ]
47
57
 
48
- __version__ = "0.1.0"
58
+ __version__ = "0.1.1"
@@ -0,0 +1,383 @@
1
+ """An offline stand-in for BNB Chain, for agents that read on-chain state.
2
+
3
+ The rest of this package intercepts `ctx.send`, which covers agent-to-agent messaging.
4
+ It does not cover a handler that reaches out to an RPC endpoint — an agent that reads a
5
+ BEP-20 balance, a token's symbol, or a wallet's BNB balance still talks to the network
6
+ during a test. That makes those tests slow, rate-limited, dependent on mainnet state
7
+ that changes underneath them, and impossible to run in CI offline.
8
+
9
+ `ChainDouble` blocks outbound HTTP from handlers and answers JSON-RPC from state the
10
+ test sets up, so an agent that reads BNB Chain becomes as testable as one that doesn't:
11
+
12
+ chain = ChainDouble()
13
+ chain.add_token(FET, symbol="FET", decimals=18, balances={wallet: 5 * 10**18})
14
+
15
+ with chain.install():
16
+ result = await h.deliver(CheckBalance(wallet=wallet))
17
+
18
+ assert result.reply(BalanceReport).amount == 5.0
19
+ chain.assert_called("eth_call")
20
+
21
+ Anything the double has not been told about raises rather than silently returning zero,
22
+ so a test cannot pass on data that was never stubbed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from contextlib import ExitStack, contextmanager
28
+ from typing import Any, Iterator
29
+
30
+ #: BEP-20 / ERC-20 selectors. Stable across every token that implements the standard.
31
+ SELECTOR_BALANCE_OF = "70a08231"
32
+ SELECTOR_SYMBOL = "95d89b41"
33
+ SELECTOR_DECIMALS = "313ce567"
34
+ SELECTOR_TOTAL_SUPPLY = "18160ddd"
35
+ SELECTOR_NAME = "06fdde03"
36
+
37
+ #: FET on BNB Chain — the asset Agent Launch grants are paid in.
38
+ FET_BSC = "0x031b41e504677879370e9dbcf937283a8691fa7f"
39
+
40
+ BSC_CHAIN_ID = 56
41
+
42
+
43
+ class NetworkCallBlocked(AssertionError):
44
+ """Raised when a handler tries to reach the network during a test."""
45
+
46
+
47
+ class UnstubbedCall(AssertionError):
48
+ """Raised when a handler asks the chain for something the test never set up."""
49
+
50
+
51
+ def _norm(address: str) -> str:
52
+ return address.lower()
53
+
54
+
55
+ def _enc_uint(value: int) -> str:
56
+ return f"{value:064x}"
57
+
58
+
59
+ def _enc_string(value: str) -> str:
60
+ raw = value.encode()
61
+ padded = raw + b"\x00" * ((32 - len(raw) % 32) % 32)
62
+ return _enc_uint(32) + _enc_uint(len(raw)) + padded.hex()
63
+
64
+
65
+ class _Token:
66
+ def __init__(
67
+ self,
68
+ symbol: str,
69
+ decimals: int,
70
+ total_supply: int,
71
+ balances: dict[str, int],
72
+ name: str | None,
73
+ ):
74
+ self.symbol = symbol
75
+ self.decimals = decimals
76
+ self.total_supply = total_supply
77
+ self.balances = {_norm(k): v for k, v in balances.items()}
78
+ self.name = name if name is not None else symbol
79
+
80
+
81
+ class ChainDouble:
82
+ """A stubbable JSON-RPC endpoint standing in for BNB Chain."""
83
+
84
+ def __init__(self, *, chain_id: int = BSC_CHAIN_ID, block_number: int = 40_000_000):
85
+ self.chain_id = chain_id
86
+ self.block_number = block_number
87
+ self._balances: dict[str, int] = {}
88
+ self._tokens: dict[str, _Token] = {}
89
+ self._code: dict[str, str] = {}
90
+ #: Every JSON-RPC method the agent asked for, in order.
91
+ self.calls: list[tuple[str, Any]] = []
92
+
93
+ # -- setting up state ------------------------------------------------------
94
+
95
+ def set_balance(self, address: str, wei: int) -> "ChainDouble":
96
+ """Set a wallet's native BNB balance, in wei."""
97
+ self._balances[_norm(address)] = wei
98
+ return self
99
+
100
+ def add_token(
101
+ self,
102
+ address: str,
103
+ *,
104
+ symbol: str,
105
+ decimals: int = 18,
106
+ total_supply: int = 0,
107
+ balances: dict[str, int] | None = None,
108
+ name: str | None = None,
109
+ ) -> "ChainDouble":
110
+ """Register a BEP-20 token and the balances it should report."""
111
+ self._tokens[_norm(address)] = _Token(
112
+ symbol, decimals, total_supply, balances or {}, name
113
+ )
114
+ self._code.setdefault(_norm(address), "0x60806040")
115
+ return self
116
+
117
+ def set_block_number(self, n: int) -> "ChainDouble":
118
+ self.block_number = n
119
+ return self
120
+
121
+ # -- assertions ------------------------------------------------------------
122
+
123
+ def assert_called(self, method: str) -> "ChainDouble":
124
+ if not any(m == method for m, _ in self.calls):
125
+ raise AssertionError(
126
+ f"expected a {method} call; got {[m for m, _ in self.calls] or 'none'}"
127
+ )
128
+ return self
129
+
130
+ def assert_no_calls(self) -> "ChainDouble":
131
+ if self.calls:
132
+ raise AssertionError(f"expected no chain access, got {self.calls}")
133
+ return self
134
+
135
+ # -- JSON-RPC --------------------------------------------------------------
136
+
137
+ def rpc(self, payload: Any) -> Any:
138
+ """Answer a single JSON-RPC request or a batch of them."""
139
+ if isinstance(payload, list):
140
+ return [self._one(p) for p in payload]
141
+ return self._one(payload)
142
+
143
+ def _one(self, payload: dict) -> dict:
144
+ method = payload.get("method")
145
+ params = payload.get("params") or []
146
+ self.calls.append((method, params))
147
+ return {
148
+ "jsonrpc": "2.0",
149
+ "id": payload.get("id", 1),
150
+ "result": self._dispatch(method, params),
151
+ }
152
+
153
+ def _dispatch(self, method: str, params: list) -> Any:
154
+ if method == "eth_chainId":
155
+ return hex(self.chain_id)
156
+ if method == "eth_blockNumber":
157
+ return hex(self.block_number)
158
+ if method == "net_version":
159
+ return str(self.chain_id)
160
+ if method == "eth_getBalance":
161
+ address = _norm(params[0])
162
+ if address not in self._balances:
163
+ raise UnstubbedCall(
164
+ f"handler read the BNB balance of {params[0]}, which the test never "
165
+ f"set. Call chain.set_balance({params[0]!r}, wei) first."
166
+ )
167
+ return hex(self._balances[address])
168
+ if method == "eth_getCode":
169
+ return self._code.get(_norm(params[0]), "0x")
170
+ if method == "eth_getTransactionCount":
171
+ return "0x0"
172
+ if method == "eth_call":
173
+ return self._eth_call(params[0])
174
+ raise UnstubbedCall(
175
+ f"handler called {method}, which ChainDouble does not implement. "
176
+ f"Supported: eth_call, eth_getBalance, eth_getCode, eth_blockNumber, "
177
+ f"eth_chainId, eth_getTransactionCount, net_version."
178
+ )
179
+
180
+ def _eth_call(self, call: dict) -> str:
181
+ to = _norm(call.get("to", ""))
182
+ data = (call.get("data") or call.get("input") or "").removeprefix("0x")
183
+ selector, args = data[:8], data[8:]
184
+
185
+ token = self._tokens.get(to)
186
+ if token is None:
187
+ raise UnstubbedCall(
188
+ f"handler called contract {call.get('to')}, which the test never "
189
+ f"registered. Call chain.add_token({call.get('to')!r}, symbol=...) first."
190
+ )
191
+
192
+ if selector == SELECTOR_BALANCE_OF:
193
+ holder = "0x" + args[24:64]
194
+ if holder not in token.balances:
195
+ raise UnstubbedCall(
196
+ f"handler read the {token.symbol} balance of {holder}, which the "
197
+ f"test never set. Add it to the balances= of add_token()."
198
+ )
199
+ return "0x" + _enc_uint(token.balances[holder])
200
+ if selector == SELECTOR_DECIMALS:
201
+ return "0x" + _enc_uint(token.decimals)
202
+ if selector == SELECTOR_TOTAL_SUPPLY:
203
+ return "0x" + _enc_uint(token.total_supply)
204
+ if selector == SELECTOR_SYMBOL:
205
+ return "0x" + _enc_string(token.symbol)
206
+ if selector == SELECTOR_NAME:
207
+ return "0x" + _enc_string(token.name)
208
+ raise UnstubbedCall(
209
+ f"handler called selector 0x{selector} on {call.get('to')}; ChainDouble "
210
+ f"implements balanceOf, symbol, name, decimals and totalSupply."
211
+ )
212
+
213
+ # -- installing over the HTTP clients --------------------------------------
214
+
215
+ @contextmanager
216
+ def install(self) -> Iterator["ChainDouble"]:
217
+ """Route HTTP from handlers here, and block anything that isn't JSON-RPC.
218
+
219
+ Patches whichever of requests / httpx / aiohttp are importable, so this works
220
+ whether the agent uses web3.py (requests) or raw async HTTP.
221
+ """
222
+ with ExitStack() as stack:
223
+ for patch in (self._patch_requests, self._patch_httpx, self._patch_aiohttp):
224
+ ctx = patch()
225
+ if ctx is not None:
226
+ stack.enter_context(ctx)
227
+ yield self
228
+
229
+ def _payload_or_block(self, url: str, json_body: Any, data: Any = None) -> Any:
230
+ """Resolve a request body to a JSON-RPC payload, or refuse it.
231
+
232
+ web3.py's HTTPProvider posts a JSON-encoded body via `data=` rather than
233
+ `json=`, so both have to be understood.
234
+ """
235
+ payload = json_body
236
+ if payload is None and data is not None:
237
+ import json as _json
238
+
239
+ if isinstance(data, (bytes, bytearray)):
240
+ data = data.decode()
241
+ if isinstance(data, str):
242
+ try:
243
+ payload = _json.loads(data)
244
+ except ValueError:
245
+ payload = None
246
+ if payload is None:
247
+ raise NetworkCallBlocked(
248
+ f"handler made a non-JSON-RPC HTTP request to {url} during a test. "
249
+ f"Stub it, or assert the handler does not call out."
250
+ )
251
+ return self.rpc(payload)
252
+
253
+ def _patch_requests(self):
254
+ try:
255
+ import requests
256
+ except ImportError:
257
+ return None
258
+
259
+ double = self
260
+
261
+ class _Response:
262
+ def __init__(self, payload):
263
+ self._payload = payload
264
+ self.status_code = 200
265
+ self.reason = "OK"
266
+ self.headers = {"Content-Type": "application/json"}
267
+
268
+ def json(self):
269
+ return self._payload
270
+
271
+ @property
272
+ def content(self):
273
+ import json
274
+
275
+ return json.dumps(self._payload).encode()
276
+
277
+ @property
278
+ def text(self):
279
+ return self.content.decode()
280
+
281
+ def raise_for_status(self):
282
+ return None
283
+
284
+ @contextmanager
285
+ def _ctx():
286
+ original = requests.Session.request
287
+
288
+ def patched(self, method, url, *args, json=None, data=None, **kwargs):
289
+ return _Response(double._payload_or_block(url, json, data))
290
+
291
+ requests.Session.request = patched
292
+ try:
293
+ yield
294
+ finally:
295
+ requests.Session.request = original
296
+
297
+ return _ctx()
298
+
299
+ def _patch_httpx(self):
300
+ try:
301
+ import httpx
302
+ except ImportError:
303
+ return None
304
+
305
+ double = self
306
+
307
+ @contextmanager
308
+ def _ctx():
309
+ original = httpx.Client.request
310
+ original_async = httpx.AsyncClient.request
311
+
312
+ def _resp(url, json_body, data):
313
+ return httpx.Response(
314
+ 200, json=double._payload_or_block(str(url), json_body, data)
315
+ )
316
+
317
+ def patched(self, method, url, *args, json=None, content=None, **kwargs):
318
+ return _resp(url, json, content)
319
+
320
+ async def patched_async(
321
+ self, method, url, *args, json=None, content=None, **kwargs
322
+ ):
323
+ return _resp(url, json, content)
324
+
325
+ httpx.Client.request = patched
326
+ httpx.AsyncClient.request = patched_async
327
+ try:
328
+ yield
329
+ finally:
330
+ httpx.Client.request = original
331
+ httpx.AsyncClient.request = original_async
332
+
333
+ return _ctx()
334
+
335
+ def _patch_aiohttp(self):
336
+ try:
337
+ import aiohttp
338
+ except ImportError:
339
+ return None
340
+
341
+ double = self
342
+
343
+ class _Response:
344
+ def __init__(self, payload):
345
+ self._payload = payload
346
+ self.status = 200
347
+
348
+ async def json(self, **_kwargs):
349
+ return self._payload
350
+
351
+ async def text(self):
352
+ import json
353
+
354
+ return json.dumps(self._payload)
355
+
356
+ def raise_for_status(self):
357
+ return None
358
+
359
+ async def __aenter__(self):
360
+ return self
361
+
362
+ async def __aexit__(self, *exc):
363
+ return False
364
+
365
+ async def release(self):
366
+ return None
367
+
368
+ @contextmanager
369
+ def _ctx():
370
+ original = aiohttp.ClientSession._request
371
+
372
+ # _request is a coroutine function, and session.post() wraps its result
373
+ # in _RequestContextManager, which awaits it. It must stay awaitable.
374
+ async def patched(self, method, url, *args, json=None, data=None, **kwargs):
375
+ return _Response(double._payload_or_block(str(url), json, data))
376
+
377
+ aiohttp.ClientSession._request = patched
378
+ try:
379
+ yield
380
+ finally:
381
+ aiohttp.ClientSession._request = original
382
+
383
+ return _ctx()
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import pytest
6
6
 
7
7
  from ._loop import ensure_event_loop
8
+ from .chain import ChainDouble
8
9
  from .harness import AgentHarness, harness as _harness
9
10
  from .network import AgentNetwork
10
11
 
@@ -43,6 +44,19 @@ def agent_harness():
43
44
  return _factory
44
45
 
45
46
 
47
+ @pytest.fixture
48
+ def bsc():
49
+ """An installed ChainDouble standing in for BNB Chain.
50
+
51
+ def test_reads_balance(bsc):
52
+ bsc.add_token(FET_BSC, symbol="FET", balances={wallet: 10**18})
53
+ ...
54
+ """
55
+ double = ChainDouble()
56
+ with double.install():
57
+ yield double
58
+
59
+
46
60
  @pytest.fixture
47
61
  def agent_network():
48
62
  """Factory fixture building an AgentNetwork from agents or harnesses.
File without changes