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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {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
|