iwa 0.0.0__py3-none-any.whl → 0.0.1a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
tests/test_erc20.py ADDED
@@ -0,0 +1,95 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+
5
+ from iwa.core.contracts.erc20 import ERC20Contract
6
+
7
+
8
+ @pytest.fixture
9
+ def mock_contract_instance():
10
+ with (
11
+ patch("iwa.core.contracts.contract.ContractInstance.__init__", return_value=None),
12
+ patch("iwa.core.contracts.contract.ContractInstance.call") as mock_call,
13
+ patch("iwa.core.contracts.contract.ContractInstance.prepare_transaction") as mock_prep,
14
+ ):
15
+ mock_call.side_effect = lambda method, *args: {
16
+ "decimals": 18,
17
+ "symbol": "TEST",
18
+ "name": "Test Token",
19
+ "totalSupply": 1000000,
20
+ "allowance": 500,
21
+ "balanceOf": 1000,
22
+ }.get(method)
23
+
24
+ yield mock_call, mock_prep
25
+
26
+
27
+ def test_init(mock_contract_instance):
28
+ erc20 = ERC20Contract("0xToken", "gnosis")
29
+ assert erc20.decimals == 18
30
+ assert erc20.symbol == "TEST"
31
+ assert erc20.name == "Test Token"
32
+ assert erc20.total_supply == 1000000
33
+
34
+
35
+ def test_allowance_wei(mock_contract_instance):
36
+ erc20 = ERC20Contract("0xToken", "gnosis")
37
+ assert erc20.allowance_wei("0xOwner", "0xSpender") == 500
38
+
39
+
40
+ def test_allowance_eth(mock_contract_instance):
41
+ erc20 = ERC20Contract("0xToken", "gnosis")
42
+ # 500 wei / 10^18 is tiny
43
+ assert erc20.allowance_eth("0xOwner", "0xSpender") == 500 / 10**18
44
+
45
+
46
+ def test_balance_of_wei(mock_contract_instance):
47
+ erc20 = ERC20Contract("0xToken", "gnosis")
48
+ assert erc20.balance_of_wei("0xAccount") == 1000
49
+
50
+
51
+ def test_balance_of_eth(mock_contract_instance):
52
+ erc20 = ERC20Contract("0xToken", "gnosis")
53
+ assert erc20.balance_of_eth("0xAccount") == 1000 / 10**18
54
+
55
+
56
+ def test_prepare_transfer_tx(mock_contract_instance):
57
+ mock_call, mock_prep = mock_contract_instance
58
+ mock_prep.return_value = {"data": "0x"}
59
+
60
+ erc20 = ERC20Contract("0xToken", "gnosis")
61
+ tx = erc20.prepare_transfer_tx("0xFrom", "0xTo", 100)
62
+ assert tx == {"data": "0x"}
63
+ mock_prep.assert_called_with(
64
+ method_name="transfer",
65
+ method_kwargs={"to": "0xTo", "amount": 100},
66
+ tx_params={"from": "0xFrom"},
67
+ )
68
+
69
+
70
+ def test_prepare_transfer_from_tx(mock_contract_instance):
71
+ mock_call, mock_prep = mock_contract_instance
72
+ mock_prep.return_value = {"data": "0x"}
73
+
74
+ erc20 = ERC20Contract("0xToken", "gnosis")
75
+ tx = erc20.prepare_transfer_from_tx("0xFrom", "0xSender", "0xRecipient", 100)
76
+ assert tx == {"data": "0x"}
77
+ mock_prep.assert_called_with(
78
+ method_name="transferFrom",
79
+ method_kwargs={"_sender": "0xSender", "_recipient": "0xRecipient", "_amount": 100},
80
+ tx_params={"from": "0xFrom"},
81
+ )
82
+
83
+
84
+ def test_prepare_approve_tx(mock_contract_instance):
85
+ mock_call, mock_prep = mock_contract_instance
86
+ mock_prep.return_value = {"data": "0x"}
87
+
88
+ erc20 = ERC20Contract("0xToken", "gnosis")
89
+ tx = erc20.prepare_approve_tx("0xFrom", "0xSpender", 100)
90
+ assert tx == {"data": "0x"}
91
+ mock_prep.assert_called_with(
92
+ method_name="approve",
93
+ method_kwargs={"spender": "0xSpender", "amount": 100},
94
+ tx_params={"from": "0xFrom"},
95
+ )
@@ -0,0 +1,111 @@
1
+ """Tests for Gnosis Plugin."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ import typer
7
+
8
+ from iwa.plugins.gnosis.plugin import GnosisPlugin
9
+
10
+
11
+ @pytest.fixture
12
+ def plugin():
13
+ return GnosisPlugin()
14
+
15
+
16
+ def test_plugin_name(plugin):
17
+ """Test plugin name property."""
18
+ assert plugin.name == "gnosis"
19
+
20
+
21
+ def test_get_cli_commands(plugin):
22
+ """Test get_cli_commands returns correct commands."""
23
+ commands = plugin.get_cli_commands()
24
+
25
+ assert "create-safe" in commands
26
+ assert callable(commands["create-safe"])
27
+
28
+
29
+ def test_create_safe_command_success(plugin):
30
+ """Test create_safe_command with successful creation."""
31
+ with (
32
+ patch("iwa.plugins.gnosis.plugin.KeyStorage"),
33
+ patch("iwa.core.services.AccountService"),
34
+ patch("iwa.core.services.SafeService") as mock_safe_service,
35
+ ):
36
+ mock_safe_service.return_value.create_safe.return_value = "0xSafeAddress"
37
+
38
+ # Call the command directly
39
+ plugin.create_safe_command(
40
+ tag="my_safe",
41
+ owners="owner1,owner2",
42
+ threshold=2,
43
+ chain_name="gnosis",
44
+ )
45
+
46
+ mock_safe_service.return_value.create_safe.assert_called_once_with(
47
+ deployer_tag_or_address="master",
48
+ owner_tags_or_addresses=["owner1", "owner2"],
49
+ threshold=2,
50
+ chain_name="gnosis",
51
+ tag="my_safe",
52
+ )
53
+
54
+
55
+ def test_create_safe_command_error(plugin):
56
+ """Test create_safe_command handles ValueError."""
57
+ with (
58
+ patch("iwa.plugins.gnosis.plugin.KeyStorage"),
59
+ patch("iwa.core.services.AccountService"),
60
+ patch("iwa.core.services.SafeService") as mock_safe_service,
61
+ ):
62
+ mock_safe_service.return_value.create_safe.side_effect = ValueError("Owner not found")
63
+
64
+ with pytest.raises(typer.Exit) as exc_info:
65
+ plugin.create_safe_command(
66
+ tag="my_safe",
67
+ owners="unknown_owner",
68
+ threshold=1,
69
+ chain_name="gnosis",
70
+ )
71
+
72
+ assert exc_info.value.exit_code == 1
73
+
74
+
75
+ def test_create_safe_command_no_tag(plugin):
76
+ """Test create_safe_command without tag."""
77
+ with (
78
+ patch("iwa.plugins.gnosis.plugin.KeyStorage"),
79
+ patch("iwa.core.services.AccountService"),
80
+ patch("iwa.core.services.SafeService") as mock_safe_service,
81
+ ):
82
+ mock_safe_service.return_value.create_safe.return_value = "0xSafeAddress"
83
+
84
+ plugin.create_safe_command(
85
+ tag=None,
86
+ owners="owner1",
87
+ threshold=1,
88
+ chain_name="gnosis",
89
+ )
90
+
91
+ mock_safe_service.return_value.create_safe.assert_called_once()
92
+ call_kwargs = mock_safe_service.return_value.create_safe.call_args[1]
93
+ assert call_kwargs["tag"] is None
94
+
95
+
96
+ def test_create_safe_command_multiple_owners(plugin):
97
+ """Test create_safe_command with multiple owners."""
98
+ with (
99
+ patch("iwa.plugins.gnosis.plugin.KeyStorage"),
100
+ patch("iwa.core.services.AccountService"),
101
+ patch("iwa.core.services.SafeService") as mock_safe_service,
102
+ ):
103
+ plugin.create_safe_command(
104
+ tag="multi_safe",
105
+ owners="owner1, owner2, owner3", # With spaces
106
+ threshold=2,
107
+ chain_name="gnosis",
108
+ )
109
+
110
+ call_kwargs = mock_safe_service.return_value.create_safe.call_args[1]
111
+ assert call_kwargs["owner_tags_or_addresses"] == ["owner1", "owner2", "owner3"]
tests/test_keys.py ADDED
@@ -0,0 +1,449 @@
1
+ """Tests for KeyStorage - all tests use tmp_path to avoid touching real wallet.json."""
2
+
3
+ import base64
4
+ import json
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from iwa.core.keys import EncryptedAccount, KeyStorage, StoredSafeAccount
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_secrets():
14
+ """Mock settings to provide test password."""
15
+ with patch("iwa.core.keys.settings") as mock:
16
+ mock.wallet_password.get_secret_value.return_value = "test_password"
17
+ mock.gnosis_rpc.get_secret_value.return_value = "http://rpc"
18
+ yield mock
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_aesgcm():
23
+ """Mock AESGCM for predictable encryption/decryption."""
24
+ with patch("iwa.core.keys.AESGCM") as mock:
25
+ mock.return_value.encrypt.return_value = b"ciphertext"
26
+ mock.return_value.decrypt.return_value = b"private_key"
27
+ yield mock
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_scrypt():
32
+ """Mock Scrypt for predictable key derivation."""
33
+ with patch("iwa.core.keys.Scrypt") as mock:
34
+ mock.return_value.derive.return_value = b"key" * 11 # 32 bytes
35
+ yield mock
36
+
37
+
38
+ @pytest.fixture
39
+ def mock_account():
40
+ """Mock eth_account.Account for predictable account creation."""
41
+ with patch("iwa.core.keys.Account") as mock:
42
+ from itertools import cycle
43
+
44
+ addresses = cycle(
45
+ [
46
+ "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
47
+ "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B",
48
+ "0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db",
49
+ "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB",
50
+ ]
51
+ )
52
+
53
+ def create_side_effect():
54
+ addr = next(addresses)
55
+ m = MagicMock()
56
+ m.key.hex.return_value = f"0xPrivateKey{addr}"
57
+ m.address = addr
58
+ return m
59
+
60
+ mock.create.side_effect = create_side_effect
61
+
62
+ def from_key_side_effect(private_key):
63
+ if isinstance(private_key, str) and "0xPrivateKey" in private_key:
64
+ addr = private_key.replace("0xPrivateKey", "")
65
+ else:
66
+ addr = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
67
+ m = MagicMock()
68
+ m.address = addr
69
+ m.key = private_key.encode() if isinstance(private_key, str) else private_key
70
+ return m
71
+
72
+ mock.from_key.side_effect = from_key_side_effect
73
+ yield mock
74
+
75
+
76
+ # --- EncryptedAccount tests (no file I/O needed) ---
77
+
78
+
79
+ def test_encrypted_account_derive_key(mock_scrypt):
80
+ """Test key derivation."""
81
+ key = EncryptedAccount.derive_key("password", b"salt")
82
+ assert key == b"key" * 11
83
+ mock_scrypt.assert_called_once()
84
+
85
+
86
+ def test_encrypted_account_encrypt_private_key(mock_scrypt, mock_aesgcm, mock_account):
87
+ """Test private key encryption."""
88
+ enc_account = EncryptedAccount.encrypt_private_key(
89
+ "0xPrivateKey0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", "password", "tag"
90
+ )
91
+ assert enc_account.address == "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
92
+ assert enc_account.tag == "tag"
93
+ assert enc_account.ciphertext == base64.b64encode(b"ciphertext").decode()
94
+
95
+
96
+ def test_encrypted_account_decrypt_private_key(mock_scrypt, mock_aesgcm, mock_secrets):
97
+ """Test private key decryption."""
98
+ enc_account = EncryptedAccount(
99
+ address="0x1111111111111111111111111111111111111111",
100
+ salt=base64.b64encode(b"salt").decode(),
101
+ nonce=base64.b64encode(b"nonce").decode(),
102
+ ciphertext=base64.b64encode(b"ciphertext").decode(),
103
+ tag="tag",
104
+ )
105
+ pkey = enc_account.decrypt_private_key()
106
+ assert pkey == "private_key"
107
+
108
+
109
+ # --- KeyStorage tests using tmp_path ---
110
+
111
+
112
+ def test_keystorage_init_new(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
113
+ """Test initialization of new KeyStorage creates master account."""
114
+ wallet_path = tmp_path / "wallet.json"
115
+ storage = KeyStorage(wallet_path, password="test_password")
116
+
117
+ # Master account should be created automatically
118
+ assert len(storage.accounts) == 1
119
+ assert storage.get_account("master") is not None
120
+
121
+
122
+ def test_keystorage_init_existing(tmp_path, mock_secrets):
123
+ """Test loading existing wallet file."""
124
+ wallet_path = tmp_path / "wallet.json"
125
+ data = {
126
+ "accounts": {
127
+ "0x1111111111111111111111111111111111111111": {
128
+ "address": "0x1111111111111111111111111111111111111111",
129
+ "salt": base64.b64encode(b"salt").decode(),
130
+ "nonce": base64.b64encode(b"nonce").decode(),
131
+ "ciphertext": base64.b64encode(b"ciphertext").decode(),
132
+ "tag": "master",
133
+ }
134
+ }
135
+ }
136
+ wallet_path.write_text(json.dumps(data))
137
+
138
+ storage = KeyStorage(wallet_path, password="test_password")
139
+ assert "0x1111111111111111111111111111111111111111" in storage.accounts
140
+ assert storage.get_account("master") is not None
141
+
142
+
143
+ def test_keystorage_init_corrupted(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
144
+ """Test handling of corrupted wallet file."""
145
+ wallet_path = tmp_path / "wallet.json"
146
+ wallet_path.write_text("{invalid json")
147
+
148
+ with patch("iwa.core.keys.logger") as mock_logger:
149
+ storage = KeyStorage(wallet_path, password="test_password")
150
+ # Corrupted file -> empty accounts -> auto create master
151
+ assert len(storage.accounts) == 1
152
+ assert storage.get_account("master") is not None
153
+ mock_logger.error.assert_called()
154
+
155
+
156
+ def test_keystorage_save(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
157
+ """Test saving wallet to file."""
158
+ wallet_path = tmp_path / "wallet.json"
159
+ storage = KeyStorage(wallet_path, password="test_password")
160
+ storage.save()
161
+
162
+ # Verify file was created
163
+ assert wallet_path.exists()
164
+ data = json.loads(wallet_path.read_text())
165
+ assert "accounts" in data
166
+
167
+
168
+ def test_keystorage_create_account(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
169
+ """Test creating additional accounts."""
170
+ wallet_path = tmp_path / "wallet.json"
171
+ storage = KeyStorage(wallet_path, password="test_password")
172
+
173
+ # Master created in init
174
+ enc_account = storage.create_account("tag")
175
+ assert enc_account.tag == "tag"
176
+ assert len(storage.accounts) == 2 # master + tag
177
+
178
+
179
+ def test_keystorage_create_account_duplicate_tag(
180
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
181
+ ):
182
+ """Test creating account with duplicate tag raises error."""
183
+ wallet_path = tmp_path / "wallet.json"
184
+ storage = KeyStorage(wallet_path, password="test_password")
185
+ storage.create_account("tag")
186
+
187
+ with pytest.raises(ValueError, match="already exists"):
188
+ storage.create_account("tag")
189
+
190
+
191
+ def test_keystorage_get_private_key(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
192
+ """Test internal private key retrieval."""
193
+ wallet_path = tmp_path / "wallet.json"
194
+ storage = KeyStorage(wallet_path, password="test_password")
195
+
196
+ master = storage.get_account("master")
197
+ pkey = storage._get_private_key(master.address)
198
+ assert pkey == "private_key"
199
+
200
+
201
+ def test_keystorage_sign_message(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
202
+ """Test message signing."""
203
+ wallet_path = tmp_path / "wallet.json"
204
+ storage = KeyStorage(wallet_path, password="test_password")
205
+ storage.create_account("tag")
206
+
207
+ mock_signed_msg = MagicMock()
208
+ mock_signed_msg.signature = b"signature"
209
+ mock_account.sign_message.return_value = mock_signed_msg
210
+
211
+ result = storage.sign_message(b"test message", "tag")
212
+ assert result == b"signature"
213
+
214
+
215
+ def test_keystorage_sign_transaction(
216
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
217
+ ):
218
+ """Test transaction signing."""
219
+ wallet_path = tmp_path / "wallet.json"
220
+ storage = KeyStorage(wallet_path, password="test_password")
221
+ storage.create_account("tag")
222
+
223
+ tx = {
224
+ "to": "0x0000000000000000000000000000000000000000",
225
+ "value": 0,
226
+ "gas": 21000,
227
+ "gasPrice": 1,
228
+ "nonce": 0,
229
+ "chainId": 1,
230
+ }
231
+
232
+ mock_signed_tx = MagicMock()
233
+ mock_account.sign_transaction.return_value = mock_signed_tx
234
+
235
+ result = storage.sign_transaction(tx, "tag")
236
+ assert result == mock_signed_tx
237
+ mock_account.sign_transaction.assert_called_once()
238
+
239
+
240
+ def test_keystorage_get_private_key_not_found(
241
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
242
+ ):
243
+ """Test private key retrieval for non-existent account."""
244
+ wallet_path = tmp_path / "wallet.json"
245
+ storage = KeyStorage(wallet_path, password="test_password")
246
+
247
+ assert storage._get_private_key("0x0000000000000000000000000000000000000000") is None
248
+
249
+
250
+ def test_keystorage_get_account(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
251
+ """Test getting account by address or tag."""
252
+ wallet_path = tmp_path / "wallet.json"
253
+ storage = KeyStorage(wallet_path, password="test_password")
254
+ acc1 = storage.create_account("tag")
255
+
256
+ # Get by address
257
+ acct = storage.get_account(acc1.address)
258
+ assert acct.address == acc1.address
259
+
260
+ # Get by tag
261
+ acct = storage.get_account("tag")
262
+ assert acct.address == acc1.address
263
+
264
+
265
+ def test_keystorage_get_tag_by_address(
266
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
267
+ ):
268
+ """Test getting tag by address."""
269
+ wallet_path = tmp_path / "wallet.json"
270
+ storage = KeyStorage(wallet_path, password="test_password")
271
+ acc = storage.create_account("tag")
272
+
273
+ assert storage.get_tag_by_address(acc.address) == "tag"
274
+ master = storage.get_account("master")
275
+ assert storage.get_tag_by_address(master.address) == "master"
276
+ assert storage.get_tag_by_address("0x3333333333333333333333333333333333333333") is None
277
+
278
+
279
+ def test_keystorage_get_address_by_tag(
280
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
281
+ ):
282
+ """Test getting address by tag."""
283
+ wallet_path = tmp_path / "wallet.json"
284
+ storage = KeyStorage(wallet_path, password="test_password")
285
+ acc = storage.create_account("tag")
286
+
287
+ assert storage.get_address_by_tag("tag") == acc.address
288
+ assert storage.get_address_by_tag("unknown") is None
289
+
290
+
291
+ def test_keystorage_master_account_fallback(tmp_path, mock_secrets):
292
+ """Test master_account property fallback when no master tag."""
293
+ wallet_path = tmp_path / "wallet.json"
294
+
295
+ # Create a wallet with an account that doesn't have "master" tag
296
+ enc_account = EncryptedAccount(
297
+ address="0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
298
+ salt=base64.b64encode(b"salt").decode(),
299
+ nonce=base64.b64encode(b"nonce").decode(),
300
+ ciphertext=base64.b64encode(b"ciphertext").decode(),
301
+ tag="other",
302
+ )
303
+
304
+ data = {"accounts": {enc_account.address: enc_account.model_dump()}}
305
+ wallet_path.write_text(json.dumps(data))
306
+
307
+ # Patch create_account to prevent auto-creation of master
308
+ with patch.object(KeyStorage, "create_account"):
309
+ storage = KeyStorage(wallet_path, password="test_password")
310
+ # Manually add the account since create_account is mocked
311
+ storage.accounts[enc_account.address] = enc_account
312
+
313
+ # Should return the first account if master not found
314
+ assert storage.master_account.tag == "other"
315
+
316
+
317
+ def test_keystorage_master_account_success(
318
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
319
+ ):
320
+ """Test master_account property returns master."""
321
+ wallet_path = tmp_path / "wallet.json"
322
+ storage = KeyStorage(wallet_path, password="test_password")
323
+
324
+ assert storage.master_account.tag == "master"
325
+ assert storage.master_account.address is not None
326
+
327
+
328
+ def test_keystorage_create_account_default_tag(
329
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
330
+ ):
331
+ """Test creating account with custom tag."""
332
+ wallet_path = tmp_path / "wallet.json"
333
+ storage = KeyStorage(wallet_path, password="test_password")
334
+
335
+ acc = storage.create_account("foo")
336
+ assert acc.tag == "foo"
337
+ assert len(storage.accounts) == 2
338
+
339
+
340
+ def test_keystorage_remove_account_not_found(
341
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
342
+ ):
343
+ """Test removing non-existent account doesn't raise."""
344
+ wallet_path = tmp_path / "wallet.json"
345
+ storage = KeyStorage(wallet_path, password="test_password")
346
+
347
+ # Should not raise
348
+ storage.remove_account("0x0000000000000000000000000000000000000000")
349
+
350
+
351
+ def test_keystorage_get_account_auto_load_safe(
352
+ tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt
353
+ ):
354
+ """Test getting StoredSafeAccount."""
355
+ wallet_path = tmp_path / "wallet.json"
356
+ storage = KeyStorage(wallet_path, password="test_password")
357
+
358
+ safe_addr = "0x61a4f49e9dD1f90EB312889632FA956a21353720"
359
+ safe = StoredSafeAccount(
360
+ tag="safe", address=safe_addr, chains=["gnosis"], threshold=1, signers=[]
361
+ )
362
+ storage.accounts[safe_addr] = safe
363
+
364
+ acc = storage.get_account(safe_addr)
365
+ assert isinstance(acc, StoredSafeAccount)
366
+ assert acc.tag == "safe"
367
+
368
+
369
+ def test_keystorage_get_account_none(tmp_path, mock_secrets):
370
+ """Test getting non-existent account returns None."""
371
+ wallet_path = tmp_path / "wallet.json"
372
+
373
+ # Create empty wallet
374
+ data = {"accounts": {}}
375
+ wallet_path.write_text(json.dumps(data))
376
+
377
+ # Patch create_account to prevent auto-creation
378
+ with patch.object(KeyStorage, "create_account"):
379
+ storage = KeyStorage(wallet_path, password="test_password")
380
+ assert storage.get_account("0x5B38Da6a701c568545dCfcB03FcB875f56beddC4") is None
381
+ assert storage.get_account("tag") is None
382
+
383
+
384
+ def test_get_account_info(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
385
+ """Test get_account_info alias."""
386
+ wallet_path = tmp_path / "wallet.json"
387
+ storage = KeyStorage(wallet_path, password="test_password")
388
+ storage.create_account("tag1")
389
+
390
+ info = storage.get_account_info("tag1")
391
+ assert info.address == storage.find_stored_account("tag1").address
392
+ assert info.tag == "tag1"
393
+ assert not hasattr(info, "key")
394
+
395
+
396
+ def test_get_signer(tmp_path, mock_secrets, mock_account, mock_aesgcm, mock_scrypt):
397
+ """Test get_signer method."""
398
+ wallet_path = tmp_path / "wallet.json"
399
+ storage = KeyStorage(wallet_path, password="test_password")
400
+ storage.create_account("tag")
401
+
402
+ # Test valid signer retrieval
403
+ signer = storage.get_signer("tag")
404
+ assert signer is not None
405
+ mock_account.from_key.assert_called_with("private_key")
406
+
407
+ # Test non-existent account
408
+ assert storage.get_signer("unknown") is None
409
+
410
+ # Test safe account (should return None)
411
+ safe = StoredSafeAccount(
412
+ tag="safe",
413
+ address="0x61a4f49e9dD1f90EB312889632FA956a21353720",
414
+ chains=["gnosis"],
415
+ threshold=1,
416
+ signers=[],
417
+ )
418
+ storage.accounts["0x61a4f49e9dD1f90EB312889632FA956a21353720"] = safe
419
+ assert storage.get_signer("safe") is None
420
+
421
+
422
+ def test_keystorage_edge_cases_with_real_storage(tmp_path):
423
+ """Test KeyStorage edge cases with real file storage."""
424
+ wallet_path = tmp_path / "wallet.json"
425
+ storage = KeyStorage(wallet_path, password="password")
426
+
427
+ # Create account
428
+ encrypted_acc = storage.create_account("acc1")
429
+ assert encrypted_acc is not None
430
+
431
+ # Get by address
432
+ acc_by_addr = storage.get_account(encrypted_acc.address)
433
+ assert acc_by_addr is not None
434
+
435
+ # Remove account
436
+ storage.remove_account(encrypted_acc.address)
437
+
438
+ # Verify removal
439
+ assert storage.get_account(encrypted_acc.address) is None
440
+ assert storage.get_account("acc1") is None
441
+
442
+ # Get private key via internal method
443
+ encrypted_acc2 = storage.create_account("acc2")
444
+ pk = storage._get_private_key(encrypted_acc2.address)
445
+ assert pk is not None
446
+
447
+ # Sign transaction unknown account
448
+ with pytest.raises(ValueError):
449
+ storage.sign_transaction({}, "0xUnknown")