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,403 @@
1
+ """CoW Swap execution logic."""
2
+
3
+ import time
4
+ import warnings
5
+ from typing import TYPE_CHECKING
6
+
7
+ import requests
8
+ from eth_account import Account
9
+ from eth_account.signers.local import LocalAccount
10
+ from eth_typing.evm import ChecksumAddress
11
+ from web3 import Web3
12
+ from web3.types import Wei
13
+
14
+ from iwa.core.chain import SupportedChain
15
+ from iwa.core.utils import configure_logger
16
+ from iwa.plugins.gnosis.cow.quotes import get_max_buy_amount_wei, get_max_sell_amount_wei
17
+ from iwa.plugins.gnosis.cow.types import (
18
+ COW_API_URLS,
19
+ COW_EXPLORER_URL,
20
+ HTTP_OK,
21
+ OrderType,
22
+ )
23
+ from iwa.plugins.gnosis.cow_utils import get_cowpy_module
24
+
25
+ warnings.filterwarnings("ignore", message="Pydantic serializer warnings:")
26
+ warnings.filterwarnings(
27
+ "ignore", message="This AsyncLimiter instance is being re-used across loops.*"
28
+ )
29
+
30
+ logger = configure_logger()
31
+
32
+ if TYPE_CHECKING:
33
+ from cowdao_cowpy.common.chains import Chain
34
+ from cowdao_cowpy.cow.swap import CompletedOrder
35
+ from cowdao_cowpy.order_book.config import Envs
36
+
37
+ # Placeholders for cowdao_cowpy functions/classes to allow patching in tests
38
+ swap_tokens = None
39
+ get_order_quote = None
40
+ OrderQuoteRequest = None
41
+ OrderQuoteSide1 = None
42
+ OrderQuoteSide3 = None
43
+ OrderQuoteSideKindBuy = None
44
+ OrderQuoteSideKindSell = None
45
+ TokenAmount = None
46
+ SupportedChainId = None
47
+ OrderBookApi = None
48
+ OrderBookAPIConfigFactory = None
49
+ Order = None
50
+ PreSignSignature = None
51
+ SigningScheme = None
52
+ sign_order = None
53
+ post_order = None
54
+ CompletedOrder = None
55
+
56
+
57
+ class CowSwap:
58
+ """Simple CoW Swap integration using CoW Protocol's public API.
59
+
60
+ Handles token swaps on Gnosis Chain (and others) using CoW Protocol.
61
+ Uses lazy loading for `cowdao-cowpy` dependencies to improve startup time
62
+ and avoid asyncio conflicts during import.
63
+ """
64
+
65
+ env: str = "prod"
66
+
67
+ def __init__(self, private_key_or_signer: str | LocalAccount, chain: SupportedChain):
68
+ """Initialize CowSwap."""
69
+ if isinstance(private_key_or_signer, str):
70
+ self.account = Account.from_key(private_key_or_signer)
71
+ else:
72
+ self.account = private_key_or_signer
73
+ self.chain = chain
74
+ supported_chain_id_cls = get_cowpy_module("SupportedChainId")
75
+ self.supported_chain_id = supported_chain_id_cls(chain.chain_id)
76
+ self.cow_chain = self.get_chain()
77
+ self.cowswap_api_url = COW_API_URLS.get(chain.chain_id)
78
+ order_book_api_cls = get_cowpy_module("OrderBookApi")
79
+ order_book_api_config_factory_cls = get_cowpy_module("OrderBookAPIConfigFactory")
80
+ self.order_book_api = order_book_api_cls(
81
+ order_book_api_config_factory_cls.get_config(self.env, chain.chain_id)
82
+ )
83
+
84
+ def get_chain(self) -> "Chain":
85
+ """Get the Chain enum based on the supported chain ID."""
86
+ chain_cls = get_cowpy_module("Chain")
87
+ for chain in chain_cls:
88
+ if chain.value[0] == self.supported_chain_id:
89
+ return chain
90
+ raise ValueError(f"Unsupported SupportedChainId: {self.supported_chain_id}")
91
+
92
+ @staticmethod
93
+ async def check_cowswap_order(order: "CompletedOrder") -> dict | None:
94
+ """Check if a CowSwap order has been executed by polling the Explorer API."""
95
+ import asyncio
96
+
97
+ logger.info(f"Checking order status for UID: {order.uid}")
98
+
99
+ max_retries = 6
100
+ sleep_between_retries = 25
101
+ retries = 0
102
+
103
+ while retries < max_retries:
104
+ retries += 1
105
+
106
+ try:
107
+ # Use a thread executor for blocking requests.get
108
+ loop = asyncio.get_event_loop()
109
+ response = await loop.run_in_executor(
110
+ None, lambda: requests.get(order.url, timeout=60)
111
+ )
112
+ except Exception as e:
113
+ logger.warning(f"Error checking order status: {e}")
114
+ await asyncio.sleep(sleep_between_retries)
115
+ continue
116
+
117
+ if response.status_code != HTTP_OK:
118
+ logger.debug(
119
+ f"Order status check {retries}/{max_retries}: HTTP {response.status_code}. Retry in {sleep_between_retries}s"
120
+ )
121
+ await asyncio.sleep(sleep_between_retries)
122
+ continue
123
+
124
+ order_data = response.json()
125
+
126
+ status = order_data.get("status", "unknown")
127
+ valid_to = int(order_data.get("validTo", 0))
128
+ current_time = int(time.time())
129
+
130
+ if status == "expired" or (valid_to > 0 and current_time > valid_to):
131
+ logger.error(
132
+ f"Order expired without execution (Status: {status}, ValidTo: {valid_to}, Now: {current_time})."
133
+ )
134
+ return None
135
+
136
+ executed_sell = int(order_data.get("executedSellAmount", "0"))
137
+ executed_buy = int(order_data.get("executedBuyAmount", "0"))
138
+
139
+ if executed_sell > 0 or executed_buy > 0:
140
+ logger.info("Order executed successfully.")
141
+ sell_price = order_data.get("quote", {}).get("sellTokenPrice", None)
142
+ buy_price = order_data.get("quote", {}).get("buyTokenPrice", None)
143
+
144
+ if sell_price is not None:
145
+ logger.debug(f"Sell price: ${float(sell_price):.2f}")
146
+
147
+ if buy_price is not None:
148
+ logger.debug(f"Buy price: ${float(buy_price):.2f}")
149
+
150
+ return order_data
151
+
152
+ logger.info(
153
+ f"Order pending... ({retries}/{max_retries}). Retry in {sleep_between_retries}s"
154
+ )
155
+ await asyncio.sleep(sleep_between_retries)
156
+
157
+ logger.warning("Max retries reached. Order status unknown.")
158
+ return None
159
+
160
+ async def swap(
161
+ self,
162
+ amount_wei: Wei,
163
+ sell_token_name: str,
164
+ buy_token_name: str,
165
+ safe_address: ChecksumAddress | None = None,
166
+ order_type: OrderType = OrderType.SELL,
167
+ wait_for_execution: bool = False,
168
+ ) -> dict | None:
169
+ """Execute a token swap on CoW Protocol.
170
+
171
+ Args:
172
+ amount_wei: Amount to swap in wei.
173
+ sell_token_name: Name of token to sell.
174
+ buy_token_name: Name of token to buy.
175
+ safe_address: Optional Safe address for multi-sig.
176
+ order_type: SELL or BUY order type.
177
+ wait_for_execution: If True, wait for order to be filled (blocking).
178
+ If False, return immediately after order placement.
179
+
180
+ Returns:
181
+ dict with order info (uid, url, status) or None on error.
182
+
183
+ """
184
+ amount_eth = Web3.from_wei(amount_wei, "ether")
185
+
186
+ if order_type == OrderType.BUY:
187
+ logger.info(
188
+ f"Swapping {sell_token_name} to {amount_eth:.4f} {buy_token_name} on {self.chain.name}..."
189
+ )
190
+
191
+ else:
192
+ logger.info(
193
+ f"Swapping {amount_eth:.4f} {sell_token_name} to {buy_token_name} on {self.chain.name}..."
194
+ )
195
+
196
+ valid_to = int(time.time()) + 3 * 60 # Order valid for 3 minutes
197
+
198
+ # Check if they are patched (testing context)
199
+ global swap_tokens
200
+ if swap_tokens is not None:
201
+ # If patched, we use the patched version
202
+ swap_function = (
203
+ self.swap_tokens_to_exact_tokens if order_type == OrderType.BUY else swap_tokens
204
+ )
205
+ else:
206
+ # Normal execution, lazy load
207
+ actual_swap_tokens = get_cowpy_module("swap_tokens")
208
+ swap_function = (
209
+ self.swap_tokens_to_exact_tokens
210
+ if order_type == OrderType.BUY
211
+ else actual_swap_tokens
212
+ )
213
+
214
+ try:
215
+ order = await swap_function(
216
+ amount=amount_wei,
217
+ account=self.account,
218
+ chain=self.cow_chain,
219
+ sell_token=self.chain.get_token_address(sell_token_name),
220
+ buy_token=self.chain.get_token_address(buy_token_name),
221
+ safe_address=safe_address,
222
+ valid_to=valid_to,
223
+ env=self.env,
224
+ slippage_tolerance=0.01,
225
+ partially_fillable=False,
226
+ )
227
+
228
+ logger.info(f"Swap order placed: {COW_EXPLORER_URL}{order.uid.root}")
229
+
230
+ # Return immediately with order info (non-blocking by default)
231
+ if wait_for_execution:
232
+ # Blocking mode: wait for order to be filled or expire
233
+ return await self.check_cowswap_order(order)
234
+ else:
235
+ # Non-blocking mode: return immediately with order details
236
+ return {
237
+ "uid": order.uid.root if hasattr(order.uid, "root") else str(order.uid),
238
+ "url": order.url,
239
+ "status": "open",
240
+ "validTo": valid_to,
241
+ "sellToken": sell_token_name,
242
+ "buyToken": buy_token_name,
243
+ "sellAmount": str(amount_wei),
244
+ }
245
+
246
+ except Exception as e:
247
+ logger.error(f"Error during token swap: {e}")
248
+ return None
249
+
250
+ async def get_max_sell_amount_wei(
251
+ self,
252
+ amount_wei: Wei,
253
+ sell_token: ChecksumAddress,
254
+ buy_token: ChecksumAddress,
255
+ safe_address: ChecksumAddress | None = None,
256
+ app_data: str | None = None,
257
+ env: "Envs" = "prod",
258
+ slippage_tolerance: float = 0.005,
259
+ ) -> int:
260
+ """Calculate the estimated sell amount needed to buy a fixed amount of tokens."""
261
+ return await get_max_sell_amount_wei(
262
+ amount_wei=amount_wei,
263
+ sell_token=sell_token,
264
+ buy_token=buy_token,
265
+ chain_id_val=self.cow_chain.value[0],
266
+ account_address=self.account.address,
267
+ safe_address=safe_address,
268
+ app_data=app_data,
269
+ env=env,
270
+ slippage_tolerance=slippage_tolerance,
271
+ )
272
+
273
+ async def get_max_buy_amount_wei(
274
+ self,
275
+ sell_amount_wei: Wei,
276
+ sell_token: ChecksumAddress,
277
+ buy_token: ChecksumAddress,
278
+ safe_address: ChecksumAddress | None = None,
279
+ app_data: str | None = None,
280
+ env: "Envs" = "prod",
281
+ slippage_tolerance: float = 0.005,
282
+ ) -> int:
283
+ """Calculate the maximum buy amount for a given sell amount."""
284
+ return await get_max_buy_amount_wei(
285
+ sell_amount_wei=sell_amount_wei,
286
+ sell_token=sell_token,
287
+ buy_token=buy_token,
288
+ chain_id_val=self.cow_chain.value[0],
289
+ account_address=self.account.address,
290
+ safe_address=safe_address,
291
+ app_data=app_data,
292
+ env=env,
293
+ slippage_tolerance=slippage_tolerance,
294
+ )
295
+
296
+ @staticmethod
297
+ async def swap_tokens_to_exact_tokens(
298
+ amount: Wei,
299
+ account: LocalAccount,
300
+ chain: "Chain",
301
+ sell_token: ChecksumAddress,
302
+ buy_token: ChecksumAddress,
303
+ safe_address: ChecksumAddress | None = None,
304
+ app_data: str | None = None,
305
+ valid_to: int | None = None,
306
+ env: "Envs" = "prod",
307
+ slippage_tolerance: float = 0.005,
308
+ partially_fillable: bool = False,
309
+ ) -> "CompletedOrder":
310
+ """Execute a 'Buy' order (Exact Output) on CoW Protocol."""
311
+ # Lazy imports
312
+ if app_data is None:
313
+ app_data = get_cowpy_module("DEFAULT_APP_DATA_HASH")
314
+
315
+ global \
316
+ get_order_quote, \
317
+ OrderQuoteRequest, \
318
+ OrderQuoteSide3, \
319
+ OrderQuoteSideKindBuy, \
320
+ TokenAmount, \
321
+ SupportedChainId, \
322
+ OrderBookApi, \
323
+ OrderBookAPIConfigFactory, \
324
+ Order, \
325
+ PreSignSignature, \
326
+ SigningScheme, \
327
+ sign_order, \
328
+ post_order, \
329
+ CompletedOrder
330
+
331
+ # Re-initialize lazy modules if needed (they are file-global in this file)
332
+ _get_order_quote = get_order_quote or get_cowpy_module("get_order_quote")
333
+ _order_quote_request_cls = OrderQuoteRequest or get_cowpy_module("OrderQuoteRequest")
334
+ _order_quote_side_cls = OrderQuoteSide3 or get_cowpy_module("OrderQuoteSide3")
335
+ _order_quote_side_kind_buy_cls = OrderQuoteSideKindBuy or get_cowpy_module(
336
+ "OrderQuoteSideKindBuy"
337
+ )
338
+ _token_amount_cls = TokenAmount or get_cowpy_module("TokenAmount")
339
+ _supported_chain_id_cls = SupportedChainId or get_cowpy_module("SupportedChainId")
340
+ _order_book_api_cls = OrderBookApi or get_cowpy_module("OrderBookApi")
341
+ _order_book_api_config_factory_cls = OrderBookAPIConfigFactory or get_cowpy_module(
342
+ "OrderBookAPIConfigFactory"
343
+ )
344
+ _order_cls = Order or get_cowpy_module("Order")
345
+ _pre_sign_signature_cls = PreSignSignature or get_cowpy_module("PreSignSignature")
346
+ _signing_scheme_cls = SigningScheme or get_cowpy_module("SigningScheme")
347
+ _sign_order = sign_order or get_cowpy_module("sign_order")
348
+ _post_order = post_order or get_cowpy_module("post_order")
349
+ _completed_order_cls = CompletedOrder or get_cowpy_module("CompletedOrder")
350
+
351
+ chain_id = _supported_chain_id_cls(chain.value[0])
352
+ order_book_api = _order_book_api_cls(
353
+ _order_book_api_config_factory_cls.get_config(env, chain_id)
354
+ )
355
+
356
+ order_quote_request = _order_quote_request_cls(
357
+ sellToken=sell_token,
358
+ buyToken=buy_token,
359
+ from_=safe_address if safe_address is not None else account._address, # type: ignore
360
+ appData=app_data,
361
+ )
362
+
363
+ order_side = _order_quote_side_cls(
364
+ kind=_order_quote_side_kind_buy_cls.buy,
365
+ buyAmountAfterFee=_token_amount_cls(str(amount)),
366
+ )
367
+
368
+ order_quote = await _get_order_quote(order_quote_request, order_side, order_book_api)
369
+
370
+ sell_amount_wei = int(int(order_quote.quote.sellAmount.root) * (1.0 + slippage_tolerance))
371
+
372
+ min_valid_to = (
373
+ order_quote.quote.validTo
374
+ if valid_to is None
375
+ else min(order_quote.quote.validTo, valid_to)
376
+ )
377
+
378
+ order_obj = _order_cls(
379
+ sell_token=sell_token,
380
+ buy_token=buy_token,
381
+ receiver=safe_address if safe_address is not None else account.address,
382
+ valid_to=min_valid_to,
383
+ app_data=app_data,
384
+ sell_amount=str(sell_amount_wei),
385
+ buy_amount=str(amount),
386
+ fee_amount="0", # CoW Swap does not charge fees.
387
+ kind=_order_quote_side_kind_buy_cls.buy.value,
388
+ sell_token_balance="erc20",
389
+ buy_token_balance="erc20",
390
+ partially_fillable=partially_fillable,
391
+ )
392
+
393
+ signature = (
394
+ _pre_sign_signature_cls(
395
+ scheme=_signing_scheme_cls.PRESIGN,
396
+ data=safe_address,
397
+ )
398
+ if safe_address is not None
399
+ else _sign_order(chain, account, order_obj)
400
+ )
401
+ order_uid = await _post_order(account, safe_address, order_obj, signature, order_book_api)
402
+ order_link = order_book_api.get_order_link(order_uid)
403
+ return _completed_order_cls(uid=order_uid, url=order_link)
@@ -0,0 +1,20 @@
1
+ """CoW Swap types and constants."""
2
+
3
+ from enum import Enum
4
+
5
+ from iwa.core.types import EthereumAddress
6
+
7
+ COW_API_URLS = {100: "https://api.cow.fi/xdai"}
8
+ ORDER_ENDPOINT_URL = "/api/v1/orders/"
9
+ COW_EXPLORER_URL = "https://explorer.cow.fi/gc/orders/"
10
+ HTTP_OK = 200
11
+
12
+ COWSWAP_GPV2_VAULT_RELAYER_ADDRESS = EthereumAddress("0xC92E8bdf79f0507f65a392b0ab4667716BFE0110")
13
+ MAX_APPROVAL = 2**256 - 1
14
+
15
+
16
+ class OrderType(Enum):
17
+ """Order types."""
18
+
19
+ SELL = "sell"
20
+ BUY = "buy"
@@ -0,0 +1,44 @@
1
+ """Utilities for CowSwap plugin."""
2
+
3
+ import importlib
4
+ from typing import Any
5
+
6
+ # Lazy import cache for cowdao_cowpy modules to avoid asyncio.run() conflict
7
+ _cowpy_cache: dict[str, Any] = {}
8
+
9
+ # Mapping of module names to (module_path, attribute_name)
10
+ _COWPY_IMPORTS: dict[str, tuple[str, str]] = {
11
+ "DEFAULT_APP_DATA_HASH": ("cowdao_cowpy.app_data.utils", "DEFAULT_APP_DATA_HASH"),
12
+ "Chain": ("cowdao_cowpy.common.chains", "Chain"),
13
+ "SupportedChainId": ("cowdao_cowpy.common.chains", "SupportedChainId"),
14
+ "Order": ("cowdao_cowpy.contracts.order", "Order"),
15
+ "PreSignSignature": ("cowdao_cowpy.contracts.sign", "PreSignSignature"),
16
+ "SigningScheme": ("cowdao_cowpy.contracts.sign", "SigningScheme"),
17
+ "CompletedOrder": ("cowdao_cowpy.cow.swap", "CompletedOrder"),
18
+ "get_order_quote": ("cowdao_cowpy.cow.swap", "get_order_quote"),
19
+ "post_order": ("cowdao_cowpy.cow.swap", "post_order"),
20
+ "sign_order": ("cowdao_cowpy.cow.swap", "sign_order"),
21
+ "swap_tokens": ("cowdao_cowpy.cow.swap", "swap_tokens"),
22
+ "OrderBookApi": ("cowdao_cowpy.order_book.api", "OrderBookApi"),
23
+ "Envs": ("cowdao_cowpy.order_book.config", "Envs"),
24
+ "OrderBookAPIConfigFactory": ("cowdao_cowpy.order_book.config", "OrderBookAPIConfigFactory"),
25
+ "OrderQuoteRequest": ("cowdao_cowpy.order_book.generated.model", "OrderQuoteRequest"),
26
+ "OrderQuoteSide3": ("cowdao_cowpy.order_book.generated.model", "OrderQuoteSide3"),
27
+ "OrderQuoteSideKindBuy": ("cowdao_cowpy.order_book.generated.model", "OrderQuoteSideKindBuy"),
28
+ "TokenAmount": ("cowdao_cowpy.order_book.generated.model", "TokenAmount"),
29
+ "OrderQuoteSide1": ("cowdao_cowpy.order_book.generated.model", "OrderQuoteSide1"),
30
+ "OrderQuoteSideKindSell": ("cowdao_cowpy.order_book.generated.model", "OrderQuoteSideKindSell"),
31
+ }
32
+
33
+
34
+ def get_cowpy_module(name: str) -> Any:
35
+ """Lazily import cowdao_cowpy submodules to avoid asyncio conflict at import time."""
36
+ if name not in _cowpy_cache:
37
+ if name not in _COWPY_IMPORTS:
38
+ raise ValueError(f"Unknown cowpy module: {name}")
39
+
40
+ module_path, attr_name = _COWPY_IMPORTS[name]
41
+ module = importlib.import_module(module_path)
42
+ _cowpy_cache[name] = getattr(module, attr_name)
43
+
44
+ return _cowpy_cache[name]
@@ -0,0 +1,68 @@
1
+ """Gnosis Safe plugin."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+ import typer
6
+
7
+ from iwa.core.keys import KeyStorage
8
+ from iwa.core.plugins import Plugin
9
+
10
+
11
+ class GnosisPlugin(Plugin):
12
+ """Gnosis Safe Plugin."""
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ """Get plugin name."""
17
+ return "gnosis"
18
+
19
+ def get_cli_commands(self) -> Dict[str, callable]:
20
+ """Get CLI commands."""
21
+ return {"create-safe": self.create_safe_command}
22
+
23
+ def create_safe_command(
24
+ self,
25
+ tag: Optional[str] = typer.Option(
26
+ None,
27
+ "--tag",
28
+ "-t",
29
+ help="Tag for this account",
30
+ ),
31
+ owners: str = typer.Option(
32
+ ...,
33
+ "--owners",
34
+ "-o",
35
+ help="Comma-separated list of owner addresses or tags.",
36
+ ),
37
+ threshold: int = typer.Option(
38
+ ...,
39
+ "--threshold",
40
+ "-h",
41
+ help="Number of required confirmations.",
42
+ ),
43
+ chain_name: str = typer.Option(
44
+ "gnosis",
45
+ "--chain",
46
+ "-c",
47
+ help="Chain to deploy the multisig on.",
48
+ ),
49
+ ):
50
+ """Create a new multisig account (Safe)"""
51
+ from iwa.core.services import AccountService, SafeService
52
+
53
+ key_storage = KeyStorage()
54
+ account_service = AccountService(key_storage)
55
+ safe_service = SafeService(key_storage, account_service)
56
+
57
+ owner_list = [owner.strip() for owner in owners.split(",")]
58
+ try:
59
+ safe_service.create_safe(
60
+ deployer_tag_or_address="master",
61
+ owner_tags_or_addresses=owner_list,
62
+ threshold=threshold,
63
+ chain_name=chain_name,
64
+ tag=tag,
65
+ )
66
+ except ValueError as e:
67
+ typer.echo(f"Error: {e}")
68
+ raise typer.Exit(code=1) from e
@@ -0,0 +1,157 @@
1
+ """Gnosis Safe interaction."""
2
+
3
+ from typing import Callable, Optional
4
+
5
+ from safe_eth.eth import EthereumClient
6
+ from safe_eth.eth.constants import NULL_ADDRESS
7
+ from safe_eth.safe import Safe, SafeOperationEnum
8
+ from safe_eth.safe.safe_tx import SafeTx
9
+
10
+ from iwa.core.models import StoredSafeAccount
11
+ from iwa.core.settings import settings
12
+ from iwa.core.utils import configure_logger
13
+
14
+ logger = configure_logger()
15
+
16
+
17
+ class SafeMultisig:
18
+ """Class to interact with Gnosis Safe multisig wallets.
19
+
20
+ Wraps the `safe-eth-py` library to provide a simplified interface for
21
+ checking owners, thresholds, and building/sending multi-signature transactions.
22
+ """
23
+
24
+ def __init__(self, safe_account: StoredSafeAccount, chain_name: str):
25
+ """Initialize the SafeMultisig instance."""
26
+ # Normalize chain comparison to be case-insensitive
27
+ normalized_chains = [c.lower() for c in safe_account.chains]
28
+ if chain_name.lower() not in normalized_chains:
29
+ raise ValueError(f"Safe account is not deployed on chain: {chain_name}")
30
+
31
+ rpc_secret = getattr(settings, f"{chain_name.lower()}_rpc")
32
+ ethereum_client = EthereumClient(rpc_secret.get_secret_value())
33
+ self.multisig = Safe(safe_account.address, ethereum_client)
34
+ self.ethereum_client = ethereum_client
35
+
36
+ def get_owners(self) -> list:
37
+ """Get the list of owners of the safe."""
38
+ return self.multisig.retrieve_owners()
39
+
40
+ def get_threshold(self) -> int:
41
+ """Get the threshold of the safe."""
42
+ return self.multisig.retrieve_threshold()
43
+
44
+ def get_nonce(self) -> int:
45
+ """Get the current nonce of the safe."""
46
+ return self.multisig.retrieve_nonce()
47
+
48
+ def retrieve_all_info(self) -> dict:
49
+ """Retrieve all information about the safe."""
50
+ return self.multisig.retrieve_all_info()
51
+
52
+ def build_tx(
53
+ self,
54
+ to: str,
55
+ value: int,
56
+ data: str = "",
57
+ operation: int = SafeOperationEnum.CALL.value,
58
+ safe_tx_gas: int = 0,
59
+ base_gas: int = 0,
60
+ gas_price: int = 0,
61
+ gas_token: str = NULL_ADDRESS,
62
+ refund_receiver: str = NULL_ADDRESS,
63
+ signatures: str = "",
64
+ safe_nonce: Optional[int] = None,
65
+ ) -> SafeTx:
66
+ """Build a Safe transaction without signing it.
67
+
68
+ Args:
69
+ to: Destination address.
70
+ value: Value in Wei to transfer.
71
+ data: Hex data string (calldata).
72
+ operation: Operation type (0=Call, 1=DelegateCall).
73
+ safe_tx_gas: Gas that should be used for the Safe transaction.
74
+ base_gas: Gas costs for that are independent of the transaction execution
75
+ (e.g. base transaction fee, signature check, payment of the refund).
76
+ gas_price: Gas price that should be used for the payment calculation.
77
+ gas_token: Token address (or 0 if ETH) that is used for the payment.
78
+ refund_receiver: Address of receiver of gas payment (or 0 if tx.origin).
79
+ signatures: Packed signature data (optional at build time).
80
+ safe_nonce: Nonce of the Safe transaction (optional, defaults to current).
81
+
82
+ Returns:
83
+ SafeTx: The constructed Safe transaction object.
84
+
85
+ """
86
+ return self.multisig.build_multisig_tx(
87
+ to,
88
+ value,
89
+ bytes.fromhex(data[2:]) if data else b"",
90
+ operation,
91
+ safe_tx_gas,
92
+ base_gas,
93
+ gas_price,
94
+ gas_token,
95
+ refund_receiver,
96
+ signatures,
97
+ safe_nonce,
98
+ )
99
+
100
+ def send_tx(
101
+ self,
102
+ to: str,
103
+ value: int,
104
+ sign_and_execute_callback: Callable[[SafeTx], str],
105
+ data: str = "",
106
+ operation: int = SafeOperationEnum.CALL.value,
107
+ safe_tx_gas: int = 0,
108
+ base_gas: int = 0,
109
+ gas_price: int = 0,
110
+ gas_token: str = NULL_ADDRESS,
111
+ refund_receiver: str = NULL_ADDRESS,
112
+ signatures: str = "",
113
+ safe_nonce: Optional[int] = None,
114
+ ) -> str:
115
+ """Build and execute a multisig transaction using a callback for signing/execution.
116
+
117
+ This method:
118
+ 1. Builds the `SafeTx` object.
119
+ 2. Passes it to the `sign_and_execute_callback`.
120
+ 3. Returns the resulting transaction hash.
121
+
122
+ Args:
123
+ sign_and_execute_callback: A function that accepts a `SafeTx`, signs it,
124
+ executes it, and returns the tx hash.
125
+ to: Destination address.
126
+ value: Value in Wei.
127
+ data: Calldata hex string.
128
+ operation: Operation type (Call/DelegateCall).
129
+ safe_tx_gas: Gas limit for the safe tx.
130
+ base_gas: Base gas cost.
131
+ gas_price: Gas price for refund.
132
+ gas_token: Gas token for refund.
133
+ refund_receiver: Refund receiver address.
134
+ signatures: Pre-existing signatures.
135
+ safe_nonce: Safe nonce.
136
+
137
+ Returns:
138
+ str: The executed transaction hash.
139
+
140
+ """
141
+ safe_tx = self.build_tx(
142
+ to,
143
+ value,
144
+ data,
145
+ operation,
146
+ safe_tx_gas,
147
+ base_gas,
148
+ gas_price,
149
+ gas_token,
150
+ refund_receiver,
151
+ signatures,
152
+ safe_nonce,
153
+ )
154
+
155
+ tx_hash = sign_and_execute_callback(safe_tx)
156
+ logger.info(f"Safe transaction sent. Tx Hash: {tx_hash}")
157
+ return tx_hash