perp-cli 0.12.6 → 0.12.8

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.
@@ -9,7 +9,27 @@ function pac(adapter) {
9
9
  }
10
10
  export function registerAccountCommands(program, getAdapter, isJson, getAdapterForExchange) {
11
11
  const account = program.command("account").description("Account commands");
12
- // ── Single exchange balance fetch (used by both single + multi mode) ──
12
+ // ── balance alias → redirect to portfolio (renamed in v0.12) ──
13
+ // Users who type `perp account balance` get a structured pointer instead of
14
+ // "unknown command". Per Rule #2 we don't silently retarget — we tell the
15
+ // user what to run and exit non-zero so scripts can detect the rename.
16
+ account
17
+ .command("balance")
18
+ .description("[renamed] Use 'perp portfolio' (exchange-aggregated) or 'perp portfolio -e <ex>'")
19
+ .action(async () => {
20
+ const msg = "'account balance' was renamed in v0.12 — use 'portfolio' instead.";
21
+ const remediation = "perp portfolio # all exchanges\nperp portfolio -e <exchange> # single exchange detail";
22
+ if (isJson()) {
23
+ const { jsonError } = await import("../utils.js");
24
+ const { printJson } = await import("../utils.js");
25
+ printJson(jsonError("INVALID_PARAMS", msg, { details: { remediation } }));
26
+ process.exit(1);
27
+ }
28
+ const chalk = (await import("chalk")).default;
29
+ console.error(chalk.yellow(`\n ${msg}`));
30
+ console.error(chalk.gray(` Run: ${remediation}\n`));
31
+ process.exit(1);
32
+ });
13
33
  // ── HIP-3 dex helper: fetch data from all deployed dexes in parallel ──
14
34
  async function fetchHip3Data(hlAdapter, fetcher) {
15
35
  const dexes = await hlAdapter.listDeployedDexes();
@@ -7,3 +7,37 @@ import { Command } from "commander";
7
7
  * relocates it to `wallet agent ...`.
8
8
  */
9
9
  export declare function registerWalletAgentCommands(walletCmd: Command, isJson: () => boolean): void;
10
+ /** Public abstraction modes (CLI surface) → HL on-wire string. */
11
+ declare const HL_ABSTRACTION_TO_WIRE: {
12
+ readonly unified: "unifiedAccount";
13
+ readonly standard: "disabled";
14
+ readonly portfolio: "portfolioMargin";
15
+ };
16
+ export type HlAbstractionMode = keyof typeof HL_ABSTRACTION_TO_WIRE;
17
+ interface HlSetAbstractionFlowOpts {
18
+ masterName: string;
19
+ passphrase: string;
20
+ mode: HlAbstractionMode;
21
+ isTestnet: boolean;
22
+ }
23
+ interface HlSetAbstractionFlowResult {
24
+ mode: HlAbstractionMode;
25
+ abstraction: string;
26
+ hyperliquidChain: "Mainnet" | "Testnet";
27
+ nonce: number;
28
+ userEvmAddress: `0x${string}`;
29
+ response: Record<string, unknown>;
30
+ }
31
+ /**
32
+ * Send a Hyperliquid `userSetAbstraction` action.
33
+ *
34
+ * Reuses the same EIP-712 user-signed domain (`HyperliquidSignTransaction`,
35
+ * chainId 42161) and `OwsEvmSigner.signTypedData` plumbing as
36
+ * `runHlApproveFlow`; the only differences are the type struct
37
+ * (`UserSetAbstraction` vs `ApproveAgent`) and the action payload.
38
+ *
39
+ * Throws `PerpError` with structured remediation on signature/network/venue
40
+ * failure (Rule #2: no fallback, no silent retry).
41
+ */
42
+ export declare function runHlSetAbstractionFlow(opts: HlSetAbstractionFlowOpts): Promise<HlSetAbstractionFlowResult>;
43
+ export {};
@@ -1703,6 +1703,96 @@ async function revokeHlAgent(masterSigner) {
1703
1703
  });
1704
1704
  // Revoke is best-effort — don't throw on HTTP errors
1705
1705
  }
1706
+ // ── HL userSetAbstraction (account-mode write) helper ────────────────────────
1707
+ /** EIP-712 types for HyperliquidTransaction:UserSetAbstraction */
1708
+ const HL_USER_SET_ABSTRACTION_TYPES = {
1709
+ "HyperliquidTransaction:UserSetAbstraction": [
1710
+ { name: "hyperliquidChain", type: "string" },
1711
+ { name: "abstraction", type: "string" },
1712
+ { name: "nonce", type: "uint64" },
1713
+ ],
1714
+ };
1715
+ /** Public abstraction modes (CLI surface) → HL on-wire string. */
1716
+ const HL_ABSTRACTION_TO_WIRE = {
1717
+ unified: "unifiedAccount",
1718
+ standard: "disabled",
1719
+ portfolio: "portfolioMargin",
1720
+ };
1721
+ /**
1722
+ * Send a Hyperliquid `userSetAbstraction` action.
1723
+ *
1724
+ * Reuses the same EIP-712 user-signed domain (`HyperliquidSignTransaction`,
1725
+ * chainId 42161) and `OwsEvmSigner.signTypedData` plumbing as
1726
+ * `runHlApproveFlow`; the only differences are the type struct
1727
+ * (`UserSetAbstraction` vs `ApproveAgent`) and the action payload.
1728
+ *
1729
+ * Throws `PerpError` with structured remediation on signature/network/venue
1730
+ * failure (Rule #2: no fallback, no silent retry).
1731
+ */
1732
+ export async function runHlSetAbstractionFlow(opts) {
1733
+ const { masterName, passphrase, mode, isTestnet } = opts;
1734
+ const { ethers } = await import("ethers");
1735
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1736
+ const userEvmAddress = masterSigner.getAddress();
1737
+ const hyperliquidChain = (isTestnet ? "Testnet" : "Mainnet");
1738
+ const sigChainIdHex = "0xa4b1"; // 42161 hex
1739
+ const abstraction = HL_ABSTRACTION_TO_WIRE[mode];
1740
+ const nonce = Date.now();
1741
+ const action = {
1742
+ type: "userSetAbstraction",
1743
+ hyperliquidChain,
1744
+ signatureChainId: sigChainIdHex,
1745
+ user: userEvmAddress,
1746
+ abstraction,
1747
+ nonce,
1748
+ };
1749
+ const message = {
1750
+ hyperliquidChain,
1751
+ abstraction,
1752
+ nonce,
1753
+ };
1754
+ let sigRaw;
1755
+ try {
1756
+ sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_USER_SET_ABSTRACTION_TYPES, message);
1757
+ }
1758
+ catch (sigErr) {
1759
+ throw new PerpError("SIGNATURE_FAILED", `HL userSetAbstraction signing failed: ${sigErr instanceof Error ? sigErr.message : String(sigErr)}`, {
1760
+ remediation: "Confirm OWS master is unlocked and passphrase is correct.",
1761
+ });
1762
+ }
1763
+ const parsed = ethers.Signature.from(sigRaw);
1764
+ const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1765
+ const baseUrl = isTestnet ? "https://api.hyperliquid-testnet.xyz" : "https://api.hyperliquid.xyz";
1766
+ const payload = { action, nonce, signature, vaultAddress: null };
1767
+ const res = await fetch(`${baseUrl}/exchange`, {
1768
+ method: "POST",
1769
+ headers: { "Content-Type": "application/json" },
1770
+ body: JSON.stringify(payload),
1771
+ });
1772
+ const text = await res.text();
1773
+ let resp;
1774
+ try {
1775
+ resp = JSON.parse(text);
1776
+ }
1777
+ catch {
1778
+ throw new PerpError("EXCHANGE_ERROR", `HL exchange API parse error (${res.status}): ${text.slice(0, 200)}`, {
1779
+ remediation: "Check Hyperliquid API status. Retry: perp wallet manage account-mode <mode>",
1780
+ });
1781
+ }
1782
+ if (resp?.status === "err") {
1783
+ throw new PerpError("EXCHANGE_ERROR", `HL userSetAbstraction failed: ${typeof resp.response === "string" ? resp.response : JSON.stringify(resp)}`, {
1784
+ remediation: "Verify master EVM wallet is the account owner. Retry: perp wallet manage account-mode <mode>",
1785
+ });
1786
+ }
1787
+ return {
1788
+ mode,
1789
+ abstraction,
1790
+ hyperliquidChain,
1791
+ nonce,
1792
+ userEvmAddress,
1793
+ response: resp,
1794
+ };
1795
+ }
1706
1796
  /**
1707
1797
  * Core Pacifica approve flow.
1708
1798
  * Caller MUST hold the "pacifica" lock before calling this function.
@@ -1,8 +1,13 @@
1
1
  import { printJson, jsonOk, jsonError } from "../utils.js";
2
2
  import chalk from "chalk";
3
3
  import { hasPacificaSdk } from "../exchanges/capabilities.js";
4
+ import { PerpError } from "../errors.js";
5
+ import { loadSettings } from "../settings.js";
6
+ import { resolvePassphrase } from "../agent-wallet/passphrase.js";
7
+ import { runHlSetAbstractionFlow } from "./agent.js";
8
+ import { HyperliquidAdapter } from "../exchanges/hyperliquid.js";
4
9
  export function registerWalletManageCommands(parent, getAdapter, isJson, getPacificaAdapter) {
5
- const manage = parent.command("manage").description("Exchange account settings (margin, subaccount, API keys, builder) — Pacifica + Lighter");
10
+ const manage = parent.command("manage").description("Exchange account settings (margin, subaccount, API keys, builder, account-mode) — Pacifica + Lighter + Hyperliquid");
6
11
  // Ensure adapter is initialized before accessing PacificaAdapter
7
12
  async function pac() {
8
13
  const adapter = await getAdapter();
@@ -236,4 +241,154 @@ export function registerWalletManageCommands(parent, getAdapter, isJson, getPaci
236
241
  // 3-tier signer routing, expiry tracking, free-slot picking, and AgentMeta
237
242
  // persistence. The auto-setup path in LighterAdapter.init() remains as the
238
243
  // env-key fallback for users who haven't run the unified command yet.
244
+ // ── Hyperliquid account abstraction mode ────────────────────────────────
245
+ // Read side: HyperliquidAdapter._getAbstractionMode() (added v0.12.7).
246
+ // Write side: HL `userSetAbstraction` action — master EVM EIP-712 signature.
247
+ // Reuses the same `OwsEvmSigner.signTypedData` path as `wallet agent approve
248
+ // hyperliquid` (see runHlApproveFlow). No agent signer involved: this is a
249
+ // master-only action that toggles the user's account-level mode.
250
+ manage
251
+ .command("account-mode [mode]")
252
+ .description("Hyperliquid account abstraction mode. Modes: unified | standard | portfolio. No arg = show current.")
253
+ .option("--master <name>", "Master OWS wallet name (defaults to settings.owsActiveWallet)")
254
+ .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
255
+ .option("--json", "Machine-readable output")
256
+ .action(async (mode, opts, command) => {
257
+ const useJson = opts.json ?? isJson();
258
+ // C5 fix: parent --passphrase shadows subcommand flag in Commander v13.
259
+ // Merge parent + subcommand opts so --passphrase works on either side.
260
+ const mergedOpts = command.optsWithGlobals();
261
+ const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
262
+ const network = mergedOpts.network ?? "mainnet";
263
+ const isTestnet = network === "testnet";
264
+ // ── Show branch (no positional arg) ─────────────────────────────────
265
+ if (!mode) {
266
+ try {
267
+ const adapter = new HyperliquidAdapter(undefined, isTestnet);
268
+ // Resolve the address. _getAbstractionMode requires an address; if
269
+ // the user hasn't configured one, surface a clear error instead of
270
+ // an opaque "address not set" exception.
271
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
272
+ if (!masterName) {
273
+ throw new PerpError("INVALID_PARAMS", "Master wallet name required to read account-mode. Use --master <name> or set owsActiveWallet.", {
274
+ remediation: "Run: perp wallet generate <name> or perp wallet use <name>, then retry.",
275
+ });
276
+ }
277
+ // Resolve the EVM address from the OWS wallet without unlocking it
278
+ // (read-only path needs no signature).
279
+ const { loadOws } = await import("../signer/ows-loader.js");
280
+ const ows = loadOws();
281
+ const wallet = ows.getWallet(masterName);
282
+ const evmAccount = wallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
283
+ if (!evmAccount) {
284
+ throw new PerpError("INVALID_PARAMS", `OWS wallet "${masterName}" has no EVM account.`, {
285
+ remediation: "Generate an EVM-capable wallet: perp wallet generate <name>",
286
+ });
287
+ }
288
+ adapter.setAddress(evmAccount.address);
289
+ const current = await adapter._getAbstractionMode();
290
+ if (useJson) {
291
+ return printJson(jsonOk({ mode: current, master: masterName, address: evmAccount.address, network: isTestnet ? "testnet" : "mainnet" }));
292
+ }
293
+ console.log(chalk.cyan.bold("\n Hyperliquid Account Mode\n"));
294
+ console.log(` Master: ${chalk.white(masterName)}`);
295
+ console.log(` Address: ${chalk.gray(evmAccount.address)}`);
296
+ console.log(` Network: ${chalk.gray(isTestnet ? "testnet" : "mainnet")}`);
297
+ console.log(` Mode: ${chalk.green(current)}\n`);
298
+ return;
299
+ }
300
+ catch (err) {
301
+ if (useJson) {
302
+ const e = err instanceof PerpError
303
+ ? err.structured
304
+ : { code: "UNKNOWN", message: err instanceof Error ? err.message : String(err) };
305
+ console.error(JSON.stringify(jsonError(e.code, e.message)));
306
+ process.exit(1);
307
+ }
308
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
309
+ process.exit(1);
310
+ }
311
+ }
312
+ // ── Set branch ─────────────────────────────────────────────────────
313
+ const modeLower = mode.toLowerCase();
314
+ if (modeLower !== "unified" && modeLower !== "standard" && modeLower !== "portfolio") {
315
+ const e = new PerpError("INVALID_PARAMS", `Mode must be one of: unified, standard, portfolio (got "${mode}")`, {
316
+ remediation: "Example: perp wallet manage account-mode standard --master main",
317
+ });
318
+ if (useJson) {
319
+ console.error(JSON.stringify(jsonError(e.structured.code, e.structured.message)));
320
+ process.exit(1);
321
+ }
322
+ console.error(chalk.red(e.message));
323
+ process.exit(1);
324
+ }
325
+ const newMode = modeLower;
326
+ try {
327
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
328
+ if (!masterName) {
329
+ throw new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
330
+ remediation: "Run: perp wallet generate <name> or perp wallet use <name>, then retry.",
331
+ });
332
+ }
333
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
334
+ if (passphrase === null) {
335
+ throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
336
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
337
+ });
338
+ }
339
+ // Capture previous mode (best-effort — info-only, do NOT block on read).
340
+ let prevMode = null;
341
+ try {
342
+ const readAdapter = new HyperliquidAdapter(undefined, isTestnet);
343
+ const { loadOws } = await import("../signer/ows-loader.js");
344
+ const ows = loadOws();
345
+ const wallet = ows.getWallet(masterName);
346
+ const evmAccount = wallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
347
+ if (evmAccount) {
348
+ readAdapter.setAddress(evmAccount.address);
349
+ prevMode = await readAdapter._getAbstractionMode();
350
+ }
351
+ }
352
+ catch {
353
+ // Best-effort previous-mode capture. SSOT Rule #2 forbids fallback
354
+ // for the primary action, but reading the prior mode is purely
355
+ // informational metadata — its absence does NOT mask the write.
356
+ prevMode = null;
357
+ }
358
+ const result = await runHlSetAbstractionFlow({
359
+ masterName,
360
+ passphrase: passphrase ?? "",
361
+ mode: newMode,
362
+ isTestnet,
363
+ });
364
+ if (useJson) {
365
+ return printJson(jsonOk({
366
+ mode: result.mode,
367
+ prevMode,
368
+ abstraction: result.abstraction,
369
+ hyperliquidChain: result.hyperliquidChain,
370
+ nonce: result.nonce,
371
+ user: result.userEvmAddress,
372
+ response: result.response,
373
+ }));
374
+ }
375
+ console.log(chalk.green.bold("\n Hyperliquid account mode updated.\n"));
376
+ console.log(` User: ${chalk.gray(result.userEvmAddress)}`);
377
+ console.log(` Network: ${chalk.gray(result.hyperliquidChain)}`);
378
+ if (prevMode)
379
+ console.log(` Previous: ${chalk.gray(prevMode)}`);
380
+ console.log(` New mode: ${chalk.cyan(result.mode)} ${chalk.gray(`(${result.abstraction})`)}\n`);
381
+ }
382
+ catch (err) {
383
+ if (useJson) {
384
+ const e = err instanceof PerpError
385
+ ? err.structured
386
+ : { code: "UNKNOWN", message: err instanceof Error ? err.message : String(err) };
387
+ console.error(JSON.stringify(jsonError(e.code, e.message)));
388
+ process.exit(1);
389
+ }
390
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
391
+ process.exit(1);
392
+ }
393
+ });
239
394
  }
@@ -33,6 +33,12 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
33
33
  get isTestnet(): boolean;
34
34
  /** Inject an external EVM signer. Call before init() to skip LocalEvmSigner creation. */
35
35
  setSigner(signer: EvmSigner): void;
36
+ /**
37
+ * Set the user EVM address without attaching a signer. Used by read-only
38
+ * paths (e.g. `wallet manage account-mode` show branch) that need to query
39
+ * `/info` endpoints scoped to a master wallet but never sign.
40
+ */
41
+ setAddress(address: string): void;
36
42
  /** Tier 1: inject agent OWS wallet signer. */
37
43
  setAgentSigner(meta: AgentMeta, signer: EvmSigner): void;
38
44
  /** Bypass Tier 1 (agent) — use for --no-agent flag. */
@@ -64,6 +70,25 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
64
70
  private ensureAddress;
65
71
  /** Read-through cached clearinghouseState — returns cached data if fresh */
66
72
  private _getClearinghouseState;
73
+ /**
74
+ * Read the user's account abstraction mode from HL.
75
+ *
76
+ * Returns the canonical mode used to branch collateral accounting:
77
+ * - "unified" → spot clearinghouseState is the truth source (USDC total
78
+ * is unified across spot + perp). app.hyperliquid.xyz default.
79
+ * - "standard" → perp clearinghouseState is the truth source. Required
80
+ * mode for builder fee accrual.
81
+ * - "portfolio" → multi-asset cross-collateral; for now treated like
82
+ * unified (spot truth). Refine when portfolio margin GA.
83
+ *
84
+ * Per SSOT Rule #2: an unrecognized venue response throws — no silent
85
+ * default. HIP-3 dex accounts (`this._dex` set) bypass the lookup since
86
+ * cross margin scopes per-DEX in standard semantics.
87
+ *
88
+ * Endpoint: `POST /info {"type":"userAbstraction","user":"0x..."}` →
89
+ * returns one of `"unifiedAccount" | "disabled" | "portfolioMargin"`.
90
+ */
91
+ _getAbstractionMode(): Promise<"unified" | "standard" | "portfolio">;
67
92
  /** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
68
93
  _getSpotClearinghouseState(): Promise<Record<string, unknown>>;
69
94
  getBalance(): Promise<ExchangeBalance>;
@@ -61,6 +61,14 @@ export class HyperliquidAdapter {
61
61
  this._evmSigner = signer;
62
62
  this._address = signer.getAddress();
63
63
  }
64
+ /**
65
+ * Set the user EVM address without attaching a signer. Used by read-only
66
+ * paths (e.g. `wallet manage account-mode` show branch) that need to query
67
+ * `/info` endpoints scoped to a master wallet but never sign.
68
+ */
69
+ setAddress(address) {
70
+ this._address = address;
71
+ }
64
72
  /** Tier 1: inject agent OWS wallet signer. */
