@michaleffffff/mcp-trading-server 3.0.24 → 3.0.27

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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.27 - 2026-03-20
4
+
5
+ ### Fixed
6
+ - Hardened LP MCP behavior for mismatched token metadata and non-standard ERC20 approvals:
7
+ - `get_my_lp_holdings` now retries balance discovery against live `poolInfo` LP token addresses when market detail addresses are stale.
8
+ - LP router fallback now treats SDK `allowance()` read reverts as recoverable and attempts direct router approval/execution.
9
+ - Removed the MCP-side 5% LP slippage business cap; `manage_liquidity.slippage` now only normalizes ratio input and leaves risk tolerance to the caller / downstream contracts.
10
+
11
+ ### Changed
12
+ - Refreshed operator docs and examples to match current LP execution semantics:
13
+ - `TOOL_EXAMPLES.md` clarifies LP `slippage` ratio semantics and removal of the business cap.
14
+ - `trading_best_practices` prompt now aligns with release `v3.0.27`.
15
+
16
+ ## 3.0.26 - 2026-03-20
17
+
18
+ ### Fixed
19
+ - Removed MCP-side increase-order margin reconciliation that could conflict with SDK `createIncreaseOrder`:
20
+ - `open_position_simple` / `execute_trade` now validate parameters locally but delegate deposit-delta handling entirely to the SDK write path.
21
+ - Deprecated `autoDeposit` as a compatibility-only flag in `open_position_simple`.
22
+ - Realigned `check_account_ready` with SDK trading semantics:
23
+ - Uses SDK `getAvailableMarginBalance` as the primary trading-account source instead of trusting raw `freeMargin`.
24
+ - Returns degraded diagnostics (`summary.degraded`, `diagnostics.availableMarginError`) when SDK margin reads fail.
25
+
26
+ ### Changed
27
+ - Refreshed operator docs and examples to match current execution semantics:
28
+ - `README.md` now documents explicit `price` in the quick-start trade example, SDK-delegated increase-order funding deltas, and the new `check_account_ready` degraded diagnostics.
29
+ - `TOOL_EXAMPLES.md` now marks `autoDeposit` as deprecated and updates `MARKET` examples to provide `price`.
30
+ - `trading_best_practices` prompt now explains that pre-checks use SDK `availableMarginBalance`, that increase-order funding reconciliation lives in the SDK, and that LP holdings may be resolved from live pool token addresses.
31
+ - Updated stale tests to match current MCP semantics:
32
+ - `tests/test_trading.ts` now provides `price` for `open_position_simple` market dry runs.
33
+ - `tests/verify_tp_sl_close_invalid_param.mjs` no longer relies on legacy `autoDeposit` semantics and now supplies `price` when opening a market position.
34
+
35
+ ## 3.0.25 - 2026-03-19
36
+
37
+ ### Fixed
38
+ - Improved MCP compatibility and diagnostics for current testnet brokers:
39
+ - `resolveClient` now auto-detects beta mode for Arbitrum Sepolia broker `0x895C4ae2A22bB26851011d733A9355f663a1F939`
40
+ - `resolveClient` now auto-detects beta mode for Linea Sepolia broker `0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2`
41
+ - nested viem / SDK revert payloads now decode common selectors like `AccountInsufficientTradableAmount(uint256,uint256)`
42
+ - `open_position_simple` now returns decoded nested contract errors instead of only raw error text
43
+
3
44
  ## 3.0.24 - 2026-03-19
4
45
 
5
46
  ### Fixed
@@ -8,6 +49,7 @@
8
49
  - `get_pool_info` MCP-side market price resolution no longer falls back from fresh Oracle to ticker-derived price.
9
50
  - Beta LP router / `PoolManager` fallback mappings were completed for MCP-managed LP and create-market paths.
10
51
  - `get_my_lp_holdings` no longer ranks rows by mixed BASE/QUOTE LP raw balances.
52
+ - `open_position_simple` no longer auto-fills a fresh Oracle price for `MARKET` orders; callers must provide `price` explicitly.
11
53
 
12
54
  ### Changed
13
55
  - Synced release metadata and operator docs for `v3.0.24`:
package/README.md CHANGED
@@ -10,7 +10,7 @@ A production-ready MCP (Model Context Protocol) server for deep integration with
10
10
  - **SDK baseline**: `@myx-trade/sdk@^1.0.4-beta.4` 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
+ - **Safety refresh**: Docs and prompt guidance now reflect explicit-price execution for `open_position_simple`, exact-approval defaults, notional-based fee checks, TP/SL semantic validation, and LP preview fail-close behavior.
14
14
  - **Breaking changes**: Many low-level tools (e.g., `get_market_price`, `get_oracle_price`, `get_open_orders`) have been merged into unified counterparts.
15
15
 
16
16
  ---
@@ -37,6 +37,7 @@ CHAIN_ID=...
37
37
  BROKER_ADDRESS=0x...
38
38
  QUOTE_TOKEN_ADDRESS=0x...
39
39
  QUOTE_TOKEN_DECIMALS=...
40
+ IS_BETA_MODE=true
40
41
  ```
41
42
 
42
43
  ## Testnet `MYXBroker` Reference
@@ -45,6 +46,7 @@ QUOTE_TOKEN_DECIMALS=...
45
46
  - Linea test: `0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2`
46
47
 
47
48
  Use the broker that matches your active RPC and chain configuration.
49
+ If `IS_BETA_MODE` is omitted, MCP now auto-detects beta mode for the two testnet brokers above.
48
50
 
49
51
  ---
50
52
 
@@ -72,7 +74,7 @@ Use the broker that matches your active RPC and chain configuration.
72
74
  * **`get_orders`**: Historical and active order ledger.
73
75
  * **`get_positions_all`**: Currently open and recently closed positions.
74
76
  * **`get_trade_flow`**: Granular transaction history.
75
- * **`check_account_ready`**: Pre-trade balance validator with auto-deposit support.
77
+ * **`check_account_ready`**: Pre-trade balance validator aligned with SDK `availableMarginBalance` semantics.
76
78
 
77
79
  ---
78
80
 
@@ -80,21 +82,27 @@ Use the broker that matches your active RPC and chain configuration.
80
82
 
81
83
  1. **Find Target**: `find_pool(keyword="BTC")`
82
84
  2. **Check State**: `get_account_snapshot(poolId="...")`
83
- 3. **Execute**: `open_position_simple(poolId="...", direction="LONG", leverage=5, collateralAmount="100")`
85
+ 3. **Execute**: `open_position_simple(poolId="...", direction="LONG", leverage=5, collateralAmount="100", price="2500")`
84
86
  4. **Monitor**: `get_positions_all(status="OPEN")`
85
87
 
86
88
  ---
87
89
 
88
90
  # Safety Defaults
89
91
 
90
- - **Oracle-only execution**: trading paths now require a fresh Oracle price and reject stale / missing execution prices.
92
+ - **Explicit execution price**: `open_position_simple` no longer auto-fills a fresh Oracle price for `MARKET` orders; provide `price` explicitly when opening a position.
93
+ - **SDK-delegated funding delta**: MCP no longer performs its own increase-order margin/deposit reconciliation. New increase orders delegate collateral shortfall handling to SDK `createIncreaseOrder`.
94
+ - **Beta broker compatibility**: current Arbitrum Sepolia / Linea Sepolia test brokers auto-enable beta mode when `IS_BETA_MODE` is unset.
91
95
  - **Exact approvals by default**: local fallback flows now prefer exact approvals instead of implicit unlimited approval.
92
96
  - **Size semantics**: `size` always means base-asset quantity, not USD notional.
97
+ - **Pre-check semantics**: `check_account_ready` now uses SDK `getAvailableMarginBalance`. When that read fails, the response marks `summary.degraded=true` and includes diagnostics instead of silently trusting stale `freeMargin`.
93
98
  - **Direction validation**: when a tool operates on an existing `positionId`, the supplied `direction` must match the live position.
94
99
  - **TP/SL semantics**: LONG requires `tpPrice > entryPrice` and `slPrice < entryPrice`; SHORT uses the inverse.
95
100
  - **LP safety**: LP preview failures are fail-close and no longer downgrade to `minAmountOut=0`.
101
+ - **LP slippage input**: `manage_liquidity.slippage` only validates numeric format and ratio normalization; MCP no longer imposes its own 5% business cap.
96
102
  - **LP metadata safety**: `get_pool_metadata(includeLiquidity=true)` now ignores caller-supplied `marketPrice` and derives liquidity depth from a fresh Oracle price only.
97
103
  - **LP holdings semantics**: `get_my_lp_holdings` is an inventory view; returned rows are no longer ranked by mixed BASE/QUOTE LP raw balances.
104
+ - **LP token-source safety**: `get_my_lp_holdings` now falls back to live `poolInfo.basePool.poolToken` / `poolInfo.quotePool.poolToken` when market-detail token addresses are stale or mismatched.
105
+ - **LP approval fallback**: when SDK LP deposit paths fail on non-standard `allowance()` reads, MCP falls back to direct router approval/execution instead of stopping at the SDK error.
98
106
 
99
107
  ---
100
108
 
@@ -168,7 +176,7 @@ For `get_pool_metadata(includeLiquidity=true)`, do not rely on a custom `marketP
168
176
  "direction": "LONG",
