perp-cli 0.14.0 → 0.14.2

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.
Files changed (75) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/cli-program.js +19 -2
  19. package/dist/commands/account.js +2 -2
  20. package/dist/commands/arb/index.d.ts +20 -0
  21. package/dist/commands/arb/index.js +121 -36
  22. package/dist/commands/arb-auto.d.ts +36 -0
  23. package/dist/commands/arb-auto.js +202 -76
  24. package/dist/commands/arb.js +51 -35
  25. package/dist/commands/bot.d.ts +6 -0
  26. package/dist/commands/bot.js +55 -45
  27. package/dist/commands/funds-onchain.d.ts +1 -1
  28. package/dist/commands/funds-onchain.js +12 -49
  29. package/dist/commands/funds.js +51 -52
  30. package/dist/commands/history.js +165 -62
  31. package/dist/commands/portfolio.d.ts +5 -0
  32. package/dist/commands/portfolio.js +58 -20
  33. package/dist/commands/rebalance.js +46 -10
  34. package/dist/commands/settings.js +2 -1
  35. package/dist/commands/trade.js +8 -4
  36. package/dist/commands/wallet.js +74 -155
  37. package/dist/cross-chain-margin.d.ts +29 -2
  38. package/dist/cross-chain-margin.js +30 -4
  39. package/dist/dashboard/server.d.ts +45 -8
  40. package/dist/dashboard/server.js +113 -42
  41. package/dist/dashboard/ui.js +46 -27
  42. package/dist/dashboard/ws-feeds.d.ts +25 -3
  43. package/dist/dashboard/ws-feeds.js +219 -82
  44. package/dist/dex-asset-map.d.ts +23 -4
  45. package/dist/dex-asset-map.js +39 -13
  46. package/dist/exchanges/arcus/adapter.js +33 -6
  47. package/dist/exchanges/arcus/public.d.ts +6 -0
  48. package/dist/exchanges/arcus/public.js +12 -4
  49. package/dist/exchanges/aster.d.ts +33 -0
  50. package/dist/exchanges/aster.js +162 -55
  51. package/dist/exchanges/hyperliquid.d.ts +30 -0
  52. package/dist/exchanges/hyperliquid.js +159 -34
  53. package/dist/exchanges/lighter.d.ts +1 -1
  54. package/dist/exchanges/lighter.js +11 -9
  55. package/dist/exchanges/ondo/adapter.d.ts +4 -18
  56. package/dist/exchanges/ondo/adapter.js +5 -29
  57. package/dist/exchanges/ondo/funds.d.ts +1 -29
  58. package/dist/exchanges/ondo/funds.js +2 -94
  59. package/dist/exchanges/pacifica.d.ts +12 -0
  60. package/dist/exchanges/pacifica.js +78 -35
  61. package/dist/exchanges/qfex/adapter.js +4 -1
  62. package/dist/exchanges/venues/ondo.d.ts +0 -5
  63. package/dist/exchanges/venues/ondo.js +2 -6
  64. package/dist/exchanges/venues/qfex.d.ts +11 -0
  65. package/dist/exchanges/venues/qfex.js +10 -2
  66. package/dist/mcp-server.js +2 -2
  67. package/dist/onchain-balances.d.ts +40 -0
  68. package/dist/onchain-balances.js +173 -0
  69. package/dist/rebalance.d.ts +20 -1
  70. package/dist/rebalance.js +31 -5
  71. package/dist/strategies/funding-arb.js +38 -7
  72. package/package.json +1 -1
  73. package/skills/perp-cli/SKILL.md +20 -3
  74. package/skills/perp-cli/references/agent-operations.md +2 -2
  75. package/skills/perp-cli/references/commands.md +12 -9
