perp-cli 0.12.5 → 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.
@@ -70,15 +70,17 @@ function askChoice(rl, question, choices) {
70
70
  });
71
71
  }
72
72
  // ── Exchange → env var mapping ────────────────────────────────
73
- // Aster removed in v0.12.4: legacy HMAC API-key signing path was retired in
74
- // Plan v3.0. New users must onboard via `perp wallet agent approve aster`
75
- // (off-chain agent wallet, same flow as HL/PAC/LT). The setup wizard
76
- // surfaces a redirect when Aster is selected; `wallet set aster <key>`
77
- // throws INVALID_PARAMS pointing at the agent flow.
73
+ // v0.12.4 retired the legacy HMAC ASTER_API_KEY/SECRET path (Plan v3.0).
74
+ // v0.12.6 restores Aster as a normal EVM exchange: `ASTER_PRIVATE_KEY`
75
+ // (Tier 3 PK direct path) is collected like HL/LT, and the agent flow
76
+ // (`wallet agent approve aster`) is the recommended Tier 1 onboarding.
77
+ // Removing Aster entirely was over-correction — the entry must exist so
78
+ // `wallet import` / `wallet set` / setup wizard treat all 4 DEX uniformly.
78
79
  export const EXCHANGE_ENV_MAP = {
79
80
  pacifica: { envKey: "PACIFICA_PRIVATE_KEY", chain: "solana", label: "Pacifica (Solana)" },
80
81
  hyperliquid: { envKey: "HL_PRIVATE_KEY", chain: "evm", label: "Hyperliquid (EVM)" },
81
82
  lighter: { envKey: "LIGHTER_PRIVATE_KEY", chain: "evm", label: "Lighter (EVM)" },
83
+ aster: { envKey: "ASTER_PRIVATE_KEY", chain: "evm", label: "Aster (BNB Chain)" },
82
84
  };
83
85
  // ── Validate keys ─────────────────────────────────────────────
84
86
  export async function validateKey(chain, key) {
@@ -179,25 +181,9 @@ export function registerInitCommand(program) {
179
181
  console.log(` 4) ${chalk.cyan("Aster")} ${chalk.gray("— BNB Chain perps (use 'wallet agent approve aster')")}`);
180
182
  console.log();
181
183
  const exchangeChoice = await ask(rl, " Which exchange(s)? (1,2,3,4 or 'all'): ");
182
- const selectedRaw = parseExchangeChoice(exchangeChoice);
183
- // v0.12.4: legacy Aster HMAC API-key path is removed. The wizard can
184
- // no longer collect ASTER_API_KEY/ASTER_API_SECRET — Aster onboarding
185
- // is now exclusively via `perp wallet agent approve aster --master`.
186
- // Print a redirect and drop Aster from the env-write set.
187
- const asterRequested = selectedRaw.includes("aster");
188
- const selected = selectedRaw.filter((e) => e !== "aster");
189
- if (asterRequested) {
190
- console.log(chalk.yellow.bold("\n Aster onboarding is via agent wallet (no API keys):"));
191
- console.log(` ${chalk.green("perp wallet agent approve aster --master <name>")}`);
192
- console.log(chalk.gray(" Run that after this wizard completes.\n"));
193
- }
184
+ const selected = parseExchangeChoice(exchangeChoice);
194
185
  if (selected.length === 0) {
195
- if (asterRequested) {
196
- console.log(chalk.gray(" No additional exchanges to configure here. See the Aster instructions above.\n"));
197
- }
198
- else {
199
- console.log(chalk.red("\n No exchanges selected.\n"));
200
- }
186
+ console.log(chalk.red("\n No exchanges selected.\n"));
201
187
  rl.close();
202
188
  return;
203
189
  }
@@ -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
  }
@@ -613,29 +613,14 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
613
613
  // Resolve alias (hl → hyperliquid, pac → pacifica, lt → lighter, ast → aster)
614
614
  const aliases = { hl: "hyperliquid", pac: "pacifica", lt: "lighter", ast: "aster" };
615
615
  const resolved = aliases[exchange.toLowerCase()] || exchange.toLowerCase();
616
- // v0.12.4: Aster legacy HMAC API-key path is removed entirely. Plan v3.0
617
- // de-wired the runtime/signing surface but left the wallet set entry
618
- // accepting keys that no longer drive any signing path. Fail loudly
619
- // (SSOT Rule #2) and point at the agent flow.
620
- if (resolved === "aster") {
621
- const msg = "'wallet set aster' is removed in v0.13. Use 'wallet agent approve aster' instead.";
622
- const remediation = "perp wallet agent approve aster --master <wallet>";
623
- if (isJson()) {
624
- const { jsonError } = await import("../utils.js");
625
- return printJson(jsonError("INVALID_PARAMS", msg, { details: { remediation } }));
626
- }
627
- console.error(chalk.red(`\n ${msg}`));
628
- console.error(chalk.gray(` Run: ${remediation}\n`));
629
- process.exit(1);
630
- }
631
616
  const info = EXCHANGE_ENV_MAP[resolved];
632
617
  if (!info) {
633
618
  if (isJson()) {
634
619
  const { jsonError } = await import("../utils.js");
635
- return printJson(jsonError("INVALID_PARAMS", `Unknown exchange: ${exchange}. Use: pacifica, hyperliquid, lighter (or hl, pac, lt). For Aster: 'perp wallet agent approve aster'.`));
620
+ return printJson(jsonError("INVALID_PARAMS", `Unknown exchange: ${exchange}. Use: pacifica, hyperliquid, lighter, aster (or hl, pac, lt, ast).`));
636
621
  }
637
622
  console.error(chalk.red(`\n Unknown exchange: ${exchange}`));
638
- console.error(chalk.gray(` Use: pacifica, hyperliquid, lighter (or hl, pac, lt). For Aster: 'perp wallet agent approve aster'.\n`));
623
+ console.error(chalk.gray(` Use: pacifica, hyperliquid, lighter, aster (or hl, pac, lt, ast).\n`));
639
624
  process.exit(1);
640
625
  }
641
626
  const { valid, address } = await validateKey(info.chain, key);
@@ -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.5",
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.5"
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.5)
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