169
177
  "collateralAmount": "100",
170
178
  "leverage": 5,
171
- "orderType": "LIMIT",
179
+ "orderType": "MARKET",
172
180
  "price": "2.5"
173
181
  }
174
182
  }
package/TOOL_EXAMPLES.md CHANGED
@@ -1,4 +1,4 @@
1
- # MYX MCP Tool Examples Handbook (v3.0.24)
1
+ # MYX MCP Tool Examples Handbook
2
2
 
3
3
  This guide provides practical MCP payload examples for the current unified toolset.
4
4
  All examples use the MCP format:
@@ -82,8 +82,9 @@ Recommended high-level entry tool.
82
82
  `marketId` is optional on `open_position_simple`. If supplied, it is validated against the market resolved from `poolId` or `keyword`.
83
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...`.
84
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.
85
+ Provide `price` explicitly for both `MARKET` and `LIMIT/STOP` on `open_position_simple`. MCP no longer auto-fills a fresh Oracle price for `MARKET`.
86
86
  Auto-computed `tradingFee` follows notional semantics rather than raw collateral-only estimation.
87
+ `autoDeposit` is now a deprecated compatibility flag. MCP delegates increase-order funding deltas to the SDK during `createIncreaseOrder`.
87
88
 
88
89
  Raw-units example:
89
90
 
@@ -95,7 +96,8 @@ Raw-units example:
95
96
  "direction": "SHORT",
96
97
  "collateralAmount": "raw:100000000",
97
98
  "leverage": 10,
98
- "orderType": "MARKET"
99
+ "orderType": "MARKET",
100
+ "price": "raw:2000000000000000000000000000000000"
99
101
  }
100
102
  }
101
103
  ```
@@ -302,6 +304,9 @@ Alias-friendly form also works:
302
304
 
303
305
  LP preview failures now fail closed; the server no longer downgrades to `minAmountOut=0`.
304
306
  Oracle-backed LP pricing requires a fresh price snapshot before execution.
307
+ `slippage` here is a ratio, so `0.01 = 1%` and `0.005 = 0.5%`.
308
+ MCP no longer adds its own 5% LP slippage business cap on top of the provided value.
309
+ When the SDK LP path fails on a non-standard token `allowance()` read, MCP falls back to the direct router path automatically.
305
310
 
306
311
  ### `get_lp_price`
307
312
  Read LP NAV price for BASE or QUOTE side.
@@ -332,6 +337,7 @@ Read current LP balances across pools.
332
337
  ```
333
338
 
334
339
  `get_my_lp_holdings` is an inventory view. Results are now stably ordered by symbol / pool id instead of summing BASE LP raw units with QUOTE LP raw units into a misleading mixed-unit ranking.
340
+ If market detail exposes stale LP token addresses, MCP now re-checks live pool token addresses from pool info before reporting balances.
335
341
 
336
342
  ---
337
343
 
@@ -362,6 +368,8 @@ Pre-check whether collateral is available before trading.
362
368
  }
363
369
  ```
364
370
 
371
+ `check_account_ready` now uses SDK `getAvailableMarginBalance` as its primary trading-account source. If the SDK read degrades, the response includes `summary.degraded=true` plus diagnostics such as `sdkAvailableMarginBalance`, `accountInfoFreeMargin`, and `availableMarginError`.
372
+
365
373
  ### `get_orders`
366
374
  Read open orders, history, or both.
367
375
 
@@ -2,6 +2,10 @@ import { MyxClient } from "@myx-trade/sdk";
2
2
  import { JsonRpcProvider, Wallet } from "ethers";
3
3
  import { normalizeAddress } from "../utils/address.js";
4
4
  let cached = null;
