iwa 0.0.0__py3-none-any.whl → 0.0.1a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,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()
|