perp-cli 0.12.6 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/account.js +21 -1
- package/dist/commands/agent.d.ts +34 -0
- package/dist/commands/agent.js +90 -0
- package/dist/commands/manage.js +156 -1
- package/dist/exchanges/hyperliquid.d.ts +25 -0
- package/dist/exchanges/hyperliquid.js +67 -18
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
package/dist/commands/account.js
CHANGED
|
@@ -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
|
-
// ──
|
|
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();
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/agent.js
CHANGED
|
@@ -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.
|
package/dist/commands/manage.js
CHANGED
|
@@ -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,49 @@ 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
|
+
// "default" is the implicit pre-set value HL returns when a user has
|
|
387
|
+
// never invoked `userSetAbstraction`. Per HL docs the four modes are
|
|
388
|
+
// Standard / Unified / Portfolio / (legacy) DEX abstraction. The set
|
|
389
|
+
// API only accepts the latter three; Standard is the de-facto baseline
|
|
390
|
+
// for unset accounts. Map "default" → "standard" so accounting branches
|
|
391
|
+
// pick the perp clearinghouse path. Users can opt into unified explicitly
|
|
392
|
+
// via `wallet manage account-mode unified`.
|
|
393
|
+
if (raw === "default")
|
|
394
|
+
return "standard";
|
|
395
|
+
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
|
+
});
|
|
397
|
+
}
|
|
347
398
|
/** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
|
|
348
399
|
async _getSpotClearinghouseState() {
|
|
349
400
|
this.ensureAddress();
|
|
@@ -367,26 +418,24 @@ export class HyperliquidAdapter {
|
|
|
367
418
|
}
|
|
368
419
|
let equity;
|
|
369
420
|
let available;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
available = Number(s?.withdrawable ?? 0);
|
|
386
|
-
}
|
|
421
|
+
// Branch by user's account abstraction mode (Rule #2: no silent fallback).
|
|
422
|
+
// - "unified" / "portfolio": spot USDC total = true equity; perp margin
|
|
423
|
+
// is held against the same USDC pool, so adding both double-counts.
|
|
424
|
+
// - "standard": separate perp/spot accounting; perp clearinghouse is the
|
|
425
|
+
// only collateral source. Required mode for builder fee accrual.
|
|
426
|
+
// HIP-3 dex accounts (this._dex) always use standard semantics.
|
|
427
|
+
const mode = await this._getAbstractionMode();
|
|
428
|
+
if (mode === "unified" || mode === "portfolio") {
|
|
429
|
+
const spotState = await this._getSpotClearinghouseState();
|
|
430
|
+
const balances = (spotState?.balances ?? []);
|
|
431
|
+
const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
|
|
432
|
+
const spotTotal = Number(usdc?.total ?? 0);
|
|
433
|
+
const spotHold = Number(usdc?.hold ?? 0);
|
|
434
|
+
equity = spotTotal;
|
|
435
|
+
available = spotTotal - spotHold;
|
|
387
436
|
}
|
|
388
437
|
else {
|
|
389
|
-
//
|
|
438
|
+
// mode === "standard"
|
|
390
439
|
equity = Number(margin.accountValue ?? cross.accountValue ?? 0);
|
|
391
440
|
available = Number(s?.withdrawable ?? 0);
|
|
392
441
|
}
|
package/package.json
CHANGED
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
8
|
+
version: "0.12.9"
|
|
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.
|
|
44
|
+
perp --version 2>/dev/null # check if exists (must be >= 0.12.9)
|
|
45
45
|
npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
|
|
46
46
|
```
|
|
47
47
|
|