perp-cli 0.12.10 → 0.12.12

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.
@@ -271,7 +271,7 @@ export function registerAlertCommands(program, isJson) {
271
271
  // Pass through env vars for keys
272
272
  // L2 agent key + indices now live in ~/.perp keystore + settings.agents (SSOT Rule #3),
273
273
  // not env. Only Tier 3 master PKs need passthrough.
274
- const envKeys = ["LIGHTER_PRIVATE_KEY", "HL_PRIVATE_KEY", "PACIFICA_PRIVATE_KEY", "PRIVATE_KEY"];
274
+ const envKeys = ["LIGHTER_PRIVATE_KEY", "HL_PRIVATE_KEY", "PACIFICA_PRIVATE_KEY", "ASTER_PRIVATE_KEY", "PRIVATE_KEY"];
275
275
  const envStr = envKeys.filter(k => process.env[k]).map(k => `${k}='${process.env[k]}'`).join(" ");
276
276
  const cmd = `${envStr} ${nodeCmd} ${cliPath} ${args}`;
277
277
  execSync(`tmux new-session -d -s ${session} '${cmd.replace(/'/g, "'\\''")}'`);
@@ -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 },
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Aster DEX adapter — EIP-712 three-tier signer routing (v3.2).
2
+ * Aster DEX adapter — EIP-712 agent-only signer routing (v3.3).
3
3
  *
4
- * Signer priority:
5
- * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent)
6
- * Tier 2 — OWS master wallet (--ows / owsActiveWallet)
7
- * Tier 3 — PK direct (--private-key / PRIVATE_KEY / ASTER_PRIVATE_KEY)
4
+ * Signer policy:
5
+ * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent) — REQUIRED
6
+ * Tier 2 — OWS master — NOT_SUPPORTED (venue rejects master self-signing)
7
+ * Tier 3 — PK direct — NOT_SUPPORTED (venue rejects master self-signing)
8
+ *
9
+ * Aster V3 spec requires `signer` to be a registered API_WALLET (agent). The
10
+ * master wallet is never a valid signer — venue returns
11
+ * `code:-1000 msg:"Signature check failed"` when user==signer==master, even
12
+ * though the EIP-712 envelope is well-formed. Master/PK signers are still
13
+ * accepted via setMasterSigner/setPkSigner (used during approveAgent flow),
14
+ * but signed read/trade requests must use Tier 1.
8
15
  *
9
16
  * HMAC paths fully removed (see Step 3 plan — all Binance-compat HMAC code deleted).
10
17
  *
