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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {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()
|
iwa/tui/widgets/base.py
ADDED
|
@@ -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()
|
iwa/web/dependencies.py
ADDED
|
@@ -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()
|