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.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/cli-program.js +19 -2
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.d.ts +1 -1
- package/dist/commands/funds-onchain.js +12 -49
- package/dist/commands/funds.js +51 -52
- package/dist/commands/history.js +165 -62
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +74 -155
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +46 -27
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +33 -0
- package/dist/exchanges/aster.js +162 -55
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- 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 -29
- package/dist/exchanges/ondo/funds.js +2 -94
- package/dist/exchanges/pacifica.d.ts +12 -0
- package/dist/exchanges/pacifica.js +78 -35
- 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/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- 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 +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
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
return
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
return
|
|
1386
|
-
time:
|
|
1387
|
-
symbol:
|
|
1388
|
-
payment:
|
|
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
|
-
|
|
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/dist/rebalance.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|