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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Ondo Perps funding (mainnet only) + withdrawal address book.
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) + withdrawal address book.
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, verifyMessage } from "ethers";
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 result = await this.client.getOrderHistory(this.account);
1347
- const data = (result.data ?? result.orders ?? []);
1348
- if (!Array.isArray(data))
1349
- return [];
1350
- return data.slice(0, limit).map((o) => ({
1351
- orderId: String(o.order_id ?? ""),
1352
- symbol: String(o.symbol ?? ""),
1353
- side: String(o.side) === "bid" ? "buy" : "sell",
1354
- price: String(o.initial_price ?? o.price ?? "0"),
1355
- size: String(o.amount ?? o.initial_amount ?? ""),
1356
- filled: String(o.filled_amount ?? "0"),
1357
- status: String(o.order_status ?? "done"),
1358
- type: String(o.order_type ?? ""),
1359
- triggerPrice: Number(o.stop_price) > 0 ? String(o.stop_price) : null,
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
- withdrawAssets: ["USDC"],
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
@@ -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", "ondo", "arcus", "qfex"];
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 balances" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster, Lighter RH, Ondo Perps, Arcus",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -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.0"
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.0
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 need `settings.withdraw.qfex` (allowlist + limits), which only the user can set in an interactive terminal — an agent cannot set or change it. `perp --json --dry-run funds withdraw qfex <amt> --to <addr>` shows the gate verdict.
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: