perp-cli 0.14.0 → 0.14.1
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/dist/cli-program.js +19 -2
- package/dist/commands/funds-onchain.d.ts +1 -1
- package/dist/commands/funds-onchain.js +9 -47
- package/dist/commands/funds.js +34 -48
- package/dist/commands/history.js +30 -9
- package/dist/commands/wallet.js +74 -155
- package/dist/exchanges/aster.d.ts +21 -0
- package/dist/exchanges/aster.js +117 -43
- package/dist/exchanges/ondo/adapter.d.ts +4 -18
- package/dist/exchanges/ondo/adapter.js +5 -29
- package/dist/exchanges/ondo/funds.d.ts +1 -28
- package/dist/exchanges/ondo/funds.js +2 -94
- package/dist/exchanges/pacifica.d.ts +5 -0
- package/dist/exchanges/pacifica.js +32 -15
- package/dist/exchanges/qfex/adapter.js +4 -1
- package/dist/exchanges/venues/ondo.d.ts +0 -5
- package/dist/exchanges/venues/ondo.js +2 -6
- package/dist/exchanges/venues/qfex.d.ts +11 -0
- package/dist/exchanges/venues/qfex.js +10 -2
- package/dist/mcp-server.js +2 -2
- package/dist/onchain-balances.d.ts +40 -0
- package/dist/onchain-balances.js +173 -0
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +20 -3
- package/skills/perp-cli/references/agent-operations.md +2 -2
- package/skills/perp-cli/references/commands.md +7 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ondo Perps funding (mainnet only)
|
|
2
|
+
* Ondo Perps funding (mainnet only). Withdrawals are web-app only (the API cannot withdraw).
|
|
3
3
|
*
|
|
4
4
|
* Deposit = a plain ERC-20 transfer of USDC to the account's own funding address
|
|
5
5
|
* (docs.ondoperps.xyz/funding-ondo-perps, api-reference/integration_guide "Deposit USDC").
|
|
@@ -13,12 +13,7 @@
|
|
|
13
13
|
* - With a probe amount the transfer is two-step: probe → wait until GET /v1/wallet/deposits
|
|
14
14
|
* shows it confirmed → remainder. A probe that is not credited within the window stops the
|
|
15
15
|
* flow (ORDER_PENDING with the probe tx hash); the remainder is NOT sent.
|
|
16
|
-
*
|
|
17
|
-
* Address book: withdrawals go only to addresses on the account's address book. An address is
|
|
18
|
-
* added by signing a SIWE challenge with the account's wallet
|
|
19
|
-
* (POST /v1/auth/erc-4361/address_book/get_challenge → personal_sign → …/complete_challenge).
|
|
20
16
|
*/
|
|
21
|
-
import { PerpError } from "../../errors.js";
|
|
22
17
|
import { type TxSigner } from "../../evm-master-tx.js";
|
|
23
18
|
import type { Network } from "../venue-types.js";
|
|
24
19
|
import { type OndoDepositChain } from "../venues/ondo.js";
|
|
@@ -98,25 +93,3 @@ export interface OndoDepositResult {
|
|
|
98
93
|
* remainder is never sent while the probe is uncredited (the ORDER_PENDING carries `sent`).
|
|
99
94
|
*/
|
|
100
95
|
export declare function executeOndoDeposit(plan: OndoDepositPlan, sign: TxSigner, readDeposits: () => Promise<unknown>, timing: PollTiming): Promise<OndoDepositResult>;
|
|
101
|
-
/** Withdrawal addresses on the account's address book (GET /v1/wallet/address_book). */
|
|
102
|
-
export declare function ondoAddressBook(client: OndoFundsClient): Promise<string[]>;
|
|
103
|
-
/** Refusal for a withdraw destination that is not on the address book (nothing was sent). */
|
|
104
|
-
export declare function ondoAddressNotOnBook(destination: string, book: readonly string[]): PerpError;
|
|
105
|
-
/** A challenge the master key will sign must be about this address book entry — never an arbitrary message. */
|
|
106
|
-
export declare function assertOndoAddressBookChallenge(message: string, wallet: string, withdrawalAddress: string, restUrl: string): void;
|
|
107
|
-
export interface OndoAddressBookAddResult {
|
|
108
|
-
address: string;
|
|
109
|
-
alreadyListed: boolean;
|
|
110
|
-
listed: boolean;
|
|
111
|
-
challengeId: string | null;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Add the signing wallet's own address to the address book: get_challenge → personal_sign
|
|
115
|
-
* (EIP-191) with the wallet → complete_challenge. Challenge chainId "1" (enum: 1 | 43114).
|
|
116
|
-
*/
|
|
117
|
-
export declare function addOndoAddressBookEntry(client: OndoFundsClient, p: {
|
|
118
|
-
wallet: string;
|
|
119
|
-
restUrl: string;
|
|
120
|
-
label: string | null;
|
|
121
|
-
signMessage: (message: string) => Promise<string>;
|
|
122
|
-
}): Promise<OndoAddressBookAddResult>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ondo Perps funding (mainnet only)
|
|
2
|
+
* Ondo Perps funding (mainnet only). Withdrawals are web-app only (the API cannot withdraw).
|
|
3
3
|
*
|
|
4
4
|
* Deposit = a plain ERC-20 transfer of USDC to the account's own funding address
|
|
5
5
|
* (docs.ondoperps.xyz/funding-ondo-perps, api-reference/integration_guide "Deposit USDC").
|
|
@@ -13,12 +13,8 @@
|
|
|
13
13
|
* - With a probe amount the transfer is two-step: probe → wait until GET /v1/wallet/deposits
|
|
14
14
|
* shows it confirmed → remainder. A probe that is not credited within the window stops the
|
|
15
15
|
* flow (ORDER_PENDING with the probe tx hash); the remainder is NOT sent.
|
|
16
|
-
*
|
|
17
|
-
* Address book: withdrawals go only to addresses on the account's address book. An address is
|
|
18
|
-
* added by signing a SIWE challenge with the account's wallet
|
|
19
|
-
* (POST /v1/auth/erc-4361/address_book/get_challenge → personal_sign → …/complete_challenge).
|
|
20
16
|
*/
|
|
21
|
-
import { formatUnits, parseUnits
|
|
17
|
+
import { formatUnits, parseUnits } from "ethers";
|
|
22
18
|
import { PerpError } from "../../errors.js";
|
|
23
19
|
import { ERC20, EVM_ADDRESS_RE, assertRpcChainId, erc20Balance, erc20Decimals, evmProvider, sameAddress, sendMasterTx, } from "../../evm-master-tx.js";
|
|
24
20
|
import { unsupported } from "../trading-v2.js";
|
|
@@ -200,91 +196,3 @@ export async function executeOndoDeposit(plan, sign, readDeposits, timing) {
|
|
|
200
196
|
}
|
|
201
197
|
return { transfers, depositAddress: plan.depositAddress };
|
|
202
198
|
}
|
|
203
|
-
// ── Address book ──
|
|
204
|
-
const ADDRESS_BOOK_UI = "add the address in the Ondo Perps web app (Withdraw → address book)";
|
|
205
|
-
/** Withdrawal addresses on the account's address book (GET /v1/wallet/address_book). */
|
|
206
|
-
export async function ondoAddressBook(client) {
|
|
207
|
-
const res = await client.get("/v1/wallet/address_book", null);
|
|
208
|
-
if (!Array.isArray(res.addressBook))
|
|
209
|
-
throw venueError("GET /v1/wallet/address_book returned no addressBook list");
|
|
210
|
-
return res.addressBook.map(e => {
|
|
211
|
-
if (typeof e?.withdrawalAddress !== "string")
|
|
212
|
-
throw venueError("address book entry has no withdrawalAddress");
|
|
213
|
-
return e.withdrawalAddress;
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
/** Refusal for a withdraw destination that is not on the address book (nothing was sent). */
|
|
217
|
-
export function ondoAddressNotOnBook(destination, book) {
|
|
218
|
-
return new PerpError("INVALID_PARAMS", `${destination} is not on the Ondo Perps withdrawal address book (${book.length === 0 ? "the book is empty" : `listed: ${book.join(", ")}`}) — nothing was sent`, {
|
|
219
|
-
exchange: "ondo",
|
|
220
|
-
remediation: `Ondo only withdraws to address-book entries. An entry is added by signing a SIWE challenge with the account's wallet (api-reference/auth/get-siwe-address-book-challenge + complete). For the master wallet's own address run: perp funds address-book add ondo ${destination} — otherwise ${ADDRESS_BOOK_UI}`,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
/** A challenge the master key will sign must be about this address book entry — never an arbitrary message. */
|
|
224
|
-
export function assertOndoAddressBookChallenge(message, wallet, withdrawalAddress, restUrl) {
|
|
225
|
-
const refuse = (why) => {
|
|
226
|
-
throw new PerpError("POLICY_DENIED", `Ondo address-book challenge refused: ${why}. Nothing was signed.`, {
|
|
227
|
-
exchange: "ondo", challenge: message.slice(0, 600), remediation: `If this message is expected, ${ADDRESS_BOOK_UI}`,
|
|
228
|
-
});
|
|
229
|
-
};
|
|
230
|
-
if (!message.toLowerCase().includes(withdrawalAddress.toLowerCase()))
|
|
231
|
-
refuse(`the message does not name the withdrawal address ${withdrawalAddress}`);
|
|
232
|
-
// The endpoint is POST /v1/auth/erc-4361/address_book/get_challenge — "Get a Sign-In with Ethereum
|
|
233
|
-
// challenge for adding a withdrawal address" (Ondo REST spec). ERC-4361 shape:
|
|
234
|
-
// "<domain> wants you to sign in with your Ethereum account:\n<address>\n…". Any other message
|
|
235
|
-
// is refused: the master key never signs free-form text.
|
|
236
|
-
const siwe = /^(\S+) wants you to sign in with your Ethereum account:\n(0x[0-9a-fA-F]{40})\n/.exec(message);
|
|
237
|
-
if (siwe === null)
|
|
238
|
-
return refuse("the message is not a Sign-In with Ethereum (ERC-4361) message");
|
|
239
|
-
const venueHost = new URL(restUrl).hostname.split(".").slice(-2).join(".");
|
|
240
|
-
const domain = siwe[1].replace(/^[a-z]+:\/\//, "").split(":")[0];
|
|
241
|
-
if (domain !== venueHost && !domain.endsWith(`.${venueHost}`))
|
|
242
|
-
refuse(`its domain "${siwe[1]}" is not ${venueHost}`);
|
|
243
|
-
if (!sameAddress(siwe[2], wallet))
|
|
244
|
-
refuse(`it is addressed to ${siwe[2]}, not the signing wallet ${wallet}`);
|
|
245
|
-
}
|
|
246
|
-
/** A venue refusal of the SIWE address-book endpoints, re-raised with the web-app route. */
|
|
247
|
-
function addressBookAuthError(e, step) {
|
|
248
|
-
if (!(e instanceof PerpError) || e.structured.code === "ORDER_PENDING")
|
|
249
|
-
return e;
|
|
250
|
-
return new PerpError(e.structured.code, `${e.message} — Ondo refused the address-book ${step} with API-key auth`, {
|
|
251
|
-
...e.structured.details, exchange: "ondo",
|
|
252
|
-
remediation: `These endpoints may need a wallet (SIWE/JWT) session instead of an API key: ${ADDRESS_BOOK_UI}`,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Add the signing wallet's own address to the address book: get_challenge → personal_sign
|
|
257
|
-
* (EIP-191) with the wallet → complete_challenge. Challenge chainId "1" (enum: 1 | 43114).
|
|
258
|
-
*/
|
|
259
|
-
export async function addOndoAddressBookEntry(client, p) {
|
|
260
|
-
const before = await ondoAddressBook(client);
|
|
261
|
-
if (before.some(a => sameAddress(a, p.wallet)))
|
|
262
|
-
return { address: p.wallet, alreadyListed: true, listed: true, challengeId: null };
|
|
263
|
-
let challenge;
|
|
264
|
-
try {
|
|
265
|
-
challenge = await client.post("/v1/auth/erc-4361/address_book/get_challenge", {
|
|
266
|
-
walletAddress: p.wallet, chainId: "1", withdrawalAddress: p.wallet,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
catch (e) {
|
|
270
|
-
throw addressBookAuthError(e, "challenge");
|
|
271
|
-
}
|
|
272
|
-
if (typeof challenge.id !== "string" || typeof challenge.message !== "string" || challenge.message === "") {
|
|
273
|
-
throw venueError("address_book/get_challenge returned no id / message");
|
|
274
|
-
}
|
|
275
|
-
assertOndoAddressBookChallenge(challenge.message, p.wallet, p.wallet, p.restUrl);
|
|
276
|
-
const signature = await p.signMessage(challenge.message);
|
|
277
|
-
if (!sameAddress(verifyMessage(challenge.message, signature), p.wallet)) {
|
|
278
|
-
throw new PerpError("SIGNATURE_FAILED", `The address-book challenge signature does not recover to ${p.wallet} — nothing was submitted`, { exchange: "ondo" });
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
await client.postNoResult("/v1/auth/erc-4361/address_book/complete_challenge", {
|
|
282
|
-
id: challenge.id, signature, ...(p.label !== null ? { addressLabel: p.label } : {}),
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
catch (e) {
|
|
286
|
-
throw addressBookAuthError(e, "completion");
|
|
287
|
-
}
|
|
288
|
-
const after = await ondoAddressBook(client);
|
|
289
|
-
return { address: p.wallet, alreadyListed: false, listed: after.some(a => sameAddress(a, p.wallet)), challengeId: challenge.id };
|
|
290
|
-
}
|
|
@@ -375,6 +375,11 @@ export declare class PacificaAdapter implements ExchangeAdapter {
|
|
|
375
375
|
price: string | null;
|
|
376
376
|
}[]>;
|
|
377
377
|
getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
|
|
378
|
+
/**
|
|
379
|
+
* Recent orders, newest first (GET /orders/history; PacificaClient.get already unwraps the
|
|
380
|
+
* `{success, data}` envelope, so the list is the result itself). A non-list or a row without a
|
|
381
|
+
* readable field → EXCHANGE_ERROR, never an empty list or a placeholder value.
|
|
382
|
+
*/
|
|
378
383
|
getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
|
|
379
384
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
380
385
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
@@ -1340,24 +1340,41 @@ export class PacificaAdapter {
|
|
|
1340
1340
|
trades: k.n,
|
|
1341
1341
|
}));
|
|
1342
1342
|
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Recent orders, newest first (GET /orders/history; PacificaClient.get already unwraps the
|
|
1345
|
+
* `{success, data}` envelope, so the list is the result itself). A non-list or a row without a
|
|
1346
|
+
* readable field → EXCHANGE_ERROR, never an empty list or a placeholder value.
|
|
1347
|
+
*/
|
|
1343
1348
|
async getOrderHistory(limit = 30) {
|
|
1344
1349
|
if (!this._hasRealKey)
|
|
1345
1350
|
throw new Error("No private key configured — account data unavailable. Run: perp setup");
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1351
|
+
const rows = await this.client.getOrderHistory(this.account, { limit });
|
|
1352
|
+
if (!Array.isArray(rows)) {
|
|
1353
|
+
throw new PerpError("EXCHANGE_ERROR", `Pacifica /orders/history returned no order list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
|
|
1354
|
+
}
|
|
1355
|
+
const field = (o, k) => {
|
|
1356
|
+
const v = o[k];
|
|
1357
|
+
if ((typeof v === "string" && v !== "") || typeof v === "number")
|
|
1358
|
+
return String(v);
|
|
1359
|
+
throw new PerpError("EXCHANGE_ERROR", `Pacifica order-history row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "pacifica" });
|
|
1360
|
+
};
|
|
1361
|
+
return rows.slice(0, limit).map((o) => {
|
|
1362
|
+
const side = field(o, "side");
|
|
1363
|
+
if (side !== "bid" && side !== "ask") {
|
|
1364
|
+
throw new PerpError("EXCHANGE_ERROR", `Pacifica order-history row with unknown side "${side}"`, { exchange: "pacifica" });
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
orderId: field(o, "order_id"),
|
|
1368
|
+
symbol: field(o, "symbol"),
|
|
1369
|
+
side: side === "bid" ? "buy" : "sell",
|
|
1370
|
+
price: field(o, "initial_price"),
|
|
1371
|
+
size: field(o, "amount"),
|
|
1372
|
+
filled: field(o, "filled_amount"),
|
|
1373
|
+
status: field(o, "order_status"),
|
|
1374
|
+
type: field(o, "order_type"),
|
|
1375
|
+
triggerPrice: Number(o.stop_price) > 0 ? String(o.stop_price) : null,
|
|
1376
|
+
};
|
|
1377
|
+
});
|
|
1361
1378
|
}
|
|
1362
1379
|
async getTradeHistory(limit = 30) {
|
|
1363
1380
|
if (!this._hasRealKey)
|
|
@@ -47,7 +47,7 @@ import { alignProtectionPrice, floorSize, requireOnTick, slippagePrice, subDecim
|
|
|
47
47
|
import { unsupported, validateLegacyTif, validateOrderRequest } from "../trading-v2.js";
|
|
48
48
|
import { requireCapabilities } from "../venue.js";
|
|
49
49
|
import { parseOrderRef, toLegacyMarketInfo } from "../venue-types.js";
|
|
50
|
-
import { QFEX_BANKER_NETWORK, QFEX_ENDPOINTS } from "../venues/qfex.js";
|
|
50
|
+
import { QFEX_BANKER_NETWORK, QFEX_ENDPOINTS, QFEX_WITHDRAW_DISABLED, QFEX_WITHDRAW_ENABLED } from "../venues/qfex.js";
|
|
51
51
|
import { loadQfexCredentials, QFEX_IMPORT_REMEDIATION } from "./agent.js";
|
|
52
52
|
import { QfexClient } from "./client.js";
|
|
53
53
|
import { qfexStatusClass } from "./errors.js";
|
|
@@ -1319,6 +1319,9 @@ export class QfexAdapter {
|
|
|
1319
1319
|
* `available_balance` is a different quantity — the venue's own 400 is the answer.
|
|
1320
1320
|
*/
|
|
1321
1321
|
async withdraw(amount, destination, opts) {
|
|
1322
|
+
if (!QFEX_WITHDRAW_ENABLED) {
|
|
1323
|
+
throw unsupported("QFEX", "withdraw", QFEX_WITHDRAW_DISABLED.reason, QFEX_WITHDRAW_DISABLED.remediation);
|
|
1324
|
+
}
|
|
1322
1325
|
const meta = this.requireMeta();
|
|
1323
1326
|
if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
|
|
1324
1327
|
throw new PerpError("INVALID_PARAMS", "QFEX withdraw takes no assetId/routeType (USDC on Arbitrum One only)");
|
|
@@ -7,11 +7,6 @@
|
|
|
7
7
|
* Not registered here; the registry module owns registerVenue().
|
|
8
8
|
*/
|
|
9
9
|
import type { VenueDefinition } from "../venue.js";
|
|
10
|
-
/**
|
|
11
|
-
* Chain Ondo Perps withdrawals are paid out on (POST /v1/withdraw `network`).
|
|
12
|
-
* The venue settles off-chain (settlementChain "offchain"); USDC leaves on Ethereum.
|
|
13
|
-
*/
|
|
14
|
-
export declare const ONDO_WITHDRAW_NETWORK = "ethereum";
|
|
15
10
|
/** Chains the CLI funds an Ondo Perps account from (`funds deposit ondo --chain`). */
|
|
16
11
|
export type OndoDepositChain = "arbitrum" | "ethereum";
|
|
17
12
|
export interface OndoDepositChainPins {
|
|
@@ -16,11 +16,6 @@ const publicData = {
|
|
|
16
16
|
return (await import("../ondo/public.js")).ondoPublicData.fetchMarkets(net);
|
|
17
17
|
},
|
|
18
18
|
};
|
|
19
|
-
/**
|
|
20
|
-
* Chain Ondo Perps withdrawals are paid out on (POST /v1/withdraw `network`).
|
|
21
|
-
* The venue settles off-chain (settlementChain "offchain"); USDC leaves on Ethereum.
|
|
22
|
-
*/
|
|
23
|
-
export const ONDO_WITHDRAW_NETWORK = "ethereum";
|
|
24
19
|
/**
|
|
25
20
|
* PINNED USDC token addresses for Ondo Perps funding (mainnet). Funding is a plain ERC-20
|
|
26
21
|
* transfer of USDC to the account's own funding address (docs.ondoperps.xyz/funding-ondo-perps:
|
|
@@ -103,7 +98,8 @@ export const ondoVenue = {
|
|
|
103
98
|
amend: "cancelReplace",
|
|
104
99
|
batchPlaceMax: 20,
|
|
105
100
|
marginModes: ["cross"],
|
|
106
|
-
|
|
101
|
+
// The API does not allow withdrawals (web app only) — OndoAdapter.withdraw refuses.
|
|
102
|
+
withdrawAssets: null,
|
|
107
103
|
},
|
|
108
104
|
rateLimit: { capacity: 1, refillPerSec: 1, weights: null },
|
|
109
105
|
// Docs (account-set-up, about): "outside the U.S., Panama and other prohibited
|
|
@@ -23,6 +23,17 @@ export declare const QFEX_ENDPOINTS: Readonly<Record<Network, {
|
|
|
23
23
|
}>>;
|
|
24
24
|
/** Chain QFEX USDC deposits / withdrawals use in perp-cli (banker network ARBITRUM_ONE). */
|
|
25
25
|
export declare const QFEX_WITHDRAW_NETWORK = "arbitrum";
|
|
26
|
+
/**
|
|
27
|
+
* API withdrawals are switched off (owner decision 2026-10-13): `funds withdraw qfex` and
|
|
28
|
+
* QfexAdapter.withdraw refuse with CAPABILITY_UNSUPPORTED. The implementation (banker POST
|
|
29
|
+
* /withdraw behind settings.withdraw.qfex) is kept; flip this to re-enable it.
|
|
30
|
+
*/
|
|
31
|
+
export declare const QFEX_WITHDRAW_ENABLED: boolean;
|
|
32
|
+
/** The uniform refusal while QFEX_WITHDRAW_ENABLED is false. */
|
|
33
|
+
export declare const QFEX_WITHDRAW_DISABLED: {
|
|
34
|
+
readonly reason: "QFEX withdrawals are disabled in perp-cli for now";
|
|
35
|
+
readonly remediation: "Withdraw from the QFEX web app";
|
|
36
|
+
};
|
|
26
37
|
/** Banker `network` value for QFEX_WITHDRAW_NETWORK. */
|
|
27
38
|
export declare const QFEX_BANKER_NETWORK = "ARBITRUM_ONE";
|
|
28
39
|
export declare const qfexVenue: VenueDefinition;
|
|
@@ -21,6 +21,14 @@ export const QFEX_ENDPOINTS = {
|
|
|
21
21
|
};
|
|
22
22
|
/** Chain QFEX USDC deposits / withdrawals use in perp-cli (banker network ARBITRUM_ONE). */
|
|
23
23
|
export const QFEX_WITHDRAW_NETWORK = "arbitrum";
|
|
24
|
+
/**
|
|
25
|
+
* API withdrawals are switched off (owner decision 2026-10-13): `funds withdraw qfex` and
|
|
26
|
+
* QfexAdapter.withdraw refuse with CAPABILITY_UNSUPPORTED. The implementation (banker POST
|
|
27
|
+
* /withdraw behind settings.withdraw.qfex) is kept; flip this to re-enable it.
|
|
28
|
+
*/
|
|
29
|
+
export const QFEX_WITHDRAW_ENABLED = false;
|
|
30
|
+
/** The uniform refusal while QFEX_WITHDRAW_ENABLED is false. */
|
|
31
|
+
export const QFEX_WITHDRAW_DISABLED = { reason: "QFEX withdrawals are disabled in perp-cli for now", remediation: "Withdraw from the QFEX web app" };
|
|
24
32
|
/** Banker `network` value for QFEX_WITHDRAW_NETWORK. */
|
|
25
33
|
export const QFEX_BANKER_NETWORK = "ARBITRUM_ONE";
|
|
26
34
|
/** Lazy: public.ts (and `ws`) is only loaded when market data is actually requested. */
|
|
@@ -92,8 +100,8 @@ export const qfexVenue = {
|
|
|
92
100
|
amend: QFEX_MODIFY_KEEPS_CLIENT_ID === true ? "native" : null,
|
|
93
101
|
batchPlaceMax: null,
|
|
94
102
|
marginModes: ["cross"],
|
|
95
|
-
// TRON USDT and USD bank rails are out of scope.
|
|
96
|
-
withdrawAssets: ["USDC"],
|
|
103
|
+
// TRON USDT and USD bank rails are out of scope. API withdrawals are off (QFEX_WITHDRAW_ENABLED).
|
|
104
|
+
withdrawAssets: QFEX_WITHDRAW_ENABLED ? ["USDC"] : null,
|
|
97
105
|
},
|
|
98
106
|
// Venue budget: 12,000 weight / 60 s per USER across all connections and keys (general and
|
|
99
107
|
// cancel buckets separate). This process takes 25% (50/s, burst 100); the rest is left to the
|
package/dist/mcp-server.js
CHANGED
|
@@ -214,7 +214,7 @@ const HL_SCOPE_NOTE = "Hyperliquid: native perps only (HIP-3 dexes via the CLI -
|
|
|
214
214
|
*/
|
|
215
215
|
export const MCP_DEPOSIT_VENUES = ["pacifica", "hyperliquid", "lighter", "lighter-rh", "aster", "ondo", "qfex"];
|
|
216
216
|
/** Venues with a `perp funds withdraw <venue>` command. Tested against the real command tree. */
|
|
217
|
-
export const MCP_WITHDRAW_VENUES = ["pacifica", "hyperliquid", "lighter", "aster", "
|
|
217
|
+
export const MCP_WITHDRAW_VENUES = ["pacifica", "hyperliquid", "lighter", "aster", "arcus"];
|
|
218
218
|
// ── MCP Server ──
|
|
219
219
|
const _require = createRequire(import.meta.url);
|
|
220
220
|
const _pkg = _require("../package.json");
|
|
@@ -1314,7 +1314,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1314
1314
|
subcommands: {
|
|
1315
1315
|
show: { usage: "perp wallet show", description: "Show configured wallets with public addresses" },
|
|
1316
1316
|
list: { usage: "perp wallet list", description: "List all wallets (OWS vault + legacy)" },
|
|
1317
|
-
balance: { usage: "perp wallet balance [name]", description: "On-chain
|
|
1317
|
+
balance: { usage: "perp wallet balance [name]", description: "On-chain gas token + USDC/USDT on Arbitrum, Ethereum, Base, BNB Chain, Robinhood Chain (USDG) and Solana" },
|
|
1318
1318
|
generate: { usage: "perp wallet generate [name]", description: "Generate new OWS wallet (encrypted, multi-chain)" },
|
|
1319
1319
|
import: { usage: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp wallet import --name <name> [--chain evm|solana|dual]", description: "Import key into OWS vault (stdin / hidden prompt — never argv)" },
|
|
1320
1320
|
migrate: { usage: "perp wallet migrate", description: "Migrate legacy wallets to OWS vault" },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface OnchainBalance {
|
|
2
|
+
/** Chain id in perp-cli terms (arbitrum, ethereum, base, bsc, robinhood, solana, …). */
|
|
3
|
+
chain: string;
|
|
4
|
+
token: string;
|
|
5
|
+
kind: "stable" | "native";
|
|
6
|
+
/** Decimal string; null = the read failed (see `error`). */
|
|
7
|
+
balance: string | null;
|
|
8
|
+
/** Stablecoins at $1; native tokens at the Hyperliquid mid; null when unknown. */
|
|
9
|
+
usdValue: number | null;
|
|
10
|
+
/** Why the balance read failed (balance null). */
|
|
11
|
+
error: string | null;
|
|
12
|
+
/** Why a native token's usdValue is null although its balance was read. */
|
|
13
|
+
priceError: string | null;
|
|
14
|
+
}
|
|
15
|
+
interface TokenPin {
|
|
16
|
+
symbol: string;
|
|
17
|
+
address: string;
|
|
18
|
+
decimals: number;
|
|
19
|
+
}
|
|
20
|
+
interface EvmChainPin {
|
|
21
|
+
label: string;
|
|
22
|
+
chainId: number;
|
|
23
|
+
rpcUrl: string;
|
|
24
|
+
native: string;
|
|
25
|
+
tokens: TokenPin[];
|
|
26
|
+
}
|
|
27
|
+
/** Mainnet EVM chains, in display order. */
|
|
28
|
+
export declare function mainnetEvmChains(): Record<string, EvmChainPin>;
|
|
29
|
+
/** Per-request timeout of every read here: a stuck public RPC becomes a per-row error, not a hang. */
|
|
30
|
+
export declare const ONCHAIN_READ_TIMEOUT_MS = 10000;
|
|
31
|
+
/** Gas token + stablecoins on every EVM chain (mainnet), or Arbitrum Sepolia (testnet). `only` limits the chains. */
|
|
32
|
+
export declare function evmOnchainBalances(address: string, testnet: boolean, only?: readonly string[]): Promise<OnchainBalance[]>;
|
|
33
|
+
/** SOL + USDC / USDT (mainnet) or USDP (devnet) of a Solana address. */
|
|
34
|
+
export declare function solanaOnchainBalances(address: string, testnet: boolean): Promise<OnchainBalance[]>;
|
|
35
|
+
/** Sum of the stablecoin balances that were read (failed reads are excluded and listed in `errors`). */
|
|
36
|
+
export declare function stableTotal(rows: readonly OnchainBalance[]): {
|
|
37
|
+
usd: number;
|
|
38
|
+
errors: string[];
|
|
39
|
+
};
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-chain wallet balances (`perp wallet balance`): the gas token plus the USD stablecoins
|
|
3
|
+
* (USDC / USDT; USDG on Robinhood Chain) on every chain perp-cli moves funds on.
|
|
4
|
+
*
|
|
5
|
+
* Token pins come from the registries the fund-movement code already uses (RELAY_CHAINS,
|
|
6
|
+
* ONDO_DEPOSIT_CHAINS); the two not in them are Tether's Ethereum USDT and Solana USDT
|
|
7
|
+
* (tether.to/en/supported-protocols). A read that fails is reported per token
|
|
8
|
+
* (balance null + error) — never shown as 0.
|
|
9
|
+
*/
|
|
10
|
+
import { HYPERLIQUID_API_URL } from "./api/public/urls.js";
|
|
11
|
+
import { ONDO_DEPOSIT_CHAINS } from "./exchanges/venues/ondo.js";
|
|
12
|
+
import { RELAY_CHAINS } from "./relay-swap.js";
|
|
13
|
+
const relayToken = (chain, symbol) => {
|
|
14
|
+
const a = RELAY_CHAINS[chain]?.assets[symbol];
|
|
15
|
+
if (a === undefined)
|
|
16
|
+
throw new Error(`RELAY_CHAINS has no ${symbol} on ${chain}`);
|
|
17
|
+
return { symbol, address: a.address, decimals: a.decimals };
|
|
18
|
+
};
|
|
19
|
+
const relayRpc = (chain) => {
|
|
20
|
+
const url = RELAY_CHAINS[chain]?.rpcUrl;
|
|
21
|
+
if (url === null || url === undefined)
|
|
22
|
+
throw new Error(`RELAY_CHAINS has no RPC for ${chain}`);
|
|
23
|
+
return url;
|
|
24
|
+
};
|
|
25
|
+
/** Mainnet EVM chains, in display order. */
|
|
26
|
+
export function mainnetEvmChains() {
|
|
27
|
+
return {
|
|
28
|
+
arbitrum: {
|
|
29
|
+
label: "Arbitrum", chainId: 42161, rpcUrl: relayRpc("arbitrum"), native: "ETH",
|
|
30
|
+
tokens: [relayToken("arbitrum", "USDC"), relayToken("arbitrum", "USDT")],
|
|
31
|
+
},
|
|
32
|
+
ethereum: {
|
|
33
|
+
label: "Ethereum", chainId: 1, rpcUrl: ONDO_DEPOSIT_CHAINS.ethereum.rpcUrl, native: "ETH",
|
|
34
|
+
tokens: [
|
|
35
|
+
{ symbol: "USDC", address: ONDO_DEPOSIT_CHAINS.ethereum.usdc, decimals: 6 },
|
|
36
|
+
{ symbol: "USDT", address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6 },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
base: {
|
|
40
|
+
label: "Base", chainId: 8453, rpcUrl: relayRpc("base"), native: "ETH",
|
|
41
|
+
tokens: [relayToken("base", "USDC")],
|
|
42
|
+
},
|
|
43
|
+
bsc: {
|
|
44
|
+
label: "BNB Chain", chainId: 56, rpcUrl: relayRpc("bsc"), native: "BNB",
|
|
45
|
+
tokens: [relayToken("bsc", "USDT"), relayToken("bsc", "USDC")],
|
|
46
|
+
},
|
|
47
|
+
robinhood: {
|
|
48
|
+
label: "Robinhood Chain", chainId: 4663, rpcUrl: relayRpc("robinhood"), native: "ETH",
|
|
49
|
+
tokens: [relayToken("robinhood", "USDG")],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const TESTNET_EVM_CHAINS = {
|
|
54
|
+
"arbitrum-sepolia": {
|
|
55
|
+
label: "Arbitrum Sepolia", chainId: 421614, rpcUrl: "https://sepolia-rollup.arbitrum.io/rpc", native: "ETH",
|
|
56
|
+
tokens: [{ symbol: "USDC", address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", decimals: 6 }],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
/** Built on use (not at import) so a registry change can only break `wallet balance`, never startup. */
|
|
60
|
+
function solanaPins(testnet) {
|
|
61
|
+
return testnet
|
|
62
|
+
? { rpcUrl: "https://api.devnet.solana.com", tokens: [{ symbol: "USDP", address: "USDPqRbLidFGufty2s3oizmDEKdqx7ePTqzDMbf5ZKM", decimals: 6 }] }
|
|
63
|
+
: {
|
|
64
|
+
rpcUrl: "https://api.mainnet-beta.solana.com",
|
|
65
|
+
tokens: [
|
|
66
|
+
{ symbol: "USDC", address: relayToken("solana", "USDC").address, decimals: 6 },
|
|
67
|
+
{ symbol: "USDT", address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", decimals: 6 },
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Per-request timeout of every read here: a stuck public RPC becomes a per-row error, not a hang. */
|
|
72
|
+
export const ONCHAIN_READ_TIMEOUT_MS = 10_000;
|
|
73
|
+
const timedFetch = (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(ONCHAIN_READ_TIMEOUT_MS) });
|
|
74
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 160);
|
|
75
|
+
/**
|
|
76
|
+
* USD mids of the native gas tokens from Hyperliquid allMids. A failed / missing price leaves the
|
|
77
|
+
* native row's usdValue null and names the reason in `priceError`; balances are unaffected.
|
|
78
|
+
*/
|
|
79
|
+
async function nativePrices() {
|
|
80
|
+
const px = new Map();
|
|
81
|
+
try {
|
|
82
|
+
const res = await timedFetch(HYPERLIQUID_API_URL, {
|
|
83
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "allMids" }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
return { px, error: `Hyperliquid allMids HTTP ${res.status}` };
|
|
87
|
+
const mids = await res.json();
|
|
88
|
+
for (const sym of ["ETH", "BNB", "SOL"]) {
|
|
89
|
+
const v = Number(mids[sym]);
|
|
90
|
+
if (typeof mids[sym] === "string" && Number.isFinite(v) && v > 0)
|
|
91
|
+
px.set(sym, v);
|
|
92
|
+
}
|
|
93
|
+
return { px, error: null };
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return { px, error: `Hyperliquid allMids: ${errText(e)}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function row(chain, token, kind, balance, prices, error) {
|
|
100
|
+
let usdValue = null;
|
|
101
|
+
let priceError = null;
|
|
102
|
+
if (balance !== null) {
|
|
103
|
+
if (kind === "stable")
|
|
104
|
+
usdValue = Number(balance);
|
|
105
|
+
else if (prices.px.has(token))
|
|
106
|
+
usdValue = Number(balance) * prices.px.get(token);
|
|
107
|
+
else
|
|
108
|
+
priceError = prices.error ?? `no Hyperliquid mid for ${token}`;
|
|
109
|
+
}
|
|
110
|
+
return { chain, token, kind, balance, usdValue, error, priceError };
|
|
111
|
+
}
|
|
112
|
+
async function evmChainBalances(ethers, chain, pin, address, prices) {
|
|
113
|
+
const req = new ethers.FetchRequest(pin.rpcUrl);
|
|
114
|
+
req.timeout = ONCHAIN_READ_TIMEOUT_MS;
|
|
115
|
+
// batchMaxCount 1: one plain JSON-RPC call per read (not every public RPC accepts batches).
|
|
116
|
+
const provider = new ethers.JsonRpcProvider(req, pin.chainId, { staticNetwork: true, batchMaxCount: 1 });
|
|
117
|
+
const native = provider.getBalance(address)
|
|
118
|
+
.then(b => row(chain, pin.native, "native", ethers.formatEther(b), prices, null))
|
|
119
|
+
.catch(e => row(chain, pin.native, "native", null, prices, errText(e)));
|
|
120
|
+
const tokens = pin.tokens.map(t => {
|
|
121
|
+
const c = new ethers.Contract(t.address, ["function balanceOf(address) view returns (uint256)"], provider);
|
|
122
|
+
return c.balanceOf(address)
|
|
123
|
+
.then(b => row(chain, t.symbol, "stable", ethers.formatUnits(b, t.decimals), prices, null))
|
|
124
|
+
.catch(e => row(chain, t.symbol, "stable", null, prices, errText(e)));
|
|
125
|
+
});
|
|
126
|
+
return Promise.all([native, ...tokens]);
|
|
127
|
+
}
|
|
128
|
+
/** Gas token + stablecoins on every EVM chain (mainnet), or Arbitrum Sepolia (testnet). `only` limits the chains. */
|
|
129
|
+
export async function evmOnchainBalances(address, testnet, only) {
|
|
130
|
+
const chains = testnet ? TESTNET_EVM_CHAINS : mainnetEvmChains();
|
|
131
|
+
const selected = Object.entries(chains).filter(([id]) => only === undefined || only.includes(id));
|
|
132
|
+
const [{ ethers }, prices] = await Promise.all([import("ethers"), nativePrices()]);
|
|
133
|
+
return (await Promise.all(selected.map(([id, pin]) => evmChainBalances(ethers, id, pin, address, prices)))).flat();
|
|
134
|
+
}
|
|
135
|
+
/** SOL + USDC / USDT (mainnet) or USDP (devnet) of a Solana address. */
|
|
136
|
+
export async function solanaOnchainBalances(address, testnet) {
|
|
137
|
+
const { Connection, PublicKey } = await import("@solana/web3.js");
|
|
138
|
+
const cfg = solanaPins(testnet);
|
|
139
|
+
const chain = testnet ? "solana-devnet" : "solana";
|
|
140
|
+
const connection = new Connection(cfg.rpcUrl, { fetch: timedFetch });
|
|
141
|
+
const owner = new PublicKey(address);
|
|
142
|
+
const prices = await nativePrices();
|
|
143
|
+
const sol = connection.getBalance(owner)
|
|
144
|
+
.then(l => row(chain, "SOL", "native", (l / 1e9).toFixed(9).replace(/\.?0+$/, ""), prices, null))
|
|
145
|
+
.catch(e => row(chain, "SOL", "native", null, prices, errText(e)));
|
|
146
|
+
const tokens = cfg.tokens.map(t => connection.getParsedTokenAccountsByOwner(owner, { mint: new PublicKey(t.address) })
|
|
147
|
+
.then(res => {
|
|
148
|
+
let raw = 0n;
|
|
149
|
+
for (const { account } of res.value) {
|
|
150
|
+
const amount = account.data.parsed?.info?.tokenAmount?.amount;
|
|
151
|
+
if (typeof amount !== "string")
|
|
152
|
+
throw new Error(`token account without a raw amount for ${t.symbol}`);
|
|
153
|
+
raw += BigInt(amount);
|
|
154
|
+
}
|
|
155
|
+
const s = raw.toString().padStart(t.decimals + 1, "0");
|
|
156
|
+
const dec = `${s.slice(0, -t.decimals)}.${s.slice(-t.decimals)}`.replace(/\.?0+$/, "");
|
|
157
|
+
return row(chain, t.symbol, "stable", dec, prices, null);
|
|
158
|
+
})
|
|
159
|
+
.catch(e => row(chain, t.symbol, "stable", null, prices, errText(e))));
|
|
160
|
+
return Promise.all([sol, ...tokens]);
|
|
161
|
+
}
|
|
162
|
+
/** Sum of the stablecoin balances that were read (failed reads are excluded and listed in `errors`). */
|
|
163
|
+
export function stableTotal(rows) {
|
|
164
|
+
let usd = 0;
|
|
165
|
+
const errors = [];
|
|
166
|
+
for (const r of rows) {
|
|
167
|
+
if (r.error !== null)
|
|
168
|
+
errors.push(`${r.chain} ${r.token}: ${r.error}`);
|
|
169
|
+
else if (r.kind === "stable")
|
|
170
|
+
usd += Number(r.balance);
|
|
171
|
+
}
|
|
172
|
+
return { usd, errors };
|
|
173
|
+
}
|
package/package.json
CHANGED
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -6,7 +6,7 @@ allowed-tools: "Bash(perp:*), Bash(npx -y perp-cli:*), Bash(bash scripts/preflig
|
|
|
6
6
|
compatibility: "Requires Node.js 20 or newer and the perp-cli npm package (global install or npx). Network access required for exchange APIs and bridge providers. Mutating commands need a wallet key imported into the OWS vault via 'perp wallet set EXCHANGE' (key on stdin or hidden prompt — never argv). Skill is portable across Claude Code, Claude.ai, Cursor, Codex, and Gemini CLI — no platform-specific features."
|
|
7
7
|
metadata:
|
|
8
8
|
author: hypurrquant
|
|
9
|
-
version: "0.14.
|
|
9
|
+
version: "0.14.1"
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
# perp-cli
|
|
@@ -46,7 +46,7 @@ Also bundled under `scripts/`: `preflight.sh`, `arb-monitor.sh`, `funding-analys
|
|
|
46
46
|
### Step 1: Verify install + version
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.
|
|
49
|
+
perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.1
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
If it is missing or older, ask the user before installing/upgrading (`npm install -g perp-cli@latest`) — never install on your own. If `perp` is not on PATH, prefix every command with `npx -y perp-cli@latest`.
|
|
@@ -94,7 +94,7 @@ Imported keys trade; `funds deposit|withdraw|transfer|bridge`, `wallet manage re
|
|
|
94
94
|
- Limit GTC / ALO returns `status: accepted, finality: pending` after the ACK; `open` only via `perp --json trade status c:<id> --symbol <S> -e qfex`.
|
|
95
95
|
- `ORDER_PENDING` = outcome unknown: NEVER resubmit; check with the `c:<id>` in the remediation.
|
|
96
96
|
- Funding rates on QFEX are unverified: excluded everywhere unless `perp --json arb scan --rates --include-unverified` (display only).
|
|
97
|
-
- Withdrawals
|
|
97
|
+
- Withdrawals are disabled in perp-cli for now: `funds withdraw qfex` → `CAPABILITY_UNSUPPORTED` (also under `--dry-run`). Withdraw in the QFEX web app. Ondo Perps has no API withdrawals either.
|
|
98
98
|
|
|
99
99
|
Venue rules for these: `trade limit` defaults to `--tif GTC` on every venue (arcus maps it to GTT; ondo offers GTC|IOC|ALO; FOK is never emulated → `CAPABILITY_UNSUPPORTED` where not offered); market orders on every venue are bounded by `--slippage-bps <n>` → `settings.slippageBps.<venue>` → `settings.defaultSlippageBps` (shipped default 50). Ondo (US, PA) and Arcus (US, CA, GB) print a one-time restricted-region notice on stderr when used with credentials (warn only, never blocks).
|
|
100
100
|
|
|
@@ -116,6 +116,23 @@ perp --json arb scan --live --interval 60 # continuous monitoring
|
|
|
116
116
|
|
|
117
117
|
Common knobs: `--min <pct>`, `--top <n>`, `--hold-days <n>`, `--bridge-cost <usd>`, `--size <usd>`.
|
|
118
118
|
|
|
119
|
+
#### HIP-3 dexes (part of Hyperliquid, not a separate venue)
|
|
120
|
+
|
|
121
|
+
HIP-3 dexes are selected on the Hyperliquid venue with the root `--dex <name>` flag; every market / account / trade command works the same way. Same account and wallet as native Hyperliquid perps.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
perp --json -e hyperliquid market hip3 # list deployed dexes (name, markets)
|
|
125
|
+
perp --json -e hyperliquid --dex xyz market list # markets on dex "xyz"
|
|
126
|
+
perp --json -e hyperliquid --dex xyz market info GOLD # symbols WITHOUT the "xyz:" prefix
|
|
127
|
+
perp --json -e hyperliquid --dex xyz account positions # positions on that dex only
|
|
128
|
+
perp --json -e hyperliquid --dex xyz --dry-run trade market GOLD buy 0.01
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `--dex` with any other `-e` venue → `INVALID_PARAMS` (never ignored).
|
|
132
|
+
- A dex symbol without `--dex` (e.g. `-e hyperliquid market mid xyz:GOLD`) → `SYMBOL_NOT_FOUND` with remediation `--dex xyz`; it is never read from the native dex.
|
|
133
|
+
- `portfolio --dexes xyz,…` adds HIP-3 dex balances; builder fees are never attached on HIP-3.
|
|
134
|
+
- MCP tools cover native Hyperliquid perps only — use the CLI for HIP-3.
|
|
135
|
+
|
|
119
136
|
### Step 4: Validate before mutating
|
|
120
137
|
|
|
121
138
|
For arb:
|