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,262 @@
1
+ """Native transfer mixin."""
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from loguru import logger
6
+ from web3 import Web3
7
+ from web3.types import Wei
8
+
9
+ from iwa.core.chain import ChainInterfaces
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 NativeTransferMixin:
18
+ """Mixin for native currency transfers and wrapping."""
19
+
20
+ def _send_native_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
+ from_tag: Optional[str],
28
+ to_tag: Optional[str],
29
+ token_symbol: str,
30
+ ) -> str:
31
+ """Send native currency via Safe multisig."""
32
+ tx_hash = self.safe_service.execute_safe_transaction(
33
+ safe_address_or_tag=from_address_or_tag,
34
+ to=to_address,
35
+ value=amount_wei,
36
+ chain_name=chain_name,
37
+ )
38
+ # Get receipt for gas calculation
39
+ receipt = None
40
+ try:
41
+ interface = ChainInterfaces().get(chain_name)
42
+ receipt = interface.web3.eth.get_transaction_receipt(tx_hash)
43
+ except Exception as e:
44
+ logger.warning(f"Could not get receipt for Safe tx {tx_hash}: {e}")
45
+
46
+ gas_cost, gas_value_eur = self._calculate_gas_info(receipt, chain_name)
47
+ # Get price and value
48
+ p_eur, v_eur = self._get_token_price_info(token_symbol, amount_wei, chain_name)
49
+ log_transaction(
50
+ tx_hash=tx_hash,
51
+ from_addr=from_account.address,
52
+ to_addr=to_address,
53
+ token=token_symbol,
54
+ amount_wei=amount_wei,
55
+ chain=chain_name,
56
+ from_tag=from_tag,
57
+ to_tag=to_tag,
58
+ gas_cost=gas_cost,
59
+ gas_value_eur=gas_value_eur,
60
+ price_eur=p_eur,
61
+ value_eur=v_eur,
62
+ tags=["native-transfer", "safe-transaction"],
63
+ )
64
+ return tx_hash
65
+
66
+ def _send_native_via_eoa(
67
+ self: "TransferService",
68
+ from_account,
69
+ to_address: str,
70
+ amount_wei: Wei,
71
+ chain_name: str,
72
+ chain_interface,
73
+ from_tag: Optional[str],
74
+ to_tag: Optional[str],
75
+ token_symbol: str,
76
+ ) -> Optional[str]:
77
+ """Send native currency via EOA (externally owned account)."""
78
+ success, tx_hash = chain_interface.send_native_transfer(
79
+ from_address=from_account.address,
80
+ to_address=to_address,
81
+ value_wei=amount_wei,
82
+ sign_callback=lambda tx: self.key_storage.sign_transaction(tx, from_account.address),
83
+ )
84
+ if success and tx_hash:
85
+ # Get receipt for gas calculation
86
+ receipt = None
87
+ try:
88
+ receipt = chain_interface.web3.eth.get_transaction_receipt(tx_hash)
89
+ except Exception as e:
90
+ logger.warning(f"Could not get receipt for {tx_hash}: {e}")
91
+
92
+ gas_cost, gas_value_eur = self._calculate_gas_info(receipt, chain_name)
93
+ p_eur, v_eur = self._get_token_price_info(token_symbol, amount_wei, chain_name)
94
+ log_transaction(
95
+ tx_hash=tx_hash,
96
+ from_addr=from_account.address,
97
+ to_addr=to_address,
98
+ token=token_symbol,
99
+ amount_wei=amount_wei,
100
+ chain=chain_name,
101
+ from_tag=from_tag,
102
+ to_tag=to_tag,
103
+ gas_cost=gas_cost,
104
+ gas_value_eur=gas_value_eur,
105
+ price_eur=p_eur,
106
+ value_eur=v_eur,
107
+ tags=["native-transfer"],
108
+ )
109
+ return tx_hash
110
+ return None
111
+
112
+ def wrap_native(
113
+ self: "TransferService",
114
+ account_address_or_tag: str,
115
+ amount_wei: Wei,
116
+ chain_name: str = "gnosis",
117
+ ) -> Optional[str]:
118
+ """Wrap native currency to wrapped token (e.g., xDAI → WXDAI).
119
+
120
+ Args:
121
+ account_address_or_tag: Account to wrap from
122
+ amount_wei: Amount in wei to wrap
123
+ chain_name: Chain name (default: gnosis)
124
+
125
+ Returns:
126
+ Transaction hash if successful, None otherwise.
127
+
128
+ """
129
+ account = self.account_service.resolve_account(account_address_or_tag)
130
+ if not account:
131
+ logger.error(f"Account '{account_address_or_tag}' not found.")
132
+ return None
133
+
134
+ chain_interface = ChainInterfaces().get(chain_name)
135
+ wrapped_token = chain_interface.chain.tokens.get("WXDAI")
136
+ if not wrapped_token:
137
+ logger.error(f"WXDAI not found on {chain_name}")
138
+ return None
139
+
140
+ # Simple WETH ABI for deposit
141
+ weth_abi = [
142
+ {
143
+ "constant": False,
144
+ "inputs": [],
145
+ "name": "deposit",
146
+ "outputs": [],
147
+ "payable": True,
148
+ "type": "function",
149
+ }
150
+ ]
151
+
152
+ contract = chain_interface.web3._web3.eth.contract(address=wrapped_token, abi=weth_abi)
153
+
154
+ amount_eth = float(Web3.from_wei(amount_wei, "ether"))
155
+ logger.info(f"Wrapping {amount_eth:.4f} xDAI → WXDAI...")
156
+
157
+ try:
158
+ tx = contract.functions.deposit().build_transaction(
159
+ {
160
+ "from": account.address,
161
+ "value": amount_wei,
162
+ "gas": 100000,
163
+ "gasPrice": chain_interface.web3._web3.eth.gas_price,
164
+ "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
165
+ }
166
+ )
167
+
168
+ signed = self.key_storage.sign_transaction(tx, account.address)
169
+ tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
170
+ receipt = chain_interface.web3._web3.eth.wait_for_transaction_receipt(
171
+ tx_hash, timeout=60
172
+ )
173
+
174
+ if receipt.status == 1:
175
+ logger.info(f"Wrap successful! TX: {tx_hash.hex()}")
176
+ return tx_hash.hex()
177
+ else:
178
+ logger.error(f"Wrap failed. TX: {tx_hash.hex()}")
179
+ return None
180
+ except Exception as e:
181
+ logger.error(f"Error wrapping: {e}")
182
+ return None
183
+
184
+ def unwrap_native(
185
+ self: "TransferService",
186
+ account_address_or_tag: str,
187
+ amount_wei: Optional[Wei] = None,
188
+ chain_name: str = "gnosis",
189
+ ) -> Optional[str]:
190
+ """Unwrap wrapped token to native currency (e.g., WXDAI → xDAI).
191
+
192
+ Args:
193
+ account_address_or_tag: Account to unwrap from
194
+ amount_wei: Amount in wei to unwrap (None = all balance)
195
+ chain_name: Chain name (default: gnosis)
196
+
197
+ Returns:
198
+ Transaction hash if successful, None otherwise.
199
+
200
+ """
201
+ account = self.account_service.resolve_account(account_address_or_tag)
202
+ if not account:
203
+ logger.error(f"Account '{account_address_or_tag}' not found.")
204
+ return None
205
+
206
+ chain_interface = ChainInterfaces().get(chain_name)
207
+ wrapped_token = chain_interface.chain.tokens.get("WXDAI")
208
+ if not wrapped_token:
209
+ logger.error(f"WXDAI not found on {chain_name}")
210
+ return None
211
+
212
+ # Get balance if amount not specified
213
+ if amount_wei is None:
214
+ amount_wei = self.balance_service.get_erc20_balance_wei(
215
+ account.address, "WXDAI", chain_name
216
+ )
217
+ if not amount_wei or amount_wei == 0:
218
+ logger.warning("No WXDAI balance to unwrap")
219
+ return None
220
+
221
+ # Simple WETH ABI for withdraw
222
+ weth_abi = [
223
+ {
224
+ "constant": False,
225
+ "inputs": [{"name": "wad", "type": "uint256"}],
226
+ "name": "withdraw",
227
+ "outputs": [],
228
+ "payable": False,
229
+ "type": "function",
230
+ }
231
+ ]
232
+
233
+ contract = chain_interface.web3._web3.eth.contract(address=wrapped_token, abi=weth_abi)
234
+
235
+ amount_eth = float(Web3.from_wei(amount_wei, "ether"))
236
+ logger.info(f"Unwrapping {amount_eth:.4f} WXDAI → xDAI...")
237
+
238
+ try:
239
+ tx = contract.functions.withdraw(amount_wei).build_transaction(
240
+ {
241
+ "from": account.address,
242
+ "gas": 100000,
243
+ "gasPrice": chain_interface.web3._web3.eth.gas_price,
244
+ "nonce": chain_interface.web3._web3.eth.get_transaction_count(account.address),
245
+ }
246
+ )
247
+
248
+ signed = self.key_storage.sign_transaction(tx, account.address)
249
+ tx_hash = chain_interface.web3._web3.eth.send_raw_transaction(signed.raw_transaction)
250
+ receipt = chain_interface.web3._web3.eth.wait_for_transaction_receipt(
251
+ tx_hash, timeout=60
252
+ )
253
+
254
+ if receipt.status == 1:
255
+ logger.info(f"Unwrap successful! TX: {tx_hash.hex()}")
256
+ return tx_hash.hex()
257
+ else:
258
+ logger.error(f"Unwrap failed. TX: {tx_hash.hex()}")
259
+ return None
260
+ except Exception as e:
261
+ logger.error(f"Error unwrapping: {e}")
262
+ return None
@@ -0,0 +1,326 @@
1
+ """Swap mixin module."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional
4
+
5
+ from loguru import logger
6
+ from web3 import Web3
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.plugins.gnosis.cow import COWSWAP_GPV2_VAULT_RELAYER_ADDRESS, CowSwap, OrderType
12
+
13
+ if TYPE_CHECKING:
14
+ from iwa.core.services.transfer import TransferService
15
+
16
+
17
+ class SwapMixin:
18
+ """Mixin for token swaps."""
19
+
20
+ async def swap(
21
+ self: "TransferService",
22
+ account_address_or_tag: str,
23
+ amount_eth: Optional[float],
24
+ sell_token_name: str,
25
+ buy_token_name: str,
26
+ chain_name: str = "gnosis",
27
+ order_type: OrderType = OrderType.SELL,
28
+ ) -> Optional[dict]:
29
+ """Swap ERC-20 tokens on CowSwap.
30
+
31
+ Returns:
32
+ dict | None: The executed order data if successful, None otherwise.
33
+
34
+ """
35
+ amount_wei = self._prepare_swap_amount(
36
+ account_address_or_tag,
37
+ amount_eth,
38
+ sell_token_name,
39
+ buy_token_name,
40
+ chain_name,
41
+ order_type,
42
+ )
43
+ if amount_wei is None:
44
+ return None
45
+
46
+ chain = ChainInterfaces().get(chain_name).chain
47
+ account = self.account_service.resolve_account(account_address_or_tag)
48
+
49
+ # Validate balance before proceeding (for SELL orders)
50
+ if order_type == OrderType.SELL:
51
+ current_balance = self.balance_service.get_erc20_balance_wei(
52
+ account_address_or_tag, sell_token_name, chain_name
53
+ )
54
+ if current_balance is not None and current_balance < amount_wei:
55
+ # Precision tolerance: if the discrepancy is tiny (e.g. < 0.0001 tokens),
56
+ # just use the actual balance instead of failing.
57
+ # This handles float precision issues from the frontend.
58
+ diff = amount_wei - current_balance
59
+ tolerance = 10**14 # 0.0001 tokens (handles most rounding issues)
60
+
61
+ if diff <= tolerance:
62
+ logger.warning(
63
+ f"Adjusting swap amount due to precision discrepancy: "
64
+ f"requested {amount_wei}, balance {current_balance} (diff: {diff})"
65
+ )
66
+ amount_wei = current_balance
67
+ else:
68
+ balance_eth = current_balance / 1e18
69
+ amount_eth_val = amount_wei / 1e18
70
+ raise ValueError(
71
+ f"Insufficient {sell_token_name} balance: have {balance_eth:.6f}, need {amount_eth_val:.6f}"
72
+ )
73
+ elif current_balance is None:
74
+ raise ValueError(f"Could not retrieve balance for {sell_token_name}")
75
+
76
+ # Get signer (LocalAccount)
77
+ signer = self.key_storage.get_signer(account.address)
78
+ if not signer:
79
+ logger.error(f"Could not retrieve signer for {account_address_or_tag}")
80
+ return None
81
+
82
+ cow = CowSwap(
83
+ private_key_or_signer=signer,
84
+ chain=chain,
85
+ )
86
+
87
+ # Check and approve allowance if needed
88
+ await self._ensure_allowance_for_swap(
89
+ account_address_or_tag,
90
+ sell_token_name,
91
+ buy_token_name,
92
+ chain_name,
93
+ amount_wei,
94
+ order_type,
95
+ cow,
96
+ )
97
+
98
+ # Execute Swap
99
+ logger.debug(
100
+ f"Executing swap: amount_wei={amount_wei}, sell={sell_token_name}, buy={buy_token_name}, order_type={order_type}"
101
+ )
102
+ result = await cow.swap(
103
+ amount_wei=amount_wei,
104
+ sell_token_name=sell_token_name,
105
+ buy_token_name=buy_token_name,
106
+ order_type=order_type,
107
+ )
108
+
109
+ if result:
110
+ logger.info("Swap successful")
111
+
112
+ # Log transaction and analytics
113
+ try:
114
+ analytics = self._calculate_swap_analytics(
115
+ result, sell_token_name, buy_token_name, chain_name
116
+ )
117
+
118
+ tx_hash = result.get("txHash") or result.get("uid")
119
+ if tx_hash:
120
+ self._log_swap_transaction(
121
+ tx_hash,
122
+ account,
123
+ account_address_or_tag,
124
+ sell_token_name,
125
+ buy_token_name,
126
+ result,
127
+ chain_name,
128
+ analytics,
129
+ )
130
+
131
+ # Inject analytics back into result for API/Frontend
132
+ result["analytics"] = analytics
133
+
134
+ except Exception as log_err:
135
+ logger.warning(f"Failed to log swap analytics: {log_err}")
136
+
137
+ return result
138
+
139
+ logger.error("Swap failed")
140
+ return None
141
+
142
+ def _prepare_swap_amount(
143
+ self: "TransferService",
144
+ account_address_or_tag: str,
145
+ amount_eth: Optional[float],
146
+ sell_token_name: str,
147
+ buy_token_name: str,
148
+ chain_name: str,
149
+ order_type: OrderType,
150
+ ) -> Optional[int]:
151
+ """Calculate and validate the swap amount in wei."""
152
+ if amount_eth is None:
153
+ if order_type == OrderType.BUY:
154
+ # raise ValueError("Amount must be specified for buy orders.")
155
+ # To maintain existing behavior (exception raised in original code),
156
+ # we can either raise or let the caller handle None.
157
+ # Original raised ValueError, let's keep it safe or just return.
158
+ # Since original code raised it inside the method, let's raise it here.
159
+ raise ValueError("Amount must be specified for buy orders.")
160
+
161
+ logger.info(f"Swapping entire {sell_token_name} balance to {buy_token_name}")
162
+ return self.balance_service.get_erc20_balance_wei(
163
+ account_address_or_tag, sell_token_name, chain_name
164
+ )
165
+ else:
166
+ # Get decimals correctly!
167
+ decimals = 18
168
+ try:
169
+ chain_interface = ChainInterfaces().get(chain_name)
170
+ token_addr = chain_interface.chain.get_token_address(sell_token_name)
171
+ if token_addr:
172
+ checksum_addr = Web3.to_checksum_address(token_addr)
173
+ decimals = ERC20Contract(checksum_addr, chain_name).decimals
174
+ except Exception as e:
175
+ logger.warning(f"Could not get decimals for {sell_token_name}, assuming 18: {e}")
176
+
177
+ return int(amount_eth * (10**decimals))
178
+
179
+ async def _ensure_allowance_for_swap(
180
+ self: "TransferService",
181
+ account_address_or_tag: str,
182
+ sell_token_name: str,
183
+ buy_token_name: str,
184
+ chain_name: str,
185
+ amount_wei: int,
186
+ order_type: OrderType,
187
+ cow: CowSwap,
188
+ ) -> int:
189
+ """Check and approve allowance for CowSwap."""
190
+ # Check current allowance first
191
+ current_allowance = (
192
+ self.get_erc20_allowance(
193
+ owner_address_or_tag=account_address_or_tag,
194
+ spender_address=COWSWAP_GPV2_VAULT_RELAYER_ADDRESS,
195
+ token_address_or_name=sell_token_name,
196
+ chain_name=chain_name,
197
+ )
198
+ or 0
199
+ )
200
+
201
+ # Calculate required amount
202
+ if order_type == OrderType.SELL:
203
+ required_amount = amount_wei
204
+ else:
205
+ # Need token addresses for buy mode calculation
206
+ chain_interface = ChainInterfaces().get(chain_name)
207
+ sell_token_address = chain_interface.chain.get_token_address(sell_token_name)
208
+ buy_token_address = chain_interface.chain.get_token_address(buy_token_name)
209
+ required_amount = await cow.get_max_sell_amount_wei(
210
+ amount_wei,
211
+ sell_token_address,
212
+ buy_token_address,
213
+ )
214
+
215
+ # If allowance is insufficient, approve EXACT amount (No Infinite)
216
+ if current_allowance < required_amount:
217
+ logger.info(
218
+ f"Insufficient allowance ({current_allowance} < {required_amount}). Approving EXACT amount."
219
+ )
220
+ self.approve_erc20(
221
+ owner_address_or_tag=account_address_or_tag,
222
+ spender_address_or_tag=COWSWAP_GPV2_VAULT_RELAYER_ADDRESS,
223
+ token_address_or_name=sell_token_name,
224
+ amount_wei=required_amount,
225
+ chain_name=chain_name,
226
+ )
227
+ else:
228
+ logger.info(
229
+ f"Allowance sufficient ({current_allowance} >= {required_amount}). Skipping approval."
230
+ )
231
+ return required_amount
232
+
233
+ def _calculate_swap_analytics(
234
+ self: "TransferService",
235
+ result: dict,
236
+ sell_token_name: str,
237
+ buy_token_name: str,
238
+ chain_name: str,
239
+ ) -> dict:
240
+ """Calculate swap analytics from result."""
241
+ executed_sell = float(result.get("executedSellAmount", 0))
242
+ executed_buy = float(result.get("executedBuyAmount", 0))
243
+ quote = result.get("quote", {})
244
+ sell_price_usd = float(quote.get("sellTokenPrice", 0) or 0)
245
+ buy_price_usd = float(quote.get("buyTokenPrice", 0) or 0)
246
+
247
+ # Calculate Analytics
248
+ execution_price = 0.0
249
+ if executed_sell > 0:
250
+ execution_price = executed_buy / executed_sell # Raw ratio
251
+
252
+ # Get actual token decimals
253
+ sell_decimals = 18
254
+ buy_decimals = 18
255
+ try:
256
+ chain_interface = ChainInterfaces().get(chain_name)
257
+ if chain_interface:
258
+ sell_addr = chain_interface.chain.get_token_address(sell_token_name)
259
+ buy_addr = chain_interface.chain.get_token_address(buy_token_name)
260
+ if sell_addr:
261
+ sell_decimals = ERC20Contract(
262
+ Web3.to_checksum_address(sell_addr), chain_name
263
+ ).decimals
264
+ if buy_addr:
265
+ buy_decimals = ERC20Contract(
266
+ Web3.to_checksum_address(buy_addr), chain_name
267
+ ).decimals
268
+ except Exception as e:
269
+ logger.warning(f"Could not get decimals for analytics: {e}")
270
+
271
+ value_sold = (executed_sell / (10**sell_decimals)) * sell_price_usd
272
+ value_bought = (executed_buy / (10**buy_decimals)) * buy_price_usd
273
+
274
+ value_change_pct = None
275
+ if value_sold > 0 and buy_price_usd > 0:
276
+ value_change_pct = ((value_bought - value_sold) / value_sold) * 100
277
+
278
+ # Prepare extra_data
279
+ return {
280
+ "type": "swap",
281
+ "platform": "cowswap",
282
+ "sell_token": sell_token_name,
283
+ "buy_token": buy_token_name,
284
+ "executed_sell_amount": executed_sell,
285
+ "executed_buy_amount": executed_buy,
286
+ "sell_price_usd": sell_price_usd,
287
+ "buy_price_usd": buy_price_usd,
288
+ "execution_price": execution_price,
289
+ "value_change_pct": value_change_pct if value_change_pct is not None else "N/A",
290
+ # Internal fields for logging use
291
+ "_value_sold": value_sold,
292
+ }
293
+
294
+ def _log_swap_transaction(
295
+ self,
296
+ tx_hash: str,
297
+ account: Any,
298
+ account_tag: str,
299
+ sell_token: str,
300
+ buy_token: str,
301
+ result: dict,
302
+ chain_name: str,
303
+ analytics: dict,
304
+ ) -> None:
305
+ """Log swap transaction to database."""
306
+ executed_sell = float(result.get("executedSellAmount", 0))
307
+ value_sold = analytics.get("_value_sold", 0.0)
308
+
309
+ # Clean internal fields
310
+ clean_analytics = analytics.copy()
311
+ clean_analytics.pop("_value_sold", None)
312
+
313
+ log_transaction(
314
+ tx_hash=tx_hash,
315
+ from_addr=account.address,
316
+ to_addr=COWSWAP_GPV2_VAULT_RELAYER_ADDRESS,
317
+ token=sell_token,
318
+ amount_wei=int(executed_sell),
319
+ chain=chain_name,
320
+ from_tag=account_tag,
321
+ tags=["swap", "cowswap", sell_token, buy_token],
322
+ gas_cost="0",
323
+ gas_value_eur=0.0,
324
+ value_eur=float(value_sold) if value_sold > 0 else None,
325
+ extra_data=clean_analytics,
326
+ )
iwa/core/settings.py ADDED
@@ -0,0 +1,95 @@
1
+ """Configuration settings module."""
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ from dotenv import load_dotenv
7
+ from pydantic import ConfigDict, SecretStr, model_validator
8
+ from pydantic_settings import BaseSettings
9
+
10
+ from iwa.core.constants import SECRETS_PATH
11
+ from iwa.core.utils import singleton
12
+
13
+
14
+ @singleton
15
+ class Settings(BaseSettings):
16
+ """Application Settings loaded from environment and secrets file."""
17
+
18
+ # Testing mode - when True, uses Tenderly test RPCs; when False, uses production RPCs
19
+ testing: bool = True
20
+
21
+ # RPC endpoints (loaded from gnosis_rpc/ethereum_rpc/base_rpc in secrets.env)
22
+ # When testing=True, these get overwritten with *_test_rpc values
23
+ gnosis_rpc: Optional[SecretStr] = None
24
+ base_rpc: Optional[SecretStr] = None
25
+ ethereum_rpc: Optional[SecretStr] = None
26
+
27
+ # Test RPCs
28
+ gnosis_test_rpc: Optional[SecretStr] = None
29
+ ethereum_test_rpc: Optional[SecretStr] = None
30
+ base_test_rpc: Optional[SecretStr] = None
31
+
32
+ gnosisscan_api_key: Optional[SecretStr] = None
33
+ coingecko_api_key: Optional[SecretStr] = None
34
+ wallet_password: Optional[SecretStr] = None
35
+ security_word: Optional[SecretStr] = None
36
+
37
+ # Tenderly profile (1 or 2) - determines which credentials to load
38
+ tenderly_profile: int = 1
39
+
40
+ # Tenderly credentials - loaded dynamically based on profile
41
+ tenderly_account_slug: Optional[SecretStr] = None
42
+ tenderly_project_slug: Optional[SecretStr] = None
43
+ tenderly_access_key: Optional[SecretStr] = None
44
+
45
+ # Tenderly funding configuration
46
+ tenderly_native_funds: float = 1000.0
47
+ tenderly_olas_funds: float = 100000.0
48
+
49
+ web_enabled: bool = False
50
+ web_port: int = 8080
51
+ webui_password: Optional[SecretStr] = None
52
+
53
+ model_config = ConfigDict(env_file=str(SECRETS_PATH), env_file_encoding="utf-8", extra="ignore")
54
+
55
+ def __init__(self, **values):
56
+ """Initialize Settings and load environment variables."""
57
+ # Force load dotenv to ensure os.environ variables are set
58
+ load_dotenv(SECRETS_PATH, override=True)
59
+ super().__init__(**values)
60
+
61
+ @model_validator(mode="after")
62
+ def load_tenderly_profile_credentials(self) -> "Settings":
63
+ """Load Tenderly credentials based on the selected profile."""
64
+ profile = self.tenderly_profile
65
+
66
+ # Load profile-specific credentials from environment
67
+ account = os.getenv(f"tenderly_account_slug_{profile}")
68
+ project = os.getenv(f"tenderly_project_slug_{profile}")
69
+ access_key = os.getenv(f"tenderly_access_key_{profile}")
70
+
71
+ if account:
72
+ self.tenderly_account_slug = SecretStr(account)
73
+ if project:
74
+ self.tenderly_project_slug = SecretStr(project)
75
+ if access_key:
76
+ self.tenderly_access_key = SecretStr(access_key)
77
+
78
+ # When in testing mode, override RPCs with test RPCs (Tenderly)
79
+ if self.testing:
80
+ if self.gnosis_test_rpc:
81
+ self.gnosis_rpc = self.gnosis_test_rpc
82
+ if self.ethereum_test_rpc:
83
+ self.ethereum_rpc = self.ethereum_test_rpc
84
+ if self.base_test_rpc:
85
+ self.base_rpc = self.base_test_rpc
86
+
87
+ # Convert empty webui_password to None (no auth required)
88
+ if self.webui_password and not self.webui_password.get_secret_value():
89
+ self.webui_password = None
90
+
91
+ return self
92
+
93
+
94
+ # Global settings instance
95
+ settings = Settings()