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,38 @@
|
|
|
1
|
+
"""ChainInterfaces manager singleton."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
from iwa.core.chain.interface import ChainInterface
|
|
6
|
+
from iwa.core.chain.models import Base, Ethereum, Gnosis
|
|
7
|
+
from iwa.core.utils import singleton
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@singleton
|
|
11
|
+
class ChainInterfaces:
|
|
12
|
+
"""ChainInterfaces"""
|
|
13
|
+
|
|
14
|
+
gnosis: ChainInterface = ChainInterface(Gnosis())
|
|
15
|
+
ethereum: ChainInterface = ChainInterface(Ethereum())
|
|
16
|
+
base: ChainInterface = ChainInterface(Base())
|
|
17
|
+
|
|
18
|
+
def get(self, chain_name: str) -> ChainInterface:
|
|
19
|
+
"""Get ChainInterface by chain name"""
|
|
20
|
+
chain_name = chain_name.strip().lower()
|
|
21
|
+
|
|
22
|
+
if not hasattr(self, chain_name):
|
|
23
|
+
raise ValueError(f"Unsupported chain: {chain_name}")
|
|
24
|
+
|
|
25
|
+
return getattr(self, chain_name)
|
|
26
|
+
|
|
27
|
+
def items(self):
|
|
28
|
+
"""Iterate over all chain interfaces."""
|
|
29
|
+
yield "gnosis", self.gnosis
|
|
30
|
+
yield "ethereum", self.ethereum
|
|
31
|
+
yield "base", self.base
|
|
32
|
+
|
|
33
|
+
def check_all_rpcs(self) -> Dict[str, bool]:
|
|
34
|
+
"""Check health of all chain RPCs."""
|
|
35
|
+
results = {}
|
|
36
|
+
for name, interface in self.items():
|
|
37
|
+
results[name] = interface.check_rpc_health()
|
|
38
|
+
return results
|
iwa/core/chain/models.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Chain model definitions."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from iwa.core.models import EthereumAddress
|
|
8
|
+
from iwa.core.settings import settings
|
|
9
|
+
from iwa.core.utils import singleton
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SupportedChain(BaseModel):
|
|
13
|
+
"""SupportedChain"""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
rpcs: List[str]
|
|
17
|
+
chain_id: int
|
|
18
|
+
native_currency: str
|
|
19
|
+
tokens: Dict[str, EthereumAddress] = {}
|
|
20
|
+
contracts: Dict[str, EthereumAddress] = {}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def rpc(self) -> str:
|
|
24
|
+
"""Get the primary RPC URL."""
|
|
25
|
+
return self.rpcs[0] if self.rpcs else ""
|
|
26
|
+
|
|
27
|
+
def get_token_address(self, token_address_or_name: str) -> Optional[EthereumAddress]:
|
|
28
|
+
"""Get token address"""
|
|
29
|
+
try:
|
|
30
|
+
address = EthereumAddress(token_address_or_name)
|
|
31
|
+
except Exception:
|
|
32
|
+
address = None
|
|
33
|
+
|
|
34
|
+
if address and address in self.tokens.values():
|
|
35
|
+
return address
|
|
36
|
+
|
|
37
|
+
if address is None:
|
|
38
|
+
return self.tokens.get(token_address_or_name, None)
|
|
39
|
+
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_token_name(self, token_address: str) -> Optional[str]:
|
|
43
|
+
"""Get token name from address."""
|
|
44
|
+
addr_lower = token_address.lower()
|
|
45
|
+
for name, addr in self.tokens.items():
|
|
46
|
+
if addr.lower() == addr_lower:
|
|
47
|
+
return name
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@singleton
|
|
52
|
+
class Gnosis(SupportedChain):
|
|
53
|
+
"""Gnosis Chain"""
|
|
54
|
+
|
|
55
|
+
name: str = "Gnosis"
|
|
56
|
+
rpcs: List[str] = [] # Set dynamically in __init__
|
|
57
|
+
chain_id: int = 100
|
|
58
|
+
native_currency: str = "xDAI"
|
|
59
|
+
tokens: Dict[str, EthereumAddress] = {
|
|
60
|
+
"OLAS": EthereumAddress("0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f"),
|
|
61
|
+
"WXDAI": EthereumAddress("0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"),
|
|
62
|
+
"USDC": EthereumAddress("0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0"),
|
|
63
|
+
"SDAI": EthereumAddress("0xaf204776c7245bF4147c2612BF6e5972Ee483701"),
|
|
64
|
+
"EURE": EthereumAddress("0xcB444e90D8198415266c6a2724b7900fb12FC56E"),
|
|
65
|
+
}
|
|
66
|
+
contracts: Dict[str, EthereumAddress] = {
|
|
67
|
+
"GNOSIS_SAFE_MULTISIG_IMPLEMENTATION": EthereumAddress(
|
|
68
|
+
"0x3C1fF68f5aa342D296d4DEe4Bb1cACCA912D95fE"
|
|
69
|
+
),
|
|
70
|
+
"GNOSIS_SAFE_FALLBACK_HANDLER": EthereumAddress(
|
|
71
|
+
"0xf48f2b2d2a534e402487b3ee7c18c33aec0fe5e4"
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def __init__(self, **data):
|
|
76
|
+
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
77
|
+
super().__init__(**data)
|
|
78
|
+
if not self.rpcs and settings.gnosis_rpc:
|
|
79
|
+
self.rpcs = settings.gnosis_rpc.get_secret_value().split(",")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@singleton
|
|
83
|
+
class Ethereum(SupportedChain):
|
|
84
|
+
"""Ethereum Mainnet"""
|
|
85
|
+
|
|
86
|
+
name: str = "Ethereum"
|
|
87
|
+
rpcs: List[str] = [] # Set dynamically in __init__
|
|
88
|
+
chain_id: int = 1
|
|
89
|
+
native_currency: str = "ETH"
|
|
90
|
+
tokens: Dict[str, EthereumAddress] = {
|
|
91
|
+
"OLAS": EthereumAddress("0x0001A500A6B18995B03f44bb040A5fFc28E45CB0"),
|
|
92
|
+
}
|
|
93
|
+
contracts: Dict[str, EthereumAddress] = {}
|
|
94
|
+
|
|
95
|
+
def __init__(self, **data):
|
|
96
|
+
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
97
|
+
super().__init__(**data)
|
|
98
|
+
if not self.rpcs and settings.ethereum_rpc:
|
|
99
|
+
self.rpcs = settings.ethereum_rpc.get_secret_value().split(",")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@singleton
|
|
103
|
+
class Base(SupportedChain):
|
|
104
|
+
"""Base"""
|
|
105
|
+
|
|
106
|
+
name: str = "Base"
|
|
107
|
+
rpcs: List[str] = [] # Set dynamically in __init__
|
|
108
|
+
chain_id: int = 8453
|
|
109
|
+
native_currency: str = "ETH"
|
|
110
|
+
tokens: Dict[str, EthereumAddress] = {
|
|
111
|
+
"OLAS": EthereumAddress("0x54330d28ca3357F294334BDC454a032e7f353416"),
|
|
112
|
+
}
|
|
113
|
+
contracts: Dict[str, EthereumAddress] = {}
|
|
114
|
+
|
|
115
|
+
def __init__(self, **data):
|
|
116
|
+
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
117
|
+
super().__init__(**data)
|
|
118
|
+
if not self.rpcs and settings.base_rpc:
|
|
119
|
+
self.rpcs = settings.base_rpc.get_secret_value().split(",")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@singleton
|
|
123
|
+
class SupportedChains:
|
|
124
|
+
"""SupportedChains"""
|
|
125
|
+
|
|
126
|
+
gnosis: SupportedChain = Gnosis()
|
|
127
|
+
ethereum: SupportedChain = Ethereum()
|
|
128
|
+
base: SupportedChain = Base()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""RPC rate limiting classes for chain interactions."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Dict
|
|
6
|
+
|
|
7
|
+
from iwa.core.utils import configure_logger
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from iwa.core.chain.interface import ChainInterface
|
|
11
|
+
|
|
12
|
+
logger = configure_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RPCRateLimiter:
|
|
16
|
+
"""Token bucket rate limiter for RPC calls.
|
|
17
|
+
|
|
18
|
+
Uses a token bucket algorithm that allows bursts while maintaining
|
|
19
|
+
a maximum average rate over time.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEFAULT_RATE = 25.0
|
|
23
|
+
DEFAULT_BURST = 50
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
rate: float = DEFAULT_RATE,
|
|
28
|
+
burst: int = DEFAULT_BURST,
|
|
29
|
+
):
|
|
30
|
+
"""Initialize rate limiter.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
rate: Maximum requests per second (refill rate)
|
|
34
|
+
burst: Maximum tokens (bucket size)
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
self.rate = rate
|
|
38
|
+
self.burst = burst
|
|
39
|
+
self.tokens = float(burst)
|
|
40
|
+
self.last_update = time.monotonic()
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._backoff_until = 0.0
|
|
43
|
+
|
|
44
|
+
def acquire(self, timeout: float = 30.0) -> bool:
|
|
45
|
+
"""Acquire a token, blocking if necessary."""
|
|
46
|
+
deadline = time.monotonic() + timeout
|
|
47
|
+
|
|
48
|
+
while True:
|
|
49
|
+
with self._lock:
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
|
|
52
|
+
if now < self._backoff_until:
|
|
53
|
+
wait_time = self._backoff_until - now
|
|
54
|
+
if now + wait_time > deadline:
|
|
55
|
+
return False
|
|
56
|
+
else:
|
|
57
|
+
elapsed = now - self.last_update
|
|
58
|
+
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
|
59
|
+
self.last_update = now
|
|
60
|
+
|
|
61
|
+
if self.tokens >= 1.0:
|
|
62
|
+
self.tokens -= 1.0
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
wait_time = (1.0 - self.tokens) / self.rate
|
|
66
|
+
if now + wait_time > deadline:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
time.sleep(min(wait_time, 0.1))
|
|
70
|
+
|
|
71
|
+
def trigger_backoff(self, seconds: float = 5.0):
|
|
72
|
+
"""Trigger rate limit backoff."""
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._backoff_until = time.monotonic() + seconds
|
|
75
|
+
self.tokens = 0
|
|
76
|
+
logger.warning(f"RPC rate limit triggered, backing off for {seconds}s")
|
|
77
|
+
|
|
78
|
+
def get_status(self) -> dict:
|
|
79
|
+
"""Get current rate limiter status."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
now = time.monotonic()
|
|
82
|
+
in_backoff = now < self._backoff_until
|
|
83
|
+
return {
|
|
84
|
+
"tokens": self.tokens,
|
|
85
|
+
"rate": self.rate,
|
|
86
|
+
"burst": self.burst,
|
|
87
|
+
"in_backoff": in_backoff,
|
|
88
|
+
"backoff_remaining": max(0, self._backoff_until - now) if in_backoff else 0,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Global rate limiters per chain
|
|
93
|
+
_rate_limiters: Dict[str, RPCRateLimiter] = {}
|
|
94
|
+
_rate_limiters_lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) -> RPCRateLimiter:
|
|
98
|
+
"""Get or create a rate limiter for a chain."""
|
|
99
|
+
with _rate_limiters_lock:
|
|
100
|
+
if chain_name not in _rate_limiters:
|
|
101
|
+
_rate_limiters[chain_name] = RPCRateLimiter(
|
|
102
|
+
rate=rate or RPCRateLimiter.DEFAULT_RATE,
|
|
103
|
+
burst=burst or RPCRateLimiter.DEFAULT_BURST,
|
|
104
|
+
)
|
|
105
|
+
return _rate_limiters[chain_name]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RateLimitedEth:
|
|
109
|
+
"""Wrapper around web3.eth that applies rate limiting transparently."""
|
|
110
|
+
|
|
111
|
+
RPC_METHODS = {
|
|
112
|
+
"get_balance",
|
|
113
|
+
"get_code",
|
|
114
|
+
"get_transaction_count",
|
|
115
|
+
"estimate_gas",
|
|
116
|
+
"send_raw_transaction",
|
|
117
|
+
"wait_for_transaction_receipt",
|
|
118
|
+
"get_block",
|
|
119
|
+
"get_transaction",
|
|
120
|
+
"get_transaction_receipt",
|
|
121
|
+
"call",
|
|
122
|
+
"get_logs",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
|
|
126
|
+
"""Initialize RateLimitedEth wrapper."""
|
|
127
|
+
object.__setattr__(self, "_eth", web3_eth)
|
|
128
|
+
object.__setattr__(self, "_rate_limiter", rate_limiter)
|
|
129
|
+
object.__setattr__(self, "_chain_interface", chain_interface)
|
|
130
|
+
|
|
131
|
+
def __getattr__(self, name):
|
|
132
|
+
"""Get attribute from underlying eth, wrapping RPC methods with rate limiting."""
|
|
133
|
+
attr = getattr(self._eth, name)
|
|
134
|
+
|
|
135
|
+
if name in self.RPC_METHODS and callable(attr):
|
|
136
|
+
return self._wrap_with_rate_limit(attr, name)
|
|
137
|
+
|
|
138
|
+
return attr
|
|
139
|
+
|
|
140
|
+
def __setattr__(self, name, value):
|
|
141
|
+
"""Set attribute on underlying eth for test mocking."""
|
|
142
|
+
if name.startswith("_"):
|
|
143
|
+
object.__setattr__(self, name, value)
|
|
144
|
+
else:
|
|
145
|
+
setattr(self._eth, name, value)
|
|
146
|
+
|
|
147
|
+
def __delattr__(self, name):
|
|
148
|
+
"""Delete attribute from underlying eth for patch.object cleanup."""
|
|
149
|
+
if name.startswith("_"):
|
|
150
|
+
object.__delattr__(self, name)
|
|
151
|
+
else:
|
|
152
|
+
delattr(self._eth, name)
|
|
153
|
+
|
|
154
|
+
def _wrap_with_rate_limit(self, method, method_name):
|
|
155
|
+
"""Wrap a method with rate limiting and error handling."""
|
|
156
|
+
|
|
157
|
+
def wrapper(*args, **kwargs):
|
|
158
|
+
if not self._rate_limiter.acquire(timeout=30.0):
|
|
159
|
+
raise TimeoutError(f"Rate limit timeout waiting for {method_name}")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
return method(*args, **kwargs)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self._chain_interface._handle_rpc_error(e)
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
return wrapper
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RateLimitedWeb3:
|
|
171
|
+
"""Wrapper around Web3 instance that applies rate limiting transparently."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self, web3_instance, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"
|
|
175
|
+
):
|
|
176
|
+
"""Initialize RateLimitedWeb3 wrapper."""
|
|
177
|
+
self._web3 = web3_instance
|
|
178
|
+
self._rate_limiter = rate_limiter
|
|
179
|
+
self._chain_interface = chain_interface
|
|
180
|
+
self._eth_wrapper = None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def eth(self):
|
|
184
|
+
"""Return rate-limited eth interface."""
|
|
185
|
+
if self._eth_wrapper is None:
|
|
186
|
+
self._eth_wrapper = RateLimitedEth(
|
|
187
|
+
self._web3.eth, self._rate_limiter, self._chain_interface
|
|
188
|
+
)
|
|
189
|
+
return self._eth_wrapper
|
|
190
|
+
|
|
191
|
+
def __getattr__(self, name):
|
|
192
|
+
"""Delegate attribute access to underlying Web3 instance."""
|
|
193
|
+
return getattr(self._web3, name)
|
iwa/core/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""CLI"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from web3 import Web3
|
|
7
|
+
|
|
8
|
+
from iwa.core.chain import ChainInterfaces
|
|
9
|
+
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
|
|
10
|
+
from iwa.core.keys import KeyStorage
|
|
11
|
+
from iwa.core.services import PluginService
|
|
12
|
+
from iwa.core.tables import list_accounts
|
|
13
|
+
from iwa.core.wallet import Wallet
|
|
14
|
+
from iwa.tui.app import IwaApp
|
|
15
|
+
|
|
16
|
+
iwa_cli = typer.Typer(help="iwa command line interface")
|
|
17
|
+
wallet_cli = typer.Typer(help="Manage wallet")
|
|
18
|
+
|
|
19
|
+
iwa_cli.add_typer(wallet_cli, name="wallet")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@wallet_cli.command("create")
|
|
23
|
+
def account_create(
|
|
24
|
+
tag: Optional[str] = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--tag",
|
|
27
|
+
"-t",
|
|
28
|
+
help="Tag for this account",
|
|
29
|
+
),
|
|
30
|
+
):
|
|
31
|
+
"""Create a new wallet account"""
|
|
32
|
+
key_storage = KeyStorage()
|
|
33
|
+
try:
|
|
34
|
+
key_storage.create_account(tag)
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
typer.echo(f"Error: {e}")
|
|
37
|
+
raise typer.Exit(code=1) from e
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@wallet_cli.command("list")
|
|
41
|
+
def account_list(
|
|
42
|
+
chain_name: Optional[str] = typer.Option(
|
|
43
|
+
"gnosis",
|
|
44
|
+
"--chain",
|
|
45
|
+
"-c",
|
|
46
|
+
help="Chain to retrieve balances from.",
|
|
47
|
+
),
|
|
48
|
+
balances: Optional[str] = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
"--balances",
|
|
51
|
+
"-b",
|
|
52
|
+
help="Comma-separated list of token names to fetch balances for. Use 'native' for native currency.",
|
|
53
|
+
),
|
|
54
|
+
):
|
|
55
|
+
"""List wallet accounts"""
|
|
56
|
+
wallet = Wallet()
|
|
57
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
58
|
+
token_names_list = balances.split(",") if balances else []
|
|
59
|
+
|
|
60
|
+
accounts_data, token_balances = wallet.get_accounts_balances(chain_name, token_names_list)
|
|
61
|
+
|
|
62
|
+
list_accounts(
|
|
63
|
+
accounts_data,
|
|
64
|
+
chain_interface,
|
|
65
|
+
token_names_list,
|
|
66
|
+
token_balances,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@wallet_cli.command("send")
|
|
71
|
+
def account_send(
|
|
72
|
+
from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
|
|
73
|
+
to_address_or_tag: str = typer.Option(..., "--to", "-t", help="To address or tag"),
|
|
74
|
+
token_address_or_name: str = typer.Option(
|
|
75
|
+
NATIVE_CURRENCY_ADDRESS,
|
|
76
|
+
"--token",
|
|
77
|
+
"-k",
|
|
78
|
+
help="ERC20 token contract address, ignore for native",
|
|
79
|
+
),
|
|
80
|
+
amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to send, in ether"),
|
|
81
|
+
chain: str = typer.Option(
|
|
82
|
+
"gnosis",
|
|
83
|
+
"--chain",
|
|
84
|
+
help="Chain to send from",
|
|
85
|
+
),
|
|
86
|
+
):
|
|
87
|
+
"""Send native currency or ERC20 tokens to an address"""
|
|
88
|
+
wallet = Wallet()
|
|
89
|
+
wallet.send(
|
|
90
|
+
from_address_or_tag=from_address_or_tag,
|
|
91
|
+
to_address_or_tag=to_address_or_tag,
|
|
92
|
+
token_address_or_name=token_address_or_name,
|
|
93
|
+
amount_wei=Web3.to_wei(amount_eth, "ether"),
|
|
94
|
+
chain_name=chain,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@wallet_cli.command("transfer-from")
|
|
99
|
+
def erc20_transfer_from(
|
|
100
|
+
from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
|
|
101
|
+
sender_address_or_tag: str = typer.Option(..., "--sender", "-s", help="Sender address or tag"),
|
|
102
|
+
recipient_address_or_tag: str = typer.Option(
|
|
103
|
+
..., "--recipient", "-r", help="Recipient address or tag"
|
|
104
|
+
),
|
|
105
|
+
token_address_or_name: str = typer.Option(
|
|
106
|
+
..., "--token", "-k", help="ERC20 token contract address"
|
|
107
|
+
),
|
|
108
|
+
amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to transfer, in ether"),
|
|
109
|
+
chain: str = typer.Option(
|
|
110
|
+
"gnosis",
|
|
111
|
+
"--chain",
|
|
112
|
+
help="Chain to send from",
|
|
113
|
+
),
|
|
114
|
+
):
|
|
115
|
+
"""Transfer ERC20 tokens from a sender to a recipient using allowance"""
|
|
116
|
+
wallet = Wallet()
|
|
117
|
+
wallet.transfer_from_erc20(
|
|
118
|
+
from_address_or_tag=from_address_or_tag,
|
|
119
|
+
sender_address_or_tag=sender_address_or_tag,
|
|
120
|
+
recipient_address_or_tag=recipient_address_or_tag,
|
|
121
|
+
token_address_or_name=token_address_or_name,
|
|
122
|
+
amount_wei=Web3.to_wei(amount_eth, "ether"),
|
|
123
|
+
chain_name=chain,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@wallet_cli.command("approve")
|
|
128
|
+
def erc20_approve(
|
|
129
|
+
owner_address_or_tag: str = typer.Option(..., "--owner", "-f", help="Owner address or tag"),
|
|
130
|
+
spender_address_or_tag: str = typer.Option(
|
|
131
|
+
..., "--spender", "-t", help="Spender address or tag"
|
|
132
|
+
),
|
|
133
|
+
token_address_or_name: str = typer.Option(
|
|
134
|
+
..., "--token", "-k", help="ERC20 token contract address"
|
|
135
|
+
),
|
|
136
|
+
amount_eth: float = typer.Option(..., "--amount", "-a", help="Amount to approve, in ether"),
|
|
137
|
+
chain: str = typer.Option(
|
|
138
|
+
"gnosis",
|
|
139
|
+
"--chain",
|
|
140
|
+
help="Chain to send from",
|
|
141
|
+
),
|
|
142
|
+
):
|
|
143
|
+
"""Approve ERC20 token allowance for a spender"""
|
|
144
|
+
wallet = Wallet()
|
|
145
|
+
wallet.approve_erc20(
|
|
146
|
+
owner_address_or_tag=owner_address_or_tag,
|
|
147
|
+
spender_address_or_tag=spender_address_or_tag,
|
|
148
|
+
token_address_or_name=token_address_or_name,
|
|
149
|
+
amount_wei=Web3.to_wei(amount_eth, "ether"),
|
|
150
|
+
chain_name=chain,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@iwa_cli.command("tui")
|
|
155
|
+
def tui():
|
|
156
|
+
"""Start Terminal User Interface."""
|
|
157
|
+
app = IwaApp()
|
|
158
|
+
app.run()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@iwa_cli.command("web")
|
|
162
|
+
def web_server(
|
|
163
|
+
port: Optional[int] = typer.Option(None, "--port", "-p", help="Port to listen on"),
|
|
164
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to listen on"),
|
|
165
|
+
):
|
|
166
|
+
"""Start Web Interface."""
|
|
167
|
+
from iwa.core.settings import settings
|
|
168
|
+
from iwa.web.server import run_server
|
|
169
|
+
|
|
170
|
+
server_port = port or settings.web_port
|
|
171
|
+
typer.echo(f"Starting web server on http://{host}:{server_port}")
|
|
172
|
+
run_server(host=host, port=server_port)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@wallet_cli.command("drain")
|
|
176
|
+
def drain_wallet(
|
|
177
|
+
from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
|
|
178
|
+
to_address_or_tag: str = typer.Option(..., "--to", "-t", help="To address or tag"),
|
|
179
|
+
chain_name: str = typer.Option(
|
|
180
|
+
"gnosis",
|
|
181
|
+
"--chain",
|
|
182
|
+
"-c",
|
|
183
|
+
help="Chain to drain from.",
|
|
184
|
+
),
|
|
185
|
+
):
|
|
186
|
+
"""Drain all tokens and native currency from one wallet to another"""
|
|
187
|
+
wallet = Wallet()
|
|
188
|
+
wallet.drain(
|
|
189
|
+
from_address_or_tag=from_address_or_tag,
|
|
190
|
+
to_address_or_tag=to_address_or_tag,
|
|
191
|
+
chain_name=chain_name,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Load Plugins
|
|
196
|
+
# Removed direct import here, moved to top
|
|
197
|
+
|
|
198
|
+
plugin_service = PluginService()
|
|
199
|
+
plugins = plugin_service.get_all_plugins()
|
|
200
|
+
|
|
201
|
+
for plugin_name, plugin in plugins.items():
|
|
202
|
+
commands = plugin.get_cli_commands()
|
|
203
|
+
if commands:
|
|
204
|
+
plugin_app = typer.Typer(help=f"{plugin_name} commands")
|
|
205
|
+
for cmd_name, cmd_func in commands.items():
|
|
206
|
+
plugin_app.command(name=cmd_name)(cmd_func)
|
|
207
|
+
iwa_cli.add_typer(plugin_app, name=plugin_name)
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__": # pragma: no cover
|
|
210
|
+
iwa_cli()
|
iwa/core/constants.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Core constants"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from iwa.core.types import EthereumAddress
|
|
6
|
+
|
|
7
|
+
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
|
8
|
+
|
|
9
|
+
# Data directory for sensitive/runtime files
|
|
10
|
+
DATA_DIR = PROJECT_ROOT / "data"
|
|
11
|
+
|
|
12
|
+
SECRETS_PATH = DATA_DIR / "secrets.env"
|
|
13
|
+
CONFIG_PATH = DATA_DIR / "config.yaml"
|
|
14
|
+
WALLET_PATH = DATA_DIR / "wallet.json"
|
|
15
|
+
BACKUP_DIR = DATA_DIR / "backup"
|
|
16
|
+
TENDERLY_CONFIG_PATH = PROJECT_ROOT / "tenderly.yaml"
|
|
17
|
+
|
|
18
|
+
ABI_PATH = PROJECT_ROOT / "src" / "iwa" / "core" / "contracts" / "abis"
|
|
19
|
+
|
|
20
|
+
# Standard Ethereum addresses
|
|
21
|
+
ZERO_ADDRESS = EthereumAddress("0x0000000000000000000000000000000000000000")
|
|
22
|
+
NATIVE_CURRENCY_ADDRESS = EthereumAddress("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE")
|
|
23
|
+
DEFAULT_MECH_CONTRACT_ADDRESS = EthereumAddress("0x77af31De935740567Cf4FF1986D04B2c964A786a")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_tenderly_config_path(profile: int = 1) -> Path:
|
|
27
|
+
"""Get the path to a profile-specific Tenderly config file."""
|
|
28
|
+
return PROJECT_ROOT / f"tenderly_{profile}.yaml"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""iwa.core.contracts package."""
|