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
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
|