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,297 @@
1
+ """Contract interaction helpers."""
2
+
3
+ import json
4
+ import warnings
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from eth_abi import decode
9
+ from web3 import Web3
10
+ from web3.contract import Contract
11
+ from web3.exceptions import ContractCustomError
12
+
13
+ from iwa.core.chain import ChainInterfaces
14
+ from iwa.core.utils import configure_logger
15
+
16
+ logger = configure_logger()
17
+
18
+ # Standard error selectors
19
+ ERROR_SELECTOR = "0x08c379a0" # Error(string)
20
+ PANIC_SELECTOR = "0x4e487b71" # Panic(uint256)
21
+
22
+ # Panic codes (from Solidity)
23
+ PANIC_CODES = {
24
+ 0x00: "Generic compiler inserted panic",
25
+ 0x01: "Assert failed",
26
+ 0x11: "Arithmetic overflow/underflow",
27
+ 0x12: "Division by zero",
28
+ 0x21: "Invalid enum value",
29
+ 0x22: "Storage byte array incorrectly encoded",
30
+ 0x31: "Pop on empty array",
31
+ 0x32: "Array index out of bounds",
32
+ 0x41: "Too much memory allocated",
33
+ 0x51: "Invalid internal function call",
34
+ }
35
+
36
+
37
+ class ContractInstance:
38
+ """Class to interact with smart contracts."""
39
+
40
+ name: str = None
41
+ abi_path: Path = None
42
+
43
+ def __init__(self, address: str, chain_name: str = "gnosis"):
44
+ """Initialize contract instance."""
45
+ self.address = address
46
+ self.abi = None
47
+ self.chain_interface = ChainInterfaces().get(chain_name)
48
+
49
+ with open(self.abi_path, "r", encoding="utf-8") as abi_file:
50
+ contract_abi = json.load(abi_file)
51
+
52
+ if isinstance(contract_abi, dict) and "abi" in contract_abi:
53
+ self.abi = contract_abi.get("abi")
54
+ else:
55
+ self.abi = contract_abi
56
+
57
+ self.contract: Contract = self.chain_interface.web3.eth.contract(
58
+ address=self.address, abi=self.abi
59
+ )
60
+ self.error_selectors = self.load_error_selectors()
61
+
62
+ def load_error_selectors(self) -> Dict[str, Any]:
63
+ """Load error selectors from the contract ABI."""
64
+ selectors = {}
65
+ for entry in self.abi:
66
+ if entry.get("type") == "error":
67
+ name = entry["name"]
68
+ inputs = entry.get("inputs", [])
69
+ types = ",".join(i["type"] for i in inputs)
70
+ signature = f"{name}({types})"
71
+ selector = Web3.keccak(text=signature)[:4].hex()
72
+ selectors[f"0x{selector}"] = (
73
+ name,
74
+ [i["type"] for i in inputs],
75
+ [i["name"] for i in inputs],
76
+ )
77
+ return selectors
78
+
79
+ def decode_error(self, error_data: str) -> Optional[Tuple[str, str]]:
80
+ """Decode error data from a failed transaction or call.
81
+
82
+ Handles:
83
+ - Custom errors defined in the contract ABI
84
+ - Standard Error(string) reverts
85
+ - Panic(uint256) errors
86
+
87
+ Args:
88
+ error_data: The hex-encoded error data (with or without 0x prefix).
89
+
90
+ Returns:
91
+ Tuple of (error_name, formatted_message) or None if decoding fails.
92
+
93
+ """
94
+ if not error_data:
95
+ return None
96
+
97
+ # Normalize data
98
+ if not error_data.startswith("0x"):
99
+ error_data = f"0x{error_data}"
100
+
101
+ if len(error_data) < 10:
102
+ return None
103
+
104
+ selector = error_data[:10]
105
+ encoded_args = error_data[10:]
106
+
107
+ # Check for custom errors from ABI
108
+ if selector in self.error_selectors:
109
+ error_name, types, names = self.error_selectors[selector]
110
+ try:
111
+ decoded = decode(types, bytes.fromhex(encoded_args))
112
+ error_str = ", ".join(
113
+ f"{name}={value}" for name, value in zip(names, decoded, strict=True)
114
+ )
115
+ return (error_name, f"{error_name}({error_str})")
116
+ except Exception as e:
117
+ logger.debug(f"Failed to decode custom error args: {e}")
118
+ return (error_name, f"{error_name}(decoding failed)")
119
+
120
+ # Check for standard Error(string)
121
+ if selector == ERROR_SELECTOR:
122
+ try:
123
+ decoded = decode(["string"], bytes.fromhex(encoded_args))
124
+ return ("Error", decoded[0])
125
+ except Exception as e:
126
+ logger.debug(f"Failed to decode Error(string): {e}")
127
+ return ("Error", "Failed to decode error message")
128
+
129
+ # Check for Panic(uint256)
130
+ if selector == PANIC_SELECTOR:
131
+ try:
132
+ decoded = decode(["uint256"], bytes.fromhex(encoded_args))
133
+ panic_code = decoded[0]
134
+ panic_msg = PANIC_CODES.get(panic_code, f"Unknown panic code: {panic_code}")
135
+ return ("Panic", panic_msg)
136
+ except Exception as e:
137
+ logger.debug(f"Failed to decode Panic(uint256): {e}")
138
+ return ("Panic", "Failed to decode panic code")
139
+
140
+ return None
141
+
142
+ def _extract_error_data(self, exception: Exception) -> Optional[str]:
143
+ """Extract error data from various exception formats.
144
+
145
+ Different RPC providers and web3 versions format errors differently.
146
+ This method tries to extract the error data from common formats.
147
+ """
148
+ # ContractCustomError has data directly
149
+ if isinstance(exception, ContractCustomError) and exception.args:
150
+ return exception.args[0] if isinstance(exception.args[0], str) else None
151
+
152
+ # Check exception args for hex data
153
+ args = getattr(exception, "args", ())
154
+ for arg in args:
155
+ if isinstance(arg, str) and arg.startswith("0x"):
156
+ return arg
157
+ if isinstance(arg, dict):
158
+ # Some providers return {"data": "0x..."}
159
+ data = arg.get("data")
160
+ if isinstance(data, str) and data.startswith("0x"):
161
+ return data
162
+
163
+ # Check for 'data' attribute
164
+ data = getattr(exception, "data", None)
165
+ if isinstance(data, str) and data.startswith("0x"):
166
+ return data
167
+
168
+ return None
169
+
170
+ def call(self, method_name: str, *args) -> Any:
171
+ """Call a function in the contract without sending a transaction.
172
+
173
+ Args:
174
+ method_name: The name of the contract function to call.
175
+ *args: Arguments to pass to the function.
176
+
177
+ Returns:
178
+ The return value of the contract function.
179
+
180
+ Raises:
181
+ Exception: If the call fails, with decoded error information.
182
+
183
+ """
184
+ method = getattr(self.contract.functions, method_name)
185
+ try:
186
+ return method(*args).call()
187
+ except Exception as e:
188
+ error_data = self._extract_error_data(e)
189
+ if error_data:
190
+ decoded = self.decode_error(error_data)
191
+ if decoded:
192
+ error_name, error_msg = decoded
193
+ logger.error(
194
+ f"Contract call '{method_name}' on {self.name}[{self.address}] "
195
+ f"failed: {error_name}: {error_msg}"
196
+ )
197
+ raise
198
+
199
+ def _sanitize_for_web3(self, value: Any) -> Any:
200
+ """Convert EthereumAddress subclass to pure str for eth_abi encoding.
201
+
202
+ eth_abi encoder cannot handle custom str subclasses; this ensures
203
+ all address strings are pure str instances.
204
+ """
205
+ if isinstance(value, str) and type(value) is not str:
206
+ # It's a str subclass (like EthereumAddress), convert to pure str
207
+ return str.__str__(value)
208
+ if isinstance(value, dict):
209
+ return {k: self._sanitize_for_web3(v) for k, v in value.items()}
210
+ if isinstance(value, (list, tuple)):
211
+ return type(value)(self._sanitize_for_web3(v) for v in value)
212
+ return value
213
+
214
+ def prepare_transaction(
215
+ self, method_name: str, method_kwargs: Dict, tx_params: Dict
216
+ ) -> Optional[dict]:
217
+ """Prepare a transaction.
218
+
219
+ Args:
220
+ method_name: The name of the contract function to call.
221
+ method_kwargs: Dictionary of keyword arguments for the function.
222
+ tx_params: Transaction parameters (from, gas, etc.).
223
+
224
+ Returns:
225
+ The prepared transaction dict, or None if preparation failed.
226
+
227
+ """
228
+ # Sanitize kwargs and params to convert EthereumAddress to pure str
229
+ method_kwargs = self._sanitize_for_web3(method_kwargs)
230
+ tx_params = self._sanitize_for_web3(tx_params)
231
+
232
+ method = getattr(self.contract.functions, method_name)
233
+ built_method = method(*method_kwargs.values())
234
+
235
+ try:
236
+ tx_params = self.chain_interface.calculate_transaction_params(built_method, tx_params)
237
+ transaction = built_method.build_transaction(tx_params)
238
+ return transaction
239
+
240
+ except Exception as e:
241
+ error_data = self._extract_error_data(e)
242
+ if error_data:
243
+ decoded = self.decode_error(error_data)
244
+ if decoded:
245
+ error_name, error_msg = decoded
246
+ logger.error(
247
+ f"Failed to prepare '{method_name}' on {self.name}[{self.address}]: "
248
+ f"{error_name}: {error_msg}"
249
+ )
250
+ return None
251
+
252
+ # Fallback: log the raw exception
253
+ logger.error(f"Failed to prepare '{method_name}': {e}")
254
+ return None
255
+
256
+ def extract_events(self, receipt) -> List[Dict]:
257
+ """Extract events from a transaction receipt.
258
+
259
+ Args:
260
+ receipt: The transaction receipt.
261
+
262
+ Returns:
263
+ List of event dictionaries with 'name' and 'args' keys.
264
+
265
+ """
266
+ all_events = []
267
+
268
+ if not receipt:
269
+ return all_events
270
+
271
+ for event_abi in self.contract.abi:
272
+ # Skip non events
273
+ if event_abi.get("type") != "event":
274
+ continue
275
+
276
+ event_name = event_abi.get("name", "Unknown")
277
+ try:
278
+ event = self.contract.events[event_name]
279
+ except KeyError:
280
+ continue
281
+
282
+ with warnings.catch_warnings():
283
+ warnings.simplefilter("ignore")
284
+ try:
285
+ decoded_logs = event().process_receipt(receipt)
286
+
287
+ if not decoded_logs:
288
+ continue
289
+
290
+ for log in decoded_logs:
291
+ all_events.append({"name": log["event"], "args": dict(log.args)})
292
+ except Exception as e:
293
+ # Log at debug level to avoid noise, but capture the issue
294
+ logger.debug(f"Failed to decode event '{event_name}' from {self.name}: {e}")
295
+ continue
296
+
297
+ return all_events
@@ -0,0 +1,79 @@
1
+ """ERC20 contract interaction."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+ from iwa.core.constants import ABI_PATH
6
+ from iwa.core.contracts.contract import ContractInstance
7
+ from iwa.core.types import EthereumAddress
8
+
9
+
10
+ class ERC20Contract(ContractInstance):
11
+ """Class to interact with ERC20 contracts."""
12
+
13
+ name = "erc20"
14
+ abi_path = ABI_PATH / "erc20.json"
15
+
16
+ def __init__(self, address: EthereumAddress, chain_name: str = "gnosis"):
17
+ """Initialize ERC20 contract instance."""
18
+ super().__init__(address, chain_name)
19
+
20
+ self.decimals = self.call("decimals")
21
+ self.symbol = self.call("symbol")
22
+ self.name = self.call("name")
23
+ self.total_supply = self.call("totalSupply")
24
+
25
+ def allowance_wei(self, owner: EthereumAddress, spender: EthereumAddress) -> int:
26
+ """Allowance"""
27
+ return self.call("allowance", owner, spender)
28
+
29
+ def allowance_eth(self, owner: EthereumAddress, spender: EthereumAddress) -> float:
30
+ """Allowance in human readable format"""
31
+ return self.allowance_wei(owner, spender) / (10**self.decimals)
32
+
33
+ def balance_of_wei(self, account: EthereumAddress) -> int:
34
+ """Balance of"""
35
+ return self.call("balanceOf", account)
36
+
37
+ def balance_of_eth(self, account: EthereumAddress) -> float:
38
+ """Balance of in human readable format"""
39
+ return self.balance_of_wei(account) / (10**self.decimals)
40
+
41
+ def prepare_transfer_tx(
42
+ self,
43
+ from_address: EthereumAddress,
44
+ to: EthereumAddress,
45
+ amount_wei: int,
46
+ ) -> Optional[Dict]:
47
+ """Transfer."""
48
+ return self.prepare_transaction(
49
+ method_name="transfer",
50
+ method_kwargs={"to": to, "amount": amount_wei},
51
+ tx_params={"from": from_address},
52
+ )
53
+
54
+ def prepare_transfer_from_tx(
55
+ self,
56
+ from_address: EthereumAddress,
57
+ sender: EthereumAddress,
58
+ recipient: EthereumAddress,
59
+ amount_wei: int,
60
+ ) -> Optional[Dict]:
61
+ """Transfer from."""
62
+ return self.prepare_transaction(
63
+ method_name="transferFrom",
64
+ method_kwargs={"_sender": sender, "_recipient": recipient, "_amount": amount_wei},
65
+ tx_params={"from": from_address},
66
+ )
67
+
68
+ def prepare_approve_tx(
69
+ self,
70
+ from_address: EthereumAddress,
71
+ spender: EthereumAddress,
72
+ amount_wei: int,
73
+ ) -> Optional[Dict]:
74
+ """Approve."""
75
+ return self.prepare_transaction(
76
+ method_name="approve",
77
+ method_kwargs={"spender": spender, "amount": amount_wei},
78
+ tx_params={"from": from_address},
79
+ )
@@ -0,0 +1,71 @@
1
+ """Multisend contract interaction."""
2
+
3
+ from typing import Dict, List, Optional, cast
4
+
5
+ from hexbytes import HexBytes
6
+ from safe_eth.safe import SafeOperationEnum
7
+
8
+ from iwa.core.constants import ABI_PATH
9
+ from iwa.core.contracts.contract import ContractInstance
10
+ from iwa.core.types import EthereumAddress
11
+
12
+ # MultiSend addresses (same across Ethereum, Base, Gnosis via Singleton Factory)
13
+ MULTISEND_CALL_ONLY_ADDRESS = EthereumAddress("0x40A2aCCbd92BCA938b02010E17A5b8929b49130D")
14
+ MULTISEND_ADDRESS = EthereumAddress("0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761")
15
+
16
+
17
+ class MultiSendCallOnlyContract(ContractInstance):
18
+ """Class to interact with multisend (call only) contract."""
19
+
20
+ name = "multisend_call_only"
21
+ abi_path = ABI_PATH / "multisend_call_only.json"
22
+
23
+ @staticmethod
24
+ def encode_data(tx: Dict) -> bytes:
25
+ """Encodes multisend transaction."""
26
+ # Operation 1 byte
27
+ operation = HexBytes("{:0>2x}".format(cast(SafeOperationEnum, tx.get("operation")).value))
28
+
29
+ # Address 20 bytes
30
+ to = HexBytes("{:0>40x}".format(int(cast(str, tx.get("to")), 16)))
31
+
32
+ # Value 32 bytes
33
+ value = HexBytes("{:0>64x}".format(cast(int, tx.get("value", 0))))
34
+
35
+ # Data length 32 bytes
36
+ data = cast(bytes, tx.get("data", b""))
37
+ data_ = HexBytes(data)
38
+ data_length = HexBytes("{:0>64x}".format(len(data_)))
39
+
40
+ return operation + to + value + data_length + data_
41
+
42
+ @staticmethod
43
+ def to_bytes(multi_send_txs: List[Dict]) -> bytes:
44
+ """Multi send tx list to bytes."""
45
+ return b"".join([MultiSendCallOnlyContract.encode_data(tx) for tx in multi_send_txs])
46
+
47
+ def prepare_tx(
48
+ self,
49
+ from_address: EthereumAddress,
50
+ transactions: list,
51
+ ) -> Optional[Dict]:
52
+ """Prepare multisend transaction."""
53
+ encoded_multisend_data = MultiSendCallOnlyContract.to_bytes(transactions)
54
+
55
+ total_value_wei = sum([tx.get("value", 0) for tx in transactions])
56
+
57
+ return self.prepare_transaction(
58
+ method_name="multiSend",
59
+ method_kwargs={"encoded_multisend_data": encoded_multisend_data},
60
+ tx_params={
61
+ "from": from_address,
62
+ "value": total_value_wei,
63
+ },
64
+ )
65
+
66
+
67
+ class MultiSendContract(MultiSendCallOnlyContract):
68
+ """Class to interact with multisend contract."""
69
+
70
+ name = "multisend"
71
+ abi_path = ABI_PATH / "multisend.json"