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
iwa/core/tables.py ADDED
@@ -0,0 +1,60 @@
1
+ """Account storage protocol definitions"""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from iwa.core.chain import ChainInterface
10
+ from iwa.core.models import StoredSafeAccount
11
+
12
+
13
+ def list_accounts(
14
+ accounts: Optional[Dict],
15
+ chain_interface: ChainInterface,
16
+ token_names: Optional[List[str]],
17
+ token_balances: Optional[Dict],
18
+ ) -> None:
19
+ """List accounts"""
20
+ console = Console()
21
+ table = Table(
22
+ title="Accounts",
23
+ show_header=True,
24
+ )
25
+
26
+ table.add_column("Address", style="dim", width=42, justify="center")
27
+ table.add_column("Type", style="dim", width=10, justify="center")
28
+ table.add_column("Tag", style="dim", width=20, justify="center")
29
+
30
+ if token_names:
31
+ for token_name in token_names:
32
+ token = (
33
+ chain_interface.chain.native_currency
34
+ if token_name == "native"
35
+ else token_name.upper()
36
+ )
37
+ table.add_column(f"Balance {token}", style="dim", justify="center")
38
+
39
+ if accounts:
40
+ for acct in accounts.values():
41
+ acct_type = "Safe" if isinstance(acct, StoredSafeAccount) else "EOA"
42
+ tag_cell = Text(acct.tag, style="bold green")
43
+ args = (acct.address, acct_type, tag_cell)
44
+ if token_balances:
45
+ balances = token_balances.get(acct.address)
46
+ for token_name, token_balance in balances.items():
47
+ token = (
48
+ chain_interface.chain.native_currency
49
+ if token_name == "native"
50
+ else token_name.upper()
51
+ )
52
+ args += (f"{token_balance:.2f} {token}",)
53
+ table.add_row(*args)
54
+ else:
55
+ row_args = ("No accounts found", "-")
56
+ if token_balances:
57
+ row_args += tuple("-" for _ in token_balances)
58
+ table.add_row(*row_args)
59
+
60
+ console.print(table, justify="center")
iwa/core/test.py ADDED
@@ -0,0 +1,27 @@
1
+ """Test module."""
2
+
3
+ import asyncio
4
+
5
+ from iwa.core.wallet import Wallet
6
+ from iwa.plugins.olas.service_manager import ServiceManager
7
+
8
+ wallet = Wallet()
9
+
10
+
11
+ async def main():
12
+ """Example of using CoW Swap on Gnosis Chain."""
13
+ # await wallet.swap_tokens(
14
+ # account_address_or_tag="master",
15
+ # amount_eth=None, # Swap entire balance
16
+ # sell_token_name="OLAS",
17
+ # buy_token_name="SDAI",
18
+ # chain_name="gnosis",
19
+ # fixed_buy_amount=False,
20
+ # )
21
+
22
+ service_manager = ServiceManager(wallet=wallet)
23
+ service_manager.create()
24
+
25
+
26
+ if __name__ == "__main__": # pragma: no cover
27
+ asyncio.run(main())
@@ -0,0 +1,255 @@
1
+ """Tests for Wallet module."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.core.wallet import Wallet
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_keys_and_services():
12
+ """Mock keys and services."""
13
+ with (
14
+ patch("iwa.core.wallet.KeyStorage") as mock_ks,
15
+ patch("iwa.core.wallet.AccountService") as mock_as,
16
+ patch("iwa.core.wallet.BalanceService") as mock_bs,
17
+ patch("iwa.core.wallet.SafeService") as mock_ss,
18
+ patch("iwa.core.wallet.TransactionService") as mock_ts,
19
+ patch("iwa.core.wallet.TransferService") as mock_trs,
20
+ patch("iwa.core.wallet.PluginService") as mock_ps,
21
+ patch("iwa.core.wallet.init_db") as mock_init_db,
22
+ patch("iwa.core.wallet.configure_logger"),
23
+ ):
24
+ yield {
25
+ "key_storage": mock_ks,
26
+ "account_service": mock_as,
27
+ "balance_service": mock_bs,
28
+ "safe_service": mock_ss,
29
+ "transaction_service": mock_ts,
30
+ "transfer_service": mock_trs,
31
+ "plugin_service": mock_ps,
32
+ "init_db": mock_init_db,
33
+ }
34
+
35
+
36
+ @pytest.fixture
37
+ def wallet(mock_keys_and_services):
38
+ """Wallet fixture."""
39
+ return Wallet()
40
+
41
+
42
+ def test_init(mock_keys_and_services):
43
+ """Test initialization."""
44
+ wallet = Wallet()
45
+ assert wallet.key_storage == mock_keys_and_services["key_storage"].return_value
46
+ mock_keys_and_services["init_db"].assert_called_once()
47
+
48
+
49
+ def test_master_account(wallet, mock_keys_and_services):
50
+ """Test master account property."""
51
+ # Accesses property on account_service instance
52
+
53
+
54
+ def test_get_token_address(wallet, mock_keys_and_services):
55
+ """Test get_token_address."""
56
+ wallet.get_token_address("OLAS", "gnosis")
57
+ mock_keys_and_services["account_service"].return_value.get_token_address.assert_called_with(
58
+ "OLAS", "gnosis"
59
+ )
60
+
61
+
62
+ def test_get_accounts_balances(wallet, mock_keys_and_services):
63
+ """Test get_accounts_balances."""
64
+ # Mock account data
65
+ mock_keys_and_services["account_service"].return_value.get_account_data.return_value = {
66
+ "0x1": {"tag": "one"},
67
+ "0x2": {"tag": "two"},
68
+ }
69
+
70
+ # Mock balance service
71
+ mock_bs = mock_keys_and_services["balance_service"].return_value
72
+ mock_bs.get_native_balance_eth.return_value = 1.0
73
+ mock_bs.get_erc20_balance_eth.return_value = 2.0
74
+
75
+ # Test with no token names
76
+ data, balances = wallet.get_accounts_balances("gnosis")
77
+ assert data == {"0x1": {"tag": "one"}, "0x2": {"tag": "two"}}
78
+ assert balances is None
79
+
80
+ # Test with token names
81
+ # Mock ThreadPoolExecutor to run synchronously or just return futures
82
+ # Since we can't easily suppress the real ThreadPoolExecutor context manager used in the code without patching it,
83
+ # let's patch it in the test function scope.
84
+
85
+ with patch("iwa.core.wallet.ThreadPoolExecutor") as mock_executor:
86
+ # returns context manager
87
+ mock_context = MagicMock()
88
+ mock_executor.return_value.__enter__.return_value = mock_context
89
+
90
+ # mock submit to return a future
91
+ mock_future1 = MagicMock()
92
+ mock_future1.result.return_value = ("0x1", "native", 1.0)
93
+
94
+ mock_future2 = MagicMock()
95
+ mock_future2.result.return_value = ("0x1", "OLAS", 2.0)
96
+
97
+ mock_context.submit.side_effect = [
98
+ mock_future1,
99
+ mock_future2,
100
+ mock_future1,
101
+ mock_future2,
102
+ ] # Just cycling mocks
103
+
104
+ # We need to rely on what the code does: it iterates over accounts, then tokens.
105
+ # 2 accounts * 2 tokens = 4 calls.
106
+
107
+ # Simpler approach: let the real ThreadPoolExecutor run but mock the balance service methods which are already mocked.
108
+ # The issue is the code uses `fetch_balance` inner function.
109
+ pass
110
+
111
+
112
+ # Re-implementing test_get_accounts_balances with delegation verification via patching ThreadPoolExecutor
113
+ # effectively mocking concurrency.
114
+
115
+
116
+ def test_get_accounts_balances_concurrency(wallet, mock_keys_and_services):
117
+ """Test get_accounts_balances concurrency."""
118
+ mock_keys_and_services["account_service"].return_value.get_account_data.return_value = {
119
+ "0x1": {"tag": "one"}
120
+ }
121
+
122
+ with patch("iwa.core.wallet.ThreadPoolExecutor") as mock_executor:
123
+ mock_context = MagicMock()
124
+ mock_executor.return_value.__enter__.return_value = mock_context
125
+
126
+ mock_future_native = MagicMock()
127
+ mock_future_native.result.return_value = ("0x1", "native", 1.5)
128
+
129
+ mock_future_token = MagicMock()
130
+ mock_future_token.result.return_value = ("0x1", "OLAS", 10.0)
131
+
132
+ # The loop order in wallet.py: for addr in accounts: for t in tokens: submit
133
+ # so for 1 account and 2 tokens: native, OLAS
134
+ mock_context.submit.side_effect = [mock_future_native, mock_future_token]
135
+
136
+ accounts, balances = wallet.get_accounts_balances("gnosis", ["native", "OLAS"])
137
+
138
+ assert accounts == {"0x1": {"tag": "one"}}
139
+ # balances structure: {addr: {token: val}}
140
+ assert balances["0x1"]["native"] == 1.5
141
+ assert balances["0x1"]["OLAS"] == 10.0
142
+
143
+
144
+ def test_send_native_transfer(wallet, mock_keys_and_services):
145
+ """Test send_native_transfer."""
146
+ mock_keys_and_services["transfer_service"].return_value.send.return_value = "0xhash"
147
+ success, tx_hash = wallet.send_native_transfer("0xfrom", "0xto", 100, "gnosis")
148
+ assert success is True
149
+ assert tx_hash == "0xhash"
150
+ mock_keys_and_services["transfer_service"].return_value.send.assert_called_with(
151
+ from_address_or_tag="0xfrom",
152
+ to_address_or_tag="0xto",
153
+ amount_wei=100,
154
+ token_address_or_name="native",
155
+ chain_name="gnosis",
156
+ )
157
+
158
+
159
+ def test_sign_and_send_transaction(wallet, mock_keys_and_services):
160
+ """Test sign_and_send_transaction."""
161
+ wallet.sign_and_send_transaction({"to": "0x1"}, "owner", "gnosis")
162
+ mock_keys_and_services["transaction_service"].return_value.sign_and_send.assert_called_with(
163
+ {"to": "0x1"}, "owner", "gnosis", None
164
+ )
165
+
166
+
167
+ def test_send_erc20_transfer(wallet, mock_keys_and_services):
168
+ """Test send_erc20_transfer."""
169
+ mock_keys_and_services["transfer_service"].return_value.send.return_value = "0xhash"
170
+ success, tx_hash = wallet.send_erc20_transfer("0xfrom", "0xto", 100, "0xtoken", "gnosis")
171
+ assert success is True
172
+ assert tx_hash == "0xhash"
173
+ mock_keys_and_services["transfer_service"].return_value.send.assert_called_with(
174
+ from_address_or_tag="0xfrom",
175
+ to_address_or_tag="0xto",
176
+ amount_wei=100,
177
+ token_address_or_name="0xtoken",
178
+ chain_name="gnosis",
179
+ )
180
+
181
+
182
+ def test_send(wallet, mock_keys_and_services):
183
+ """Test send."""
184
+ wallet.send("0xfrom", "0xto", 100)
185
+ mock_keys_and_services["transfer_service"].return_value.send.assert_called_with(
186
+ "0xfrom", "0xto", 100, "native", "gnosis"
187
+ )
188
+
189
+
190
+ def test_multi_send(wallet, mock_keys_and_services):
191
+ """Test multi_send."""
192
+ txs = [{"to": "0x1", "value": 1}]
193
+ wallet.multi_send("0xfrom", txs, "gnosis")
194
+ mock_keys_and_services["transfer_service"].return_value.multi_send.assert_called_with(
195
+ "0xfrom", txs, "gnosis"
196
+ )
197
+
198
+
199
+ def test_balances_getters(wallet, mock_keys_and_services):
200
+ """Test balances getters."""
201
+ mock_bs = mock_keys_and_services["balance_service"].return_value
202
+
203
+ wallet.get_native_balance_eth("0x1", "gnosis")
204
+ mock_bs.get_native_balance_eth.assert_called_with("0x1", "gnosis")
205
+
206
+ wallet.get_native_balance_wei("0x1", "gnosis")
207
+ mock_bs.get_native_balance_wei.assert_called_with("0x1", "gnosis")
208
+
209
+ wallet.get_erc20_balance_eth("0x1", "OLAS", "gnosis")
210
+ mock_bs.get_erc20_balance_eth.assert_called_with("0x1", "OLAS", "gnosis")
211
+
212
+ wallet.get_erc20_balance_wei("0x1", "OLAS", "gnosis")
213
+ mock_bs.get_erc20_balance_wei.assert_called_with("0x1", "OLAS", "gnosis")
214
+
215
+
216
+ def test_erc20_operations(wallet, mock_keys_and_services):
217
+ """Test erc20 operations."""
218
+ mock_trs = mock_keys_and_services["transfer_service"].return_value
219
+
220
+ wallet.get_erc20_allowance("owner", "spender", "token", "gnosis")
221
+ mock_trs.get_erc20_allowance.assert_called_with("owner", "spender", "token", "gnosis")
222
+
223
+ wallet.approve_erc20("owner", "spender", "token", 100, "gnosis")
224
+ mock_trs.approve_erc20.assert_called_with("owner", "spender", "token", 100, "gnosis")
225
+
226
+ wallet.transfer_from_erc20("from", "sender", "recipient", "token", 100, "gnosis")
227
+ mock_trs.transfer_from_erc20.assert_called_with(
228
+ "from", "sender", "recipient", "token", 100, "gnosis"
229
+ )
230
+
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_swap(wallet, mock_keys_and_services):
234
+ """Test swap."""
235
+ mock_trs = mock_keys_and_services["transfer_service"].return_value
236
+
237
+ # Mock swap as async method
238
+ mock_trs.swap = AsyncMock(return_value=True)
239
+
240
+ result = await wallet.swap("account", 1.0, "SELL", "BUY", "gnosis")
241
+ assert result is True
242
+
243
+ # Check if await matches call args
244
+ # Note: Enum handling might need import, passing string/value might be tested
245
+ args, kwargs = mock_trs.swap.call_args
246
+ assert args[0] == "account"
247
+ assert args[1] == 1.0
248
+
249
+
250
+ def test_drain(wallet, mock_keys_and_services):
251
+ """Test drain."""
252
+ wallet.drain("from", "to", "gnosis")
253
+ mock_keys_and_services["transfer_service"].return_value.drain.assert_called_with(
254
+ "from", "to", "gnosis"
255
+ )
iwa/core/types.py ADDED
@@ -0,0 +1,59 @@
1
+ """Core type definitions."""
2
+
3
+ import re
4
+
5
+ import yaml
6
+ from pydantic_core import core_schema
7
+ from web3 import Web3
8
+
9
+ ETHEREUM_ADDRESS_REGEX = r"0x[0-9a-fA-F]{40}"
10
+
11
+
12
+ class EthereumAddress(str):
13
+ """EthereumAddress - a checksummed Ethereum address that behaves as a plain str.
14
+
15
+ When passed to web3.py functions, this behaves exactly like a str.
16
+ The class validates and checksums addresses on creation.
17
+ """
18
+
19
+ def __new__(cls, value: str):
20
+ """Create a new EthereumAddress instance."""
21
+ if not re.fullmatch(ETHEREUM_ADDRESS_REGEX, value):
22
+ raise ValueError(f"Invalid Ethereum address: {value}")
23
+ checksummed = Web3.to_checksum_address(value)
24
+ instance = str.__new__(cls, checksummed)
25
+ return instance
26
+
27
+ def __repr__(self) -> str:
28
+ """Return string representation for debugging."""
29
+ return str.__str__(self)
30
+
31
+ def __str__(self) -> str:
32
+ """Return as plain string - critical for web3.py compatibility."""
33
+ return str.__str__(self)
34
+
35
+ @classmethod
36
+ def __get_pydantic_core_schema__(cls, _source, _handler):
37
+ """Get the Pydantic core schema for EthereumAddress."""
38
+ return core_schema.with_info_after_validator_function(
39
+ cls.validate,
40
+ core_schema.str_schema(),
41
+ )
42
+
43
+ @classmethod
44
+ def validate(cls, value: str, _info) -> "EthereumAddress":
45
+ """Validate that the value is a valid Ethereum address."""
46
+ if not re.fullmatch(ETHEREUM_ADDRESS_REGEX, value):
47
+ raise ValueError(f"Invalid Ethereum address: {value}")
48
+ return cls(value)
49
+
50
+
51
+ # Register YAML representer so EthereumAddress serializes as plain string
52
+ def _ethereum_address_representer(
53
+ dumper: yaml.SafeDumper, data: EthereumAddress
54
+ ) -> yaml.ScalarNode:
55
+ """Represent EthereumAddress as a plain YAML string."""
56
+ return dumper.represent_str(str.__str__(data))
57
+
58
+
59
+ yaml.add_representer(EthereumAddress, _ethereum_address_representer, Dumper=yaml.SafeDumper)
iwa/core/ui.py ADDED
@@ -0,0 +1,99 @@
1
+ """UI utilities for mnemonic handling."""
2
+
3
+ import getpass
4
+ import os
5
+
6
+ from rich import box
7
+ from rich.align import Align
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from iwa.core.mnemonic import MnemonicManager
13
+
14
+
15
+ def prompt_and_store_mnemonic(
16
+ manager: MnemonicManager, out_file: str = None, max_attempts: int = 3
17
+ ) -> None:
18
+ """Prompt for a password twice, verify they match, and store mnemonic.
19
+
20
+ Args:
21
+ manager (MnemonicManager): The manager instance.
22
+ out_file (str): Optional destination file for the encrypted object.
23
+ max_attempts (int): Number of attempts allowed for confirmation.
24
+
25
+ Returns:
26
+ str | None: The plaintext mnemonic if successful, otherwise None.
27
+
28
+ """
29
+ target_file = out_file or manager.mnemonic_file
30
+ if os.path.exists(target_file):
31
+ print(f"Mnemonic file '{target_file}' already exists.")
32
+ return None
33
+
34
+ for _ in range(max_attempts):
35
+ p1 = getpass.getpass("Enter a strong password to encrypt the mnemonic: ").strip()
36
+ if not p1:
37
+ print("Empty password not allowed.")
38
+ continue
39
+ p2 = getpass.getpass("Confirm password: ").strip()
40
+ if p1 != p2:
41
+ print("Passwords do not match. Please try again.")
42
+ continue
43
+ # Passwords match — generate and store mnemonic
44
+ manager.generate_and_store_mnemonic(p1, target_file)
45
+ return None
46
+ raise ValueError("Maximum password attempts exceeded.")
47
+
48
+
49
+ def display_mnemonic(
50
+ mnemonic: str,
51
+ columns: int = 6,
52
+ rows: int = 4,
53
+ ) -> None:
54
+ """Format and print a mnemonic as a numbered table wrapped in a Panel.
55
+
56
+ Args:
57
+ mnemonic (str): The plaintext mnemonic (space separated words).
58
+ columns (int): Number of columns per row (default 6).
59
+ rows (int): Number of rows (default 4).
60
+
61
+ """
62
+ words = mnemonic.split()
63
+ console = Console()
64
+ # build table without internal borders; we'll wrap it in a Panel
65
+ table = Table(
66
+ show_header=False,
67
+ box=None,
68
+ show_lines=False,
69
+ expand=False,
70
+ )
71
+ # add columns
72
+ for _ in range(columns):
73
+ table.add_column(justify="left")
74
+ # warning: advise user to create a paper backup
75
+ console.print(
76
+ "[bold yellow]Warning:[/bold yellow] Make a paper backup of "
77
+ "your mnemonic and store it in a safe place:"
78
+ )
79
+ # prepare numbered cells (colored green) with padded indices
80
+ cells = []
81
+ for i, w in enumerate(words):
82
+ cells.append(f"[green]{i + 1:2d}. {w}[/green]")
83
+ # add rows of `columns` columns
84
+ for r in range(rows):
85
+ start = r * columns
86
+ row = cells[start : start + columns]
87
+ # if row shorter than columns, pad with empty strings
88
+ if len(row) < columns:
89
+ row += [""] * (columns - len(row))
90
+ table.add_row(*row)
91
+ # wrap table in a panel to draw only the outer border
92
+ panel = Panel(
93
+ table,
94
+ box=box.ROUNDED,
95
+ border_style="bright_blue",
96
+ padding=(0, 1),
97
+ expand=False,
98
+ )
99
+ console.print(Align.center(panel))
iwa/core/utils.py ADDED
@@ -0,0 +1,59 @@
1
+ """Utility functions"""
2
+
3
+ from loguru import logger
4
+ from safe_eth.eth import EthereumNetwork
5
+ from safe_eth.safe.addresses import MASTER_COPIES, PROXY_FACTORIES
6
+
7
+
8
+ def singleton(cls):
9
+ """Singleton decorator to ensure a class has only one instance."""
10
+ instances = {}
11
+
12
+ def get_instance(*args, **kwargs):
13
+ if cls not in instances:
14
+ instances[cls] = cls(*args, **kwargs)
15
+ return instances[cls]
16
+
17
+ return get_instance
18
+
19
+
20
+ def get_safe_master_copy_address(target_version: str = "1.4.1") -> str:
21
+ """Get Safe master copy address by version"""
22
+ for address, _, version in MASTER_COPIES[EthereumNetwork.MAINNET]:
23
+ if version == target_version:
24
+ return address
25
+ raise ValueError(f"Did not find master copy for version {target_version}")
26
+
27
+
28
+ def get_safe_proxy_factory_address(target_version: str = "1.4.1") -> str:
29
+ """Get Safe proxy factory address by version"""
30
+ # PROXY_FACTORIES values are (address, block_number) without version
31
+ # converting 1.4.1 address manually if needed, or returning the one found.
32
+ # The address 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67 is for 1.4.1
33
+ if target_version == "1.4.1":
34
+ return "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67"
35
+
36
+ for address, _ in PROXY_FACTORIES[EthereumNetwork.MAINNET]:
37
+ return address
38
+ raise ValueError(f"Did not find proxy factory for version {target_version}")
39
+
40
+
41
+ def configure_logger():
42
+ """Configure the logger for the application."""
43
+ if hasattr(configure_logger, "configured"):
44
+ return logger
45
+
46
+ logger.remove()
47
+
48
+ logger.add(
49
+ "iwa.log",
50
+ rotation="10 MB",
51
+ level="INFO",
52
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
53
+ )
54
+ # Also keep stderr for console if needed, but Textual captures it?
55
+ # Textual usually captures stderr. Writing to file is safer for debugging.
56
+ # Users previous logs show stdout format?
57
+
58
+ configure_logger.configured = True
59
+ return logger