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.
Files changed (40) hide show
  1. arc_devkit/__init__.py +2 -2
  2. arc_devkit/agents/__init__.py +5 -3
  3. arc_devkit/agents/async_base.py +29 -0
  4. arc_devkit/agents/async_monitor.py +316 -0
  5. arc_devkit/agents/base_agent.py +62 -36
  6. arc_devkit/agents/monitor_agent.py +234 -53
  7. arc_devkit/agents/payment_agent.py +207 -50
  8. arc_devkit/analytics/__init__.py +5 -0
  9. arc_devkit/analytics/portfolio.py +323 -0
  10. arc_devkit/api/main.py +185 -16
  11. arc_devkit/api/routes/agents.py +98 -27
  12. arc_devkit/api/routes/copilot.py +46 -11
  13. arc_devkit/api/routes/debugger.py +44 -12
  14. arc_devkit/cli/commands/agent.py +59 -57
  15. arc_devkit/cli/commands/copilot.py +29 -8
  16. arc_devkit/cli/commands/debug.py +35 -35
  17. arc_devkit/cli/flat.py +1095 -0
  18. arc_devkit/cli/main.py +21 -23
  19. arc_devkit/config.py +17 -24
  20. arc_devkit/contracts/__init__.py +10 -0
  21. arc_devkit/contracts/loader.py +163 -0
  22. arc_devkit/copilot/agent.py +189 -43
  23. arc_devkit/core/__init__.py +1 -1
  24. arc_devkit/core/connection.py +10 -12
  25. arc_devkit/core/gas.py +27 -25
  26. arc_devkit/core/wallet.py +17 -17
  27. arc_devkit/debugger/tx_analyzer.py +318 -56
  28. arc_devkit/deploy/__init__.py +5 -0
  29. arc_devkit/deploy/deployer.py +290 -0
  30. arc_devkit/events/__init__.py +5 -0
  31. arc_devkit/events/listener.py +199 -0
  32. arc_devkit/usdc/__init__.py +5 -0
  33. arc_devkit/usdc/token.py +244 -0
  34. arc_devkit-0.4.0.dist-info/METADATA +746 -0
  35. arc_devkit-0.4.0.dist-info/RECORD +44 -0
  36. {arc_devkit-0.2.0.dist-info → arc_devkit-0.4.0.dist-info}/entry_points.txt +1 -0
  37. arc_devkit-0.2.0.dist-info/METADATA +0 -238
  38. arc_devkit-0.2.0.dist-info/RECORD +0 -31
  39. {arc_devkit-0.2.0.dist-info → arc_devkit-0.4.0.dist-info}/WHEEL +0 -0
  40. {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 — Ferramentas para desenvolvedores da Arc blockchain (Circle)."""
1
+ """Arc DevKit — Developer tools for the Arc blockchain by Circle."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.4.0"
@@ -1,7 +1,9 @@
1
- """Módulo Agent Starter Kit — templates de agentes econômicos Arc."""
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.")
@@ -1,21 +1,41 @@
1
- """Classe base abstrata para todos os agentes econômicos Arc."""
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
- Fundação para todos os agentes econômicos Arc.
35
+ Foundation for all Arc economic agents.
15
36
 
16
- Gerencia a conexão com a blockchain, a identidade da carteira
17
- e fornece helpers de logging. Subclasses implementam get_balance()
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
- Inicializa o agente com carteira e conexão RPC.
47
+ Initialize the agent with wallet and RPC connection.
28
48
 
29
49
  Args:
30
- private_key: Chave privada hex (opcional). Se omitida, usa
31
- ARC_PRIVATE_KEY do ambiente. Sem chave = modo leitura.
32
- rpc_url: URL do RPC (opcional). Se omitida, usa ARC_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
- # Configurar conexão com a Arc
38
- self._w3: Web3 = get_web3()
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
- # Resolver chave privada: argumento > variável de ambiente > None
41
- _chave = private_key or settings.arc_private_key
70
+ # Resolve private key: argument > env var > None
71
+ _key = private_key or settings.arc_private_key
42
72
 
43
- if _chave:
44
- account = Account.from_key(_chave)
73
+ if _key:
74
+ account = Account.from_key(_key)
45
75
  self._address: str | None = account.address
46
- self._private_key: str | None = _chave
76
+ self._private_key: str | None = _key
47
77
  logger.info(
48
- "[%s] Inicializado com carteira %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] Sem chave privadamodo somente leitura.",
86
+ "[%s] No private keyread-only mode.",
57
87
  self.__class__.__name__,
58
88
  )
59
89
 
60
90
  @property
61
91
  def wallet_address(self) -> str | None:
62
- """Endereço checksummed da carteira do agente."""
92
+ """Checksummed wallet address."""
63
93
  return self._address
64
94
 
65
- @abstractmethod
66
- def get_balance(self) -> dict:
67
- """
68
- Retorna o saldo atual da carteira do agente.
69
-
70
- Returns:
71
- Dict com pelo menos 'address' e 'balance_usdc'.
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 execute(self, **kwargs) -> dict:
77
- """
78
- Executa a ação principal do agente.
106
+ def get_balance(self) -> dict: ...
79
107
 
80
- Returns:
81
- Dict com o resultado da execução, incluindo 'status'.
82
- """
83
- ...
108
+ @abstractmethod
109
+ def execute(self, **kwargs) -> dict: ...
84
110
 
85
111
  def log(self, msg: str) -> None:
86
- """Helper de log padronizado: prefixo com nome da classe."""
112
+ """Standardized log helper: prefixes with class name."""
87
113
  logger.info("[%s] %s", self.__class__.__name__, msg)