@michaleffffff/mcp-trading-server 3.0.20 → 3.0.21
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.
- package/CHANGELOG.md +8 -1
- package/README.md +23 -3
- package/TOOL_EXAMPLES.md +20 -0
- package/dist/prompts/tradingGuide.js +13 -7
- package/dist/server.js +1 -1
- package/dist/services/marketService.js +41 -0
- package/dist/services/poolService.js +15 -66
- package/dist/services/tradeService.js +61 -6
- package/dist/tools/accountTransfer.js +14 -7
- package/dist/tools/closeAllPositions.js +30 -23
- package/dist/tools/closePosition.js +14 -0
- package/dist/tools/executeTrade.js +26 -1
- package/dist/tools/manageLiquidity.js +7 -2
- package/dist/tools/manageTpSl.js +81 -7
- package/dist/tools/openPositionSimple.js +37 -18
- package/dist/utils/slippage.js +20 -0
- package/dist/utils/token.js +15 -0
- package/dist/utils/units.js +4 -12
- package/dist/utils/verification.js +43 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 3.0.
|
|
3
|
+
## 3.0.21 - 2026-03-19
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
6
|
- Hardened `get_pool_metadata` funding-rate formatting:
|
|
7
7
|
- `fundingInfo.nextFundingRate` now preserves readable `%/秒` and `%/天` output for negative rates too.
|
|
8
8
|
- Regression coverage now asserts both numeric funding-rate views and display strings.
|
|
9
9
|
|
|
10
|
+
### Changed
|
|
11
|
+
- Refreshed operator-facing docs and prompts to match the latest trading safety behavior:
|
|
12
|
+
- `README.md` now documents Oracle-only execution, exact-approval defaults, base-size semantics, TP/SL semantic checks, and LP preview fail-close.
|
|
13
|
+
- `mcp_config_guide.md` now includes required `BROKER_ADDRESS` configuration and testnet `MYXBroker` references.
|
|
14
|
+
- `TOOL_EXAMPLES.md` now reflects fresh-Oracle execution, live-direction validation, human-price TP/SL parsing, and trading `slippagePct` conventions.
|
|
15
|
+
- `trading_best_practices` prompt now aligns with the current MCP safety constraints and testnet broker references.
|
|
16
|
+
|
|
10
17
|
## 3.0.19 - 2026-03-19
|
|
11
18
|
|
|
12
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ A production-ready MCP (Model Context Protocol) server for deep integration with
|
|
|
10
10
|
- **SDK baseline**: `@myx-trade/sdk@^1.0.2` compatibility completed.
|
|
11
11
|
- **Refinement**: Consolidated 40+ specialized tools into ~26 high-level unified tools.
|
|
12
12
|
- **Improved UX**: Enhanced AI parameter parsing, automated unit conversion, and structured error reporting.
|
|
13
|
+
- **Safety refresh**: Docs and prompt guidance now reflect Oracle-only execution, exact-approval defaults, notional-based fee checks, TP/SL semantic validation, and LP preview fail-close behavior.
|
|
13
14
|
- **Breaking changes**: Many low-level tools (e.g., `get_market_price`, `get_oracle_price`, `get_open_orders`) have been merged into unified counterparts.
|
|
14
15
|
|
|
15
16
|
---
|
|
@@ -31,13 +32,20 @@ Copy `.env.example` to `.env` and configure your trading wallet:
|
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
34
|
PRIVATE_KEY=0x...
|
|
34
|
-
RPC_URL=https://
|
|
35
|
-
CHAIN_ID
|
|
35
|
+
RPC_URL=https://your-testnet-or-mainnet-rpc
|
|
36
|
+
CHAIN_ID=...
|
|
36
37
|
BROKER_ADDRESS=0x...
|
|
37
38
|
QUOTE_TOKEN_ADDRESS=0x...
|
|
38
|
-
QUOTE_TOKEN_DECIMALS
|
|
39
|
+
QUOTE_TOKEN_DECIMALS=...
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
## Testnet `MYXBroker` Reference
|
|
43
|
+
|
|
44
|
+
- Arbitrum test: `0x895C4ae2A22bB26851011d733A9355f663a1F939`
|
|
45
|
+
- Linea test: `0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2`
|
|
46
|
+
|
|
47
|
+
Use the broker that matches your active RPC and chain configuration.
|
|
48
|
+
|
|
41
49
|
---
|
|
42
50
|
|
|
43
51
|
# Core Tools Reference
|
|
@@ -77,6 +85,17 @@ QUOTE_TOKEN_DECIMALS=18
|
|
|
77
85
|
|
|
78
86
|
---
|
|
79
87
|
|
|
88
|
+
# Safety Defaults
|
|
89
|
+
|
|
90
|
+
- **Oracle-only execution**: trading paths now require a fresh Oracle price and reject stale / missing execution prices.
|
|
91
|
+
- **Exact approvals by default**: local fallback flows now prefer exact approvals instead of implicit unlimited approval.
|
|
92
|
+
- **Size semantics**: `size` always means base-asset quantity, not USD notional.
|
|
93
|
+
- **Direction validation**: when a tool operates on an existing `positionId`, the supplied `direction` must match the live position.
|
|
94
|
+
- **TP/SL semantics**: LONG requires `tpPrice > entryPrice` and `slPrice < entryPrice`; SHORT uses the inverse.
|
|
95
|
+
- **LP safety**: LP preview failures are fail-close and no longer downgrade to `minAmountOut=0`.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
80
99
|
# Tool Discovery
|
|
81
100
|
|
|
82
101
|
When a client or LLM is unsure which tool to call:
|
|
@@ -111,6 +130,7 @@ Use these conventions when generating tool arguments:
|
|
|
111
130
|
- `timeInForce`: SDK `v1.0.2` currently supports `IOC` only, so use `0` or `"IOC"`
|
|
112
131
|
- `size`: base token quantity, not USD notional; expected order value is usually `collateralAmount * leverage`
|
|
113
132
|
- `executionFeeToken`: must be a real token address; zero address is rejected. Use the pool `quoteToken`
|
|
133
|
+
- `slippagePct`: trading tools use 4-decimal raw units where `100 = 1.00%` and `50 = 0.50%`
|
|
114
134
|
- Human units: `"100"` means 100 USDC or 100 token units depending on field
|
|
115
135
|
- Raw units: `"raw:1000000"` means exact on-chain integer units
|
|
116
136
|
|
package/TOOL_EXAMPLES.md
CHANGED
|
@@ -7,6 +7,13 @@ All examples use the MCP format:
|
|
|
7
7
|
{ "name": "tool_name", "arguments": { "...": "..." } }
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
## Environment Notes
|
|
11
|
+
|
|
12
|
+
- Common testnet brokers:
|
|
13
|
+
- Arbitrum test: `0x895C4ae2A22bB26851011d733A9355f663a1F939`
|
|
14
|
+
- Linea test: `0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2`
|
|
15
|
+
- Keep `RPC_URL`, `CHAIN_ID`, and `BROKER_ADDRESS` on the same network.
|
|
16
|
+
|
|
10
17
|
---
|
|
11
18
|
|
|
12
19
|
## Discovery First
|
|
@@ -75,6 +82,8 @@ Recommended high-level entry tool.
|
|
|
75
82
|
`marketId` is optional on `open_position_simple`. If supplied, it is validated against the market resolved from `poolId` or `keyword`.
|
|
76
83
|
`size` is always the base-asset quantity, not the USD notional. For example, a 500 USD order at price 1200 implies `size ≈ 0.416666...`.
|
|
77
84
|
`collateralAmount` remains required on `open_position_simple`; if omitted, MCP now returns an actionable suggestion instead of a generic parse error.
|
|
85
|
+
If `price` is omitted, the tool uses a fresh Oracle price only; stale or unresolved execution prices are rejected.
|
|
86
|
+
Auto-computed `tradingFee` follows notional semantics rather than raw collateral-only estimation.
|
|
78
87
|
|
|
79
88
|
Raw-units example:
|
|
80
89
|
|
|
@@ -113,6 +122,7 @@ Low-level increase-order tool when you want full control.
|
|
|
113
122
|
|
|
114
123
|
`timeInForce` should be `0` (or `"IOC"` in string form) for SDK `v1.0.2`.
|
|
115
124
|
`executionFeeToken` must be a real token address; do not pass the zero address. Use the pool `quoteToken`.
|
|
125
|
+
If `positionId` is supplied on increase flows, `direction` must remain consistent with the live position.
|
|
116
126
|
|
|
117
127
|
### `close_position`
|
|
118
128
|
Close or reduce a position. Use `ALL` for a full close.
|
|
@@ -137,6 +147,8 @@ Close or reduce a position. Use `ALL` for a full close.
|
|
|
137
147
|
}
|
|
138
148
|
```
|
|
139
149
|
|
|
150
|
+
`direction` must match the live position. MCP now validates live direction before sending the close request.
|
|
151
|
+
|
|
140
152
|
### `manage_tp_sl`
|
|
141
153
|
Create or update TP/SL on an open position.
|
|
142
154
|
|
|
@@ -154,6 +166,9 @@ Create or update TP/SL on an open position.
|
|
|
154
166
|
}
|
|
155
167
|
```
|
|
156
168
|
|
|
169
|
+
Plain integer prices such as `"2800"` are treated as human prices, not implicit raw 30-decimal values.
|
|
170
|
+
For LONG positions, use `tpPrice > entryPrice` and `slPrice < entryPrice`. For SHORT positions, use the inverse.
|
|
171
|
+
|
|
157
172
|
Delete both TP/SL orders:
|
|
158
173
|
|
|
159
174
|
```json
|
|
@@ -284,6 +299,9 @@ Alias-friendly form also works:
|
|
|
284
299
|
}
|
|
285
300
|
```
|
|
286
301
|
|
|
302
|
+
LP preview failures now fail closed; the server no longer downgrades to `minAmountOut=0`.
|
|
303
|
+
Oracle-backed LP pricing requires a fresh price snapshot before execution.
|
|
304
|
+
|
|
287
305
|
### `get_lp_price`
|
|
288
306
|
Read LP NAV price for BASE or QUOTE side.
|
|
289
307
|
|
|
@@ -391,3 +409,5 @@ Read open positions, history, or both.
|
|
|
391
409
|
4. Canonical enums are still preferred:
|
|
392
410
|
`OPEN|HISTORY|ALL`, `BASE|QUOTE`, `LONG|SHORT`, `MARKET|LIMIT|STOP`.
|
|
393
411
|
5. The server tolerates common lowercase and alias forms for better AI compatibility.
|
|
412
|
+
6. Trading `slippagePct` uses 4-decimal raw units, so `100 = 1.00%` and `50 = 0.50%`.
|
|
413
|
+
7. High-risk execution paths prefer fresh Oracle pricing, exact approval sizing, and fail-close behavior on missing previews or invalid units.
|
|
@@ -12,7 +12,7 @@ export const tradingGuidePrompt = {
|
|
|
12
12
|
content: {
|
|
13
13
|
type: "text",
|
|
14
14
|
text: `
|
|
15
|
-
# MYX Trading MCP Best Practices (v3.0.
|
|
15
|
+
# MYX Trading MCP Best Practices (v3.0.21)
|
|
16
16
|
|
|
17
17
|
You are an expert crypto trader using the MYX Protocol. To ensure successful execution and safe handling of user funds, follow these patterns:
|
|
18
18
|
|
|
@@ -28,21 +28,27 @@ You are an expert crypto trader using the MYX Protocol. To ensure successful exe
|
|
|
28
28
|
- **Consolidated Tools**: Many legacy tools have been merged. Always use the high-level versions (e.g., \`get_price\` instead of \`get_market_price\`).
|
|
29
29
|
- **Discovery**: \`search_tools\` understands legacy names like \`get_open_orders\` and intent phrases like \`add base lp\`.
|
|
30
30
|
- **Unit Prefixes**: Prefer \`human:\` for readable amounts (e.g., "100" USDC) and \`raw:\` for exact on-chain units.
|
|
31
|
-
- **Slippage**:
|
|
31
|
+
- **Slippage**: Trading tools use 4-decimal raw units where \`100 = 1.00%\` and \`50 = 0.50%\`. Keep it tight unless the market is genuinely illiquid.
|
|
32
32
|
- **Fees**: Use \`get_pool_metadata\` to view current fee tiers and pool configuration.
|
|
33
33
|
- **LP Strategy**: Use \`get_my_lp_holdings\` to monitor liquidity positions. Naming follows \`mBASE.QUOTE\` (e.g., \`mBTC.USDC\`).
|
|
34
34
|
- **Enum Tolerance**: The server tolerates common lowercase or alias inputs such as \`open\`, \`base\`, \`buy\`, and \`add\`, but canonical forms are still preferred in documentation.
|
|
35
|
+
- **Oracle Safety**: Execution flows now require a fresh Oracle price. Do not fall back to stale ticker or user-supplied execution prices when the Oracle is unavailable.
|
|
36
|
+
- **Approval Safety**: Local fallback flows prefer exact approval sizing. Do not assume unlimited approvals are necessary.
|
|
37
|
+
- **Position Semantics**: \`size\` is BASE quantity, not USD notional. If a \`positionId\` is supplied, \`direction\` must match the live position.
|
|
38
|
+
- **TP/SL Semantics**: LONG should use \`tpPrice > entryPrice\` and \`slPrice < entryPrice\`; SHORT uses the inverse. Plain integer strings like \`"65000"\` are treated as human prices, not implicit raw 30-decimal values.
|
|
39
|
+
- **LP Safety**: LP execution requires a fresh price snapshot and preview success; do not continue after preview failure.
|
|
40
|
+
|
|
41
|
+
## 3. Testnet Broker Reference
|
|
42
|
+
- Arbitrum test: \`0x895C4ae2A22bB26851011d733A9355f663a1F939\`
|
|
43
|
+
- Linea test: \`0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2\`
|
|
44
|
+
- Always keep \`RPC_URL\`, \`CHAIN_ID\`, and \`BROKER_ADDRESS\` on the same network.
|
|
35
45
|
|
|
36
46
|
Current Session:
|
|
37
47
|
- Wallet: ${address}
|
|
38
48
|
- Chain ID: ${chainId}
|
|
39
49
|
|
|
40
|
-
##
|
|
50
|
+
## 4. Self-Healing
|
|
41
51
|
If a transaction reverts with a hex code, the server will attempt to decode it (e.g., "AccountInsufficientFreeAmount"). Error payloads now include structured \`code/hint/action\` fields; use them to provide concrete next steps.
|
|
42
|
-
|
|
43
|
-
Current Session:
|
|
44
|
-
- Wallet: ${address}
|
|
45
|
-
- Chain ID: ${chainId}
|
|
46
52
|
`
|
|
47
53
|
}
|
|
48
54
|
}
|
package/dist/server.js
CHANGED
|
@@ -461,7 +461,7 @@ function zodSchemaToJsonSchema(zodSchema) {
|
|
|
461
461
|
};
|
|
462
462
|
}
|
|
463
463
|
// ─── MCP Server ───
|
|
464
|
-
const server = new Server({ name: "myx-mcp-trading-server", version: "3.0.
|
|
464
|
+
const server = new Server({ name: "myx-mcp-trading-server", version: "3.0.21" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
|
|
465
465
|
// List tools
|
|
466
466
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
467
467
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getChainId } from "../auth/resolveClient.js";
|
|
2
2
|
import { getMarketStateDesc } from "../utils/mappings.js";
|
|
3
|
+
export const DEFAULT_ORACLE_MAX_AGE_SEC = Number(process.env.ORACLE_MAX_AGE_SEC ?? 90);
|
|
3
4
|
function collectRows(input) {
|
|
4
5
|
if (Array.isArray(input))
|
|
5
6
|
return input.flatMap(collectRows);
|
|
@@ -92,6 +93,46 @@ export async function getOraclePrice(client, poolId, chainIdOverride) {
|
|
|
92
93
|
const chainId = chainIdOverride ?? getChainId();
|
|
93
94
|
return client.utils.getOraclePrice(poolId, chainId);
|
|
94
95
|
}
|
|
96
|
+
function parseOraclePublishTime(value, poolId) {
|
|
97
|
+
const text = String(value ?? "").trim();
|
|
98
|
+
if (!/^\d+$/.test(text)) {
|
|
99
|
+
throw new Error(`Oracle publishTime missing or invalid for poolId=${poolId}.`);
|
|
100
|
+
}
|
|
101
|
+
const parsed = BigInt(text);
|
|
102
|
+
if (parsed <= 0n) {
|
|
103
|
+
throw new Error(`Oracle publishTime must be positive for poolId=${poolId}.`);
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
export function assertOracleFreshness(publishTimeValue, poolId, maxAgeSec = DEFAULT_ORACLE_MAX_AGE_SEC) {
|
|
108
|
+
if (!Number.isFinite(maxAgeSec) || maxAgeSec <= 0) {
|
|
109
|
+
throw new Error(`Invalid oracle max age configuration: ${maxAgeSec}`);
|
|
110
|
+
}
|
|
111
|
+
const publishTime = parseOraclePublishTime(publishTimeValue, poolId);
|
|
112
|
+
const nowSec = BigInt(Math.floor(Date.now() / 1000));
|
|
113
|
+
const maxAge = BigInt(Math.floor(maxAgeSec));
|
|
114
|
+
if (publishTime > nowSec + 5n) {
|
|
115
|
+
throw new Error(`Oracle publishTime is in the future for poolId=${poolId}.`);
|
|
116
|
+
}
|
|
117
|
+
const age = nowSec - publishTime;
|
|
118
|
+
if (age > maxAge) {
|
|
119
|
+
throw new Error(`Oracle price expired for poolId=${poolId}: age=${age.toString()}s exceeds maxAge=${maxAge.toString()}s.`);
|
|
120
|
+
}
|
|
121
|
+
return publishTime;
|
|
122
|
+
}
|
|
123
|
+
export async function getFreshOraclePrice(client, poolId, chainIdOverride, maxAgeSec = DEFAULT_ORACLE_MAX_AGE_SEC) {
|
|
124
|
+
const oracle = await getOraclePrice(client, poolId, chainIdOverride);
|
|
125
|
+
const price = String(oracle?.price ?? "").trim();
|
|
126
|
+
if (!price) {
|
|
127
|
+
throw new Error(`Oracle price missing for poolId=${poolId}.`);
|
|
128
|
+
}
|
|
129
|
+
const publishTime = assertOracleFreshness(oracle?.publishTime, poolId, maxAgeSec);
|
|
130
|
+
return {
|
|
131
|
+
...oracle,
|
|
132
|
+
price,
|
|
133
|
+
publishTime: publishTime.toString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
95
136
|
export async function searchMarket(client, keyword, limit = 1000, chainIdOverride) {
|
|
96
137
|
const chainId = chainIdOverride ?? getChainId();
|
|
97
138
|
const normalizedKeyword = String(keyword ?? "").trim();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { pool, quote, base } from "@myx-trade/sdk";
|
|
2
2
|
import { getChainId, resolveClient } from "../auth/resolveClient.js";
|
|
3
3
|
import { extractErrorMessage } from "../utils/errorMessage.js";
|
|
4
|
-
import { ensureUnits } from "../utils/units.js";
|
|
4
|
+
import { ensureUnits, parseUserUnits } from "../utils/units.js";
|
|
5
5
|
import { normalizeAddress } from "../utils/address.js";
|
|
6
|
-
import { Contract
|
|
6
|
+
import { Contract } from "ethers";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
|
+
import { assertOracleFreshness } from "./marketService.js";
|
|
8
9
|
const LP_DECIMALS = 18;
|
|
9
10
|
const POOL_MANAGER_BY_CHAIN = {
|
|
10
11
|
421614: "0xf268D9FeD3Bd56fd9aBdb4FeEb993338613678A8",
|
|
@@ -202,10 +203,13 @@ async function buildOraclePricePayload(client, chainId, poolId, fallbackOracleTy
|
|
|
202
203
|
if (!vaa || !vaa.startsWith("0x")) {
|
|
203
204
|
throw new Error(`Oracle VAA unavailable for pool ${poolId}.`);
|
|
204
205
|
}
|
|
205
|
-
const publishTime =
|
|
206
|
+
const publishTime = assertOracleFreshness(oracle?.publishTime, poolId);
|
|
206
207
|
const oracleType = Number.isFinite(Number(oracle?.oracleType)) ? Number(oracle.oracleType) : fallbackOracleType;
|
|
207
208
|
const value = toPositiveBigint(oracle?.value) ?? 0n;
|
|
208
209
|
const referencePrice30 = BigInt(ensureUnits(String(oracle?.price ?? "0"), 30, "oracle price"));
|
|
210
|
+
if (referencePrice30 <= 0n) {
|
|
211
|
+
throw new Error(`Oracle price must be positive for pool ${poolId}.`);
|
|
212
|
+
}
|
|
209
213
|
return {
|
|
210
214
|
prices: [[poolId, oracleType, publishTime, vaa]],
|
|
211
215
|
value,
|
|
@@ -229,8 +233,7 @@ async function previewAmountOutForLiquidity(signer, chainId, poolId, poolType, a
|
|
|
229
233
|
return toPositiveBigint(out) ?? 0n;
|
|
230
234
|
}
|
|
231
235
|
catch (error) {
|
|
232
|
-
|
|
233
|
-
return 0n;
|
|
236
|
+
throw new Error(`LP preview failed: ${extractErrorMessage(error)}`);
|
|
234
237
|
}
|
|
235
238
|
}
|
|
236
239
|
async function executeLiquidityTxViaRouter(params) {
|
|
@@ -244,7 +247,7 @@ async function executeLiquidityTxViaRouter(params) {
|
|
|
244
247
|
if (!Number.isFinite(decimals) || decimals < 0) {
|
|
245
248
|
throw new Error(`Invalid decimals while preparing ${poolType} ${action} transaction.`);
|
|
246
249
|
}
|
|
247
|
-
const amountIn =
|
|
250
|
+
const amountIn = BigInt(parseUserUnits(String(amount), decimals, "amount"));
|
|
248
251
|
if (amountIn <= 0n) {
|
|
249
252
|
throw new Error(`Liquidity ${poolType.toLowerCase()} ${action} amount must be > 0.`);
|
|
250
253
|
}
|
|
@@ -255,7 +258,7 @@ async function executeLiquidityTxViaRouter(params) {
|
|
|
255
258
|
const allowance = toPositiveBigint(await tokenContract.allowance(address, addresses.router)) ?? 0n;
|
|
256
259
|
if (allowance < amountIn) {
|
|
257
260
|
logger.info(`[LP fallback] allowance insufficient for ${poolType} deposit, approving router. required=${amountIn.toString()}, current=${allowance.toString()}`);
|
|
258
|
-
const approveTx = await tokenContract.approve(addresses.router,
|
|
261
|
+
const approveTx = await tokenContract.approve(addresses.router, amountIn);
|
|
259
262
|
approvalTxHash = String(approveTx?.hash ?? "").trim() || null;
|
|
260
263
|
const approveReceipt = await approveTx?.wait?.();
|
|
261
264
|
if (approveReceipt && approveReceipt.status !== 1) {
|
|
@@ -351,10 +354,8 @@ async function resolvePositiveMarketPrice30(client, poolId, chainId) {
|
|
|
351
354
|
return null;
|
|
352
355
|
try {
|
|
353
356
|
const oracle = await client.utils?.getOraclePrice?.(poolId, chainId);
|
|
354
|
-
|
|
357
|
+
assertOracleFreshness(oracle?.publishTime, poolId);
|
|
355
358
|
const byPrice = toPositiveBigint(oracle?.price);
|
|
356
|
-
if (byValue)
|
|
357
|
-
return byValue;
|
|
358
359
|
if (byPrice)
|
|
359
360
|
return byPrice;
|
|
360
361
|
}
|
|
@@ -481,80 +482,28 @@ export async function getLiquidityInfo(client, poolId, marketPrice, chainIdOverr
|
|
|
481
482
|
*/
|
|
482
483
|
export async function quoteDeposit(poolId, amount, slippage, chainIdOverride) {
|
|
483
484
|
const chainId = chainIdOverride ?? getChainId();
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
return await withMutedSdkAbiMismatchLogs(() => quote.deposit({ chainId, poolId, amount, slippage }));
|
|
487
|
-
}
|
|
488
|
-
catch (error) {
|
|
489
|
-
const message = extractErrorMessage(error);
|
|
490
|
-
const recovered = recoverSdkSubmittedTxHash(txHashBefore, message, { poolType: "QUOTE", action: "deposit" });
|
|
491
|
-
if (recovered)
|
|
492
|
-
return recovered;
|
|
493
|
-
if (!isAbiLengthMismatchError(message))
|
|
494
|
-
throw error;
|
|
495
|
-
logger.warn("quote.deposit hit SDK ABI mismatch; switching to explicit router path.");
|
|
496
|
-
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "deposit", amount, slippage });
|
|
497
|
-
}
|
|
485
|
+
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "deposit", amount, slippage });
|
|
498
486
|
}
|
|
499
487
|
/**
|
|
500
488
|
* Quote 池 withdraw
|
|
501
489
|
*/
|
|
502
490
|
export async function quoteWithdraw(poolId, amount, slippage, chainIdOverride) {
|
|
503
491
|
const chainId = chainIdOverride ?? getChainId();
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
return await withMutedSdkAbiMismatchLogs(() => quote.withdraw({ chainId, poolId, amount, slippage }));
|
|
507
|
-
}
|
|
508
|
-
catch (error) {
|
|
509
|
-
const message = extractErrorMessage(error);
|
|
510
|
-
const recovered = recoverSdkSubmittedTxHash(txHashBefore, message, { poolType: "QUOTE", action: "withdraw" });
|
|
511
|
-
if (recovered)
|
|
512
|
-
return recovered;
|
|
513
|
-
if (!isAbiLengthMismatchError(message))
|
|
514
|
-
throw error;
|
|
515
|
-
logger.warn("quote.withdraw hit SDK ABI mismatch; switching to explicit router path.");
|
|
516
|
-
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "withdraw", amount, slippage });
|
|
517
|
-
}
|
|
492
|
+
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "withdraw", amount, slippage });
|
|
518
493
|
}
|
|
519
494
|
/**
|
|
520
495
|
* Base 池 deposit
|
|
521
496
|
*/
|
|
522
497
|
export async function baseDeposit(poolId, amount, slippage, chainIdOverride) {
|
|
523
498
|
const chainId = chainIdOverride ?? getChainId();
|
|
524
|
-
|
|
525
|
-
try {
|
|
526
|
-
return await withMutedSdkAbiMismatchLogs(() => base.deposit({ chainId, poolId, amount, slippage }));
|
|
527
|
-
}
|
|
528
|
-
catch (error) {
|
|
529
|
-
const message = extractErrorMessage(error);
|
|
530
|
-
const recovered = recoverSdkSubmittedTxHash(txHashBefore, message, { poolType: "BASE", action: "deposit" });
|
|
531
|
-
if (recovered)
|
|
532
|
-
return recovered;
|
|
533
|
-
if (!isAbiLengthMismatchError(message))
|
|
534
|
-
throw error;
|
|
535
|
-
logger.warn("base.deposit hit SDK ABI mismatch; switching to explicit router path.");
|
|
536
|
-
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "deposit", amount, slippage });
|
|
537
|
-
}
|
|
499
|
+
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "deposit", amount, slippage });
|
|
538
500
|
}
|
|
539
501
|
/**
|
|
540
502
|
* Base 池 withdraw
|
|
541
503
|
*/
|
|
542
504
|
export async function baseWithdraw(poolId, amount, slippage, chainIdOverride) {
|
|
543
505
|
const chainId = chainIdOverride ?? getChainId();
|
|
544
|
-
|
|
545
|
-
try {
|
|
546
|
-
return await withMutedSdkAbiMismatchLogs(() => base.withdraw({ chainId, poolId, amount, slippage }));
|
|
547
|
-
}
|
|
548
|
-
catch (error) {
|
|
549
|
-
const message = extractErrorMessage(error);
|
|
550
|
-
const recovered = recoverSdkSubmittedTxHash(txHashBefore, message, { poolType: "BASE", action: "withdraw" });
|
|
551
|
-
if (recovered)
|
|
552
|
-
return recovered;
|
|
553
|
-
if (!isAbiLengthMismatchError(message))
|
|
554
|
-
throw error;
|
|
555
|
-
logger.warn("base.withdraw hit SDK ABI mismatch; switching to explicit router path.");
|
|
556
|
-
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "withdraw", amount, slippage });
|
|
557
|
-
}
|
|
506
|
+
return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "withdraw", amount, slippage });
|
|
558
507
|
}
|
|
559
508
|
/**
|
|
560
509
|
* 获取 LP 价格
|
|
@@ -7,6 +7,7 @@ import { normalizeSlippagePct4dp } from "../utils/slippage.js";
|
|
|
7
7
|
import { finalizeMutationResult } from "../utils/mutationResult.js";
|
|
8
8
|
import { extractErrorMessage } from "../utils/errorMessage.js";
|
|
9
9
|
import { mapTimeInForce } from "../utils/mappings.js";
|
|
10
|
+
import { getFreshOraclePrice } from "./marketService.js";
|
|
10
11
|
function resolveDirection(direction) {
|
|
11
12
|
if (typeof direction === "string") {
|
|
12
13
|
const text = direction.trim().toUpperCase();
|
|
@@ -154,6 +155,37 @@ function validateIncreaseOrderEconomics(args) {
|
|
|
154
155
|
const priceHuman = formatUnits(priceRawBig, 30);
|
|
155
156
|
throw new Error(`Invalid size semantics: size is BASE quantity, not USD notional. collateralAmount*leverage implies ≈${targetHuman} quote, but size*price implies ≈${actualHuman} quote. At price ${priceHuman}, recommended size is ≈${recommendedSizeHuman}.`);
|
|
156
157
|
}
|
|
158
|
+
function countAdditionalExecutionOrders(args) {
|
|
159
|
+
let count = 1;
|
|
160
|
+
if (String(args.tpPrice ?? "").trim()) {
|
|
161
|
+
count += 1;
|
|
162
|
+
}
|
|
163
|
+
if (String(args.slPrice ?? "").trim()) {
|
|
164
|
+
count += 1;
|
|
165
|
+
}
|
|
166
|
+
return count;
|
|
167
|
+
}
|
|
168
|
+
async function getRequiredIncreaseSpendRaw(client, marketId, args, chainId) {
|
|
169
|
+
const collateralRaw = BigInt(args.collateralRaw);
|
|
170
|
+
const tradingFeeRaw = BigInt(args.tradingFeeRaw);
|
|
171
|
+
const executionOrderCount = countAdditionalExecutionOrders(args);
|
|
172
|
+
const networkFeeText = String(await client.utils.getNetworkFee(marketId, chainId) ?? "").trim();
|
|
173
|
+
if (!/^\d+$/.test(networkFeeText)) {
|
|
174
|
+
throw new Error(`Failed to resolve networkFee for marketId=${marketId}.`);
|
|
175
|
+
}
|
|
176
|
+
const baseNetworkFeeRaw = BigInt(networkFeeText);
|
|
177
|
+
if (baseNetworkFeeRaw <= 0n) {
|
|
178
|
+
throw new Error(`networkFee must be > 0 for marketId=${marketId}.`);
|
|
179
|
+
}
|
|
180
|
+
const networkFeeRaw = baseNetworkFeeRaw * BigInt(executionOrderCount);
|
|
181
|
+
return {
|
|
182
|
+
collateralRaw,
|
|
183
|
+
tradingFeeRaw,
|
|
184
|
+
networkFeeRaw,
|
|
185
|
+
executionOrderCount,
|
|
186
|
+
totalSpendRaw: collateralRaw + tradingFeeRaw + networkFeeRaw,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
157
189
|
async function resolveDecimalsForUpdateOrder(client, chainId, marketId, poolIdHint) {
|
|
158
190
|
let baseDecimals = 18;
|
|
159
191
|
let quoteDecimals = getQuoteDecimals();
|
|
@@ -249,6 +281,18 @@ export async function openPosition(client, address, args) {
|
|
|
249
281
|
const sizeRaw = ensureUnits(args.size, baseDecimals, "size", { allowImplicitRaw: false });
|
|
250
282
|
const priceRaw = ensureUnits(args.price, 30, "price", { allowImplicitRaw: false });
|
|
251
283
|
const tradingFeeRaw = ensureUnits(args.tradingFee, quoteDecimals, "tradingFee", { allowImplicitRaw: false });
|
|
284
|
+
const resolvedMarketId = String(args.marketId ?? poolData.marketId ?? "").trim();
|
|
285
|
+
if (!resolvedMarketId) {
|
|
286
|
+
throw new Error(`marketId is required to compute networkFee for poolId=${args.poolId}.`);
|
|
287
|
+
}
|
|
288
|
+
const spend = await getRequiredIncreaseSpendRaw(client, resolvedMarketId, {
|
|
289
|
+
collateralRaw,
|
|
290
|
+
tradingFeeRaw,
|
|
291
|
+
tpPrice: args.tpPrice,
|
|
292
|
+
tpSize: args.tpSize,
|
|
293
|
+
slPrice: args.slPrice,
|
|
294
|
+
slSize: args.slSize,
|
|
295
|
+
}, chainId);
|
|
252
296
|
validateIncreaseOrderEconomics({
|
|
253
297
|
collateralRaw,
|
|
254
298
|
sizeRaw,
|
|
@@ -268,7 +312,7 @@ export async function openPosition(client, address, args) {
|
|
|
268
312
|
marginBalanceRaw = toBigIntOrZero(marginInfo.data.freeMargin);
|
|
269
313
|
walletBalanceRaw = toBigIntOrZero(marginInfo.data.walletBalance);
|
|
270
314
|
}
|
|
271
|
-
const requiredRaw =
|
|
315
|
+
const requiredRaw = spend.totalSpendRaw;
|
|
272
316
|
if (marginBalanceRaw < requiredRaw) {
|
|
273
317
|
if (!allowAutoDeposit) {
|
|
274
318
|
throw new Error(`Insufficient marginBalance (${marginBalanceRaw.toString()}) for required collateral (${requiredRaw.toString()}). ` +
|
|
@@ -459,9 +503,14 @@ export async function closeAllPositions(client, address) {
|
|
|
459
503
|
const results = [];
|
|
460
504
|
for (const pos of positions) {
|
|
461
505
|
const dir = pos.direction === 0 ? Direction.LONG : Direction.SHORT;
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
506
|
+
const marketDetailRes = await client.markets.getMarketDetail({ chainId, poolId: pos.poolId });
|
|
507
|
+
const marketDetail = marketDetailRes?.data || (marketDetailRes?.marketId ? marketDetailRes : null);
|
|
508
|
+
if (!marketDetail?.marketId) {
|
|
509
|
+
throw new Error(`Could not resolve market metadata for poolId=${pos.poolId}.`);
|
|
510
|
+
}
|
|
511
|
+
const baseDecimals = Number(marketDetail.baseDecimals ?? 18);
|
|
512
|
+
const oracleData = await getFreshOraclePrice(client, pos.poolId, chainId);
|
|
513
|
+
const currentPrice30 = ensureUnits(oracleData.price, 30, "oracle price", { allowImplicitRaw: false });
|
|
465
514
|
// For LONG close (Decrease LONG): Price should be lower (e.g. 90% of current)
|
|
466
515
|
// For SHORT close (Decrease SHORT): Price should be higher (e.g. 110% of current)
|
|
467
516
|
// Here we use a safe 10% slippage price
|
|
@@ -472,7 +521,13 @@ export async function closeAllPositions(client, address) {
|
|
|
472
521
|
else {
|
|
473
522
|
slippagePrice30 = (BigInt(currentPrice30) * 110n) / 100n;
|
|
474
523
|
}
|
|
475
|
-
const
|
|
524
|
+
const sizeInput = /^\d+$/.test(String(pos.sizeRaw ?? pos.positionSizeRaw ?? "").trim())
|
|
525
|
+
? `raw:${String(pos.sizeRaw ?? pos.positionSizeRaw).trim()}`
|
|
526
|
+
: String(pos.size ?? pos.positionSize ?? "").trim();
|
|
527
|
+
if (!sizeInput) {
|
|
528
|
+
throw new Error(`Position size missing for positionId=${String(pos.positionId ?? "").trim()}.`);
|
|
529
|
+
}
|
|
530
|
+
const sizeWei = ensureUnits(sizeInput, baseDecimals, "size", { allowImplicitRaw: false });
|
|
476
531
|
const res = await client.order.createDecreaseOrder({
|
|
477
532
|
chainId,
|
|
478
533
|
address,
|
|
@@ -485,7 +540,7 @@ export async function closeAllPositions(client, address) {
|
|
|
485
540
|
size: sizeWei,
|
|
486
541
|
price: slippagePrice30.toString(),
|
|
487
542
|
postOnly: false,
|
|
488
|
-
slippagePct: "
|
|
543
|
+
slippagePct: "100", // 1%
|
|
489
544
|
executionFeeToken: getQuoteToken(),
|
|
490
545
|
leverage: pos.userLeverage,
|
|
491
546
|
});
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { resolveClient, getChainId, getQuoteToken } from "../auth/resolveClient.js";
|
|
3
3
|
import { normalizeAddress } from "../utils/address.js";
|
|
4
4
|
import { finalizeMutationResult } from "../utils/mutationResult.js";
|
|
5
|
+
import { fetchErc20Decimals } from "../utils/token.js";
|
|
5
6
|
const MAX_UINT256 = "115792089237316195423570985008687907853269984665640564039457584007913129639935";
|
|
6
7
|
function asBigintOrNull(value) {
|
|
7
8
|
try {
|
|
@@ -52,11 +53,9 @@ export const accountDepositTool = {
|
|
|
52
53
|
const chainId = getChainId();
|
|
53
54
|
const tokenAddressInput = String(args.tokenAddress ?? "").trim() || getQuoteToken();
|
|
54
55
|
const tokenAddress = normalizeAddress(tokenAddressInput, "tokenAddress");
|
|
55
|
-
// For deposit, we default to quote decimals (6) as it's the most common use case.
|
|
56
|
-
// ensureUnits handles 'raw:' prefix if absolute precision is needed.
|
|
57
56
|
const { ensureUnits } = await import("../utils/units.js");
|
|
58
|
-
const
|
|
59
|
-
const amount = ensureUnits(args.amount,
|
|
57
|
+
const tokenDecimals = await fetchErc20Decimals(signer.provider ?? signer, tokenAddress, "deposit token");
|
|
58
|
+
const amount = ensureUnits(args.amount, tokenDecimals, "amount", { allowImplicitRaw: false });
|
|
60
59
|
let approval = null;
|
|
61
60
|
const needApproval = await client.utils.needsApproval(address, chainId, tokenAddress, amount);
|
|
62
61
|
if (needApproval) {
|
|
@@ -102,9 +101,17 @@ export const accountWithdrawTool = {
|
|
|
102
101
|
const { client, address, signer } = await resolveClient();
|
|
103
102
|
const chainId = getChainId();
|
|
104
103
|
const { ensureUnits } = await import("../utils/units.js");
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
const marketDetailRes = await client.markets.getMarketDetail({ chainId, poolId: args.poolId });
|
|
105
|
+
const marketDetail = marketDetailRes?.data || (marketDetailRes?.marketId ? marketDetailRes : null);
|
|
106
|
+
if (!marketDetail?.marketId) {
|
|
107
|
+
throw new Error(`Could not resolve market metadata for poolId=${args.poolId}.`);
|
|
108
|
+
}
|
|
109
|
+
const decimals = Number(Boolean(args.isQuoteToken)
|
|
110
|
+
? marketDetail.quoteDecimals
|
|
111
|
+
: marketDetail.baseDecimals);
|
|
112
|
+
if (!Number.isFinite(decimals) || decimals < 0) {
|
|
113
|
+
throw new Error(`Invalid token decimals for withdraw on poolId=${args.poolId}.`);
|
|
114
|
+
}
|
|
108
115
|
const amount = ensureUnits(args.amount, decimals, "amount", { allowImplicitRaw: false });
|
|
109
116
|
const amountRaw = asBigintOrNull(amount);
|
|
110
117
|
if (amountRaw === null || amountRaw <= 0n) {
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolveClient, getChainId, getQuoteToken } from "../auth/resolveClient.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getFreshOraclePrice, resolvePool } from "../services/marketService.js";
|
|
4
4
|
import { ensureUnits } from "../utils/units.js";
|
|
5
5
|
import { finalizeMutationResult } from "../utils/mutationResult.js";
|
|
6
6
|
import { normalizeSlippagePct4dpFlexible, SLIPPAGE_PCT_4DP_DESC } from "../utils/slippage.js";
|
|
7
|
+
const INTEGER_RE = /^\d+$/;
|
|
8
|
+
function resolvePositionSizeInput(position) {
|
|
9
|
+
const rawCandidates = [position?.sizeRaw, position?.positionSizeRaw];
|
|
10
|
+
for (const candidate of rawCandidates) {
|
|
11
|
+
const text = String(candidate ?? "").trim();
|
|
12
|
+
if (INTEGER_RE.test(text))
|
|
13
|
+
return `raw:${text}`;
|
|
14
|
+
}
|
|
15
|
+
const humanCandidates = [position?.size, position?.positionSize];
|
|
16
|
+
for (const candidate of humanCandidates) {
|
|
17
|
+
const text = String(candidate ?? "").trim();
|
|
18
|
+
if (text)
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Position size missing for positionId=${String(position?.positionId ?? position?.position_id ?? "").trim()}.`);
|
|
22
|
+
}
|
|
7
23
|
export const closeAllPositionsTool = {
|
|
8
24
|
name: "close_all_positions",
|
|
9
25
|
description: "[TRADE] Emergency: close ALL open positions in a pool at once. Use for risk management.",
|
|
@@ -28,27 +44,18 @@ export const closeAllPositionsTool = {
|
|
|
28
44
|
return { content: [{ type: "text", text: JSON.stringify({ status: "success", data: { message: "No open positions in this pool.", closed: 0 } }, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2) }] };
|
|
29
45
|
}
|
|
30
46
|
// 2) 为每个仓位构建平仓参数
|
|
31
|
-
const slippagePct = normalizeSlippagePct4dpFlexible(args.slippagePct ?? "
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
fallbackPrice = oraclePriceReq.price.toString();
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
const { getMarketPrice } = await import("../services/marketService.js");
|
|
40
|
-
const marketData = await getMarketPrice(client, poolId).catch(() => null);
|
|
41
|
-
if (marketData && marketData.price) {
|
|
42
|
-
fallbackPrice = marketData.price.toString();
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
// Extreme fallback - but this may still revert
|
|
46
|
-
fallbackPrice = poolPositions[0]?.entryPrice || "0";
|
|
47
|
-
}
|
|
47
|
+
const slippagePct = normalizeSlippagePct4dpFlexible(args.slippagePct ?? "100");
|
|
48
|
+
const marketDetailRes = await client.markets.getMarketDetail({ chainId, poolId });
|
|
49
|
+
const marketDetail = marketDetailRes?.data || (marketDetailRes?.marketId ? marketDetailRes : null);
|
|
50
|
+
if (!marketDetail?.marketId) {
|
|
51
|
+
throw new Error(`Could not resolve market metadata for poolId=${poolId}.`);
|
|
48
52
|
}
|
|
53
|
+
const baseDecimals = Number(marketDetail.baseDecimals ?? 18);
|
|
54
|
+
const quoteToken = String(marketDetail.quoteToken ?? "").trim() || getQuoteToken();
|
|
55
|
+
const oracle = await getFreshOraclePrice(client, poolId, chainId);
|
|
56
|
+
const freshOraclePrice = oracle.price.toString();
|
|
49
57
|
const closeParams = poolPositions.map((pos) => {
|
|
50
|
-
const
|
|
51
|
-
const rawPrice = pos.markPrice || pos.mark_price || fallbackPrice;
|
|
58
|
+
const sizeInput = resolvePositionSizeInput(pos);
|
|
52
59
|
return {
|
|
53
60
|
chainId,
|
|
54
61
|
address,
|
|
@@ -59,11 +66,11 @@ export const closeAllPositionsTool = {
|
|
|
59
66
|
timeInForce: 0, // IOC
|
|
60
67
|
direction: pos.direction ?? 0,
|
|
61
68
|
collateralAmount: "0",
|
|
62
|
-
size: ensureUnits(
|
|
63
|
-
price: ensureUnits(
|
|
69
|
+
size: ensureUnits(sizeInput, baseDecimals, "size", { allowImplicitRaw: false }),
|
|
70
|
+
price: ensureUnits(freshOraclePrice, 30, "price", { allowImplicitRaw: false }),
|
|
64
71
|
postOnly: false,
|
|
65
72
|
slippagePct,
|
|
66
|
-
executionFeeToken:
|
|
73
|
+
executionFeeToken: quoteToken,
|
|
67
74
|
leverage: pos.userLeverage ?? pos.leverage ?? 1,
|
|
68
75
|
};
|
|
69
76
|
});
|
|
@@ -95,6 +95,20 @@ export const closePositionTool = {
|
|
|
95
95
|
triggerType: preparedArgs.triggerType !== undefined ? mapTriggerType(preparedArgs.triggerType) : undefined,
|
|
96
96
|
executionFeeToken: poolData.quoteToken || preparedArgs.executionFeeToken
|
|
97
97
|
};
|
|
98
|
+
const positionsRes = await client.position.listPositions(address);
|
|
99
|
+
const positions = Array.isArray(positionsRes?.data) ? positionsRes.data : [];
|
|
100
|
+
const target = positions.find((position) => {
|
|
101
|
+
const pid = String(position?.positionId ?? position?.position_id ?? "").trim().toLowerCase();
|
|
102
|
+
const pool = String(position?.poolId ?? position?.pool_id ?? "").trim().toLowerCase();
|
|
103
|
+
return pid === String(preparedArgs.positionId ?? "").trim().toLowerCase() && pool === String(poolId).toLowerCase();
|
|
104
|
+
});
|
|
105
|
+
if (!target) {
|
|
106
|
+
throw new Error(`Could not find live position snapshot for positionId=${preparedArgs.positionId} in poolId=${poolId}.`);
|
|
107
|
+
}
|
|
108
|
+
const liveDirection = Number(target?.direction);
|
|
109
|
+
if (!Number.isFinite(liveDirection) || liveDirection !== mappedArgs.direction) {
|
|
110
|
+
throw new Error(`direction mismatch for positionId=${preparedArgs.positionId}: input=${mappedArgs.direction}, live=${String(target?.direction ?? "unknown")}.`);
|
|
111
|
+
}
|
|
98
112
|
const raw = await closePos(client, address, mappedArgs);
|
|
99
113
|
const data = await finalizeMutationResult(raw, signer, "close_position");
|
|
100
114
|
const txHash = data.confirmation?.txHash;
|
|
@@ -10,6 +10,14 @@ import { parseUserUnits } from "../utils/units.js";
|
|
|
10
10
|
import { isZeroAddress } from "../utils/address.js";
|
|
11
11
|
const POSITION_ID_RE = /^$|^0x[0-9a-fA-F]{64}$/;
|
|
12
12
|
const ZERO_POSITION_ID_RE = /^0x0{64}$/i;
|
|
13
|
+
function pow10(decimals) {
|
|
14
|
+
return 10n ** BigInt(decimals);
|
|
15
|
+
}
|
|
16
|
+
function computeQuoteNotionalRaw(sizeRaw, priceRaw30, baseDecimals, quoteDecimals) {
|
|
17
|
+
const numerator = sizeRaw * priceRaw30 * pow10(quoteDecimals);
|
|
18
|
+
const denominator = pow10(baseDecimals + 30);
|
|
19
|
+
return numerator / denominator;
|
|
20
|
+
}
|
|
13
21
|
export const executeTradeTool = {
|
|
14
22
|
name: "execute_trade",
|
|
15
23
|
description: "[TRADE] Create an increase order (open or add to position) using SDK-native parameters.",
|
|
@@ -76,6 +84,22 @@ export const executeTradeTool = {
|
|
|
76
84
|
throw new Error("size must be > 0.");
|
|
77
85
|
if (BigInt(priceRaw) <= 0n)
|
|
78
86
|
throw new Error("price must be > 0.");
|
|
87
|
+
if (normalizedPositionId) {
|
|
88
|
+
const positionsRes = await client.position.listPositions(address);
|
|
89
|
+
const positions = Array.isArray(positionsRes?.data) ? positionsRes.data : [];
|
|
90
|
+
const target = positions.find((position) => {
|
|
91
|
+
const positionId = String(position?.positionId ?? position?.position_id ?? "").trim().toLowerCase();
|
|
92
|
+
const positionPoolId = String(position?.poolId ?? position?.pool_id ?? "").trim().toLowerCase();
|
|
93
|
+
return positionId === normalizedPositionId.toLowerCase() && positionPoolId === String(poolId).toLowerCase();
|
|
94
|
+
});
|
|
95
|
+
if (!target) {
|
|
96
|
+
throw new Error(`Could not find live position for positionId=${normalizedPositionId} in poolId=${poolId}.`);
|
|
97
|
+
}
|
|
98
|
+
const liveDirection = Number(target?.direction);
|
|
99
|
+
if (!Number.isFinite(liveDirection) || liveDirection !== mappedDirection) {
|
|
100
|
+
throw new Error(`direction mismatch for positionId=${normalizedPositionId}: input=${mappedDirection}, live=${String(target?.direction ?? "unknown")}.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
79
103
|
let tradingFeeRaw = "";
|
|
80
104
|
let tradingFeeMeta = { source: "user" };
|
|
81
105
|
const tradingFeeInput = String(args.tradingFee ?? "").trim();
|
|
@@ -107,7 +131,8 @@ export const executeTradeTool = {
|
|
|
107
131
|
}
|
|
108
132
|
const rateRaw = args.postOnly ? feeRes.data.makerFeeRate : feeRes.data.takerFeeRate;
|
|
109
133
|
const rateBig = BigInt(String(rateRaw ?? "0"));
|
|
110
|
-
|
|
134
|
+
const notionalQuoteRaw = computeQuoteNotionalRaw(BigInt(sizeRaw), BigInt(priceRaw), baseDecimals, quoteDecimals);
|
|
135
|
+
tradingFeeRaw = ((notionalQuoteRaw * rateBig) / 1000000n).toString();
|
|
111
136
|
tradingFeeMeta = { source: "computed", assetClass, riskTier, feeRate: String(rateRaw ?? "0") };
|
|
112
137
|
}
|
|
113
138
|
const mappedArgs = {
|
|
@@ -5,6 +5,7 @@ import { resolveClient, getChainId } from "../auth/resolveClient.js";
|
|
|
5
5
|
import { resolvePool } from "../services/marketService.js";
|
|
6
6
|
import { finalizeMutationResult } from "../utils/mutationResult.js";
|
|
7
7
|
import { extractErrorMessage } from "../utils/errorMessage.js";
|
|
8
|
+
import { normalizeLpSlippageRatio } from "../utils/slippage.js";
|
|
8
9
|
function normalizeAssetSymbol(value) {
|
|
9
10
|
const text = String(value ?? "").trim();
|
|
10
11
|
if (!text)
|
|
@@ -67,7 +68,7 @@ export const manageLiquidityTool = {
|
|
|
67
68
|
action: z.coerce.string().describe("'deposit' or 'withdraw' (aliases: add/remove/increase/decrease; case-insensitive)"),
|
|
68
69
|
poolType: z.enum(["BASE", "QUOTE"]).describe("'BASE' or 'QUOTE'"),
|
|
69
70
|
poolId: z.string().describe("Pool ID or Base Token Address"),
|
|
70
|
-
amount: z.coerce.
|
|
71
|
+
amount: z.coerce.string().describe("Amount in human-readable units string"),
|
|
71
72
|
slippage: z.coerce.number().min(0).describe("LP slippage ratio (e.g. 0.01 = 1%)"),
|
|
72
73
|
chainId: z.coerce.number().int().positive().optional().describe("Optional chainId override"),
|
|
73
74
|
},
|
|
@@ -75,9 +76,13 @@ export const manageLiquidityTool = {
|
|
|
75
76
|
try {
|
|
76
77
|
const { client, signer } = await resolveClient();
|
|
77
78
|
let { action, poolType, poolId } = args;
|
|
78
|
-
const
|
|
79
|
+
const amount = String(args.amount ?? "").trim();
|
|
80
|
+
const slippage = normalizeLpSlippageRatio(args.slippage);
|
|
79
81
|
const chainId = args.chainId ?? getChainId();
|
|
80
82
|
action = String(action ?? "").trim().toLowerCase();
|
|
83
|
+
if (!amount) {
|
|
84
|
+
throw new Error("amount is required.");
|
|
85
|
+
}
|
|
81
86
|
const validActions = new Set(["deposit", "withdraw", "add", "remove", "increase", "decrease"]);
|
|
82
87
|
if (!validActions.has(action)) {
|
|
83
88
|
throw new Error(`Invalid action: ${args.action}. Use deposit/withdraw or aliases add/remove/increase/decrease.`);
|
package/dist/tools/manageTpSl.js
CHANGED
|
@@ -29,7 +29,7 @@ function readSnapshotText(snapshot, keys) {
|
|
|
29
29
|
}
|
|
30
30
|
return "";
|
|
31
31
|
}
|
|
32
|
-
function
|
|
32
|
+
function normalizeSizeInput(text) {
|
|
33
33
|
const raw = String(text ?? "").trim();
|
|
34
34
|
if (!raw)
|
|
35
35
|
return "";
|
|
@@ -39,6 +39,17 @@ function normalizeUnitsInput(text) {
|
|
|
39
39
|
return `raw:${raw}`;
|
|
40
40
|
return raw;
|
|
41
41
|
}
|
|
42
|
+
function normalizePriceInput(text, source = "user") {
|
|
43
|
+
const raw = String(text ?? "").trim();
|
|
44
|
+
if (!raw)
|
|
45
|
+
return "";
|
|
46
|
+
if (/^(raw|human):/i.test(raw))
|
|
47
|
+
return raw;
|
|
48
|
+
if (source === "snapshot" && /^\d+$/.test(raw) && raw.length > 12) {
|
|
49
|
+
return `raw:${raw}`;
|
|
50
|
+
}
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
42
53
|
function isExplicitZeroValue(value) {
|
|
43
54
|
if (value === undefined || value === null)
|
|
44
55
|
return false;
|
|
@@ -160,6 +171,58 @@ function resolvePositionSizeRaw(positionSnapshot, baseDecimals) {
|
|
|
160
171
|
}
|
|
161
172
|
return "";
|
|
162
173
|
}
|
|
174
|
+
function resolveEntryPriceInput(positionSnapshot) {
|
|
175
|
+
const rawCandidates = [
|
|
176
|
+
positionSnapshot?.entryPriceRaw,
|
|
177
|
+
positionSnapshot?.openPriceRaw,
|
|
178
|
+
positionSnapshot?.avgPriceRaw,
|
|
179
|
+
positionSnapshot?.averageOpenPriceRaw,
|
|
180
|
+
];
|
|
181
|
+
for (const value of rawCandidates) {
|
|
182
|
+
const text = String(value ?? "").trim();
|
|
183
|
+
if (INTEGER_RE.test(text))
|
|
184
|
+
return `raw:${text}`;
|
|
185
|
+
}
|
|
186
|
+
const humanCandidates = [
|
|
187
|
+
positionSnapshot?.entryPrice,
|
|
188
|
+
positionSnapshot?.openPrice,
|
|
189
|
+
positionSnapshot?.avgPrice,
|
|
190
|
+
positionSnapshot?.averageOpenPrice,
|
|
191
|
+
];
|
|
192
|
+
for (const value of humanCandidates) {
|
|
193
|
+
const text = String(value ?? "").trim();
|
|
194
|
+
if (text)
|
|
195
|
+
return text;
|
|
196
|
+
}
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
function validateTpSlPriceSemantics(direction, entryPriceInput, tpPriceInput, slPriceInput) {
|
|
200
|
+
if (!entryPriceInput) {
|
|
201
|
+
throw new Error("Unable to resolve entryPrice for TP/SL validation.");
|
|
202
|
+
}
|
|
203
|
+
const entryPriceRaw = BigInt(parseUserUnits(entryPriceInput, 30, "entryPrice"));
|
|
204
|
+
if (entryPriceRaw <= 0n) {
|
|
205
|
+
throw new Error("entryPrice must be > 0 for TP/SL validation.");
|
|
206
|
+
}
|
|
207
|
+
if (tpPriceInput) {
|
|
208
|
+
const tpPriceRaw = BigInt(parseUserUnits(tpPriceInput, 30, "tpPrice"));
|
|
209
|
+
if (direction === 0 && tpPriceRaw <= entryPriceRaw) {
|
|
210
|
+
throw new Error("LONG TP must be greater than entryPrice.");
|
|
211
|
+
}
|
|
212
|
+
if (direction === 1 && tpPriceRaw >= entryPriceRaw) {
|
|
213
|
+
throw new Error("SHORT TP must be less than entryPrice.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (slPriceInput) {
|
|
217
|
+
const slPriceRaw = BigInt(parseUserUnits(slPriceInput, 30, "slPrice"));
|
|
218
|
+
if (direction === 0 && slPriceRaw >= entryPriceRaw) {
|
|
219
|
+
throw new Error("LONG SL must be less than entryPrice.");
|
|
220
|
+
}
|
|
221
|
+
if (direction === 1 && slPriceRaw <= entryPriceRaw) {
|
|
222
|
+
throw new Error("SHORT SL must be greater than entryPrice.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
163
226
|
async function cancelTpSlByIntent(client, address, signer, chainId, args) {
|
|
164
227
|
if (args.orderId) {
|
|
165
228
|
const raw = await client.order.cancelAllOrders([String(args.orderId)], chainId);
|
|
@@ -265,12 +328,21 @@ export const manageTpSlTool = {
|
|
|
265
328
|
const snapshotTpSize = readSnapshotText(orderSnapshot, ["tpSize", "takeProfitSize"]);
|
|
266
329
|
const snapshotSlPrice = readSnapshotText(orderSnapshot, ["slPrice", "stopLossPrice", "slTriggerPrice"]);
|
|
267
330
|
const snapshotSlSize = readSnapshotText(orderSnapshot, ["slSize", "stopLossSize"]);
|
|
331
|
+
const orderPositionId = readSnapshotText(orderSnapshot, ["positionId", "position_id"]);
|
|
268
332
|
const size = isNonEmpty(args.size) ? String(args.size) : snapshotSize;
|
|
269
333
|
const price = isNonEmpty(args.price) ? String(args.price) : snapshotPrice;
|
|
270
334
|
const tpPrice = isNonEmpty(args.tpPrice) ? String(args.tpPrice) : snapshotTpPrice;
|
|
271
335
|
const tpSize = isNonEmpty(args.tpSize) ? String(args.tpSize) : snapshotTpSize;
|
|
272
336
|
const slPrice = isNonEmpty(args.slPrice) ? String(args.slPrice) : snapshotSlPrice;
|
|
273
337
|
const slSize = isNonEmpty(args.slSize) ? String(args.slSize) : snapshotSlSize;
|
|
338
|
+
const updateDirection = normalizeDirectionInput(args.direction ?? orderSnapshot?.direction);
|
|
339
|
+
const positionSnapshot = orderPositionId
|
|
340
|
+
? await findPositionSnapshot(client, address, args.poolId, orderPositionId)
|
|
341
|
+
: null;
|
|
342
|
+
if (updateDirection !== undefined) {
|
|
343
|
+
const entryPriceInput = resolveEntryPriceInput(positionSnapshot);
|
|
344
|
+
validateTpSlPriceSemantics(updateDirection, entryPriceInput, tpPrice || undefined, slPrice || undefined);
|
|
345
|
+
}
|
|
274
346
|
if (!size || !price) {
|
|
275
347
|
throw new Error("size and price are required for update. Provide them explicitly, or ensure orderId can be found via get_orders so they can be auto-resolved.");
|
|
276
348
|
}
|
|
@@ -288,12 +360,12 @@ export const manageTpSlTool = {
|
|
|
288
360
|
orderId: args.orderId,
|
|
289
361
|
marketId: market.marketId,
|
|
290
362
|
poolId: args.poolId,
|
|
291
|
-
size:
|
|
292
|
-
price:
|
|
293
|
-
tpPrice: tpPrice ?
|
|
294
|
-
tpSize: tpSize ?
|
|
295
|
-
slPrice: slPrice ?
|
|
296
|
-
slSize: slSize ?
|
|
363
|
+
size: normalizeSizeInput(size),
|
|
364
|
+
price: normalizePriceInput(price, isNonEmpty(args.price) ? "user" : "snapshot"),
|
|
365
|
+
tpPrice: tpPrice ? normalizePriceInput(tpPrice, isNonEmpty(args.tpPrice) ? "user" : "snapshot") : "0",
|
|
366
|
+
tpSize: tpSize ? normalizeSizeInput(tpSize) : "0",
|
|
367
|
+
slPrice: slPrice ? normalizePriceInput(slPrice, isNonEmpty(args.slPrice) ? "user" : "snapshot") : "0",
|
|
368
|
+
slSize: slSize ? normalizeSizeInput(slSize) : "0",
|
|
297
369
|
quoteToken: market.quoteToken,
|
|
298
370
|
useOrderCollateral: args.useOrderCollateral ?? true
|
|
299
371
|
}, chainId);
|
|
@@ -326,6 +398,8 @@ export const manageTpSlTool = {
|
|
|
326
398
|
if (resolvedDirection === undefined || !resolvedLeverage || Number(resolvedLeverage) <= 0) {
|
|
327
399
|
throw new Error("direction and leverage are required when creating new TP/SL.");
|
|
328
400
|
}
|
|
401
|
+
const entryPriceInput = resolveEntryPriceInput(positionSnapshot);
|
|
402
|
+
validateTpSlPriceSemantics(resolvedDirection, entryPriceInput, isNonEmpty(args.tpPrice) ? String(args.tpPrice) : undefined, isNonEmpty(args.slPrice) ? String(args.slPrice) : undefined);
|
|
329
403
|
let tpSizeInput = isNonEmpty(args.tpSize) ? String(args.tpSize) : "";
|
|
330
404
|
let slSizeInput = isNonEmpty(args.slSize) ? String(args.slSize) : "";
|
|
331
405
|
const needsTpSize = isNonEmpty(args.tpPrice) && !tpSizeInput;
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { OrderType } from "@myx-trade/sdk";
|
|
3
3
|
import { formatUnits } from "ethers";
|
|
4
4
|
import { resolveClient, getChainId } from "../auth/resolveClient.js";
|
|
5
|
-
import { resolvePool } from "../services/marketService.js";
|
|
5
|
+
import { resolvePool, getFreshOraclePrice } from "../services/marketService.js";
|
|
6
6
|
import { openPosition } from "../services/tradeService.js";
|
|
7
7
|
import { isZeroAddress, normalizeAddress } from "../utils/address.js";
|
|
8
8
|
import { finalizeMutationResult } from "../utils/mutationResult.js";
|
|
@@ -25,6 +25,29 @@ function asBigint(raw, label) {
|
|
|
25
25
|
throw new Error(`${label} must be an integer string.`);
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
function computeQuoteNotionalRaw(sizeRaw, priceRaw30, baseDecimals, quoteDecimals) {
|
|
29
|
+
const numerator = sizeRaw * priceRaw30 * pow10(quoteDecimals);
|
|
30
|
+
const denominator = pow10(baseDecimals + 30);
|
|
31
|
+
return numerator / denominator;
|
|
32
|
+
}
|
|
33
|
+
async function getRequiredApprovalSpendRaw(client, marketId, args, chainId) {
|
|
34
|
+
const networkFeeText = String(await client.utils.getNetworkFee(marketId, chainId) ?? "").trim();
|
|
35
|
+
if (!/^\d+$/.test(networkFeeText)) {
|
|
36
|
+
throw new Error(`Failed to resolve networkFee for marketId=${marketId}.`);
|
|
37
|
+
}
|
|
38
|
+
const baseNetworkFeeRaw = BigInt(networkFeeText);
|
|
39
|
+
if (baseNetworkFeeRaw <= 0n) {
|
|
40
|
+
throw new Error(`networkFee must be > 0 for marketId=${marketId}.`);
|
|
41
|
+
}
|
|
42
|
+
let executionOrderCount = 1n;
|
|
43
|
+
if (args.tpPrice && BigInt(String(args.tpSize ?? "0")) > 0n)
|
|
44
|
+
executionOrderCount += 1n;
|
|
45
|
+
if (args.slPrice && BigInt(String(args.slSize ?? "0")) > 0n)
|
|
46
|
+
executionOrderCount += 1n;
|
|
47
|
+
return (args.collateralRaw +
|
|
48
|
+
BigInt(args.tradingFeeRaw) +
|
|
49
|
+
(baseNetworkFeeRaw * executionOrderCount)).toString();
|
|
50
|
+
}
|
|
28
51
|
function pickMarketDetail(res) {
|
|
29
52
|
if (!res)
|
|
30
53
|
return null;
|
|
@@ -128,19 +151,9 @@ export const openPositionSimpleTool = {
|
|
|
128
151
|
let price30;
|
|
129
152
|
let priceMeta = { source: "user", publishTime: null, oracleType: null, human: null };
|
|
130
153
|
if (orderType === OrderType.MARKET) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
priceMeta = { source: "oracle", publishTime: oracle.publishTime, oracleType: oracle.oracleType, human: oracle.price };
|
|
135
|
-
}
|
|
136
|
-
catch (e) {
|
|
137
|
-
const tickers = await client.markets.getTickerList({ chainId, poolIds: [poolId] });
|
|
138
|
-
const row = Array.isArray(tickers) ? tickers[0] : tickers?.data?.[0];
|
|
139
|
-
if (!row?.price)
|
|
140
|
-
throw new Error(`Failed to fetch oracle and ticker price for poolId=${poolId}: ${e?.message || e}`);
|
|
141
|
-
price30 = parseUserPrice30(row.price, "marketPrice");
|
|
142
|
-
priceMeta = { source: "ticker", publishTime: null, oracleType: null, human: row.price };
|
|
143
|
-
}
|
|
154
|
+
const oracle = await getFreshOraclePrice(client, poolId, chainId);
|
|
155
|
+
price30 = parseUserPrice30(oracle.price, "oraclePrice");
|
|
156
|
+
priceMeta = { source: "oracle", publishTime: oracle.publishTime, oracleType: oracle.oracleType, human: oracle.price };
|
|
144
157
|
}
|
|
145
158
|
else {
|
|
146
159
|
const userPrice = String(args.price ?? "").trim();
|
|
@@ -219,7 +232,8 @@ export const openPositionSimpleTool = {
|
|
|
219
232
|
const rateRaw = postOnly ? feeData.makerFeeRate : feeData.takerFeeRate;
|
|
220
233
|
tradingFeeMeta.feeRate = rateRaw;
|
|
221
234
|
const rate = asBigint(String(rateRaw), "feeRate");
|
|
222
|
-
const
|
|
235
|
+
const notionalQuoteRaw = computeQuoteNotionalRaw(sizeRawBig, price30Big, baseDecimals, quoteDecimals);
|
|
236
|
+
const fee = (notionalQuoteRaw * rate) / 1000000n;
|
|
223
237
|
tradingFeeRaw = fee.toString();
|
|
224
238
|
}
|
|
225
239
|
else {
|
|
@@ -274,9 +288,14 @@ export const openPositionSimpleTool = {
|
|
|
274
288
|
// 7) Optional approval
|
|
275
289
|
let approval = null;
|
|
276
290
|
if (args.autoApprove) {
|
|
277
|
-
const requiredApprovalRaw =
|
|
278
|
-
|
|
279
|
-
:
|
|
291
|
+
const requiredApprovalRaw = await getRequiredApprovalSpendRaw(client, marketId, {
|
|
292
|
+
collateralRaw: collateralRawBig,
|
|
293
|
+
tradingFeeRaw: String(tradingFeeRaw),
|
|
294
|
+
tpPrice: prep.tpPrice,
|
|
295
|
+
tpSize: prep.tpSize,
|
|
296
|
+
slPrice: prep.slPrice,
|
|
297
|
+
slSize: prep.slSize,
|
|
298
|
+
}, chainId);
|
|
280
299
|
const needApproval = await client.utils.needsApproval(address, chainId, quoteToken, requiredApprovalRaw);
|
|
281
300
|
if (needApproval) {
|
|
282
301
|
const approveAmount = args.approveMax ? MAX_UINT256 : requiredApprovalRaw;
|
package/dist/utils/slippage.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const SLIPPAGE_PCT_4DP_RE = /^\d+$/;
|
|
2
2
|
export const SLIPPAGE_PCT_4DP_MAX = 10000n;
|
|
3
|
+
export const BUSINESS_SLIPPAGE_PCT_4DP_MAX = BigInt(process.env.BUSINESS_MAX_SLIPPAGE_PCT_4DP ?? "500");
|
|
3
4
|
export const SLIPPAGE_PCT_4DP_DESC = "Slippage in 4-decimal precision raw units (1 = 0.01%, 10000 = 100%)";
|
|
4
5
|
const SLIPPAGE_PERCENT_HUMAN_RE = /^\d+(\.\d{1,2})?$/;
|
|
6
|
+
export const BUSINESS_LP_SLIPPAGE_MAX_RATIO = Number(process.env.BUSINESS_MAX_LP_SLIPPAGE_RATIO ?? 0.05);
|
|
5
7
|
export function isValidSlippagePct4dp(value) {
|
|
6
8
|
if (!SLIPPAGE_PCT_4DP_RE.test(value))
|
|
7
9
|
return false;
|
|
@@ -12,6 +14,10 @@ export function normalizeSlippagePct4dp(value, label = "slippagePct") {
|
|
|
12
14
|
if (!isValidSlippagePct4dp(raw)) {
|
|
13
15
|
throw new Error(`${label} must be an integer in [0, 10000] with 4-decimal precision (1 = 0.01%).`);
|
|
14
16
|
}
|
|
17
|
+
const parsed = BigInt(raw);
|
|
18
|
+
if (parsed > BUSINESS_SLIPPAGE_PCT_4DP_MAX) {
|
|
19
|
+
throw new Error(`${label} exceeds business safety cap ${BUSINESS_SLIPPAGE_PCT_4DP_MAX.toString()} (${Number(BUSINESS_SLIPPAGE_PCT_4DP_MAX) / 100}%).`);
|
|
20
|
+
}
|
|
15
21
|
return raw;
|
|
16
22
|
}
|
|
17
23
|
export function normalizeSlippagePct4dpFlexible(value, label = "slippagePct") {
|
|
@@ -34,5 +40,19 @@ export function normalizeSlippagePct4dpFlexible(value, label = "slippagePct") {
|
|
|
34
40
|
if (converted > SLIPPAGE_PCT_4DP_MAX) {
|
|
35
41
|
throw new Error(`${label} must be <= 100% (raw <= 10000).`);
|
|
36
42
|
}
|
|
43
|
+
if (converted > BUSINESS_SLIPPAGE_PCT_4DP_MAX) {
|
|
44
|
+
throw new Error(`${label} exceeds business safety cap ${BUSINESS_SLIPPAGE_PCT_4DP_MAX.toString()} (${Number(BUSINESS_SLIPPAGE_PCT_4DP_MAX) / 100}%).`);
|
|
45
|
+
}
|
|
37
46
|
return converted.toString();
|
|
38
47
|
}
|
|
48
|
+
export function normalizeLpSlippageRatio(value, label = "slippage") {
|
|
49
|
+
const numeric = Number(value);
|
|
50
|
+
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
51
|
+
throw new Error(`${label} must be a finite number >= 0.`);
|
|
52
|
+
}
|
|
53
|
+
const ratio = numeric > 1 && numeric <= 100 ? numeric / 100 : numeric;
|
|
54
|
+
if (ratio > BUSINESS_LP_SLIPPAGE_MAX_RATIO) {
|
|
55
|
+
throw new Error(`${label} exceeds business safety cap ${BUSINESS_LP_SLIPPAGE_MAX_RATIO * 100}%.`);
|
|
56
|
+
}
|
|
57
|
+
return ratio;
|
|
58
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Contract } from "ethers";
|
|
2
|
+
const ERC20_DECIMALS_ABI = [
|
|
3
|
+
"function decimals() view returns (uint8)",
|
|
4
|
+
];
|
|
5
|
+
export async function fetchErc20Decimals(providerOrSigner, tokenAddress, label = "token") {
|
|
6
|
+
if (!providerOrSigner) {
|
|
7
|
+
throw new Error(`Provider unavailable while resolving decimals for ${label}.`);
|
|
8
|
+
}
|
|
9
|
+
const contract = new Contract(tokenAddress, ERC20_DECIMALS_ABI, providerOrSigner);
|
|
10
|
+
const decimals = Number(await contract.decimals());
|
|
11
|
+
if (!Number.isFinite(decimals) || decimals < 0) {
|
|
12
|
+
throw new Error(`Invalid decimals returned for ${label}: ${decimals}`);
|
|
13
|
+
}
|
|
14
|
+
return Math.floor(decimals);
|
|
15
|
+
}
|
package/dist/utils/units.js
CHANGED
|
@@ -36,25 +36,17 @@ export function ensureUnits(value, decimals, label = "value", options = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
if (!DECIMAL_RE.test(str))
|
|
38
38
|
throw new Error(`${label} must be a numeric string.`);
|
|
39
|
-
// Truncate decimals if they exceed the allowed precision to prevent parseUnits throwing
|
|
40
39
|
if (str.includes(".")) {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
str = `${parts[0]}.${parts[1].slice(0, decimals)}`;
|
|
40
|
+
const [, fracPart = ""] = str.split(".");
|
|
41
|
+
if (fracPart.length > decimals) {
|
|
42
|
+
throw new Error(`${label} exceeds supported precision: got ${fracPart.length} decimals, max is ${decimals}.`);
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
// Legacy compatibility: optionally treat very large integers as already raw.
|
|
48
46
|
if (allowImplicitRaw && !str.includes(".") && (str.length > 12 || str.length > decimals)) {
|
|
49
47
|
return str;
|
|
50
48
|
}
|
|
51
|
-
|
|
52
|
-
return parseUnits(str, decimals).toString();
|
|
53
|
-
}
|
|
54
|
-
catch (e) {
|
|
55
|
-
console.warn(`[ensureUnits] parseUnits failed for ${label}: ${str}. Returning raw.`);
|
|
56
|
-
return str;
|
|
57
|
-
}
|
|
49
|
+
return parseUnits(str, decimals).toString();
|
|
58
50
|
}
|
|
59
51
|
export function parseUserUnits(value, decimals, label = "value") {
|
|
60
52
|
let str = String(value).trim();
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { getChainId } from "../auth/resolveClient.js";
|
|
2
2
|
import { decodeErrorSelector } from "./errors.js";
|
|
3
|
+
import { getHistoryOrderStatusDesc } from "./mappings.js";
|
|
4
|
+
const FINAL_HISTORY_STATUSES = new Set([1, 2, 9]);
|
|
5
|
+
function collectHistoryRows(historyRes) {
|
|
6
|
+
if (Array.isArray(historyRes?.data))
|
|
7
|
+
return historyRes.data;
|
|
8
|
+
if (Array.isArray(historyRes?.data?.data))
|
|
9
|
+
return historyRes.data.data;
|
|
10
|
+
if (Array.isArray(historyRes))
|
|
11
|
+
return historyRes;
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
function normalizeHash(value) {
|
|
15
|
+
return String(value ?? "").trim().toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
function enrichCancelReason(order) {
|
|
18
|
+
let cancelReason = order?.cancelReason || (Number(order?.status) === 1 ? "Unknown cancellation" : null);
|
|
19
|
+
if (cancelReason && cancelReason.startsWith("0x")) {
|
|
20
|
+
const decoded = decodeErrorSelector(cancelReason);
|
|
21
|
+
if (decoded)
|
|
22
|
+
cancelReason = `${cancelReason} (${decoded})`;
|
|
23
|
+
}
|
|
24
|
+
return cancelReason;
|
|
25
|
+
}
|
|
3
26
|
/**
|
|
4
27
|
* 等待后端索引并验证交易结果 (增强版)
|
|
5
28
|
*/
|
|
@@ -9,22 +32,26 @@ export async function verifyTradeOutcome(client, address, poolId, txHash) {
|
|
|
9
32
|
const maxAttempts = 6;
|
|
10
33
|
let currentDelay = 1000;
|
|
11
34
|
let matchedOrder = null;
|
|
35
|
+
let matchedBy = null;
|
|
12
36
|
for (let i = 0; i < maxAttempts; i++) {
|
|
13
37
|
try {
|
|
14
38
|
// 查询历史订单
|
|
15
39
|
const historyRes = await client.order.getOrderHistory({
|
|
16
40
|
chainId,
|
|
17
41
|
poolId,
|
|
18
|
-
limit:
|
|
42
|
+
limit: 50,
|
|
43
|
+
page: 1,
|
|
19
44
|
}, address);
|
|
20
|
-
const history = historyRes
|
|
21
|
-
|
|
22
|
-
|
|
45
|
+
const history = collectHistoryRows(historyRes);
|
|
46
|
+
const targetHash = normalizeHash(txHash);
|
|
47
|
+
const txHashMatch = history.find((o) => normalizeHash(o.txHash) === targetHash);
|
|
48
|
+
const orderHashMatch = history.find((o) => normalizeHash(o.orderHash) === targetHash);
|
|
49
|
+
matchedOrder = txHashMatch ?? orderHashMatch ?? null;
|
|
50
|
+
matchedBy = txHashMatch ? "txHash" : orderHashMatch ? "orderHash" : null;
|
|
23
51
|
// 如果找到了订单且已经有最终状态,则退出轮询
|
|
24
52
|
if (matchedOrder) {
|
|
25
53
|
const status = Number(matchedOrder.status);
|
|
26
|
-
if (status
|
|
27
|
-
// 1: Cancelled, 9: Successful, 2: Expired
|
|
54
|
+
if (FINAL_HISTORY_STATUSES.has(status)) {
|
|
28
55
|
break;
|
|
29
56
|
}
|
|
30
57
|
}
|
|
@@ -46,16 +73,20 @@ export async function verifyTradeOutcome(client, address, poolId, txHash) {
|
|
|
46
73
|
catch (e) {
|
|
47
74
|
console.warn(`[verifyTradeOutcome] Failed to fetch positions:`, e);
|
|
48
75
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
76
|
+
const statusCode = matchedOrder ? Number(matchedOrder.status) : null;
|
|
77
|
+
const statusText = statusCode === null || !Number.isFinite(statusCode)
|
|
78
|
+
? null
|
|
79
|
+
: getHistoryOrderStatusDesc(statusCode);
|
|
80
|
+
const final = statusCode !== null && FINAL_HISTORY_STATUSES.has(statusCode);
|
|
81
|
+
const cancelReason = matchedOrder ? enrichCancelReason(matchedOrder) : null;
|
|
55
82
|
return {
|
|
56
83
|
order: matchedOrder,
|
|
57
84
|
positions: positions,
|
|
58
85
|
verified: !!matchedOrder,
|
|
86
|
+
matchedBy,
|
|
87
|
+
statusCode,
|
|
88
|
+
statusText,
|
|
89
|
+
final,
|
|
59
90
|
cancelReason
|
|
60
91
|
};
|
|
61
92
|
}
|