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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
@@ -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