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
@@ -0,0 +1,38 @@
1
+ """ChainInterfaces manager singleton."""
2
+
3
+ from typing import Dict
4
+
5
+ from iwa.core.chain.interface import ChainInterface
6
+ from iwa.core.chain.models import Base, Ethereum, Gnosis
7
+ from iwa.core.utils import singleton
8
+
9
+
10
+ @singleton
11
+ class ChainInterfaces:
12
+ """ChainInterfaces"""
13
+
14
+ gnosis: ChainInterface = ChainInterface(Gnosis())
15
+ ethereum: ChainInterface = ChainInterface(Ethereum())
16
+ base: ChainInterface = ChainInterface(Base())
17
+
18
+ def get(self, chain_name: str) -> ChainInterface:
19
+ """Get ChainInterface by chain name"""
20
+ chain_name = chain_name.strip().lower()
21
+
22
+ if not hasattr(self, chain_name):
23
+ raise ValueError(f"Unsupported chain: {chain_name}")
24
+
25
+ return getattr(self, chain_name)
26
+
27
+ def items(self):
28
+ """Iterate over all chain interfaces."""
29
+ yield "gnosis", self.gnosis
30
+ yield "ethereum", self.ethereum
31
+ yield "base", self.base
32
+
33
+ def check_all_rpcs(self) -> Dict[str, bool]:
34
+ """Check health of all chain RPCs."""
35
+ results = {}
36
+ for name, interface in self.items():
37
+ results[name] = interface.check_rpc_health()
38
+ return results
@@ -0,0 +1,128 @@
1
+ """Chain model definitions."""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from iwa.core.models import EthereumAddress
8
+ from iwa.core.settings import settings
9
+ from iwa.core.utils import singleton
10
+
11
+
12
+ class SupportedChain(BaseModel):
13
+ """SupportedChain"""
14
+
15
+ name: str
16
+ rpcs: List[str]
17
+ chain_id: int
18
+ native_currency: str
19
+ tokens: Dict[str, EthereumAddress] = {}
20
+ contracts: Dict[str, EthereumAddress] = {}
21
+
22
+ @property
23
+ def rpc(self) -> str:
24
+ """Get the primary RPC URL."""
25
+ return self.rpcs[0] if self.rpcs else ""
26
+
27
+ def get_token_address(self, token_address_or_name: str) -> Optional[EthereumAddress]:
28
+ """Get token address"""
29
+ try:
30
+ address = EthereumAddress(token_address_or_name)
31
+ except Exception:
32
+ address = None
33
+
34
+ if address and address in self.tokens.values():
35
+ return address
36
+
37
+ if address is None:
38
+ return self.tokens.get(token_address_or_name, None)
39
+
40
+ return None
41
+
42
+ def get_token_name(self, token_address: str) -> Optional[str]:
43
+ """Get token name from address."""
44
+ addr_lower = token_address.lower()
45
+ for name, addr in self.tokens.items():
46
+ if addr.lower() == addr_lower:
47
+ return name
48
+ return None
49
+
50
+
51
+ @singleton
52
+ class Gnosis(SupportedChain):
53
+ """Gnosis Chain"""
54
+
55
+ name: str = "Gnosis"
56
+ rpcs: List[str] = [] # Set dynamically in __init__
57
+ chain_id: int = 100
58
+ native_currency: str = "xDAI"
59
+ tokens: Dict[str, EthereumAddress] = {
60
+ "OLAS": EthereumAddress("0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f"),
61
+ "WXDAI": EthereumAddress("0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"),
62
+ "USDC": EthereumAddress("0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0"),
63
+ "SDAI": EthereumAddress("0xaf204776c7245bF4147c2612BF6e5972Ee483701"),
64
+ "EURE": EthereumAddress("0xcB444e90D8198415266c6a2724b7900fb12FC56E"),
65
+ }
66
+ contracts: Dict[str, EthereumAddress] = {
67
+ "GNOSIS_SAFE_MULTISIG_IMPLEMENTATION": EthereumAddress(
68
+ "0x3C1fF68f5aa342D296d4DEe4Bb1cACCA912D95fE"
69
+ ),
70
+ "GNOSIS_SAFE_FALLBACK_HANDLER": EthereumAddress(
71
+ "0xf48f2b2d2a534e402487b3ee7c18c33aec0fe5e4"
72
+ ),
73
+ }
74
+
75
+ def __init__(self, **data):
76
+ """Initialize with RPCs from settings (after testing override is applied)."""
77
+ super().__init__(**data)
78
+ if not self.rpcs and settings.gnosis_rpc:
79
+ self.rpcs = settings.gnosis_rpc.get_secret_value().split(",")
80
+
81
+
82
+ @singleton
83
+ class Ethereum(SupportedChain):
84
+ """Ethereum Mainnet"""
85
+
86
+ name: str = "Ethereum"
87
+ rpcs: List[str] = [] # Set dynamically in __init__
88
+ chain_id: int = 1
89
+ native_currency: str = "ETH"
90
+ tokens: Dict[str, EthereumAddress] = {
91
+ "OLAS": EthereumAddress("0x0001A500A6B18995B03f44bb040A5fFc28E45CB0"),
92
+ }
93
+ contracts: Dict[str, EthereumAddress] = {}
94
+
95
+ def __init__(self, **data):
96
+ """Initialize with RPCs from settings (after testing override is applied)."""
97
+ super().__init__(**data)
98
+ if not self.rpcs and settings.ethereum_rpc:
99
+ self.rpcs = settings.ethereum_rpc.get_secret_value().split(",")
100
+
101
+
102
+ @singleton
103
+ class Base(SupportedChain):
104
+ """Base"""
105
+
106
+ name: str = "Base"
107
+ rpcs: List[str] = [] # Set dynamically in __init__
108
+ chain_id: int = 8453
109
+ native_currency: str = "ETH"
110
+ tokens: Dict[str, EthereumAddress] = {
111
+ "OLAS": EthereumAddress("0x54330d28ca3357F294334BDC454a032e7f353416"),
112
+ }
113
+ contracts: Dict[str, EthereumAddress] = {}
114
+
115
+ def __init__(self, **data):
116
+ """Initialize with RPCs from settings (after testing override is applied)."""
117
+ super().__init__(**data)
118
+ if not self.rpcs and settings.base_rpc:
119
+ self.rpcs = settings.base_rpc.get_secret_value().split(",")
120
+
121
+
122
+ @singleton
123
+ class SupportedChains:
124
+ """SupportedChains"""
125
+
126
+ gnosis: SupportedChain = Gnosis()
127
+ ethereum: SupportedChain = Ethereum()
128
+ base: SupportedChain = Base()
@@ -0,0 +1,193 @@
1
+ """RPC rate limiting classes for chain interactions."""
2
+
3
+ import threading
4
+ import time
5
+ from typing import TYPE_CHECKING, Dict
6
+
7
+ from iwa.core.utils import configure_logger
8
+
9
+ if TYPE_CHECKING:
10
+ from iwa.core.chain.interface import ChainInterface
11
+
12
+ logger = configure_logger()
13
+
14
+
15
+ class RPCRateLimiter:
16
+ """Token bucket rate limiter for RPC calls.
17
+
18
+ Uses a token bucket algorithm that allows bursts while maintaining
19
+ a maximum average rate over time.
20
+ """
21
+
22
+ DEFAULT_RATE = 25.0
23
+ DEFAULT_BURST = 50
24
+
25
+ def __init__(
26
+ self,
27
+ rate: float = DEFAULT_RATE,
28
+ burst: int = DEFAULT_BURST,
29
+ ):
30
+ """Initialize rate limiter.
31
+
32
+ Args:
33
+ rate: Maximum requests per second (refill rate)
34
+ burst: Maximum tokens (bucket size)
35
+
36
+ """
37
+ self.rate = rate
38
+ self.burst = burst
39
+ self.tokens = float(burst)
40
+ self.last_update = time.monotonic()
41
+ self._lock = threading.Lock()
42
+ self._backoff_until = 0.0
43
+
44
+ def acquire(self, timeout: float = 30.0) -> bool:
45
+ """Acquire a token, blocking if necessary."""
46
+ deadline = time.monotonic() + timeout
47
+
48
+ while True:
49
+ with self._lock:
50
+ now = time.monotonic()
51
+
52
+ if now < self._backoff_until:
53
+ wait_time = self._backoff_until - now
54
+ if now + wait_time > deadline:
55
+ return False
56
+ else:
57
+ elapsed = now - self.last_update
58
+ self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
59
+ self.last_update = now
60
+
61
+ if self.tokens >= 1.0:
62
+ self.tokens -= 1.0
63
+ return True
64
+
65
+ wait_time = (1.0 - self.tokens) / self.rate
66
+ if now + wait_time > deadline:
67
+ return False
68
+
69
+ time.sleep(min(wait_time, 0.1))
70
+
71
+ def trigger_backoff(self, seconds: float = 5.0):
72
+ """Trigger rate limit backoff."""
73
+ with self._lock:
74
+ self._backoff_until = time.monotonic() + seconds
75
+ self.tokens = 0
76
+ logger.warning(f"RPC rate limit triggered, backing off for {seconds}s")
77
+
78
+ def get_status(self) -> dict:
79
+ """Get current rate limiter status."""
80
+ with self._lock:
81
+ now = time.monotonic()
82
+ in_backoff = now < self._backoff_until
83
+ return {
84
+ "tokens": self.tokens,
85
+ "rate": self.rate,
86
+ "burst": self.burst,
87
+ "in_backoff": in_backoff,
88
+ "backoff_remaining": max(0, self._backoff_until - now) if in_backoff else 0,
89
+ }
90
+
91
+
92
+ # Global rate limiters per chain
93
+ _rate_limiters: Dict[str, RPCRateLimiter] = {}
94
+ _rate_limiters_lock = threading.Lock()
95
+
96
+
97
+ def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) -> RPCRateLimiter:
98
+ """Get or create a rate limiter for a chain."""
99
+ with _rate_limiters_lock:
100
+ if chain_name not in _rate_limiters:
101
+ _rate_limiters[chain_name] = RPCRateLimiter(
102
+ rate=rate or RPCRateLimiter.DEFAULT_RATE,
103
+ burst=burst or RPCRateLimiter.DEFAULT_BURST,
104
+ )
105
+ return _rate_limiters[chain_name]
106
+
107
+
108
+ class RateLimitedEth:
109
+ """Wrapper around web3.eth that applies rate limiting transparently."""
110
+
111
+ RPC_METHODS = {
112
+ "get_balance",
113
+ "get_code",
114
+ "get_transaction_count",
115
+ "estimate_gas",
116
+ "send_raw_transaction",
117
+ "wait_for_transaction_receipt",
118
+ "get_block",
119
+ "get_transaction",
120
+ "get_transaction_receipt",
121
+ "call",
122
+ "get_logs",
123
+ }
124
+
125
+ def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
126
+ """Initialize RateLimitedEth wrapper."""
127
+ object.__setattr__(self, "_eth", web3_eth)
128
+ object.__setattr__(self, "_rate_limiter", rate_limiter)
129
+ object.__setattr__(self, "_chain_interface", chain_interface)
130
+
131
+ def __getattr__(self, name):
132
+ """Get attribute from underlying eth, wrapping RPC methods with rate limiting."""
133
+ attr = getattr(self._eth, name)
134
+
135
+ if name in self.RPC_METHODS and callable(attr):
136
+ return self._wrap_with_rate_limit(attr, name)
137
+
138
+ return attr
139
+
140
+ def __setattr__(self, name, value):
141
+ """Set attribute on underlying eth for test mocking."""
142
+ if name.startswith("_"):
143
+ object.__setattr__(self, name, value)
144
+ else:
145
+ setattr(self._eth, name, value)
146
+
147
+ def __delattr__(self, name):
148
+ """Delete attribute from underlying eth for patch.object cleanup."""
149
+ if name.startswith("_"):
150
+ object.__delattr__(self, name)
151
+ else:
152
+ delattr(self._eth, name)
153
+
154
+ def _wrap_with_rate_limit(self, method, method_name):
155
+ """Wrap a method with rate limiting and error handling."""
156
+
157
+ def wrapper(*args, **kwargs):
158
+ if not self._rate_limiter.acquire(timeout=30.0):
159
+ raise TimeoutError(f"Rate limit timeout waiting for {method_name}")
160
+
161
+ try:
162
+ return method(*args, **kwargs)
163
+ except Exception as e:
164
+ self._chain_interface._handle_rpc_error(e)
165
+ raise
166
+
167
+ return wrapper
168
+
169
+
170
+ class RateLimitedWeb3:
171
+ """Wrapper around Web3 instance that applies rate limiting transparently."""
172
+
173
+ def __init__(
174
+ self, web3_instance, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"
175
+ ):
176
+ """Initialize RateLimitedWeb3 wrapper."""
177
+ self._web3 = web3_instance
178
+ self._rate_limiter = rate_limiter
179
+ self._chain_interface = chain_interface
180
+ self._eth_wrapper = None
181
+
182
+ @property
183
+ def eth(self):
184
+ """Return rate-limited eth interface."""
185
+ if self._eth_wrapper is None:
186
+ self._eth_wrapper = RateLimitedEth(
187
+ self._web3.eth, self._rate_limiter, self._chain_interface
188
+ )
189
+ return self._eth_wrapper
190
+
191
+ def __getattr__(self, name):
192
+ """Delegate attribute access to underlying Web3 instance."""
193
+ return getattr(self._web3, name)
iwa/core/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ """CLI"""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from web3 import Web3
7
+
8
+ from iwa.core.chain import ChainInterfaces
9
+ from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
10
+ from iwa.core.keys import KeyStorage
11
+ from iwa.core.services import PluginService
12
+ from iwa.core.tables import list_accounts
13
+ from iwa.core.wallet import Wallet
14
+ from iwa.tui.app import IwaApp
15
+
16
+ iwa_cli = typer.Typer(help="iwa command line interface")
17
+ wallet_cli = typer.Typer(help="Manage wallet")
18
+
19
+ iwa_cli.add_typer(wallet_cli, name="wallet")
20
+
21
+
22
+ @wallet_cli.command("create")
23
+ def account_create(
24
+ tag: Optional[str] = typer.Option(
25
+ None,
26
+ "--tag",
27
+ "-t",
28
+ help="Tag for this account",
29
+ ),
30
+ ):
31
+ """Create a new wallet account"""
32
+ key_storage = KeyStorage()
33
+ try:
34
+ key_storage.create_account(tag)
35
+ except ValueError as e:
36
+ typer.echo(f"Error: {e}")
37
+ raise typer.Exit(code=1) from e
38
+
39
+
40
+ @wallet_cli.command("list")
41
+ def account_list(
42
+ chain_name: Optional[str] = typer.Option(
43
+ "gnosis",
44
+ "--chain",
45
+ "-c",
46
+ help="Chain to retrieve balances from.",
47
+ ),
48
+ balances: Optional[str] = typer.Option(
49
+ None,
50
+ "--balances",
51
+ "-b",
52
+ help="Comma-separated list of token names to fetch balances for. Use 'native' for native currency.",
53
+ ),
54
+ ):
55
+ """List wallet accounts"""
56
+ wallet = Wallet()
57
+ chain_interface = ChainInterfaces().get(chain_name)
58
+ token_names_list = balances.split(",") if balances else []
59
+
60
+ accounts_data, token_balances = wallet.get_accounts_balances(chain_name, token_names_list)
61
+
62
+ list_accounts(
63
+ accounts_data,
64
+ chain_interface,
65
+ token_names_list,
66
+ token_balances,
67
+ )
68
+
69
+
70
+ @wallet_cli.command("send")
71
+ def account_send(
72
+ from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
73
+ to_address_or_tag: str = typer.Option(..., "--to", "-t", help="To address or tag"),
74
+ token_address_or_name: str = typer.Option(
75
+ NATIVE_CURRENCY_ADDRESS,
76
+ "--token",
77
+ "-k",
78
+ help="ERC20 token contract address, ignore for native",
79
+ ),
80
+ amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to send, in ether"),
81
+ chain: str = typer.Option(
82
+ "gnosis",
83
+ "--chain",
84
+ help="Chain to send from",
85
+ ),
86
+ ):
87
+ """Send native currency or ERC20 tokens to an address"""
88
+ wallet = Wallet()
89
+ wallet.send(
90
+ from_address_or_tag=from_address_or_tag,
91
+ to_address_or_tag=to_address_or_tag,
92
+ token_address_or_name=token_address_or_name,
93
+ amount_wei=Web3.to_wei(amount_eth, "ether"),
94
+ chain_name=chain,
95
+ )
96
+
97
+
98
+ @wallet_cli.command("transfer-from")
99
+ def erc20_transfer_from(
100
+ from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
101
+ sender_address_or_tag: str = typer.Option(..., "--sender", "-s", help="Sender address or tag"),
102
+ recipient_address_or_tag: str = typer.Option(
103
+ ..., "--recipient", "-r", help="Recipient address or tag"
104
+ ),
105
+ token_address_or_name: str = typer.Option(
106
+ ..., "--token", "-k", help="ERC20 token contract address"
107
+ ),
108
+ amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to transfer, in ether"),
109
+ chain: str = typer.Option(
110
+ "gnosis",
111
+ "--chain",
112
+ help="Chain to send from",
113
+ ),
114
+ ):
115
+ """Transfer ERC20 tokens from a sender to a recipient using allowance"""
116
+ wallet = Wallet()
117
+ wallet.transfer_from_erc20(
118
+ from_address_or_tag=from_address_or_tag,
119
+ sender_address_or_tag=sender_address_or_tag,
120
+ recipient_address_or_tag=recipient_address_or_tag,
121
+ token_address_or_name=token_address_or_name,
122
+ amount_wei=Web3.to_wei(amount_eth, "ether"),
123
+ chain_name=chain,
124
+ )
125
+
126
+
127
+ @wallet_cli.command("approve")
128
+ def erc20_approve(
129
+ owner_address_or_tag: str = typer.Option(..., "--owner", "-f", help="Owner address or tag"),
130
+ spender_address_or_tag: str = typer.Option(
131
+ ..., "--spender", "-t", help="Spender address or tag"
132
+ ),
133
+ token_address_or_name: str = typer.Option(
134
+ ..., "--token", "-k", help="ERC20 token contract address"
135
+ ),
136
+ amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to approve, in ether"),
137
+ chain: str = typer.Option(
138
+ "gnosis",
139
+ "--chain",
140
+ help="Chain to send from",
141
+ ),
142
+ ):
143
+ """Approve ERC20 token allowance for a spender"""
144
+ wallet = Wallet()
145
+ wallet.approve_erc20(
146
+ owner_address_or_tag=owner_address_or_tag,
147
+ spender_address_or_tag=spender_address_or_tag,
148
+ token_address_or_name=token_address_or_name,
149
+ amount_wei=Web3.to_wei(amount_eth, "ether"),
150
+ chain_name=chain,
151
+ )
152
+
153
+
154
+ @iwa_cli.command("tui")
155
+ def tui():
156
+ """Start Terminal User Interface."""
157
+ app = IwaApp()
158
+ app.run()
159
+
160
+
161
+ @iwa_cli.command("web")
162
+ def web_server(
163
+ port: Optional[int] = typer.Option(None, "--port", "-p", help="Port to listen on"),
164
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to listen on"),
165
+ ):
166
+ """Start Web Interface."""
167
+ from iwa.core.settings import settings
168
+ from iwa.web.server import run_server
169
+
170
+ server_port = port or settings.web_port
171
+ typer.echo(f"Starting web server on http://{host}:{server_port}")
172
+ run_server(host=host, port=server_port)
173
+
174
+
175
+ @wallet_cli.command("drain")
176
+ def drain_wallet(
177
+ from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
178
+ to_address_or_tag: str = typer.Option(..., "--to", "-t", help="To address or tag"),
179
+ chain_name: str = typer.Option(
180
+ "gnosis",
181
+ "--chain",
182
+ "-c",
183
+ help="Chain to drain from.",
184
+ ),
185
+ ):
186
+ """Drain all tokens and native currency from one wallet to another"""
187
+ wallet = Wallet()
188
+ wallet.drain(
189
+ from_address_or_tag=from_address_or_tag,
190
+ to_address_or_tag=to_address_or_tag,
191
+ chain_name=chain_name,
192
+ )
193
+
194
+
195
+ # Load Plugins
196
+ # Removed direct import here, moved to top
197
+
198
+ plugin_service = PluginService()
199
+ plugins = plugin_service.get_all_plugins()
200
+
201
+ for plugin_name, plugin in plugins.items():
202
+ commands = plugin.get_cli_commands()
203
+ if commands:
204
+ plugin_app = typer.Typer(help=f"{plugin_name} commands")
205
+ for cmd_name, cmd_func in commands.items():
206
+ plugin_app.command(name=cmd_name)(cmd_func)
207
+ iwa_cli.add_typer(plugin_app, name=plugin_name)
208
+
209
+ if __name__ == "__main__": # pragma: no cover
210
+ iwa_cli()
iwa/core/constants.py ADDED
@@ -0,0 +1,28 @@
1
+ """Core constants"""
2
+
3
+ from pathlib import Path
4
+
5
+ from iwa.core.types import EthereumAddress
6
+
7
+ PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
8
+
9
+ # Data directory for sensitive/runtime files
10
+ DATA_DIR = PROJECT_ROOT / "data"
11
+
12
+ SECRETS_PATH = DATA_DIR / "secrets.env"
13
+ CONFIG_PATH = DATA_DIR / "config.yaml"
14
+ WALLET_PATH = DATA_DIR / "wallet.json"
15
+ BACKUP_DIR = DATA_DIR / "backup"
16
+ TENDERLY_CONFIG_PATH = PROJECT_ROOT / "tenderly.yaml"
17
+
18
+ ABI_PATH = PROJECT_ROOT / "src" / "iwa" / "core" / "contracts" / "abis"
19
+
20
+ # Standard Ethereum addresses
21
+ ZERO_ADDRESS = EthereumAddress("0x0000000000000000000000000000000000000000")
22
+ NATIVE_CURRENCY_ADDRESS = EthereumAddress("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE")
23
+ DEFAULT_MECH_CONTRACT_ADDRESS = EthereumAddress("0x77af31De935740567Cf4FF1986D04B2c964A786a")
24
+
25
+
26
+ def get_tenderly_config_path(profile: int = 1) -> Path:
27
+ """Get the path to a profile-specific Tenderly config file."""
28
+ return PROJECT_ROOT / f"tenderly_{profile}.yaml"
@@ -0,0 +1 @@
1
+ """iwa.core.contracts package."""