wayfinder-paths 0.1.22__py3-none-any.whl → 0.1.24__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.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/__init__.py +0 -4
- wayfinder_paths/adapters/balance_adapter/README.md +0 -1
- wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
- wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
- wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
- wayfinder_paths/adapters/boros_adapter/client.py +476 -0
- wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
- wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
- wayfinder_paths/adapters/boros_adapter/types.py +70 -0
- wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
- wayfinder_paths/adapters/brap_adapter/README.md +22 -75
- wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
- wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -61
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
- wayfinder_paths/adapters/ledger_adapter/README.md +4 -1
- wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +649 -547
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
- wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
- wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
- wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
- wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
- wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
- wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
- wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
- wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/adapter.py +14 -0
- wayfinder_paths/adapters/token_adapter/examples.json +0 -4
- wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
- wayfinder_paths/conftest.py +24 -17
- wayfinder_paths/core/__init__.py +0 -3
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +4 -1
- wayfinder_paths/core/clients/ClientManager.py +0 -7
- wayfinder_paths/core/clients/LedgerClient.py +196 -172
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -3
- wayfinder_paths/core/clients/__init__.py +0 -5
- wayfinder_paths/core/clients/protocols.py +21 -35
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +10 -162
- wayfinder_paths/core/constants/__init__.py +73 -2
- wayfinder_paths/core/constants/base.py +8 -17
- wayfinder_paths/core/constants/chains.py +36 -0
- wayfinder_paths/core/constants/contracts.py +52 -0
- wayfinder_paths/core/constants/erc20_abi.py +0 -1
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
- wayfinder_paths/core/constants/hyperliquid.py +16 -0
- wayfinder_paths/core/constants/moonwell_abi.py +0 -15
- wayfinder_paths/core/constants/tokens.py +9 -0
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -71
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/evm_helpers.py +5 -15
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/tokens.py +28 -0
- wayfinder_paths/core/utils/transaction.py +57 -8
- wayfinder_paths/core/utils/web3.py +8 -3
- wayfinder_paths/mcp/__init__.py +5 -0
- wayfinder_paths/mcp/preview.py +185 -0
- wayfinder_paths/mcp/scripting.py +84 -0
- wayfinder_paths/mcp/server.py +52 -0
- wayfinder_paths/mcp/state/profile_store.py +195 -0
- wayfinder_paths/mcp/state/store.py +89 -0
- wayfinder_paths/mcp/test_scripting.py +267 -0
- wayfinder_paths/mcp/tools/__init__.py +0 -0
- wayfinder_paths/mcp/tools/balances.py +290 -0
- wayfinder_paths/mcp/tools/discovery.py +158 -0
- wayfinder_paths/mcp/tools/execute.py +770 -0
- wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
- wayfinder_paths/mcp/tools/quotes.py +288 -0
- wayfinder_paths/mcp/tools/run_script.py +286 -0
- wayfinder_paths/mcp/tools/strategies.py +188 -0
- wayfinder_paths/mcp/tools/tokens.py +46 -0
- wayfinder_paths/mcp/tools/wallets.py +354 -0
- wayfinder_paths/mcp/utils.py +129 -0
- wayfinder_paths/policies/enso.py +1 -2
- wayfinder_paths/policies/hyper_evm.py +6 -3
- wayfinder_paths/policies/hyperlend.py +1 -2
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/moonwell.py +12 -7
- wayfinder_paths/policies/prjx.py +1 -3
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/run_strategy.py +97 -300
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
- wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
- wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
- wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
- wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
- wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
- wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
- wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
- wayfinder_paths/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
- wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
- wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +15 -23
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
- wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
- wayfinder_paths/tests/test_test_coverage.py +1 -4
- wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
- wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
- {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/clients/WalletClient.py +0 -41
- wayfinder_paths/core/engine/StrategyJob.py +0 -110
- wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths/templates/adapter/README.md +0 -150
- wayfinder_paths/templates/adapter/adapter.py +0 -16
- wayfinder_paths/templates/adapter/examples.json +0 -8
- wayfinder_paths/templates/adapter/test_adapter.py +0 -30
- wayfinder_paths/templates/strategy/README.md +0 -186
- wayfinder_paths/templates/strategy/examples.json +0 -11
- wayfinder_paths/templates/strategy/strategy.py +0 -35
- wayfinder_paths/tests/test_smoke_manifest.py +0 -63
- wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
- wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,1574 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from eth_abi import encode
|
|
9
|
+
from eth_utils import function_signature_to_4byte_selector, to_checksum_address
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
13
|
+
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
14
|
+
from wayfinder_paths.core.constants.contracts import BOROS_MARKET_HUB, BOROS_ROUTER
|
|
15
|
+
from wayfinder_paths.core.utils.tokens import build_approve_transaction
|
|
16
|
+
from wayfinder_paths.core.utils.transaction import send_transaction
|
|
17
|
+
from wayfinder_paths.core.utils.web3 import web3_from_chain_id
|
|
18
|
+
|
|
19
|
+
from .client import BorosClient
|
|
20
|
+
from .parsers import (
|
|
21
|
+
extract_collateral,
|
|
22
|
+
extract_maturity_ts,
|
|
23
|
+
extract_symbol,
|
|
24
|
+
extract_underlying,
|
|
25
|
+
parse_market_position,
|
|
26
|
+
time_to_maturity_days,
|
|
27
|
+
)
|
|
28
|
+
from .types import BorosLimitOrder, BorosMarketQuote
|
|
29
|
+
from .utils import (
|
|
30
|
+
BOROS_TICK_BASE,
|
|
31
|
+
cash_wei_to_float,
|
|
32
|
+
market_id_from_market_acc,
|
|
33
|
+
)
|
|
34
|
+
from .utils import (
|
|
35
|
+
normalize_apr as _normalize_apr,
|
|
36
|
+
)
|
|
37
|
+
from .utils import (
|
|
38
|
+
rate_from_tick as _rate_from_tick,
|
|
39
|
+
)
|
|
40
|
+
from .utils import (
|
|
41
|
+
tick_from_rate as _tick_from_rate,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BorosAdapter(BaseAdapter):
|
|
46
|
+
"""Adapter for Boros fixed-rate market operations.
|
|
47
|
+
|
|
48
|
+
Provides methods for:
|
|
49
|
+
- Market data: list markets, get quotes, orderbook
|
|
50
|
+
- Account data: collaterals, balances, positions, open orders
|
|
51
|
+
- Execution: deposit, withdraw, place orders, close positions
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
adapter_type = "BOROS"
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
config: dict[str, Any] | None = None,
|
|
59
|
+
*,
|
|
60
|
+
sign_callback: Callable | None = None,
|
|
61
|
+
simulation: bool = False,
|
|
62
|
+
user_address: str | None = None,
|
|
63
|
+
account_id: int = 0,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__("boros_adapter", config)
|
|
66
|
+
|
|
67
|
+
self.simulation = simulation
|
|
68
|
+
self.sign_callback = sign_callback
|
|
69
|
+
self._scaling_factor_cache: dict[int, int] = {}
|
|
70
|
+
|
|
71
|
+
boros_cfg = (config or {}).get("boros_adapter", {})
|
|
72
|
+
self.chain_id = int(boros_cfg.get("chain_id", 42161))
|
|
73
|
+
|
|
74
|
+
# Extract user address from config if not provided
|
|
75
|
+
if not user_address:
|
|
76
|
+
wallet = (config or {}).get("strategy_wallet") or (config or {}).get(
|
|
77
|
+
"main_wallet"
|
|
78
|
+
)
|
|
79
|
+
if wallet and isinstance(wallet, dict):
|
|
80
|
+
user_address = wallet.get("address")
|
|
81
|
+
|
|
82
|
+
self.user_address = user_address
|
|
83
|
+
self.account_id = boros_cfg.get("account_id", account_id)
|
|
84
|
+
|
|
85
|
+
self.boros_client = BorosClient(
|
|
86
|
+
base_url=boros_cfg.get("base_url", "https://api.boros.finance"),
|
|
87
|
+
endpoints=boros_cfg.get("endpoints"),
|
|
88
|
+
user_address=user_address,
|
|
89
|
+
account_id=self.account_id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# Transaction Helpers #
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
|
|
96
|
+
BOROS_MARKET_HUB_VIEW_ABI = [
|
|
97
|
+
{
|
|
98
|
+
"inputs": [
|
|
99
|
+
{
|
|
100
|
+
"internalType": "address",
|
|
101
|
+
"name": "user",
|
|
102
|
+
"type": "address",
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"name": "getPersonalCooldown",
|
|
106
|
+
"outputs": [
|
|
107
|
+
{
|
|
108
|
+
"internalType": "uint256",
|
|
109
|
+
"name": "",
|
|
110
|
+
"type": "uint256",
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"stateMutability": "view",
|
|
114
|
+
"type": "function",
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _unwrap_tx_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
"""Best-effort unwrap of API payloads that may nest the tx dict."""
|
|
121
|
+
tx_src: Any = payload
|
|
122
|
+
for key in ("data", "calldata", "transaction", "tx", "result"):
|
|
123
|
+
if isinstance(tx_src, dict) and isinstance(tx_src.get(key), dict):
|
|
124
|
+
tx_src = tx_src[key]
|
|
125
|
+
return tx_src if isinstance(tx_src, dict) else payload
|
|
126
|
+
|
|
127
|
+
def _build_tx_from_calldata(
|
|
128
|
+
self,
|
|
129
|
+
calldata: dict[str, Any],
|
|
130
|
+
*,
|
|
131
|
+
from_address: str,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Build a transaction dict from Boros API calldata.
|
|
134
|
+
|
|
135
|
+
NOTE: We intentionally do NOT copy 'gas' from the API response.
|
|
136
|
+
The Boros API sometimes returns incorrect gas values (e.g., 1234).
|
|
137
|
+
Instead, we let the transaction service estimate gas properly.
|
|
138
|
+
"""
|
|
139
|
+
tx_src = self._unwrap_tx_payload(calldata)
|
|
140
|
+
|
|
141
|
+
to_addr = tx_src.get("to") or calldata.get("to")
|
|
142
|
+
|
|
143
|
+
# Handle v3 API format that returns {'calldatas': ['0x...']} without 'to' address
|
|
144
|
+
data_val = tx_src.get("data") or calldata.get("data")
|
|
145
|
+
if not data_val:
|
|
146
|
+
# Check for calldatas array format (v3 API)
|
|
147
|
+
calldatas = calldata.get("calldatas") or tx_src.get("calldatas")
|
|
148
|
+
if isinstance(calldatas, list) and len(calldatas) > 0:
|
|
149
|
+
data_val = calldatas[0]
|
|
150
|
+
# Use Router address when calldatas format is used (for calldata execution)
|
|
151
|
+
if not to_addr:
|
|
152
|
+
to_addr = BOROS_ROUTER
|
|
153
|
+
logger.debug(
|
|
154
|
+
f"Using Boros Router address for calldatas format: {to_addr}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if not isinstance(to_addr, str) or not to_addr:
|
|
158
|
+
raise ValueError("Boros calldata missing 'to' address")
|
|
159
|
+
|
|
160
|
+
if not data_val:
|
|
161
|
+
data_val = "0x"
|
|
162
|
+
if not isinstance(data_val, str):
|
|
163
|
+
raise ValueError("Boros calldata missing 'data' field")
|
|
164
|
+
|
|
165
|
+
chain_id_val = (
|
|
166
|
+
tx_src.get("chainId")
|
|
167
|
+
or tx_src.get("chain_id")
|
|
168
|
+
or calldata.get("chainId")
|
|
169
|
+
or calldata.get("chain_id")
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
chain_id_int = (
|
|
173
|
+
int(chain_id_val) if chain_id_val is not None else int(self.chain_id)
|
|
174
|
+
)
|
|
175
|
+
except (TypeError, ValueError):
|
|
176
|
+
chain_id_int = int(self.chain_id)
|
|
177
|
+
|
|
178
|
+
value_val = (
|
|
179
|
+
tx_src.get("value") if "value" in tx_src else calldata.get("value", 0)
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
value_int = int(value_val) if value_val is not None else 0
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
value_int = 0
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"chainId": int(chain_id_int),
|
|
188
|
+
"from": to_checksum_address(from_address),
|
|
189
|
+
"to": to_checksum_address(to_addr),
|
|
190
|
+
"data": data_val if data_val.startswith("0x") else f"0x{data_val}",
|
|
191
|
+
"value": int(value_int),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async def _broadcast_calldata(
|
|
195
|
+
self,
|
|
196
|
+
calldata: dict[str, Any],
|
|
197
|
+
*,
|
|
198
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
199
|
+
max_retries: int = 2,
|
|
200
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
201
|
+
"""Broadcast calldata from Boros API with retry logic.
|
|
202
|
+
|
|
203
|
+
Handles multiple formats:
|
|
204
|
+
- {"calldatas": ["0x...", "0x..."]} - execute each sequentially to Router
|
|
205
|
+
- {"data": "0x...", "to": "0x..."} - standard format
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
calldata: Transaction calldata from Boros API.
|
|
209
|
+
timeout: Transaction timeout in seconds.
|
|
210
|
+
max_retries: Number of retry attempts for failed transactions.
|
|
211
|
+
"""
|
|
212
|
+
if not self.sign_callback:
|
|
213
|
+
return False, {
|
|
214
|
+
"error": "sign_callback not configured",
|
|
215
|
+
"calldata": calldata,
|
|
216
|
+
}
|
|
217
|
+
if not self.user_address:
|
|
218
|
+
return False, {"error": "user_address not configured", "calldata": calldata}
|
|
219
|
+
|
|
220
|
+
# Check for calldatas array format (multiple transactions)
|
|
221
|
+
calldatas = calldata.get("calldatas")
|
|
222
|
+
if isinstance(calldatas, list) and len(calldatas) > 1:
|
|
223
|
+
# Execute each calldata sequentially (multi-tx response)
|
|
224
|
+
results = []
|
|
225
|
+
for i, data in enumerate(calldatas):
|
|
226
|
+
single_calldata = {"data": data, "to": BOROS_ROUTER}
|
|
227
|
+
tx = self._build_tx_from_calldata(
|
|
228
|
+
single_calldata, from_address=self.user_address
|
|
229
|
+
)
|
|
230
|
+
logger.debug(
|
|
231
|
+
f"Broadcasting calldata {i + 1}/{len(calldatas)} to {tx.get('to')}"
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
tx_hash = await send_transaction(
|
|
235
|
+
tx, self.sign_callback, wait_for_receipt=True
|
|
236
|
+
)
|
|
237
|
+
results.append({"ok": True, "res": {"tx_hash": tx_hash}})
|
|
238
|
+
except Exception as e:
|
|
239
|
+
results.append({"ok": False, "res": {"error": str(e)}})
|
|
240
|
+
return False, {
|
|
241
|
+
"status": "error",
|
|
242
|
+
"error": f"Failed on calldata {i + 1}/{len(calldatas)}: {e}",
|
|
243
|
+
"tx": {"error": str(e)},
|
|
244
|
+
"calldata": calldata,
|
|
245
|
+
"partial_results": results,
|
|
246
|
+
}
|
|
247
|
+
return True, {
|
|
248
|
+
"status": "ok",
|
|
249
|
+
"tx": results[-1]["res"],
|
|
250
|
+
"calldata": calldata,
|
|
251
|
+
"all_results": results,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Single calldata (standard format) with retry logic
|
|
255
|
+
last_error = None
|
|
256
|
+
for attempt in range(max_retries + 1):
|
|
257
|
+
tx = self._build_tx_from_calldata(calldata, from_address=self.user_address)
|
|
258
|
+
try:
|
|
259
|
+
tx_hash = await send_transaction(
|
|
260
|
+
tx, self.sign_callback, wait_for_receipt=True
|
|
261
|
+
)
|
|
262
|
+
return True, {
|
|
263
|
+
"status": "ok",
|
|
264
|
+
"tx": {"tx_hash": tx_hash},
|
|
265
|
+
"calldata": calldata,
|
|
266
|
+
}
|
|
267
|
+
except Exception as e:
|
|
268
|
+
last_error = str(e)
|
|
269
|
+
error_str = str(e).lower()
|
|
270
|
+
# Check if it's a revert (not worth retrying) vs transient error
|
|
271
|
+
if "revert" in error_str:
|
|
272
|
+
logger.warning(
|
|
273
|
+
f"Boros transaction reverted on attempt {attempt + 1}/{max_retries + 1}: {e}"
|
|
274
|
+
)
|
|
275
|
+
# For reverts, wait a bit and retry in case it's a timing issue
|
|
276
|
+
if attempt < max_retries:
|
|
277
|
+
await asyncio.sleep(2 * (attempt + 1))
|
|
278
|
+
continue
|
|
279
|
+
else:
|
|
280
|
+
# Non-revert error, log and retry
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"Boros transaction failed on attempt {attempt + 1}/{max_retries + 1}: {e}"
|
|
283
|
+
)
|
|
284
|
+
if attempt < max_retries:
|
|
285
|
+
await asyncio.sleep(1)
|
|
286
|
+
|
|
287
|
+
return False, {
|
|
288
|
+
"status": "error",
|
|
289
|
+
"error": str(last_error),
|
|
290
|
+
"tx": last_error,
|
|
291
|
+
"calldata": calldata,
|
|
292
|
+
"attempts": max_retries + 1,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async def connect(self) -> bool:
|
|
296
|
+
try:
|
|
297
|
+
markets = await self.boros_client.list_markets(limit=1)
|
|
298
|
+
return len(markets) > 0
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
logger.error(f"BorosAdapter connection failed: {exc}")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------ #
|
|
304
|
+
# Tick Math Utilities #
|
|
305
|
+
# ------------------------------------------------------------------ #
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def tick_from_rate(cls, rate: float, tick_step: int, *, round_down: bool) -> int:
|
|
309
|
+
"""Convert APR rate to Boros limitTick.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
rate: APR as decimal (e.g., 0.11 = 11%).
|
|
313
|
+
tick_step: Market's tickStep from metadata.
|
|
314
|
+
round_down: If True, round toward zero (for shorts).
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
limitTick value for Boros API.
|
|
318
|
+
"""
|
|
319
|
+
return _tick_from_rate(
|
|
320
|
+
rate,
|
|
321
|
+
tick_step,
|
|
322
|
+
round_down=round_down,
|
|
323
|
+
base=BOROS_TICK_BASE,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def rate_from_tick(cls, tick: int, tick_step: int) -> float:
|
|
328
|
+
"""Convert Boros limitTick to APR rate.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
tick: Boros tick value.
|
|
332
|
+
tick_step: Market's tickStep.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
APR as decimal (e.g., 0.11 = 11%).
|
|
336
|
+
"""
|
|
337
|
+
return _rate_from_tick(tick, tick_step, base=BOROS_TICK_BASE)
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def normalize_apr(value: Any) -> float | None:
|
|
341
|
+
"""Normalize various APR encodings to decimal.
|
|
342
|
+
|
|
343
|
+
Handles: decimal (0.1115), percent (11.15), bps (1115), 1e18-scaled.
|
|
344
|
+
"""
|
|
345
|
+
return _normalize_apr(value)
|
|
346
|
+
|
|
347
|
+
# ------------------------------------------------------------------ #
|
|
348
|
+
# Market Data #
|
|
349
|
+
# ------------------------------------------------------------------ #
|
|
350
|
+
|
|
351
|
+
async def list_markets(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
is_whitelisted: bool | None = True,
|
|
355
|
+
skip: int = 0,
|
|
356
|
+
limit: int = 100,
|
|
357
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
358
|
+
try:
|
|
359
|
+
markets = await self.boros_client.list_markets(
|
|
360
|
+
is_whitelisted=is_whitelisted, skip=skip, limit=limit
|
|
361
|
+
)
|
|
362
|
+
return True, markets
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error(f"Failed to list markets: {e}")
|
|
365
|
+
return False, str(e) # type: ignore
|
|
366
|
+
|
|
367
|
+
async def get_market(self, market_id: int) -> tuple[bool, dict[str, Any]]:
|
|
368
|
+
try:
|
|
369
|
+
market = await self.boros_client.get_market(market_id)
|
|
370
|
+
return True, market
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.error(f"Failed to get market {market_id}: {e}")
|
|
373
|
+
return False, str(e) # type: ignore
|
|
374
|
+
|
|
375
|
+
async def get_orderbook(
|
|
376
|
+
self, market_id: int, *, tick_size: float = 0.001
|
|
377
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
378
|
+
try:
|
|
379
|
+
book = await self.boros_client.get_order_book(
|
|
380
|
+
market_id, tick_size=tick_size
|
|
381
|
+
)
|
|
382
|
+
return True, book
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.error(f"Failed to get orderbook for market {market_id}: {e}")
|
|
385
|
+
return False, str(e) # type: ignore
|
|
386
|
+
|
|
387
|
+
async def quote_market(
|
|
388
|
+
self, market: dict[str, Any], *, tick_size: float = 0.001
|
|
389
|
+
) -> tuple[bool, BorosMarketQuote]:
|
|
390
|
+
try:
|
|
391
|
+
market_id = int(market.get("marketId") or market.get("id") or 0)
|
|
392
|
+
market_address = market.get("address") or market.get("marketAddress") or ""
|
|
393
|
+
if not market_id:
|
|
394
|
+
raise ValueError("Market missing marketId/id")
|
|
395
|
+
|
|
396
|
+
orderbook = await self.boros_client.get_order_book(
|
|
397
|
+
market_id, tick_size=tick_size
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
long_side = orderbook.get("long") or {}
|
|
401
|
+
short_side = orderbook.get("short") or {}
|
|
402
|
+
|
|
403
|
+
long_ticks = long_side.get("ia") or []
|
|
404
|
+
short_ticks = short_side.get("ia") or []
|
|
405
|
+
|
|
406
|
+
bid_apr: float | None = None
|
|
407
|
+
ask_apr: float | None = None
|
|
408
|
+
|
|
409
|
+
# Best bid = highest rate long side is willing to pay
|
|
410
|
+
if long_ticks:
|
|
411
|
+
best_bid_tick = max(long_ticks)
|
|
412
|
+
bid_apr = float(best_bid_tick) * tick_size
|
|
413
|
+
|
|
414
|
+
# Best ask = lowest rate short side willing to receive
|
|
415
|
+
if short_ticks:
|
|
416
|
+
best_ask_tick = min(short_ticks)
|
|
417
|
+
ask_apr = float(best_ask_tick) * tick_size
|
|
418
|
+
|
|
419
|
+
if bid_apr is not None and ask_apr is not None:
|
|
420
|
+
mid_apr = (bid_apr + ask_apr) / 2.0
|
|
421
|
+
else:
|
|
422
|
+
mid_apr = bid_apr if bid_apr is not None else ask_apr
|
|
423
|
+
|
|
424
|
+
maturity_ts = self._extract_maturity_ts(market)
|
|
425
|
+
tenor_days = (
|
|
426
|
+
self._time_to_maturity_days(maturity_ts) if maturity_ts else 0.0
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
quote = BorosMarketQuote(
|
|
430
|
+
market_id=market_id,
|
|
431
|
+
market_address=market_address,
|
|
432
|
+
symbol=self._extract_symbol(market),
|
|
433
|
+
underlying=self._extract_underlying(market),
|
|
434
|
+
tenor_days=tenor_days,
|
|
435
|
+
maturity_ts=maturity_ts or 0,
|
|
436
|
+
collateral_address=self._extract_collateral(market),
|
|
437
|
+
collateral_token_id=market.get("tokenId"),
|
|
438
|
+
tick_step=(market.get("imData") or {}).get("tickStep"),
|
|
439
|
+
mid_apr=mid_apr,
|
|
440
|
+
best_bid_apr=bid_apr,
|
|
441
|
+
best_ask_apr=ask_apr,
|
|
442
|
+
)
|
|
443
|
+
return True, quote
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(f"Failed to quote market: {e}")
|
|
446
|
+
return False, str(e) # type: ignore
|
|
447
|
+
|
|
448
|
+
async def quote_markets_for_underlying(
|
|
449
|
+
self,
|
|
450
|
+
underlying_symbol: str,
|
|
451
|
+
*,
|
|
452
|
+
platform: str | None = None,
|
|
453
|
+
max_markets: int = 50,
|
|
454
|
+
tick_size: float = 0.001,
|
|
455
|
+
) -> tuple[bool, list[BorosMarketQuote]]:
|
|
456
|
+
try:
|
|
457
|
+
markets = await self.boros_client.list_markets(
|
|
458
|
+
is_whitelisted=True, skip=0, limit=max_markets
|
|
459
|
+
)
|
|
460
|
+
target = underlying_symbol.upper()
|
|
461
|
+
platform_filter = platform.upper() if platform else None
|
|
462
|
+
|
|
463
|
+
def _matches(mkt: dict[str, Any]) -> bool:
|
|
464
|
+
under = self._extract_underlying(mkt).upper()
|
|
465
|
+
sym = self._extract_symbol(mkt).upper()
|
|
466
|
+
under_match = target == under
|
|
467
|
+
sym_parts = sym.replace("_", "-").split("-")
|
|
468
|
+
sym_match = target in sym_parts
|
|
469
|
+
if not under_match and not sym_match:
|
|
470
|
+
return False
|
|
471
|
+
if platform_filter:
|
|
472
|
+
metadata = mkt.get("metadata") or {}
|
|
473
|
+
plat = mkt.get("platform") or {}
|
|
474
|
+
plat_name = (
|
|
475
|
+
metadata.get("platformName") or plat.get("name") or ""
|
|
476
|
+
).upper()
|
|
477
|
+
if platform_filter not in plat_name and not sym.startswith(
|
|
478
|
+
platform_filter
|
|
479
|
+
):
|
|
480
|
+
return False
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
filtered = [m for m in markets if _matches(m)]
|
|
484
|
+
quotes: list[BorosMarketQuote] = []
|
|
485
|
+
|
|
486
|
+
for market in filtered:
|
|
487
|
+
try:
|
|
488
|
+
success, quote = await self.quote_market(
|
|
489
|
+
market, tick_size=tick_size
|
|
490
|
+
)
|
|
491
|
+
if success:
|
|
492
|
+
quotes.append(quote)
|
|
493
|
+
except Exception as e:
|
|
494
|
+
market_id = market.get("marketId") or market.get("id")
|
|
495
|
+
logger.warning(f"quote_market failed for {market_id}: {e}")
|
|
496
|
+
|
|
497
|
+
quotes.sort(key=lambda q: q.maturity_ts)
|
|
498
|
+
return True, quotes
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.error(f"Failed to quote markets for {underlying_symbol}: {e}")
|
|
501
|
+
return False, str(e) # type: ignore
|
|
502
|
+
|
|
503
|
+
# ------------------------------------------------------------------ #
|
|
504
|
+
# Account Data #
|
|
505
|
+
# ------------------------------------------------------------------ #
|
|
506
|
+
|
|
507
|
+
async def get_collaterals(
|
|
508
|
+
self, *, account_id: int | None = None
|
|
509
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
510
|
+
try:
|
|
511
|
+
data = await self.boros_client.get_collaterals(
|
|
512
|
+
user_address=self.user_address,
|
|
513
|
+
account_id=account_id,
|
|
514
|
+
)
|
|
515
|
+
return True, data
|
|
516
|
+
except Exception as e:
|
|
517
|
+
logger.error(f"Failed to get collaterals: {e}")
|
|
518
|
+
return False, str(e) # type: ignore
|
|
519
|
+
|
|
520
|
+
async def get_account_balances(
|
|
521
|
+
self, token_id: int = 3, *, account_id: int | None = None
|
|
522
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
523
|
+
result = {
|
|
524
|
+
"isolated": 0.0,
|
|
525
|
+
"cross": 0.0,
|
|
526
|
+
"total": 0.0,
|
|
527
|
+
"isolated_wei": 0,
|
|
528
|
+
"cross_wei": 0,
|
|
529
|
+
"isolated_market_id": None,
|
|
530
|
+
"isolated_positions": [],
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
success, summary = await self.get_collaterals(account_id=account_id)
|
|
535
|
+
if not success:
|
|
536
|
+
return False, str(summary) # type: ignore
|
|
537
|
+
|
|
538
|
+
collaterals = summary.get("collaterals", [])
|
|
539
|
+
for coll in collaterals:
|
|
540
|
+
if coll.get("tokenId") != token_id:
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
# Isolated positions
|
|
544
|
+
for iso in coll.get("isolatedPositions", []):
|
|
545
|
+
net_raw = iso.get("availableBalance") or iso.get("netBalance")
|
|
546
|
+
if net_raw:
|
|
547
|
+
try:
|
|
548
|
+
wei = int(net_raw)
|
|
549
|
+
result["isolated_wei"] += wei
|
|
550
|
+
result["isolated"] += cash_wei_to_float(net_raw)
|
|
551
|
+
# Extract market ID from marketAcc (last 6 hex chars = 3 bytes)
|
|
552
|
+
market_acc = iso.get("marketAcc", "")
|
|
553
|
+
market_id = market_id_from_market_acc(market_acc)
|
|
554
|
+
if market_id is not None:
|
|
555
|
+
result["isolated_market_id"] = market_id
|
|
556
|
+
result["isolated_positions"].append(
|
|
557
|
+
{
|
|
558
|
+
"market_id": market_id,
|
|
559
|
+
"balance": cash_wei_to_float(net_raw),
|
|
560
|
+
"balance_wei": wei,
|
|
561
|
+
"marketAcc": market_acc,
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
# Cross position
|
|
568
|
+
cross = coll.get("crossPosition", {})
|
|
569
|
+
cross_raw = cross.get("availableBalance") or cross.get("netBalance")
|
|
570
|
+
if cross_raw:
|
|
571
|
+
try:
|
|
572
|
+
wei = int(cross_raw)
|
|
573
|
+
result["cross_wei"] += wei
|
|
574
|
+
result["cross"] += cash_wei_to_float(cross_raw)
|
|
575
|
+
except Exception:
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
result["total"] = result["isolated"] + result["cross"]
|
|
579
|
+
result["raw"] = summary # Include raw data for marketAcc lookup
|
|
580
|
+
return True, result
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.error(f"Failed to get account balances: {e}")
|
|
583
|
+
return False, str(e) # type: ignore
|
|
584
|
+
|
|
585
|
+
async def get_active_positions(
|
|
586
|
+
self, market_id: int | None = None
|
|
587
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
588
|
+
try:
|
|
589
|
+
success, collaterals = await self.get_collaterals()
|
|
590
|
+
if not success:
|
|
591
|
+
return False, []
|
|
592
|
+
|
|
593
|
+
coll_list = collaterals.get("collaterals", [])
|
|
594
|
+
positions: list[dict[str, Any]] = []
|
|
595
|
+
|
|
596
|
+
for entry in coll_list:
|
|
597
|
+
token_id = entry.get("tokenId")
|
|
598
|
+
|
|
599
|
+
# Cross position
|
|
600
|
+
cross_pos = entry.get("crossPosition", {})
|
|
601
|
+
for mkt_pos in cross_pos.get("marketPositions", []):
|
|
602
|
+
pos = self._parse_market_position(mkt_pos, token_id, is_cross=True)
|
|
603
|
+
if pos:
|
|
604
|
+
positions.append(pos)
|
|
605
|
+
|
|
606
|
+
# Isolated positions
|
|
607
|
+
for iso_pos in entry.get("isolatedPositions", []):
|
|
608
|
+
for mkt_pos in iso_pos.get("marketPositions", []):
|
|
609
|
+
pos = self._parse_market_position(
|
|
610
|
+
mkt_pos, token_id, is_cross=False
|
|
611
|
+
)
|
|
612
|
+
if pos:
|
|
613
|
+
positions.append(pos)
|
|
614
|
+
|
|
615
|
+
if market_id is not None:
|
|
616
|
+
positions = [p for p in positions if p.get("marketId") == market_id]
|
|
617
|
+
|
|
618
|
+
return True, positions
|
|
619
|
+
except Exception as e:
|
|
620
|
+
logger.error(f"Failed to get active positions: {e}")
|
|
621
|
+
return False, str(e) # type: ignore
|
|
622
|
+
|
|
623
|
+
async def get_open_limit_orders(
|
|
624
|
+
self, *, limit: int = 50
|
|
625
|
+
) -> tuple[bool, list[BorosLimitOrder]]:
|
|
626
|
+
try:
|
|
627
|
+
orders_raw = await self.boros_client.get_open_orders(
|
|
628
|
+
user_address=self.user_address, limit=limit
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
orders: list[BorosLimitOrder] = []
|
|
632
|
+
for o in orders_raw:
|
|
633
|
+
try:
|
|
634
|
+
tick = int(o.get("limitTick") or 0)
|
|
635
|
+
tick_step = int(o.get("tickStep") or 1)
|
|
636
|
+
apr = self.rate_from_tick(tick, tick_step)
|
|
637
|
+
|
|
638
|
+
size = float(o.get("size") or 0) / 1e18
|
|
639
|
+
filled = float(o.get("filledSize") or 0) / 1e18
|
|
640
|
+
remaining = size - filled
|
|
641
|
+
|
|
642
|
+
orders.append(
|
|
643
|
+
BorosLimitOrder(
|
|
644
|
+
order_id=str(o.get("orderId") or o.get("id") or ""),
|
|
645
|
+
market_id=int(o.get("marketId") or 0),
|
|
646
|
+
side="long" if int(o.get("side") or 0) == 0 else "short",
|
|
647
|
+
size=size,
|
|
648
|
+
limit_tick=tick,
|
|
649
|
+
limit_apr=apr,
|
|
650
|
+
filled_size=filled,
|
|
651
|
+
remaining_size=remaining,
|
|
652
|
+
status=o.get("status") or "open",
|
|
653
|
+
raw=o,
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.warning(f"Failed to parse order: {e}")
|
|
658
|
+
|
|
659
|
+
return True, orders
|
|
660
|
+
except Exception as e:
|
|
661
|
+
logger.error(f"Failed to get open orders: {e}")
|
|
662
|
+
return False, str(e) # type: ignore
|
|
663
|
+
|
|
664
|
+
async def get_full_user_state(
|
|
665
|
+
self,
|
|
666
|
+
*,
|
|
667
|
+
account: str | None = None,
|
|
668
|
+
account_id: int | None = None,
|
|
669
|
+
token_id: int = 3,
|
|
670
|
+
token_decimals: int = 6,
|
|
671
|
+
open_orders_limit: int = 50,
|
|
672
|
+
include_open_orders: bool = True,
|
|
673
|
+
include_withdrawal_status: bool = True,
|
|
674
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
675
|
+
"""
|
|
676
|
+
Full Boros user state snapshot.
|
|
677
|
+
|
|
678
|
+
Pulls:
|
|
679
|
+
- Collaterals summary (raw)
|
|
680
|
+
- Parsed positions (cross + isolated)
|
|
681
|
+
- Cash balances for token_id (cross/isolated/total)
|
|
682
|
+
- Open limit orders (optional)
|
|
683
|
+
- Withdrawal status (optional)
|
|
684
|
+
"""
|
|
685
|
+
addr = account or self.user_address
|
|
686
|
+
if not addr:
|
|
687
|
+
return False, "user_address not configured"
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
collaterals = await self.boros_client.get_collaterals(
|
|
691
|
+
user_address=addr,
|
|
692
|
+
account_id=int(
|
|
693
|
+
account_id if account_id is not None else self.account_id
|
|
694
|
+
),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
coll_list = collaterals.get("collaterals", [])
|
|
698
|
+
|
|
699
|
+
# Positions (cross + isolated)
|
|
700
|
+
positions: list[dict[str, Any]] = []
|
|
701
|
+
for entry in coll_list:
|
|
702
|
+
tid = entry.get("tokenId")
|
|
703
|
+
|
|
704
|
+
cross_pos = entry.get("crossPosition", {})
|
|
705
|
+
for mkt_pos in cross_pos.get("marketPositions", []):
|
|
706
|
+
pos = self._parse_market_position(mkt_pos, tid, is_cross=True)
|
|
707
|
+
if pos:
|
|
708
|
+
positions.append(pos)
|
|
709
|
+
|
|
710
|
+
for iso_pos in entry.get("isolatedPositions", []):
|
|
711
|
+
for mkt_pos in iso_pos.get("marketPositions", []):
|
|
712
|
+
pos = self._parse_market_position(mkt_pos, tid, is_cross=False)
|
|
713
|
+
if pos:
|
|
714
|
+
positions.append(pos)
|
|
715
|
+
|
|
716
|
+
# Cash balances (token_id only)
|
|
717
|
+
balances: dict[str, Any] = {
|
|
718
|
+
"token_id": int(token_id),
|
|
719
|
+
"isolated": 0.0,
|
|
720
|
+
"cross": 0.0,
|
|
721
|
+
"total": 0.0,
|
|
722
|
+
"isolated_wei": 0,
|
|
723
|
+
"cross_wei": 0,
|
|
724
|
+
"isolated_market_id": None,
|
|
725
|
+
"isolated_positions": [],
|
|
726
|
+
}
|
|
727
|
+
for coll in coll_list:
|
|
728
|
+
if coll.get("tokenId") != int(token_id):
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
for iso in coll.get("isolatedPositions", []):
|
|
732
|
+
net_raw = iso.get("availableBalance") or iso.get("netBalance")
|
|
733
|
+
if net_raw:
|
|
734
|
+
try:
|
|
735
|
+
wei = int(net_raw)
|
|
736
|
+
balances["isolated_wei"] += wei
|
|
737
|
+
balances["isolated"] += cash_wei_to_float(net_raw)
|
|
738
|
+
market_acc = iso.get("marketAcc", "")
|
|
739
|
+
market_id = market_id_from_market_acc(market_acc)
|
|
740
|
+
if market_id is not None:
|
|
741
|
+
balances["isolated_market_id"] = market_id
|
|
742
|
+
balances["isolated_positions"].append(
|
|
743
|
+
{
|
|
744
|
+
"market_id": market_id,
|
|
745
|
+
"balance": cash_wei_to_float(net_raw),
|
|
746
|
+
"balance_wei": wei,
|
|
747
|
+
"marketAcc": market_acc,
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
|
|
753
|
+
cross = coll.get("crossPosition", {})
|
|
754
|
+
cross_raw = cross.get("availableBalance") or cross.get("netBalance")
|
|
755
|
+
if cross_raw:
|
|
756
|
+
try:
|
|
757
|
+
wei = int(cross_raw)
|
|
758
|
+
balances["cross_wei"] += wei
|
|
759
|
+
balances["cross"] += cash_wei_to_float(cross_raw)
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
balances["total"] = balances["isolated"] + balances["cross"]
|
|
764
|
+
|
|
765
|
+
# Orders
|
|
766
|
+
orders: list[dict[str, Any]] | None = None
|
|
767
|
+
if include_open_orders:
|
|
768
|
+
try:
|
|
769
|
+
orders_raw = await self.boros_client.get_open_orders(
|
|
770
|
+
user_address=addr, limit=int(open_orders_limit)
|
|
771
|
+
)
|
|
772
|
+
parsed: list[dict[str, Any]] = []
|
|
773
|
+
for o in orders_raw:
|
|
774
|
+
try:
|
|
775
|
+
tick = int(o.get("limitTick") or 0)
|
|
776
|
+
tick_step = int(o.get("tickStep") or 1)
|
|
777
|
+
apr = self.rate_from_tick(tick, tick_step)
|
|
778
|
+
|
|
779
|
+
size = float(o.get("size") or 0) / 1e18
|
|
780
|
+
filled = float(o.get("filledSize") or 0) / 1e18
|
|
781
|
+
remaining = size - filled
|
|
782
|
+
|
|
783
|
+
parsed.append(
|
|
784
|
+
{
|
|
785
|
+
"order_id": str(
|
|
786
|
+
o.get("orderId") or o.get("id") or ""
|
|
787
|
+
),
|
|
788
|
+
"market_id": int(o.get("marketId") or 0),
|
|
789
|
+
"side": "long"
|
|
790
|
+
if int(o.get("side") or 0) == 0
|
|
791
|
+
else "short",
|
|
792
|
+
"size": size,
|
|
793
|
+
"limit_tick": tick,
|
|
794
|
+
"limit_apr": apr,
|
|
795
|
+
"filled_size": filled,
|
|
796
|
+
"remaining_size": remaining,
|
|
797
|
+
"status": o.get("status") or "open",
|
|
798
|
+
"raw": o,
|
|
799
|
+
}
|
|
800
|
+
)
|
|
801
|
+
except Exception as exc:
|
|
802
|
+
logger.warning(f"Failed to parse order: {exc}")
|
|
803
|
+
orders = parsed
|
|
804
|
+
except Exception as exc: # noqa: BLE001
|
|
805
|
+
logger.warning(f"Failed to fetch open orders: {exc}")
|
|
806
|
+
orders = None
|
|
807
|
+
|
|
808
|
+
# Withdrawal status
|
|
809
|
+
withdrawal_status: dict[str, Any] | None = None
|
|
810
|
+
if include_withdrawal_status:
|
|
811
|
+
cooldown_seconds: int | None = None
|
|
812
|
+
cooldown_source = "unknown"
|
|
813
|
+
try:
|
|
814
|
+
async with web3_from_chain_id(self.chain_id) as web3:
|
|
815
|
+
market_hub = web3.eth.contract(
|
|
816
|
+
address=to_checksum_address(BOROS_MARKET_HUB),
|
|
817
|
+
abi=self.BOROS_MARKET_HUB_VIEW_ABI,
|
|
818
|
+
)
|
|
819
|
+
cooldown_seconds = int(
|
|
820
|
+
await market_hub.functions.getPersonalCooldown(
|
|
821
|
+
to_checksum_address(addr)
|
|
822
|
+
).call()
|
|
823
|
+
)
|
|
824
|
+
cooldown_source = "onchain"
|
|
825
|
+
except Exception as exc: # noqa: BLE001
|
|
826
|
+
logger.warning(f"Failed to read Boros personal cooldown: {exc}")
|
|
827
|
+
|
|
828
|
+
for coll in coll_list:
|
|
829
|
+
if coll.get("tokenId") != int(token_id):
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
withdrawal = coll.get("withdrawal", {})
|
|
833
|
+
request_time = int(withdrawal.get("lastWithdrawalRequestTime", 0))
|
|
834
|
+
raw_amount = int(withdrawal.get("lastWithdrawalAmount", 0))
|
|
835
|
+
amount = (
|
|
836
|
+
raw_amount / (10 ** int(token_decimals)) if raw_amount else 0.0
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
current_time = int(time.time())
|
|
840
|
+
elapsed = current_time - request_time if request_time > 0 else 0
|
|
841
|
+
if cooldown_seconds is None:
|
|
842
|
+
cooldown_seconds = 3600
|
|
843
|
+
cooldown_source = "default_3600s"
|
|
844
|
+
|
|
845
|
+
withdrawal_status = {
|
|
846
|
+
"amount": amount,
|
|
847
|
+
"request_time": request_time,
|
|
848
|
+
"elapsed_seconds": elapsed,
|
|
849
|
+
"cooldown_seconds": cooldown_seconds,
|
|
850
|
+
"cooldown_source": cooldown_source,
|
|
851
|
+
"can_finalize": elapsed >= cooldown_seconds
|
|
852
|
+
if request_time > 0 and cooldown_seconds is not None
|
|
853
|
+
else False,
|
|
854
|
+
"wait_seconds": max(0, cooldown_seconds - elapsed)
|
|
855
|
+
if request_time > 0 and cooldown_seconds is not None
|
|
856
|
+
else None,
|
|
857
|
+
}
|
|
858
|
+
break
|
|
859
|
+
|
|
860
|
+
if withdrawal_status is None:
|
|
861
|
+
withdrawal_status = {
|
|
862
|
+
"amount": 0,
|
|
863
|
+
"request_time": 0,
|
|
864
|
+
"can_finalize": False,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return (
|
|
868
|
+
True,
|
|
869
|
+
{
|
|
870
|
+
"protocol": "boros",
|
|
871
|
+
"chainId": int(self.chain_id),
|
|
872
|
+
"account": addr,
|
|
873
|
+
"collaterals": collaterals,
|
|
874
|
+
"balances": balances,
|
|
875
|
+
"positions": positions,
|
|
876
|
+
"openOrders": orders,
|
|
877
|
+
"withdrawal": withdrawal_status,
|
|
878
|
+
},
|
|
879
|
+
)
|
|
880
|
+
except Exception as exc: # noqa: BLE001
|
|
881
|
+
return False, str(exc)
|
|
882
|
+
|
|
883
|
+
async def get_pending_withdrawal_amount(
|
|
884
|
+
self, token_id: int = 3, *, token_decimals: int = 6
|
|
885
|
+
) -> tuple[bool, float]:
|
|
886
|
+
try:
|
|
887
|
+
success, collaterals = await self.get_collaterals()
|
|
888
|
+
if not success:
|
|
889
|
+
return False, 0.0
|
|
890
|
+
|
|
891
|
+
amount = self.parse_pending_withdrawal_amount(
|
|
892
|
+
collaterals, token_id=token_id, token_decimals=token_decimals
|
|
893
|
+
)
|
|
894
|
+
return True, amount
|
|
895
|
+
except Exception as e:
|
|
896
|
+
logger.error(f"Failed to get pending withdrawal amount: {e}")
|
|
897
|
+
return False, 0.0
|
|
898
|
+
|
|
899
|
+
@staticmethod
|
|
900
|
+
def parse_pending_withdrawal_amount(
|
|
901
|
+
collaterals_data: dict[str, Any],
|
|
902
|
+
*,
|
|
903
|
+
token_id: int,
|
|
904
|
+
token_decimals: int = 6,
|
|
905
|
+
) -> float:
|
|
906
|
+
"""Parse pending withdrawal amount from collaterals response.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
collaterals_data: Response from get_collaterals().
|
|
910
|
+
token_id: Boros token ID to look for.
|
|
911
|
+
token_decimals: Token decimals for conversion.
|
|
912
|
+
|
|
913
|
+
Returns:
|
|
914
|
+
Pending withdrawal amount in token units (native decimals).
|
|
915
|
+
"""
|
|
916
|
+
try:
|
|
917
|
+
coll_list = collaterals_data.get("collaterals", [])
|
|
918
|
+
for coll in coll_list:
|
|
919
|
+
if coll.get("tokenId") != token_id:
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# Check withdrawal field (native decimals)
|
|
923
|
+
withdrawal = coll.get("withdrawal", {})
|
|
924
|
+
raw = withdrawal.get("lastWithdrawalAmount", "0")
|
|
925
|
+
if raw and int(raw) > 0:
|
|
926
|
+
return float(raw) / (10**token_decimals)
|
|
927
|
+
|
|
928
|
+
return 0.0
|
|
929
|
+
except Exception as e:
|
|
930
|
+
logger.warning(f"Failed to parse pending withdrawal: {e}")
|
|
931
|
+
return 0.0
|
|
932
|
+
|
|
933
|
+
async def get_withdrawal_status(
|
|
934
|
+
self, token_id: int = 3, *, token_decimals: int = 6
|
|
935
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
936
|
+
"""Get withdrawal status including timing info.
|
|
937
|
+
|
|
938
|
+
Boros withdrawals can have a user-specific cooldown. Prefer on-chain
|
|
939
|
+
cooldown reads when connected to chain, and treat any fallback
|
|
940
|
+
estimate as advisory only.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
token_id: Boros token ID (default 3 = USDT).
|
|
944
|
+
token_decimals: Token decimals for conversion.
|
|
945
|
+
|
|
946
|
+
Returns:
|
|
947
|
+
Tuple of (success, status dict with 'amount', 'request_time', 'can_finalize').
|
|
948
|
+
"""
|
|
949
|
+
try:
|
|
950
|
+
success, collaterals = await self.get_collaterals()
|
|
951
|
+
if not success:
|
|
952
|
+
return False, {"error": "Failed to get collaterals"}
|
|
953
|
+
|
|
954
|
+
cooldown_seconds: int | None = None
|
|
955
|
+
cooldown_source = "unknown"
|
|
956
|
+
if self.user_address:
|
|
957
|
+
try:
|
|
958
|
+
async with web3_from_chain_id(self.chain_id) as web3:
|
|
959
|
+
market_hub = web3.eth.contract(
|
|
960
|
+
address=to_checksum_address(BOROS_MARKET_HUB),
|
|
961
|
+
abi=self.BOROS_MARKET_HUB_VIEW_ABI,
|
|
962
|
+
)
|
|
963
|
+
cooldown_seconds = int(
|
|
964
|
+
await market_hub.functions.getPersonalCooldown(
|
|
965
|
+
to_checksum_address(self.user_address)
|
|
966
|
+
).call()
|
|
967
|
+
)
|
|
968
|
+
cooldown_source = "onchain"
|
|
969
|
+
except Exception as exc:
|
|
970
|
+
logger.warning(f"Failed to read Boros personal cooldown: {exc}")
|
|
971
|
+
|
|
972
|
+
for coll in collaterals.get("collaterals", []):
|
|
973
|
+
if coll.get("tokenId") != token_id:
|
|
974
|
+
continue
|
|
975
|
+
|
|
976
|
+
withdrawal = coll.get("withdrawal", {})
|
|
977
|
+
request_time = int(withdrawal.get("lastWithdrawalRequestTime", 0))
|
|
978
|
+
raw_amount = int(withdrawal.get("lastWithdrawalAmount", 0))
|
|
979
|
+
amount = raw_amount / (10**token_decimals) if raw_amount else 0.0
|
|
980
|
+
|
|
981
|
+
current_time = int(time.time())
|
|
982
|
+
elapsed = current_time - request_time if request_time > 0 else 0
|
|
983
|
+
if cooldown_seconds is None:
|
|
984
|
+
cooldown_seconds = 3600
|
|
985
|
+
cooldown_source = "default_3600s"
|
|
986
|
+
|
|
987
|
+
return True, {
|
|
988
|
+
"amount": amount,
|
|
989
|
+
"request_time": request_time,
|
|
990
|
+
"elapsed_seconds": elapsed,
|
|
991
|
+
"cooldown_seconds": cooldown_seconds,
|
|
992
|
+
"cooldown_source": cooldown_source,
|
|
993
|
+
"can_finalize": elapsed >= cooldown_seconds
|
|
994
|
+
if request_time > 0 and cooldown_seconds is not None
|
|
995
|
+
else False,
|
|
996
|
+
"wait_seconds": max(0, cooldown_seconds - elapsed)
|
|
997
|
+
if request_time > 0 and cooldown_seconds is not None
|
|
998
|
+
else None,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return True, {"amount": 0, "request_time": 0, "can_finalize": False}
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
logger.error(f"Failed to get withdrawal status: {e}")
|
|
1004
|
+
return False, {"error": str(e)}
|
|
1005
|
+
|
|
1006
|
+
# ------------------------------------------------------------------ #
|
|
1007
|
+
# Execution Methods #
|
|
1008
|
+
# ------------------------------------------------------------------ #
|
|
1009
|
+
|
|
1010
|
+
async def deposit_to_cross_margin(
|
|
1011
|
+
self,
|
|
1012
|
+
collateral_address: str,
|
|
1013
|
+
amount_wei: int,
|
|
1014
|
+
*,
|
|
1015
|
+
token_id: int,
|
|
1016
|
+
market_id: int,
|
|
1017
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1018
|
+
if self.simulation:
|
|
1019
|
+
logger.info(
|
|
1020
|
+
f"[SIMULATION] deposit_to_cross_margin: {amount_wei} wei, "
|
|
1021
|
+
f"token_id={token_id}, market_id={market_id}"
|
|
1022
|
+
)
|
|
1023
|
+
return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
|
|
1024
|
+
|
|
1025
|
+
try:
|
|
1026
|
+
calldata = await self.boros_client.build_deposit_calldata(
|
|
1027
|
+
token_id=token_id,
|
|
1028
|
+
amount_wei=amount_wei,
|
|
1029
|
+
market_id=market_id,
|
|
1030
|
+
user_address=self.user_address,
|
|
1031
|
+
account_id=0, # Cross margin
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
if not self.sign_callback or not self.user_address:
|
|
1035
|
+
return False, {
|
|
1036
|
+
"error": "sign_callback or user_address not configured",
|
|
1037
|
+
"calldata": calldata,
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
# Approve Boros to pull collateral for deposit.
|
|
1041
|
+
tx_src = self._unwrap_tx_payload(calldata)
|
|
1042
|
+
spender = tx_src.get("to") or calldata.get("to")
|
|
1043
|
+
if not isinstance(spender, str) or not spender:
|
|
1044
|
+
return False, {
|
|
1045
|
+
"error": "Deposit calldata missing spender address",
|
|
1046
|
+
"calldata": calldata,
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
approve_tx = await build_approve_transaction(
|
|
1051
|
+
from_address=to_checksum_address(self.user_address),
|
|
1052
|
+
chain_id=int(self.chain_id),
|
|
1053
|
+
token_address=to_checksum_address(collateral_address),
|
|
1054
|
+
spender_address=to_checksum_address(spender),
|
|
1055
|
+
amount=int(amount_wei),
|
|
1056
|
+
)
|
|
1057
|
+
approve_hash = await send_transaction(
|
|
1058
|
+
approve_tx, self.sign_callback, wait_for_receipt=True
|
|
1059
|
+
)
|
|
1060
|
+
approve_res = {"tx_hash": approve_hash}
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
return False, {
|
|
1063
|
+
"error": f"ERC20 approval failed: {e}",
|
|
1064
|
+
"approve": {"error": str(e)},
|
|
1065
|
+
"calldata": calldata,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1069
|
+
if not tx_ok:
|
|
1070
|
+
return False, {
|
|
1071
|
+
"error": f"Deposit transaction failed: {tx_res.get('error') or tx_res}",
|
|
1072
|
+
"approve": approve_res,
|
|
1073
|
+
"calldata": calldata,
|
|
1074
|
+
"tx": tx_res,
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return True, {"status": "ok", "approve": approve_res, "tx": tx_res}
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
logger.error(f"Failed to deposit to cross margin: {e}")
|
|
1080
|
+
return False, {"error": str(e)}
|
|
1081
|
+
|
|
1082
|
+
async def withdraw_collateral(
|
|
1083
|
+
self,
|
|
1084
|
+
*,
|
|
1085
|
+
token_id: int,
|
|
1086
|
+
amount_native: int | None = None,
|
|
1087
|
+
amount_wei: int | None = None,
|
|
1088
|
+
account_id: int | None = None,
|
|
1089
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1090
|
+
"""Withdraw collateral from Boros account.
|
|
1091
|
+
|
|
1092
|
+
IMPORTANT: The amount must be in NATIVE token decimals, not 1e18!
|
|
1093
|
+
- For USDT (token_id=3): 6 decimals, so 1 USDT = 1_000_000
|
|
1094
|
+
- For other tokens: check their native decimals
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
token_id: Boros token ID.
|
|
1098
|
+
amount_native: Amount in native token decimals (e.g., 6 decimals for USDT).
|
|
1099
|
+
amount_wei: Backwards-compatible alias for amount_native (Boros APIs use
|
|
1100
|
+
"wei" naming even when values are native decimals).
|
|
1101
|
+
account_id: Account ID.
|
|
1102
|
+
|
|
1103
|
+
Returns:
|
|
1104
|
+
Tuple of (success, transaction result).
|
|
1105
|
+
"""
|
|
1106
|
+
# Backwards-compat: older callers/tests used amount_wei even though this is
|
|
1107
|
+
# native token decimals. Prefer amount_native going forward.
|
|
1108
|
+
if amount_native is None:
|
|
1109
|
+
if amount_wei is None:
|
|
1110
|
+
raise TypeError(
|
|
1111
|
+
"withdraw_collateral requires amount_native (or amount_wei)"
|
|
1112
|
+
)
|
|
1113
|
+
amount_native = int(amount_wei)
|
|
1114
|
+
|
|
1115
|
+
if self.simulation:
|
|
1116
|
+
logger.info(
|
|
1117
|
+
f"[SIMULATION] withdraw_collateral: {amount_native} native units, token_id={token_id}"
|
|
1118
|
+
)
|
|
1119
|
+
return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
|
|
1120
|
+
|
|
1121
|
+
try:
|
|
1122
|
+
calldata = await self.boros_client.build_withdraw_calldata(
|
|
1123
|
+
token_id=token_id,
|
|
1124
|
+
amount_wei=amount_native, # API expects native decimals despite param name
|
|
1125
|
+
user_address=self.user_address,
|
|
1126
|
+
account_id=account_id,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1130
|
+
if not tx_ok:
|
|
1131
|
+
return False, tx_res
|
|
1132
|
+
return True, tx_res
|
|
1133
|
+
except Exception as e:
|
|
1134
|
+
logger.error(f"Failed to withdraw collateral: {e}")
|
|
1135
|
+
return False, {"error": str(e)}
|
|
1136
|
+
|
|
1137
|
+
async def cash_transfer(
|
|
1138
|
+
self,
|
|
1139
|
+
*,
|
|
1140
|
+
market_id: int,
|
|
1141
|
+
amount_wei: int,
|
|
1142
|
+
is_deposit: bool = False,
|
|
1143
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1144
|
+
"""Transfer cash between isolated and cross margin accounts.
|
|
1145
|
+
|
|
1146
|
+
Semantics:
|
|
1147
|
+
- is_deposit=True: cross -> isolated
|
|
1148
|
+
- is_deposit=False: isolated -> cross
|
|
1149
|
+
|
|
1150
|
+
Notes:
|
|
1151
|
+
- Boros uses 1e18 internal cash units for this call.
|
|
1152
|
+
"""
|
|
1153
|
+
if self.simulation:
|
|
1154
|
+
logger.info(
|
|
1155
|
+
f"[SIMULATION] cash_transfer: market={market_id}, amount={amount_wei}, is_deposit={is_deposit}"
|
|
1156
|
+
)
|
|
1157
|
+
return True, {"status": "simulated"}
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
calldata = await self.boros_client.build_cash_transfer_calldata(
|
|
1161
|
+
market_id=market_id,
|
|
1162
|
+
amount_wei=amount_wei,
|
|
1163
|
+
is_deposit=is_deposit,
|
|
1164
|
+
)
|
|
1165
|
+
logger.debug(f"Boros cash_transfer calldata response: {calldata}")
|
|
1166
|
+
|
|
1167
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1168
|
+
logger.debug(f"Boros cash_transfer tx result: ok={tx_ok}, res={tx_res}")
|
|
1169
|
+
if not tx_ok:
|
|
1170
|
+
return False, tx_res
|
|
1171
|
+
logger.info("Boros cash_transfer succeeded (isolated -> cross)")
|
|
1172
|
+
return True, tx_res
|
|
1173
|
+
except Exception as e:
|
|
1174
|
+
logger.error(f"Failed to cash transfer: {e}")
|
|
1175
|
+
return False, {"error": str(e)}
|
|
1176
|
+
|
|
1177
|
+
async def place_rate_order(
|
|
1178
|
+
self,
|
|
1179
|
+
*,
|
|
1180
|
+
market_id: int,
|
|
1181
|
+
token_id: int,
|
|
1182
|
+
size_yu_wei: int,
|
|
1183
|
+
side: str,
|
|
1184
|
+
limit_tick: int | None = None,
|
|
1185
|
+
tif: str = "GTC",
|
|
1186
|
+
slippage: float = 0.05,
|
|
1187
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1188
|
+
if self.simulation:
|
|
1189
|
+
logger.info(
|
|
1190
|
+
f"[SIMULATION] place_rate_order: market={market_id}, "
|
|
1191
|
+
f"side={side}, size={size_yu_wei}"
|
|
1192
|
+
)
|
|
1193
|
+
return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
|
|
1194
|
+
|
|
1195
|
+
try:
|
|
1196
|
+
market_acc = await self._get_market_acc(token_id=token_id)
|
|
1197
|
+
|
|
1198
|
+
if limit_tick is None:
|
|
1199
|
+
limit_tick = await self._pick_limit_tick_for_fill(
|
|
1200
|
+
market_id=market_id, side=side, size_yu_wei=size_yu_wei
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
side_int = 0 if side.lower() in ("long", "buy") else 1
|
|
1204
|
+
tif_int = {"GTC": 0, "IOC": 1, "FOK": 2}.get(tif.upper(), 0)
|
|
1205
|
+
|
|
1206
|
+
calldata = await self.boros_client.build_place_order_calldata(
|
|
1207
|
+
market_acc=market_acc,
|
|
1208
|
+
market_id=market_id,
|
|
1209
|
+
side=side_int,
|
|
1210
|
+
size_wei=size_yu_wei,
|
|
1211
|
+
limit_tick=limit_tick,
|
|
1212
|
+
tif=tif_int,
|
|
1213
|
+
slippage=slippage,
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
if not self.sign_callback:
|
|
1217
|
+
return False, {
|
|
1218
|
+
"error": "sign_callback not configured",
|
|
1219
|
+
"calldata": calldata,
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1223
|
+
if not tx_ok:
|
|
1224
|
+
return False, tx_res
|
|
1225
|
+
return True, tx_res
|
|
1226
|
+
except Exception as e:
|
|
1227
|
+
logger.error(f"Failed to place rate order: {e}")
|
|
1228
|
+
return False, {"error": str(e)}
|
|
1229
|
+
|
|
1230
|
+
async def close_positions_market(
|
|
1231
|
+
self,
|
|
1232
|
+
market_id: int,
|
|
1233
|
+
*,
|
|
1234
|
+
token_id: int = 3,
|
|
1235
|
+
size_yu_wei: int | None = None,
|
|
1236
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1237
|
+
if self.simulation:
|
|
1238
|
+
logger.info(f"[SIMULATION] close_positions_market: market={market_id}")
|
|
1239
|
+
return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
|
|
1240
|
+
|
|
1241
|
+
try:
|
|
1242
|
+
success, positions = await self.get_active_positions(market_id=market_id)
|
|
1243
|
+
if not success or not positions:
|
|
1244
|
+
return True, {"status": "no_position"}
|
|
1245
|
+
|
|
1246
|
+
position = positions[0]
|
|
1247
|
+
pos_side = int(position.get("side", 0))
|
|
1248
|
+
pos_size_wei = int(position.get("sizeWei") or 0)
|
|
1249
|
+
|
|
1250
|
+
if pos_size_wei == 0:
|
|
1251
|
+
return True, {"status": "zero_size"}
|
|
1252
|
+
|
|
1253
|
+
close_size = (
|
|
1254
|
+
abs(int(size_yu_wei)) if size_yu_wei is not None else abs(pos_size_wei)
|
|
1255
|
+
)
|
|
1256
|
+
close_side = 1 if pos_side == 0 else 0
|
|
1257
|
+
close_side_str = "short" if close_side == 1 else "long"
|
|
1258
|
+
|
|
1259
|
+
market_acc = await self._get_market_acc(token_id=token_id)
|
|
1260
|
+
limit_tick = await self._pick_limit_tick_for_fill(
|
|
1261
|
+
market_id=market_id,
|
|
1262
|
+
side=close_side_str,
|
|
1263
|
+
size_yu_wei=close_size,
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
calldata = await self.boros_client.build_close_position_calldata(
|
|
1267
|
+
market_acc=market_acc,
|
|
1268
|
+
market_id=market_id,
|
|
1269
|
+
side=close_side,
|
|
1270
|
+
size_wei=close_size,
|
|
1271
|
+
limit_tick=limit_tick,
|
|
1272
|
+
tif=1, # IOC
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
if not self.sign_callback:
|
|
1276
|
+
return False, {
|
|
1277
|
+
"error": "sign_callback not configured",
|
|
1278
|
+
"calldata": calldata,
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1282
|
+
if not tx_ok:
|
|
1283
|
+
return False, tx_res
|
|
1284
|
+
return True, tx_res
|
|
1285
|
+
except Exception as e:
|
|
1286
|
+
logger.error(f"Failed to close positions: {e}")
|
|
1287
|
+
return False, {"error": str(e)}
|
|
1288
|
+
|
|
1289
|
+
async def cancel_orders(
|
|
1290
|
+
self,
|
|
1291
|
+
*,
|
|
1292
|
+
market_id: int,
|
|
1293
|
+
token_id: int = 3,
|
|
1294
|
+
order_ids: list[str] | None = None,
|
|
1295
|
+
cancel_all: bool = False,
|
|
1296
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1297
|
+
if self.simulation:
|
|
1298
|
+
logger.info(
|
|
1299
|
+
f"[SIMULATION] cancel_orders: market={market_id}, cancel_all={cancel_all}"
|
|
1300
|
+
)
|
|
1301
|
+
return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
|
|
1302
|
+
|
|
1303
|
+
try:
|
|
1304
|
+
market_acc = await self._get_market_acc(token_id=token_id)
|
|
1305
|
+
|
|
1306
|
+
calldata = await self.boros_client.build_cancel_order_calldata(
|
|
1307
|
+
market_acc=market_acc,
|
|
1308
|
+
market_id=market_id,
|
|
1309
|
+
order_ids=order_ids,
|
|
1310
|
+
cancel_all=cancel_all,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
if not self.sign_callback:
|
|
1314
|
+
return False, {
|
|
1315
|
+
"error": "sign_callback not configured",
|
|
1316
|
+
"calldata": calldata,
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
tx_ok, tx_res = await self._broadcast_calldata(calldata)
|
|
1320
|
+
if not tx_ok:
|
|
1321
|
+
return False, tx_res
|
|
1322
|
+
return True, tx_res
|
|
1323
|
+
except Exception as e:
|
|
1324
|
+
logger.error(f"Failed to cancel orders: {e}")
|
|
1325
|
+
return False, {"error": str(e)}
|
|
1326
|
+
|
|
1327
|
+
async def finalize_vault_withdrawal(
|
|
1328
|
+
self, *, token_id: int, root_address: str | None = None
|
|
1329
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1330
|
+
"""Finalize a previously requested MarketHub withdrawal.
|
|
1331
|
+
|
|
1332
|
+
This transfers collateral that was previously requested for withdrawal
|
|
1333
|
+
to the root_address (defaults to the user's wallet address).
|
|
1334
|
+
|
|
1335
|
+
Note: This calls the MarketHub contract directly as there's no API endpoint.
|
|
1336
|
+
|
|
1337
|
+
Args:
|
|
1338
|
+
token_id: Boros token ID.
|
|
1339
|
+
root_address: Destination address (defaults to user_address).
|
|
1340
|
+
|
|
1341
|
+
Returns:
|
|
1342
|
+
Tuple of (success, transaction result).
|
|
1343
|
+
"""
|
|
1344
|
+
if self.simulation:
|
|
1345
|
+
logger.info(
|
|
1346
|
+
f"[SIMULATION] finalize_vault_withdrawal: token_id={token_id}, "
|
|
1347
|
+
f"root_address={root_address or self.user_address}"
|
|
1348
|
+
)
|
|
1349
|
+
return True, {
|
|
1350
|
+
"status": "simulated",
|
|
1351
|
+
"token_id": token_id,
|
|
1352
|
+
"root_address": root_address or self.user_address,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
try:
|
|
1356
|
+
dest_address = root_address or self.user_address
|
|
1357
|
+
if not dest_address:
|
|
1358
|
+
return False, {"error": "No destination address configured"}
|
|
1359
|
+
|
|
1360
|
+
if not self.sign_callback:
|
|
1361
|
+
return False, {"error": "sign_callback not configured"}
|
|
1362
|
+
|
|
1363
|
+
# Encode finalizeVaultWithdrawal(address root, uint16 tokenId) directly
|
|
1364
|
+
# Function selector: keccak256("finalizeVaultWithdrawal(address,uint16)")[:4]
|
|
1365
|
+
selector = function_signature_to_4byte_selector(
|
|
1366
|
+
"finalizeVaultWithdrawal(address,uint16)"
|
|
1367
|
+
)
|
|
1368
|
+
params = encode(
|
|
1369
|
+
["address", "uint16"], [to_checksum_address(dest_address), token_id]
|
|
1370
|
+
)
|
|
1371
|
+
data = "0x" + selector.hex() + params.hex()
|
|
1372
|
+
|
|
1373
|
+
tx = {
|
|
1374
|
+
"chainId": self.chain_id,
|
|
1375
|
+
"from": to_checksum_address(self.user_address),
|
|
1376
|
+
"to": to_checksum_address(BOROS_MARKET_HUB),
|
|
1377
|
+
"data": data,
|
|
1378
|
+
"value": 0,
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
try:
|
|
1382
|
+
tx_hash = await send_transaction(
|
|
1383
|
+
tx, self.sign_callback, wait_for_receipt=True
|
|
1384
|
+
)
|
|
1385
|
+
return True, {"status": "ok", "tx": {"tx_hash": tx_hash}}
|
|
1386
|
+
except Exception as e:
|
|
1387
|
+
return False, {
|
|
1388
|
+
"status": "error",
|
|
1389
|
+
"error": str(e),
|
|
1390
|
+
"tx": {"error": str(e)},
|
|
1391
|
+
}
|
|
1392
|
+
except Exception as e:
|
|
1393
|
+
logger.error(f"Failed to finalize vault withdrawal: {e}")
|
|
1394
|
+
return False, {"error": str(e)}
|
|
1395
|
+
|
|
1396
|
+
# ------------------------------------------------------------------ #
|
|
1397
|
+
# Internal Helpers #
|
|
1398
|
+
# ------------------------------------------------------------------ #
|
|
1399
|
+
|
|
1400
|
+
def _extract_symbol(self, market: dict[str, Any]) -> str:
|
|
1401
|
+
return extract_symbol(market)
|
|
1402
|
+
|
|
1403
|
+
def _extract_underlying(self, market: dict[str, Any]) -> str:
|
|
1404
|
+
return extract_underlying(market)
|
|
1405
|
+
|
|
1406
|
+
def _extract_collateral(self, market: dict[str, Any]) -> str:
|
|
1407
|
+
return extract_collateral(market)
|
|
1408
|
+
|
|
1409
|
+
def _extract_maturity_ts(self, market: dict[str, Any]) -> int | None:
|
|
1410
|
+
return extract_maturity_ts(market)
|
|
1411
|
+
|
|
1412
|
+
def _time_to_maturity_days(self, maturity_ts: int) -> float:
|
|
1413
|
+
return time_to_maturity_days(maturity_ts)
|
|
1414
|
+
|
|
1415
|
+
def _parse_market_position(
|
|
1416
|
+
self,
|
|
1417
|
+
mkt_pos: dict[str, Any],
|
|
1418
|
+
token_id: int | None,
|
|
1419
|
+
is_cross: bool,
|
|
1420
|
+
) -> dict[str, Any] | None:
|
|
1421
|
+
return parse_market_position(mkt_pos, token_id, is_cross=is_cross)
|
|
1422
|
+
|
|
1423
|
+
async def _get_market_acc(self, token_id: int) -> str:
|
|
1424
|
+
"""Get marketAcc from Boros API (collaterals/summary).
|
|
1425
|
+
|
|
1426
|
+
Fetch from the Boros API rather than building locally to match backend expectations.
|
|
1427
|
+
|
|
1428
|
+
Falls back to local construction if API doesn't return marketAcc.
|
|
1429
|
+
"""
|
|
1430
|
+
if not self.user_address:
|
|
1431
|
+
raise ValueError("user_address not configured")
|
|
1432
|
+
|
|
1433
|
+
# Try to get marketAcc from API (preferred)
|
|
1434
|
+
try:
|
|
1435
|
+
success, balances = await self.get_account_balances(token_id=token_id)
|
|
1436
|
+
if success and isinstance(balances, dict):
|
|
1437
|
+
# Look for marketAcc in the raw data
|
|
1438
|
+
raw = balances.get("raw", {})
|
|
1439
|
+
for coll in raw.get("collaterals", []):
|
|
1440
|
+
if coll.get("tokenId") == token_id:
|
|
1441
|
+
cross = coll.get("crossPosition") or {}
|
|
1442
|
+
market_acc = cross.get("marketAcc")
|
|
1443
|
+
if market_acc:
|
|
1444
|
+
logger.debug(f"Got marketAcc from API: {market_acc}")
|
|
1445
|
+
return market_acc
|
|
1446
|
+
except Exception as e:
|
|
1447
|
+
logger.debug(
|
|
1448
|
+
f"Failed to get marketAcc from API, falling back to local: {e}"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
# Fallback: build locally
|
|
1452
|
+
# MarketAcc = address(20) | accountId(1) | tokenId(2) | marketId(3)
|
|
1453
|
+
addr = (
|
|
1454
|
+
self.user_address[2:]
|
|
1455
|
+
if self.user_address.startswith("0x")
|
|
1456
|
+
else self.user_address
|
|
1457
|
+
)
|
|
1458
|
+
account_hex = format(self.account_id, "02x")
|
|
1459
|
+
token_hex = format(token_id, "04x")
|
|
1460
|
+
market_hex = "ffffff" # Cross margin marker
|
|
1461
|
+
|
|
1462
|
+
market_acc = f"0x{addr.lower()}{account_hex}{token_hex}{market_hex}"
|
|
1463
|
+
logger.debug(f"Built marketAcc locally: {market_acc}")
|
|
1464
|
+
return market_acc
|
|
1465
|
+
|
|
1466
|
+
async def _get_tick_step(self, market_id: int) -> int:
|
|
1467
|
+
try:
|
|
1468
|
+
success, mkt = await self.get_market(market_id)
|
|
1469
|
+
if not success:
|
|
1470
|
+
return 1
|
|
1471
|
+
step = (mkt.get("imData") or {}).get("tickStep") or mkt.get("tickStep") or 1
|
|
1472
|
+
return int(step)
|
|
1473
|
+
except Exception:
|
|
1474
|
+
return 1
|
|
1475
|
+
|
|
1476
|
+
async def _pick_limit_tick_for_fill(
|
|
1477
|
+
self,
|
|
1478
|
+
market_id: int,
|
|
1479
|
+
side: str,
|
|
1480
|
+
size_yu_wei: int,
|
|
1481
|
+
max_ia_deviation: int = 50,
|
|
1482
|
+
) -> int:
|
|
1483
|
+
"""Find a limit tick deep enough in the orderbook to fill the order.
|
|
1484
|
+
|
|
1485
|
+
IMPORTANT: The orderbook returns 'ia' (implied APR in bps, e.g., 116 = 1.16%)
|
|
1486
|
+
but Boros API expects 'limitTick' which uses TickMath (nonlinear).
|
|
1487
|
+
We must convert ia -> rate -> limitTick using the market's tickStep.
|
|
1488
|
+
|
|
1489
|
+
For SHORT: walk down the long side (bids) until cumulative size >= order size
|
|
1490
|
+
For LONG: walk up the short side (asks) until cumulative size >= order size
|
|
1491
|
+
|
|
1492
|
+
Args:
|
|
1493
|
+
market_id: Boros market ID
|
|
1494
|
+
side: "short"/"long"
|
|
1495
|
+
size_yu_wei: Order size in wei
|
|
1496
|
+
max_ia_deviation: Max allowed implied APR deviation from best (in bps)
|
|
1497
|
+
|
|
1498
|
+
Returns:
|
|
1499
|
+
limitTick value for Boros API (NOT the same as ia!)
|
|
1500
|
+
"""
|
|
1501
|
+
try:
|
|
1502
|
+
success, book = await self.get_orderbook(market_id, tick_size=0.0001)
|
|
1503
|
+
if not success:
|
|
1504
|
+
logger.warning(f"Failed to get orderbook for market {market_id}")
|
|
1505
|
+
return 0
|
|
1506
|
+
|
|
1507
|
+
is_short = side.lower() in ("short", "sell")
|
|
1508
|
+
|
|
1509
|
+
if is_short:
|
|
1510
|
+
# Selling YU -> hit the bids (long side)
|
|
1511
|
+
ia_list = (book.get("long") or {}).get("ia") or []
|
|
1512
|
+
sz_list = (book.get("long") or {}).get("sz") or []
|
|
1513
|
+
else:
|
|
1514
|
+
# Buying YU -> hit the asks (short side)
|
|
1515
|
+
ia_list = (book.get("short") or {}).get("ia") or []
|
|
1516
|
+
sz_list = (book.get("short") or {}).get("sz") or []
|
|
1517
|
+
|
|
1518
|
+
if not ia_list or not sz_list:
|
|
1519
|
+
logger.warning(
|
|
1520
|
+
f"Empty {'long' if is_short else 'short'} side in orderbook for market {market_id}"
|
|
1521
|
+
)
|
|
1522
|
+
return 0
|
|
1523
|
+
|
|
1524
|
+
# Pair implied APR buckets with sizes and sort appropriately
|
|
1525
|
+
levels = list(zip(ia_list, sz_list, strict=False))
|
|
1526
|
+
if is_short:
|
|
1527
|
+
# For sells, go from highest ia (best bid) down
|
|
1528
|
+
levels.sort(key=lambda x: x[0], reverse=True)
|
|
1529
|
+
else:
|
|
1530
|
+
# For buys, go from lowest ia (best ask) up
|
|
1531
|
+
levels.sort(key=lambda x: x[0])
|
|
1532
|
+
|
|
1533
|
+
best_ia = levels[0][0]
|
|
1534
|
+
cumulative = 0
|
|
1535
|
+
chosen_ia = best_ia
|
|
1536
|
+
|
|
1537
|
+
for ia_bps, size_str in levels:
|
|
1538
|
+
# Check if we've deviated too far from best price
|
|
1539
|
+
if is_short:
|
|
1540
|
+
if best_ia - ia_bps > max_ia_deviation:
|
|
1541
|
+
break
|
|
1542
|
+
else:
|
|
1543
|
+
if ia_bps - best_ia > max_ia_deviation:
|
|
1544
|
+
break
|
|
1545
|
+
|
|
1546
|
+
size_wei = int(size_str) if isinstance(size_str, str) else int(size_str)
|
|
1547
|
+
cumulative += size_wei
|
|
1548
|
+
chosen_ia = ia_bps
|
|
1549
|
+
|
|
1550
|
+
if cumulative >= size_yu_wei:
|
|
1551
|
+
break
|
|
1552
|
+
|
|
1553
|
+
# Convert implied APR (bps) -> rate (decimal) -> limitTick
|
|
1554
|
+
tick_step = await self._get_tick_step(market_id)
|
|
1555
|
+
chosen_rate = (
|
|
1556
|
+
float(chosen_ia) / 10_000.0
|
|
1557
|
+
) # ia is in bps (e.g., 116 = 1.16%)
|
|
1558
|
+
|
|
1559
|
+
# For shorts, round_down to ensure we cross the spread and fill
|
|
1560
|
+
# For longs, round_up (round_down=False) to ensure we cross and fill
|
|
1561
|
+
limit_tick = self.tick_from_rate(
|
|
1562
|
+
chosen_rate, tick_step, round_down=is_short
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
logger.info(
|
|
1566
|
+
f"Boros tick selection: side={side}, chosen_ia={chosen_ia} bps ({chosen_rate * 100:.2f}%), "
|
|
1567
|
+
f"tick_step={tick_step}, limitTick={limit_tick}, "
|
|
1568
|
+
f"verify_rate={self.rate_from_tick(limit_tick, tick_step) * 100:.4f}%"
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
return limit_tick
|
|
1572
|
+
except Exception as e:
|
|
1573
|
+
logger.warning(f"Failed to pick limit tick: {e}")
|
|
1574
|
+
return 0
|