arc-devkit 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- arc_devkit/__init__.py +2 -2
- arc_devkit/agents/__init__.py +5 -3
- arc_devkit/agents/async_base.py +29 -0
- arc_devkit/agents/async_monitor.py +316 -0
- arc_devkit/agents/base_agent.py +62 -36
- arc_devkit/agents/monitor_agent.py +234 -53
- arc_devkit/agents/payment_agent.py +207 -50
- arc_devkit/analytics/__init__.py +5 -0
- arc_devkit/analytics/portfolio.py +323 -0
- arc_devkit/api/main.py +185 -16
- arc_devkit/api/routes/agents.py +98 -27
- arc_devkit/api/routes/copilot.py +46 -11
- arc_devkit/api/routes/debugger.py +44 -12
- arc_devkit/cli/commands/agent.py +59 -57
- arc_devkit/cli/commands/copilot.py +29 -8
- arc_devkit/cli/commands/debug.py +35 -35
- arc_devkit/cli/flat.py +1095 -0
- arc_devkit/cli/main.py +21 -23
- arc_devkit/config.py +17 -24
- arc_devkit/contracts/__init__.py +10 -0
- arc_devkit/contracts/loader.py +163 -0
- arc_devkit/copilot/agent.py +189 -43
- arc_devkit/core/__init__.py +1 -1
- arc_devkit/core/connection.py +10 -12
- arc_devkit/core/gas.py +27 -25
- arc_devkit/core/wallet.py +17 -17
- arc_devkit/debugger/tx_analyzer.py +318 -56
- arc_devkit/deploy/__init__.py +5 -0
- arc_devkit/deploy/deployer.py +290 -0
- arc_devkit/events/__init__.py +5 -0
- arc_devkit/events/listener.py +199 -0
- arc_devkit/usdc/__init__.py +5 -0
- arc_devkit/usdc/token.py +244 -0
- arc_devkit-0.4.0.dist-info/METADATA +746 -0
- arc_devkit-0.4.0.dist-info/RECORD +44 -0
- {arc_devkit-0.2.0.dist-info → arc_devkit-0.4.0.dist-info}/entry_points.txt +1 -0
- arc_devkit-0.2.0.dist-info/METADATA +0 -238
- arc_devkit-0.2.0.dist-info/RECORD +0 -31
- {arc_devkit-0.2.0.dist-info → arc_devkit-0.4.0.dist-info}/WHEEL +0 -0
- {arc_devkit-0.2.0.dist-info → arc_devkit-0.4.0.dist-info}/top_level.txt +0 -0
arc_devkit/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
"""Arc DevKit —
|
|
1
|
+
"""Arc DevKit — Developer tools for the Arc blockchain by Circle."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.4.0"
|
arc_devkit/agents/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Agent Starter Kit — economic agent templates for Arc."""
|
|
2
2
|
|
|
3
|
+
from arc_devkit.agents.async_base import AsyncBaseAgent
|
|
4
|
+
from arc_devkit.agents.async_monitor import AsyncMonitorAgent
|
|
3
5
|
from arc_devkit.agents.base_agent import BaseAgent
|
|
4
|
-
from arc_devkit.agents.payment_agent import PaymentAgent
|
|
5
6
|
from arc_devkit.agents.monitor_agent import MonitorAgent
|
|
7
|
+
from arc_devkit.agents.payment_agent import PaymentAgent
|
|
6
8
|
|
|
7
|
-
__all__ = ["BaseAgent", "PaymentAgent", "MonitorAgent"]
|
|
9
|
+
__all__ = ["BaseAgent", "PaymentAgent", "MonitorAgent", "AsyncBaseAgent", "AsyncMonitorAgent"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Async abstract base class for Arc economic agents."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
|
|
7
|
+
from arc_devkit.agents.base_agent import BaseAgent
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncBaseAgent(BaseAgent):
|
|
13
|
+
"""
|
|
14
|
+
Async variant of BaseAgent — get_balance() and execute() are coroutines.
|
|
15
|
+
|
|
16
|
+
Inherits wallet resolution and RPC connection from BaseAgent.
|
|
17
|
+
Blocking web3 calls are dispatched to a thread pool via asyncio.to_thread()
|
|
18
|
+
so the event loop is never blocked.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@abstractmethod # type: ignore[override]
|
|
22
|
+
async def get_balance(self) -> dict: ...
|
|
23
|
+
|
|
24
|
+
@abstractmethod # type: ignore[override]
|
|
25
|
+
async def execute(self, **kwargs) -> dict: ...
|
|
26
|
+
|
|
27
|
+
async def _acall_rpc(self, fn, *args, **kwargs):
|
|
28
|
+
"""Run a blocking RPC call in a thread without blocking the event loop."""
|
|
29
|
+
return await asyncio.to_thread(fn, *args, **kwargs)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Async monitor agent — detects balance changes in Arc wallets (async variant)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator, Callable, Coroutine
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from eth_typing import ChecksumAddress
|
|
12
|
+
from web3 import Web3
|
|
13
|
+
|
|
14
|
+
from arc_devkit.agents.async_base import AsyncBaseAgent
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_TRANSFER_ABI = [
|
|
19
|
+
{
|
|
20
|
+
"anonymous": False,
|
|
21
|
+
"inputs": [
|
|
22
|
+
{"indexed": True, "name": "from", "type": "address"},
|
|
23
|
+
{"indexed": True, "name": "to", "type": "address"},
|
|
24
|
+
{"indexed": False, "name": "value", "type": "uint256"},
|
|
25
|
+
],
|
|
26
|
+
"name": "Transfer",
|
|
27
|
+
"type": "event",
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_WEBHOOK_TIMEOUT = 10
|
|
32
|
+
|
|
33
|
+
# Callback type: may be a plain function OR an async coroutine function
|
|
34
|
+
_Callback = Callable[[dict], Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncMonitorAgent(AsyncBaseAgent):
|
|
38
|
+
"""
|
|
39
|
+
Async version of MonitorAgent.
|
|
40
|
+
|
|
41
|
+
Uses asyncio.sleep() instead of time.sleep() so it integrates cleanly with
|
|
42
|
+
FastAPI WebSocket handlers and other async applications.
|
|
43
|
+
All balance reads are dispatched via _acall_rpc() to avoid blocking the loop.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
watched_address: str | None = None,
|
|
49
|
+
watched_addresses: list[str] | None = None,
|
|
50
|
+
interval_seconds: int = 15,
|
|
51
|
+
min_change_wei: int = 0,
|
|
52
|
+
state_file: str | Path | None = None,
|
|
53
|
+
usdc_contract_address: str | None = None,
|
|
54
|
+
webhook_url: str | None = None,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(**kwargs)
|
|
58
|
+
|
|
59
|
+
addrs: list[str] = []
|
|
60
|
+
if watched_addresses:
|
|
61
|
+
addrs = [Web3.to_checksum_address(a) for a in watched_addresses]
|
|
62
|
+
elif watched_address:
|
|
63
|
+
addrs = [Web3.to_checksum_address(watched_address)]
|
|
64
|
+
self._watched: list[str] = addrs
|
|
65
|
+
self._watched_lower: set[str] = {a.lower() for a in addrs}
|
|
66
|
+
|
|
67
|
+
self._interval = interval_seconds
|
|
68
|
+
self._min_change_wei = min_change_wei
|
|
69
|
+
self._last_balances: dict[str, int] = {}
|
|
70
|
+
self._last_erc20_block: int = 0
|
|
71
|
+
self._running = False
|
|
72
|
+
self._state_file = Path(state_file) if state_file else None
|
|
73
|
+
self._webhook_url = webhook_url
|
|
74
|
+
|
|
75
|
+
self._usdc_contract = None
|
|
76
|
+
if usdc_contract_address:
|
|
77
|
+
self._usdc_contract = self._w3.eth.contract(
|
|
78
|
+
address=Web3.to_checksum_address(usdc_contract_address),
|
|
79
|
+
abi=_TRANSFER_ABI,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if self._state_file and self._state_file.exists():
|
|
83
|
+
try:
|
|
84
|
+
saved = json.loads(self._state_file.read_text())
|
|
85
|
+
if "balances" in saved:
|
|
86
|
+
self._last_balances = {k: int(v) for k, v in saved["balances"].items()}
|
|
87
|
+
self._last_erc20_block = int(saved.get("last_erc20_block", 0))
|
|
88
|
+
else:
|
|
89
|
+
self._last_balances = {k: int(v) for k, v in saved.items()}
|
|
90
|
+
logger.info("State restored from %s", self._state_file)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.warning("Failed to restore state: %s", exc)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def watched_addresses(self) -> list[str]:
|
|
96
|
+
return list(self._watched)
|
|
97
|
+
|
|
98
|
+
def _save_state(self) -> None:
|
|
99
|
+
if self._state_file:
|
|
100
|
+
try:
|
|
101
|
+
self._state_file.write_text(
|
|
102
|
+
json.dumps({
|
|
103
|
+
"balances": {k: str(v) for k, v in self._last_balances.items()},
|
|
104
|
+
"last_erc20_block": self._last_erc20_block,
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
logger.warning("Failed to save state: %s", exc)
|
|
109
|
+
|
|
110
|
+
async def _fire_webhook(self, event: dict) -> None:
|
|
111
|
+
if not self._webhook_url:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT) as client:
|
|
115
|
+
resp = await client.post(
|
|
116
|
+
self._webhook_url,
|
|
117
|
+
json=event,
|
|
118
|
+
headers={"Content-Type": "application/json"},
|
|
119
|
+
)
|
|
120
|
+
resp.raise_for_status()
|
|
121
|
+
logger.debug("Webhook delivered: %s", resp.status_code)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
logger.warning("Webhook delivery failed (%s): %s", self._webhook_url, exc)
|
|
124
|
+
|
|
125
|
+
async def _emit(self, event: dict, callback: _Callback | None) -> None:
|
|
126
|
+
"""Fire callback (sync or async) and webhook."""
|
|
127
|
+
if callback:
|
|
128
|
+
result = callback(event)
|
|
129
|
+
if asyncio.iscoroutine(result):
|
|
130
|
+
await result
|
|
131
|
+
await self._fire_webhook(event)
|
|
132
|
+
|
|
133
|
+
async def _scan_erc20_events(
|
|
134
|
+
self,
|
|
135
|
+
from_block: int,
|
|
136
|
+
to_block: int,
|
|
137
|
+
callback: _Callback | None,
|
|
138
|
+
) -> None:
|
|
139
|
+
if not self._usdc_contract or not self._watched:
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
logs = await self._acall_rpc(
|
|
143
|
+
self._usdc_contract.events.Transfer.get_logs, # type: ignore[attr-defined]
|
|
144
|
+
from_block=from_block,
|
|
145
|
+
to_block=to_block,
|
|
146
|
+
)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
logger.debug("ERC-20 log query failed: %s", exc)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
for log in logs:
|
|
152
|
+
args = log.get("args", {})
|
|
153
|
+
sender = (args.get("from") or "").lower()
|
|
154
|
+
recipient = (args.get("to") or "").lower()
|
|
155
|
+
value = args.get("value", 0)
|
|
156
|
+
|
|
157
|
+
if sender not in self._watched_lower and recipient not in self._watched_lower:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
direction = "credit" if recipient in self._watched_lower else "debit"
|
|
161
|
+
address = next(
|
|
162
|
+
(a for a in self._watched if a.lower() == (recipient if direction == "credit" else sender)),
|
|
163
|
+
"",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
event = {
|
|
167
|
+
"address": address,
|
|
168
|
+
"event_type": "erc20_transfer",
|
|
169
|
+
"token": "USDC",
|
|
170
|
+
"from": args.get("from"),
|
|
171
|
+
"to": args.get("to"),
|
|
172
|
+
"value_atomic": str(value),
|
|
173
|
+
"tx_hash": log.get("transactionHash", b"").hex()
|
|
174
|
+
if hasattr(log.get("transactionHash", b""), "hex")
|
|
175
|
+
else str(log.get("transactionHash", "")),
|
|
176
|
+
"block": log.get("blockNumber"),
|
|
177
|
+
"type": direction,
|
|
178
|
+
}
|
|
179
|
+
self.log(f"[ERC-20] {direction} {value} atomic USDC → {address[:10]}")
|
|
180
|
+
await self._emit(event, callback)
|
|
181
|
+
|
|
182
|
+
async def get_balance(self) -> dict: # type: ignore[override]
|
|
183
|
+
resultado = {}
|
|
184
|
+
for addr in self._watched:
|
|
185
|
+
wei = await self._acall_rpc(
|
|
186
|
+
self._w3.eth.get_balance, cast(ChecksumAddress, addr)
|
|
187
|
+
)
|
|
188
|
+
resultado[addr] = {
|
|
189
|
+
"address": addr,
|
|
190
|
+
"balance_wei": str(wei),
|
|
191
|
+
"balance_eth": str(self._w3.from_wei(wei, "ether")),
|
|
192
|
+
}
|
|
193
|
+
return resultado
|
|
194
|
+
|
|
195
|
+
async def execute( # type: ignore[override]
|
|
196
|
+
self,
|
|
197
|
+
callback: _Callback | None = None,
|
|
198
|
+
max_iterations: int = 0,
|
|
199
|
+
) -> dict:
|
|
200
|
+
"""
|
|
201
|
+
Start the async wallet monitoring loop.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
callback: Called with an event dict on each detected change.
|
|
205
|
+
May be a plain function or an async coroutine function.
|
|
206
|
+
max_iterations: Maximum iterations (0 = infinite until stop()).
|
|
207
|
+
"""
|
|
208
|
+
self._running = True
|
|
209
|
+
|
|
210
|
+
for addr in self._watched:
|
|
211
|
+
if addr not in self._last_balances:
|
|
212
|
+
self._last_balances[addr] = await self._acall_rpc(
|
|
213
|
+
self._w3.eth.get_balance, cast(ChecksumAddress, addr)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if self._usdc_contract and self._last_erc20_block == 0:
|
|
217
|
+
try:
|
|
218
|
+
self._last_erc20_block = await self._acall_rpc(
|
|
219
|
+
lambda: self._w3.eth.block_number
|
|
220
|
+
)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
iterations = 0
|
|
225
|
+
self.log(f"[Async] Monitoring {len(self._watched)} wallet(s) every {self._interval}s")
|
|
226
|
+
|
|
227
|
+
while self._running:
|
|
228
|
+
for addr in self._watched:
|
|
229
|
+
current_balance = await self._acall_rpc(
|
|
230
|
+
self._w3.eth.get_balance, cast(ChecksumAddress, addr)
|
|
231
|
+
)
|
|
232
|
+
prev_balance = self._last_balances.get(addr, current_balance)
|
|
233
|
+
delta = current_balance - prev_balance
|
|
234
|
+
|
|
235
|
+
if delta != 0 and abs(delta) >= self._min_change_wei:
|
|
236
|
+
event = {
|
|
237
|
+
"address": addr,
|
|
238
|
+
"prev_balance_wei": str(prev_balance),
|
|
239
|
+
"balance_wei": str(current_balance),
|
|
240
|
+
"change_wei": str(delta),
|
|
241
|
+
"type": "credit" if delta > 0 else "debit",
|
|
242
|
+
"event_type": "native",
|
|
243
|
+
}
|
|
244
|
+
self.log(f"[{addr[:10]}] Change: {delta:+d} wei ({event['type']})")
|
|
245
|
+
await self._emit(event, callback)
|
|
246
|
+
self._last_balances[addr] = current_balance
|
|
247
|
+
|
|
248
|
+
if self._usdc_contract:
|
|
249
|
+
try:
|
|
250
|
+
current_block = await self._acall_rpc(lambda: self._w3.eth.block_number)
|
|
251
|
+
if current_block > self._last_erc20_block:
|
|
252
|
+
await self._scan_erc20_events(
|
|
253
|
+
from_block=self._last_erc20_block + 1,
|
|
254
|
+
to_block=current_block,
|
|
255
|
+
callback=callback,
|
|
256
|
+
)
|
|
257
|
+
self._last_erc20_block = current_block
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
logger.debug("ERC-20 scan skipped: %s", exc)
|
|
260
|
+
|
|
261
|
+
await asyncio.to_thread(self._save_state)
|
|
262
|
+
iterations += 1
|
|
263
|
+
if max_iterations and iterations >= max_iterations:
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
await asyncio.sleep(self._interval)
|
|
267
|
+
|
|
268
|
+
return {"status": "done", "iterations": iterations}
|
|
269
|
+
|
|
270
|
+
async def event_stream(
|
|
271
|
+
self,
|
|
272
|
+
max_events: int = 0,
|
|
273
|
+
) -> AsyncIterator[dict]:
|
|
274
|
+
"""
|
|
275
|
+
Async generator that yields events as they occur.
|
|
276
|
+
|
|
277
|
+
Starts execute() as a background task and forwards events via an
|
|
278
|
+
internal asyncio.Queue. Safe to use inside WebSocket handlers.
|
|
279
|
+
|
|
280
|
+
Usage:
|
|
281
|
+
async for event in monitor.event_stream():
|
|
282
|
+
await ws.send_json(event)
|
|
283
|
+
"""
|
|
284
|
+
queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
285
|
+
count = 0
|
|
286
|
+
|
|
287
|
+
async def _enqueue(event: dict) -> None:
|
|
288
|
+
await queue.put(event)
|
|
289
|
+
|
|
290
|
+
task = asyncio.create_task(self.execute(callback=_enqueue))
|
|
291
|
+
try:
|
|
292
|
+
while True:
|
|
293
|
+
# Drain remaining events after the task finishes
|
|
294
|
+
if task.done() and queue.empty():
|
|
295
|
+
break
|
|
296
|
+
try:
|
|
297
|
+
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
|
298
|
+
except asyncio.TimeoutError:
|
|
299
|
+
continue # re-check task.done() on next iteration
|
|
300
|
+
yield event
|
|
301
|
+
count += 1
|
|
302
|
+
if max_events and count >= max_events:
|
|
303
|
+
break
|
|
304
|
+
finally:
|
|
305
|
+
self.stop()
|
|
306
|
+
task.cancel()
|
|
307
|
+
try:
|
|
308
|
+
await task
|
|
309
|
+
except asyncio.CancelledError:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def stop(self) -> None:
|
|
313
|
+
"""Stop the monitoring loop on the next iteration."""
|
|
314
|
+
self._running = False
|
|
315
|
+
self._save_state()
|
|
316
|
+
self.log("Async monitoring stopped.")
|
arc_devkit/agents/base_agent.py
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Abstract base class for all Arc economic agents."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
|
|
6
6
|
from eth_account import Account
|
|
7
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
7
8
|
from web3 import Web3
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger(__name__)
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _make_web3(rpc_url: str) -> Web3:
|
|
14
|
+
from web3.middleware import ExtraDataToPOAMiddleware
|
|
15
|
+
|
|
16
|
+
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
|
|
17
|
+
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
18
|
+
return w3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_web3_with_fallback(rpc_urls: tuple[str, ...]) -> Web3:
|
|
22
|
+
"""Try each URL in order; raise ConnectionError if all fail."""
|
|
23
|
+
for url in rpc_urls:
|
|
24
|
+
try:
|
|
25
|
+
w3 = _make_web3(url)
|
|
26
|
+
if w3.is_connected():
|
|
27
|
+
return w3
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
logger.warning("RPC %s unavailable: %s", url, exc)
|
|
30
|
+
raise ConnectionError(f"No RPC available: {rpc_urls}")
|
|
31
|
+
|
|
32
|
+
|
|
12
33
|
class BaseAgent(ABC):
|
|
13
34
|
"""
|
|
14
|
-
|
|
35
|
+
Foundation for all Arc economic agents.
|
|
15
36
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
e execute() com a lógica específica de cada tipo de agente.
|
|
37
|
+
Manages blockchain connection, wallet identity, and logging helpers.
|
|
38
|
+
Subclasses implement get_balance() and execute() with agent-specific logic.
|
|
19
39
|
"""
|
|
20
40
|
|
|
21
41
|
def __init__(
|
|
@@ -24,28 +44,38 @@ class BaseAgent(ABC):
|
|
|
24
44
|
rpc_url: str | None = None,
|
|
25
45
|
) -> None:
|
|
26
46
|
"""
|
|
27
|
-
|
|
47
|
+
Initialize the agent with wallet and RPC connection.
|
|
28
48
|
|
|
29
49
|
Args:
|
|
30
|
-
private_key:
|
|
31
|
-
|
|
32
|
-
rpc_url:
|
|
50
|
+
private_key: Hex private key (optional). Falls back to ARC_PRIVATE_KEY
|
|
51
|
+
env var. Without a key the agent runs in read-only mode.
|
|
52
|
+
rpc_url: RPC node URL (optional). Falls back to ARC_RPC_URL.
|
|
53
|
+
Comma-separated list enables automatic failover.
|
|
33
54
|
"""
|
|
34
55
|
from arc_devkit.config import settings
|
|
35
56
|
from arc_devkit.core.connection import get_web3
|
|
36
57
|
|
|
37
|
-
#
|
|
38
|
-
|
|
58
|
+
# Multi-RPC support
|
|
59
|
+
if rpc_url:
|
|
60
|
+
# Explicit rpc_url — may be a comma-separated list
|
|
61
|
+
rpc_urls = tuple(u.strip() for u in rpc_url.split(",") if u.strip())
|
|
62
|
+
self._w3: Web3 = _get_web3_with_fallback(rpc_urls)
|
|
63
|
+
elif len(settings.arc_rpc_urls) > 1:
|
|
64
|
+
# Multiple RPCs configured — activate automatic fallback
|
|
65
|
+
self._w3 = _get_web3_with_fallback(settings.arc_rpc_urls)
|
|
66
|
+
else:
|
|
67
|
+
# Single RPC — use default get_web3() (mockable in tests)
|
|
68
|
+
self._w3 = get_web3()
|
|
39
69
|
|
|
40
|
-
#
|
|
41
|
-
|
|
70
|
+
# Resolve private key: argument > env var > None
|
|
71
|
+
_key = private_key or settings.arc_private_key
|
|
42
72
|
|
|
43
|
-
if
|
|
44
|
-
account = Account.from_key(
|
|
73
|
+
if _key:
|
|
74
|
+
account = Account.from_key(_key)
|
|
45
75
|
self._address: str | None = account.address
|
|
46
|
-
self._private_key: str | None =
|
|
76
|
+
self._private_key: str | None = _key
|
|
47
77
|
logger.info(
|
|
48
|
-
"[%s]
|
|
78
|
+
"[%s] Initialized with wallet %s",
|
|
49
79
|
self.__class__.__name__,
|
|
50
80
|
self._address,
|
|
51
81
|
)
|
|
@@ -53,35 +83,31 @@ class BaseAgent(ABC):
|
|
|
53
83
|
self._address = None
|
|
54
84
|
self._private_key = None
|
|
55
85
|
logger.warning(
|
|
56
|
-
"[%s]
|
|
86
|
+
"[%s] No private key — read-only mode.",
|
|
57
87
|
self.__class__.__name__,
|
|
58
88
|
)
|
|
59
89
|
|
|
60
90
|
@property
|
|
61
91
|
def wallet_address(self) -> str | None:
|
|
62
|
-
"""
|
|
92
|
+
"""Checksummed wallet address."""
|
|
63
93
|
return self._address
|
|
64
94
|
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"""
|
|
73
|
-
|
|
95
|
+
@retry(
|
|
96
|
+
retry=retry_if_exception_type((ConnectionError, TimeoutError, OSError)),
|
|
97
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
98
|
+
stop=stop_after_attempt(3),
|
|
99
|
+
reraise=True,
|
|
100
|
+
)
|
|
101
|
+
def _call_rpc(self, fn, *args, **kwargs):
|
|
102
|
+
"""Execute an RPC call with automatic retry on network failures."""
|
|
103
|
+
return fn(*args, **kwargs)
|
|
74
104
|
|
|
75
105
|
@abstractmethod
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
78
|
-
Executa a ação principal do agente.
|
|
106
|
+
def get_balance(self) -> dict: ...
|
|
79
107
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"""
|
|
83
|
-
...
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def execute(self, **kwargs) -> dict: ...
|
|
84
110
|
|
|
85
111
|
def log(self, msg: str) -> None:
|
|
86
|
-
"""
|
|
112
|
+
"""Standardized log helper: prefixes with class name."""
|
|
87
113
|
logger.info("[%s] %s", self.__class__.__name__, msg)
|