iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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 (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
iwa/core/models.py ADDED
@@ -0,0 +1,344 @@
1
+ """Core models"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Type, TypeVar
6
+
7
+ import tomli
8
+ import tomli_w
9
+ import yaml
10
+ from pydantic import BaseModel, Field, PrivateAttr
11
+ from pydantic_core import core_schema
12
+
13
+ from iwa.core.types import EthereumAddress # noqa: F401 - re-exported for backwards compatibility
14
+ from iwa.core.utils import singleton
15
+
16
+
17
+ class StoredAccount(BaseModel):
18
+ """StoredAccount representing an EOA or contract account."""
19
+
20
+ address: EthereumAddress = Field(description="Ethereum address (checksummed)")
21
+ tag: str = Field(description="Human-readable alias for the account")
22
+
23
+
24
+ class StoredSafeAccount(StoredAccount):
25
+ """StoredSafeAccount representing a Gnosis Safe."""
26
+
27
+ signers: List[EthereumAddress] = Field(description="List of owner addresses")
28
+ threshold: int = Field(description="Required signatures threshold")
29
+ chains: List[str] = Field(description="List of supported chains")
30
+
31
+
32
+ class CoreConfig(BaseModel):
33
+ """Core configuration settings."""
34
+
35
+ manual_claim_enabled: bool = Field(
36
+ default=False, description="Enable manual claiming of rewards"
37
+ )
38
+ request_activity_alert_enabled: bool = Field(
39
+ default=True, description="Enable alerts for suspicious activity"
40
+ )
41
+ whitelist: Dict[str, EthereumAddress] = Field(
42
+ default_factory=dict, description="Address whitelist for security"
43
+ )
44
+ custom_tokens: Dict[str, Dict[str, EthereumAddress]] = Field(
45
+ default_factory=dict, description="Custom token definitions per chain"
46
+ )
47
+
48
+
49
+ T = TypeVar("T", bound="StorableModel")
50
+
51
+
52
+ class StorableModel(BaseModel):
53
+ """StorableModel with load and save methods for JSON, TOML, and YAML formats."""
54
+
55
+ _storage_format: Optional[str] = PrivateAttr(default=None)
56
+ _path: Optional[Path] = PrivateAttr()
57
+
58
+ def save_json(self, path: Optional[Path] = None, **kwargs) -> None:
59
+ """Save to JSON file"""
60
+ if path is None:
61
+ if getattr(self, "_path", None) is None:
62
+ raise ValueError("Save path not specified and no previous path stored.")
63
+ path = self._path
64
+
65
+ path = path.with_suffix(".json")
66
+
67
+ with path.open("w", encoding="utf-8") as f:
68
+ json.dump(self.model_dump(), f, indent=2, ensure_ascii=False, **kwargs)
69
+ self._storage_format = "json"
70
+ self._path = path
71
+
72
+ def save_toml(self, path: Optional[Path] = None) -> None:
73
+ """Save to TOML file"""
74
+ if path is None:
75
+ if getattr(self, "_path", None) is None:
76
+ raise ValueError("Save path not specified and no previous path stored.")
77
+ path = self._path
78
+
79
+ path = path.with_suffix(".toml")
80
+
81
+ with path.open("wb") as f:
82
+ tomli_w.dump(self.model_dump(exclude_none=True), f)
83
+ self._storage_format = "toml"
84
+ self._path = path
85
+
86
+ def save_yaml(self, path: Optional[Path] = None) -> None:
87
+ """Save to YAML file"""
88
+ if path is None:
89
+ if getattr(self, "_path", None) is None:
90
+ raise ValueError("Save path not specified and no previous path stored.")
91
+ path = self._path
92
+
93
+ path = path.with_suffix(".yaml")
94
+
95
+ with path.open("w", encoding="utf-8") as f:
96
+ yaml.safe_dump(self.model_dump(), f, sort_keys=False, allow_unicode=True)
97
+ self._storage_format = "yaml"
98
+ self._path = path
99
+
100
+ def save(self, path: str | Path | None = None, **kwargs) -> None:
101
+ """Save to file with specified format"""
102
+ if path is None:
103
+ if getattr(self, "_path", None) is None:
104
+ raise ValueError("Save path not specified and no previous path stored.")
105
+ path = self._path
106
+
107
+ path = Path(path)
108
+ ext = path.suffix.lower()
109
+ if ext == ".json":
110
+ self.save_json(path, **kwargs)
111
+ elif ext in {".toml", ".tml"}:
112
+ self.save_toml(path)
113
+ elif ext in {".yaml", ".yml"}:
114
+ self.save_yaml(path)
115
+ else:
116
+ sf = (self._storage_format or "").lower()
117
+ if sf == "json":
118
+ self.save_json(path, **kwargs)
119
+ elif sf in {"toml", "tml"}:
120
+ self.save_toml(path)
121
+ elif sf in {"yaml", "yml"}:
122
+ self.save_yaml(path)
123
+ else:
124
+ raise ValueError(f"Extension not supported: {ext}")
125
+
126
+ @classmethod
127
+ def load_json(cls: Type[T], path: str | Path) -> T:
128
+ """Load from JSON file"""
129
+ path = Path(path)
130
+ with path.open("r", encoding="utf-8") as f:
131
+ data = json.load(f)
132
+ obj = cls(**data)
133
+ obj._storage_format = "json"
134
+ obj._path = path
135
+ return obj
136
+
137
+ @classmethod
138
+ def load_toml(cls: Type[T], path: str | Path) -> T:
139
+ """Load from TOML file"""
140
+ path = Path(path)
141
+ with path.open("rb") as f:
142
+ data = tomli.load(f)
143
+ obj = cls(**data)
144
+ obj._storage_format = "toml"
145
+ obj._path = path
146
+ return obj
147
+
148
+ @classmethod
149
+ def load_yaml(cls: Type[T], path: str | Path) -> T:
150
+ """Load from YAML file"""
151
+ path = Path(path)
152
+ with path.open("r", encoding="utf-8") as f:
153
+ data = yaml.safe_load(f)
154
+ obj = cls(**data)
155
+ obj._storage_format = "yaml"
156
+ obj._path = path
157
+ return obj
158
+
159
+ @classmethod
160
+ def load(cls: Type[T], path: Path) -> T:
161
+ """Load from file with specified format"""
162
+ extension = path.suffix.lower()
163
+ if extension == ".json":
164
+ return cls.load_json(path)
165
+ elif extension in {".toml", ".tml"}:
166
+ return cls.load_toml(path)
167
+ elif extension in {".yaml", ".yml"}:
168
+ return cls.load_yaml(path)
169
+ else:
170
+ raise ValueError(f"Unsupported file extension: {extension}")
171
+
172
+
173
+ @singleton
174
+ class Config(StorableModel):
175
+ """Config with auto-loading and plugin support."""
176
+
177
+ core: Optional[CoreConfig] = None
178
+ plugins: Dict[str, BaseModel] = Field(default_factory=dict)
179
+
180
+ _initialized: bool = PrivateAttr(default=False)
181
+ _plugin_models: Dict[str, type] = PrivateAttr(default_factory=dict)
182
+
183
+ def model_post_init(self, __context) -> None:
184
+ """Load config from file after initialization."""
185
+ if not self._initialized:
186
+ self._try_load()
187
+ self._initialized = True
188
+
189
+ def _try_load(self) -> None:
190
+ """Try to load from config.yaml if exists, otherwise create default."""
191
+ from loguru import logger
192
+
193
+ from iwa.core.constants import CONFIG_PATH
194
+
195
+ if not CONFIG_PATH.exists():
196
+ # Initialize default core config and save
197
+ self.core = CoreConfig()
198
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
199
+ self.save_yaml(CONFIG_PATH)
200
+ logger.info(f"Created default config file: {CONFIG_PATH}")
201
+ return
202
+
203
+ try:
204
+ import yaml
205
+
206
+ with CONFIG_PATH.open("r", encoding="utf-8") as f:
207
+ data = yaml.safe_load(f) or {}
208
+
209
+ # Load core config
210
+ if "core" in data:
211
+ self.core = CoreConfig(**data["core"])
212
+
213
+ # Load plugin configs - will be hydrated when plugins register
214
+ if "plugins" in data:
215
+ for plugin_name, plugin_data in data["plugins"].items():
216
+ # Store raw data until plugin model is registered
217
+ if plugin_name in self._plugin_models:
218
+ self.plugins[plugin_name] = self._plugin_models[plugin_name](**plugin_data)
219
+ else:
220
+ # Store as dict temporarily, will hydrate on register
221
+ self.plugins[plugin_name] = plugin_data
222
+
223
+ self._path = CONFIG_PATH
224
+ self._storage_format = "yaml"
225
+ except Exception as e:
226
+ logger.warning(f"Failed to load config from {CONFIG_PATH}: {e}")
227
+
228
+ # Ensure core config always exists
229
+ if self.core is None:
230
+ self.core = CoreConfig()
231
+
232
+ def register_plugin_config(self, plugin_name: str, model_class: type) -> None:
233
+ """Register a plugin's config model class.
234
+
235
+ If raw data was loaded for this plugin, it will be hydrated into the model.
236
+ If no data exists, creates default config and persists to file.
237
+ """
238
+ self._plugin_models[plugin_name] = model_class
239
+
240
+ # Hydrate any raw data that was loaded
241
+ if plugin_name in self.plugins:
242
+ current = self.plugins[plugin_name]
243
+ if isinstance(current, dict):
244
+ self.plugins[plugin_name] = model_class(**current)
245
+ else:
246
+ # Create default config for plugin and persist
247
+ self.plugins[plugin_name] = model_class()
248
+ self.save_config()
249
+
250
+ def save_config(self) -> None:
251
+ """Persist current config to config.yaml."""
252
+ import yaml
253
+
254
+ from iwa.core.constants import CONFIG_PATH
255
+
256
+ data = {}
257
+
258
+ if self.core:
259
+ data["core"] = self.core.model_dump()
260
+
261
+ data["plugins"] = {}
262
+ for plugin_name, plugin_config in self.plugins.items():
263
+ if isinstance(plugin_config, BaseModel):
264
+ data["plugins"][plugin_name] = plugin_config.model_dump()
265
+ elif isinstance(plugin_config, dict):
266
+ data["plugins"][plugin_name] = plugin_config
267
+
268
+ with CONFIG_PATH.open("w", encoding="utf-8") as f:
269
+ yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, default_flow_style=False)
270
+
271
+ self._path = CONFIG_PATH
272
+ self._storage_format = "yaml"
273
+
274
+ def get_plugin_config(self, plugin_name: str) -> Optional[BaseModel]:
275
+ """Get a plugin's configuration."""
276
+ return self.plugins.get(plugin_name)
277
+
278
+
279
+ class Token(BaseModel):
280
+ """Token model for defined tokens."""
281
+
282
+ symbol: str
283
+ address: EthereumAddress
284
+ decimals: int = 18
285
+ name: Optional[str] = None
286
+
287
+
288
+ class TokenAmount(BaseModel):
289
+ """TokenAmount - amount in human-readable ETH units."""
290
+
291
+ address: EthereumAddress
292
+ symbol: str
293
+ amount_eth: float
294
+
295
+
296
+ class FundRequirements(BaseModel):
297
+ """FundRequirements - amounts in human-readable ETH units."""
298
+
299
+ native_eth: float
300
+ tokens: List[TokenAmount] = Field(default_factory=list)
301
+
302
+
303
+ class VirtualNet(BaseModel):
304
+ """Virtual Network configuration for Tenderly."""
305
+
306
+ vnet_id: Optional[str] = Field(default=None, description="Tenderly Virtual TestNet ID")
307
+ chain_id: int = Field(description="Chain ID of the forked network")
308
+ vnet_slug: Optional[str] = Field(default=None, description="Slug for the Virtual TestNet")
309
+ vnet_display_name: Optional[str] = Field(default=None, description="Display name for UI")
310
+ funds_requirements: Dict[str, FundRequirements] = Field(
311
+ description="Required funds for test accounts"
312
+ )
313
+ admin_rpc: Optional[str] = Field(default=None, description="Admin RPC URL for the vNet")
314
+ public_rpc: Optional[str] = Field(default=None, description="Public RPC URL for the vNet")
315
+ initial_block: int = Field(default=0, description="Block number at vNet creation")
316
+
317
+ @classmethod
318
+ def __get_pydantic_core_schema__(cls, _source, _handler):
319
+ """Get the Pydantic core schema for VirtualNet."""
320
+ return core_schema.with_info_after_validator_function(
321
+ cls.validate,
322
+ _handler(_source),
323
+ )
324
+
325
+ @classmethod
326
+ def validate(cls, value: "VirtualNet", _info) -> "VirtualNet":
327
+ """Validate RPC URLs."""
328
+ if value.admin_rpc and not (
329
+ value.admin_rpc.startswith("http://") or value.admin_rpc.startswith("https://")
330
+ ):
331
+ raise ValueError(f"Invalid admin_rpc URL: {value.admin_rpc}")
332
+ if value.public_rpc and not (
333
+ value.public_rpc.startswith("http://") or value.public_rpc.startswith("https://")
334
+ ):
335
+ raise ValueError(f"Invalid public_rpc URL: {value.public_rpc}")
336
+ return value
337
+
338
+
339
+ class TenderlyConfig(StorableModel):
340
+ """Configuration for Tenderly integration."""
341
+
342
+ vnets: Dict[str, VirtualNet] = Field(
343
+ description="Map of chain names to VirtualNet configurations"
344
+ )
iwa/core/monitor.py ADDED
@@ -0,0 +1,209 @@
1
+ """Event Monitor for Iwa TUI"""
2
+
3
+ import time
4
+ from typing import Any, Callable, Dict, List
5
+
6
+ from web3 import Web3
7
+
8
+ from iwa.core.chain import ChainInterfaces
9
+ from iwa.core.utils import configure_logger
10
+
11
+ logger = configure_logger()
12
+
13
+
14
+ class EventMonitor:
15
+ """Monitors chain for events affecting specific addresses."""
16
+
17
+ def __init__(
18
+ self, addresses: List[str], callback: Callable, chain_name: str = "gnosis"
19
+ ) -> None:
20
+ """Initialize events monitor."""
21
+ self.chain_name = chain_name
22
+ self.addresses = [Web3.to_checksum_address(addr) for addr in addresses]
23
+ self.callback = callback
24
+ self.chain_interface = ChainInterfaces().get(chain_name)
25
+ self.web3 = self.chain_interface.web3
26
+ self.running = False
27
+ if self.chain_interface.chain.rpc:
28
+ try:
29
+ self.last_checked_block = self.web3.eth.block_number
30
+ except Exception:
31
+ self.last_checked_block = 0
32
+ else:
33
+ self.last_checked_block = 0
34
+
35
+ def start(self):
36
+ """Start monitoring loop."""
37
+ self.running = True
38
+ logger.info(
39
+ f"Starting EventMonitor for {len(self.addresses)} addresses on {self.chain_interface.chain.name}"
40
+ )
41
+
42
+ if not self.chain_interface.chain.rpc:
43
+ logger.error(
44
+ f"Cannot start EventMonitor: No RPC URL found for chain {self.chain_interface.chain.name}"
45
+ )
46
+ self.running = False
47
+ return
48
+
49
+ logger.info(f"Monitoring addresses: {self.addresses}")
50
+
51
+ while self.running:
52
+ try:
53
+ self.check_activity()
54
+ except Exception as e:
55
+ logger.error(f"Error in EventMonitor: {e}")
56
+
57
+ time.sleep(6)
58
+
59
+ def stop(self):
60
+ """Stop monitoring."""
61
+ self.running = False
62
+
63
+ def check_activity(self):
64
+ """Check for new blocks and logs."""
65
+ try:
66
+ latest_block = self.web3.eth.block_number
67
+ except Exception as e:
68
+ logger.error(f"Failed to get block number: {e}")
69
+ return
70
+
71
+ if not self._should_check(latest_block):
72
+ return
73
+
74
+ logger.info(f"New block detected: {latest_block} (Last: {self.last_checked_block})")
75
+
76
+ from_block, to_block = self._get_block_range(latest_block)
77
+
78
+ found_txs = []
79
+ found_txs.extend(self._check_native_transfers(from_block, to_block))
80
+ found_txs.extend(self._check_erc20_transfers(from_block, to_block))
81
+
82
+ self.last_checked_block = to_block
83
+
84
+ if found_txs:
85
+ self.callback(found_txs)
86
+
87
+ def _should_check(self, latest_block: int) -> bool:
88
+ return latest_block > self.last_checked_block
89
+
90
+ def _get_block_range(self, latest_block: int) -> tuple[int, int]:
91
+ from_block = self.last_checked_block + 1
92
+ to_block = latest_block
93
+
94
+ if to_block - from_block > 100:
95
+ from_block = to_block - 100
96
+ return from_block, to_block
97
+
98
+ def _check_native_transfers(self, from_block: int, to_block: int) -> List[Dict[str, Any]]:
99
+ found_txs = []
100
+ my_addrs = set(a.lower() for a in self.addresses)
101
+
102
+ for block_num in range(from_block, to_block + 1):
103
+ try:
104
+ block = self.web3.eth.get_block(block_num, full_transactions=True)
105
+ for tx in block.transactions:
106
+ # Handle case where RPC returns hash despite full_transactions=True
107
+ if isinstance(tx, (str, bytes)):
108
+ logger.debug(f"Got tx hash {tx}, fetching details...")
109
+ tx = self.web3.eth.get_transaction(tx)
110
+
111
+ # Normalize tx addresses to lower, handling None for contract creation
112
+ tx_from = tx.get("from", "").lower() if tx.get("from") else None
113
+ tx_to = tx.get("to", "").lower() if tx.get("to") else None
114
+
115
+ if (tx_from and tx_from in my_addrs) or (tx_to and tx_to in my_addrs):
116
+ logger.info(
117
+ f"Native activity detected in block {block_num} tx {tx['hash'].hex()}"
118
+ )
119
+ found_txs.append(
120
+ {
121
+ "hash": tx["hash"].hex(),
122
+ # Use original checksummed 'from' if available in tx, or re-checksum
123
+ "from": Web3.to_checksum_address(tx_from) if tx_from else None,
124
+ "to": Web3.to_checksum_address(tx_to) if tx_to else None,
125
+ "value": tx.get("value", 0),
126
+ "token": "NATIVE",
127
+ "timestamp": block.timestamp,
128
+ "chain": self.chain_name,
129
+ }
130
+ )
131
+ except Exception as e:
132
+ logger.warning(f"Failed to fetch/process block {block_num}: {e}")
133
+
134
+ return found_txs
135
+
136
+ def _check_erc20_transfers(self, from_block: int, to_block: int) -> List[Dict[str, Any]]:
137
+ found_txs = []
138
+ my_addrs = set(a.lower() for a in self.addresses)
139
+
140
+ transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
141
+ padded_addresses = [
142
+ "0x000000000000000000000000" + addr.lower().replace("0x", "") for addr in self.addresses
143
+ ]
144
+
145
+ try:
146
+ # Efficiently Query 1: Transfers FROM our addresses (Topic 1)
147
+ logs_sent = self.web3.eth.get_logs(
148
+ {
149
+ "fromBlock": from_block,
150
+ "toBlock": to_block,
151
+ "topics": [transfer_topic, padded_addresses],
152
+ }
153
+ )
154
+
155
+ # Efficiently Query 2: Transfers TO our addresses (Topic 2)
156
+ logs_received = self.web3.eth.get_logs(
157
+ {
158
+ "fromBlock": from_block,
159
+ "toBlock": to_block,
160
+ "topics": [transfer_topic, None, padded_addresses],
161
+ }
162
+ )
163
+
164
+ all_logs = logs_sent + logs_received
165
+
166
+ for log in all_logs:
167
+ if len(log["topics"]) < 3:
168
+ continue
169
+ # topic[1] is from, topic[2] is to. (32 bytes)
170
+ t_from = "0x" + log["topics"][1].hex()[-40:]
171
+ t_to = "0x" + log["topics"][2].hex()[-40:]
172
+
173
+ # Check for uniqueness? Hash is unique key in UI anyway.
174
+
175
+ # Double check (though RPC filter should have ensured it)
176
+ t_from_lower = t_from.lower()
177
+ t_to_lower = t_to.lower()
178
+
179
+ is_related = False
180
+ for my_addr in my_addrs:
181
+ if my_addr in t_from_lower or my_addr in t_to_lower:
182
+ is_related = True
183
+ break
184
+
185
+ if is_related:
186
+ found_txs.append(
187
+ {
188
+ "hash": log["transactionHash"].hex(),
189
+ "from": Web3.to_checksum_address(t_from),
190
+ "to": Web3.to_checksum_address(t_to),
191
+ "value": int(
192
+ log["data"].hex()
193
+ if isinstance(log["data"], bytes)
194
+ else log["data"],
195
+ 16,
196
+ )
197
+ if log.get("data")
198
+ else 0,
199
+ "token": "TOKEN",
200
+ "contract_address": log["address"],
201
+ "timestamp": 0, # Would require block fetch
202
+ "chain": self.chain_name,
203
+ }
204
+ )
205
+
206
+ except Exception as e:
207
+ logger.warning(f"Failed to fetch logs: {e}")
208
+
209
+ return found_txs
iwa/core/plugins.py ADDED
@@ -0,0 +1,45 @@
1
+ """Plugin system architecture."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Dict, Optional, Type
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from iwa.core.utils import configure_logger
9
+
10
+ if TYPE_CHECKING:
11
+ from textual.widget import Widget
12
+
13
+ logger = configure_logger()
14
+
15
+
16
+ class Plugin(ABC):
17
+ """Abstract base class for plugins."""
18
+
19
+ @property
20
+ @abstractmethod
21
+ def name(self) -> str:
22
+ """Plugin name."""
23
+ pass
24
+
25
+ @property
26
+ def version(self) -> str:
27
+ """Plugin version."""
28
+ return "0.1.0"
29
+
30
+ @property
31
+ def config_model(self) -> Optional[Type[BaseModel]]:
32
+ """Pydantic model for plugin configuration."""
33
+ return None
34
+
35
+ def get_cli_commands(self) -> Dict[str, callable]:
36
+ """Return a dict of command_name: function to registers in CLI."""
37
+ return {}
38
+
39
+ def on_load(self) -> None: # noqa: B027
40
+ """Called when plugin is loaded."""
41
+ pass
42
+
43
+ def get_tui_view(self, wallet=None) -> Optional["Widget"]:
44
+ """Return a Textual Widget to be displayed in the TUI."""
45
+ return None
iwa/core/pricing.py ADDED
@@ -0,0 +1,91 @@
1
+ """Pricing service module."""
2
+
3
+ import time
4
+ from datetime import datetime, timedelta
5
+ from typing import Dict, Optional
6
+
7
+ import requests
8
+ from loguru import logger
9
+
10
+ from iwa.core.settings import settings
11
+
12
+
13
+ class PriceService:
14
+ """Service to fetch token prices from CoinGecko."""
15
+
16
+ BASE_URL = "https://api.coingecko.com/api/v3"
17
+
18
+ def __init__(self, cache_ttl_minutes: int = 5):
19
+ """Initialize PriceService."""
20
+ self.settings = settings
21
+ self.cache: Dict[str, Dict] = {} # {id_currency: {"price": float, "timestamp": datetime}}
22
+ self.cache_ttl = timedelta(minutes=cache_ttl_minutes)
23
+ self.api_key = (
24
+ self.settings.coingecko_api_key.get_secret_value()
25
+ if self.settings.coingecko_api_key
26
+ else None
27
+ )
28
+
29
+ def get_token_price(self, token_id: str, vs_currency: str = "eur") -> Optional[float]:
30
+ """Get token price in specified currency.
31
+
32
+ Args:
33
+ token_id: CoinGecko token ID (e.g. 'ethereum', 'gnosis', 'olas')
34
+ vs_currency: Target currency (default 'eur')
35
+
36
+ Returns:
37
+ Price as float, or None if fetch failed.
38
+
39
+ """
40
+ cache_key = f"{token_id}_{vs_currency}"
41
+
42
+ # Check cache
43
+ if cache_key in self.cache:
44
+ entry = self.cache[cache_key]
45
+ if datetime.now() - entry["timestamp"] < self.cache_ttl:
46
+ return entry["price"]
47
+
48
+ # Fetch from API with 2 retries
49
+ max_retries = 2
50
+ for attempt in range(max_retries + 1):
51
+ try:
52
+ url = f"{self.BASE_URL}/simple/price"
53
+ params = {"ids": token_id, "vs_currencies": vs_currency}
54
+ headers = {}
55
+ if self.api_key:
56
+ headers["x-cg-demo-api-key"] = self.api_key
57
+
58
+ response = requests.get(url, params=params, headers=headers, timeout=10)
59
+
60
+ if response.status_code == 429:
61
+ logger.warning(
62
+ f"CoinGecko rate limit reached (429) for {token_id}. Attempt {attempt + 1}/{max_retries + 1}"
63
+ )
64
+ if attempt < max_retries:
65
+ time.sleep(2 * (attempt + 1))
66
+ continue
67
+ return None
68
+
69
+ response.raise_for_status()
70
+
71
+ data = response.json()
72
+ if token_id in data and vs_currency in data[token_id]:
73
+ price = float(data[token_id][vs_currency])
74
+
75
+ # Update cache
76
+ self.cache[cache_key] = {"price": price, "timestamp": datetime.now()}
77
+ return price
78
+ else:
79
+ logger.warning(
80
+ f"Price for {token_id} in {vs_currency} not found in response: {data}"
81
+ )
82
+ # Don't cache None, might be a temporary hiccup
83
+ return None
84
+
85
+ except Exception as e:
86
+ logger.error(f"Failed to fetch price for {token_id} (Attempt {attempt + 1}): {e}")
87
+ if attempt < max_retries:
88
+ time.sleep(1)
89
+ continue
90
+ return None
91
+ return None