perp-cli 0.12.10 → 0.12.11

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.
@@ -239,8 +239,7 @@ export function registerWalletManageCommands(parent, getAdapter, isJson, getPaci
239
239
  // Legacy `manage setup-api-key` removed in v0.12 (Phase 2d) — superseded by
240
240
  // the unified `wallet agent approve lighter` flow. The agent flow provides
241
241
  // 3-tier signer routing, expiry tracking, free-slot picking, and AgentMeta
242
- // persistence. The auto-setup path in LighterAdapter.init() remains as the
243
- // env-key fallback for users who haven't run the unified command yet.
242
+ // persistence.
244
243
  // ── Hyperliquid account abstraction mode ────────────────────────────────
245
244
  // Read side: HyperliquidAdapter._getAbstractionMode() (added v0.12.7).
246
245
  // Write side: HL `userSetAbstraction` action — master EVM EIP-712 signature.
@@ -1270,12 +1270,19 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
1270
1270
  .option("--policy <ids>", "Comma-separated policy IDs to enforce")
1271
1271
  .option("--expires <iso>", "Key expiry (ISO-8601)")
1272
1272
  .option("-p, --passphrase <pass>", "Vault passphrase", "")
1273
- .action(async (opts) => {
1273
+ .action(async (opts, command) => {
1274
1274
  try {
1275
1275
  const o = loadOws();
1276
1276
  const walletIds = opts.wallets.split(",").map(s => s.trim());
1277
1277
  const policyIds = opts.policy ? opts.policy.split(",").map(s => s.trim()) : [];
1278
- const result = o.createApiKey(opts.name, walletIds, policyIds, opts.passphrase, opts.expires);
1278
+ // optsWithGlobals() merges parent + subcommand options, so --passphrase
1279
+ // works whether placed on `perp --passphrase X wallet key create` (parent)
1280
+ // or `perp wallet key create --passphrase X` (subcommand). Without this
1281
+ // merge, Commander v13 silently shadows the subcommand flag with the
1282
+ // parent flag and opts.passphrase becomes the empty-string default.
1283
+ const merged = command.optsWithGlobals();
1284
+ const passphrase = merged.passphrase ?? opts.passphrase;
1285
+ const result = o.createApiKey(opts.name, walletIds, policyIds, passphrase, opts.expires);
1279
1286
  if (isJson())
1280
1287
  return printJson(jsonOk({ id: result.id, name: result.name, token: result.token, wallets: walletIds, policies: policyIds, expires: opts.expires ?? null }));
1281
1288
  console.log(chalk.green.bold("\n OWS API Key Created\n"));
@@ -25,6 +25,15 @@ export declare const ASTER_DOMAIN_B: {
25
25
  readonly version: "1";
26
26
  readonly verifyingContract: "0x0000000000000000000000000000000000000000";
27
27
  };
28
+ /** Domain B (testnet): chainId=714 per Aster V3 testnet docs. */
29
+ export declare const ASTER_DOMAIN_B_TESTNET: {
30
+ readonly chainId: 714;
31
+ readonly name: "AsterSignTransaction";
32
+ readonly version: "1";
33
+ readonly verifyingContract: "0x0000000000000000000000000000000000000000";
34
+ };
35
+ /** Returns the Domain B object for the requested network (testnet branches chainId). */
36
+ export declare function getAsterDomainB(testnet: boolean): typeof ASTER_DOMAIN_B | typeof ASTER_DOMAIN_B_TESTNET;
28
37
  /** EIP-712 types for Domain B order signing — single msg string (URL-encoded query string). */
29
38
  export declare const ORDER_TYPES: {
30
39
  readonly Message: readonly [{
@@ -113,11 +122,15 @@ export declare function buildOrderQueryStringMsg(orderParams: Record<string, str
113
122
  /**
114
123
  * Build the EIP-712 typed-data payload for agent-signed order requests.
115
124
  *
116
- * Domain B (chainId=1666), primaryType="Message", single `msg` field containing
117
- * the URL-encoded query string produced by buildOrderQueryStringMsg.
125
+ * Domain B (chainId=1666 mainnet / 714 testnet), primaryType="Message",
126
+ * single `msg` field containing the URL-encoded query string produced by
127
+ * buildOrderQueryStringMsg.
128
+ *
129
+ * @param testnet When true, returns Domain B with chainId=714 per Aster V3
130
+ * testnet docs. Defaults to false (mainnet).
118
131
  */
119
- export declare function buildOrderTypedData(orderParams: Record<string, string | number | boolean>): {
120
- domain: typeof ASTER_DOMAIN_B;
132
+ export declare function buildOrderTypedData(orderParams: Record<string, string | number | boolean>, testnet?: boolean): {
133
+ domain: typeof ASTER_DOMAIN_B | typeof ASTER_DOMAIN_B_TESTNET;
121
134
  types: typeof ORDER_TYPES;
122
135
  primaryType: "Message";
123
136
  message: {
@@ -24,6 +24,15 @@ export const ASTER_DOMAIN_B = {
24
24
  ...ASTER_DOMAIN_A,
25
25
  chainId: 1666,
26
26
  };
27
+ /** Domain B (testnet): chainId=714 per Aster V3 testnet docs. */
28
+ export const ASTER_DOMAIN_B_TESTNET = {
29
+ ...ASTER_DOMAIN_A,
30
+ chainId: 714,
31
+ };
32
+ /** Returns the Domain B object for the requested network (testnet branches chainId). */
33
+ export function getAsterDomainB(testnet) {
34
+ return testnet ? ASTER_DOMAIN_B_TESTNET : ASTER_DOMAIN_B;
35
+ }
27
36
  /** EIP-712 types for Domain B order signing — single msg string (URL-encoded query string). */
28
37
  export const ORDER_TYPES = {
29
38
  Message: [{ name: "msg", type: "string" }],
@@ -136,13 +145,17 @@ export function buildOrderQueryStringMsg(orderParams) {
136
145
  /**
137
146
  * Build the EIP-712 typed-data payload for agent-signed order requests.
138
147
  *
139
- * Domain B (chainId=1666), primaryType="Message", single `msg` field containing
140
- * the URL-encoded query string produced by buildOrderQueryStringMsg.
148
+ * Domain B (chainId=1666 mainnet / 714 testnet), primaryType="Message",
149
+ * single `msg` field containing the URL-encoded query string produced by
150
+ * buildOrderQueryStringMsg.
151
+ *
152
+ * @param testnet When true, returns Domain B with chainId=714 per Aster V3
153
+ * testnet docs. Defaults to false (mainnet).
141
154
  */
142
- export function buildOrderTypedData(orderParams) {
155
+ export function buildOrderTypedData(orderParams, testnet = false) {
143
156
  const msg = buildOrderQueryStringMsg(orderParams);
144
157
  return {
145
- domain: ASTER_DOMAIN_B,
158
+ domain: getAsterDomainB(testnet),
146
159
  types: ORDER_TYPES,
147
160
  primaryType: "Message",
148
161
  message: { msg },
@@ -15,6 +15,14 @@ import type { ExchangeAdapter, ExchangeMarketInfo, ExchangePosition, ExchangeOrd
15
15
  import type { EvmSigner } from "../signer/interface.js";
16
16
  import type { AgentMeta } from "../settings.js";
17
17
  import type { AgentSigningStrategy } from "../agent-wallet/signing-strategy.js";
18
+ type ResolvedSigner = {
19
+ tier: "agent" | "master" | "pk";
20
+ signer: EvmSigner | AgentSigningStrategy;
21
+ /** EVM address that will appear in the `signer` field of Aster requests */
22
+ signerAddress: string;
23
+ /** EVM address of the owner/master — used as `user` field in Aster requests */
24
+ userAddress: string;
25
+ };
18
26
  export declare class AsterAdapter implements ExchangeAdapter {
19
27
  readonly name = "aster";
20
28
  readonly chain = "bnb";
@@ -88,7 +96,13 @@ export declare class AsterAdapter implements ExchangeAdapter {
88
96
  assetId?: number;
89
97
  routeType?: number;
90
98
  }): Promise<unknown>;
91
- private _resolveSigner;
99
+ /**
100
+ * Resolve the active signer per the three-tier policy.
101
+ *
102
+ * Exposed (leading underscore) for unit-test access only — do not call
103
+ * from production code outside this class.
104
+ */
105
+ _resolveSigner(): ResolvedSigner;
92
106
  private _nextNonce;
93
107
  /**
94
108
  * Build the canonical signed-request URL per Aster v3 spec.
@@ -102,24 +116,48 @@ export declare class AsterAdapter implements ExchangeAdapter {
102
116
  * signed dict and the URL query string must be byte-identical except for
103
117
  * the appended signature.
104
118
  *
119
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields,
120
+ * even when the values are identical (Tier 2/3 self-signing case where
121
+ * master signs for itself). Aster's authority server checks both fields.
122
+ *
105
123
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
106
124
  * scheme; including it breaks signature verification on every call.
125
+ *
126
+ * Exposed (with leading underscore) for unit-test access only — do not call
127
+ * from production code outside this class.
107
128
  */
108
- private _buildSignedQueryString;
129
+ _buildSignedQueryString(params: Record<string, string | number | boolean>, resolved: ResolvedSigner): Promise<string>;
109
130
  /**
110
131
  * Sign and POST to a path using EIP-712 Domain B.
111
132
  */
112
133
  private _signedPostEip712;
113
134
  /**
114
135
  * Sign and GET from a path using EIP-712 Domain B.
136
+ *
137
+ * Unlike public GETs, signed GETs route through _handleAsterResponse so
138
+ * venue JSON error envelopes ({code, msg}) are not silently treated as
139
+ * success (SSOT Rule #2).
115
140
  */
116
141
  private _signedGetEip712;
117
142
  /**
118
143
  * Sign and DELETE from a path using EIP-712 Domain B.
119
144
  */
120
145
  private _signedDeleteEip712;
121
- private _handleEip712Response;
122
- /** Handle 429 rate limit with retry — used by GET/DELETE multi-attempt loops */
146
+ /**
147
+ * Unified response validator for ALL signed Aster requests.
148
+ *
149
+ * Validates BOTH HTTP status AND the venue JSON error envelope. Aster's
150
+ * API commonly returns HTTP 200 + `{code, msg: "Signature check failed"}`
151
+ * for signing or auth failures — checking only HTTP status would silently
152
+ * accept these as success and cause callers (e.g., getBalance) to cache
153
+ * zero balances or empty arrays. SSOT Rule #2: failures throw with
154
+ * remediation; never substitute a default.
155
+ *
156
+ * Used by signed POST/GET/DELETE. Public unsigned GETs use _handleResponse.
157
+ */
158
+ private _handleAsterResponse;
159
+ /** Handle 429 rate limit with retry — used by public unsigned GET multi-attempt loops */
123
160
  private _handleResponse;
124
161
  private _publicGet;
125
162
  }
163
+ export {};
@@ -501,6 +501,12 @@ export class AsterAdapter {
501
501
  throw new Error("Aster withdrawal requires the spot API (not available in futures mode). Use the Aster web UI to withdraw.");
502
502
  }
503
503
  // ── Private: signer resolution ────────────────────────────────────────────
504
+ /**
505
+ * Resolve the active signer per the three-tier policy.
506
+ *
507
+ * Exposed (leading underscore) for unit-test access only — do not call
508
+ * from production code outside this class.
509
+ */
504
510
  _resolveSigner() {
505
511
  // Tier 1: agent (when registered, not expired, --no-agent NOT set)
506
512
  if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
@@ -522,12 +528,15 @@ export class AsterAdapter {
522
528
  };
523
529
  }
524
530
  }
525
- // Tier 2: OWS master
531
+ // Tier 2: OWS master — master self-signs, so user==signer per V3 spec.
532
+ // The query string MUST still emit `user=` AND `signer=` as distinct
533
+ // fields even when the values are identical (Aster authority verifies
534
+ // both keys); see _buildSignedQueryString.
526
535
  if (this._masterSigner) {
527
536
  const addr = this._masterSigner.getAddress();
528
537
  return { tier: "master", signer: this._masterSigner, signerAddress: addr, userAddress: addr };
529
538
  }
530
- // Tier 3: PK direct
539
+ // Tier 3: PK direct — same self-signing model as Tier 2.
531
540
  if (this._pkSigner) {
532
541
  const addr = this._pkSigner.getAddress();
533
542
  return { tier: "pk", signer: this._pkSigner, signerAddress: addr, userAddress: addr };
@@ -555,8 +564,15 @@ export class AsterAdapter {
555
564
  * signed dict and the URL query string must be byte-identical except for
556
565
  * the appended signature.
557
566
  *
567
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields,
568
+ * even when the values are identical (Tier 2/3 self-signing case where
569
+ * master signs for itself). Aster's authority server checks both fields.
570
+ *
558
571
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
559
572
  * scheme; including it breaks signature verification on every call.
573
+ *
574
+ * Exposed (with leading underscore) for unit-test access only — do not call
575
+ * from production code outside this class.
560
576
  */
561
577
  async _buildSignedQueryString(params, resolved) {
562
578
  // Insertion order (matches Python reference: nonce, user, signer last).
@@ -566,7 +582,7 @@ export class AsterAdapter {
566
582
  user: resolved.userAddress,
567
583
  signer: resolved.signerAddress,
568
584
  };
569
- const typed = buildOrderTypedData(fullParams);
585
+ const typed = buildOrderTypedData(fullParams, this._testnet);
570
586
  // Sign via the appropriate signer type
571
587
  const sigRaw = await resolved.signer.signTypedData(typed.domain, typed.types, typed.message);
572
588
  // Normalize: AgentSigningStrategy returns { signature, r, s, v }; EvmSigner returns string
@@ -586,21 +602,20 @@ export class AsterAdapter {
586
602
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
587
603
  body: "",
588
604
  });
589
- return this._handleEip712Response(res, "POST", path);
605
+ return this._handleAsterResponse(res, "POST", path);
590
606
  }
591
607
  /**
592
608
  * Sign and GET from a path using EIP-712 Domain B.
609
+ *
610
+ * Unlike public GETs, signed GETs route through _handleAsterResponse so
611
+ * venue JSON error envelopes ({code, msg}) are not silently treated as
612
+ * success (SSOT Rule #2).
593
613
  */
594
614
  async _signedGetEip712(path, params, resolved) {
595
615
  const qs = await this._buildSignedQueryString(params, resolved);
596
616
  const url = `${this._baseUrl}${path}?${qs}`;
597
- for (let attempt = 0; attempt < 3; attempt++) {
598
- const res = await fetch(url);
599
- const result = await this._handleResponse(res, "GET", path, attempt);
600
- if (result !== null)
601
- return result;
602
- }
603
- throw new Error(`GET ${path} failed: max retries exceeded`);
617
+ const res = await fetch(url);
618
+ return this._handleAsterResponse(res, "GET", path);
604
619
  }
605
620
  /**
606
621
  * Sign and DELETE from a path using EIP-712 Domain B.
@@ -608,16 +623,23 @@ export class AsterAdapter {
608
623
  async _signedDeleteEip712(path, params, resolved) {
609
624
  const qs = await this._buildSignedQueryString(params, resolved);
610
625
  const url = `${this._baseUrl}${path}?${qs}`;
611
- for (let attempt = 0; attempt < 3; attempt++) {
612
- const res = await fetch(url, { method: "DELETE" });
613
- const result = await this._handleResponse(res, "DELETE", path, attempt);
614
- if (result !== null)
615
- return result;
616
- }
617
- throw new Error(`DELETE ${path} failed: max retries exceeded`);
626
+ const res = await fetch(url, { method: "DELETE" });
627
+ return this._handleAsterResponse(res, "DELETE", path);
618
628
  }
619
629
  // ── Private: HTTP response handling ──────────────────────────────────────
620
- async _handleEip712Response(res, method, path) {
630
+ /**
631
+ * Unified response validator for ALL signed Aster requests.
632
+ *
633
+ * Validates BOTH HTTP status AND the venue JSON error envelope. Aster's
634
+ * API commonly returns HTTP 200 + `{code, msg: "Signature check failed"}`
635
+ * for signing or auth failures — checking only HTTP status would silently
636
+ * accept these as success and cause callers (e.g., getBalance) to cache
637
+ * zero balances or empty arrays. SSOT Rule #2: failures throw with
638
+ * remediation; never substitute a default.
639
+ *
640
+ * Used by signed POST/GET/DELETE. Public unsigned GETs use _handleResponse.
641
+ */
642
+ async _handleAsterResponse(res, method, path) {
621
643
  if (res.status === 429) {
622
644
  const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10);
623
645
  await new Promise(r => setTimeout(r, Math.min(retryAfter * 1000, 30000)));
@@ -628,15 +650,28 @@ export class AsterAdapter {
628
650
  const s = classifyError(new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`), "aster");
629
651
  throw new PerpError(s.code, s.message, { exchange: s.exchange });
630
652
  }
631
- const json = await res.json();
632
- if (json.code !== undefined && json.code !== "000000" && json.code !== 200) {
633
- const rawMsg = typeof json.msg === "string" ? json.msg : JSON.stringify(json);
634
- const s = classifyError(new Error(rawMsg), "aster");
635
- throw new PerpError(s.code, s.message, { exchange: s.exchange });
653
+ // Parse JSON, then validate venue error envelope. Aster success returns
654
+ // either an object whose `code` is "000000"/200 (or absent) or an array
655
+ // (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades).
656
+ const text = await res.text();
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(text);
660
+ }
661
+ catch {
662
+ throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster" });
663
+ }
664
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
665
+ const json = parsed;
666
+ if (json.code !== undefined && json.code !== "000000" && json.code !== 200 && json.code !== 0) {
667
+ const rawMsg = typeof json.msg === "string" ? json.msg : JSON.stringify(json);
668
+ const s = classifyError(new Error(rawMsg), "aster");
669
+ throw new PerpError(s.code, s.message, { exchange: s.exchange });
670
+ }
636
671
  }
637
- return json;
672
+ return parsed;
638
673
  }
639
- /** Handle 429 rate limit with retry — used by GET/DELETE multi-attempt loops */
674
+ /** Handle 429 rate limit with retry — used by public unsigned GET multi-attempt loops */
640
675
  async _handleResponse(res, method, path, attempt = 0) {
641
676
  if (res.status === 429) {
642
677
  if (attempt >= 2)
@@ -392,6 +392,12 @@ export class HyperliquidAdapter {
392
392
  // via `wallet manage account-mode unified`.
393
393
  if (raw === "default")
394
394
  return "standard";
395
+ // "dexAbstraction" is HL's legacy fourth mode, slated for deprecation
396
+ // but still returned by the venue for some users. Per HL docs the spot
397
+ // pool acts as collateral (same accounting semantics as unified), so
398
+ // route through the unified branch rather than throwing.
399
+ if (raw === "dexAbstraction")
400
+ return "unified";
395
401
  throw new PerpError("INVALID_PARAMS", `UNKNOWN_ACCOUNT_MODE: HL returned unrecognized userAbstraction mode: ${JSON.stringify(raw)}`, { remediation: "Check app.hyperliquid.xyz Settings → Account Mode" });
396
402
  });
397
403
  }
@@ -424,6 +430,12 @@ export class HyperliquidAdapter {
424
430
  // - "standard": separate perp/spot accounting; perp clearinghouse is the
425
431
  // only collateral source. Required mode for builder fee accrual.
426
432
  // HIP-3 dex accounts (this._dex) always use standard semantics.
433
+ //
434
+ // Portfolio-margin mode: per HL portfolio-margin docs, eligible collateral
435
+ // includes HYPE, BTC, USDH, USDC. We currently use USDC-only as a minimum
436
+ // viable baseline (mark-priced multi-asset accounting requires fetching
437
+ // spot mark prices and is deferred to a follow-up). Emit a stderr note so
438
+ // users see the breakdown when non-USDC collateral is present.
427
439
  const mode = await this._getAbstractionMode();
428
440
  if (mode === "unified" || mode === "portfolio") {
429
441
  const spotState = await this._getSpotClearinghouseState();
@@ -433,6 +445,22 @@ export class HyperliquidAdapter {
433
445
  const spotHold = Number(usdc?.hold ?? 0);
434
446
  equity = spotTotal;
435
447
  available = spotTotal - spotHold;
448
+ if (mode === "portfolio") {
449
+ const PORTFOLIO_COLLATERAL = ["HYPE", "BTC", "USDH"];
450
+ const nonUsdc = balances.filter((b) => {
451
+ const coin = String(b.coin);
452
+ if (coin.startsWith("USDC"))
453
+ return false;
454
+ if (Number(b.total ?? 0) <= 0)
455
+ return false;
456
+ return PORTFOLIO_COLLATERAL.some((c) => coin === c || coin.startsWith(`${c}-`));
457
+ });
458
+ if (nonUsdc.length > 0) {
459
+ const summary = nonUsdc.map((b) => `${b.coin}=${b.total}`).join(", ");
460
+ process.stderr.write(`[hl] portfolio mode: non-USDC collateral present but not counted in equity (${summary}). ` +
461
+ `Equity reflects USDC only; full multi-asset accounting pending.\n`);
462
+ }
463
+ }
436
464
  }
437
465
  else {
438
466
  // mode === "standard"
package/dist/index.js CHANGED
@@ -587,7 +587,7 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
587
587
  const settings = loadSettings();
588
588
  const hasEnvKey = !!(process.env.PRIVATE_KEY || process.env.PACIFICA_PRIVATE_KEY ||
589
589
  process.env.HL_PRIVATE_KEY || process.env.HYPERLIQUID_PRIVATE_KEY ||
590
- process.env.LIGHTER_PRIVATE_KEY);
590
+ process.env.LIGHTER_PRIVATE_KEY || process.env.ASTER_PRIVATE_KEY);
591
591
  const hasOwsWallet = !!settings.owsActiveWallet;
592
592
  // ── ASCII banner ──
593
593
  const banner = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
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.10"
8
+ version: "0.12.11"
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.10)
44
+ perp --version 2>/dev/null # check if exists (must be >= 0.12.11)
45
45
  npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
46
46
  ```
47
47