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,125 @@
1
+ """Tests for TUI App."""
2
+
3
+ import importlib
4
+ import sys
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+
10
+ # Fixture to mock dependencies
11
+ @pytest.fixture
12
+ def mock_dependencies():
13
+ """Mock app dependencies."""
14
+ # Start patches for Textual widgets at source to persist through reload
15
+ patch_header = patch("textual.widgets.Header")
16
+ patch_footer = patch("textual.widgets.Footer")
17
+ patch_tabbed = patch("textual.widgets.TabbedContent")
18
+ patch_pane = patch("textual.widgets.TabPane")
19
+
20
+ mock_header = patch_header.start()
21
+ mock_footer = patch_footer.start()
22
+ mock_tabbed = patch_tabbed.start()
23
+ mock_pane = patch_pane.start()
24
+
25
+ # Setup TabbedContent and TabPane as context managers
26
+ mock_tabbed.return_value.__enter__.return_value = MagicMock()
27
+ mock_pane.return_value.__enter__.return_value = MagicMock()
28
+
29
+ with (
30
+ patch("iwa.core.wallet.Wallet") as mock_wallet,
31
+ patch("iwa.tui.rpc.RPCView") as mock_rpc_view,
32
+ patch("iwa.tui.screens.wallets.WalletsScreen") as mock_wallets_screen,
33
+ patch("loguru.logger") as mock_global_logger,
34
+ ):
35
+ mock_wallet_instance = mock_wallet.return_value
36
+ mock_plugin = MagicMock()
37
+ mock_plugin.name = "Olas"
38
+ mock_plugin.get_tui_view.return_value = MagicMock()
39
+
40
+ mock_wallet_instance.plugin_service.get_all_plugins.return_value = {"olas": mock_plugin}
41
+
42
+ yield {
43
+ "wallet": mock_wallet,
44
+ "rpc_view": mock_rpc_view,
45
+ "wallets_screen": mock_wallets_screen,
46
+ "logger": mock_global_logger,
47
+ "widgets": [mock_header, mock_footer, mock_tabbed, mock_pane],
48
+ }
49
+
50
+ # Stop global patches after the test yields
51
+ patch_header.stop()
52
+ patch_footer.stop()
53
+ patch_tabbed.stop()
54
+ patch_pane.stop()
55
+
56
+
57
+ @pytest.fixture
58
+ def iwa_app_cls(mock_dependencies):
59
+ """Fixture to reload and return IwaApp class."""
60
+ if "iwa.tui.app" in sys.modules:
61
+ importlib.reload(sys.modules["iwa.tui.app"])
62
+ else:
63
+ importlib.import_module("iwa.tui.app")
64
+ return sys.modules["iwa.tui.app"].IwaApp
65
+
66
+
67
+ def test_init(iwa_app_cls, mock_dependencies):
68
+ """Test app initialization."""
69
+ app = iwa_app_cls()
70
+ assert app.wallet is not None
71
+ assert "olas" in app.plugins
72
+ mock_dependencies["logger"].add.assert_called()
73
+
74
+
75
+ def test_compose_runs(iwa_app_cls, mock_dependencies):
76
+ """Test compose method runs."""
77
+ app = iwa_app_cls()
78
+
79
+ # Run compose
80
+ widgets = list(app.compose())
81
+
82
+ assert len(widgets) > 0
83
+ mock_dependencies["wallets_screen"].assert_called()
84
+ mock_dependencies["rpc_view"].assert_called()
85
+
86
+
87
+ def test_action_refresh(iwa_app_cls, mock_dependencies):
88
+ """Test refresh action."""
89
+ app = iwa_app_cls()
90
+
91
+ mock_screen = MagicMock()
92
+ app.query_one = MagicMock(return_value=mock_screen)
93
+
94
+ app.action_refresh()
95
+
96
+ app.query_one.assert_called()
97
+ mock_screen.refresh_accounts.assert_called_once()
98
+
99
+
100
+ def test_action_refresh_error(iwa_app_cls, mock_dependencies):
101
+ """Test refresh action error handling."""
102
+ app = iwa_app_cls()
103
+ app.query_one = MagicMock(side_effect=Exception("No screen"))
104
+ app.action_refresh()
105
+
106
+
107
+ def test_copy_to_clipboard(iwa_app_cls, mock_dependencies):
108
+ """Test copy to clipboard."""
109
+ app = iwa_app_cls()
110
+
111
+ with patch.dict(sys.modules, {"pyperclip": MagicMock()}):
112
+ app.copy_to_clipboard("test")
113
+ sys.modules["pyperclip"].copy.assert_called_with("test") # type: ignore
114
+
115
+
116
+ def test_copy_to_clipboard_error(iwa_app_cls, mock_dependencies):
117
+ """Test copy to clipboard error."""
118
+ app = iwa_app_cls()
119
+
120
+ mock_pyperclip = MagicMock()
121
+ mock_pyperclip.copy.side_effect = Exception("Copy fail")
122
+
123
+ with patch.dict(sys.modules, {"pyperclip": mock_pyperclip}):
124
+ app.copy_to_clipboard("test")
125
+ mock_dependencies["logger"].error.assert_called()
@@ -0,0 +1,139 @@
1
+ """Tests for RPC view."""
2
+
3
+ import importlib
4
+ import sys
5
+ from unittest.mock import MagicMock, PropertyMock, patch
6
+
7
+ import pytest
8
+
9
+
10
+ # Mock textual's work decorator to run synchronously or just return the function
11
+ @pytest.fixture(autouse=True)
12
+ def mock_work_decorator():
13
+ """Mock textual work decorator."""
14
+
15
+ # Patch textual.work before import/reload
16
+ def decorator(*args, **kwargs):
17
+ if args and callable(args[0]):
18
+ return args[0]
19
+
20
+ def real_decorator(func):
21
+ return func
22
+
23
+ return real_decorator
24
+
25
+ with patch("textual.work", side_effect=decorator):
26
+ yield
27
+
28
+
29
+ @pytest.fixture
30
+ def rpc_view_cls(mock_chain_interfaces):
31
+ """Fixture to reload and return RPCView class."""
32
+ if "iwa.tui.rpc" in sys.modules:
33
+ importlib.reload(sys.modules["iwa.tui.rpc"])
34
+ else:
35
+ importlib.import_module("iwa.tui.rpc")
36
+ return sys.modules["iwa.tui.rpc"].RPCView
37
+
38
+
39
+ @pytest.fixture
40
+ def mock_chain_interfaces():
41
+ """Mock ChainInterfaces."""
42
+ # Patch the source class so it works even after reload
43
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci:
44
+ mock_instance = mock_ci.return_value
45
+ yield mock_instance
46
+
47
+
48
+ @pytest.fixture
49
+ def rpc_view(rpc_view_cls):
50
+ """Fixture to create an RPCView instance."""
51
+ view = rpc_view_cls()
52
+ # Mock app property
53
+ mock_app = MagicMock()
54
+ with patch.object(view.__class__, "app", new_callable=PropertyMock) as mock_app_prop:
55
+ mock_app_prop.return_value = mock_app
56
+ view.query_one = MagicMock()
57
+ yield view
58
+
59
+
60
+ def test_compose(rpc_view):
61
+ """Test compose method."""
62
+ # Just check it yields valid widgets
63
+ widgets = list(rpc_view.compose())
64
+ assert len(widgets) == 2
65
+ # Check first widget is a Label with correct text
66
+ # Check first widget is a Label
67
+ assert "Label" in str(widgets[0]) or widgets[0].__class__.__name__ == "Label"
68
+
69
+
70
+ def test_check_rpcs_success(rpc_view, mock_chain_interfaces):
71
+ """Test check_rpcs with successful connections."""
72
+ # Setup mock chain interfaces
73
+ mock_gnosis = MagicMock()
74
+ mock_gnosis.chain.rpc = "http://gnosis"
75
+ mock_gnosis.web3.is_connected.return_value = True
76
+
77
+ mock_chain_interfaces.get.side_effect = lambda name: mock_gnosis if name == "gnosis" else None
78
+
79
+ # We need to ensure check_rpcs calls update_table via call_from_thread
80
+ # Since we mocked @work to simply return the function, calling check_rpcs runs logic in main thread
81
+ # But check_rpcs calls self.app.call_from_thread(self.update_table, results)
82
+
83
+ # Run
84
+ rpc_view.check_rpcs()
85
+
86
+ # Verify results
87
+ assert rpc_view.app.call_from_thread.called
88
+ args = rpc_view.app.call_from_thread.call_args[0]
89
+ assert args[0] == rpc_view.update_table
90
+ results = args[1]
91
+
92
+ # We mocked gnosis to return interface, others None
93
+ # expected results: (chain, url, status, latency)
94
+ gnosis_result = next(r for r in results if r[0] == "gnosis")
95
+ assert gnosis_result[1] == "http://gnosis"
96
+ assert gnosis_result[2] == "Online"
97
+
98
+
99
+ def test_check_rpcs_error(rpc_view, mock_chain_interfaces):
100
+ """Test check_rpcs with connection error."""
101
+ mock_eth = MagicMock()
102
+ mock_eth.chain.rpc = "http://eth"
103
+ mock_eth.web3.is_connected.side_effect = Exception("Connection fail")
104
+
105
+ mock_chain_interfaces.get.side_effect = lambda name: mock_eth if name == "ethereum" else None
106
+
107
+ rpc_view.check_rpcs()
108
+
109
+ args = rpc_view.app.call_from_thread.call_args[0]
110
+ results = args[1]
111
+
112
+ eth_result = next(r for r in results if r[0] == "ethereum")
113
+ assert "Error" in eth_result[2]
114
+
115
+
116
+ def test_check_rpcs_missing_interface(rpc_view, mock_chain_interfaces):
117
+ """Test check_rpcs with missing configuration."""
118
+ mock_chain_interfaces.get.return_value = None
119
+
120
+ rpc_view.check_rpcs()
121
+
122
+ args = rpc_view.app.call_from_thread.call_args[0]
123
+ results = args[1]
124
+
125
+ # All Should be Not Configured
126
+ for res in results:
127
+ assert res[2] == "Not Configured"
128
+
129
+
130
+ def test_update_table(rpc_view):
131
+ """Test update_table method."""
132
+ mock_table = MagicMock()
133
+ rpc_view.query_one.return_value = mock_table
134
+
135
+ results = [("gnosis", "url", "Online", "10ms")]
136
+ rpc_view.update_table(results)
137
+
138
+ mock_table.clear.assert_called_once()
139
+ mock_table.add_row.assert_called_with("gnosis", "url", "Online", "10ms")
@@ -0,0 +1,30 @@
1
+ """Tests for Wallets Refactor."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from iwa.tui.screens.wallets import WalletsScreen
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_wallet():
12
+ """Mock wallet."""
13
+ return MagicMock()
14
+
15
+
16
+ def test_wallets_screen_initialization(mock_wallet):
17
+ """Test that WalletsScreen can be initialized with the new import structure."""
18
+ with patch("iwa.core.chain.ChainInterfaces"):
19
+ # Mock MonitorWorker import if needed, but it should be imported from workers.py now
20
+ screen = WalletsScreen(wallet=mock_wallet)
21
+ assert screen is not None
22
+ assert hasattr(screen, "monitor_worker") or hasattr(screen, "start_monitor")
23
+
24
+
25
+ def test_monitor_worker_integration():
26
+ """Test that MonitorWorker is imported from the correct location."""
27
+ from iwa.tui.screens.wallets import MonitorWorker as WalletsMonitorWorker
28
+ from iwa.tui.workers import MonitorWorker as DefinedMonitorWorker
29
+
30
+ assert WalletsMonitorWorker is DefinedMonitorWorker
@@ -0,0 +1,123 @@
1
+ """Tests for TUI widgets."""
2
+
3
+ import importlib
4
+ import sys
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+
10
+ # Fixture to reload module to pick up mocks
11
+ @pytest.fixture
12
+ def widgets_module():
13
+ """Fixture to reload module and mock dependencies."""
14
+ # Patch dependencies at source textual.widgets before import/reload
15
+ with (
16
+ patch("textual.widgets.Label"),
17
+ patch("textual.widgets.Select"),
18
+ ):
19
+ # Do NOT patch DataTable, let it be real so inheritance works
20
+ # But we will verify logic by mocking methods on instance or via patch.object
21
+
22
+ if "iwa.tui.widgets.base" in sys.modules:
23
+ importlib.reload(sys.modules["iwa.tui.widgets.base"])
24
+ else:
25
+ pass
26
+
27
+ yield sys.modules["iwa.tui.widgets.base"]
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_chain_interfaces():
32
+ """Mock ChainInterfaces."""
33
+ # Patch at source or where imported. Code imports ChainInterfaces from iwa.core.chain
34
+ # But it calls ChainInterfaces().get()
35
+ # Let's patch iwa.core.chain.ChainInterfaces
36
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci:
37
+ yield mock_ci.return_value
38
+
39
+
40
+ def test_chain_selector_compose(widgets_module, mock_chain_interfaces):
41
+ """Test ChainSelector composition."""
42
+ chain_selector_cls = widgets_module.ChainSelector
43
+ label_cls = widgets_module.Label # This is the mock now
44
+ select_cls = widgets_module.Select # This is the mock now
45
+
46
+ # Setup mock interfaces
47
+ mock_gnosis = MagicMock()
48
+ mock_gnosis.chain.rpc = "http://rpc"
49
+
50
+ mock_eth = MagicMock()
51
+ mock_eth.chain.rpc = "" # No RPC
52
+
53
+ def get_interface(name):
54
+ if name == "gnosis":
55
+ return mock_gnosis
56
+ if name == "ethereum":
57
+ return mock_eth
58
+ return MagicMock() # base
59
+
60
+ mock_chain_interfaces.get.side_effect = get_interface
61
+
62
+ selector = chain_selector_cls(active_chain="gnosis")
63
+ widgets = list(selector.compose())
64
+
65
+ assert len(widgets) == 2
66
+ assert widgets[0] == label_cls.return_value
67
+ assert widgets[1] == select_cls.return_value
68
+
69
+ # Verify Label called with correct text
70
+ label_cls.assert_called_with("Chain:", classes="label")
71
+
72
+ # Verify Select options
73
+ select_cls.assert_called()
74
+ call_kwargs = select_cls.call_args.kwargs
75
+ assert call_kwargs["value"] == "gnosis"
76
+ assert len(call_kwargs["options"]) == 3 # gnosis, ethereum, base
77
+
78
+
79
+ def test_chain_selector_message(widgets_module):
80
+ """Test ChainSelector message posting."""
81
+ chain_selector_cls = widgets_module.ChainSelector
82
+
83
+ selector = chain_selector_cls()
84
+ selector.post_message = MagicMock()
85
+
86
+
87
+ def test_account_table_setup_columns(widgets_module):
88
+ """Test AccountTable columns setup."""
89
+ account_table_cls = widgets_module.AccountTable
90
+
91
+ # We must patch DataTable.__init__ so it doesn't fail due to missing app context
92
+ with (
93
+ patch("textual.widgets.DataTable.__init__", return_value=None),
94
+ patch("textual.widgets.DataTable.clear") as mock_clear,
95
+ patch("textual.widgets.DataTable.add_column") as mock_add_column,
96
+ ):
97
+ table = account_table_cls()
98
+ table.setup_columns("gnosis", "xDAI", ["GNO", "COW"])
99
+
100
+ mock_clear.assert_called_with(columns=True)
101
+ assert mock_add_column.call_count == 4 + 2
102
+
103
+
104
+ def test_transaction_table_setup_columns(widgets_module):
105
+ """Test TransactionTable columns setup."""
106
+ transaction_table_cls = widgets_module.TransactionTable
107
+
108
+ with (
109
+ patch("textual.widgets.DataTable.__init__", return_value=None),
110
+ patch("textual.widgets.DataTable.add_column") as mock_add_column,
111
+ ):
112
+ table = transaction_table_cls()
113
+ # Mock columns property which exists on DataTable
114
+ table.columns = []
115
+
116
+ table.setup_columns()
117
+ assert mock_add_column.call_count > 5
118
+
119
+ # Test idempotency
120
+ table.columns = ["Time"]
121
+ mock_add_column.reset_mock()
122
+ table.setup_columns()
123
+ mock_add_column.assert_not_called()
@@ -0,0 +1,5 @@
1
+ """Widgets for the IWA TUI."""
2
+
3
+ from .base import AccountTable, ChainSelector, TransactionTable
4
+
5
+ __all__ = ["AccountTable", "ChainSelector", "TransactionTable"]
@@ -0,0 +1,100 @@
1
+ """Custom widgets for the IWA TUI."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from rich.text import Text
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Horizontal
8
+ from textual.widgets import (
9
+ DataTable,
10
+ Label,
11
+ Select,
12
+ )
13
+
14
+ from iwa.core.chain import ChainInterfaces
15
+
16
+
17
+ class ChainSelector(Horizontal):
18
+ """Widget for selecting the active blockchain."""
19
+
20
+ def __init__(self, active_chain: str = "gnosis", id: Optional[str] = None):
21
+ """Initialize ChainSelector.
22
+
23
+ Args:
24
+ active_chain: The chain to be selected by default.
25
+ id: The widget ID.
26
+
27
+ """
28
+ super().__init__(id=id)
29
+ self.active_chain = active_chain
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Compose the widget.
33
+
34
+ Yields a Label and a Select widget populated with available chains.
35
+ Chains without RPC endpoints are disabled/struck-through.
36
+ """
37
+ chain_options = []
38
+ chain_names = ["gnosis", "ethereum", "base"]
39
+
40
+ for name in chain_names:
41
+ interface = ChainInterfaces().get(name)
42
+ if interface.chain.rpc:
43
+ label = name.title()
44
+ chain_options.append((label, name))
45
+ else:
46
+ label = Text(f"{name.title()} (No RPC)", style="dim strike")
47
+ chain_options.append((label, name))
48
+
49
+ yield Label("Chain:", classes="label")
50
+ yield Select(
51
+ options=chain_options,
52
+ value=self.active_chain,
53
+ id="chain_select",
54
+ allow_blank=False,
55
+ )
56
+
57
+
58
+ class AccountTable(DataTable):
59
+ """Table for displaying account addresses and balances."""
60
+
61
+ def setup_columns(self, chain_name: str, native_symbol: str, token_names: List[str]):
62
+ """Setup table columns dynamically based on chain and token list.
63
+
64
+ Clears existing columns and adds new ones structure:
65
+ Tag | Address | Type | Native Symbol | Token 1 | Token 2 ...
66
+
67
+ Args:
68
+ chain_name: Name of the current chain (unused in col setup but contextually relevant).
69
+ native_symbol: Symbol of the native currency (e.g., ETH, xDAI).
70
+ token_names: List of additional token names/symbols to display.
71
+
72
+ """
73
+ self.clear(columns=True)
74
+ self.add_column("Tag", width=12)
75
+ self.add_column("Address", width=44)
76
+ self.add_column("Type", width=6)
77
+ self.add_column(Text(native_symbol.upper(), justify="center"), width=12)
78
+
79
+ for token_name in token_names:
80
+ self.add_column(Text(f"{token_name.upper()}", justify="center"), width=12)
81
+
82
+
83
+ class TransactionTable(DataTable):
84
+ """Table for displaying transaction history."""
85
+
86
+ def setup_columns(self):
87
+ """Setup initial table columns."""
88
+ if not self.columns:
89
+ self.add_column("Time", width=22)
90
+ self.add_column("Chain", width=10)
91
+ self.add_column("From", width=20)
92
+ self.add_column("To", width=20)
93
+ self.add_column("Token", width=10)
94
+ self.add_column("Amount", width=12)
95
+ self.add_column("Value (€)", width=12)
96
+ self.add_column("Status", width=12)
97
+ self.add_column("Hash", width=14)
98
+ self.add_column("Gas (wei)", width=12)
99
+ self.add_column("Gas (€)", width=10)
100
+ self.add_column("Tags", width=20)
iwa/tui/workers.py ADDED
@@ -0,0 +1,42 @@
1
+ """Background workers for TUI."""
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ from loguru import logger
7
+
8
+ if TYPE_CHECKING:
9
+ from iwa.core.monitor import EventMonitor
10
+ from iwa.tui.app import IwaApp
11
+
12
+
13
+ class MonitorWorker:
14
+ """Worker to run the EventMonitor."""
15
+
16
+ def __init__(self, monitor: "EventMonitor", app: "IwaApp"):
17
+ """Initialize MonitorWorker."""
18
+ self.monitor = monitor
19
+ self.app = app
20
+ self._running = False
21
+
22
+ async def run(self):
23
+ """Run the monitor loop."""
24
+ self._running = True
25
+ self.monitor.running = True
26
+ logger.info(f"Starting MonitorWorker for {self.monitor.chain_name}")
27
+
28
+ while self._running:
29
+ try:
30
+ # Run check_activity in a thread to avoid blocking the async loop
31
+ # since web3 calls are synchronous
32
+ await asyncio.to_thread(self.monitor.check_activity)
33
+ except Exception as e:
34
+ logger.error(f"Error in MonitorWorker: {e}")
35
+
36
+ # Non-blocking sleep
37
+ await asyncio.sleep(6)
38
+
39
+ def stop(self):
40
+ """Stop the worker."""
41
+ self._running = False
42
+ self.monitor.stop()
@@ -0,0 +1,76 @@
1
+ """Shared dependencies for Web API routers."""
2
+
3
+ import logging
4
+ import secrets
5
+ from typing import Optional
6
+
7
+ from fastapi import Header, HTTPException, Security
8
+ from fastapi.security import APIKeyHeader
9
+
10
+ from iwa.core.wallet import Wallet
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Singleton wallet instance for the web app
15
+ wallet = Wallet()
16
+
17
+ # Authentication
18
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
19
+
20
+
21
+ def _get_webui_password() -> Optional[str]:
22
+ """Get WEBUI_PASSWORD from settings (lazy load to ensure secrets.env is loaded)."""
23
+ from iwa.core.settings import settings
24
+
25
+ if hasattr(settings, "webui_password") and settings.webui_password:
26
+ return settings.webui_password.get_secret_value()
27
+ return None
28
+
29
+
30
+ async def verify_auth(
31
+ x_api_key: Optional[str] = Security(api_key_header), authorization: Optional[str] = Header(None)
32
+ ) -> bool:
33
+ """Verify authentication via API key or Password.
34
+
35
+ Uses timing-safe comparison to prevent timing attacks.
36
+ """
37
+ password = _get_webui_password()
38
+
39
+ # If no password configured, allow everything
40
+ if not password:
41
+ return True
42
+
43
+ # Check X-API-Key header (timing-safe comparison)
44
+ if x_api_key and secrets.compare_digest(x_api_key, password):
45
+ return True
46
+
47
+ # Check Authorization header (Bearer token)
48
+ if authorization:
49
+ scheme, _, param = authorization.partition(" ")
50
+ if scheme.lower() == "bearer" and param and secrets.compare_digest(param, password):
51
+ return True
52
+
53
+ raise HTTPException(
54
+ status_code=401,
55
+ detail="Invalid authentication credentials",
56
+ headers={"WWW-Authenticate": "Bearer"},
57
+ )
58
+
59
+
60
+ def get_config():
61
+ """Dependency to provide the Config object."""
62
+ import yaml
63
+
64
+ from iwa.core.constants import CONFIG_PATH
65
+ from iwa.core.models import Config
66
+
67
+ if not CONFIG_PATH.exists():
68
+ return Config()
69
+
70
+ try:
71
+ with open(CONFIG_PATH, "r") as f:
72
+ data = yaml.safe_load(f) or {}
73
+ return Config(**data)
74
+ except Exception as e:
75
+ logger.error(f"Error loading config: {e}")
76
+ return Config()