@@ -332,6 +332,23 @@ function spotInfoSymbols(rows) {
332
332
  export function isPacificaStopType(type) {
333
333
  return /^(stop|take_profit|stop_loss)_/.test(type);
334
334
  }
335
+ /** Fill sides of GET /trades/history (position effect) plus the order sides bid / ask. */
336
+ const PACIFICA_FILL_BUY_SIDES = new Set(["open_long", "close_short", "bid"]);
337
+ const PACIFICA_FILL_SELL_SIDES = new Set(["open_short", "close_long", "ask"]);
338
+ /** `created_at` (ms) of a Pacifica history row; non-numeric → EXCHANGE_ERROR (never NaN). */
339
+ function pacificaTime(row, endpoint) {
340
+ const t = Number(pacificaField(row, "created_at", endpoint));
341
+ if (!Number.isFinite(t))
342
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row with a non-numeric created_at`, { exchange: "pacifica" });
343
+ return t;
344
+ }
345
+ /** A required field of a Pacifica history row as a string; missing / empty → EXCHANGE_ERROR. */
346
+ function pacificaField(row, key, endpoint) {
347
+ const v = row[key];
348
+ if ((typeof v === "string" && v !== "") || (typeof v === "number" && Number.isFinite(v)))
349
+ return String(v);
350
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row without a readable ${key}: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "pacifica" });
351
+ }
335
352
  export class PacificaAdapter {
336
353
  name = "pacifica";
337
354
  chain = "solana";
@@ -1340,52 +1357,78 @@ export class PacificaAdapter {
1340
1357
  trades: k.n,
1341
1358
  }));
1342
1359
  }
1360
+ /**
1361
+ * Recent orders, newest first (GET /orders/history; PacificaClient.get already unwraps the
1362
+ * `{success, data}` envelope, so the list is the result itself). A non-list or a row without a
1363
+ * readable field → EXCHANGE_ERROR, never an empty list or a placeholder value.
1364
+ */
1343
1365
  async getOrderHistory(limit = 30) {
1344
1366
  if (!this._hasRealKey)
1345
1367
  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
- }));
1368
+ const rows = await this.client.getOrderHistory(this.account, { limit });
1369
+ if (!Array.isArray(rows)) {
1370
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /orders/history returned no order list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1371
+ }
1372
+ const field = (o, k) => pacificaField(o, k, "/orders/history");
1373
+ return rows.slice(0, limit).map((o) => {
1374
+ const side = field(o, "side");
1375
+ if (side !== "bid" && side !== "ask") {
1376
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica order-history row with unknown side "${side}"`, { exchange: "pacifica" });
1377
+ }
1378
+ return {
1379
+ orderId: field(o, "order_id"),
1380
+ symbol: field(o, "symbol"),
1381
+ side: side === "bid" ? "buy" : "sell",
1382
+ price: field(o, "initial_price"),
1383
+ size: field(o, "amount"),
1384
+ filled: field(o, "filled_amount"),
1385
+ status: field(o, "order_status"),
1386
+ type: field(o, "order_type"),
1387
+ triggerPrice: Number(o.stop_price) > 0 ? String(o.stop_price) : null,
1388
+ };
1389
+ });
1361
1390
  }