@@ -15,6 +22,14 @@ import type { ExchangeAdapter, ExchangeMarketInfo, ExchangePosition, ExchangeOrd
15
22
  import type { EvmSigner } from "../signer/interface.js";
16
23
  import type { AgentMeta } from "../settings.js";
17
24
  import type { AgentSigningStrategy } from "../agent-wallet/signing-strategy.js";
25
+ type ResolvedSigner = {
26
+ tier: "agent" | "master" | "pk";
27
+ signer: EvmSigner | AgentSigningStrategy;
28
+ /** EVM address that will appear in the `signer` field of Aster requests */
29
+ signerAddress: string;
30
+ /** EVM address of the owner/master — used as `user` field in Aster requests */
31
+ userAddress: string;
32
+ };
18
33
  export declare class AsterAdapter implements ExchangeAdapter {
19
34
  readonly name = "aster";
20
35
  readonly chain = "bnb";
@@ -88,7 +103,13 @@ export declare class AsterAdapter implements ExchangeAdapter {
88
103
  assetId?: number;
89
104
  routeType?: number;
90
105
  }): Promise<unknown>;
91
- private _resolveSigner;
106
+ /**
107
+ * Resolve the active signer per the three-tier policy.
108
+ *
109
+ * Exposed (leading underscore) for unit-test access only — do not call
110
+ * from production code outside this class.
111
+ */
112
+ _resolveSigner(): ResolvedSigner;
92
113
  private _nextNonce;
93
114
  /**
94
115
  * Build the canonical signed-request URL per Aster v3 spec.
@@ -102,24 +123,56 @@ export declare class AsterAdapter implements ExchangeAdapter {
102
123
  * signed dict and the URL query string must be byte-identical except for
103
124
  * the appended signature.
104
125
  *
126
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields.
127
+ * In production (Tier 1 only), `user` is the master and `signer` is the
128
+ * registered agent address. Aster's authority server checks both fields.
129
+ *
105
130
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
106
131
  * scheme; including it breaks signature verification on every call.
132
+ *
133
+ * Exposed (with leading underscore) for unit-test access only — do not call
134
+ * from production code outside this class.
107
135
  */
108
- private _buildSignedQueryString;
136
+ _buildSignedQueryString(params: Record<string, string | number | boolean>, resolved: ResolvedSigner): Promise<string>;
109
137
  /**
110
138
  * Sign and POST to a path using EIP-712 Domain B.
111
139
  */
112
140
  private _signedPostEip712;
113
141
  /**
114
142
  * Sign and GET from a path using EIP-712 Domain B.
143
+ *
144
+ * Unlike public GETs, signed GETs route through _handleAsterResponse so
145
+ * venue JSON error envelopes ({code, msg}) are not silently treated as
146
+ * success (SSOT Rule #2).
147
+ *
148
+ * Retries up to 3 attempts with exponential backoff (2s/4s/8s) on HTTP 429
149
+ * rate-limit. Each retry rebuilds a fresh signed query string (new nonce
150
+ * + signature) — Aster reuses recent nonces strictly, so reusing a stale
151
+ * one would itself be rejected. Non-429 venue errors throw immediately.
115
152
  */
116
153
  private _signedGetEip712;
117
154
  /**
118
155
  * Sign and DELETE from a path using EIP-712 Domain B.
156
+ * Same retry behavior as _signedGetEip712.
119
157
  */
120
158
  private _signedDeleteEip712;
121
- private _handleEip712Response;
122
- /** Handle 429 rate limit with retry — used by GET/DELETE multi-attempt loops */
159
+ /** Shared GET/DELETE retry loop with fresh-nonce per attempt. */
160
+ private _signedRequestWithRetry;
161
+ /**
162
+ * Unified response validator for ALL signed Aster requests.
163
+ *
164
+ * Validates BOTH HTTP status AND the venue JSON error envelope. Aster's
165
+ * API commonly returns HTTP 200 + `{code, msg: "Signature check failed"}`
166
+ * for signing or auth failures — checking only HTTP status would silently
167
+ * accept these as success and cause callers (e.g., getBalance) to cache
168
+ * zero balances or empty arrays. SSOT Rule #2: failures throw with
169
+ * remediation; never substitute a default.
170
+ *
171
+ * Used by signed POST/GET/DELETE. Public unsigned GETs use _handleResponse.
172
+ */
173
+ private _handleAsterResponse;
174
+ /** Handle 429 rate limit with retry — used by public unsigned GET multi-attempt loops */
123
175
  private _handleResponse;
124
176
  private _publicGet;
125
177
  }
178
+ export {};
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Aster DEX adapter — EIP-712 three-tier signer routing (v3.2).
2
+ * Aster DEX adapter — EIP-712 agent-only signer routing (v3.3).
3
3
  *
4
- * Signer priority:
5
- * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent)
6
- * Tier 2 — OWS master wallet (--ows / owsActiveWallet)
7
- * Tier 3 — PK direct (--private-key / PRIVATE_KEY / ASTER_PRIVATE_KEY)
4
+ * Signer policy:
5
+ * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent) — REQUIRED
6
+ * Tier 2 — OWS master — NOT_SUPPORTED (venue rejects master self-signing)
7
+ * Tier 3 — PK direct — NOT_SUPPORTED (venue rejects master self-signing)
8
+ *
9
+ * Aster V3 spec requires `signer` to be a registered API_WALLET (agent). The
10
+ * master wallet is never a valid signer — venue returns
11
+ * `code:-1000 msg:"Signature check failed"` when user==signer==master, even
12
+ * though the EIP-712 envelope is well-formed. Master/PK signers are still
13
+ * accepted via setMasterSigner/setPkSigner (used during approveAgent flow),
14
+ * but signed read/trade requests must use Tier 1.
8
15
  *
9
16
  * HMAC paths fully removed (see Step 3 plan — all Binance-compat HMAC code deleted).
10
17
  *
@@ -501,6 +508,12 @@ export class AsterAdapter {
501
508
  throw new Error("Aster withdrawal requires the spot API (not available in futures mode). Use the Aster web UI to withdraw.");
502
509
  }
503
510
  // ── Private: signer resolution ────────────────────────────────────────────
