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,199 @@
1
+ """Tests for RPC rate limiting."""
2
+
3
+ import threading
4
+ import time
5
+ from unittest.mock import patch
6
+
7
+ from iwa.core.chain import RPCRateLimiter, get_rate_limiter
8
+
9
+
10
+ class TestRPCRateLimiter:
11
+ """Test cases for RPCRateLimiter."""
12
+
13
+ def test_init_defaults(self):
14
+ """Test default initialization."""
15
+ limiter = RPCRateLimiter()
16
+ assert limiter.rate == RPCRateLimiter.DEFAULT_RATE
17
+ assert limiter.burst == RPCRateLimiter.DEFAULT_BURST
18
+ assert limiter.tokens == float(limiter.burst)
19
+
20
+ def test_init_custom_values(self):
21
+ """Test custom initialization."""
22
+ limiter = RPCRateLimiter(rate=10.0, burst=20)
23
+ assert limiter.rate == 10.0
24
+ assert limiter.burst == 20
25
+
26
+ def test_acquire_success(self):
27
+ """Test successful token acquisition."""
28
+ limiter = RPCRateLimiter(rate=100.0, burst=10)
29
+ assert limiter.acquire(timeout=1.0) is True
30
+ assert limiter.tokens == 9.0
31
+
32
+ def test_acquire_multiple(self):
33
+ """Test acquiring multiple tokens."""
34
+ limiter = RPCRateLimiter(rate=100.0, burst=10)
35
+ for _ in range(5):
36
+ assert limiter.acquire(timeout=1.0) is True
37
+ # Allow small floating point variance due to time passage
38
+ assert 4.9 <= limiter.tokens <= 5.1
39
+
40
+ def test_acquire_exhausted_waits(self):
41
+ """Test that acquire waits when tokens exhausted."""
42
+ limiter = RPCRateLimiter(rate=100.0, burst=2)
43
+ # Exhaust tokens
44
+ limiter.tokens = 0.0
45
+ limiter.last_update = time.monotonic()
46
+
47
+ start = time.monotonic()
48
+ result = limiter.acquire(timeout=1.0)
49
+ elapsed = time.monotonic() - start
50
+
51
+ assert result is True
52
+ # Should have waited a bit for token refill
53
+ assert elapsed >= 0.005 # At least some wait
54
+
55
+ def test_acquire_timeout(self):
56
+ """Test that acquire times out."""
57
+ limiter = RPCRateLimiter(rate=0.1, burst=1) # Very slow refill
58
+ limiter.tokens = 0.0
59
+ limiter.last_update = time.monotonic()
60
+
61
+ result = limiter.acquire(timeout=0.01)
62
+ assert result is False
63
+
64
+ def test_trigger_backoff(self):
65
+ """Test backoff triggering."""
66
+ limiter = RPCRateLimiter()
67
+ _ = limiter.tokens # Check tokens exist
68
+
69
+ limiter.trigger_backoff(seconds=1.0)
70
+
71
+ assert limiter.tokens == 0
72
+ status = limiter.get_status()
73
+ assert status["in_backoff"] is True
74
+ assert status["backoff_remaining"] > 0
75
+
76
+ def test_backoff_blocks_acquire(self):
77
+ """Test that backoff blocks acquire."""
78
+ limiter = RPCRateLimiter(rate=100.0, burst=50)
79
+ limiter.trigger_backoff(seconds=10.0)
80
+
81
+ # Should timeout waiting for backoff
82
+ result = limiter.acquire(timeout=0.01)
83
+ assert result is False
84
+
85
+ def test_get_status(self):
86
+ """Test status reporting."""
87
+ limiter = RPCRateLimiter(rate=25.0, burst=50)
88
+ status = limiter.get_status()
89
+
90
+ assert "tokens" in status
91
+ assert "rate" in status
92
+ assert "burst" in status
93
+ assert "in_backoff" in status
94
+ assert status["rate"] == 25.0
95
+ assert status["burst"] == 50
96
+
97
+ def test_thread_safety(self):
98
+ """Test that rate limiter is thread-safe."""
99
+ limiter = RPCRateLimiter(rate=1000.0, burst=100)
100
+ acquired = []
101
+
102
+ def acquire_tokens():
103
+ for _ in range(10):
104
+ if limiter.acquire(timeout=1.0):
105
+ acquired.append(1)
106
+
107
+ threads = [threading.Thread(target=acquire_tokens) for _ in range(5)]
108
+ for t in threads:
109
+ t.start()
110
+ for t in threads:
111
+ t.join()
112
+
113
+ # All should have acquired
114
+ assert len(acquired) == 50
115
+
116
+
117
+ class TestGetRateLimiter:
118
+ """Test get_rate_limiter function."""
119
+
120
+ def test_creates_new_limiter(self):
121
+ """Test creating a new rate limiter."""
122
+ # Use unique chain name to avoid interference
123
+ limiter = get_rate_limiter("test_chain_unique_1")
124
+ assert isinstance(limiter, RPCRateLimiter)
125
+
126
+ def test_returns_same_limiter(self):
127
+ """Test that same chain returns same limiter."""
128
+ limiter1 = get_rate_limiter("test_chain_unique_2")
129
+ limiter2 = get_rate_limiter("test_chain_unique_2")
130
+ assert limiter1 is limiter2
131
+
132
+ def test_different_chains_different_limiters(self):
133
+ """Test that different chains get different limiters."""
134
+ limiter1 = get_rate_limiter("chain_a")
135
+ limiter2 = get_rate_limiter("chain_b")
136
+ assert limiter1 is not limiter2
137
+
138
+ def test_custom_rate(self):
139
+ """Test creating limiter with custom rate."""
140
+ limiter = get_rate_limiter("test_chain_unique_3", rate=50.0, burst=100)
141
+ assert limiter.rate == 50.0
142
+ assert limiter.burst == 100
143
+
144
+
145
+ class TestRateLimitRotationInterplay:
146
+ """Test interaction between rate limiting and RPC rotation."""
147
+
148
+ def test_rate_limit_triggers_rotation_first(self):
149
+ """Test that rate limit error triggers RPC rotation before backoff."""
150
+ from unittest.mock import MagicMock, PropertyMock
151
+
152
+ from iwa.core.chain import ChainInterface, SupportedChain
153
+
154
+ # Create a mock chain with multiple RPCs
155
+ with patch("iwa.core.chain.interface.Web3"):
156
+ chain = MagicMock(spec=SupportedChain)
157
+ chain.name = "TestChain"
158
+ chain.rpcs = ["https://rpc1", "https://rpc2", "https://rpc3"]
159
+ type(chain).rpc = PropertyMock(return_value="https://rpc1")
160
+
161
+ ci = ChainInterface(chain)
162
+ original_index = ci._current_rpc_index
163
+
164
+ # Mock health check to pass
165
+ with patch.object(ci, "check_rpc_health", return_value=True):
166
+ # Simulate rate limit error
167
+ rate_limit_error = Exception("Error 429: Too Many Requests")
168
+ result = ci._handle_rpc_error(rate_limit_error)
169
+
170
+ # Should have rotated
171
+ assert result["rotated"] is True
172
+ assert result["should_retry"] is True
173
+ assert ci._current_rpc_index != original_index
174
+ # Backoff should NOT be triggered since rotation succeeded
175
+ assert ci._rate_limiter.get_status()["in_backoff"] is False
176
+
177
+ def test_rate_limit_triggers_backoff_when_no_rotation(self):
178
+ """Test that rate limit triggers backoff when no other RPCs available."""
179
+ from unittest.mock import MagicMock, PropertyMock
180
+
181
+ from iwa.core.chain import ChainInterface, SupportedChain
182
+
183
+ # Create a mock chain with single RPC (can't rotate)
184
+ with patch("iwa.core.chain.interface.Web3"):
185
+ chain = MagicMock(spec=SupportedChain)
186
+ chain.name = "TestChainSingle"
187
+ chain.rpcs = ["https://rpc1"] # Only one RPC
188
+ type(chain).rpc = PropertyMock(return_value="https://rpc1")
189
+
190
+ ci = ChainInterface(chain)
191
+
192
+ # Simulate rate limit error
193
+ rate_limit_error = Exception("Error 429: Too Many Requests")
194
+ result = ci._handle_rpc_error(rate_limit_error)
195
+
196
+ # Should have triggered backoff since can't rotate
197
+ assert result["should_retry"] is True
198
+ assert result["rotated"] is False
199
+ assert ci._rate_limiter.get_status()["in_backoff"] is True
@@ -0,0 +1,202 @@
1
+ import os
2
+ from unittest.mock import MagicMock, mock_open, patch
3
+
4
+ import pytest
5
+
6
+ from iwa.core.models import FundRequirements, TenderlyConfig, TokenAmount, VirtualNet
7
+ from iwa.tools.reset_tenderly import (
8
+ _create_vnet,
9
+ _delete_vnet,
10
+ _fund_wallet,
11
+ _generate_vnet_slug,
12
+ main,
13
+ update_rpc_variables,
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_requests():
19
+ with patch("iwa.tools.reset_tenderly.requests") as mock:
20
+ import requests
21
+
22
+ mock.exceptions.JSONDecodeError = requests.exceptions.JSONDecodeError
23
+ yield mock
24
+
25
+
26
+ @pytest.fixture
27
+ def mock_tenderly_config():
28
+ config = MagicMock(spec=TenderlyConfig)
29
+ vnet = MagicMock(spec=VirtualNet)
30
+ vnet.vnet_id = "old_id"
31
+ vnet.chain_id = 1
32
+ vnet.public_rpc = "https://rpc.com"
33
+ vnet.admin_rpc = "https://admin.rpc.com"
34
+ vnet.funds_requirements = {
35
+ "tag1": FundRequirements(
36
+ native_eth=1.0,
37
+ tokens=[
38
+ TokenAmount(
39
+ address="0x1234567890123456789012345678901234567890",
40
+ amount_eth=10.0,
41
+ symbol="TKN",
42
+ )
43
+ ],
44
+ )
45
+ }
46
+ config.vnets = {"Gnosis": vnet}
47
+ return config
48
+
49
+
50
+ def test_delete_vnet(mock_requests):
51
+ _delete_vnet("key", "account", "project", "vnet_id")
52
+ mock_requests.delete.assert_called_once()
53
+ args, kwargs = mock_requests.delete.call_args
54
+ assert "vnet_id" in kwargs["url"]
55
+ assert kwargs["headers"]["X-Access-Key"] == "key"
56
+
57
+
58
+ def test_create_vnet(mock_requests):
59
+ mock_response = MagicMock()
60
+ mock_response.json.return_value = {
61
+ "id": "new_id",
62
+ "rpcs": [
63
+ {"name": "Admin RPC", "url": "admin_url"},
64
+ {"name": "Public RPC", "url": "public_url"},
65
+ ],
66
+ }
67
+ mock_requests.post.return_value = mock_response
68
+
69
+ vnet_id, admin_rpc, public_rpc = _create_vnet("key", "account", "project", 1, 1, "slug", "name")
70
+
71
+ assert vnet_id == "new_id"
72
+ assert admin_rpc == "admin_url"
73
+ assert public_rpc == "public_url"
74
+
75
+
76
+ def test_generate_vnet_slug():
77
+ slug = _generate_vnet_slug("prefix", 5)
78
+ assert slug.startswith("prefix-")
79
+ assert len(slug) == len("prefix-") + 5
80
+
81
+
82
+ def test_update_rpc_variables(mock_tenderly_config):
83
+ mock_file_content = "gnosis_test_rpc=old_url\nother_var=1"
84
+ with patch("builtins.open", mock_open(read_data=mock_file_content)) as mock_file:
85
+ update_rpc_variables(mock_tenderly_config)
86
+
87
+ mock_file().write.assert_called_once()
88
+ written_content = mock_file().write.call_args[0][0]
89
+ assert "gnosis_test_rpc=https://rpc.com" in written_content
90
+
91
+
92
+ def test_update_rpc_variables_new(mock_tenderly_config):
93
+ mock_file_content = "other_var=1"
94
+ with patch("builtins.open", mock_open(read_data=mock_file_content)) as mock_file:
95
+ update_rpc_variables(mock_tenderly_config)
96
+
97
+ mock_file().write.assert_called_once()
98
+ written_content = mock_file().write.call_args[0][0]
99
+ assert "gnosis_test_rpc=https://rpc.com" in written_content
100
+
101
+
102
+ def test_fund_wallet_native(mock_requests):
103
+ mock_requests.post.return_value.status_code = 200
104
+ _fund_wallet("admin_url", ["0x1"], 1.0, "native")
105
+
106
+ mock_requests.post.assert_called_once()
107
+ kwargs = mock_requests.post.call_args[1]
108
+ assert kwargs["json"]["method"] == "tenderly_setBalance"
109
+ assert kwargs["json"]["params"][1] == hex(int(1e18))
110
+
111
+
112
+ def test_fund_wallet_token(mock_requests):
113
+ mock_requests.post.return_value.status_code = 200
114
+ _fund_wallet("admin_url", ["0x1"], 1.0, "0xToken")
115
+
116
+ mock_requests.post.assert_called_once()
117
+ kwargs = mock_requests.post.call_args[1]
118
+ assert kwargs["json"]["method"] == "tenderly_setErc20Balance"
119
+ assert kwargs["json"]["params"][0] == "0xToken"
120
+
121
+
122
+ def test_fund_wallet_error(mock_requests, capsys):
123
+ mock_requests.post.return_value.status_code = 500
124
+ mock_requests.post.return_value.json.return_value = {"error": "fail"}
125
+
126
+ _fund_wallet("admin_url", ["0x1"], 1.0)
127
+
128
+ captured = capsys.readouterr()
129
+ assert "500" in captured.out
130
+ assert "{'error': 'fail'}" in captured.out
131
+
132
+
133
+ def test_fund_wallet_json_error(mock_requests, capsys):
134
+ import requests
135
+
136
+ mock_requests.post.return_value.status_code = 500
137
+ mock_requests.post.return_value.json.side_effect = requests.exceptions.JSONDecodeError(
138
+ "msg", "doc", 0
139
+ )
140
+
141
+ _fund_wallet("admin_url", ["0x1"], 1.0)
142
+
143
+ captured = capsys.readouterr()
144
+ assert "500" in captured.out
145
+ # Should not print json error
146
+ assert "msg" not in captured.out
147
+
148
+
149
+ def test_main(mock_requests, mock_tenderly_config):
150
+ # Mock env vars
151
+ with patch.dict(
152
+ os.environ,
153
+ {
154
+ "tenderly_account_slug": "acc",
155
+ "tenderly_project_slug": "proj",
156
+ "tenderly_access_key": "key",
157
+ },
158
+ ):
159
+ # Mock TenderlyConfig.load
160
+ with patch("iwa.core.models.TenderlyConfig.load", return_value=mock_tenderly_config):
161
+ with patch(
162
+ "iwa.tools.reset_tenderly.get_tenderly_credentials",
163
+ return_value=("acc", "proj", "key"),
164
+ ):
165
+ # Mock KeyStorage
166
+ with patch("iwa.tools.reset_tenderly.KeyStorage") as mock_key_storage:
167
+ mock_keys = mock_key_storage.return_value
168
+ mock_keys.get_account.return_value.address = "0xAddress"
169
+ mock_keys.accounts.keys.return_value = ["tag1"]
170
+
171
+ # Mock _create_vnet return values (since we mock requests, _create_vnet logic runs, but we can also mock _create_vnet directly)
172
+ # But let's let it run with mocked requests
173
+ mock_response_create = MagicMock()
174
+ mock_response_create.json.return_value = {
175
+ "id": "new_id",
176
+ "rpcs": [
177
+ {"name": "Admin RPC", "url": "admin"},
178
+ {"name": "Public RPC", "url": "public"},
179
+ ],
180
+ }
181
+
182
+ # Mock requests.post for create and fund
183
+ # We need side_effect to handle different calls
184
+ def post_side_effect(*args, **kwargs):
185
+ if "vnets" in kwargs.get("url", ""):
186
+ return mock_response_create
187
+ return MagicMock(status_code=200)
188
+
189
+ mock_requests.post.side_effect = post_side_effect
190
+
191
+ # Mock update_rpc_variables to avoid file I/O
192
+ with patch("iwa.tools.reset_tenderly.update_rpc_variables") as mock_update:
193
+ # Mock SafeService
194
+ with patch("iwa.core.services.SafeService") as mock_safe_service_cls:
195
+ main()
196
+
197
+ # Verify interactions
198
+ mock_requests.delete.assert_called() # _delete_vnet
199
+ mock_tenderly_config.save.assert_called()
200
+ mock_update.assert_called()
201
+ # mock_keys.redeploy_safes.assert_called() # Removed
202
+ mock_safe_service_cls.return_value.redeploy_safes.assert_called()
tests/test_rpc_view.py ADDED
@@ -0,0 +1,73 @@
1
+ """Tests for TUI RPC View."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.tui.rpc import RPCView
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_chain_interfaces():
12
+ with patch("iwa.tui.rpc.ChainInterfaces") as mock:
13
+ yield mock
14
+
15
+
16
+ def test_rpc_view_compose():
17
+ """Test RPCView compose method returns expected widgets."""
18
+ view = RPCView()
19
+ result = list(view.compose())
20
+
21
+ assert len(result) == 2 # Label and DataTable
22
+
23
+
24
+ def test_check_rpcs_interface_not_found(mock_chain_interfaces):
25
+ """Test check_rpcs handles missing interface."""
26
+ mock_chain_interfaces.return_value.get.return_value = None
27
+
28
+ view = RPCView()
29
+
30
+ # Verify the method exists and is callable
31
+ assert hasattr(view, "check_rpcs")
32
+ assert callable(view.check_rpcs)
33
+
34
+
35
+ def test_check_rpcs_no_rpc_url(mock_chain_interfaces):
36
+ """Test check_rpcs handles missing RPC URL."""
37
+ mock_interface = MagicMock()
38
+ mock_interface.chain.rpc = ""
39
+ mock_chain_interfaces.return_value.get.return_value = mock_interface
40
+
41
+ view = RPCView()
42
+
43
+ # Verify method exists and has correct signature
44
+ assert callable(view.check_rpcs)
45
+
46
+
47
+ def test_check_rpcs_connection_error(mock_chain_interfaces):
48
+ """Test check_rpcs handles connection errors."""
49
+ mock_interface = MagicMock()
50
+ mock_interface.chain.rpc = "http://localhost:8545"
51
+ mock_interface.web3.is_connected.side_effect = Exception("Connection refused")
52
+ mock_chain_interfaces.return_value.get.return_value = mock_interface
53
+
54
+ view = RPCView()
55
+
56
+ assert callable(view.check_rpcs)
57
+
58
+
59
+ def test_update_table():
60
+ """Test update_table method updates DataTable."""
61
+ view = RPCView()
62
+
63
+ mock_table = MagicMock()
64
+ with patch.object(view, "query_one", return_value=mock_table):
65
+ results = [
66
+ ("gnosis", "http://rpc1", "Online", "50.00"),
67
+ ("ethereum", "http://rpc2", "Offline", "-"),
68
+ ]
69
+
70
+ view.update_table(results)
71
+
72
+ mock_table.clear.assert_called_once()
73
+ assert mock_table.add_row.call_count == 2
@@ -0,0 +1,139 @@
1
+ """Tests for SafeService coverage."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.core.models import StoredSafeAccount
8
+ from iwa.core.services.safe import SafeService
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_deps():
13
+ """Mock dependencies for SafeService."""
14
+ mock_key_storage = MagicMock()
15
+ mock_account_service = MagicMock()
16
+
17
+ return {
18
+ "key_storage": mock_key_storage,
19
+ "account_service": mock_account_service,
20
+ }
21
+
22
+
23
+ @pytest.fixture
24
+ def safe_service(mock_deps):
25
+ """SafeService instance."""
26
+ return SafeService(mock_deps["key_storage"], mock_deps["account_service"])
27
+
28
+
29
+ def test_execute_safe_transaction_success(safe_service, mock_deps):
30
+ """Test execute_safe_transaction success."""
31
+ # Mock inputs
32
+ safe_address = "0xSafe"
33
+ to_address = "0xTo"
34
+ value = 1000
35
+ chain_name = "gnosis"
36
+
37
+ # Mock Safe Account
38
+ mock_account = MagicMock(spec=StoredSafeAccount)
39
+ mock_account.address = safe_address
40
+ mock_account.signers = ["0xSigner1", "0xSigner2"]
41
+ mock_account.threshold = 1
42
+ mock_deps["key_storage"].find_stored_account.return_value = mock_account
43
+
44
+ # Mock Private Keys
45
+ mock_deps["key_storage"]._get_private_key.return_value = "0xPrivKey"
46
+
47
+ # Mock SafeMultisig via patch
48
+ # The import is inside the method: from iwa.plugins.gnosis.safe import SafeMultisig
49
+ # We need to patch where it is IMPORTED from
50
+ with patch("iwa.plugins.gnosis.safe.SafeMultisig") as mock_safe_multisig_cls:
51
+ # But wait, execute_safe_transaction does local import.
52
+ # patch('iwa.core.services.safe.SafeMultisig') won't work if it's not global.
53
+ # We must patch 'iwa.plugins.gnosis.safe.SafeMultisig'.
54
+ # And since it's imported INSIDE the function, patching the source module works.
55
+
56
+ mock_safe_instance = mock_safe_multisig_cls.return_value
57
+ mock_safe_tx = MagicMock()
58
+ mock_safe_instance.build_tx.return_value = mock_safe_tx
59
+ mock_safe_tx.tx_hash.hex.return_value = "0xTxHash"
60
+
61
+ # Execute
62
+ tx_hash = safe_service.execute_safe_transaction(safe_address, to_address, value, chain_name)
63
+
64
+ # Verify
65
+ assert tx_hash == "0xTxHash"
66
+ mock_safe_tx.sign.assert_called()
67
+ mock_safe_tx.execute.assert_called()
68
+
69
+
70
+ def test_execute_safe_transaction_account_not_found(safe_service, mock_deps):
71
+ """Test execute_safe_transaction fails if account not found."""
72
+ mock_deps["key_storage"].find_stored_account.return_value = None
73
+
74
+ with pytest.raises(ValueError, match="Safe account '0xSafe' not found"):
75
+ safe_service.execute_safe_transaction("0xSafe", "0xTo", 0, "gnosis")
76
+
77
+
78
+ def test_get_sign_and_execute_callback(safe_service, mock_deps):
79
+ """Test get_sign_and_execute_callback returns working callback."""
80
+ safe_address = "0xSafe"
81
+ mock_account = MagicMock(spec=StoredSafeAccount)
82
+ mock_account.address = safe_address
83
+ mock_account.signers = ["0xSigner1"]
84
+ mock_account.threshold = 1
85
+ mock_deps["key_storage"].find_stored_account.return_value = mock_account
86
+ mock_deps["key_storage"]._get_private_key.return_value = "0xPrivKey"
87
+
88
+ callback = safe_service.get_sign_and_execute_callback(safe_address)
89
+ assert callable(callback)
90
+
91
+ # Test executing callback
92
+ mock_safe_tx = MagicMock()
93
+ mock_safe_tx.tx_hash.hex.return_value = "0xTxHash"
94
+
95
+ result = callback(mock_safe_tx)
96
+
97
+ assert result == "0xTxHash"
98
+ mock_safe_tx.sign.assert_called()
99
+ mock_safe_tx.execute.assert_called()
100
+
101
+
102
+ def test_get_sign_and_execute_callback_fail(safe_service, mock_deps):
103
+ """Test callback generation fails if account missing."""
104
+ mock_deps["key_storage"].find_stored_account.return_value = None
105
+ with pytest.raises(ValueError):
106
+ safe_service.get_sign_and_execute_callback("0xSafe")
107
+
108
+
109
+ def test_redeploy_safes(safe_service, mock_deps):
110
+ """Test redeploy_safes logic."""
111
+ # Mock accounts
112
+ account1 = MagicMock(spec=StoredSafeAccount)
113
+ account1.address = "0xSafe1"
114
+ account1.chains = ["gnosis"]
115
+ account1.signers = ["0xSigner"]
116
+ account1.threshold = 1
117
+ # account1.tag needs to be accessible
118
+ account1.tag = " Safe1"
119
+
120
+ mock_deps["key_storage"].accounts = {"0xSafe1": account1}
121
+
122
+ with patch("iwa.core.services.safe.settings") as mock_settings:
123
+ mock_settings.gnosis_rpc.get_secret_value.return_value = "http://rpc"
124
+
125
+ with patch("iwa.core.services.safe.EthereumClient") as mock_eth_client:
126
+ with patch.object(safe_service, "create_safe") as mock_create:
127
+ mock_w3 = mock_eth_client.return_value.w3
128
+
129
+ # Case 1: Code exists (no redeploy)
130
+ mock_w3.eth.get_code.return_value = b"code"
131
+ safe_service.redeploy_safes()
132
+ mock_create.assert_not_called()
133
+
134
+ # Case 2: No code (redeploy)
135
+ mock_w3.eth.get_code.return_value = b""
136
+ # Need to mock remove_account
137
+ safe_service.redeploy_safes()
138
+ mock_deps["key_storage"].remove_account.assert_called_with("0xSafe1")
139
+ mock_create.assert_called()