1391
+ /**
1392
+ * Recent fills, newest first (GET /trades/history; the client unwraps `{success, data}`).
1393
+ * `side` is the position effect — open_long / close_short buy, open_short / close_long sell
1394
+ * (live 2026-10-13: `"side":"close_short"` on a buy-to-close fill); bid / ask are accepted for
1395
+ * rows that carry an order side. Anything else, a non-list or an unreadable field → EXCHANGE_ERROR.
1396
+ */
1362
1397
  async getTradeHistory(limit = 30) {
1363
1398
  if (!this._hasRealKey)
1364
1399
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1365
- const raw = await this.client.getTradeHistory(this.account);
1366
- const trades = (raw.data ?? raw);
1367
- if (!Array.isArray(trades))
1368
- return [];
1369
- return trades.slice(0, limit).map((t) => ({
1370
- time: Number(t.created_at ?? 0),
1371
- symbol: String(t.symbol ?? ""),
1372
- side: String(t.side) === "bid" ? "buy" : "sell",
1373
- price: String(t.price ?? "0"),
1374
- size: String(t.amount ?? ""),
1375
- fee: String(t.fee ?? "0"),
1376
- }));
1400
+ const rows = await this.client.getTradeHistory(this.account, { limit });
1401
+ if (!Array.isArray(rows)) {
1402
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1403
+ }
1404
+ return rows.slice(0, limit).map((t) => {
1405
+ const side = pacificaField(t, "side", "/trades/history");
1406
+ const buy = PACIFICA_FILL_BUY_SIDES.has(side);
1407
+ if (!buy && !PACIFICA_FILL_SELL_SIDES.has(side)) {
1408
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history row with unknown side "${side}"`, { exchange: "pacifica" });
1409
+ }
1410
+ return {
1411
+ time: pacificaTime(t, "/trades/history"),
1412
+ symbol: pacificaField(t, "symbol", "/trades/history"),
1413
+ side: buy ? "buy" : "sell",
1414
+ price: pacificaField(t, "price", "/trades/history"),
1415
+ size: pacificaField(t, "amount", "/trades/history"),
1416
+ fee: pacificaField(t, "fee", "/trades/history"),
1417
+ };
1418
+ });
1377
1419
  }
1420
+ /** Funding paid / received per position (GET /funding/history, `payout` signed). Strict like getTradeHistory. */
1378
1421
  async getFundingPayments(limit = 200) {
1379
1422
  if (!this._hasRealKey)
1380
1423
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1381
- const raw = await this.client.getFundingAccountHistory(this.account);
1382
- const history = (raw.data ?? raw);
1383
- if (!Array.isArray(history))
1384
- return [];
1385
- return history.slice(0, limit).map((h) => ({
1386
- time: Number(h.created_at ?? 0),
1387
- symbol: String(h.symbol ?? ""),
1388
- payment: String(h.payout ?? "0"),
1424
+ const rows = await this.client.getFundingAccountHistory(this.account, { limit });
1425
+ if (!Array.isArray(rows)) {
1426
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /funding/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1427
+ }
1428
+ return rows.slice(0, limit).map((h) => ({
1429
+ time: pacificaTime(h, "/funding/history"),
1430
+ symbol: pacificaField(h, "symbol", "/funding/history"),
1431
+ payment: pacificaField(h, "payout", "/funding/history"),
1389
1432
  }));
1390
1433
  }
1391
1434
  }
@@ -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
+ }
@@ -28,10 +28,29 @@ export interface RebalanceMove {
28
28
  amount: number;
29
29
  reason: string;
30
30
  }
31
+ /** A venue whose balance could not be read (left out of the snapshots). */
32
+ export interface BalanceReadFailure {
33
+ exchange: string;
34
+ error: string;
35
+ }
31
36
  /**
32
- * Fetch balances from all exchanges in parallel.
37
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
38
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
33
39
  */
34
40
  export declare function fetchAllBalances(adapters: Map<string, ExchangeAdapter>): Promise<ExchangeBalanceSnapshot[]>;
41
+ /**
42
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
43
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
44
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
45
+ * is not read and is returned in `notConfigured` — not a failure.
46
+ */
47
+ export declare function fetchBalancesWithFailures(adapters: Map<string, ExchangeAdapter>): Promise<{
48
+ snapshots: ExchangeBalanceSnapshot[];
49
+ failed: BalanceReadFailure[];
50
+ notConfigured: string[];
51
+ }>;
52
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
53
+ export declare function isReadOnlyAdapter(a: ExchangeAdapter): boolean;
35
54
  /**
36
55
  * Compute a rebalancing plan to equalize available balance across exchanges.
37
56
  *
package/dist/rebalance.js CHANGED
@@ -20,10 +20,26 @@ import { EXCHANGE_TO_CHAIN } from "./bridge-engine.js";
20
20
  // Re-export for backward compat
21
21
  const EXCHANGE_CHAINS = Object.fromEntries(Object.entries(EXCHANGE_TO_CHAIN).map(([ex, chain]) => [ex, { chain }]));
22
22
  /**
23
- * Fetch balances from all exchanges in parallel.
23
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
24
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
24
25
  */
25
26
  export async function fetchAllBalances(adapters) {
26
- const entries = [...adapters.entries()];
27
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
28
+ for (const f of failed)
29
+ process.stderr.write(`[warn] ${f.exchange} balance unavailable (left out): ${f.error}\n`);
30
+ for (const n of notConfigured)
31
+ process.stderr.write(`[info] ${n} not configured (no credentials) — left out\n`);
32
+ return snapshots;
33
+ }
34
+ /**
35
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
36
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
37
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
38
+ * is not read and is returned in `notConfigured` — not a failure.
39
+ */
40
+ export async function fetchBalancesWithFailures(adapters) {
41
+ const notConfigured = [...adapters.entries()].filter(([, a]) => isReadOnlyAdapter(a)).map(([n]) => n);
42
+ const entries = [...adapters.entries()].filter(([, a]) => !isReadOnlyAdapter(a));
27
43
  const results = await Promise.allSettled(entries.map(async ([name, adapter]) => {
28
44
  const bal = await adapter.getBalance();
29
45
  // Strict policy (qa/2026-05-16): empty string is corruption, not
@@ -57,9 +73,19 @@ export async function fetchAllBalances(adapters) {
57
73
  unrealizedPnl,
58
74
  };
59
75
  }));
60
- return results
61
- .filter((r) => r.status === "fulfilled")
62
- .map((r) => r.value);
76
+ const snapshots = [];
77
+ const failed = [];
78
+ results.forEach((r, i) => {
79
+ if (r.status === "fulfilled")
80
+ snapshots.push(r.value);
81
+ else
82
+ failed.push({ exchange: entries[i][0], error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
83
+ });
84
+ return { snapshots, failed, notConfigured };
85
+ }
86
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
87
+ export function isReadOnlyAdapter(a) {
88
+ return a.isReadOnly === true;
63
89
  }
64
90
  /**
65
91
  * Compute a rebalancing plan to equalize available balance across exchanges.