511
+ /**
512
+ * Resolve the active signer per the three-tier policy.
513
+ *
514
+ * Exposed (leading underscore) for unit-test access only — do not call
515
+ * from production code outside this class.
516
+ */
504
517
  _resolveSigner() {
505
518
  // Tier 1: agent (when registered, not expired, --no-agent NOT set)
506
519
  if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
@@ -522,15 +535,19 @@ export class AsterAdapter {
522
535
  };
523
536
  }
524
537
  }
525
- // Tier 2: OWS master
526
- if (this._masterSigner) {
527
- const addr = this._masterSigner.getAddress();
528
- return { tier: "master", signer: this._masterSigner, signerAddress: addr, userAddress: addr };
529
- }
530
- // Tier 3: PK direct
531
- if (this._pkSigner) {
532
- const addr = this._pkSigner.getAddress();
533
- return { tier: "pk", signer: this._pkSigner, signerAddress: addr, userAddress: addr };
538
+ // Tier 2/3: master self-signing is not supported by the venue. Per Aster
539
+ // V3 spec the `signer` field MUST be a registered API_WALLET (agent);
540
+ // sending `user==signer==master` is rejected with
541
+ // `code:-1000 msg:"Signature check failed"` even though the EIP-712
542
+ // envelope is well-formed. SSOT Rule #2: throw with remediation rather
543
+ // than silently dispatching a request the venue will reject.
544
+ //
545
+ // Reference: HypurrQuant_FE AsterPerpAdapter.ts throws if agent isn't
546
+ // configured; never attempts master self-signing.
547
+ if (this._masterSigner || this._pkSigner) {
548
+ throw new PerpError("NOT_IMPLEMENTED", "Aster requires a registered agent — master self-signing is not supported by venue", {
549
+ remediation: "perp wallet agent approve aster --master <wallet>",
550
+ });
534
551
  }
535
552
  // No signer at any tier
536
553
  throw new PerpError("NO_SIGNER_AVAILABLE", "No signing path configured for Aster", {
@@ -555,8 +572,15 @@ export class AsterAdapter {
555
572
  * signed dict and the URL query string must be byte-identical except for
556
573
  * the appended signature.
557
574
  *
575
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields.
576
+ * In production (Tier 1 only), `user` is the master and `signer` is the
577
+ * registered agent address. Aster's authority server checks both fields.
578
+ *
558
579
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
559
580
  * scheme; including it breaks signature verification on every call.
581
+ *
582
+ * Exposed (with leading underscore) for unit-test access only — do not call
583
+ * from production code outside this class.
560
584
  */
561
585
  async _buildSignedQueryString(params, resolved) {
562
586
  // Insertion order (matches Python reference: nonce, user, signer last).
@@ -566,7 +590,7 @@ export class AsterAdapter {
566
590
  user: resolved.userAddress,
567
591
  signer: resolved.signerAddress,
568
592
  };
569
- const typed = buildOrderTypedData(fullParams);
593
+ const typed = buildOrderTypedData(fullParams, this._testnet);
570
594
  // Sign via the appropriate signer type
571
595
  const sigRaw = await resolved.signer.signTypedData(typed.domain, typed.types, typed.message);
572
596
  // Normalize: AgentSigningStrategy returns { signature, r, s, v }; EvmSigner returns string
@@ -586,38 +610,76 @@ export class AsterAdapter {
586
610
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
587
611
  body: "",
588
612
  });
589
- return this._handleEip712Response(res, "POST", path);
613
+ return this._handleAsterResponse(res, "POST", path);
590
614
  }
591
615
  /**
592
616
  * Sign and GET from a path using EIP-712 Domain B.
617
+ *
618
+ * Unlike public GETs, signed GETs route through _handleAsterResponse so
619
+ * venue JSON error envelopes ({code, msg}) are not silently treated as
620
+ * success (SSOT Rule #2).
621
+ *
622
+ * Retries up to 3 attempts with exponential backoff (2s/4s/8s) on HTTP 429
623
+ * rate-limit. Each retry rebuilds a fresh signed query string (new nonce
624
+ * + signature) — Aster reuses recent nonces strictly, so reusing a stale
625
+ * one would itself be rejected. Non-429 venue errors throw immediately.
593
626
  */
594
627
  async _signedGetEip712(path, params, resolved) {
595
- const qs = await this._buildSignedQueryString(params, resolved);
596
- 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`);
628
+ return this._signedRequestWithRetry("GET", path, params, resolved);
604
629
  }
605
630
  /**
606
631
  * Sign and DELETE from a path using EIP-712 Domain B.
632
+ * Same retry behavior as _signedGetEip712.
607
633
  */
608
634
  async _signedDeleteEip712(path, params, resolved) {
609
- const qs = await this._buildSignedQueryString(params, resolved);
610
- 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;
635
+ return this._signedRequestWithRetry("DELETE", path, params, resolved);
636
+ }
637
+ /** Shared GET/DELETE retry loop with fresh-nonce per attempt. */
638
+ async _signedRequestWithRetry(method, path, params, resolved) {
639
+ const MAX_ATTEMPTS = 3;
640
+ let lastErr;
641
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
642
+ // Rebuild signed query string with fresh nonce on each attempt.
643
+ const qs = await this._buildSignedQueryString(params, resolved);
644
+ const url = `${this._baseUrl}${path}?${qs}`;
645
+ const res = await fetch(url, method === "DELETE" ? { method: "DELETE" } : undefined);
646
+ // 429 → backoff + retry. All other paths (including non-429 venue
647
+ // errors wrapped in HTTP 200 with code≠0) throw immediately via
648
+ // _handleAsterResponse — no silent fallback (Rule #2).
649
+ if (res.status === 429 && attempt < MAX_ATTEMPTS - 1) {
650
+ const backoffMs = 2000 * Math.pow(2, attempt); // 2s, 4s, 8s
651
+ await new Promise((r) => setTimeout(r, backoffMs));
652
+ continue;
653
+ }
654
+ try {
655
+ return await this._handleAsterResponse(res, method, path);
656
+ }
657
+ catch (e) {
658
+ lastErr = e;
659
+ if (res.status === 429 && attempt < MAX_ATTEMPTS - 1) {
660
+ const backoffMs = 2000 * Math.pow(2, attempt);
661
+ await new Promise((r) => setTimeout(r, backoffMs));
662
+ continue;
663
+ }
664
+ throw e;
665
+ }
616
666
  }
617
- throw new Error(`DELETE ${path} failed: max retries exceeded`);
667
+ throw lastErr;
618
668
  }
619
669
  // ── Private: HTTP response handling ──────────────────────────────────────
620
- async _handleEip712Response(res, method, path) {
670
+ /**
671
+ * Unified response validator for ALL signed Aster requests.
672
+ *
673
+ * Validates BOTH HTTP status AND the venue JSON error envelope. Aster's
674
+ * API commonly returns HTTP 200 + `{code, msg: "Signature check failed"}`
675
+ * for signing or auth failures — checking only HTTP status would silently
676
+ * accept these as success and cause callers (e.g., getBalance) to cache
677
+ * zero balances or empty arrays. SSOT Rule #2: failures throw with
678
+ * remediation; never substitute a default.
679
+ *
680
+ * Used by signed POST/GET/DELETE. Public unsigned GETs use _handleResponse.
681
+ */
682
+ async _handleAsterResponse(res, method, path) {
621
683
  if (res.status === 429) {
622
684
  const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10);
623
685
  await new Promise(r => setTimeout(r, Math.min(retryAfter * 1000, 30000)));
@@ -628,15 +690,28 @@ export class AsterAdapter {
628
690
  const s = classifyError(new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`), "aster");
629
691
  throw new PerpError(s.code, s.message, { exchange: s.exchange });
630
692
  }
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 });
693
+ // Parse JSON, then validate venue error envelope. Aster success returns
694
+ // either an object whose `code` is "000000"/200 (or absent) or an array
695
+ // (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades).
696
+ const text = await res.text();
697
+ let parsed;
698
+ try {
699
+ parsed = JSON.parse(text);
700
+ }
701
+ catch {
702
+ throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster" });
703
+ }
704
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
705
+ const json = parsed;
706
+ if (json.code !== undefined && json.code !== "000000" && json.code !== 200 && json.code !== 0) {
707
+ const rawMsg = typeof json.msg === "string" ? json.msg : JSON.stringify(json);
708
+ const s = classifyError(new Error(rawMsg), "aster");
709
+ throw new PerpError(s.code, s.message, { exchange: s.exchange });
710
+ }
636
711
  }
637
- return json;
712
+ return parsed;
638
713
  }
639
- /** Handle 429 rate limit with retry — used by GET/DELETE multi-attempt loops */
714
+ /** Handle 429 rate limit with retry — used by public unsigned GET multi-attempt loops */
640
715
  async _handleResponse(res, method, path, attempt = 0) {
641
716
  if (res.status === 429) {
642
717
  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.12",
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.12"
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.12)
45
45
  npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
46
46
  ```
47
47