65
73
  setAgentSigner(meta, signer) {
66
74
  this._agentMeta = meta;
@@ -344,6 +352,40 @@ export class HyperliquidAdapter {
344
352
  return state;
345
353
  });
346
354
  }
355
+ /**
356
+ * Read the user's account abstraction mode from HL.
357
+ *
358
+ * Returns the canonical mode used to branch collateral accounting:
359
+ * - "unified" → spot clearinghouseState is the truth source (USDC total
360
+ * is unified across spot + perp). app.hyperliquid.xyz default.
361
+ * - "standard" → perp clearinghouseState is the truth source. Required
362
+ * mode for builder fee accrual.
363
+ * - "portfolio" → multi-asset cross-collateral; for now treated like
364
+ * unified (spot truth). Refine when portfolio margin GA.
365
+ *
366
+ * Per SSOT Rule #2: an unrecognized venue response throws — no silent
367
+ * default. HIP-3 dex accounts (`this._dex` set) bypass the lookup since
368
+ * cross margin scopes per-DEX in standard semantics.
369
+ *
370
+ * Endpoint: `POST /info {"type":"userAbstraction","user":"0x..."}` →
371
+ * returns one of `"unifiedAccount" | "disabled" | "portfolioMargin"`.
372
+ */
373
+ async _getAbstractionMode() {
374
+ if (this._dex)
375
+ return "standard";
376
+ this.ensureAddress();
377
+ const { withCache, TTL_ACCOUNT } = await import("../cache.js");
378
+ return withCache(`acct:hl:abstraction:${this._address}`, TTL_ACCOUNT, async () => {
379
+ const raw = (await this._infoPost({ type: "userAbstraction", user: this._address }));
380
+ if (raw === "unifiedAccount")
381
+ return "unified";
382
+ if (raw === "portfolioMargin")
383
+ return "portfolio";
384
+ if (raw === "disabled")
385
+ return "standard";
386
+ throw new PerpError("INVALID_PARAMS", `UNKNOWN_ACCOUNT_MODE: HL returned unrecognized userAbstraction mode: ${JSON.stringify(raw)}`, { remediation: "Check app.hyperliquid.xyz Settings → Account Mode" });
387
+ });
388
+ }
347
389
  /** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
348
390
  async _getSpotClearinghouseState() {
349
391
  this.ensureAddress();
@@ -367,26 +409,24 @@ export class HyperliquidAdapter {
367
409
  }
368
410
  let equity;
369
411
  let available;
370
- if (!this._dex) {
371
- // Unified account: spot USDC total IS the true equity (includes perp margin as "hold").
372
- // perp accountValue is a subset — adding both double-counts.
373
- try {
374
- const spotState = await this._getSpotClearinghouseState();
375
- const balances = (spotState?.balances ?? []);
376
- const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
377
- const spotTotal = usdc?.total !== undefined ? Number(usdc.total) : NaN;
378
- const spotHold = Number(usdc?.hold ?? 0);
379
- equity = !isNaN(spotTotal) ? spotTotal : Number(margin.accountValue ?? cross.accountValue ?? 0);
380
- available = !isNaN(spotTotal) ? spotTotal - spotHold : Number(s?.withdrawable ?? 0);
381
- }
382
- catch {
383
- // Spot API failed — fall back to perp-only values
384
- equity = Number(margin.accountValue ?? cross.accountValue ?? 0);
385
- available = Number(s?.withdrawable ?? 0);
386
- }
412
+ // Branch by user's account abstraction mode (Rule #2: no silent fallback).
413
+ // - "unified" / "portfolio": spot USDC total = true equity; perp margin
414
+ // is held against the same USDC pool, so adding both double-counts.
415
+ // - "standard": separate perp/spot accounting; perp clearinghouse is the
416
+ // only collateral source. Required mode for builder fee accrual.
417
+ // HIP-3 dex accounts (this._dex) always use standard semantics.
418
+ const mode = await this._getAbstractionMode();
419
+ if (mode === "unified" || mode === "portfolio") {
420
+ const spotState = await this._getSpotClearinghouseState();
421
+ const balances = (spotState?.balances ?? []);
422
+ const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
423
+ const spotTotal = Number(usdc?.total ?? 0);
424
+ const spotHold = Number(usdc?.hold ?? 0);
425
+ equity = spotTotal;
426
+ available = spotTotal - spotHold;
387
427
  }
388
428
  else {
389
- // Dex account: perp clearinghouse is the only source
429
+ // mode === "standard"
390
430
  equity = Number(margin.accountValue ?? cross.accountValue ?? 0);
391
431
  available = Number(s?.withdrawable ?? 0);
392
432
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -5,7 +5,7 @@ allowed-tools: "Bash(perp:*), Bash(npx perp-cli:*), Bash(npx -y perp-cli:*)"
5
5
  license: MIT
6
6
  metadata:
7
7
  author: hypurrquant
8
- version: "0.12.6"
8
+ version: "0.12.8"
9
9
  ---
10
10
 
11
11
  # perp-cli Agent Guide
@@ -41,7 +41,7 @@ Multi-DEX perpetual futures CLI + MCP server — Pacifica (Solana), Hyperliquid
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- perp --version 2>/dev/null # check if exists (must be >= 0.12.6)
44
+ perp --version 2>/dev/null # check if exists (must be >= 0.12.8)
45
45
  npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
46
46
  ```
47
47