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,17 @@
|
|
|
1
|
+
"""Core services package."""
|
|
2
|
+
|
|
3
|
+
from iwa.core.services.account import AccountService
|
|
4
|
+
from iwa.core.services.balance import BalanceService
|
|
5
|
+
from iwa.core.services.plugin import PluginService
|
|
6
|
+
from iwa.core.services.safe import SafeService
|
|
7
|
+
from iwa.core.services.transaction import TransactionService
|
|
8
|
+
from iwa.core.services.transfer import TransferService
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AccountService",
|
|
12
|
+
"BalanceService",
|
|
13
|
+
"PluginService",
|
|
14
|
+
"SafeService",
|
|
15
|
+
"TransactionService",
|
|
16
|
+
"TransferService",
|
|
17
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Account service module."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from iwa.core.chain import SupportedChain
|
|
8
|
+
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
|
|
9
|
+
from iwa.core.models import EthereumAddress, StoredSafeAccount
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from iwa.core.keys import EncryptedAccount, KeyStorage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AccountService:
|
|
16
|
+
"""Service for account resolution and management."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, key_storage: "KeyStorage"):
|
|
19
|
+
"""Initialize AccountService."""
|
|
20
|
+
self.key_storage = key_storage
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def master_account(self) -> Optional[StoredSafeAccount]:
|
|
24
|
+
"""Get master account."""
|
|
25
|
+
return self.key_storage.master_account
|
|
26
|
+
|
|
27
|
+
def get_token_address(
|
|
28
|
+
self, token_address_or_name: str, chain: SupportedChain
|
|
29
|
+
) -> Optional[EthereumAddress]:
|
|
30
|
+
"""Get token address from address or name."""
|
|
31
|
+
if token_address_or_name == "native":
|
|
32
|
+
return EthereumAddress(NATIVE_CURRENCY_ADDRESS)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
return EthereumAddress(token_address_or_name)
|
|
36
|
+
except ValueError:
|
|
37
|
+
token_address = chain.get_token_address(token_address_or_name)
|
|
38
|
+
if not token_address:
|
|
39
|
+
logger.error(f"Token '{token_address_or_name}' not found on chain '{chain.name}'.")
|
|
40
|
+
return None
|
|
41
|
+
return token_address
|
|
42
|
+
|
|
43
|
+
def resolve_account(
|
|
44
|
+
self, address_or_tag: str
|
|
45
|
+
) -> Optional[Union[StoredSafeAccount, "EncryptedAccount"]]:
|
|
46
|
+
"""Resolve account from address or tag."""
|
|
47
|
+
return self.key_storage.get_account(address_or_tag)
|
|
48
|
+
|
|
49
|
+
def get_tag_by_address(self, address: str) -> Optional[str]:
|
|
50
|
+
"""Get tag for a given address."""
|
|
51
|
+
return self.key_storage.get_tag_by_address(address)
|
|
52
|
+
|
|
53
|
+
def get_account_data(
|
|
54
|
+
self,
|
|
55
|
+
) -> Dict[EthereumAddress, Union[StoredSafeAccount, "EncryptedAccount"]]:
|
|
56
|
+
"""Get all accounts data."""
|
|
57
|
+
return self.key_storage.accounts
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Balance service module."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, Optional, Union
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from web3.types import Wei
|
|
8
|
+
|
|
9
|
+
from iwa.core.chain import ChainInterfaces
|
|
10
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from iwa.core.keys import KeyStorage
|
|
14
|
+
from iwa.core.services_pkg.account import AccountService
|
|
15
|
+
from iwa.core.wallet import Wallet
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BalanceService:
|
|
19
|
+
"""Service for fetching native and ERC20 balances."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
wallet_or_key_storage: Union["Wallet", "KeyStorage"],
|
|
24
|
+
account_service: "AccountService",
|
|
25
|
+
):
|
|
26
|
+
"""Initialize BalanceService."""
|
|
27
|
+
self.key_storage = (
|
|
28
|
+
wallet_or_key_storage.key_storage
|
|
29
|
+
if hasattr(wallet_or_key_storage, "key_storage")
|
|
30
|
+
else wallet_or_key_storage
|
|
31
|
+
)
|
|
32
|
+
self.account_service = account_service
|
|
33
|
+
|
|
34
|
+
def get_native_balance_eth(
|
|
35
|
+
self, account_address_or_tag: str, chain_name: str = "gnosis"
|
|
36
|
+
) -> Optional[float]:
|
|
37
|
+
"""Get native currency balance in ETH."""
|
|
38
|
+
account = self.account_service.resolve_account(account_address_or_tag)
|
|
39
|
+
if not account:
|
|
40
|
+
# If not found, try to use as raw address
|
|
41
|
+
address = account_address_or_tag
|
|
42
|
+
else:
|
|
43
|
+
address = account.address
|
|
44
|
+
|
|
45
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
46
|
+
return chain_interface.get_native_balance_eth(address)
|
|
47
|
+
|
|
48
|
+
def get_native_balance_wei(
|
|
49
|
+
self, account_address_or_tag: str, chain_name: str = "gnosis"
|
|
50
|
+
) -> Optional[Wei]:
|
|
51
|
+
"""Get native currency balance in WEI."""
|
|
52
|
+
account = self.account_service.resolve_account(account_address_or_tag)
|
|
53
|
+
if not account:
|
|
54
|
+
# If not found, try to use as raw address
|
|
55
|
+
address = account_address_or_tag
|
|
56
|
+
else:
|
|
57
|
+
address = account.address
|
|
58
|
+
|
|
59
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
60
|
+
return chain_interface.get_native_balance_wei(address)
|
|
61
|
+
|
|
62
|
+
def get_erc20_balance_eth(
|
|
63
|
+
self, account_address_or_tag: str, token_address_or_name: str, chain_name: str = "gnosis"
|
|
64
|
+
) -> Optional[float]:
|
|
65
|
+
"""Get ERC20 token balance in ETH-like format."""
|
|
66
|
+
chain = ChainInterfaces().get(chain_name)
|
|
67
|
+
token_address = self.account_service.get_token_address(token_address_or_name, chain.chain)
|
|
68
|
+
if not token_address:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
account = self.account_service.resolve_account(account_address_or_tag)
|
|
72
|
+
if not account:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
contract = ERC20Contract(chain_name=chain_name, address=token_address)
|
|
76
|
+
return contract.balance_of_eth(account.address)
|
|
77
|
+
|
|
78
|
+
def get_erc20_balance_wei(
|
|
79
|
+
self, account_address_or_tag: str, token_address_or_name: str, chain_name: str = "gnosis"
|
|
80
|
+
) -> Optional[Wei]:
|
|
81
|
+
"""Get ERC20 token balance in WEI."""
|
|
82
|
+
chain = ChainInterfaces().get(chain_name)
|
|
83
|
+
token_address = self.account_service.get_token_address(token_address_or_name, chain.chain)
|
|
84
|
+
if not token_address:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
account = self.account_service.resolve_account(account_address_or_tag)
|
|
88
|
+
if not account:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
contract = ERC20Contract(chain_name=chain_name, address=token_address)
|
|
92
|
+
return contract.balance_of_wei(account.address)
|
|
93
|
+
|
|
94
|
+
def get_erc20_balance_with_retry(
|
|
95
|
+
self,
|
|
96
|
+
account_address: str,
|
|
97
|
+
token_address_or_name: str,
|
|
98
|
+
chain_name: str = "gnosis",
|
|
99
|
+
retries: int = 3,
|
|
100
|
+
) -> Optional[float]:
|
|
101
|
+
"""Fetch balance with retry logic."""
|
|
102
|
+
for attempt in range(retries):
|
|
103
|
+
try:
|
|
104
|
+
return self.get_erc20_balance_eth(
|
|
105
|
+
account_address, token_address_or_name, chain_name
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
if attempt == retries - 1:
|
|
109
|
+
logger.error(
|
|
110
|
+
f"Failed to fetch balance for {token_address_or_name} after {retries} attempts: {e}"
|
|
111
|
+
)
|
|
112
|
+
time.sleep(1)
|
|
113
|
+
return None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Plugin Service module."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import pkgutil
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from iwa.core.plugins import Plugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginService:
|
|
14
|
+
"""Manages plugin discovery, loading, and lifecycle."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, plugins_package: str = "iwa.plugins"):
|
|
17
|
+
"""Initialize PluginService.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
plugins_package: Python package path to search for plugins.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
self.plugins_package = plugins_package
|
|
24
|
+
self.loaded_plugins: Dict[str, Plugin] = {}
|
|
25
|
+
self._load_plugins()
|
|
26
|
+
|
|
27
|
+
def _discover_plugins(self) -> List[str]:
|
|
28
|
+
"""Discover available plugins in the plugins package."""
|
|
29
|
+
try:
|
|
30
|
+
package = importlib.import_module(self.plugins_package)
|
|
31
|
+
if not hasattr(package, "__path__"):
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]
|
|
35
|
+
except ImportError:
|
|
36
|
+
logger.warning(f"Could not import plugins package: {self.plugins_package}")
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
def _load_plugins(self) -> None:
|
|
40
|
+
"""Load all discovered plugins."""
|
|
41
|
+
from iwa.core.models import Config
|
|
42
|
+
|
|
43
|
+
plugin_names = self._discover_plugins()
|
|
44
|
+
config = Config()
|
|
45
|
+
|
|
46
|
+
for name in plugin_names:
|
|
47
|
+
if name in self.loaded_plugins:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
module_name = f"{self.plugins_package}.{name}"
|
|
52
|
+
module = importlib.import_module(module_name)
|
|
53
|
+
|
|
54
|
+
# Find Plugin subclass
|
|
55
|
+
for _, obj in inspect.getmembers(module):
|
|
56
|
+
if inspect.isclass(obj) and issubclass(obj, Plugin) and obj is not Plugin:
|
|
57
|
+
try:
|
|
58
|
+
plugin_instance = obj()
|
|
59
|
+
# Verify unique name
|
|
60
|
+
if plugin_instance.name in self.loaded_plugins:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Plugin name collision: {plugin_instance.name}. Skipping."
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Register plugin's config model if it has one
|
|
67
|
+
if plugin_instance.config_model:
|
|
68
|
+
config.register_plugin_config(
|
|
69
|
+
plugin_instance.name, plugin_instance.config_model
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.loaded_plugins[plugin_instance.name] = plugin_instance
|
|
73
|
+
plugin_instance.on_load()
|
|
74
|
+
logger.info(f"Loaded plugin: {plugin_instance.name}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Failed to instantiate plugin {name}: {e}")
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Failed to load plugin module {name}: {e}")
|
|
80
|
+
|
|
81
|
+
def get_plugin(self, name: str) -> Optional[Plugin]:
|
|
82
|
+
"""Get a loaded plugin by name."""
|
|
83
|
+
return self.loaded_plugins.get(name)
|
|
84
|
+
|
|
85
|
+
def get_all_plugins(self) -> Dict[str, Plugin]:
|
|
86
|
+
"""Get all loaded plugins."""
|
|
87
|
+
# Return a copy to prevent modification
|
|
88
|
+
return self.loaded_plugins.copy()
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Safe service module."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from safe_eth.eth import EthereumClient
|
|
7
|
+
from safe_eth.safe import Safe, SafeOperationEnum
|
|
8
|
+
from safe_eth.safe.proxy_factory import ProxyFactory
|
|
9
|
+
from safe_eth.safe.safe_tx import SafeTx
|
|
10
|
+
|
|
11
|
+
from iwa.core.constants import ZERO_ADDRESS
|
|
12
|
+
from iwa.core.db import log_transaction
|
|
13
|
+
from iwa.core.models import StoredSafeAccount
|
|
14
|
+
from iwa.core.settings import settings
|
|
15
|
+
from iwa.core.utils import (
|
|
16
|
+
get_safe_master_copy_address,
|
|
17
|
+
get_safe_proxy_factory_address,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from iwa.core.keys import EncryptedAccount, KeyStorage
|
|
22
|
+
from iwa.core.services.account import AccountService
|
|
23
|
+
|
|
24
|
+
# We need EncryptedAccount for checks at runtime
|
|
25
|
+
try:
|
|
26
|
+
from iwa.core.keys import EncryptedAccount
|
|
27
|
+
except ImportError:
|
|
28
|
+
# Circular import prevention if keys imports safe (it shouldn't)
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SafeService:
|
|
33
|
+
"""Service for Safe deployment and management."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, key_storage: "KeyStorage", account_service: "AccountService"):
|
|
36
|
+
"""Initialize SafeService."""
|
|
37
|
+
self.key_storage = key_storage
|
|
38
|
+
self.account_service = account_service
|
|
39
|
+
|
|
40
|
+
def create_safe(
|
|
41
|
+
self,
|
|
42
|
+
deployer_tag_or_address: str,
|
|
43
|
+
owner_tags_or_addresses: List[str],
|
|
44
|
+
threshold: int,
|
|
45
|
+
chain_name: str,
|
|
46
|
+
tag: Optional[str] = None,
|
|
47
|
+
salt_nonce: Optional[int] = None,
|
|
48
|
+
) -> Tuple[StoredSafeAccount, str]:
|
|
49
|
+
"""Deploy a new Safe."""
|
|
50
|
+
deployer_account = self._prepare_deployer_account(deployer_tag_or_address)
|
|
51
|
+
owner_addresses = self._resolve_owner_addresses(owner_tags_or_addresses)
|
|
52
|
+
|
|
53
|
+
ethereum_client = self._get_ethereum_client(chain_name)
|
|
54
|
+
|
|
55
|
+
contract_address, tx_hash = self._deploy_safe_contract(
|
|
56
|
+
deployer_account, owner_addresses, threshold, salt_nonce, ethereum_client
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
logger.info(
|
|
60
|
+
f"Safe {tag} [{contract_address}] deployed on {chain_name} on transaction: {tx_hash}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self._log_safe_deployment(
|
|
64
|
+
deployer_account,
|
|
65
|
+
deployer_tag_or_address,
|
|
66
|
+
contract_address,
|
|
67
|
+
tx_hash,
|
|
68
|
+
chain_name,
|
|
69
|
+
ethereum_client,
|
|
70
|
+
tag,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
safe_account = self._store_safe_account(
|
|
74
|
+
contract_address, chain_name, owner_addresses, threshold, tag
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return safe_account, tx_hash
|
|
78
|
+
|
|
79
|
+
def _prepare_deployer_account(self, deployer_tag_or_address: str):
|
|
80
|
+
deployer_stored_account = self.key_storage.find_stored_account(deployer_tag_or_address)
|
|
81
|
+
if not deployer_stored_account or not isinstance(deployer_stored_account, EncryptedAccount):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Deployer account '{deployer_tag_or_address}' not found or is a Safe."
|
|
84
|
+
)
|
|
85
|
+
from eth_account import Account
|
|
86
|
+
|
|
87
|
+
deployer_private_key = self.key_storage._get_private_key(deployer_stored_account.address)
|
|
88
|
+
if not deployer_private_key:
|
|
89
|
+
raise ValueError("Deployer private key not available.")
|
|
90
|
+
return Account.from_key(deployer_private_key)
|
|
91
|
+
|
|
92
|
+
def _resolve_owner_addresses(self, owner_tags_or_addresses: List[str]) -> List[str]:
|
|
93
|
+
owner_addresses = []
|
|
94
|
+
for tag_or_address in owner_tags_or_addresses:
|
|
95
|
+
owner_stored_account = self.key_storage.find_stored_account(tag_or_address)
|
|
96
|
+
if not owner_stored_account:
|
|
97
|
+
raise ValueError(f"Owner account '{tag_or_address}' not found in wallet.")
|
|
98
|
+
owner_addresses.append(owner_stored_account.address)
|
|
99
|
+
return owner_addresses
|
|
100
|
+
|
|
101
|
+
def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
|
|
102
|
+
rpc_secret = getattr(settings, f"{chain_name}_rpc")
|
|
103
|
+
return EthereumClient(rpc_secret.get_secret_value())
|
|
104
|
+
|
|
105
|
+
def _deploy_safe_contract(
|
|
106
|
+
self,
|
|
107
|
+
deployer_account,
|
|
108
|
+
owner_addresses: List[str],
|
|
109
|
+
threshold: int,
|
|
110
|
+
salt_nonce: Optional[int],
|
|
111
|
+
ethereum_client: EthereumClient,
|
|
112
|
+
) -> Tuple[str, str]:
|
|
113
|
+
master_copy = get_safe_master_copy_address("1.4.1")
|
|
114
|
+
proxy_factory_address = get_safe_proxy_factory_address("1.4.1")
|
|
115
|
+
|
|
116
|
+
if salt_nonce is not None:
|
|
117
|
+
# Use ProxyFactory directly to enforce salt
|
|
118
|
+
proxy_factory = ProxyFactory(proxy_factory_address, ethereum_client)
|
|
119
|
+
|
|
120
|
+
# Encoded setup data
|
|
121
|
+
empty_safe = Safe(master_copy, ethereum_client)
|
|
122
|
+
setup_data = empty_safe.contract.functions.setup(
|
|
123
|
+
owner_addresses,
|
|
124
|
+
threshold,
|
|
125
|
+
str(ZERO_ADDRESS),
|
|
126
|
+
b"",
|
|
127
|
+
str(ZERO_ADDRESS),
|
|
128
|
+
str(ZERO_ADDRESS),
|
|
129
|
+
0,
|
|
130
|
+
str(ZERO_ADDRESS),
|
|
131
|
+
).build_transaction({"gas": 0, "gasPrice": 0})["data"]
|
|
132
|
+
|
|
133
|
+
gas_price = ethereum_client.w3.eth.gas_price
|
|
134
|
+
tx_sent = proxy_factory.deploy_proxy_contract_with_nonce(
|
|
135
|
+
deployer_account,
|
|
136
|
+
master_copy,
|
|
137
|
+
initializer=bytes.fromhex(setup_data[2:])
|
|
138
|
+
if setup_data.startswith("0x")
|
|
139
|
+
else bytes.fromhex(setup_data),
|
|
140
|
+
nonce=salt_nonce,
|
|
141
|
+
gas=5_000_000,
|
|
142
|
+
gas_price=gas_price,
|
|
143
|
+
)
|
|
144
|
+
return tx_sent.contract_address, tx_sent.tx_hash.hex()
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
# Standard random salt via Safe.create
|
|
148
|
+
create_tx = Safe.create(
|
|
149
|
+
ethereum_client=ethereum_client,
|
|
150
|
+
deployer_account=deployer_account,
|
|
151
|
+
master_copy_address=master_copy,
|
|
152
|
+
owners=owner_addresses,
|
|
153
|
+
threshold=threshold,
|
|
154
|
+
proxy_factory_address=proxy_factory_address,
|
|
155
|
+
)
|
|
156
|
+
return create_tx.contract_address, create_tx.tx_hash.hex()
|
|
157
|
+
|
|
158
|
+
def _log_safe_deployment(
|
|
159
|
+
self,
|
|
160
|
+
deployer_account,
|
|
161
|
+
deployer_tag_or_address: str,
|
|
162
|
+
contract_address: str,
|
|
163
|
+
tx_hash: str,
|
|
164
|
+
chain_name: str,
|
|
165
|
+
ethereum_client: EthereumClient,
|
|
166
|
+
tag: Optional[str],
|
|
167
|
+
):
|
|
168
|
+
# Resolve tag for logging
|
|
169
|
+
resolved_from_tag = self.account_service.get_tag_by_address(deployer_account.address)
|
|
170
|
+
|
|
171
|
+
# Get receipt and calculate gas info
|
|
172
|
+
gas_cost = None
|
|
173
|
+
gas_value_eur = None
|
|
174
|
+
try:
|
|
175
|
+
receipt = ethereum_client.w3.eth.get_transaction_receipt(tx_hash)
|
|
176
|
+
if receipt:
|
|
177
|
+
gas_used = receipt.get("gasUsed", 0)
|
|
178
|
+
effective_gas_price = receipt.get("effectiveGasPrice", 0)
|
|
179
|
+
gas_cost = gas_used * effective_gas_price
|
|
180
|
+
|
|
181
|
+
# Get native token price for gas value calculation
|
|
182
|
+
from iwa.core.pricing import PriceService
|
|
183
|
+
|
|
184
|
+
chain_coingecko_ids = {"gnosis": "dai", "ethereum": "ethereum", "base": "ethereum"}
|
|
185
|
+
coingecko_id = chain_coingecko_ids.get(chain_name, "ethereum")
|
|
186
|
+
price_service = PriceService()
|
|
187
|
+
native_price_eur = price_service.get_token_price(coingecko_id, "eur")
|
|
188
|
+
|
|
189
|
+
if native_price_eur and gas_cost > 0:
|
|
190
|
+
gas_cost_eth = gas_cost / 10**18
|
|
191
|
+
gas_value_eur = gas_cost_eth * native_price_eur
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.warning(f"Could not calculate gas info for Safe deployment: {e}")
|
|
194
|
+
|
|
195
|
+
# Get native currency symbol for this chain
|
|
196
|
+
from iwa.core.chain import ChainInterfaces
|
|
197
|
+
|
|
198
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
199
|
+
native_symbol = chain_interface.chain.native_currency
|
|
200
|
+
|
|
201
|
+
log_transaction(
|
|
202
|
+
tx_hash=tx_hash,
|
|
203
|
+
from_addr=deployer_account.address,
|
|
204
|
+
to_addr=contract_address,
|
|
205
|
+
token=native_symbol,
|
|
206
|
+
amount_wei=0,
|
|
207
|
+
chain=chain_name,
|
|
208
|
+
from_tag=resolved_from_tag or deployer_tag_or_address,
|
|
209
|
+
to_tag=tag,
|
|
210
|
+
gas_cost=gas_cost,
|
|
211
|
+
gas_value_eur=gas_value_eur,
|
|
212
|
+
tags=["safe-deployment"],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _store_safe_account(
|
|
216
|
+
self,
|
|
217
|
+
contract_address: str,
|
|
218
|
+
chain_name: str,
|
|
219
|
+
owner_addresses: List[str],
|
|
220
|
+
threshold: int,
|
|
221
|
+
tag: Optional[str],
|
|
222
|
+
) -> StoredSafeAccount:
|
|
223
|
+
# Check if already exists
|
|
224
|
+
accounts = self.key_storage.accounts
|
|
225
|
+
if contract_address in accounts and isinstance(
|
|
226
|
+
accounts[contract_address], StoredSafeAccount
|
|
227
|
+
):
|
|
228
|
+
safe_account = accounts[contract_address]
|
|
229
|
+
if chain_name not in safe_account.chains:
|
|
230
|
+
safe_account.chains.append(chain_name)
|
|
231
|
+
else:
|
|
232
|
+
safe_account = StoredSafeAccount(
|
|
233
|
+
tag=tag or f"Safe {contract_address[:6]}",
|
|
234
|
+
address=contract_address,
|
|
235
|
+
chains=[chain_name],
|
|
236
|
+
threshold=threshold,
|
|
237
|
+
signers=owner_addresses,
|
|
238
|
+
)
|
|
239
|
+
accounts[contract_address] = safe_account
|
|
240
|
+
|
|
241
|
+
self.key_storage.save()
|
|
242
|
+
return safe_account
|
|
243
|
+
|
|
244
|
+
def redeploy_safes(self):
|
|
245
|
+
"""Redeploy all safes to ensure they exist on all chains."""
|
|
246
|
+
for account in list(self.key_storage.accounts.values()):
|
|
247
|
+
if not isinstance(account, StoredSafeAccount):
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
for chain in account.chains:
|
|
251
|
+
rpc_secret = getattr(settings, f"{chain}_rpc")
|
|
252
|
+
ethereum_client = EthereumClient(rpc_secret.get_secret_value())
|
|
253
|
+
|
|
254
|
+
code = ethereum_client.w3.eth.get_code(account.address)
|
|
255
|
+
|
|
256
|
+
if code and code != b"":
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
self.key_storage.remove_account(account.address)
|
|
260
|
+
|
|
261
|
+
self.create_safe(
|
|
262
|
+
deployer_tag_or_address="master",
|
|
263
|
+
owner_tags_or_addresses=account.signers,
|
|
264
|
+
threshold=account.threshold,
|
|
265
|
+
chain_name=chain,
|
|
266
|
+
tag=account.tag,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _get_signer_keys(self, safe_account: StoredSafeAccount) -> List[str]:
|
|
270
|
+
"""Get signer private keys for a safe (INTERNAL USE ONLY).
|
|
271
|
+
|
|
272
|
+
This method is private and should never be called from outside SafeService.
|
|
273
|
+
Keys are used only within execute_safe_transaction and cleared immediately after.
|
|
274
|
+
"""
|
|
275
|
+
signer_pkeys = []
|
|
276
|
+
for signer_address in safe_account.signers:
|
|
277
|
+
pkey = self.key_storage._get_private_key(signer_address)
|
|
278
|
+
if pkey:
|
|
279
|
+
signer_pkeys.append(pkey)
|
|
280
|
+
|
|
281
|
+
if len(signer_pkeys) < safe_account.threshold:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"Not enough signer private keys in wallet to meet the Safe's threshold."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return signer_pkeys
|
|
287
|
+
|
|
288
|
+
def _sign_and_execute_safe_tx(self, safe_tx: SafeTx, signer_keys: List[str]) -> str:
|
|
289
|
+
"""Sign and execute a SafeTx internally (INTERNAL USE ONLY).
|
|
290
|
+
|
|
291
|
+
This method handles the signing and execution of a Safe transaction,
|
|
292
|
+
keeping private keys internal to SafeService.
|
|
293
|
+
|
|
294
|
+
SECURITY: Keys are overwritten with zeros and cleared after use.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
# Sign with all available signers
|
|
298
|
+
for pk in signer_keys:
|
|
299
|
+
safe_tx.sign(pk)
|
|
300
|
+
|
|
301
|
+
# Verify the transaction will succeed
|
|
302
|
+
safe_tx.call()
|
|
303
|
+
|
|
304
|
+
# Execute using the first signer
|
|
305
|
+
safe_tx.execute(signer_keys[0])
|
|
306
|
+
|
|
307
|
+
return safe_tx.tx_hash.hex()
|
|
308
|
+
finally:
|
|
309
|
+
# SECURITY: Overwrite keys with zeros before clearing (best effort)
|
|
310
|
+
for i in range(len(signer_keys)):
|
|
311
|
+
signer_keys[i] = "0" * len(signer_keys[i]) if signer_keys[i] else ""
|
|
312
|
+
signer_keys.clear()
|
|
313
|
+
|
|
314
|
+
def execute_safe_transaction(
|
|
315
|
+
self,
|
|
316
|
+
safe_address_or_tag: str,
|
|
317
|
+
to: str,
|
|
318
|
+
value: int,
|
|
319
|
+
chain_name: str,
|
|
320
|
+
data: str = "",
|
|
321
|
+
operation: int = SafeOperationEnum.CALL.value,
|
|
322
|
+
) -> str:
|
|
323
|
+
"""Execute a Safe transaction with internal signing.
|
|
324
|
+
|
|
325
|
+
This is the preferred method for executing Safe transactions as it
|
|
326
|
+
handles all signing internally without exposing private keys.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
safe_address_or_tag: The Safe account address or tag
|
|
330
|
+
to: Destination address
|
|
331
|
+
value: Amount in wei
|
|
332
|
+
chain_name: Chain name (e.g., 'gnosis')
|
|
333
|
+
data: Transaction data (hex string or empty)
|
|
334
|
+
operation: Safe operation type (CALL or DELEGATE_CALL)
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Transaction hash as hex string
|
|
338
|
+
|
|
339
|
+
"""
|
|
340
|
+
from iwa.plugins.gnosis.safe import SafeMultisig
|
|
341
|
+
|
|
342
|
+
safe_account = self.key_storage.find_stored_account(safe_address_or_tag)
|
|
343
|
+
if not safe_account or not isinstance(safe_account, StoredSafeAccount):
|
|
344
|
+
raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
|
|
345
|
+
|
|
346
|
+
safe = SafeMultisig(safe_account, chain_name)
|
|
347
|
+
safe_tx = safe.build_tx(
|
|
348
|
+
to=to,
|
|
349
|
+
value=value,
|
|
350
|
+
data=data,
|
|
351
|
+
operation=operation,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Get signer keys, execute, and immediately clear
|
|
355
|
+
signer_keys = self._get_signer_keys(safe_account)
|
|
356
|
+
try:
|
|
357
|
+
tx_hash = self._sign_and_execute_safe_tx(safe_tx, signer_keys)
|
|
358
|
+
logger.info(f"Safe transaction executed. Tx Hash: {tx_hash}")
|
|
359
|
+
return tx_hash
|
|
360
|
+
finally:
|
|
361
|
+
# Clear keys from memory (best effort)
|
|
362
|
+
for i in range(len(signer_keys)):
|
|
363
|
+
signer_keys[i] = None
|
|
364
|
+
del signer_keys
|
|
365
|
+
|
|
366
|
+
def get_sign_and_execute_callback(self, safe_address_or_tag: str):
|
|
367
|
+
"""Get a callback function that signs and executes a SafeTx.
|
|
368
|
+
|
|
369
|
+
This method returns a callback that can be passed to SafeMultisig.send_tx().
|
|
370
|
+
The callback handles all signing internally.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
safe_address_or_tag: The Safe account address or tag
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
A callable that takes a SafeTx and returns the transaction hash
|
|
377
|
+
|
|
378
|
+
"""
|
|
379
|
+
safe_account = self.key_storage.find_stored_account(safe_address_or_tag)
|
|
380
|
+
if not safe_account or not isinstance(safe_account, StoredSafeAccount):
|
|
381
|
+
raise ValueError(f"Safe account '{safe_address_or_tag}' not found.")
|
|
382
|
+
|
|
383
|
+
def _sign_and_execute(safe_tx: SafeTx) -> str:
|
|
384
|
+
signer_keys = self._get_signer_keys(safe_account)
|
|
385
|
+
try:
|
|
386
|
+
return self._sign_and_execute_safe_tx(safe_tx, signer_keys)
|
|
387
|
+
finally:
|
|
388
|
+
for i in range(len(signer_keys)):
|
|
389
|
+
signer_keys[i] = None
|
|
390
|
+
del signer_keys
|
|
391
|
+
|
|
392
|
+
return _sign_and_execute
|