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,247 @@
|
|
|
1
|
+
"""ERC20 transfer mixin."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from web3.types import Wei
|
|
7
|
+
|
|
8
|
+
from iwa.core.chain import ChainInterfaces
|
|
9
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
10
|
+
from iwa.core.db import log_transaction
|
|
11
|
+
from iwa.core.models import StoredSafeAccount
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from iwa.core.services.transfer import TransferService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ERC20TransferMixin:
|
|
18
|
+
"""Mixin for ERC20 token transfers and approvals."""
|
|
19
|
+
|
|
20
|
+
def _send_erc20_via_safe(
|
|
21
|
+
self: "TransferService",
|
|
22
|
+
from_account: StoredSafeAccount,
|
|
23
|
+
from_address_or_tag: str,
|
|
24
|
+
to_address: str,
|
|
25
|
+
amount_wei: Wei,
|
|
26
|
+
chain_name: str,
|
|
27
|
+
erc20: ERC20Contract,
|
|
28
|
+
transaction: dict,
|
|
29
|
+
from_tag: Optional[str],
|
|
30
|
+
to_tag: Optional[str],
|
|
31
|
+
token_symbol: str,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Send ERC20 token via Safe multisig."""
|
|
34
|
+
tx_hash = self.safe_service.execute_safe_transaction(
|
|
35
|
+
safe_address_or_tag=from_address_or_tag,
|
|
36
|
+
to=erc20.address,
|
|
37
|
+
value=0,
|
|
38
|
+
chain_name=chain_name,
|
|
39
|
+
data=transaction["data"],
|
|
40
|
+
)
|
|
41
|
+
# Get receipt for gas calculation
|
|
42
|
+
receipt = None
|
|
43
|
+
try:
|
|
44
|
+
interface = ChainInterfaces().get(chain_name)
|
|
45
|
+
receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.warning(f"Could not get receipt for Safe tx {tx_hash}: {e}")
|
|
48
|
+
|
|
49
|
+
gas_cost, gas_value_eur = self._calculate_gas_info(receipt, chain_name)
|
|
50
|
+
# Get price and value
|
|
51
|
+
p_eur, v_eur = self._get_token_price_info(token_symbol, amount_wei, chain_name)
|
|
52
|
+
log_transaction(
|
|
53
|
+
tx_hash=tx_hash,
|
|
54
|
+
from_addr=from_account.address,
|
|
55
|
+
to_addr=to_address,
|
|
56
|
+
token=token_symbol,
|
|
57
|
+
amount_wei=amount_wei,
|
|
58
|
+
chain=chain_name,
|
|
59
|
+
from_tag=from_tag,
|
|
60
|
+
to_tag=to_tag,
|
|
61
|
+
gas_cost=gas_cost,
|
|
62
|
+
gas_value_eur=gas_value_eur,
|
|
63
|
+
price_eur=p_eur,
|
|
64
|
+
value_eur=v_eur,
|
|
65
|
+
tags=["erc20-transfer", "safe-transaction"],
|
|
66
|
+
)
|
|
67
|
+
return tx_hash
|
|
68
|
+
|
|
69
|
+
def _send_erc20_via_eoa(
|
|
70
|
+
self: "TransferService",
|
|
71
|
+
from_account,
|
|
72
|
+
from_address_or_tag: str,
|
|
73
|
+
to_address: str,
|
|
74
|
+
amount_wei: Wei,
|
|
75
|
+
chain_name: str,
|
|
76
|
+
transaction: dict,
|
|
77
|
+
from_tag: Optional[str],
|
|
78
|
+
to_tag: Optional[str],
|
|
79
|
+
token_symbol: str,
|
|
80
|
+
) -> Optional[str]:
|
|
81
|
+
"""Send ERC20 token via EOA (externally owned account)."""
|
|
82
|
+
success, receipt = self.transaction_service.sign_and_send(
|
|
83
|
+
transaction, from_address_or_tag, chain_name
|
|
84
|
+
)
|
|
85
|
+
if success and receipt:
|
|
86
|
+
tx_hash = receipt["transactionHash"].hex()
|
|
87
|
+
gas_cost, gas_value_eur = self._calculate_gas_info(receipt, chain_name)
|
|
88
|
+
p_eur, v_eur = self._get_token_price_info(token_symbol, amount_wei, chain_name)
|
|
89
|
+
log_transaction(
|
|
90
|
+
tx_hash=tx_hash,
|
|
91
|
+
from_addr=from_account.address,
|
|
92
|
+
to_addr=to_address,
|
|
93
|
+
token=token_symbol,
|
|
94
|
+
amount_wei=amount_wei,
|
|
95
|
+
chain=chain_name,
|
|
96
|
+
from_tag=from_tag,
|
|
97
|
+
to_tag=to_tag,
|
|
98
|
+
gas_cost=gas_cost,
|
|
99
|
+
gas_value_eur=gas_value_eur,
|
|
100
|
+
price_eur=p_eur,
|
|
101
|
+
value_eur=v_eur,
|
|
102
|
+
tags=["erc20-transfer"],
|
|
103
|
+
)
|
|
104
|
+
return tx_hash
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def get_erc20_allowance(
|
|
108
|
+
self: "TransferService",
|
|
109
|
+
owner_address_or_tag: str,
|
|
110
|
+
spender_address: str,
|
|
111
|
+
token_address_or_name: str,
|
|
112
|
+
chain_name: str = "gnosis",
|
|
113
|
+
) -> Optional[float]:
|
|
114
|
+
"""Get ERC20 token allowance."""
|
|
115
|
+
chain = ChainInterfaces().get(chain_name)
|
|
116
|
+
|
|
117
|
+
token_address = self.account_service.get_token_address(token_address_or_name, chain.chain)
|
|
118
|
+
if not token_address:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
owner_account = self.account_service.resolve_account(owner_address_or_tag)
|
|
122
|
+
if not owner_account:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
contract = ERC20Contract(chain_name=chain_name, address=token_address)
|
|
126
|
+
return contract.allowance_wei(owner_account.address, spender_address)
|
|
127
|
+
|
|
128
|
+
def approve_erc20(
|
|
129
|
+
self: "TransferService",
|
|
130
|
+
owner_address_or_tag: str,
|
|
131
|
+
spender_address_or_tag: str,
|
|
132
|
+
token_address_or_name: str,
|
|
133
|
+
amount_wei: Wei,
|
|
134
|
+
chain_name: str = "gnosis",
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""Approve ERC20 token allowance."""
|
|
137
|
+
owner_account = self.account_service.resolve_account(owner_address_or_tag)
|
|
138
|
+
spender_account = self.account_service.resolve_account(spender_address_or_tag)
|
|
139
|
+
spender_address = spender_account.address if spender_account else spender_address_or_tag
|
|
140
|
+
|
|
141
|
+
if not owner_account:
|
|
142
|
+
logger.error(f"Owner account '{owner_address_or_tag}' not found in wallet.")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
146
|
+
|
|
147
|
+
token_address = self.account_service.get_token_address(
|
|
148
|
+
token_address_or_name, chain_interface.chain
|
|
149
|
+
)
|
|
150
|
+
if not token_address:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
erc20 = ERC20Contract(token_address, chain_name)
|
|
154
|
+
|
|
155
|
+
allowance_wei = self.get_erc20_allowance(
|
|
156
|
+
owner_address_or_tag,
|
|
157
|
+
spender_address,
|
|
158
|
+
token_address_or_name,
|
|
159
|
+
chain_name,
|
|
160
|
+
)
|
|
161
|
+
if allowance_wei is not None and allowance_wei >= amount_wei:
|
|
162
|
+
logger.info("Current allowance is sufficient. No need to approve.")
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
transaction = erc20.prepare_approve_tx(
|
|
166
|
+
from_address=owner_account.address,
|
|
167
|
+
spender=spender_address,
|
|
168
|
+
amount_wei=amount_wei,
|
|
169
|
+
)
|
|
170
|
+
if not transaction:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
is_safe = getattr(owner_account, "threshold", None) is not None
|
|
174
|
+
amount_eth = float(chain_interface.web3.from_wei(amount_wei, "ether"))
|
|
175
|
+
|
|
176
|
+
logger.info(
|
|
177
|
+
f"Approving {spender_address} to spend {amount_eth:.4f} {token_address_or_name} from {owner_address_or_tag}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if is_safe:
|
|
181
|
+
tx_limit = self.safe_service.execute_safe_transaction(
|
|
182
|
+
safe_address_or_tag=owner_address_or_tag,
|
|
183
|
+
to=erc20.address,
|
|
184
|
+
value=0,
|
|
185
|
+
chain_name=chain_name,
|
|
186
|
+
data=transaction["data"],
|
|
187
|
+
)
|
|
188
|
+
return bool(tx_limit)
|
|
189
|
+
else:
|
|
190
|
+
success, _ = self.transaction_service.sign_and_send(
|
|
191
|
+
transaction, owner_address_or_tag, chain_name
|
|
192
|
+
)
|
|
193
|
+
return success
|
|
194
|
+
|
|
195
|
+
def transfer_from_erc20(
|
|
196
|
+
self: "TransferService",
|
|
197
|
+
from_address_or_tag: str,
|
|
198
|
+
sender_address_or_tag: str,
|
|
199
|
+
recipient_address_or_tag: str,
|
|
200
|
+
token_address_or_name: str,
|
|
201
|
+
amount_wei: Wei,
|
|
202
|
+
chain_name: str = "gnosis",
|
|
203
|
+
):
|
|
204
|
+
"""TransferFrom ERC20 tokens."""
|
|
205
|
+
from_account = self.account_service.resolve_account(from_address_or_tag)
|
|
206
|
+
sender_account = self.account_service.resolve_account(sender_address_or_tag)
|
|
207
|
+
recipient_account = self.account_service.resolve_account(recipient_address_or_tag)
|
|
208
|
+
recipient_address = (
|
|
209
|
+
recipient_account.address if recipient_account else recipient_address_or_tag
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if not sender_account:
|
|
213
|
+
logger.error(f"Sender account '{sender_address_or_tag}' not found in wallet.")
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
217
|
+
|
|
218
|
+
token_address = self.account_service.get_token_address(
|
|
219
|
+
token_address_or_name, chain_interface.chain
|
|
220
|
+
)
|
|
221
|
+
if not token_address:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
erc20 = ERC20Contract(token_address, chain_name)
|
|
225
|
+
transaction = erc20.prepare_transfer_from_tx(
|
|
226
|
+
from_address=from_account.address,
|
|
227
|
+
sender=sender_account.address,
|
|
228
|
+
recipient=recipient_address,
|
|
229
|
+
amount_wei=amount_wei,
|
|
230
|
+
)
|
|
231
|
+
if not transaction:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
is_safe = getattr(from_account, "threshold", None) is not None
|
|
235
|
+
|
|
236
|
+
logger.info("Transferring ERC20 tokens via TransferFrom")
|
|
237
|
+
|
|
238
|
+
if is_safe:
|
|
239
|
+
self.safe_service.execute_safe_transaction(
|
|
240
|
+
safe_address_or_tag=from_address_or_tag,
|
|
241
|
+
to=erc20.address,
|
|
242
|
+
value=0,
|
|
243
|
+
chain_name=chain_name,
|
|
244
|
+
data=transaction["data"],
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
self.transaction_service.sign_and_send(transaction, from_address_or_tag, chain_name)
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Multisend and drain mixin."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from safe_eth.safe import SafeOperationEnum
|
|
7
|
+
|
|
8
|
+
from iwa.core.chain import ChainInterfaces
|
|
9
|
+
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
|
|
10
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
11
|
+
from iwa.core.contracts.multisend import (
|
|
12
|
+
MULTISEND_ADDRESS,
|
|
13
|
+
MULTISEND_CALL_ONLY_ADDRESS,
|
|
14
|
+
MultiSendCallOnlyContract,
|
|
15
|
+
MultiSendContract,
|
|
16
|
+
)
|
|
17
|
+
from iwa.core.models import Config, StoredSafeAccount
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from iwa.core.services.transfer import TransferService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MultiSendMixin:
|
|
24
|
+
"""Mixin for multisend and drain operations."""
|
|
25
|
+
|
|
26
|
+
def multi_send(
|
|
27
|
+
self: "TransferService",
|
|
28
|
+
from_address_or_tag: str,
|
|
29
|
+
transactions: list,
|
|
30
|
+
chain_name: str = "gnosis",
|
|
31
|
+
):
|
|
32
|
+
"""Send multiple transactions in a single multisend transaction."""
|
|
33
|
+
from_account = self.account_service.resolve_account(from_address_or_tag)
|
|
34
|
+
if not from_account:
|
|
35
|
+
logger.error(f"From account '{from_address_or_tag}' not found in wallet.")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
is_safe = isinstance(from_account, StoredSafeAccount)
|
|
39
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
40
|
+
|
|
41
|
+
if not is_safe:
|
|
42
|
+
self._handle_erc20_approvals(from_address_or_tag, transactions, chain_interface)
|
|
43
|
+
|
|
44
|
+
valid_transactions = []
|
|
45
|
+
for tx in transactions:
|
|
46
|
+
prepared_tx = self._prepare_multisend_transaction(
|
|
47
|
+
tx, from_account, chain_interface, is_safe
|
|
48
|
+
)
|
|
49
|
+
if prepared_tx:
|
|
50
|
+
valid_transactions.append(prepared_tx)
|
|
51
|
+
|
|
52
|
+
if not valid_transactions:
|
|
53
|
+
logger.error("No valid transactions to send")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
return self._execute_multisend(
|
|
57
|
+
from_account, from_address_or_tag, valid_transactions, chain_interface, is_safe
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _handle_erc20_approvals(
|
|
61
|
+
self: "TransferService",
|
|
62
|
+
from_address_or_tag: str,
|
|
63
|
+
transactions: list,
|
|
64
|
+
chain_interface,
|
|
65
|
+
):
|
|
66
|
+
"""Check allowances and approve ERC20s if needed (for EOAs)."""
|
|
67
|
+
from_account = self.account_service.resolve_account(from_address_or_tag)
|
|
68
|
+
|
|
69
|
+
if getattr(from_account, "threshold", None) is not None:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
is_all_native = all(
|
|
73
|
+
tx.get("token", NATIVE_CURRENCY_ADDRESS) == NATIVE_CURRENCY_ADDRESS
|
|
74
|
+
for tx in transactions
|
|
75
|
+
)
|
|
76
|
+
if is_all_native:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
erc20_totals = {}
|
|
80
|
+
for tx in transactions:
|
|
81
|
+
token_addr_or_tag = tx.get("token", NATIVE_CURRENCY_ADDRESS)
|
|
82
|
+
if token_addr_or_tag == NATIVE_CURRENCY_ADDRESS:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
token_address = self.account_service.get_token_address(
|
|
86
|
+
token_addr_or_tag, chain_interface.chain
|
|
87
|
+
)
|
|
88
|
+
# Support both amount_wei (preferred) and amount (legacy)
|
|
89
|
+
if "amount_wei" in tx:
|
|
90
|
+
amount_wei = tx["amount_wei"]
|
|
91
|
+
elif "amount" in tx:
|
|
92
|
+
erc20_temp = ERC20Contract(token_address, chain_interface.chain.name)
|
|
93
|
+
amount_wei = int(tx["amount"] * (10**erc20_temp.decimals))
|
|
94
|
+
else:
|
|
95
|
+
continue
|
|
96
|
+
erc20_totals[token_address] = erc20_totals.get(token_address, 0) + amount_wei
|
|
97
|
+
|
|
98
|
+
for token_addr, total_amount in erc20_totals.items():
|
|
99
|
+
self.approve_erc20(
|
|
100
|
+
owner_address_or_tag=from_address_or_tag,
|
|
101
|
+
spender_address_or_tag=MULTISEND_CALL_ONLY_ADDRESS,
|
|
102
|
+
token_address_or_name=token_addr,
|
|
103
|
+
amount_wei=total_amount,
|
|
104
|
+
chain_name=chain_interface.chain.name,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _prepare_multisend_transaction(
|
|
108
|
+
self: "TransferService",
|
|
109
|
+
tx: dict,
|
|
110
|
+
from_account,
|
|
111
|
+
chain_interface,
|
|
112
|
+
is_safe: bool,
|
|
113
|
+
) -> Optional[dict]:
|
|
114
|
+
"""Prepare a single transaction for multisend."""
|
|
115
|
+
tx_copy = dict(tx)
|
|
116
|
+
to = self.account_service.resolve_account(tx_copy["to"])
|
|
117
|
+
recipient_address = to.address if to else tx_copy["to"]
|
|
118
|
+
# Ensure recipient address is checksummed for Web3 compatibility
|
|
119
|
+
recipient_address = chain_interface.web3.to_checksum_address(recipient_address)
|
|
120
|
+
token_address_or_tag = tx_copy.get("token", NATIVE_CURRENCY_ADDRESS)
|
|
121
|
+
chain_name = chain_interface.chain.name
|
|
122
|
+
|
|
123
|
+
# Prefer amount_wei if provided (no precision loss), else convert from amount
|
|
124
|
+
if "amount_wei" in tx_copy:
|
|
125
|
+
amount_wei = tx_copy["amount_wei"]
|
|
126
|
+
elif "amount" in tx_copy:
|
|
127
|
+
# Calculate amount_wei respecting the token's decimals
|
|
128
|
+
if token_address_or_tag == NATIVE_CURRENCY_ADDRESS:
|
|
129
|
+
amount_wei = chain_interface.web3.to_wei(tx_copy["amount"], "ether")
|
|
130
|
+
else:
|
|
131
|
+
token_address = self.account_service.get_token_address(
|
|
132
|
+
token_address_or_tag, chain_interface.chain
|
|
133
|
+
)
|
|
134
|
+
erc20_temp = ERC20Contract(token_address, chain_name)
|
|
135
|
+
# Use the token's actual decimals
|
|
136
|
+
amount_wei = int(tx_copy["amount"] * (10**erc20_temp.decimals))
|
|
137
|
+
else:
|
|
138
|
+
logger.error(f"Transaction missing amount or amount_wei: {tx_copy}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# Clean up transaction dict
|
|
142
|
+
tx_copy.pop("amount", None)
|
|
143
|
+
tx_copy.pop("amount_wei", None)
|
|
144
|
+
tx_copy.pop("token", None)
|
|
145
|
+
|
|
146
|
+
if token_address_or_tag == NATIVE_CURRENCY_ADDRESS:
|
|
147
|
+
tx_copy["to"] = recipient_address
|
|
148
|
+
tx_copy["value"] = amount_wei
|
|
149
|
+
tx_copy["data"] = b""
|
|
150
|
+
tx_copy["operation"] = SafeOperationEnum.CALL
|
|
151
|
+
else:
|
|
152
|
+
# Create ERC20 contract instance for the transfer
|
|
153
|
+
token_address = self.account_service.get_token_address(
|
|
154
|
+
token_address_or_tag, chain_interface.chain
|
|
155
|
+
)
|
|
156
|
+
erc20 = ERC20Contract(token_address, chain_name)
|
|
157
|
+
|
|
158
|
+
if is_safe:
|
|
159
|
+
# Safe uses transfer() because it DelegateCalls the MultiSend (sender identity preserved)
|
|
160
|
+
transfer_tx = erc20.prepare_transfer_tx(
|
|
161
|
+
from_address=from_account.address,
|
|
162
|
+
to=recipient_address,
|
|
163
|
+
amount_wei=amount_wei,
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
# EOA uses transferFrom() because MultiSendCallOnly matches the calls (sender is MultiSend contract)
|
|
167
|
+
transfer_tx = erc20.prepare_transfer_from_tx(
|
|
168
|
+
from_address=from_account.address,
|
|
169
|
+
sender=from_account.address,
|
|
170
|
+
recipient=recipient_address,
|
|
171
|
+
amount_wei=amount_wei,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if not transfer_tx:
|
|
175
|
+
logger.error(f"Failed to prepare transfer transaction for {token_address_or_tag}")
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
tx_copy["to"] = erc20.address
|
|
179
|
+
tx_copy["value"] = 0
|
|
180
|
+
tx_copy["data"] = transfer_tx["data"]
|
|
181
|
+
tx_copy["operation"] = SafeOperationEnum.CALL
|
|
182
|
+
|
|
183
|
+
return tx_copy
|
|
184
|
+
|
|
185
|
+
def _execute_multisend(
|
|
186
|
+
self: "TransferService",
|
|
187
|
+
from_account,
|
|
188
|
+
from_address_or_tag: str,
|
|
189
|
+
valid_transactions: list,
|
|
190
|
+
chain_interface,
|
|
191
|
+
is_safe: bool,
|
|
192
|
+
):
|
|
193
|
+
"""Build and execute the multisend transaction."""
|
|
194
|
+
chain_name = chain_interface.chain.name
|
|
195
|
+
multi_send_normal_contract = MultiSendContract(
|
|
196
|
+
address=MULTISEND_ADDRESS, chain_name=chain_name
|
|
197
|
+
)
|
|
198
|
+
multi_send_call_only_contract = MultiSendCallOnlyContract(
|
|
199
|
+
address=MULTISEND_CALL_ONLY_ADDRESS, chain_name=chain_name
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
multi_send_contract = (
|
|
203
|
+
multi_send_normal_contract if is_safe else multi_send_call_only_contract
|
|
204
|
+
)
|
|
205
|
+
transaction = multi_send_contract.prepare_tx(
|
|
206
|
+
from_address=from_account.address, transactions=valid_transactions
|
|
207
|
+
)
|
|
208
|
+
if not transaction:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
logger.info("Sending multisend transaction")
|
|
212
|
+
|
|
213
|
+
if is_safe:
|
|
214
|
+
return self.safe_service.execute_safe_transaction(
|
|
215
|
+
safe_address_or_tag=from_address_or_tag,
|
|
216
|
+
to=multi_send_contract.address,
|
|
217
|
+
value=transaction["value"],
|
|
218
|
+
chain_name=chain_name,
|
|
219
|
+
data=transaction["data"],
|
|
220
|
+
operation=SafeOperationEnum.DELEGATE_CALL.value,
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
return self.transaction_service.sign_and_send(
|
|
224
|
+
transaction, from_address_or_tag, chain_name
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def drain(
|
|
228
|
+
self: "TransferService",
|
|
229
|
+
from_address_or_tag: str,
|
|
230
|
+
to_address_or_tag: str = "master",
|
|
231
|
+
chain_name: str = "gnosis",
|
|
232
|
+
):
|
|
233
|
+
"""Drain entire balance of an account to another account.
|
|
234
|
+
|
|
235
|
+
For Safes that are Olas service multisigs, this will first claim any
|
|
236
|
+
pending staking rewards before draining.
|
|
237
|
+
|
|
238
|
+
Uses multi_send to batch all transfers (ERC20 + native) into a single
|
|
239
|
+
transaction for gas efficiency.
|
|
240
|
+
"""
|
|
241
|
+
from_account = self.account_service.resolve_account(from_address_or_tag)
|
|
242
|
+
|
|
243
|
+
if not from_account:
|
|
244
|
+
logger.error(f"From account '{from_address_or_tag}' not found in wallet.")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
to_account = self.account_service.resolve_account(to_address_or_tag)
|
|
248
|
+
to_address = to_account.address if to_account else to_address_or_tag
|
|
249
|
+
|
|
250
|
+
is_safe = getattr(from_account, "threshold", None) is not None
|
|
251
|
+
chain_interface = ChainInterfaces().get(chain_name)
|
|
252
|
+
|
|
253
|
+
# If this is a Safe, check if it's an Olas service multisig and claim rewards
|
|
254
|
+
if is_safe:
|
|
255
|
+
self._claim_olas_rewards_if_service(from_account.address, chain_name)
|
|
256
|
+
|
|
257
|
+
transactions = []
|
|
258
|
+
|
|
259
|
+
# Collect ERC-20 token transfers
|
|
260
|
+
for token_name in chain_interface.chain.tokens.keys():
|
|
261
|
+
balance_wei = self.balance_service.get_erc20_balance_wei(
|
|
262
|
+
from_address_or_tag, token_name, chain_name
|
|
263
|
+
)
|
|
264
|
+
if balance_wei and balance_wei > 0:
|
|
265
|
+
# Use amount_wei directly for zero precision loss
|
|
266
|
+
transactions.append(
|
|
267
|
+
{
|
|
268
|
+
"to": to_address,
|
|
269
|
+
"amount_wei": balance_wei,
|
|
270
|
+
"token": token_name,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
logger.info(f"Queued {balance_wei} wei of {token_name} for drain.")
|
|
274
|
+
else:
|
|
275
|
+
logger.debug(f"No {token_name} to drain on {from_address_or_tag}.")
|
|
276
|
+
|
|
277
|
+
# Calculate drainable native balance
|
|
278
|
+
native_balance_wei = self.balance_service.get_native_balance_wei(from_account.address)
|
|
279
|
+
if native_balance_wei and native_balance_wei > 0:
|
|
280
|
+
if is_safe:
|
|
281
|
+
# Safe pays gas from the Safe, so we can drain all
|
|
282
|
+
drainable_balance_wei = native_balance_wei
|
|
283
|
+
else:
|
|
284
|
+
# EOA needs to reserve gas for the multi_send transaction
|
|
285
|
+
# Conservative estimate: base 100k + ~50k per transfer + 20% buffer
|
|
286
|
+
num_transfers = len(transactions) + 1 # +1 for native
|
|
287
|
+
estimated_gas = 100_000 + (50_000 * num_transfers)
|
|
288
|
+
gas_price = chain_interface.web3.eth.gas_price
|
|
289
|
+
gas_cost_wei = int(gas_price * estimated_gas * 1.2) # 20% buffer
|
|
290
|
+
drainable_balance_wei = native_balance_wei - gas_cost_wei
|
|
291
|
+
logger.debug(
|
|
292
|
+
f"EOA drain: balance={native_balance_wei}, gas_reserve={gas_cost_wei}, "
|
|
293
|
+
f"drainable={drainable_balance_wei}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if drainable_balance_wei > 0:
|
|
297
|
+
# Use amount_wei directly for zero precision loss
|
|
298
|
+
transactions.append(
|
|
299
|
+
{
|
|
300
|
+
"to": to_address,
|
|
301
|
+
"amount_wei": drainable_balance_wei,
|
|
302
|
+
# No "token" key = native currency
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
logger.info(f"Queued {drainable_balance_wei} wei native for drain.")
|
|
306
|
+
else:
|
|
307
|
+
logger.info(
|
|
308
|
+
f"Not enough native balance to cover gas fees for draining from {from_address_or_tag}."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if not transactions:
|
|
312
|
+
logger.info(f"Nothing to drain from {from_address_or_tag}.")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
logger.info(
|
|
316
|
+
f"Draining {len(transactions)} assets from {from_address_or_tag} to {to_address_or_tag}..."
|
|
317
|
+
)
|
|
318
|
+
return self.multi_send(
|
|
319
|
+
from_address_or_tag=from_address_or_tag,
|
|
320
|
+
transactions=transactions,
|
|
321
|
+
chain_name=chain_name,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _claim_olas_rewards_if_service(self, safe_address: str, chain_name: str) -> bool:
|
|
325
|
+
"""Check if Safe is an Olas service multisig and claim pending rewards.
|
|
326
|
+
|
|
327
|
+
This is a best-effort operation - if the Olas plugin is not available or
|
|
328
|
+
there's an error, it will log a warning and continue without failing.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
safe_address: The Safe address to check.
|
|
332
|
+
chain_name: The chain name.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
True if rewards were claimed, False otherwise.
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
# Import Olas plugin (optional dependency)
|
|
340
|
+
from iwa.plugins.olas.models import OlasConfig
|
|
341
|
+
|
|
342
|
+
# Check if this Safe is an Olas service multisig
|
|
343
|
+
config = Config()
|
|
344
|
+
if "olas" not in config.plugins:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
olas_config: OlasConfig = config.plugins["olas"]
|
|
348
|
+
service = olas_config.get_service_by_multisig(safe_address)
|
|
349
|
+
|
|
350
|
+
if not service:
|
|
351
|
+
logger.debug(f"Safe {safe_address} is not an Olas service multisig.")
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
if not service.staking_contract_address:
|
|
355
|
+
logger.debug(f"Olas service {service.key} is not staked.")
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
logger.info(
|
|
359
|
+
f"Safe {safe_address} is Olas service {service.key}. "
|
|
360
|
+
"Checking for pending staking rewards..."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Use ServiceManager to claim rewards
|
|
364
|
+
# Need to import Wallet dynamically to avoid circular import
|
|
365
|
+
from iwa.core.wallet import Wallet
|
|
366
|
+
from iwa.plugins.olas.service_manager import ServiceManager
|
|
367
|
+
|
|
368
|
+
wallet = Wallet()
|
|
369
|
+
service_manager = ServiceManager(wallet=wallet, service_key=service.key)
|
|
370
|
+
success, claimed_amount = service_manager.claim_rewards()
|
|
371
|
+
|
|
372
|
+
if success and claimed_amount > 0:
|
|
373
|
+
claimed_olas = claimed_amount / 1e18
|
|
374
|
+
logger.info(f"Claimed {claimed_olas:.4f} OLAS rewards before drain.")
|
|
375
|
+
return True
|
|
376
|
+
elif not success:
|
|
377
|
+
logger.debug("No rewards to claim or claim failed.")
|
|
378
|
+
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
except ImportError:
|
|
382
|
+
logger.debug("Olas plugin not available, skipping reward claiming.")
|
|
383
|
+
return False
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.warning(f"Failed to check/claim Olas rewards: {e}")
|
|
386
|
+
return False
|