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
tests/test_chain.py ADDED
@@ -0,0 +1,490 @@
1
+ from unittest.mock import MagicMock, PropertyMock, patch
2
+
3
+ import pytest
4
+
5
+ from iwa.core.chain import (
6
+ Base,
7
+ ChainInterface,
8
+ ChainInterfaces,
9
+ Ethereum,
10
+ Gnosis,
11
+ SupportedChain,
12
+ )
13
+ from iwa.core.models import EthereumAddress
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_web3():
18
+ """Mock Web3 and RateLimitedWeb3 to bypass rate limiting wrapper in tests."""
19
+ with (
20
+ patch("iwa.core.chain.interface.Web3") as mock_web3_class,
21
+ patch("iwa.core.chain.interface.RateLimitedWeb3") as mock_rl_web3,
22
+ ):
23
+ # Make RateLimitedWeb3 just return the raw web3 instance passed to it
24
+ mock_rl_web3.side_effect = lambda w3, rl, ci: w3
25
+ yield mock_web3_class
26
+
27
+
28
+ @pytest.fixture
29
+ def mock_secrets():
30
+ with patch("iwa.core.chain.models.settings") as mock:
31
+ yield mock
32
+
33
+
34
+ def test_supported_chain_get_token_address():
35
+ chain = SupportedChain(
36
+ name="Test",
37
+ rpcs=["http://rpc"],
38
+ chain_id=1,
39
+ native_currency="TEST",
40
+ tokens={"TKN": EthereumAddress("0x1234567890123456789012345678901234567890")},
41
+ )
42
+
43
+ # Test getting by name
44
+ assert chain.get_token_address("TKN") == "0x1234567890123456789012345678901234567890"
45
+
46
+ # Test getting by address
47
+ assert (
48
+ chain.get_token_address("0x1234567890123456789012345678901234567890")
49
+ == "0x1234567890123456789012345678901234567890"
50
+ )
51
+
52
+ # Test invalid
53
+ assert chain.get_token_address("INVALID") is None
54
+ assert chain.get_token_address("0xInvalid") is None
55
+
56
+ # Test valid address NOT in tokens
57
+ valid_addr_not_in_tokens = "0x0000000000000000000000000000000000000001"
58
+ assert chain.get_token_address(valid_addr_not_in_tokens) is None
59
+
60
+
61
+ def test_chain_classes(mock_secrets):
62
+ mock_secrets.gnosis_rpc.get_secret_value.return_value = "https://gnosis"
63
+ mock_secrets.ethereum_rpc.get_secret_value.return_value = "https://eth"
64
+ mock_secrets.base_rpc.get_secret_value.return_value = "https://base"
65
+
66
+ # Reset singletons
67
+ Gnosis._instance = None
68
+ Ethereum._instance = None
69
+ Base._instance = None
70
+
71
+ assert Gnosis().name == "Gnosis"
72
+ assert Ethereum().name == "Ethereum"
73
+ assert Base().name == "Base"
74
+
75
+
76
+ def test_chain_interface_init(mock_web3, mock_secrets):
77
+ mock_secrets.gnosis_rpc.get_secret_value.return_value = "https://gnosis"
78
+ Gnosis._instance = None
79
+
80
+ ci = ChainInterface()
81
+ assert ci.chain.name == "Gnosis"
82
+ mock_web3.assert_called()
83
+
84
+ ci_eth = ChainInterface("ethereum")
85
+ assert ci_eth.chain.name == "Ethereum"
86
+
87
+
88
+ def test_chain_interface_insecure_rpc_warning(mock_web3, caplog):
89
+ chain = MagicMock(spec=SupportedChain)
90
+ chain.name = "TestChain"
91
+ chain.name = "Insecure"
92
+ chain.rpcs = ["http://insecure"]
93
+
94
+ # Needs to return property value for rpc
95
+ type(chain).rpc = PropertyMock(return_value="http://insecure")
96
+
97
+ ChainInterface(chain)
98
+ assert "Using insecure RPC URL" in caplog.text
99
+
100
+
101
+ def test_is_contract(mock_web3):
102
+ chain = MagicMock(spec=SupportedChain)
103
+ chain.name = "TestChain"
104
+ chain.rpcs = ["https://rpc"]
105
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
106
+ ci = ChainInterface(chain)
107
+ ci.web3.eth.get_code.return_value = b"code"
108
+ assert ci.is_contract("0xAddress") is True
109
+
110
+ ci.web3.eth.get_code.return_value = b""
111
+ assert ci.is_contract("0xAddress") is False
112
+
113
+
114
+ def test_get_native_balance(mock_web3):
115
+ chain = MagicMock(spec=SupportedChain)
116
+ chain.name = "TestChain"
117
+ chain.rpcs = ["https://rpc"]
118
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
119
+ ci = ChainInterface(chain)
120
+ ci.web3.eth.get_balance.return_value = 10**18
121
+ ci.web3.from_wei.return_value = 1.0
122
+
123
+ assert ci.get_native_balance_wei("0xAddress") == 10**18
124
+ assert ci.get_native_balance_eth("0xAddress") == 1.0
125
+
126
+
127
+ # NOTE: Tests for sign_and_send_transaction were removed because the method was removed
128
+ # from ChainInterface for security reasons. Transaction signing is now handled exclusively
129
+ # through TransactionService.sign_and_send() which uses KeyStorage internally.
130
+
131
+
132
+ def test_estimate_gas(mock_web3):
133
+ chain = MagicMock(spec=SupportedChain)
134
+ chain.name = "TestChain"
135
+ chain.rpcs = ["https://rpc"]
136
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
137
+ ci = ChainInterface(chain)
138
+ built_method = MagicMock()
139
+ built_method.estimate_gas.return_value = 1000
140
+
141
+ # Not a contract
142
+ ci.web3.eth.get_code.return_value = b""
143
+ assert ci.estimate_gas(built_method, {"from": "0xSender"}) == 1100
144
+
145
+ # Is a contract
146
+ ci.web3.eth.get_code.return_value = b"code"
147
+ assert ci.estimate_gas(built_method, {"from": "0xSender"}) == 0
148
+
149
+
150
+ def test_calculate_transaction_params(mock_web3):
151
+ chain = MagicMock(spec=SupportedChain)
152
+ chain.name = "TestChain"
153
+ chain.rpcs = ["https://rpc"]
154
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
155
+ ci = ChainInterface(chain)
156
+ ci.web3.eth.get_transaction_count.return_value = 5
157
+ ci.web3.eth.gas_price = 20
158
+
159
+ with patch.object(ci, "estimate_gas", return_value=1000):
160
+ params = ci.calculate_transaction_params(MagicMock(), {"from": "0xSender"})
161
+ assert params["nonce"] == 5
162
+ assert params["gas"] == 1000
163
+ assert params["gasPrice"] == 20
164
+
165
+
166
+ def test_wait_for_no_pending_tx(mock_web3):
167
+ chain = MagicMock(spec=SupportedChain)
168
+ chain.name = "TestChain"
169
+ chain.rpcs = ["https://rpc"]
170
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
171
+ ci = ChainInterface(chain)
172
+
173
+ # pending == latest
174
+ ci.web3.eth.get_transaction_count.side_effect = [10, 10]
175
+ assert ci.wait_for_no_pending_tx("0xSender") is True
176
+
177
+ # pending != latest then pending == latest
178
+ ci.web3.eth.get_transaction_count.side_effect = [10, 11, 11, 11]
179
+ with patch("time.sleep"):
180
+ assert ci.wait_for_no_pending_tx("0xSender") is True
181
+
182
+ # Timeout
183
+ ci.web3.eth.get_transaction_count.return_value = 10
184
+
185
+ # Mock pending to be always different
186
+ def side_effect(address, block_identifier):
187
+ if block_identifier == "latest":
188
+ return 10
189
+ return 11
190
+
191
+ ci.web3.eth.get_transaction_count.side_effect = side_effect
192
+
193
+ with patch("time.time", side_effect=[0, 1, 61]):
194
+ with patch("time.sleep"):
195
+ assert ci.wait_for_no_pending_tx("0xSender") is False
196
+
197
+
198
+ def test_send_native_transfer(mock_web3):
199
+ chain = MagicMock(spec=SupportedChain, rpcs=["https://rpc"], chain_id=1, native_currency="ETH")
200
+ chain.name = "TestChain"
201
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
202
+ ci = ChainInterface(chain)
203
+ account = MagicMock(address="0xSender", key="key")
204
+
205
+ ci.web3.eth.get_transaction_count.return_value = 0
206
+ ci.web3.eth.gas_price = 10
207
+ ci.web3.eth.estimate_gas.return_value = 21000
208
+
209
+ # Sufficient balance
210
+ ci.web3.eth.get_balance.return_value = 10**18 # plenty
211
+ ci.web3.eth.get_balance.return_value = 10**18 # plenty
212
+ # Valid mock return for success: (True, dict_receipt)
213
+ # The actual method returns tx_hash.hex().
214
+ mock_signed_tx = MagicMock()
215
+ mock_signed_tx.raw_transaction = b"raw"
216
+ mock_receipt = {"transactionHash": b"hash", "status": 1}
217
+
218
+ with (
219
+ patch.object(ci.web3.eth, "send_raw_transaction", return_value=b"hash"),
220
+ patch.object(ci.web3.eth, "wait_for_transaction_receipt", return_value=mock_receipt),
221
+ patch.object(ci, "wait_for_no_pending_tx", return_value=True),
222
+ ):
223
+ success, tx_hash = ci.send_native_transfer(
224
+ account.address, "0xReceiver", 1000, sign_callback=lambda tx: mock_signed_tx
225
+ )
226
+ assert success is True
227
+ assert tx_hash == "68617368"
228
+
229
+ # Insufficient balance
230
+ ci.web3.eth.get_balance.return_value = 0
231
+ ci.web3.from_wei.return_value = 0.0
232
+ assert ci.send_native_transfer(
233
+ account.address, "0xReceiver", 1000, sign_callback=lambda tx: mock_signed_tx
234
+ ) == (False, None)
235
+
236
+
237
+ def test_chain_interfaces_get():
238
+ ChainInterfaces._instance = None
239
+ interfaces = ChainInterfaces()
240
+ assert interfaces.get("gnosis").chain.name == "Gnosis"
241
+
242
+ with pytest.raises(ValueError):
243
+ interfaces.get("invalid")
244
+
245
+
246
+ def test_chain_interface_get_token_address(mock_web3):
247
+ chain = MagicMock(spec=SupportedChain)
248
+ chain.name = "TestChain"
249
+ chain.rpcs = ["https://rpc"]
250
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
251
+ chain.get_token_address.return_value = "0xToken"
252
+ ci = ChainInterface(chain)
253
+
254
+ assert ci.get_token_address("Token") == "0xToken"
255
+ chain.get_token_address.assert_called_with("Token")
256
+
257
+
258
+ def test_rotate_rpc(mock_web3):
259
+ chain = MagicMock(spec=SupportedChain)
260
+ chain.name = "TestChain"
261
+ chain.rpcs = ["http://rpc1", "http://rpc2", "http://rpc3"]
262
+ # Needs to return property value for rpc if accessed
263
+ type(chain).rpc = PropertyMock(return_value="http://rpc1")
264
+
265
+ ci = ChainInterface(chain)
266
+ ci._current_rpc_index = 0
267
+
268
+ # Mock health check to always pass
269
+ with patch.object(ci, "check_rpc_health", return_value=True):
270
+ # Rotate 1
271
+ assert ci.rotate_rpc() is True
272
+ assert ci._current_rpc_index == 1
273
+
274
+ # Rotate 2
275
+ assert ci.rotate_rpc() is True
276
+ assert ci._current_rpc_index == 2
277
+
278
+ # Rotate 3 (back to 0)
279
+ assert ci.rotate_rpc() is True
280
+ assert ci._current_rpc_index == 0
281
+
282
+
283
+ def test_rotate_rpc_no_rpcs(mock_web3):
284
+ chain = MagicMock(spec=SupportedChain)
285
+ chain.name = "TestChain"
286
+ chain.rpcs = []
287
+ chain.name = "TestChain"
288
+ type(chain).rpc = PropertyMock(return_value="")
289
+ ci = ChainInterface(chain)
290
+ assert ci.rotate_rpc() is False
291
+
292
+
293
+ def test_rotate_rpc_single_rpc(mock_web3):
294
+ chain = MagicMock(spec=SupportedChain)
295
+ chain.name = "TestChain"
296
+ chain.rpcs = ["http://rpc1"]
297
+ chain.name = "TestChain"
298
+ type(chain).rpc = PropertyMock(return_value="http://rpc1")
299
+ ci = ChainInterface(chain)
300
+ assert ci.rotate_rpc() is False
301
+
302
+
303
+ # --- Tests migrated from test_chain_interface_coverage.py ---
304
+
305
+
306
+ def test_chain_interface_with_real_chains():
307
+ """Test ChainInterface with real chain configurations."""
308
+ from eth_account import Account
309
+
310
+ valid_addr_1 = Account.create().address
311
+ valid_addr_2 = Account.create().address
312
+
313
+ # Patch RateLimitedWeb3 to bypass rate limiting wrapper
314
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
315
+ # Use Gnosis() directly (SupportedChain), not ChainInterfaces().gnosis (ChainInterface)
316
+ interface = ChainInterface(Gnosis())
317
+ interface.chain.rpcs = ["http://rpc1", "http://rpc2"]
318
+ interface.web3 = MagicMock()
319
+ interface.web3.provider.endpoint_uri = "http://rpc1"
320
+ interface.web3._web3.eth.block_number = 12345 # For health check
321
+
322
+ # Mock health check to pass
323
+ with patch.object(interface, "check_rpc_health", return_value=True):
324
+ rotated = interface.rotate_rpc()
325
+ assert rotated is True
326
+
327
+ interface.web3.eth.get_code = MagicMock(return_value=b"code")
328
+ assert interface.is_contract(valid_addr_1) is True
329
+
330
+ interface.web3.eth.get_code.return_value = b""
331
+ assert interface.is_contract(valid_addr_2) is False
332
+
333
+ with patch("iwa.core.contracts.erc20.ERC20Contract") as mock_erc20:
334
+ instance = mock_erc20.return_value
335
+ instance.symbol = "SYM"
336
+ instance.decimals = 18
337
+
338
+ interface.web3.eth.get_code.return_value = b"code"
339
+
340
+ sym = interface.get_token_symbol(valid_addr_1)
341
+ assert sym == "SYM"
342
+
343
+ dec = interface.get_token_decimals(valid_addr_1)
344
+ assert dec == 18
345
+
346
+
347
+ # --- Negative Tests ---
348
+
349
+
350
+ def test_send_native_transfer_insufficient_balance(mock_web3):
351
+ """Test send_native_transfer fails with insufficient balance."""
352
+ chain = MagicMock(spec=SupportedChain)
353
+ chain.name = "TestChain"
354
+ chain.rpcs = ["https://rpc"]
355
+ chain.chain_id = 1
356
+ chain.native_currency = "ETH"
357
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
358
+
359
+ ci = ChainInterface(chain)
360
+ ci.web3.eth.get_transaction_count.return_value = 0
361
+ ci.web3.eth.gas_price = 1000000000 # 1 gwei
362
+ ci.web3.eth.estimate_gas.return_value = 21000
363
+ ci.web3.eth.get_balance.return_value = 1000 # Very low balance
364
+ ci.web3.from_wei.return_value = 0.000001
365
+
366
+ sign_callback = MagicMock()
367
+
368
+ success, tx_hash = ci.send_native_transfer(
369
+ from_address="0x1111111111111111111111111111111111111111",
370
+ to_address="0x2222222222222222222222222222222222222222",
371
+ value_wei=10**18, # 1 ETH - more than available
372
+ sign_callback=sign_callback,
373
+ )
374
+
375
+ assert success is False
376
+ assert tx_hash is None
377
+ sign_callback.assert_not_called()
378
+
379
+
380
+ def test_send_native_transfer_rpc_error(mock_web3):
381
+ """Test send_native_transfer handles RPC errors."""
382
+ chain = MagicMock(spec=SupportedChain)
383
+ chain.name = "TestChain"
384
+ chain.rpcs = ["https://rpc"]
385
+ chain.chain_id = 1
386
+ chain.native_currency = "ETH"
387
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
388
+
389
+ ci = ChainInterface(chain)
390
+ ci.web3.eth.get_transaction_count.return_value = 0
391
+ ci.web3.eth.gas_price = 1000000000
392
+ ci.web3.eth.estimate_gas.return_value = 21000
393
+ ci.web3.eth.get_balance.return_value = 10**19 # Enough balance
394
+ ci.web3.from_wei.return_value = 10.0
395
+ ci.web3.eth.send_raw_transaction.side_effect = Exception("Connection refused")
396
+
397
+ sign_callback = MagicMock()
398
+ sign_callback.return_value = MagicMock(raw_transaction=b"signed")
399
+
400
+ success, tx_hash = ci.send_native_transfer(
401
+ from_address="0x1111111111111111111111111111111111111111",
402
+ to_address="0x2222222222222222222222222222222222222222",
403
+ value_wei=10**17,
404
+ sign_callback=sign_callback,
405
+ )
406
+
407
+ assert success is False
408
+ assert tx_hash is None
409
+
410
+
411
+ def test_get_token_symbol_fallback_on_error(mock_web3):
412
+ """Test get_token_symbol returns truncated address on error."""
413
+ chain = MagicMock(spec=SupportedChain)
414
+ chain.name = "TestChain"
415
+ chain.rpcs = ["https://rpc"]
416
+ chain.tokens = {}
417
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
418
+
419
+ ci = ChainInterface(chain)
420
+
421
+ # Patch ERC20Contract to raise error
422
+ with patch("iwa.core.contracts.erc20.ERC20Contract") as mock_erc20:
423
+ mock_erc20.side_effect = Exception("Contract not found")
424
+
425
+ address = "0x1234567890123456789012345678901234567890"
426
+ symbol = ci.get_token_symbol(address)
427
+
428
+ # Should return truncated address as fallback
429
+ assert symbol == "0x1234...7890"
430
+
431
+
432
+ def test_get_token_decimals_fallback_on_error(mock_web3):
433
+ """Test get_token_decimals returns 18 on error."""
434
+ chain = MagicMock(spec=SupportedChain)
435
+ chain.name = "TestChain"
436
+ chain.rpcs = ["https://rpc"]
437
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
438
+
439
+ ci = ChainInterface(chain)
440
+
441
+ with patch("iwa.core.contracts.erc20.ERC20Contract") as mock_erc20:
442
+ mock_erc20.side_effect = Exception("Contract not found")
443
+
444
+ decimals = ci.get_token_decimals("0x1234567890123456789012345678901234567890")
445
+
446
+ # Should return default 18 as fallback
447
+ assert decimals == 18
448
+
449
+
450
+ def test_is_rate_limit_error_detection(mock_web3):
451
+ """Test _is_rate_limit_error detects various rate limit errors."""
452
+ chain = MagicMock(spec=SupportedChain)
453
+ chain.name = "TestChain"
454
+ chain.rpcs = ["https://rpc"]
455
+ type(chain).rpc = PropertyMock(return_value="https://rpc")
456
+
457
+ ci = ChainInterface(chain)
458
+
459
+ # Should detect rate limit
460
+ assert ci._is_rate_limit_error(Exception("Error 429")) is True
461
+ assert ci._is_rate_limit_error(Exception("rate limit exceeded")) is True
462
+ assert ci._is_rate_limit_error(Exception("Too Many Requests")) is True
463
+ assert ci._is_rate_limit_error(Exception("ratelimit")) is True
464
+
465
+ # Should NOT detect as rate limit
466
+ assert ci._is_rate_limit_error(Exception("Connection timeout")) is False
467
+ assert ci._is_rate_limit_error(Exception("Invalid address")) is False
468
+ assert ci._is_rate_limit_error(Exception("Out of gas")) is False
469
+
470
+
471
+ def test_handle_rpc_error_non_rate_limit(mock_web3):
472
+ """Test _handle_rpc_error returns dict with should_retry for connection errors."""
473
+ chain = MagicMock(spec=SupportedChain)
474
+ chain.name = "TestChain"
475
+ chain.rpcs = ["https://rpc1", "https://rpc2"]
476
+ type(chain).rpc = PropertyMock(return_value="https://rpc1")
477
+
478
+ ci = ChainInterface(chain)
479
+
480
+ # Connection error should now return dict with should_retry
481
+ with patch.object(ci, "check_rpc_health", return_value=True):
482
+ result = ci._handle_rpc_error(Exception("Connection timeout"))
483
+ assert isinstance(result, dict)
484
+ assert result["is_connection_error"] is True
485
+ assert result["should_retry"] is True
486
+
487
+ # Non-retryable error (e.g., invalid address) should not trigger retry
488
+ result = ci._handle_rpc_error(Exception("Invalid address"))
489
+ assert isinstance(result, dict)
490
+ assert result["should_retry"] is False
@@ -0,0 +1,210 @@
1
+ """Tests for ChainInterface RPC error handling and retry logic."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.core.chain.interface import ChainInterface
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_chain_interface():
12
+ """Create a ChainInterface with mocked web3."""
13
+ with patch("iwa.core.chain.interface.get_rate_limiter") as mock_limiter:
14
+ with patch("iwa.core.chain.interface.RateLimitedWeb3") as mock_rlw3:
15
+ mock_limiter.return_value = MagicMock()
16
+ mock_web3 = MagicMock()
17
+ mock_rlw3.return_value = mock_web3
18
+
19
+ # Construct with mock chain
20
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
21
+ mock_gnosis = mock_gnosis_cls.return_value
22
+ mock_gnosis.name = "gnosis"
23
+ mock_gnosis.rpc = "https://rpc.gnosis.gateway.fm"
24
+ mock_gnosis.rpcs = [
25
+ "https://rpc.gnosis.gateway.fm",
26
+ "https://rpc2.gnosis.gateway.fm",
27
+ ]
28
+ mock_gnosis.chain_id = 100
29
+ mock_gnosis.native_currency = "xDAI"
30
+ mock_gnosis.tokens = {}
31
+ mock_gnosis.contracts = {}
32
+
33
+ interface = ChainInterface(mock_gnosis)
34
+ interface._rate_limiter = mock_limiter.return_value
35
+
36
+ yield interface, mock_web3
37
+
38
+
39
+ def test_is_rate_limit_error():
40
+ """Test rate limit error detection."""
41
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
42
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
43
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
44
+ mock_gnosis = mock_gnosis_cls.return_value
45
+ mock_gnosis.name = "gnosis"
46
+ mock_gnosis.rpc = "https://rpc.example.com"
47
+ mock_gnosis.rpcs = ["https://rpc.example.com"]
48
+ mock_gnosis.chain_id = 100
49
+ mock_gnosis.native_currency = "xDAI"
50
+ mock_gnosis.tokens = {}
51
+ mock_gnosis.contracts = {}
52
+
53
+ interface = ChainInterface(mock_gnosis)
54
+
55
+ assert interface._is_rate_limit_error(Exception("429 Too Many Requests"))
56
+ assert interface._is_rate_limit_error(Exception("rate limit exceeded"))
57
+ assert not interface._is_rate_limit_error(Exception("connection refused"))
58
+
59
+
60
+ def test_is_connection_error():
61
+ """Test connection error detection."""
62
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
63
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
64
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
65
+ mock_gnosis = mock_gnosis_cls.return_value
66
+ mock_gnosis.name = "gnosis"
67
+ mock_gnosis.rpc = "https://rpc.example.com"
68
+ mock_gnosis.rpcs = ["https://rpc.example.com"]
69
+ mock_gnosis.chain_id = 100
70
+ mock_gnosis.native_currency = "xDAI"
71
+ mock_gnosis.tokens = {}
72
+ mock_gnosis.contracts = {}
73
+
74
+ interface = ChainInterface(mock_gnosis)
75
+
76
+ assert interface._is_connection_error(Exception("connection timeout"))
77
+ assert interface._is_connection_error(Exception("connection refused"))
78
+ assert interface._is_connection_error(Exception("read timeout"))
79
+ assert not interface._is_connection_error(Exception("429"))
80
+
81
+
82
+ def test_is_server_error():
83
+ """Test server error detection."""
84
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
85
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
86
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
87
+ mock_gnosis = mock_gnosis_cls.return_value
88
+ mock_gnosis.name = "gnosis"
89
+ mock_gnosis.rpc = "https://rpc.example.com"
90
+ mock_gnosis.rpcs = ["https://rpc.example.com"]
91
+ mock_gnosis.chain_id = 100
92
+ mock_gnosis.native_currency = "xDAI"
93
+ mock_gnosis.tokens = {}
94
+ mock_gnosis.contracts = {}
95
+
96
+ interface = ChainInterface(mock_gnosis)
97
+
98
+ assert interface._is_server_error(Exception("500 internal server error"))
99
+ assert interface._is_server_error(Exception("502 bad gateway"))
100
+ assert not interface._is_server_error(Exception("404 not found"))
101
+
102
+
103
+ def test_is_tenderly_quota_exceeded():
104
+ """Test Tenderly quota detection."""
105
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
106
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
107
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
108
+ mock_gnosis = mock_gnosis_cls.return_value
109
+ mock_gnosis.name = "gnosis"
110
+ mock_gnosis.rpc = "https://virtual.tenderly.co/xxx"
111
+ mock_gnosis.rpcs = ["https://virtual.tenderly.co/xxx"]
112
+ mock_gnosis.chain_id = 100
113
+ mock_gnosis.native_currency = "xDAI"
114
+ mock_gnosis.tokens = {}
115
+ mock_gnosis.contracts = {}
116
+
117
+ interface = ChainInterface(mock_gnosis)
118
+
119
+ assert interface._is_tenderly_quota_exceeded(
120
+ Exception("403 Forbidden tenderly virtual network")
121
+ )
122
+ assert not interface._is_tenderly_quota_exceeded(Exception("500 server error"))
123
+
124
+
125
+ def test_handle_rpc_error_rotation(mock_chain_interface):
126
+ """Test RPC error handling triggers rotation."""
127
+ interface, mock_web3 = mock_chain_interface
128
+
129
+ # Mock rotate_rpc to return True
130
+ interface.rotate_rpc = MagicMock(return_value=True)
131
+
132
+ error = Exception("429 rate limit")
133
+ result = interface._handle_rpc_error(error)
134
+
135
+ assert result["is_rate_limit"]
136
+ assert result["should_retry"]
137
+ interface.rotate_rpc.assert_called()
138
+
139
+
140
+ def test_handle_rpc_error_server_error(mock_chain_interface):
141
+ """Test server error triggers retry without rotation."""
142
+ interface, _ = mock_chain_interface
143
+ interface.rotate_rpc = MagicMock(return_value=False)
144
+
145
+ error = Exception("503 service unavailable")
146
+ result = interface._handle_rpc_error(error)
147
+
148
+ assert result["is_server_error"]
149
+ assert result["should_retry"]
150
+
151
+
152
+ def test_check_rpc_health(mock_chain_interface):
153
+ """Test RPC health check."""
154
+ interface, mock_web3 = mock_chain_interface
155
+
156
+ # Healthy
157
+ mock_web3._web3.eth.block_number = 1000
158
+ assert interface.check_rpc_health()
159
+
160
+ # Unhealthy
161
+ mock_web3._web3.eth.block_number = None
162
+ assert not interface.check_rpc_health()
163
+
164
+
165
+ def test_rotate_rpc_single_rpc():
166
+ """Test rotation fails with single RPC."""
167
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
168
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
169
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
170
+ mock_gnosis = mock_gnosis_cls.return_value
171
+ mock_gnosis.name = "gnosis"
172
+ mock_gnosis.rpc = "https://rpc.example.com"
173
+ mock_gnosis.rpcs = ["https://rpc.example.com"] # Only one RPC
174
+ mock_gnosis.chain_id = 100
175
+ mock_gnosis.native_currency = "xDAI"
176
+ mock_gnosis.tokens = {}
177
+ mock_gnosis.contracts = {}
178
+
179
+ interface = ChainInterface(mock_gnosis)
180
+
181
+ assert not interface.rotate_rpc()
182
+
183
+
184
+ def test_is_tenderly_property():
185
+ """Test is_tenderly property."""
186
+ with patch("iwa.core.chain.interface.get_rate_limiter"):
187
+ with patch("iwa.core.chain.interface.RateLimitedWeb3"):
188
+ with patch("iwa.core.chain.models.Gnosis") as mock_gnosis_cls:
189
+ mock_gnosis = mock_gnosis_cls.return_value
190
+ mock_gnosis.name = "gnosis"
191
+ mock_gnosis.rpc = "https://virtual.tenderly.co/xxx"
192
+ mock_gnosis.rpcs = ["https://virtual.tenderly.co/xxx"]
193
+ mock_gnosis.chain_id = 100
194
+ mock_gnosis.native_currency = "xDAI"
195
+ mock_gnosis.tokens = {}
196
+ mock_gnosis.contracts = {}
197
+
198
+ interface = ChainInterface(mock_gnosis)
199
+
200
+ assert interface.is_tenderly
201
+
202
+
203
+ def test_reset_rpc_failure_counts(mock_chain_interface):
204
+ """Test resetting failure counts."""
205
+ interface, _ = mock_chain_interface
206
+ interface._rpc_failure_counts = {0: 5, 1: 3}
207
+
208
+ interface.reset_rpc_failure_counts()
209
+
210
+ assert interface._rpc_failure_counts == {}