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
iwa/web/routers/swap.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Swap Router for Web API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
from slowapi import Limiter
|
|
12
|
+
from slowapi.util import get_remote_address
|
|
13
|
+
from web3 import Web3
|
|
14
|
+
|
|
15
|
+
from iwa.core.chain import ChainInterfaces, SupportedChain
|
|
16
|
+
from iwa.plugins.gnosis.cow import CowSwap
|
|
17
|
+
from iwa.web.dependencies import verify_auth, wallet
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
router = APIRouter(prefix="/api/swap", tags=["swap"])
|
|
21
|
+
|
|
22
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache(maxsize=128)
|
|
26
|
+
def get_cached_decimals(token_address: str, chain: str) -> int:
|
|
27
|
+
"""Get token decimals with caching to prevent excessive RPC calls."""
|
|
28
|
+
try:
|
|
29
|
+
from iwa.core.contracts.erc20 import ERC20Contract
|
|
30
|
+
|
|
31
|
+
# Note: ERC20Contract init makes 4 RPC calls (decimals, symbol, name, supply)
|
|
32
|
+
# Caching this result is critical for performance.
|
|
33
|
+
# FIX: Web3 requires checksum addresses
|
|
34
|
+
checksum_address = Web3.to_checksum_address(token_address)
|
|
35
|
+
contract = ERC20Contract(checksum_address, chain)
|
|
36
|
+
return contract.decimals
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.warning(f"Error fetching decimals for {token_address}: {e}")
|
|
39
|
+
return 18
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SwapRequest(BaseModel):
|
|
43
|
+
"""Request to swap tokens via CowSwap."""
|
|
44
|
+
|
|
45
|
+
account: str = Field(description="Account address or tag")
|
|
46
|
+
sell_token: str = Field(description="Token symbol to sell (e.g., WXDAI)")
|
|
47
|
+
buy_token: str = Field(description="Token symbol to buy (e.g., OLAS)")
|
|
48
|
+
amount_eth: Optional[float] = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
description="Amount in human-readable units (ETH). If null, uses entire balance.",
|
|
51
|
+
)
|
|
52
|
+
order_type: str = Field(description="Type of order: 'sell' or 'buy'")
|
|
53
|
+
chain: str = Field(default="gnosis", description="Blockchain network name")
|
|
54
|
+
|
|
55
|
+
@field_validator("order_type")
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate_order_type(cls, v: str) -> str:
|
|
58
|
+
"""Validate order type is 'sell' or 'buy'."""
|
|
59
|
+
v = v.strip().lower()
|
|
60
|
+
if v not in ("sell", "buy"):
|
|
61
|
+
raise ValueError("Order type must be 'sell' or 'buy'")
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
@field_validator("account")
|
|
65
|
+
@classmethod
|
|
66
|
+
def validate_account(cls, v: str) -> str:
|
|
67
|
+
"""Validate account address or tag."""
|
|
68
|
+
if not v:
|
|
69
|
+
raise ValueError("Account cannot be empty")
|
|
70
|
+
if v.startswith("0x") and len(v) != 42:
|
|
71
|
+
raise ValueError("Invalid account format")
|
|
72
|
+
return v
|
|
73
|
+
|
|
74
|
+
@field_validator("sell_token", "buy_token")
|
|
75
|
+
@classmethod
|
|
76
|
+
def validate_tokens(cls, v: str) -> str:
|
|
77
|
+
"""Validate token address or symbol."""
|
|
78
|
+
if not v:
|
|
79
|
+
raise ValueError("Token cannot be empty")
|
|
80
|
+
if v.startswith("0x") and len(v) != 42:
|
|
81
|
+
raise ValueError("Invalid token address")
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
@field_validator("amount_eth")
|
|
85
|
+
@classmethod
|
|
86
|
+
def validate_amount(cls, v: Optional[float]) -> Optional[float]:
|
|
87
|
+
"""Validate amount is positive (if provided)."""
|
|
88
|
+
if v is None:
|
|
89
|
+
return v # None means use entire balance
|
|
90
|
+
if v <= 0: # Swaps must be positive
|
|
91
|
+
raise ValueError("Amount must be greater than 0")
|
|
92
|
+
if v > 1e18: # Sanity check
|
|
93
|
+
raise ValueError("Amount too large")
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
@field_validator("chain")
|
|
97
|
+
@classmethod
|
|
98
|
+
def validate_chain(cls, v: str) -> str:
|
|
99
|
+
"""Validate chain name is alphanumeric."""
|
|
100
|
+
if not v.replace("-", "").isalnum():
|
|
101
|
+
raise ValueError("Invalid chain name")
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post(
|
|
106
|
+
"",
|
|
107
|
+
summary="Swap Tokens",
|
|
108
|
+
description="Execute a token swap on CowSwap (CoW Protocol). Returns immediately after order placement.",
|
|
109
|
+
)
|
|
110
|
+
@limiter.limit("10/minute")
|
|
111
|
+
async def swap_tokens(request: Request, req: SwapRequest, auth: bool = Depends(verify_auth)):
|
|
112
|
+
"""Execute a token swap via CowSwap.
|
|
113
|
+
|
|
114
|
+
This endpoint places the order and returns immediately.
|
|
115
|
+
Use GET /api/swap/orders to track order status.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
from iwa.plugins.gnosis.cow import OrderType
|
|
119
|
+
|
|
120
|
+
order_type = OrderType.SELL if req.order_type == "sell" else OrderType.BUY
|
|
121
|
+
|
|
122
|
+
# Run swap in a separate thread with its own event loop to avoid
|
|
123
|
+
# asyncio.run() conflict with cowdao_cowpy library
|
|
124
|
+
def run_swap_in_thread():
|
|
125
|
+
loop = asyncio.new_event_loop()
|
|
126
|
+
asyncio.set_event_loop(loop)
|
|
127
|
+
try:
|
|
128
|
+
return loop.run_until_complete(
|
|
129
|
+
wallet.transfer_service.swap(
|
|
130
|
+
account_address_or_tag=req.account,
|
|
131
|
+
amount_eth=req.amount_eth,
|
|
132
|
+
sell_token_name=req.sell_token,
|
|
133
|
+
buy_token_name=req.buy_token,
|
|
134
|
+
chain_name=req.chain,
|
|
135
|
+
order_type=order_type,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
finally:
|
|
139
|
+
loop.close()
|
|
140
|
+
|
|
141
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
142
|
+
future = executor.submit(run_swap_in_thread)
|
|
143
|
+
order_data = future.result(timeout=120)
|
|
144
|
+
|
|
145
|
+
if order_data:
|
|
146
|
+
# Check if order was executed (blocking mode) or just placed (non-blocking)
|
|
147
|
+
status = order_data.get("status", "unknown")
|
|
148
|
+
|
|
149
|
+
if status == "open":
|
|
150
|
+
# Non-blocking: order placed but not yet executed
|
|
151
|
+
return {
|
|
152
|
+
"status": "success",
|
|
153
|
+
"message": "Swap order placed! Track progress in Recent Orders.",
|
|
154
|
+
"order": order_data,
|
|
155
|
+
}
|
|
156
|
+
elif status == "fulfilled":
|
|
157
|
+
# Order was executed (if wait_for_execution=True was used)
|
|
158
|
+
executed_sell = float(order_data.get("executedSellAmount", 0))
|
|
159
|
+
executed_buy = float(order_data.get("executedBuyAmount", 0))
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"status": "success",
|
|
163
|
+
"message": "Swap executed successfully!",
|
|
164
|
+
"order": order_data,
|
|
165
|
+
"analytics": {
|
|
166
|
+
"executed_sell_amount": executed_sell,
|
|
167
|
+
"executed_buy_amount": executed_buy,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
else:
|
|
171
|
+
# Other status (expired, cancelled, etc)
|
|
172
|
+
return {
|
|
173
|
+
"status": "success",
|
|
174
|
+
"message": f"Order placed with status: {status}",
|
|
175
|
+
"order": order_data,
|
|
176
|
+
}
|
|
177
|
+
else:
|
|
178
|
+
raise HTTPException(status_code=400, detail="Failed to place swap order")
|
|
179
|
+
except HTTPException:
|
|
180
|
+
raise
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Error swapping tokens: {e}")
|
|
183
|
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.get(
|
|
187
|
+
"/quote",
|
|
188
|
+
summary="Get Swap Quote",
|
|
189
|
+
description="Get a price quote for a potential swap from CowSwap API.",
|
|
190
|
+
)
|
|
191
|
+
def get_swap_quote(
|
|
192
|
+
account: str,
|
|
193
|
+
sell_token: str,
|
|
194
|
+
buy_token: str,
|
|
195
|
+
amount: float,
|
|
196
|
+
mode: str = "sell",
|
|
197
|
+
chain: str = "gnosis",
|
|
198
|
+
auth: bool = Depends(verify_auth),
|
|
199
|
+
):
|
|
200
|
+
"""Get a quote for a swap."""
|
|
201
|
+
try:
|
|
202
|
+
chain_interface = ChainInterfaces().get(chain)
|
|
203
|
+
chain_obj: SupportedChain = chain_interface.chain # type: ignore[assignment]
|
|
204
|
+
account_obj = wallet.account_service.resolve_account(account)
|
|
205
|
+
signer = wallet.key_storage.get_signer(account_obj.address)
|
|
206
|
+
|
|
207
|
+
if not signer:
|
|
208
|
+
raise HTTPException(status_code=400, detail="Could not get signer for account")
|
|
209
|
+
|
|
210
|
+
# Get token addresses and decimals
|
|
211
|
+
sell_token_addr = chain_obj.get_token_address(sell_token)
|
|
212
|
+
buy_token_addr = chain_obj.get_token_address(buy_token)
|
|
213
|
+
|
|
214
|
+
sell_decimals = get_cached_decimals(sell_token_addr, chain)
|
|
215
|
+
buy_decimals = get_cached_decimals(buy_token_addr, chain)
|
|
216
|
+
|
|
217
|
+
# Convert input amount to wei using the correct decimals
|
|
218
|
+
if mode == "sell":
|
|
219
|
+
amount_wei = int(amount * (10**sell_decimals))
|
|
220
|
+
else:
|
|
221
|
+
amount_wei = int(amount * (10**buy_decimals))
|
|
222
|
+
|
|
223
|
+
def run_async_quote():
|
|
224
|
+
"""Run the async CowSwap quote in a new event loop."""
|
|
225
|
+
loop = asyncio.new_event_loop()
|
|
226
|
+
asyncio.set_event_loop(loop)
|
|
227
|
+
try:
|
|
228
|
+
cow = CowSwap(private_key_or_signer=signer, chain=chain_obj)
|
|
229
|
+
if mode == "sell":
|
|
230
|
+
# Get buy amount for given sell amount
|
|
231
|
+
return loop.run_until_complete(
|
|
232
|
+
cow.get_max_buy_amount_wei(
|
|
233
|
+
amount_wei,
|
|
234
|
+
sell_token_addr,
|
|
235
|
+
buy_token_addr,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
# Get sell amount for given buy amount
|
|
240
|
+
return loop.run_until_complete(
|
|
241
|
+
cow.get_max_sell_amount_wei(
|
|
242
|
+
amount_wei,
|
|
243
|
+
sell_token_addr,
|
|
244
|
+
buy_token_addr,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
finally:
|
|
248
|
+
loop.close()
|
|
249
|
+
|
|
250
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
251
|
+
future = executor.submit(run_async_quote)
|
|
252
|
+
result_wei = future.result(timeout=30)
|
|
253
|
+
|
|
254
|
+
# Convert result using the correct decimals
|
|
255
|
+
if mode == "sell":
|
|
256
|
+
result_eth = result_wei / (10**buy_decimals)
|
|
257
|
+
else:
|
|
258
|
+
result_eth = result_wei / (10**sell_decimals)
|
|
259
|
+
|
|
260
|
+
return {"amount": result_eth, "mode": mode}
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
error_msg = str(e)
|
|
264
|
+
if "NoLiquidity" in error_msg or "no route found" in error_msg.lower():
|
|
265
|
+
raise HTTPException(
|
|
266
|
+
status_code=400, detail="No liquidity available for this token pair."
|
|
267
|
+
) from None
|
|
268
|
+
logger.error(f"Error getting swap quote: {e}")
|
|
269
|
+
raise HTTPException(status_code=400, detail=error_msg) from None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.get(
|
|
273
|
+
"/max-amount",
|
|
274
|
+
summary="Get Max Swap Amount",
|
|
275
|
+
description="Calculate maximum available amount for a swap, considering balances and slippage.",
|
|
276
|
+
)
|
|
277
|
+
def get_swap_max_amount(
|
|
278
|
+
account: str,
|
|
279
|
+
sell_token: str,
|
|
280
|
+
buy_token: str,
|
|
281
|
+
mode: str = "sell",
|
|
282
|
+
chain: str = "gnosis",
|
|
283
|
+
auth: bool = Depends(verify_auth),
|
|
284
|
+
):
|
|
285
|
+
"""Get the maximum amount for a swap."""
|
|
286
|
+
try:
|
|
287
|
+
# Get token address and decimals
|
|
288
|
+
chain_interface = ChainInterfaces().get(chain)
|
|
289
|
+
chain_obj = chain_interface.chain
|
|
290
|
+
sell_token_addr = chain_obj.get_token_address(sell_token)
|
|
291
|
+
sell_decimals = get_cached_decimals(sell_token_addr, chain)
|
|
292
|
+
|
|
293
|
+
# Get the sell token balance
|
|
294
|
+
sell_balance = wallet.balance_service.get_erc20_balance_wei(account, sell_token, chain)
|
|
295
|
+
if sell_balance is None or sell_balance == 0:
|
|
296
|
+
return {"max_amount": 0.0, "mode": mode}
|
|
297
|
+
|
|
298
|
+
sell_balance_eth = sell_balance / (10**sell_decimals)
|
|
299
|
+
|
|
300
|
+
if mode == "sell":
|
|
301
|
+
return {"max_amount": sell_balance_eth, "mode": "sell"}
|
|
302
|
+
|
|
303
|
+
# For buy mode, use CowSwap to get quote in a separate thread
|
|
304
|
+
account_obj = wallet.account_service.resolve_account(account)
|
|
305
|
+
signer = wallet.key_storage.get_signer(account_obj.address)
|
|
306
|
+
|
|
307
|
+
if not signer:
|
|
308
|
+
raise HTTPException(status_code=400, detail="Could not get signer for account")
|
|
309
|
+
|
|
310
|
+
def run_async_quote():
|
|
311
|
+
"""Run the async CowSwap quote in a new event loop."""
|
|
312
|
+
loop = asyncio.new_event_loop()
|
|
313
|
+
asyncio.set_event_loop(loop)
|
|
314
|
+
try:
|
|
315
|
+
cow = CowSwap(private_key_or_signer=signer, chain=chain_obj)
|
|
316
|
+
return loop.run_until_complete(
|
|
317
|
+
cow.get_max_buy_amount_wei(
|
|
318
|
+
sell_balance,
|
|
319
|
+
chain_obj.get_token_address(sell_token),
|
|
320
|
+
chain_obj.get_token_address(buy_token),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
finally:
|
|
324
|
+
loop.close()
|
|
325
|
+
|
|
326
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
327
|
+
future = executor.submit(run_async_quote)
|
|
328
|
+
max_buy_wei = future.result(timeout=30)
|
|
329
|
+
|
|
330
|
+
# Convert buy amount using buy token decimals (which involves querying decimals for buy token too)
|
|
331
|
+
# Note: cow.get_max_buy_amount_wei returns result in terms of BUY token amount if we asked for max sell?
|
|
332
|
+
# Re-reading: The function calculates "max buy amount".
|
|
333
|
+
# If mode is buy, we want to know how much SELL token we need? No, function is "get_swap_max_amount".
|
|
334
|
+
# If mode is buy, frontend is asking "I want to buy MAX?". That doesn't make sense.
|
|
335
|
+
# "MAX" button is usually only for SELL.
|
|
336
|
+
# The frontend calls this endpoint with mode="sell" or "buy" depending on button.
|
|
337
|
+
# If mode="buy", handleMaxClick(false) calls this. But Max Buy button is usually HIDDEN in UI for sell mode.
|
|
338
|
+
# In buy mode (Buy exact amount), MAX means "Buy as much as possible with my sell token".
|
|
339
|
+
# So we return the max BUY amount.
|
|
340
|
+
|
|
341
|
+
# We need buy token decimals
|
|
342
|
+
buy_token_addr = chain_obj.get_token_address(buy_token)
|
|
343
|
+
buy_decimals = get_cached_decimals(buy_token_addr, chain)
|
|
344
|
+
|
|
345
|
+
max_buy_eth = max_buy_wei / (10**buy_decimals)
|
|
346
|
+
return {"max_amount": max_buy_eth, "mode": "buy", "sell_balance": sell_balance_eth}
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
import traceback
|
|
350
|
+
|
|
351
|
+
error_msg = str(e) or repr(e)
|
|
352
|
+
logger.error(f"Error getting max swap amount: {error_msg}\n{traceback.format_exc()}")
|
|
353
|
+
# Handle common CowSwap errors with clearer messages
|
|
354
|
+
if "NoLiquidity" in error_msg or "no route found" in error_msg.lower():
|
|
355
|
+
raise HTTPException(
|
|
356
|
+
status_code=400,
|
|
357
|
+
detail="No liquidity available for this token pair. Try a different pair.",
|
|
358
|
+
) from None
|
|
359
|
+
raise HTTPException(status_code=400, detail=error_msg or "Unknown error") from None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class WrapRequest(BaseModel):
|
|
363
|
+
"""Request to wrap/unwrap native currency."""
|
|
364
|
+
|
|
365
|
+
account: str = Field(description="Account address or tag")
|
|
366
|
+
amount_eth: float = Field(description="Amount in human-readable units (ETH)")
|
|
367
|
+
chain: str = Field(default="gnosis", description="Blockchain network name")
|
|
368
|
+
|
|
369
|
+
@field_validator("account")
|
|
370
|
+
@classmethod
|
|
371
|
+
def validate_account(cls, v: str) -> str:
|
|
372
|
+
"""Validate account address or tag."""
|
|
373
|
+
if not v:
|
|
374
|
+
raise ValueError("Account cannot be empty")
|
|
375
|
+
if v.startswith("0x") and len(v) != 42:
|
|
376
|
+
raise ValueError("Invalid account format")
|
|
377
|
+
return v
|
|
378
|
+
|
|
379
|
+
@field_validator("amount_eth")
|
|
380
|
+
@classmethod
|
|
381
|
+
def validate_amount(cls, v: float) -> float:
|
|
382
|
+
"""Validate amount is positive."""
|
|
383
|
+
if v <= 0:
|
|
384
|
+
raise ValueError("Amount must be greater than 0")
|
|
385
|
+
if v > 1e18:
|
|
386
|
+
raise ValueError("Amount too large")
|
|
387
|
+
return v
|
|
388
|
+
|
|
389
|
+
@field_validator("chain")
|
|
390
|
+
@classmethod
|
|
391
|
+
def validate_chain(cls, v: str) -> str:
|
|
392
|
+
"""Validate chain name is alphanumeric."""
|
|
393
|
+
if not v.replace("-", "").isalnum():
|
|
394
|
+
raise ValueError("Invalid chain name")
|
|
395
|
+
return v
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@router.post(
|
|
399
|
+
"/wrap",
|
|
400
|
+
summary="Wrap Native Currency",
|
|
401
|
+
description="Wrap native currency to wrapped token (e.g., xDAI → WXDAI).",
|
|
402
|
+
)
|
|
403
|
+
@limiter.limit("10/minute")
|
|
404
|
+
async def wrap_native(request: Request, req: WrapRequest, auth: bool = Depends(verify_auth)):
|
|
405
|
+
"""Wrap native currency to WXDAI."""
|
|
406
|
+
try:
|
|
407
|
+
amount_wei = Web3.to_wei(req.amount_eth, "ether")
|
|
408
|
+
tx_hash = wallet.transfer_service.wrap_native(
|
|
409
|
+
account_address_or_tag=req.account,
|
|
410
|
+
amount_wei=amount_wei,
|
|
411
|
+
chain_name=req.chain,
|
|
412
|
+
)
|
|
413
|
+
if tx_hash:
|
|
414
|
+
return {
|
|
415
|
+
"status": "success",
|
|
416
|
+
"message": f"Wrapped {req.amount_eth:.4f} xDAI → WXDAI",
|
|
417
|
+
"hash": tx_hash,
|
|
418
|
+
}
|
|
419
|
+
else:
|
|
420
|
+
raise HTTPException(status_code=400, detail="Wrap transaction failed")
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.error(f"Error wrapping: {e}")
|
|
423
|
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@router.post(
|
|
427
|
+
"/unwrap",
|
|
428
|
+
summary="Unwrap to Native Currency",
|
|
429
|
+
description="Unwrap wrapped token to native currency (e.g., WXDAI → xDAI).",
|
|
430
|
+
)
|
|
431
|
+
@limiter.limit("10/minute")
|
|
432
|
+
async def unwrap_native(request: Request, req: WrapRequest, auth: bool = Depends(verify_auth)):
|
|
433
|
+
"""Unwrap WXDAI to native xDAI."""
|
|
434
|
+
try:
|
|
435
|
+
amount_wei = Web3.to_wei(req.amount_eth, "ether")
|
|
436
|
+
tx_hash = wallet.transfer_service.unwrap_native(
|
|
437
|
+
account_address_or_tag=req.account,
|
|
438
|
+
amount_wei=amount_wei,
|
|
439
|
+
chain_name=req.chain,
|
|
440
|
+
)
|
|
441
|
+
if tx_hash:
|
|
442
|
+
return {
|
|
443
|
+
"status": "success",
|
|
444
|
+
"message": f"Unwrapped {req.amount_eth:.4f} WXDAI → xDAI",
|
|
445
|
+
"hash": tx_hash,
|
|
446
|
+
}
|
|
447
|
+
else:
|
|
448
|
+
raise HTTPException(status_code=400, detail="Unwrap transaction failed")
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.error(f"Error unwrapping: {e}")
|
|
451
|
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@router.get(
|
|
455
|
+
"/wrap/balance",
|
|
456
|
+
summary="Get Wrap/Unwrap Balances",
|
|
457
|
+
description="Get native and WXDAI balances for an account.",
|
|
458
|
+
)
|
|
459
|
+
def get_wrap_balances(
|
|
460
|
+
account: str,
|
|
461
|
+
chain: str = "gnosis",
|
|
462
|
+
auth: bool = Depends(verify_auth),
|
|
463
|
+
):
|
|
464
|
+
"""Get balances for wrap/unwrap operations."""
|
|
465
|
+
try:
|
|
466
|
+
native_balance_wei = wallet.balance_service.get_native_balance_wei(account, chain)
|
|
467
|
+
wxdai_balance_wei = wallet.balance_service.get_erc20_balance_wei(account, "WXDAI", chain)
|
|
468
|
+
|
|
469
|
+
native_eth = float(Web3.from_wei(native_balance_wei or 0, "ether"))
|
|
470
|
+
wxdai_eth = float(Web3.from_wei(wxdai_balance_wei or 0, "ether"))
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
"native": native_eth,
|
|
474
|
+
"wxdai": wxdai_eth,
|
|
475
|
+
}
|
|
476
|
+
except Exception as e:
|
|
477
|
+
logger.error(f"Error getting wrap balances: {e}")
|
|
478
|
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@router.get(
|
|
482
|
+
"/orders",
|
|
483
|
+
summary="Get Recent Orders",
|
|
484
|
+
description="Get recent swap orders for an account from CowSwap API.",
|
|
485
|
+
)
|
|
486
|
+
def get_recent_orders(
|
|
487
|
+
account: str = "master",
|
|
488
|
+
chain: str = "gnosis",
|
|
489
|
+
limit: int = 5,
|
|
490
|
+
auth: bool = Depends(verify_auth),
|
|
491
|
+
):
|
|
492
|
+
"""Get recent orders for an account from CowSwap API."""
|
|
493
|
+
import time
|
|
494
|
+
|
|
495
|
+
import requests
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
# Resolve account address
|
|
499
|
+
account_obj = wallet.account_service.resolve_account(account)
|
|
500
|
+
address = account_obj.address
|
|
501
|
+
|
|
502
|
+
# Get API URL for chain
|
|
503
|
+
chain_interface = ChainInterfaces().get(chain)
|
|
504
|
+
chain_id = chain_interface.chain.chain_id
|
|
505
|
+
|
|
506
|
+
api_urls = {
|
|
507
|
+
100: "https://api.cow.fi/xdai",
|
|
508
|
+
1: "https://api.cow.fi/mainnet",
|
|
509
|
+
11155111: "https://api.cow.fi/sepolia",
|
|
510
|
+
}
|
|
511
|
+
api_url = api_urls.get(chain_id)
|
|
512
|
+
if not api_url:
|
|
513
|
+
return {"orders": []}
|
|
514
|
+
|
|
515
|
+
# Fetch orders from CowSwap API
|
|
516
|
+
url = f"{api_url}/api/v1/account/{address}/orders?limit={limit}"
|
|
517
|
+
response = requests.get(url, timeout=10)
|
|
518
|
+
|
|
519
|
+
if response.status_code != 200:
|
|
520
|
+
return {"orders": []}
|
|
521
|
+
|
|
522
|
+
orders = response.json()
|
|
523
|
+
current_time = int(time.time())
|
|
524
|
+
|
|
525
|
+
# Process orders for frontend
|
|
526
|
+
result = []
|
|
527
|
+
chain_interface = ChainInterfaces().get(chain)
|
|
528
|
+
|
|
529
|
+
for order in orders[:limit]:
|
|
530
|
+
order_data = _process_order_for_frontend(order, chain_interface, chain, current_time)
|
|
531
|
+
result.append(order_data)
|
|
532
|
+
|
|
533
|
+
return {"orders": result}
|
|
534
|
+
|
|
535
|
+
except Exception as e:
|
|
536
|
+
logger.error(f"Error fetching orders: {e}")
|
|
537
|
+
return {"orders": []}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _process_order_for_frontend(
|
|
541
|
+
order: dict, chain_interface: Any, chain: str, current_time: int
|
|
542
|
+
) -> dict:
|
|
543
|
+
"""Process a single order for frontend display."""
|
|
544
|
+
valid_to = int(order.get("validTo", 0))
|
|
545
|
+
created = order.get("creationDate", "")
|
|
546
|
+
status = order.get("status", "unknown")
|
|
547
|
+
|
|
548
|
+
# Calculate progress for pending orders
|
|
549
|
+
progress_pct = 0
|
|
550
|
+
if status in ["open", "presignaturePending"] and valid_to > current_time:
|
|
551
|
+
# Calculate time elapsed vs total time
|
|
552
|
+
created_ts = 0
|
|
553
|
+
if created:
|
|
554
|
+
try:
|
|
555
|
+
from datetime import datetime
|
|
556
|
+
|
|
557
|
+
created_ts = int(datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp())
|
|
558
|
+
except ValueError:
|
|
559
|
+
created_ts = current_time - 180 # Default 3 min ago
|
|
560
|
+
|
|
561
|
+
total_duration = valid_to - created_ts
|
|
562
|
+
time_remaining = valid_to - current_time
|
|
563
|
+
if total_duration > 0:
|
|
564
|
+
progress_pct = int(max(0, min(100, (time_remaining / total_duration) * 100)))
|
|
565
|
+
else:
|
|
566
|
+
time_remaining = 0
|
|
567
|
+
|
|
568
|
+
# Resolve token addresses to names
|
|
569
|
+
sell_token_addr = order.get("sellToken", "")
|
|
570
|
+
buy_token_addr = order.get("buyToken", "")
|
|
571
|
+
sell_token_name = (
|
|
572
|
+
chain_interface.chain.get_token_name(sell_token_addr) or sell_token_addr[:8] + "..."
|
|
573
|
+
)
|
|
574
|
+
buy_token_name = (
|
|
575
|
+
chain_interface.chain.get_token_name(buy_token_addr) or buy_token_addr[:8] + "..."
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Calculate human-readable amounts
|
|
579
|
+
try:
|
|
580
|
+
sell_decimals = 18
|
|
581
|
+
buy_decimals = 18
|
|
582
|
+
|
|
583
|
+
# Try to get decimals from contract with caching!
|
|
584
|
+
try:
|
|
585
|
+
sell_decimals = get_cached_decimals(sell_token_addr, chain)
|
|
586
|
+
except Exception:
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
buy_decimals = get_cached_decimals(buy_token_addr, chain)
|
|
591
|
+
except Exception:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
sell_amount_wei = float(order.get("sellAmount", "0"))
|
|
595
|
+
buy_amount_wei = float(order.get("buyAmount", "0"))
|
|
596
|
+
|
|
597
|
+
sell_amount_fmt = sell_amount_wei / (10**sell_decimals)
|
|
598
|
+
buy_amount_fmt = buy_amount_wei / (10**buy_decimals)
|
|
599
|
+
|
|
600
|
+
except Exception as e:
|
|
601
|
+
logger.warning(f"Error converting amounts: {e}")
|
|
602
|
+
sell_amount_fmt = 0.0
|
|
603
|
+
buy_amount_fmt = 0.0
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
"uid": order.get("uid", "")[:12] + "...",
|
|
607
|
+
"full_uid": order.get("uid", ""),
|
|
608
|
+
"status": status,
|
|
609
|
+
"sellToken": sell_token_name,
|
|
610
|
+
"buyToken": buy_token_name,
|
|
611
|
+
"sellAmount": f"{sell_amount_fmt:.4f}",
|
|
612
|
+
"buyAmount": f"{buy_amount_fmt:.4f}",
|
|
613
|
+
"validTo": valid_to,
|
|
614
|
+
"created": created,
|
|
615
|
+
"progressPct": round(progress_pct, 1),
|
|
616
|
+
"timeRemaining": max(0, valid_to - current_time),
|
|
617
|
+
}
|