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_main.py ADDED
@@ -0,0 +1,13 @@
1
+ import runpy
2
+ import sys
3
+ from unittest.mock import patch
4
+
5
+
6
+ def test_main_execution():
7
+ """Test that python -m iwa executes the CLI."""
8
+ # We mock iwa.core.cli.iwa_cli to verify it gets called
9
+ with patch("iwa.core.cli.iwa_cli") as mock_cli:
10
+ # We also need to mock sys.argv to avoid interfering with pytest args
11
+ with patch.object(sys, "argv", ["iwa"]):
12
+ runpy.run_module("iwa", run_name="__main__")
13
+ mock_cli.assert_called_once()
tests/test_mnemonic.py ADDED
@@ -0,0 +1,217 @@
1
+ import base64
2
+ import json
3
+ from pathlib import Path
4
+ from unittest.mock import mock_open, patch
5
+
6
+ import pytest
7
+ from cryptography.exceptions import InvalidTag
8
+
9
+ from iwa.core.mnemonic import (
10
+ WALLET_PATH,
11
+ EncryptedMnemonic,
12
+ MnemonicManager,
13
+ MnemonicStorage,
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_scrypt():
19
+ with patch("iwa.core.mnemonic.Scrypt") as mock:
20
+ mock.return_value.derive.return_value = b"derived_key"
21
+ yield mock
22
+
23
+
24
+ @pytest.fixture
25
+ def mock_aesgcm():
26
+ with patch("iwa.core.mnemonic.AESGCM") as mock:
27
+ mock.return_value.encrypt.return_value = b"ciphertext"
28
+ mock.return_value.decrypt.return_value = b"plaintext"
29
+ yield mock
30
+
31
+
32
+ @pytest.fixture
33
+ def mock_bip39_generator():
34
+ with patch("iwa.core.mnemonic.Bip39MnemonicGenerator") as mock:
35
+ mock.return_value.FromWordsNumber.return_value.ToStr.return_value = "word1 word2 word3"
36
+ yield mock
37
+
38
+
39
+ @pytest.fixture
40
+ def mock_bip39_seed_generator():
41
+ with patch("iwa.core.mnemonic.Bip39SeedGenerator") as mock:
42
+ mock.return_value.Generate.return_value = b"seed"
43
+ yield mock
44
+
45
+
46
+ @pytest.fixture
47
+ def mock_bip44():
48
+ with patch("iwa.core.mnemonic.Bip44") as mock:
49
+ mock.FromSeed.return_value.Purpose.return_value.Coin.return_value.Account.return_value.Change.return_value.AddressIndex.return_value.PrivateKey.return_value.Raw.return_value.ToHex.return_value = "1234"
50
+ yield mock
51
+
52
+
53
+ @pytest.fixture
54
+ def mock_console():
55
+ with patch("iwa.core.mnemonic.Console") as mock:
56
+ yield mock.return_value
57
+
58
+
59
+ def test_encrypted_mnemonic_derive_key(mock_scrypt):
60
+ em = EncryptedMnemonic(kdf_salt="salt", nonce="nonce", ciphertext="ciphertext")
61
+ key = em.derive_key(b"password")
62
+ assert key == b"derived_key"
63
+ mock_scrypt.assert_called_once()
64
+
65
+
66
+ def test_encrypted_mnemonic_decrypt_success(mock_scrypt, mock_aesgcm):
67
+ em = EncryptedMnemonic(
68
+ kdf_salt=base64.b64encode(b"salt").decode(),
69
+ nonce=base64.b64encode(b"nonce").decode(),
70
+ ciphertext=base64.b64encode(b"ciphertext").decode(),
71
+ )
72
+ plaintext = em.decrypt("password")
73
+ assert plaintext == "plaintext"
74
+
75
+
76
+ def test_encrypted_mnemonic_decrypt_unsupported_kdf():
77
+ em = EncryptedMnemonic(
78
+ kdf="unsupported", kdf_salt="salt", nonce="nonce", ciphertext="ciphertext"
79
+ )
80
+ with pytest.raises(ValueError, match="Unsupported kdf"):
81
+ em.decrypt("password")
82
+
83
+
84
+ def test_encrypted_mnemonic_decrypt_unsupported_cipher():
85
+ em = EncryptedMnemonic(
86
+ cypher="unsupported", kdf_salt="salt", nonce="nonce", ciphertext="ciphertext"
87
+ )
88
+ with pytest.raises(ValueError, match="Unsupported cipher"):
89
+ em.decrypt("password")
90
+
91
+
92
+ def test_encrypted_mnemonic_encrypt(mock_scrypt, mock_aesgcm):
93
+ with patch("os.urandom", return_value=b"random"):
94
+ data = EncryptedMnemonic.encrypt("mnemonic", "password")
95
+ assert data["kdf"] == "scrypt"
96
+ assert data["cipher"] == "aesgcm"
97
+ assert data["ciphertext"] == base64.b64encode(b"ciphertext").decode()
98
+
99
+
100
+ def test_mnemonic_storage_load():
101
+ data = {
102
+ "encrypted_mnemonic": {"kdf_salt": "salt", "nonce": "nonce", "ciphertext": "ciphertext"},
103
+ "accounts": {},
104
+ }
105
+ with patch("builtins.open", mock_open(read_data=json.dumps(data))):
106
+ storage = MnemonicStorage.load(Path("test.json"))
107
+ assert isinstance(storage.encrypted_mnemonic, EncryptedMnemonic)
108
+
109
+
110
+ def test_mnemonic_storage_save():
111
+ storage = MnemonicStorage(
112
+ encrypted_mnemonic=EncryptedMnemonic(
113
+ kdf_salt="salt", nonce="nonce", ciphertext="ciphertext"
114
+ )
115
+ )
116
+ with patch("builtins.open", mock_open()) as mock_file, patch("os.chmod") as mock_chmod:
117
+ storage.save(Path("test.json"))
118
+ mock_file.assert_called_once()
119
+ mock_chmod.assert_called_once()
120
+
121
+
122
+ def test_mnemonic_manager_init():
123
+ mgr = MnemonicManager()
124
+ assert mgr.mnemonic_file == WALLET_PATH
125
+
126
+
127
+ def test_mnemonic_manager_derive_key(mock_scrypt):
128
+ mgr = MnemonicManager()
129
+ key = mgr.derive_key(b"password", b"salt")
130
+ assert key == b"derived_key"
131
+
132
+
133
+ def test_mnemonic_manager_encrypt_mnemonic(mock_scrypt, mock_aesgcm):
134
+ mgr = MnemonicManager()
135
+ with patch("os.urandom", return_value=b"random"):
136
+ data = mgr.encrypt_mnemonic("mnemonic", "password")
137
+ assert data["kdf"] == "scrypt"
138
+ assert data["cipher"] == "aesgcm"
139
+
140
+
141
+ def test_mnemonic_manager_decrypt_mnemonic_success(mock_scrypt, mock_aesgcm):
142
+ mgr = MnemonicManager()
143
+ encobj = {
144
+ "kdf": "scrypt",
145
+ "kdf_salt": base64.b64encode(b"salt").decode(),
146
+ "nonce": base64.b64encode(b"nonce").decode(),
147
+ "ciphertext": base64.b64encode(b"ciphertext").decode(),
148
+ "cipher": "aesgcm",
149
+ }
150
+ plaintext = mgr.decrypt_mnemonic(encobj, "password")
151
+ assert plaintext == "plaintext"
152
+
153
+
154
+ def test_mnemonic_manager_decrypt_mnemonic_unsupported_kdf():
155
+ mgr = MnemonicManager()
156
+ encobj = {"kdf": "unsupported"}
157
+ with pytest.raises(ValueError, match="Unsupported kdf"):
158
+ mgr.decrypt_mnemonic(encobj, "password")
159
+
160
+
161
+ def test_mnemonic_manager_decrypt_mnemonic_unsupported_cipher():
162
+ mgr = MnemonicManager()
163
+ encobj = {"kdf": "scrypt", "cipher": "unsupported"}
164
+ with pytest.raises(ValueError, match="Unsupported cipher"):
165
+ mgr.decrypt_mnemonic(encobj, "password")
166
+
167
+
168
+ def test_mnemonic_manager_generate_and_store_mnemonic(
169
+ mock_bip39_generator, mock_scrypt, mock_aesgcm
170
+ ):
171
+ mgr = MnemonicManager()
172
+ with patch("builtins.open", mock_open()) as mock_file, patch("os.chmod") as mock_chmod:
173
+ mnemonic = mgr.generate_and_store_mnemonic("password", "test.json")
174
+ mock_file.assert_called_once()
175
+ mock_chmod.assert_called_once()
176
+ assert mnemonic == "word1 word2 word3"
177
+
178
+
179
+ def test_mnemonic_manager_load_and_decrypt_mnemonic_invalid_tag(mock_scrypt, mock_aesgcm):
180
+ mgr = MnemonicManager()
181
+ mock_aesgcm.return_value.decrypt.side_effect = InvalidTag()
182
+ encobj = {
183
+ "kdf": "scrypt",
184
+ "kdf_salt": base64.b64encode(b"salt").decode(),
185
+ "nonce": base64.b64encode(b"nonce").decode(),
186
+ "ciphertext": base64.b64encode(b"ciphertext").decode(),
187
+ "cipher": "aesgcm",
188
+ }
189
+ with patch("builtins.open", mock_open(read_data=json.dumps(encobj))):
190
+ with pytest.raises(ValueError, match="Incorrect password"):
191
+ mgr.load_and_decrypt_mnemonic("password", "test.json")
192
+
193
+
194
+ def test_mnemonic_manager_derive_eth_accounts_from_mnemonic(
195
+ mock_scrypt, mock_aesgcm, mock_bip39_seed_generator, mock_bip44
196
+ ):
197
+ mgr = MnemonicManager()
198
+ # Mock load_and_decrypt_mnemonic to return a mnemonic
199
+ with (
200
+ patch.object(mgr, "load_and_decrypt_mnemonic", return_value="mnemonic"),
201
+ patch("iwa.core.mnemonic.Account") as mock_account,
202
+ ):
203
+ mock_account.from_key.return_value.address = "0xAddress"
204
+
205
+ accounts = mgr.derive_eth_accounts_from_mnemonic("password", n_accounts=1)
206
+ assert len(accounts) == 1
207
+ assert accounts[0]["address"] == "0xAddress"
208
+ assert accounts[0]["private_key_hex"] == "1234"
209
+
210
+
211
+ def test_mnemonic_manager_derive_eth_accounts_from_mnemonic_none(mock_scrypt, mock_aesgcm):
212
+ mgr = MnemonicManager()
213
+ with (
214
+ patch.object(mgr, "load_and_decrypt_mnemonic", return_value=None),
215
+ ):
216
+ accounts = mgr.derive_eth_accounts_from_mnemonic("password")
217
+ assert accounts is None
tests/test_modals.py ADDED
@@ -0,0 +1,109 @@
1
+ """Tests for TUI modals to boost coverage."""
2
+
3
+ import pytest
4
+ from textual.app import App, ComposeResult
5
+ from textual.containers import Container
6
+
7
+ # === Modal compose tests - these test the UI rendering paths ===
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_create_eoa_modal_compose():
12
+ """Cover CreateEOAModal.compose (lines 56-64)."""
13
+ from iwa.tui.modals.base import CreateEOAModal
14
+
15
+ modal = CreateEOAModal()
16
+
17
+ class TestApp(App):
18
+ def compose(self) -> ComposeResult:
19
+ yield Container()
20
+
21
+ app = TestApp()
22
+ async with app.run_test() as pilot:
23
+ app.push_screen(modal)
24
+ await pilot.pause()
25
+
26
+ # Verify modal is mounted
27
+ assert modal.is_mounted
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_create_safe_modal_compose():
32
+ """Cover CreateSafeModal.compose (lines 128-149)."""
33
+ from iwa.tui.modals.base import CreateSafeModal
34
+
35
+ accounts = [("wallet1", "0x123"), ("wallet2", "0x456")]
36
+ modal = CreateSafeModal(existing_accounts=accounts)
37
+
38
+ class TestApp(App):
39
+ def compose(self) -> ComposeResult:
40
+ yield Container()
41
+
42
+ app = TestApp()
43
+ async with app.run_test() as pilot:
44
+ app.push_screen(modal)
45
+ await pilot.pause()
46
+
47
+ assert modal.is_mounted
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_stake_service_modal_compose():
52
+ """Cover StakeServiceModal.compose (lines 205-214)."""
53
+ from iwa.tui.modals.base import StakeServiceModal
54
+
55
+ contracts = [("Contract1", "0xabc"), ("Contract2", "0xdef")]
56
+ modal = StakeServiceModal(contracts=contracts)
57
+
58
+ class TestApp(App):
59
+ def compose(self) -> ComposeResult:
60
+ yield Container()
61
+
62
+ app = TestApp()
63
+ async with app.run_test() as pilot:
64
+ app.push_screen(modal)
65
+ await pilot.pause()
66
+
67
+ assert modal.is_mounted
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_create_service_modal_compose():
72
+ """Cover CreateServiceModal.compose (lines 280-303)."""
73
+ from iwa.tui.modals.base import CreateServiceModal
74
+
75
+ chains = ["gnosis", "ethereum"]
76
+ staking_contracts = [("Staking1", "0xabc")]
77
+ modal = CreateServiceModal(
78
+ chains=chains, default_chain="gnosis", staking_contracts=staking_contracts
79
+ )
80
+
81
+ class TestApp(App):
82
+ def compose(self) -> ComposeResult:
83
+ yield Container()
84
+
85
+ app = TestApp()
86
+ async with app.run_test() as pilot:
87
+ app.push_screen(modal)
88
+ await pilot.pause()
89
+
90
+ assert modal.is_mounted
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_fund_service_modal_compose():
95
+ """Cover FundServiceModal.compose (lines 371-382)."""
96
+ from iwa.tui.modals.base import FundServiceModal
97
+
98
+ modal = FundServiceModal(service_key="gnosis:1", native_symbol="xDAI")
99
+
100
+ class TestApp(App):
101
+ def compose(self) -> ComposeResult:
102
+ yield Container()
103
+
104
+ app = TestApp()
105
+ async with app.run_test() as pilot:
106
+ app.push_screen(modal)
107
+ await pilot.pause()
108
+
109
+ assert modal.is_mounted
tests/test_models.py ADDED
@@ -0,0 +1,213 @@
1
+ from pathlib import Path
2
+ from unittest.mock import mock_open, patch
3
+
4
+ import pytest
5
+
6
+ from iwa.core.models import EthereumAddress, StorableModel
7
+
8
+
9
+ def test_ethereum_address_valid():
10
+ addr_str = "0x1111111111111111111111111111111111111111"
11
+ addr = EthereumAddress(addr_str)
12
+ assert addr == addr_str
13
+
14
+
15
+ def test_ethereum_address_invalid():
16
+ with pytest.raises(ValueError, match="Invalid Ethereum address"):
17
+ EthereumAddress("0xInvalid")
18
+
19
+
20
+ def test_ethereum_address_checksum():
21
+ # Test that it converts to checksum address
22
+ addr_lower = "0x1111111111111111111111111111111111111111" # All 1s is same
23
+ # Let's use one that changes
24
+ addr_lower = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
25
+ addr_checksum = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
26
+ addr = EthereumAddress(addr_lower)
27
+ assert addr == addr_checksum
28
+
29
+
30
+ class MockStorableModel(StorableModel):
31
+ name: str
32
+ value: int
33
+
34
+
35
+ def test_storable_model_save_json():
36
+ model = MockStorableModel(name="test", value=123)
37
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
38
+ model.save_json(Path("test.json"))
39
+ mock_file.assert_called_once()
40
+
41
+
42
+ def test_storable_model_save_toml():
43
+ model = MockStorableModel(name="test", value=123)
44
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
45
+ model.save_toml(Path("test.toml"))
46
+ mock_file.assert_called_once()
47
+
48
+
49
+ def test_storable_model_save_yaml():
50
+ model = MockStorableModel(name="test", value=123)
51
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
52
+ model.save_yaml(Path("test.yaml"))
53
+ mock_file.assert_called_once()
54
+
55
+
56
+ def test_storable_model_save_auto_json():
57
+ model = MockStorableModel(name="test", value=123)
58
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
59
+ model.save("test.json")
60
+ mock_file.assert_called_once()
61
+
62
+
63
+ def test_storable_model_save_auto_toml():
64
+ model = MockStorableModel(name="test", value=123)
65
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
66
+ model.save("test.toml")
67
+ mock_file.assert_called_once()
68
+
69
+
70
+ def test_storable_model_save_auto_yaml():
71
+ model = MockStorableModel(name="test", value=123)
72
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
73
+ model.save("test.yaml")
74
+ mock_file.assert_called_once()
75
+
76
+
77
+ def test_storable_model_save_no_path():
78
+ model = MockStorableModel(name="test", value=123)
79
+ with pytest.raises(ValueError, match="Save path not specified"):
80
+ model.save()
81
+
82
+
83
+ def test_storable_model_save_stored_path():
84
+ model = MockStorableModel(name="test", value=123)
85
+ model._path = Path("stored.json")
86
+ model._storage_format = "json"
87
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
88
+ model.save()
89
+ mock_file.assert_called_once()
90
+
91
+
92
+ def test_storable_model_load_json():
93
+ json_content = '{"name": "test", "value": 123}'
94
+ with patch("pathlib.Path.open", mock_open(read_data=json_content)):
95
+ model = MockStorableModel.load_json("test.json")
96
+ assert model.name == "test"
97
+ assert model.value == 123
98
+
99
+
100
+ def test_storable_model_load_toml():
101
+ toml_content = b'name = "test"\nvalue = 123'
102
+ with patch("pathlib.Path.open", mock_open(read_data=toml_content)):
103
+ model = MockStorableModel.load_toml("test.toml")
104
+ assert model.name == "test"
105
+ assert model.value == 123
106
+
107
+
108
+ def test_storable_model_load_yaml():
109
+ yaml_content = "name: test\nvalue: 123"
110
+ with patch("pathlib.Path.open", mock_open(read_data=yaml_content)):
111
+ model = MockStorableModel.load_yaml("test.yaml")
112
+ assert model.name == "test"
113
+ assert model.value == 123
114
+
115
+
116
+ def test_storable_model_load_auto():
117
+ json_content = '{"name": "test", "value": 123}'
118
+ with patch("pathlib.Path.open", mock_open(read_data=json_content)):
119
+ model = MockStorableModel.load(Path("test.json"))
120
+ assert model.name == "test"
121
+
122
+
123
+ def test_ethereum_address_validate_method():
124
+ # Test the validate class method directly
125
+ with pytest.raises(ValueError, match="Invalid Ethereum address"):
126
+ EthereumAddress.validate("0xInvalid", None)
127
+
128
+ addr = EthereumAddress.validate("0x1111111111111111111111111111111111111111", None)
129
+ assert addr == "0x1111111111111111111111111111111111111111"
130
+
131
+
132
+ def test_storable_model_save_methods_no_path_error():
133
+ model = MockStorableModel(name="test", value=123)
134
+ # Ensure no _path is set
135
+ if hasattr(model, "_path"):
136
+ del model._path
137
+
138
+ with pytest.raises(ValueError, match="Save path not specified"):
139
+ model.save_json()
140
+
141
+ with pytest.raises(ValueError, match="Save path not specified"):
142
+ model.save_toml()
143
+
144
+ with pytest.raises(ValueError, match="Save path not specified"):
145
+ model.save_yaml()
146
+
147
+
148
+ def test_storable_model_save_unsupported_extension():
149
+ model = MockStorableModel(name="test", value=123)
150
+ with pytest.raises(ValueError, match="Extension not supported"):
151
+ model.save("test.txt")
152
+
153
+
154
+ def test_storable_model_save_fallback_format():
155
+ model = MockStorableModel(name="test", value=123)
156
+ model._storage_format = "json"
157
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
158
+ model.save("test.txt") # Unknown extension, fallback to _storage_format
159
+ mock_file.assert_called_once() # Should call save_json
160
+
161
+ model._storage_format = "toml"
162
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
163
+ model.save("test.txt")
164
+ mock_file.assert_called_once() # Should call save_toml
165
+
166
+ model._storage_format = "yaml"
167
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
168
+ model.save("test.txt")
169
+ mock_file.assert_called_once() # Should call save_yaml
170
+
171
+
172
+ def test_storable_model_load_unsupported_extension():
173
+ with pytest.raises(ValueError, match="Unsupported file extension"):
174
+ MockStorableModel.load(Path("test.txt"))
175
+
176
+
177
+ from iwa.core.models import Config
178
+
179
+
180
+ def test_config_singleton():
181
+ c1 = Config()
182
+ c2 = Config()
183
+ assert c1 is c2
184
+
185
+
186
+ def test_storable_model_save_with_stored_path():
187
+ model = MockStorableModel(name="test", value=123)
188
+ model._path = Path("stored.json")
189
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
190
+ model.save_json()
191
+ mock_file.assert_called_once()
192
+
193
+ model._path = Path("stored.toml")
194
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
195
+ model.save_toml()
196
+ mock_file.assert_called_once()
197
+
198
+ model._path = Path("stored.yaml")
199
+ with patch("pathlib.Path.open", mock_open()) as mock_file:
200
+ model.save_yaml()
201
+ mock_file.assert_called_once()
202
+
203
+
204
+ def test_storable_model_load_auto_toml_yaml():
205
+ toml_content = b'name = "test"\nvalue = 123'
206
+ with patch("pathlib.Path.open", mock_open(read_data=toml_content)):
207
+ model = MockStorableModel.load(Path("test.toml"))
208
+ assert model.name == "test"
209
+
210
+ yaml_content = "name: test\nvalue: 123"
211
+ with patch("pathlib.Path.open", mock_open(read_data=yaml_content)):
212
+ model = MockStorableModel.load(Path("test.yaml"))
213
+ assert model.name == "test"