5
+ const BETA_BROKERS_BY_CHAIN = {
6
+ 421614: ["0x895c4ae2a22bb26851011d733a9355f663a1f939"],
7
+ 59141: ["0x634efdc9dc76d7abf6e49279875a31b02e9891e2"],
8
+ };
5
9
  function getDefaultBrokerByChainId(chainId) {
6
10
  // Testnet mappings
7
11
  if (chainId === 421614)
@@ -18,6 +22,14 @@ function getDefaultQuoteTokenByChainId(chainId) {
18
22
  return "0xD984fd34f91F92DA0586e1bE82E262fF27DC431b"; // Linea Sepolia
19
23
  return "0xD984fd34f91F92DA0586e1bE82E262fF27DC431b";
20
24
  }
25
+ function resolveIsBetaMode(chainId, brokerAddressRaw) {
26
+ const explicit = String(process.env.IS_BETA_MODE ?? "").trim().toLowerCase();
27
+ if (explicit === "true")
28
+ return true;
29
+ if (explicit === "false")
30
+ return false;
31
+ return Boolean(BETA_BROKERS_BY_CHAIN[chainId]?.includes(String(brokerAddressRaw ?? "").trim().toLowerCase()));
32
+ }
21
33
  export async function resolveClient() {
22
34
  if (cached)
23
35
  return cached;
@@ -25,7 +37,6 @@ export async function resolveClient() {
25
37
  const privateKey = process.env.PRIVATE_KEY;
26
38
  const chainId = Number(process.env.CHAIN_ID) || 59141;
27
39
  const isTestnet = process.env.IS_TESTNET !== "false";
28
- const isBetaMode = String(process.env.IS_BETA_MODE ?? "").trim().toLowerCase() === "true";
29
40
  const brokerAddressRaw = process.env.BROKER_ADDRESS || getDefaultBrokerByChainId(chainId);
30
41
  const quoteTokenRaw = process.env.QUOTE_TOKEN_ADDRESS || getDefaultQuoteTokenByChainId(chainId);
31
42
  const quoteDecimals = Number(process.env.QUOTE_TOKEN_DECIMALS) || 6;
@@ -39,6 +50,7 @@ export async function resolveClient() {
39
50
  throw new Error("QUOTE_TOKEN_ADDRESS env var is required.");
40
51
  const brokerAddress = normalizeAddress(brokerAddressRaw, "BROKER_ADDRESS");
41
52
  const quoteToken = normalizeAddress(quoteTokenRaw, "QUOTE_TOKEN_ADDRESS");
53
+ const isBetaMode = resolveIsBetaMode(chainId, brokerAddress);
42
54
  const provider = new JsonRpcProvider(rpcUrl);
43
55
  const signer = new Wallet(privateKey, provider);
44
56
  // Inject the EIP-1193 mock so SDK can sign transactions seamlessly
@@ -12,14 +12,14 @@ export const tradingGuidePrompt = {
12
12
  content: {
13
13
  type: "text",
14
14
  text: `
15
- # MYX Trading MCP Best Practices (v3.0.24)
15
+ # MYX Trading MCP Best Practices (v3.0.27)
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
 
19
19
  ## 1. The Standard Workflow
20
20
  1. **Discovery**: Use \`search_tools\` if the intent is unclear, then use \`find_pool\` with a keyword (e.g. "BTC") to get the \`poolId\`.
21
21
  2. **Context**: Use \`get_account_snapshot\` (with \`poolId\`) to check balances, trading metrics, and VIP tier. Use \`get_price\` for real-time market/oracle prices.
22
- 3. **Pre-check**: Use \`check_account_ready\` to ensure the trading account has enough collateral.
22
+ 3. **Pre-check**: Use \`check_account_ready\` to inspect SDK-aligned \`availableMarginBalance\` before trading.
23
23
  4. **Execution**: Prefer \`open_position_simple\` for entry. It supports Stop-Loss (\`slPrice\`) and Take-Profit (\`tpPrice\`) in one call.
24
24
  5. **Monitoring**: Use \`get_positions_all\` to track active trades and \`get_orders\` for pending/filled history.
25
25
  6. **Unified Operations**: Use \`cancel_orders\` for targeted or global撤单, and \`manage_tp_sl\` to update protection orders.
@@ -33,17 +33,23 @@ You are an expert crypto trader using the MYX Protocol. To ensure successful exe
33
33
  - **Liquidity Metadata**: When calling \`get_pool_metadata(includeLiquidity=true)\`, MCP uses a fresh Oracle price automatically and ignores caller-supplied \`marketPrice\`.
34
34
  - **LP Strategy**: Use \`get_my_lp_holdings\` to monitor liquidity positions. Naming follows \`mBASE.QUOTE\` (e.g., \`mBTC.USDC\`).
35
35
  - **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.
36
- - **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
+ - **Execution Price**: \`open_position_simple\` no longer auto-fills a fresh Oracle price for \`MARKET\`; provide \`price\` explicitly when you want MCP to compute size / fee previews.
37
+ - **Funding Delta Ownership**: MCP no longer performs its own increase-order margin/deposit reconciliation. SDK \`createIncreaseOrder\` owns deposit-delta handling for new increase orders.
38
+ - **Pre-check Diagnostics**: \`check_account_ready\` now reports SDK \`availableMarginBalance\` first. If that read degrades, inspect \`summary.degraded\` and \`diagnostics.availableMarginError\` before trusting fallback account fields.
39
+ - **Beta Broker Mode**: Current Arbitrum Sepolia / Linea Sepolia test brokers auto-enable beta mode when \`IS_BETA_MODE\` is omitted.
37
40
  - **Approval Safety**: Local fallback flows prefer exact approval sizing. Do not assume unlimited approvals are necessary.
38
41
  - **Position Semantics**: \`size\` is BASE quantity, not USD notional. If a \`positionId\` is supplied, \`direction\` must match the live position.
39
42
  - **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.
40
43
  - **LP Safety**: LP execution requires a fresh price snapshot and preview success; do not continue after preview failure.
41
44
  - **LP Read Semantics**: Treat \`get_my_lp_holdings\` as an inventory listing, not a portfolio ranking by economic value.
45
+ - **LP Token Resolution**: If LP balances look empty but pool supply changed, re-check live pool token addresses from pool info instead of trusting market-detail LP token addresses blindly.
46
+ - **LP Approval Fallback**: Some test tokens revert on \`allowance()\`; MCP should treat that as a recoverable LP write-path issue and try the direct router approval/execution path.
42
47
 
43
48
  ## 3. Testnet Broker Reference
44
49
  - Arbitrum test: \`0x895C4ae2A22bB26851011d733A9355f663a1F939\`
45
50
  - Linea test: \`0x634EfDC9dC76D7AbF6E49279875a31B02E9891e2\`
46
51
  - Always keep \`RPC_URL\`, \`CHAIN_ID\`, and \`BROKER_ADDRESS\` on the same network.
52
+ - If \`IS_BETA_MODE\` is unset, MCP auto-detects beta mode for those two testnet brokers.
47
53
 
48
54
  Current Session:
49
55
  - Wallet: ${address}
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.24" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
464
+ const server = new Server({ name: "myx-mcp-trading-server", version: "3.0.27" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
465
465
  // List tools
466
466
  server.setRequestHandler(ListToolsRequestSchema, async () => {
467
467
  return {
@@ -110,6 +110,14 @@ function isAbiLengthMismatchError(message) {
110
110
  function isSdkWaitNotFunctionError(message) {
111
111
  return message.toLowerCase().includes("wait is not a function");
112
112
  }
113
+ function isAllowanceReadError(message) {
114
+ const lower = message.toLowerCase();
115
+ return (lower.includes("function \"allowance\" reverted") ||
116
+ lower.includes("function 'allowance' reverted") ||
117
+ lower.includes("allowance(address owner, address spender)") ||
118
+ lower.includes("allowance(address,address)") ||
119
+ lower.includes("allowance reverted"));
120
+ }
113
121
  function readLastMockTxHash() {
114
122
  const hash = String(globalThis?.__MCP_LAST_TX_HASH ?? "").trim();
115
123
  if (!hash.startsWith("0x") || hash.length !== 66)
@@ -198,12 +206,6 @@ function applyMinOutBySlippage(amountOut, slippage) {
198
206
  const effective = ratioScaled >= scale ? 0n : (scale - ratioScaled);
199
207
  return (amountOut * effective) / scale;
200
208
  }
201
- function shouldUseOraclePriceByState(state) {
202
- const numeric = Number(state);
203
- if (!Number.isFinite(numeric))
204
- return true;
205
- return numeric !== 0 && numeric !== 1;
206
- }
207
209
  async function getMarketDetailOrThrow(client, chainId, poolId) {
208
210
  const raw = await client.markets.getMarketDetail({ chainId, poolId });
209
211
  const detail = raw?.data ?? raw;
@@ -212,14 +214,14 @@ async function getMarketDetailOrThrow(client, chainId, poolId) {
212
214
  }
213
215
  return detail;
214
216
  }
215
- async function buildOraclePricePayload(client, chainId, poolId, fallbackOracleType) {
217
+ async function buildOraclePricePayload(client, chainId, poolId) {
216
218
  const oracle = await client.utils.getOraclePrice(poolId, chainId);
217
219
  const vaa = String(oracle?.vaa ?? "").trim();
218
220
  if (!vaa || !vaa.startsWith("0x")) {
219
221
  throw new Error(`Oracle VAA unavailable for pool ${poolId}.`);
220
222
  }
221
223
  const publishTime = assertOracleFreshness(oracle?.publishTime, poolId);
222
- const oracleType = Number.isFinite(Number(oracle?.oracleType)) ? Number(oracle.oracleType) : fallbackOracleType;
224
+ const oracleType = Number.isFinite(Number(oracle?.oracleType)) ? Number(oracle.oracleType) : 1;
223
225
  const value = toPositiveBigint(oracle?.value) ?? 0n;
224
226
  const referencePrice30 = BigInt(ensureUnits(String(oracle?.price ?? "0"), 30, "oracle price"));
225
227
  if (referencePrice30 <= 0n) {
@@ -267,28 +269,52 @@ async function executeLiquidityTxViaRouter(params) {
267
269
  throw new Error(`Liquidity ${poolType.toLowerCase()} ${action} amount must be > 0.`);
268
270
  }
269
271
  let approvalTxHash = null;
272
+ let approvalMode = "not_required";
273
+ let allowanceReadError = null;
270
274
  if (action === "deposit") {
271
275
  const tokenAddress = normalizeAddress(poolType === "QUOTE" ? marketDetail.quoteToken : marketDetail.baseToken, poolType === "QUOTE" ? "quoteToken" : "baseToken");
276
+ const approvalSpender = poolType === "QUOTE" ? addresses.quotePool : addresses.basePool;
272
277
  const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer);
273
- const allowance = toPositiveBigint(await tokenContract.allowance(address, addresses.router)) ?? 0n;
274
- if (allowance < amountIn) {
275
- logger.info(`[LP fallback] allowance insufficient for ${poolType} deposit, approving router. required=${amountIn.toString()}, current=${allowance.toString()}`);
276
- const approveTx = await tokenContract.approve(addresses.router, amountIn);
278
+ let needsApproval = true;
279
+ try {
280
+ const allowance = toPositiveBigint(await tokenContract.allowance(address, approvalSpender)) ?? 0n;
281
+ needsApproval = allowance < amountIn;
282
+ approvalMode = needsApproval ? "checked_allowance_then_approve" : "existing_allowance";
283
+ if (!needsApproval) {
284
+ logger.info(`[LP fallback] allowance already sufficient for ${poolType} deposit. spender=${approvalSpender}, current=${allowance.toString()}, required=${amountIn.toString()}`);
285
+ }
286
+ }
287
+ catch (error) {
288
+ allowanceReadError = extractErrorMessage(error);
289
+ approvalMode = "optimistic_approve_after_allowance_revert";
290
+ logger.warn(`[LP fallback] allowance read failed for ${poolType} deposit; trying direct approve on spender=${approvalSpender}. error=${allowanceReadError}`);
291
+ }
292
+ if (needsApproval) {
293
+ logger.info(`[LP fallback] approving spender=${approvalSpender} for ${poolType} deposit. required=${amountIn.toString()}, mode=${approvalMode}`);
294
+ const approveTx = await tokenContract.approve(approvalSpender, amountIn);
277
295
  approvalTxHash = String(approveTx?.hash ?? "").trim() || null;
278
296
  const approveReceipt = await approveTx?.wait?.();
279
297
  if (approveReceipt && approveReceipt.status !== 1) {
280
- throw new Error(`Router approval transaction reverted for ${poolType} deposit.`);
298
+ throw new Error(`Approval transaction reverted for ${poolType} deposit (spender=${approvalSpender}).`);
281
299
  }
282
300
  }
283
301
  }
284
- const needOraclePrice = shouldUseOraclePriceByState(marketDetail.state);
285
302
  let oraclePayload = {
286
303
  prices: [],
287
304
  value: 0n,
288
305
  referencePrice30: 0n,
289
306
  };
290
- if (needOraclePrice) {
291
- oraclePayload = await buildOraclePricePayload(client, chainId, poolId, Number(marketDetail.oracleType ?? 1));
307
+ try {
308
+ oraclePayload = await buildOraclePricePayload(client, chainId, poolId);
309
+ }
310
+ catch {
311
+ const fallbackPrice = await resolvePositiveMarketPrice30(client, poolId, chainId);
312
+ if (fallbackPrice && fallbackPrice > 0n) {
313
+ oraclePayload.referencePrice30 = fallbackPrice;
314
+ }
315
+ }
316
+ if (oraclePayload.referencePrice30 <= 0n) {
317
+ throw new Error(`Oracle price unavailable for LP preview on pool ${poolId}.`);
292
318
  }
293
319
  const amountOut = await previewAmountOutForLiquidity(signer, chainId, poolId, poolType, action, amountIn, oraclePayload.referencePrice30);
294
320
  const minAmountOut = applyMinOutBySlippage(amountOut, slippage);
@@ -361,9 +387,51 @@ async function executeLiquidityTxViaRouter(params) {
361
387
  usedOraclePrice: oraclePayload.prices.length > 0,
362
388
  oracleValue: oraclePayload.value.toString(),
363
389
  approvalTxHash,
390
+ approvalMode,
391
+ allowanceReadError,
392
+ approvalSpender: action === "deposit" ? (poolType === "QUOTE" ? addresses.quotePool : addresses.basePool) : null,
364
393
  },
365
394
  };
366
395
  }
396
+ async function executeLiquidityTx(params) {
397
+ const { chainId, poolId, poolType, action, amount, slippage } = params;
398
+ const sdkAmount = Number(String(amount ?? "").trim());
399
+ const beforeHash = readLastMockTxHash();
400
+ if (!Number.isFinite(sdkAmount) || sdkAmount <= 0) {
401
+ logger.warn(`[LP SDK] amount=${String(amount)} is not a finite positive human number; using explicit router path for ${poolType} ${action}.`);
402
+ return executeLiquidityTxViaRouter(params);
403
+ }
404
+ try {
405
+ return await withMutedSdkAbiMismatchLogs(async () => {
406
+ if (poolType === "QUOTE" && action === "deposit") {
407
+ return await quote.deposit({ chainId, poolId, amount: sdkAmount, slippage });
408
+ }
409
+ if (poolType === "QUOTE" && action === "withdraw") {
410
+ return await quote.withdraw({ chainId, poolId, amount: sdkAmount, slippage });
411
+ }
412
+ if (poolType === "BASE" && action === "deposit") {
413
+ return await base.deposit({ chainId, poolId, amount: sdkAmount, slippage });
414
+ }
415
+ return await base.withdraw({ chainId, poolId, amount: sdkAmount, slippage });
416
+ });
417
+ }
418
+ catch (error) {
419
+ const message = extractErrorMessage(error);
420
+ const recovered = recoverSdkSubmittedTxHash(beforeHash, message, { poolType, action });
421
+ if (recovered) {
422
+ return recovered;
423
+ }
424
+ if (isAbiLengthMismatchError(message)) {
425
+ logger.warn(`[LP SDK] ABI overload mismatch for ${poolType} ${action}; falling back to explicit router path.`);
426
+ return executeLiquidityTxViaRouter(params);
427
+ }
428
+ if (isAllowanceReadError(message)) {
429
+ logger.warn(`[LP SDK] allowance read failed for ${poolType} ${action}; falling back to explicit router path.`);
430
+ return executeLiquidityTxViaRouter(params);
431
+ }
432
+ throw error;
433
+ }
434
+ }
367
435
  async function resolvePositiveMarketPrice30(client, poolId, chainId) {
368
436
  if (!client)
369
437
  return null;
@@ -494,28 +562,28 @@ export async function getLiquidityInfo(client, poolId, chainIdOverride) {
494
562
  */
495
563
  export async function quoteDeposit(poolId, amount, slippage, chainIdOverride) {
496
564
  const chainId = chainIdOverride ?? getChainId();
497
- return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "deposit", amount, slippage });
565
+ return executeLiquidityTx({ chainId, poolId, poolType: "QUOTE", action: "deposit", amount, slippage });
498
566
  }
499
567
  /**
500
568
  * Quote 池 withdraw
501
569
  */
502
570
  export async function quoteWithdraw(poolId, amount, slippage, chainIdOverride) {
503
571
  const chainId = chainIdOverride ?? getChainId();
504
- return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "QUOTE", action: "withdraw", amount, slippage });
572
+ return executeLiquidityTx({ chainId, poolId, poolType: "QUOTE", action: "withdraw", amount, slippage });
505
573
  }
506
574
  /**
507
575
  * Base 池 deposit
508
576
  */
509
577
  export async function baseDeposit(poolId, amount, slippage, chainIdOverride) {
510
578
  const chainId = chainIdOverride ?? getChainId();
511
- return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "deposit", amount, slippage });
579
+ return executeLiquidityTx({ chainId, poolId, poolType: "BASE", action: "deposit", amount, slippage });
512
580
  }
513
581
  /**
514
582
  * Base 池 withdraw
515
583
  */
516
584
  export async function baseWithdraw(poolId, amount, slippage, chainIdOverride) {
517
585
  const chainId = chainIdOverride ?? getChainId();
518
- return executeLiquidityTxViaRouter({ chainId, poolId, poolType: "BASE", action: "withdraw", amount, slippage });
586
+ return executeLiquidityTx({ chainId, poolId, poolType: "BASE", action: "withdraw", amount, slippage });
519
587
  }
520
588
  /**
521
589
  * 获取 LP 价格
@@ -4,7 +4,6 @@ import { getChainId, getQuoteToken, getQuoteDecimals } from "../auth/resolveClie
4
4
  import { ensureUnits } from "../utils/units.js";
5
5
  import { normalizeAddress } from "../utils/address.js";
6
6
  import { normalizeSlippagePct4dp } from "../utils/slippage.js";
7
- import { finalizeMutationResult } from "../utils/mutationResult.js";
8
7
  import { extractErrorMessage } from "../utils/errorMessage.js";
9
8
  import { mapTimeInForce } from "../utils/mappings.js";
10
9
  import { getFreshOraclePrice } from "./marketService.js";
@@ -96,17 +95,6 @@ function parseDecimals(value, fallback) {
96
95
  function normalizeIdentifier(value) {
97
96
  return String(value ?? "").trim().toLowerCase();
98
97
  }
99
- function toBigIntOrZero(value) {
100
- try {
101
- const text = String(value ?? "").trim();
102
- if (!text)
103
- return 0n;
104
- return BigInt(text);
105
- }
106
- catch {
107
- return 0n;
108
- }
109
- }
110
98
  const ORDER_VALUE_SCALE = 1000000n;
111
99
  const DECIMAL_INPUT_RE = /^\d+(\.\d+)?$/;
112
100
  function parseScaledDecimal(value, scale, label) {
@@ -155,37 +143,6 @@ function validateIncreaseOrderEconomics(args) {
155
143
  const priceHuman = formatUnits(priceRawBig, 30);
156
144
  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}.`);
157
145
  }
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
- }
189
146
  async function resolveDecimalsForUpdateOrder(client, chainId, marketId, poolIdHint) {
190
147
  let baseDecimals = 18;
191
148
  let quoteDecimals = getQuoteDecimals();
@@ -283,16 +240,8 @@ export async function openPosition(client, address, args) {
283
240
  const tradingFeeRaw = ensureUnits(args.tradingFee, quoteDecimals, "tradingFee", { allowImplicitRaw: false });
284
241
  const resolvedMarketId = String(args.marketId ?? poolData.marketId ?? "").trim();
285
242
  if (!resolvedMarketId) {
286
- throw new Error(`marketId is required to compute networkFee for poolId=${args.poolId}.`);
243
+ throw new Error(`marketId is required for poolId=${args.poolId}.`);
287
244
  }
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);
296
245
  validateIncreaseOrderEconomics({
297
246
  collateralRaw,
298
247
  sizeRaw,
@@ -302,43 +251,6 @@ export async function openPosition(client, address, args) {
302
251
  quoteDecimals,
303
252
  });
304
253
  const timeInForce = mapTimeInForce(args.timeInForce);
305
- // --- Auto-Deposit Logic (Strict) ---
306
- const allowAutoDeposit = args.autoDeposit !== false;
307
- console.log(`[tradeService] Checking marginBalance for ${address} in pool ${args.poolId}...`);
308
- const marginInfo = await client.account.getAccountInfo(chainId, address, args.poolId);
309
- let marginBalanceRaw = BigInt(0);
310
- let walletBalanceRaw = BigInt(0);
311
- if (marginInfo?.code === 0 && marginInfo?.data) {
312
- marginBalanceRaw = toBigIntOrZero(marginInfo.data.freeMargin);
313
- walletBalanceRaw = toBigIntOrZero(marginInfo.data.walletBalance);
314
- }
315
- const requiredRaw = spend.totalSpendRaw;
316
- if (marginBalanceRaw < requiredRaw) {
317
- if (!allowAutoDeposit) {
318
- throw new Error(`Insufficient marginBalance (${marginBalanceRaw.toString()}) for required collateral (${requiredRaw.toString()}). ` +
319
- `Deposit to trading account first (account_deposit) or retry with autoDeposit=true.`);
320
- }
321
- const neededRaw = requiredRaw - marginBalanceRaw;
322
- console.log(`[tradeService] marginBalance (${marginBalanceRaw.toString()}) < Required (${requiredRaw.toString()}). Need to deposit: ${neededRaw.toString()}`);
323
- if (walletBalanceRaw < neededRaw) {
324
- // Also check real wallet balance just in case account info wallet field is stale.
325
- const realWalletRes = await client.account.getWalletQuoteTokenBalance(chainId, address);
326
- const realWalletRaw = BigInt(realWalletRes?.data || "0");
327
- if (realWalletRaw < neededRaw) {
328
- throw new Error(`Insufficient funds: marginBalance (${marginBalanceRaw.toString()}) + walletBalance (${walletBalanceRaw.toString()}) + realWallet (${realWalletRaw.toString()}) is less than required collateral (${requiredRaw.toString()}).`);
329
- }
330
- walletBalanceRaw = realWalletRaw;
331
- }
332
- console.log(`[tradeService] Depositing ${neededRaw.toString()} ${poolData.quoteSymbol} from wallet...`);
333
- const depositRaw = await client.account.deposit({
334
- amount: neededRaw.toString(),
335
- tokenAddress: poolData.quoteToken,
336
- chainId
337
- });
338
- const depositResult = await finalizeMutationResult(depositRaw, client.signer || (client.provider ? { provider: client.provider } : null), "auto_deposit");
339
- console.log(`[tradeService] Auto-deposit confirmed in tx: ${depositResult.confirmation?.txHash}`);
340
- }
341
- // --- End Auto-Deposit Logic ---
342
254
  const orderParams = {
343
255
  chainId,
344
256
  address,
@@ -364,7 +276,7 @@ export async function openPosition(client, address, args) {
364
276
  orderParams.slSize = ensureUnits(args.slSize, baseDecimals, "slSize", { allowImplicitRaw: false });
365
277
  if (args.slPrice)
366
278
  orderParams.slPrice = ensureUnits(args.slPrice, 30, "slPrice", { allowImplicitRaw: false });
367
- return client.order.createIncreaseOrder(orderParams, tradingFeeRaw, args.marketId);
279
+ return client.order.createIncreaseOrder(orderParams, tradingFeeRaw, resolvedMarketId);
368
280
  }
369
281
  /**
370
282
  * 平仓 / 减仓
@@ -31,20 +31,32 @@ export const checkAccountReadyTool = {
31
31
  const detail = detailRes?.data || detailRes;
32
32
  const quoteDecimals = Number(detail?.quoteDecimals ?? 6);
33
33
  const quoteSymbol = detail?.quoteSymbol || "USDC";
34
- const marginInfo = await client.account.getAccountInfo(chainId, address, poolId);
35
- let marginBalanceRaw = 0n;
34
+ const availableMarginRes = await client.account.getAvailableMarginBalance({ chainId, address, poolId }).catch((error) => ({
35
+ code: -1,
36
+ message: error?.message || String(error),
37
+ }));
38
+ const availableMarginBalanceRaw = availableMarginRes?.code === 0
39
+ ? toBigIntOrZero(availableMarginRes.data)
40
+ : 0n;
41
+ const marginInfo = await client.account.getAccountInfo(chainId, address, poolId).catch(() => null);
36
42
  let accountWalletBalanceRaw = 0n;
43
+ let freeMarginRaw = 0n;
44
+ let quoteProfitRaw = 0n;
45
+ let lockedMarginRaw = 0n;
37
46
  if (marginInfo?.code === 0 && marginInfo?.data) {
38
- marginBalanceRaw = toBigIntOrZero(marginInfo.data.freeMargin);
47
+ freeMarginRaw = toBigIntOrZero(marginInfo.data.freeMargin);
48
+ quoteProfitRaw = toBigIntOrZero(marginInfo.data.quoteProfit);
49
+ lockedMarginRaw = toBigIntOrZero(marginInfo.data.lockedMargin);
39
50
  accountWalletBalanceRaw = toBigIntOrZero(marginInfo.data.walletBalance);
40
51
  }
41
52
  const walletRes = await client.account.getWalletQuoteTokenBalance(chainId, address);
42
53
  const walletBalanceRaw = BigInt(walletRes?.data || "0");
43
54
  const requiredRaw = BigInt(parseUserUnits(args.collateralAmount, quoteDecimals, "required"));
44
- const isReady = (marginBalanceRaw >= requiredRaw) || (marginBalanceRaw + walletBalanceRaw >= requiredRaw);
45
- const deficitRaw = requiredRaw > marginBalanceRaw ? requiredRaw - marginBalanceRaw : 0n;
55
+ const isReady = (availableMarginBalanceRaw >= requiredRaw) || (availableMarginBalanceRaw + walletBalanceRaw >= requiredRaw);
56
+ const deficitRaw = requiredRaw > availableMarginBalanceRaw ? requiredRaw - availableMarginBalanceRaw : 0n;
46
57
  const needDepositFromWallet = deficitRaw > 0n;
47
58
  const walletSufficient = walletBalanceRaw >= deficitRaw;
59
+ const degraded = availableMarginRes?.code !== 0;
48
60
  const format = (v) => ethers.formatUnits(v, quoteDecimals);
49
61
  return {
50
62
  content: [{
@@ -54,14 +66,22 @@ export const checkAccountReadyTool = {
54
66
  data: {
55
67
  isReady,
56
68
  neededTotal: format(requiredRaw),
57
- currentMarginBalance: format(marginBalanceRaw),
69
+ currentAvailableMarginBalance: format(availableMarginBalanceRaw),
58
70
  currentWalletBalance: format(walletBalanceRaw),
59
71
  summary: {
60
- hasEnoughInMargin: marginBalanceRaw >= requiredRaw,
72
+ hasEnoughInMargin: availableMarginBalanceRaw >= requiredRaw,
61
73
  needDepositFromWallet,
62
74
  walletSufficientForDeposit: walletSufficient,
63
75
  accountInfoWalletBalance: format(accountWalletBalanceRaw),
76
+ degraded,
64
77
  quoteSymbol
78
+ },
79
+ diagnostics: {
80
+ sdkAvailableMarginBalance: format(availableMarginBalanceRaw),
81
+ accountInfoFreeMargin: format(freeMarginRaw),
82
+ accountInfoQuoteProfit: format(quoteProfitRaw),
83
+ accountInfoLockedMargin: format(lockedMarginRaw),
84
+ availableMarginError: degraded ? String(availableMarginRes?.message || "Failed to get available margin balance") : null,
65
85
  }
66
86
  }
67
87
  }, null, 2)
@@ -4,6 +4,7 @@ import { COMMON_LP_AMOUNT_DECIMALS } from "@myx-trade/sdk";
4
4
  import { Contract, formatUnits } from "ethers";
5
5
  import { extractErrorMessage } from "../utils/errorMessage.js";
6
6
  import { getPoolList } from "../services/marketService.js";
7
+ import { getPoolInfo } from "../services/poolService.js";
7
8
  const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
8
9
  const ERC20_BALANCE_ABI = ["function balanceOf(address owner) view returns (uint256)"];
9
10
  function collectRows(input) {
@@ -126,22 +127,61 @@ async function readErc20Balance(provider, tokenAddress, holder) {
126
127
  const balance = await contract.balanceOf(holder);
127
128
  return BigInt(balance).toString();
128
129
  }
130
+ function collectAddressCandidates(...values) {
131
+ const unique = new Set();
132
+ for (const value of values) {
133
+ const normalized = readAddress(value);
134
+ if (normalized)
135
+ unique.add(normalized.toLowerCase());
136
+ }
137
+ return Array.from(unique.values());
138
+ }
139
+ async function resolveLpBalanceFromCandidates(provider, holder, candidateAddresses) {
140
+ let selectedAddress = null;
141
+ let selectedBalanceRaw = "0";
142
+ const warnings = [];
143
+ for (const candidate of candidateAddresses) {
144
+ try {
145
+ const balanceRaw = await readErc20Balance(provider, candidate, holder);
146
+ if (!selectedAddress) {
147
+ selectedAddress = candidate;
148
+ selectedBalanceRaw = balanceRaw;
149
+ }
150
+ if (BigInt(balanceRaw) > 0n) {
151
+ return { tokenAddress: candidate, balanceRaw, warnings };
152
+ }
153
+ }
154
+ catch (error) {
155
+ warnings.push(`${candidate}: ${extractErrorMessage(error)}`);
156
+ }
157
+ }
158
+ return {
159
+ tokenAddress: selectedAddress,
160
+ balanceRaw: selectedBalanceRaw,
161
+ warnings,
162
+ };
163
+ }
129
164
  export const getMyLpHoldingsTool = {
130
165
  name: "get_my_lp_holdings",
131
- description: "[ACCOUNT] List your LP holdings across pools on the current chain by reading base/quote LP token balances. Includes standardized LP asset names: base LP `mBASE.QUOTE`, quote LP `mQUOTE.BASE`.",
166
+ description: "[ACCOUNT] List your LP holdings across pools on the current wallet chain by reading base/quote LP token balances. Includes standardized LP asset names: base LP `mBASE.QUOTE`, quote LP `mQUOTE.BASE`.",
132
167
  schema: {
133
168
  includeZero: z.coerce.boolean().optional().describe("If true, include pools with zero LP balances (default false)."),
134
169
  poolIds: z.union([z.array(z.string()).min(1), z.string().min(1)]).optional().describe("Optional poolId filter. Supports array, JSON-array string, comma string, or single poolId."),
135
170
  maxPools: z.coerce.number().int().positive().max(2000).optional().describe("Optional cap for scanned pools (default all)."),
171
+ chainId: z.coerce.number().int().positive().optional().describe("Optional chainId hint. Must match the active wallet/provider chain for LP balance reads."),
136
172
  },
137
173
  handler: async (args) => {
138
174
  try {
139
175
  const { client, address, signer } = await resolveClient();
140
- const chainId = getChainId();
176
+ const activeChainId = getChainId();
177
+ const chainId = args.chainId ?? activeChainId;
141
178
  const provider = signer?.provider;
142
179
  if (!provider) {
143
180
  throw new Error("Provider is unavailable for LP balance reads.");
144
181
  }
182
+ if (chainId !== activeChainId) {
183
+ throw new Error(`get_my_lp_holdings reads balances from the active wallet/provider chain only. Requested chainId=${chainId}, active chainId=${activeChainId}. Switch MCP network config first, then retry.`);
184
+ }
145
185
  const includeZero = !!args.includeZero;
146
186
  const poolIdsFilter = normalizePoolIdsInput(args.poolIds);
147
187
  const filterSet = new Set(poolIdsFilter.map((item) => item.toLowerCase()));
@@ -170,6 +210,7 @@ export const getMyLpHoldingsTool = {
170
210
  let basePoolToken = readAddress(row?.basePoolToken ?? row?.base_pool_token);
171
211
  let quotePoolToken = readAddress(row?.quotePoolToken ?? row?.quote_pool_token);
172
212
  let detail = null;
213
+ let poolInfo = null;
173
214
  if (!basePoolToken || !quotePoolToken) {
174
215
  try {
175
216
  const detailRes = await client.markets.getMarketDetail({ chainId, poolId });
@@ -183,23 +224,34 @@ export const getMyLpHoldingsTool = {
183
224
  }
184
225
  const { baseSymbol, quoteSymbol } = resolveBaseQuoteSymbols(row, detail);
185
226
  const { baseLpAssetName, quoteLpAssetName } = buildLpAssetNames(baseSymbol, quoteSymbol);
186
- let baseLpRaw = "0";
187
- let quoteLpRaw = "0";
188
- if (basePoolToken) {
227
+ let baseCandidates = collectAddressCandidates(basePoolToken);
228
+ let quoteCandidates = collectAddressCandidates(quotePoolToken);
229
+ let baseResolved = await resolveLpBalanceFromCandidates(provider, address, baseCandidates);
230
+ let quoteResolved = await resolveLpBalanceFromCandidates(provider, address, quoteCandidates);
231
+ const needPoolInfoEnrichment = (BigInt(baseResolved.balanceRaw) === 0n && BigInt(quoteResolved.balanceRaw) === 0n) ||
232
+ !baseResolved.tokenAddress ||
233
+ !quoteResolved.tokenAddress;
234
+ if (needPoolInfoEnrichment) {
189
235
  try {
190
- baseLpRaw = await readErc20Balance(provider, basePoolToken, address);
236
+ poolInfo = await getPoolInfo(poolId, chainId, client);
237
+ baseCandidates = collectAddressCandidates(...baseCandidates, poolInfo?.basePool?.poolToken, poolInfo?.basePool?.pool_token);
238
+ quoteCandidates = collectAddressCandidates(...quoteCandidates, poolInfo?.quotePool?.poolToken, poolInfo?.quotePool?.pool_token);
239
+ baseResolved = await resolveLpBalanceFromCandidates(provider, address, baseCandidates);
240
+ quoteResolved = await resolveLpBalanceFromCandidates(provider, address, quoteCandidates);
191
241
  }
192
242
  catch (error) {
193
- warnings.push(`pool ${poolId}: failed to read base LP balance (${extractErrorMessage(error)})`);
243
+ warnings.push(`pool ${poolId}: failed to enrich pool info (${extractErrorMessage(error)})`);
194
244
  }
195
245
  }
196
- if (quotePoolToken) {
197
- try {
198
- quoteLpRaw = await readErc20Balance(provider, quotePoolToken, address);
199
- }
200
- catch (error) {
201
- warnings.push(`pool ${poolId}: failed to read quote LP balance (${extractErrorMessage(error)})`);
202
- }
246
+ const baseLpRaw = baseResolved.balanceRaw;
247
+ const quoteLpRaw = quoteResolved.balanceRaw;
248
+ basePoolToken = baseResolved.tokenAddress ?? basePoolToken;
249
+ quotePoolToken = quoteResolved.tokenAddress ?? quotePoolToken;
250
+ for (const warning of baseResolved.warnings) {
251
+ warnings.push(`pool ${poolId}: failed to read base LP balance (${warning})`);
252
+ }
253
+ for (const warning of quoteResolved.warnings) {
254
+ warnings.push(`pool ${poolId}: failed to read quote LP balance (${warning})`);
203
255
  }
204
256
  const hasAnyLp = BigInt(baseLpRaw) > 0n || BigInt(quoteLpRaw) > 0n;
205
257
  if (!includeZero && !hasAnyLp)
@@ -113,7 +113,7 @@ export const manageLiquidityTool = {
113
113
  : await baseWithdraw(poolId, amount, slippage, chainId);
114
114
  }
115
115
  if (!raw) {
116
- throw new Error(`SDK returned an empty result for liquidity ${action}. This usually occurs if the pool is not in an Active state (state: 2) or if there is a contract-level restriction. Please check get_pool_metadata.`);
116
+ throw new Error(`SDK returned an empty result for liquidity ${action}. This usually indicates a contract-level restriction or an unavailable LP execution path. Please check get_pool_metadata and retry.`);
117
117
  }
118
118
  if (raw && typeof raw === "object" && "code" in raw && Number(raw.code) !== 0) {
119
119
  throw new Error(`Liquidity ${action} failed: ${extractErrorMessage(raw)}`);
@@ -73,9 +73,11 @@ function hasOwnKey(input, key) {
73
73
  return !!input && typeof input === "object" && Object.prototype.hasOwnProperty.call(input, key);
74
74
  }
75
75
  function isDeleteTpSlIntent(args) {
76
- if (!hasOwnKey(args, "tpPrice") || !hasOwnKey(args, "slPrice"))
77
- return false;
78
- return isExplicitZeroValue(args.tpPrice) && isExplicitZeroValue(args.slPrice);
76
+ if (isExplicitZeroValue(args.tpPrice) && isExplicitZeroValue(args.slPrice))
77
+ return true;
78
+ if (isExplicitZeroValue(args.price))
79
+ return true;
80
+ return false;
79
81
  }
80
82
  function isInvalidParameterRevert(error) {
81
83
  const message = String(error?.message ?? error ?? "").toLowerCase();
@@ -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, getFreshOraclePrice } from "../services/marketService.js";
5
+ import { resolvePool } 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";
@@ -10,6 +10,7 @@ import { mapDirection, mapOrderType } from "../utils/mappings.js";
10
10
  import { normalizeSlippagePct4dp, SLIPPAGE_PCT_4DP_DESC } from "../utils/slippage.js";
11
11
  import { parseUserPrice30, parseUserUnits } from "../utils/units.js";
12
12
  import { verifyTradeOutcome } from "../utils/verification.js";
13
+ import { extractErrorMessage } from "../utils/errorMessage.js";
13
14
  const MAX_UINT256 = "115792089237316195423570985008687907853269984665640564039457584007913129639935";
14
15
  function pow10(decimals) {
15
16
  if (!Number.isInteger(decimals) || decimals < 0) {
@@ -100,7 +101,7 @@ export const openPositionSimpleTool = {
100
101
  autoDeposit: z.coerce
101
102
  .boolean()
102
103
  .optional()
103
- .describe("If true, auto-deposit to margin account if marginBalance < needed (default false)."),
104
+ .describe("Deprecated compatibility flag. SDK now handles deposit deltas during order creation."),
104
105
  dryRun: z.coerce.boolean().optional().describe("If true, only compute params; do not send a transaction."),
105
106
  },
106
107
  handler: async (args) => {
@@ -150,18 +151,15 @@ export const openPositionSimpleTool = {
150
151
  // 4) Determine reference price (30 decimals)
151
152
  let price30;
152
153
  let priceMeta = { source: "user", publishTime: null, oracleType: null, human: null };
153
- if (orderType === OrderType.MARKET) {
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 };
157
- }
158
- else {
159
- const userPrice = String(args.price ?? "").trim();
160
- if (!userPrice)
161
- throw new Error("price is required for LIMIT/STOP.");
162
- price30 = parseUserPrice30(userPrice, "price");
163
- priceMeta = { source: "user", publishTime: null, oracleType: null, human: userPrice };
154
+ const userPrice = String(args.price ?? "").trim();
155
+ if (!userPrice) {
156
+ if (orderType === OrderType.MARKET) {
157
+ throw new Error("price is required for MARKET orders. MCP no longer auto-fills a fresh Oracle price for MARKET.");
158
+ }
159
+ throw new Error("price is required for LIMIT/STOP.");
164
160
  }
161
+ price30 = parseUserPrice30(userPrice, "price");
162
+ priceMeta = { source: "user", publishTime: null, oracleType: null, human: userPrice };
165
163
  const price30Big = asBigint(price30, "price");
166
164
  if (price30Big <= 0n)
167
165
  throw new Error("price must be > 0.");
@@ -352,7 +350,7 @@ export const openPositionSimpleTool = {
352
350
  };
353
351
  }
354
352
  catch (error) {
355
- return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
353
+ return { content: [{ type: "text", text: `Error: ${extractErrorMessage(error)}` }], isError: true };
356
354
  }
357
355
  },
358
356
  };
@@ -82,6 +82,7 @@ const TOOL_DISCOVERY_META = {
82
82
  category: "liquidity",
83
83
  aliases: ["my lp", "lp balances"],
84
84
  intents: ["list my liquidity", "portfolio lp"],
85
+ commonArgs: ["includeZero", "poolIds", "maxPools", "chainId"],
85
86
  },
86
87
  get_account_snapshot: {
87
88
  category: "account",
@@ -1,3 +1,4 @@
1
+ import { extractContractErrorFromText } from "./errors.js";
1
2
  const USELESS_MESSAGE_SET = new Set([
2
3
  "",
3
4
  "undefined",
@@ -26,6 +27,39 @@ function safeStringify(value) {
26
27
  return cleanMessage(String(value ?? ""));
27
28
  }
28
29
  }
30
+ function extractContractError(value) {
31
+ const visited = new Set();
32
+ const scan = (input, depth) => {
33
+ if (depth > 6 || input === null || input === undefined)
34
+ return null;
35
+ if (typeof input === "string") {
36
+ return extractContractErrorFromText(input);
37
+ }
38
+ if (typeof input === "number" || typeof input === "boolean" || typeof input === "bigint") {
39
+ return null;
40
+ }
41
+ if (input instanceof Error) {
42
+ return scan(input.message, depth + 1) ?? scan(input.cause, depth + 1);
43
+ }
44
+ if (!isRecord(input))
45
+ return null;
46
+ if (visited.has(input))
47
+ return null;
48
+ visited.add(input);
49
+ for (const key of ["data", "message", "reason", "shortMessage", "error", "cause", "info", "details"]) {
50
+ const decoded = scan(input[key], depth + 1);
51
+ if (decoded)
52
+ return decoded;
53
+ }
54
+ for (const nested of Object.values(input)) {
55
+ const decoded = scan(nested, depth + 1);
56
+ if (decoded)
57
+ return decoded;
58
+ }
59
+ return null;
60
+ };
61
+ return scan(value, 0);
62
+ }
29
63
  export function isMeaningfulErrorMessage(message) {
30
64
  return cleanMessage(message) !== null;
31
65
  }
@@ -65,5 +99,10 @@ export function extractErrorMessage(error, fallback = "Unknown error") {
65
99
  }
66
100
  return safeStringify(value);
67
101
  };
68
- return read(error, 0) ?? fallback;
102
+ const base = read(error, 0) ?? fallback;
103
+ const decodedContractError = extractContractError(error);
104
+ if (decodedContractError && !base.includes(decodedContractError)) {
105
+ return `${base} (Decoded: ${decodedContractError})`;
106
+ }
107
+ return base;
69
108
  }
@@ -254,3 +254,64 @@ export function decodeErrorSelector(errorData) {
254
254
  const selector = hex.slice(0, 8);
255
255
  return CONTRACT_ERRORS[selector] || null;
256
256
  }
257
+ function normalizeErrorHex(errorData) {
258
+ if (!errorData || typeof errorData !== "string")
259
+ return null;
260
+ let hex = errorData.trim().toLowerCase();
261
+ if (!hex)
262
+ return null;
263
+ if (hex.startsWith("0x"))
264
+ hex = hex.slice(2);
265
+ if (!/^[0-9a-f]+$/i.test(hex) || hex.length < 8)
266
+ return null;
267
+ return hex;
268
+ }
269
+ function readUint256Arg(hex, argIndex) {
270
+ const start = 8 + (argIndex * 64);
271
+ const end = start + 64;
272
+ if (hex.length < end)
273
+ return null;
274
+ try {
275
+ return BigInt(`0x${hex.slice(start, end)}`);
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
281
+ export function describeContractError(errorData) {
282
+ const hex = normalizeErrorHex(errorData);
283
+ if (!hex)
284
+ return null;
285
+ const selector = hex.slice(0, 8);
286
+ const name = CONTRACT_ERRORS[selector];
287
+ if (!name)
288
+ return null;
289
+ if (selector === "ffd10028") {
290
+ const current = readUint256Arg(hex, 0);
291
+ const required = readUint256Arg(hex, 1);
292
+ if (current !== null && required !== null) {
293
+ return `${name} [current=${current.toString()}, required=${required.toString()}]`;
294
+ }
295
+ }
296
+ if (selector === "71c4efed") {
297
+ const expected = readUint256Arg(hex, 0);
298
+ const actual = readUint256Arg(hex, 1);
299
+ if (expected !== null && actual !== null) {
300
+ return `${name} [expected=${expected.toString()}, actual=${actual.toString()}]`;
301
+ }
302
+ }
303
+ return name;
304
+ }
305
+ export function extractContractErrorFromText(text) {
306
+ if (!text || typeof text !== "string")
307
+ return null;
308
+ const matches = text.match(/0x[0-9a-fA-F]{8,}/g);
309
+ if (!matches)
310
+ return null;
311
+ for (const match of matches) {
312
+ const decoded = describeContractError(match);
313
+ if (decoded)
314
+ return decoded;
315
+ }
316
+ return null;
317
+ }
@@ -3,7 +3,6 @@ export const SLIPPAGE_PCT_4DP_MAX = 10000n;
3
3
  export const BUSINESS_SLIPPAGE_PCT_4DP_MAX = BigInt(process.env.BUSINESS_MAX_SLIPPAGE_PCT_4DP ?? "500");
4
4
  export const SLIPPAGE_PCT_4DP_DESC = "Slippage in 4-decimal precision raw units (1 = 0.01%, 10000 = 100%)";
5
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);
7
6
  export function isValidSlippagePct4dp(value) {
8
7
  if (!SLIPPAGE_PCT_4DP_RE.test(value))
9
8
  return false;
@@ -50,9 +49,5 @@ export function normalizeLpSlippageRatio(value, label = "slippage") {
50
49
  if (!Number.isFinite(numeric) || numeric < 0) {
51
50
  throw new Error(`${label} must be a finite number >= 0.`);
52
51
  }
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;
52
+ return numeric > 1 && numeric <= 100 ? numeric / 100 : numeric;
58
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaleffffff/mcp-trading-server",
3
- "version": "3.0.24",
3
+ "version": "3.0.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "myx-mcp": "dist/server.js"