perp-cli 0.11.0 → 0.12.1

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.
Files changed (240) hide show
  1. package/README.md +30 -25
  2. package/dist/_stderr-filter.d.ts +0 -0
  3. package/dist/_stderr-filter.js +30 -0
  4. package/dist/agent-wallet/expiry.d.ts +15 -0
  5. package/dist/agent-wallet/expiry.js +26 -0
  6. package/dist/agent-wallet/lighter-keystore.d.ts +34 -0
  7. package/dist/agent-wallet/lighter-keystore.js +232 -0
  8. package/dist/agent-wallet/passphrase.d.ts +13 -0
  9. package/dist/agent-wallet/passphrase.js +54 -0
  10. package/dist/agent-wallet/signing-strategy.d.ts +67 -0
  11. package/dist/agent-wallet/signing-strategy.js +51 -0
  12. package/dist/agent-wallet/store.d.ts +37 -0
  13. package/dist/agent-wallet/store.js +172 -0
  14. package/dist/api/public/_http.d.ts +15 -0
  15. package/dist/api/public/_http.js +24 -0
  16. package/dist/api/public/hyperliquid.js +36 -24
  17. package/dist/api/public/lighter.d.ts +7 -1
  18. package/dist/api/public/lighter.js +85 -37
  19. package/dist/api/public/pacifica.js +47 -18
  20. package/dist/bridge-engine.js +102 -49
  21. package/dist/cli-spec.js +1 -2
  22. package/dist/commands/account.js +2 -500
  23. package/dist/commands/agent.d.ts +8 -2
  24. package/dist/commands/agent.js +1903 -366
  25. package/dist/commands/alerts.js +3 -1
  26. package/dist/commands/arb/index.js +37 -7
  27. package/dist/commands/arb-auto.js +44 -11
  28. package/dist/commands/arb.js +83 -34
  29. package/dist/commands/bot.d.ts +1 -1
  30. package/dist/commands/bot.js +18 -14
  31. package/dist/commands/bridge.d.ts +1 -1
  32. package/dist/commands/bridge.js +4 -4
  33. package/dist/commands/funds.d.ts +1 -1
  34. package/dist/commands/funds.js +18 -68
  35. package/dist/commands/jobs.d.ts +1 -1
  36. package/dist/commands/jobs.js +2 -2
  37. package/dist/commands/manage.d.ts +1 -1
  38. package/dist/commands/manage.js +12 -84
  39. package/dist/commands/plan.d.ts +1 -1
  40. package/dist/commands/plan.js +2 -2
  41. package/dist/commands/portfolio.d.ts +19 -3
  42. package/dist/commands/portfolio.js +747 -163
  43. package/dist/commands/rebalance.d.ts +1 -1
  44. package/dist/commands/rebalance.js +18 -12
  45. package/dist/commands/risk.js +2 -6
  46. package/dist/commands/settings.js +30 -53
  47. package/dist/commands/trade.js +16 -15
  48. package/dist/commands/wallet.d.ts +2 -1
  49. package/dist/commands/wallet.js +756 -28
  50. package/dist/errors.d.ts +64 -0
  51. package/dist/errors.js +82 -2
  52. package/dist/event-stream.js +12 -1
  53. package/dist/exchanges/aster-typed-data.d.ts +156 -0
  54. package/dist/exchanges/aster-typed-data.js +189 -0
  55. package/dist/exchanges/aster.d.ts +60 -11
  56. package/dist/exchanges/aster.js +278 -136
  57. package/dist/exchanges/hyperliquid.d.ts +14 -0
  58. package/dist/exchanges/hyperliquid.js +84 -34
  59. package/dist/exchanges/lighter-spot.js +14 -3
  60. package/dist/exchanges/lighter.d.ts +66 -0
  61. package/dist/exchanges/lighter.js +199 -27
  62. package/dist/exchanges/pacifica-typed-data.d.ts +75 -0
  63. package/dist/exchanges/pacifica-typed-data.js +74 -0
  64. package/dist/exchanges/pacifica.d.ts +29 -1
  65. package/dist/exchanges/pacifica.js +94 -6
  66. package/dist/funding/rates.js +59 -21
  67. package/dist/guardrail/perp-guardrail.js +9 -1
  68. package/dist/index.d.ts +1 -1
  69. package/dist/index.js +268 -360
  70. package/dist/jobs.js +3 -1
  71. package/dist/mcp-server.js +85 -83
  72. package/dist/position-history.js +47 -4
  73. package/dist/settings.d.ts +67 -3
  74. package/dist/settings.js +1 -2
  75. package/dist/signer/ows-evm.d.ts +14 -0
  76. package/dist/signer/ows-evm.js +28 -9
  77. package/dist/smart-order.d.ts +8 -2
  78. package/dist/smart-order.js +46 -11
  79. package/dist/strategies/funding-arb.js +29 -8
  80. package/dist/trade-validator.js +30 -5
  81. package/dist/utils.d.ts +10 -0
  82. package/dist/utils.js +19 -0
  83. package/package.json +2 -2
  84. package/skills/perp-cli/SKILL.md +23 -14
  85. package/skills/perp-cli/references/agent-operations.md +6 -4
  86. package/skills/perp-cli/references/commands.md +55 -34
  87. package/skills/perp-cli/scripts/preflight.sh +4 -4
  88. package/skills/perp-cli/scripts/spot-perp-scan.sh +2 -2
  89. package/dist/__tests__/alert-logic.test.d.ts +0 -1
  90. package/dist/__tests__/alert-logic.test.js +0 -107
  91. package/dist/__tests__/arb-auto-3dex.test.d.ts +0 -1
  92. package/dist/__tests__/arb-auto-3dex.test.js +0 -397
  93. package/dist/__tests__/arb-history-stats.test.d.ts +0 -1
  94. package/dist/__tests__/arb-history-stats.test.js +0 -176
  95. package/dist/__tests__/arb-logic.test.d.ts +0 -1
  96. package/dist/__tests__/arb-logic.test.js +0 -84
  97. package/dist/__tests__/arb-manage.test.d.ts +0 -1
  98. package/dist/__tests__/arb-manage.test.js +0 -253
  99. package/dist/__tests__/arb-new-features.test.d.ts +0 -1
  100. package/dist/__tests__/arb-new-features.test.js +0 -457
  101. package/dist/__tests__/arb-sizing.test.d.ts +0 -1
  102. package/dist/__tests__/arb-sizing.test.js +0 -48
  103. package/dist/__tests__/arb-state.test.d.ts +0 -1
  104. package/dist/__tests__/arb-state.test.js +0 -284
  105. package/dist/__tests__/arb-userflow.test.d.ts +0 -1
  106. package/dist/__tests__/arb-userflow.test.js +0 -945
  107. package/dist/__tests__/arb-utils.test.d.ts +0 -1
  108. package/dist/__tests__/arb-utils.test.js +0 -264
  109. package/dist/__tests__/bot-conditions.test.d.ts +0 -1
  110. package/dist/__tests__/bot-conditions.test.js +0 -341
  111. package/dist/__tests__/client-id-tracker.test.d.ts +0 -1
  112. package/dist/__tests__/client-id-tracker.test.js +0 -137
  113. package/dist/__tests__/commands/new-atomic-commands.test.d.ts +0 -1
  114. package/dist/__tests__/commands/new-atomic-commands.test.js +0 -502
  115. package/dist/__tests__/commands/order-intent.test.d.ts +0 -1
  116. package/dist/__tests__/commands/order-intent.test.js +0 -600
  117. package/dist/__tests__/commands/trade-commands.test.d.ts +0 -1
  118. package/dist/__tests__/commands/trade-commands.test.js +0 -821
  119. package/dist/__tests__/config.test.d.ts +0 -1
  120. package/dist/__tests__/config.test.js +0 -86
  121. package/dist/__tests__/cross-chain-margin.test.d.ts +0 -1
  122. package/dist/__tests__/cross-chain-margin.test.js +0 -287
  123. package/dist/__tests__/dex-asset-map.test.d.ts +0 -1
  124. package/dist/__tests__/dex-asset-map.test.js +0 -191
  125. package/dist/__tests__/errors.test.d.ts +0 -1
  126. package/dist/__tests__/errors.test.js +0 -110
  127. package/dist/__tests__/event-stream.test.d.ts +0 -1
  128. package/dist/__tests__/event-stream.test.js +0 -276
  129. package/dist/__tests__/exchanges/interface.test.d.ts +0 -1
  130. package/dist/__tests__/exchanges/interface.test.js +0 -132
  131. package/dist/__tests__/exchanges/mock-adapter.d.ts +0 -69
  132. package/dist/__tests__/exchanges/mock-adapter.js +0 -137
  133. package/dist/__tests__/execution-log.test.d.ts +0 -1
  134. package/dist/__tests__/execution-log.test.js +0 -106
  135. package/dist/__tests__/funding-calc.test.d.ts +0 -1
  136. package/dist/__tests__/funding-calc.test.js +0 -71
  137. package/dist/__tests__/funding-history.test.d.ts +0 -1
  138. package/dist/__tests__/funding-history.test.js +0 -343
  139. package/dist/__tests__/funding-rates.test.d.ts +0 -1
  140. package/dist/__tests__/funding-rates.test.js +0 -342
  141. package/dist/__tests__/funding.test.d.ts +0 -1
  142. package/dist/__tests__/funding.test.js +0 -173
  143. package/dist/__tests__/gap-logic.test.d.ts +0 -1
  144. package/dist/__tests__/gap-logic.test.js +0 -43
  145. package/dist/__tests__/hip3-dex.test.d.ts +0 -1
  146. package/dist/__tests__/hip3-dex.test.js +0 -234
  147. package/dist/__tests__/integration/agent-features.integration.test.d.ts +0 -1
  148. package/dist/__tests__/integration/agent-features.integration.test.js +0 -553
  149. package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +0 -13
  150. package/dist/__tests__/integration/atomic-commands.integration.test.js +0 -246
  151. package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +0 -1
  152. package/dist/__tests__/integration/bridge-simulation.integration.test.js +0 -453
  153. package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +0 -1
  154. package/dist/__tests__/integration/bridge-strict.integration.test.js +0 -812
  155. package/dist/__tests__/integration/bridge.integration.test.d.ts +0 -1
  156. package/dist/__tests__/integration/bridge.integration.test.js +0 -309
  157. package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +0 -1
  158. package/dist/__tests__/integration/cli-e2e.integration.test.js +0 -202
  159. package/dist/__tests__/integration/dex-arb.integration.test.d.ts +0 -1
  160. package/dist/__tests__/integration/dex-arb.integration.test.js +0 -116
  161. package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +0 -13
  162. package/dist/__tests__/integration/envelope-consistency.integration.test.js +0 -205
  163. package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +0 -1
  164. package/dist/__tests__/integration/hip3-dex.integration.test.js +0 -147
  165. package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +0 -1
  166. package/dist/__tests__/integration/hyperliquid.integration.test.js +0 -79
  167. package/dist/__tests__/integration/lighter.integration.test.d.ts +0 -1
  168. package/dist/__tests__/integration/lighter.integration.test.js +0 -53
  169. package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +0 -9
  170. package/dist/__tests__/integration/new-commands-e2e.integration.test.js +0 -236
  171. package/dist/__tests__/integration/order-verification.integration.test.d.ts +0 -1
  172. package/dist/__tests__/integration/order-verification.integration.test.js +0 -321
  173. package/dist/__tests__/integration/pacifica.integration.test.d.ts +0 -1
  174. package/dist/__tests__/integration/pacifica.integration.test.js +0 -75
  175. package/dist/__tests__/integration/response-shapes.integration.test.d.ts +0 -1
  176. package/dist/__tests__/integration/response-shapes.integration.test.js +0 -278
  177. package/dist/__tests__/liquidity.test.d.ts +0 -1
  178. package/dist/__tests__/liquidity.test.js +0 -225
  179. package/dist/__tests__/plan-executor.test.d.ts +0 -1
  180. package/dist/__tests__/plan-executor.test.js +0 -314
  181. package/dist/__tests__/position-history.test.d.ts +0 -1
  182. package/dist/__tests__/position-history.test.js +0 -367
  183. package/dist/__tests__/retry.test.d.ts +0 -1
  184. package/dist/__tests__/retry.test.js +0 -310
  185. package/dist/__tests__/risk-assessment.test.d.ts +0 -1
  186. package/dist/__tests__/risk-assessment.test.js +0 -288
  187. package/dist/__tests__/security-adversarial.test.d.ts +0 -1
  188. package/dist/__tests__/security-adversarial.test.js +0 -574
  189. package/dist/__tests__/strategies.test.d.ts +0 -1
  190. package/dist/__tests__/strategies.test.js +0 -539
  191. package/dist/__tests__/trade-execution.test.d.ts +0 -1
  192. package/dist/__tests__/trade-execution.test.js +0 -129
  193. package/dist/__tests__/trade-validator.test.d.ts +0 -1
  194. package/dist/__tests__/trade-validator.test.js +0 -655
  195. package/dist/__tests__/utils.test.d.ts +0 -1
  196. package/dist/__tests__/utils.test.js +0 -76
  197. package/dist/arb/reconcile.d.ts +0 -28
  198. package/dist/arb/reconcile.js +0 -162
  199. package/dist/commands/alert.d.ts +0 -2
  200. package/dist/commands/alert.js +0 -421
  201. package/dist/commands/analytics.d.ts +0 -3
  202. package/dist/commands/analytics.js +0 -311
  203. package/dist/commands/arb-manage.d.ts +0 -5
  204. package/dist/commands/arb-manage.js +0 -5
  205. package/dist/commands/dashboard.d.ts +0 -3
  206. package/dist/commands/dashboard.js +0 -176
  207. package/dist/commands/deposit.d.ts +0 -4
  208. package/dist/commands/deposit.js +0 -573
  209. package/dist/commands/dex.d.ts +0 -3
  210. package/dist/commands/dex.js +0 -120
  211. package/dist/commands/env.d.ts +0 -2
  212. package/dist/commands/env.js +0 -136
  213. package/dist/commands/funding.d.ts +0 -3
  214. package/dist/commands/funding.js +0 -554
  215. package/dist/commands/gap.d.ts +0 -2
  216. package/dist/commands/gap.js +0 -305
  217. package/dist/commands/health.d.ts +0 -2
  218. package/dist/commands/health.js +0 -67
  219. package/dist/commands/multileg.d.ts +0 -16
  220. package/dist/commands/multileg.js +0 -174
  221. package/dist/commands/ows.d.ts +0 -2
  222. package/dist/commands/ows.js +0 -887
  223. package/dist/commands/pnl.d.ts +0 -12
  224. package/dist/commands/pnl.js +0 -243
  225. package/dist/commands/run.d.ts +0 -3
  226. package/dist/commands/run.js +0 -202
  227. package/dist/commands/stream.d.ts +0 -5
  228. package/dist/commands/stream.js +0 -200
  229. package/dist/commands/withdraw.d.ts +0 -3
  230. package/dist/commands/withdraw.js +0 -187
  231. package/dist/exchanges/edgex.d.ts +0 -62
  232. package/dist/exchanges/edgex.js +0 -552
  233. package/dist/exchanges/nado.d.ts +0 -52
  234. package/dist/exchanges/nado.js +0 -223
  235. package/dist/funding/index.d.ts +0 -3
  236. package/dist/funding/index.js +0 -3
  237. package/dist/retry.d.ts +0 -74
  238. package/dist/retry.js +0 -129
  239. package/dist/ws/market-feed.d.ts +0 -74
  240. package/dist/ws/market-feed.js +0 -227
@@ -1,403 +1,1940 @@
1
1
  import { createRequire } from "node:module";
2
- import { printJson, jsonOk, jsonError } from "../utils.js";
3
- import { ERROR_CODES } from "../errors.js";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { printJson, jsonOk } from "../utils.js";
5
+ import { PerpError } from "../errors.js";
4
6
  import chalk from "chalk";
7
+ import bs58 from "bs58";
8
+ import { loadSettings } from "../settings.js";
9
+ import { getAgent, setAgent, deleteAgent, listAgents, acquireLock, releaseLock } from "../agent-wallet/store.js";
10
+ import { resolvePassphrase } from "../agent-wallet/passphrase.js";
11
+ import { OwsEvmSigner } from "../signer/ows-evm.js";
12
+ import { OwsSolanaSigner } from "../signer/ows-solana.js";
13
+ import { loadOws } from "../signer/ows-loader.js";
14
+ import { buildApproveAgentTypedData, buildDelAgentTypedData, buildApproveBuilderTypedData, } from "../exchanges/aster-typed-data.js";
15
+ /**
16
+ * Emit a structured JSON error envelope to stdout and exit non-zero.
17
+ * Stdout (not stderr) so machine consumers can read the envelope from a
18
+ * single stream — matches the rest of the CLI's --json error path.
19
+ */
20
+ function reportErrorAndExit(err) {
21
+ const ts = new Date().toISOString();
22
+ const envelope = err instanceof PerpError
23
+ ? { ok: false, error: { ...err.structured }, meta: { timestamp: ts } }
24
+ : { ok: false, error: { code: "UNKNOWN", message: err instanceof Error ? err.message : String(err) }, meta: { timestamp: ts } };
25
+ process.stdout.write(JSON.stringify(envelope) + "\n");
26
+ process.exit(1);
27
+ }
5
28
  const _require = createRequire(import.meta.url);
6
29
  const _pkg = _require("../../package.json");
7
- /** Infer parameter type from commander flags/description */
8
- function inferType(flags, desc) {
9
- if (flags.includes("[boolean]") || !flags.includes("<") && !flags.includes("["))
10
- return "boolean";
11
- const lower = (flags + " " + desc).toLowerCase();
12
- if (lower.includes("pct") || lower.includes("percent") || lower.includes("leverage") ||
13
- lower.includes("amount") || lower.includes("size") || lower.includes("price") ||
14
- lower.includes("<n>") || lower.includes("<sec>") || lower.includes("<ms>"))
15
- return "number";
16
- return "string";
17
- }
18
- /** Infer enum values from description */
19
- function inferEnum(desc) {
20
- // Match patterns like "buy|sell", "market, limit, stop", "on | off"
21
- const pipeMatch = desc.match(/:\s*(\w+(?:\s*[|,]\s*\w+)+)/);
22
- if (pipeMatch) {
23
- const values = pipeMatch[1].split(/\s*[|,]\s*/).map(v => v.trim()).filter(Boolean);
24
- if (values.length >= 2 && values.every(v => v.length < 20))
25
- return values;
26
- }
27
- return undefined;
30
+ // ── Per-DEX verify implementations ───────────────────────────────────────
31
+ async function verifyAster(_opts) {
32
+ // No-fallback policy (CLAUDE.md SSOT rule #2): Aster has no verified
33
+ // master-signing path for `/fapi/v3/agent`. The HypurrQuant_FE reference
34
+ // confirms Aster has no REST endpoint to list approved agents at all —
35
+ // master signing is used only for one-time approveAgent/approveBuilder.
36
+ //
37
+ // Returning local cache as if it were verified live state would be a
38
+ // fallback (silent substitution masquerading as success). The SSOT rule
39
+ // bans that pattern: failure must propagate, not get hidden behind a
40
+ // synthesized success envelope. Throw NOT_IMPLEMENTED with explicit
41
+ // remediation pointing to local-cache surfaces.
42
+ throw new PerpError("NOT_IMPLEMENTED", "Aster live agent verify is not supported (no verified master-signing path; FE reference also lacks this endpoint).", {
43
+ remediation: "Use 'perp wallet agent list aster' to inspect local cache, or 'perp wallet agent rotate aster <name>' to refresh on-chain.",
44
+ });
28
45
  }
29
- function extractSchema(cmd, parentPath = "perp") {
30
- const fullCommand = `${parentPath} ${cmd.name()}`.trim();
31
- const args = (cmd.registeredArguments ?? []).map((a) => {
32
- const desc = a.description || "";
33
- // Args are always values (not flags), so infer from name/desc semantics, never "boolean"
34
- const rawType = inferType(`<${a.name()}>`, desc);
35
- return {
36
- name: a.name(),
37
- type: rawType === "boolean" ? "string" : rawType,
38
- required: a.required,
39
- description: desc,
40
- enum: inferEnum(desc),
41
- };
46
+ /**
47
+ * Verify Hyperliquid agent registration.
48
+ *
49
+ * Cross-check (AC-28, v3.4): When agentName provided AND persisted in
50
+ * settings.agents.hyperliquid[name], asserts live response contains expected
51
+ * agentEvmAddress. Mismatch → meta.warnings populated (non-fatal).
52
+ */
53
+ async function verifyHyperliquid(opts) {
54
+ const settings = loadSettings();
55
+ // Resolve master EVM address: flag > named agent's userEvmAddress > first registered agent
56
+ let masterAddress = opts.masterAddress;
57
+ if (!masterAddress) {
58
+ const hlMap = settings.agents?.hyperliquid;
59
+ if (hlMap) {
60
+ if (opts.agentName && hlMap[opts.agentName]?.userEvmAddress) {
61
+ masterAddress = hlMap[opts.agentName].userEvmAddress;
62
+ }
63
+ else {
64
+ const first = Object.values(hlMap)[0];
65
+ if (first?.userEvmAddress)
66
+ masterAddress = first.userEvmAddress;
67
+ }
68
+ }
69
+ }
70
+ if (!masterAddress) {
71
+ throw new PerpError("INVALID_PARAMS", "Master EVM address required for Hyperliquid verify. Use --master-address.", {
72
+ remediation: "perp wallet agent verify hyperliquid --master-address 0xYOUR_ADDRESS",
73
+ });
74
+ }
75
+ const user = masterAddress.toLowerCase();
76
+ const resp = await fetch("https://api.hyperliquid.xyz/info", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ type: "extraAgents", user }),
42
80
  });
43
- const options = cmd.options
44
- .filter((o) => !["--help", "-h"].includes(o.short ?? o.long ?? ""))
45
- .map((o) => ({
46
- name: o.long?.replace(/^--/, "") ?? o.short?.replace(/^-/, "") ?? "",
47
- type: inferType(o.flags, o.description),
48
- required: o.required ?? false,
49
- description: o.description,
50
- ...(o.defaultValue !== undefined ? { default: String(o.defaultValue) } : {}),
51
- enum: inferEnum(o.description),
52
- }));
53
- const subcommands = cmd.commands
54
- .filter((c) => c.name() !== "help")
55
- .map((c) => extractSchema(c, fullCommand));
56
- return {
57
- name: cmd.name(),
58
- fullCommand,
59
- description: cmd.description(),
60
- args,
61
- options,
62
- ...(subcommands.length > 0 ? { subcommands } : {}),
81
+ if (!resp.ok) {
82
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid verify failed: HTTP ${resp.status}`, {
83
+ remediation: "Check master EVM address. Run: perp wallet agent verify hyperliquid --master-address 0xADDR",
84
+ });
85
+ }
86
+ const data = await resp.json();
87
+ const items = Array.isArray(data) ? data : [];
88
+ const warnings = [];
89
+ // Cross-check: AC-28 — verify persisted agentEvmAddress matches live response
90
+ if (opts.agentName) {
91
+ const agentMeta = settings.agents?.hyperliquid?.[opts.agentName];
92
+ if (agentMeta?.agentEvmAddress) {
93
+ const expected = agentMeta.agentEvmAddress.toLowerCase();
94
+ const found = items.some((item) => typeof item === "object" &&
95
+ item !== null &&
96
+ "address" in item &&
97
+ typeof item.address === "string" &&
98
+ item.address.toLowerCase() === expected);
99
+ if (!found) {
100
+ warnings.push(`Agent "${opts.agentName}" (expected address ${agentMeta.agentEvmAddress}) not found in Hyperliquid live response.`);
101
+ }
102
+ }
103
+ }
104
+ return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
105
+ }
106
+ /**
107
+ * Verify Pacifica agent registration.
108
+ *
109
+ * NOTE (v3.3 limitation): Cross-check semantics from AC-23 are inactive for
110
+ * this DEX because settings.agents.pacifica type slot does not exist yet
111
+ * (AgentsByExchange has only `aster?`). When Phase 2c extends AgentsByExchange
112
+ * + AgentMeta with per-DEX fields (accountIndex, publicKey, etc.), the
113
+ * cross-check branch below becomes live.
114
+ */
115
+ async function verifyPacifica(opts) {
116
+ const settings = loadSettings();
117
+ const masterName = opts.master ?? settings.owsActiveWallet;
118
+ if (!masterName) {
119
+ throw new PerpError("INVALID_PARAMS", "Master wallet name required for Pacifica verify. Use --master.", {
120
+ remediation: "perp wallet agent verify pacifica --master <walletName> --passphrase $PP",
121
+ });
122
+ }
123
+ const passphrase = opts.passphrase ?? "";
124
+ const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
125
+ const account = solanaSigner.getPublicKeyBase58();
126
+ const timestamp = Date.now();
127
+ const expiryWindow = 5000;
128
+ const type = "list_api_keys";
129
+ // Canonical JSON: sort keys lexicographically, no whitespace
130
+ const signHeader = { expiry_window: expiryWindow, timestamp, type };
131
+ const canonicalJson = JSON.stringify(signHeader);
132
+ // Sign canonical JSON with Ed25519 (OWS Solana signer)
133
+ const msgBytes = new TextEncoder().encode(canonicalJson);
134
+ const sigBytes = await solanaSigner.signMessage(msgBytes);
135
+ // Base58-encode the signature
136
+ const sigBase58 = bs58.encode(sigBytes);
137
+ const body = {
138
+ account,
139
+ agent_wallet: null,
140
+ signature: sigBase58,
141
+ timestamp,
142
+ expiry_window: expiryWindow,
143
+ type,
63
144
  };
145
+ const resp = await fetch("https://api.pacifica.fi/api/v1/account/api_keys", {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify(body),
149
+ });
150
+ if (!resp.ok) {
151
+ const body = await resp.text().catch(() => "");
152
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica verify failed: HTTP ${resp.status}: ${body.slice(0, 200)}`, {
153
+ remediation: "Check master wallet name and passphrase. Run: perp wallet agent verify pacifica --master <name> --passphrase $PP",
154
+ });
155
+ }
156
+ const data = await resp.json();
157
+ const apiKeys = (data &&
158
+ typeof data === "object" &&
159
+ "data" in data &&
160
+ typeof data.data === "object" &&
161
+ data.data !== null &&
162
+ "api_keys" in data.data)
163
+ ? data.data.api_keys
164
+ : [];
165
+ const items = Array.isArray(apiKeys) ? apiKeys : [];
166
+ return { registered: items.length > 0, count: items.length, items };
167
+ }
168
+ /**
169
+ * Verify Lighter agent registration.
170
+ *
171
+ * NOTE (v3.3 limitation): Cross-check semantics from AC-23 are inactive for
172
+ * this DEX because settings.agents.lighter type slot does not exist yet
173
+ * (AgentsByExchange has only `aster?`). When Phase 2d extends AgentsByExchange
174
+ * + AgentMeta with per-DEX fields (accountIndex, publicKey, etc.), the
175
+ * cross-check branch below becomes live.
176
+ */
177
+ async function verifyLighter(opts) {
178
+ const settings = loadSettings();
179
+ const accountIndexStr = opts.accountIndex;
180
+ // Resolve accountIndex: flag > named agent > first registered agent
181
+ let accountIndex;
182
+ if (accountIndexStr !== undefined) {
183
+ accountIndex = parseInt(accountIndexStr, 10);
184
+ }
185
+ else {
186
+ const ltMap = settings.agents?.lighter;
187
+ if (ltMap) {
188
+ if (opts.agentName && ltMap[opts.agentName]?.accountIndex !== undefined) {
189
+ accountIndex = ltMap[opts.agentName].accountIndex;
190
+ }
191
+ else {
192
+ const first = Object.values(ltMap)[0];
193
+ if (first?.accountIndex !== undefined)
194
+ accountIndex = first.accountIndex;
195
+ }
196
+ }
197
+ }
198
+ if (accountIndex === undefined || isNaN(accountIndex)) {
199
+ throw new PerpError("INVALID_PARAMS", "Account index required for Lighter verify. Use --account-index.", {
200
+ remediation: "perp wallet agent verify lighter --account-index <n>",
201
+ });
202
+ }
203
+ const resp = await fetch(`https://mainnet.zklighter.elliot.ai/api/v1/apikeys?account_index=${accountIndex}`, { method: "GET" });
204
+ if (!resp.ok) {
205
+ throw new PerpError("EXCHANGE_ERROR", `Lighter verify failed: HTTP ${resp.status}`, {
206
+ remediation: `Check account index. Run: perp wallet agent verify lighter --account-index ${accountIndex}`,
207
+ });
208
+ }
209
+ const data = await resp.json();
210
+ const apiKeys = (data &&
211
+ typeof data === "object" &&
212
+ "api_keys" in data)
213
+ ? data.api_keys
214
+ : [];
215
+ const items = Array.isArray(apiKeys) ? apiKeys : [];
216
+ const warnings = [];
217
+ // Cross-check if agentName provided
218
+ if (opts.agentName) {
219
+ const ltAgents = settings.agents;
220
+ const ltMap = (ltAgents && typeof ltAgents === "object" && "lighter" in ltAgents)
221
+ ? ltAgents.lighter
222
+ : undefined;
223
+ const agentMeta = ltMap?.[opts.agentName];
224
+ if (agentMeta?.publicKey) {
225
+ const expected = agentMeta.publicKey;
226
+ const found = items.some((item) => typeof item === "object" &&
227
+ item !== null &&
228
+ "public_key" in item &&
229
+ item.public_key === expected);
230
+ if (!found) {
231
+ warnings.push(`Agent "${opts.agentName}" (expected public_key ${agentMeta.publicKey}) not found in Lighter live response.`);
232
+ }
233
+ }
234
+ }
235
+ return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
64
236
  }
65
- export function registerAgentCommands(program, getAdapter, isJson) {
66
- const agent = program
237
+ /**
238
+ * Register the per-DEX agent-wallet management subtree (`approve` /
239
+ * `list` / `revoke` / `rotate` / `verify`) under the supplied parent
240
+ * `wallet` command. The function previously registered `agent` as a
241
+ * top-level command on `program`; the hard-cut consolidation in v0.11+
242
+ * relocates it to `wallet agent ...`.
243
+ */
244
+ export function registerWalletAgentCommands(walletCmd, isJson) {
245
+ const agent = walletCmd
67
246
  .command("agent")
68
- .description("Agent-friendly commands");
69
- // ── agent schema ── dump full command tree as JSON
247
+ .description("Agent wallet management — register/revoke/rotate/verify per-DEX delegation keys");
248
+ // ── agent approve <exchange> ── register an agent wallet ──────────────────
70
249
  agent
71
- .command("schema")
72
- .description("Output full CLI command schema as JSON (for agent discovery)")
73
- .action(() => {
74
- const errorCodeDocs = {};
75
- for (const [key, val] of Object.entries(ERROR_CODES)) {
76
- errorCodeDocs[key] = { status: val.status, retryable: val.retryable, description: key.toLowerCase().replace(/_/g, " ") };
77
- }
78
- const envelope = {
79
- schemaVersion: "2.0",
80
- cliVersion: _pkg.version,
81
- generatedAt: new Date().toISOString(),
82
- exchanges: ["pacifica", "hyperliquid", "lighter", "aster"],
83
- errorCodes: errorCodeDocs,
84
- commands: program.commands
85
- .filter(c => c.name() !== "help")
86
- .map(c => extractSchema(c)),
87
- };
88
- printJson(jsonOk(envelope));
250
+ .command("approve <exchange>")
251
+ .description("Register an agent wallet for an exchange (Phase 2a/b/c/d: aster, hyperliquid, pacifica, lighter)")
252
+ .option("--master <name>", "Master OWS wallet name")
253
+ .option("--agent-name <name>", "Agent name (default: perp-cli-aster)")
254
+ .option("--expires-in <duration>", "Expiry: 30d, 90d, 180d, 1y, or ISO-8601 datetime (default: 90d)")
255
+ .option("--can-perp", "Allow perp trading (default: on)", true)
256
+ .option("--no-perp", "Disallow perp trading")
257
+ .option("--can-spot", "Allow spot trading (default: off)", false)
258
+ .option("--no-spot", "Disallow spot trading")
259
+ .option("--can-withdraw", "Allow withdrawals (default: off)", false)
260
+ .option("--no-withdraw", "Disallow withdrawals")
261
+ .option("--rotate", "Allow re-approving an existing agent name")
262
+ .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
263
+ .option("--builder <addr>", "Builder address for optional builder approval")
264
+ .option("--max-fee-rate <bps>", "Max fee rate bps (required with --builder)")
265
+ .option("--builder-name <name>", "Optional builder name")
266
+ .option("--ip-whitelist <list>", "Comma-separated IP whitelist (pass empty string to include empty)")
267
+ .option("--api-key-index <n>", "Lighter slot index (4-254). Default: next free slot.")
268
+ .option("--json", "Machine-readable output")
269
+ .action(async (exchange, opts) => {
270
+ const useJson = opts.json ?? isJson();
271
+ // Normalize aliases
272
+ const exchangeNorm = exchange === "lt" ? "lighter"
273
+ : exchange === "hl" ? "hyperliquid"
274
+ : exchange === "pac" ? "pacifica"
275
+ : exchange === "ast" ? "aster"
276
+ : exchange;
277
+ // Phase 2d: Aster + HL + PAC + LT all implemented.
278
+ if (exchangeNorm !== "aster" &&
279
+ exchangeNorm !== "hyperliquid" &&
280
+ exchangeNorm !== "pacifica" &&
281
+ exchangeNorm !== "lighter") {
282
+ reportErrorAndExit(new PerpError("NOT_IMPLEMENTED", `${exchange} agent approve not implemented`, {
283
+ remediation: "Use one of: aster, hyperliquid, pacifica, lighter",
284
+ }));
285
+ }
286
+ // ── Lighter approve branch (Phase 2d) ───────────────────────────────
287
+ if (exchangeNorm === "lighter") {
288
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
289
+ const agentName = opts.agentName ?? "perp-cli-lt";
290
+ const expiresIn = opts.expiresIn ?? "90d";
291
+ let expiresAt;
292
+ try {
293
+ expiresAt = parseExpiresIn(expiresIn);
294
+ }
295
+ catch (e) {
296
+ const msg = e instanceof Error ? e.message : String(e);
297
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
298
+ remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
299
+ }));
300
+ }
301
+ if (!masterName) {
302
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
303
+ remediation: "Run: perp setup or use --master <name>",
304
+ }));
305
+ }
306
+ // Slot validation: explicit value must be 4..254. Otherwise pick free slot.
307
+ let chosenSlot;
308
+ if (opts.apiKeyIndex !== undefined) {
309
+ const n = parseInt(opts.apiKeyIndex, 10);
310
+ if (!Number.isInteger(n) || n < 4 || n > 254) {
311
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", `--api-key-index must be an integer in [4, 254]; got ${opts.apiKeyIndex}`, {
312
+ remediation: "Slots 0-3 are reserved by the Lighter frontend. Use 4-254.",
313
+ }));
314
+ }
315
+ chosenSlot = n;
316
+ }
317
+ const passphrase = await resolvePassphrase({ flag: opts.passphrase });
318
+ if (passphrase === null) {
319
+ reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
320
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
321
+ }));
322
+ }
323
+ try {
324
+ acquireLock("lighter");
325
+ try {
326
+ const result = await runLtApproveFlow({
327
+ masterName,
328
+ passphrase: passphrase ?? "",
329
+ agentName,
330
+ expiresAt,
331
+ expiresAtIso: expiresAt.toISOString(),
332
+ nowIso: new Date().toISOString(),
333
+ canPerp: opts.canPerp ?? true,
334
+ canSpot: opts.canSpot ?? false,
335
+ canWithdraw: opts.canWithdraw ?? false,
336
+ apiKeyIndex: chosenSlot,
337
+ });
338
+ if (useJson) {
339
+ printJson(jsonOk(result));
340
+ }
341
+ else {
342
+ console.log(chalk.green.bold("\n Lighter Agent approved successfully!\n"));
343
+ console.log(` Name: ${chalk.cyan(agentName)}`);
344
+ console.log(` API Key Index: ${chalk.cyan(String(result.apiKeyIndex))}`);
345
+ console.log(` Public Key: ${chalk.cyan(result.publicKey)}`);
346
+ console.log(` Account Index: ${chalk.cyan(String(result.accountIndex))}`);
347
+ console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
348
+ console.log();
349
+ }
350
+ }
351
+ finally {
352
+ releaseLock("lighter");
353
+ }
354
+ }
355
+ catch (err) {
356
+ reportErrorAndExit(err);
357
+ }
358
+ return;
359
+ }
360
+ // ── Pacifica approve branch (Phase 2c) ──────────────────────────────
361
+ if (exchangeNorm === "pacifica") {
362
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
363
+ const agentName = opts.agentName ?? "perp-cli-pac";
364
+ const expiresIn = opts.expiresIn ?? "90d";
365
+ let expiresAt;
366
+ try {
367
+ expiresAt = parseExpiresIn(expiresIn);
368
+ }
369
+ catch (e) {
370
+ const msg = e instanceof Error ? e.message : String(e);
371
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
372
+ remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
373
+ }));
374
+ }
375
+ if (!masterName) {
376
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
377
+ remediation: "Run: perp setup or use --master <name>",
378
+ }));
379
+ }
380
+ const passphrase = await resolvePassphrase({ flag: opts.passphrase });
381
+ if (passphrase === null) {
382
+ reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
383
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
384
+ }));
385
+ }
386
+ try {
387
+ acquireLock("pacifica");
388
+ try {
389
+ const result = await runPacApproveFlow({
390
+ masterName,
391
+ passphrase: passphrase ?? "",
392
+ agentName,
393
+ expiresAt,
394
+ expiresAtIso: expiresAt.toISOString(),
395
+ nowIso: new Date().toISOString(),
396
+ canPerp: opts.canPerp ?? true,
397
+ canSpot: opts.canSpot ?? false,
398
+ canWithdraw: opts.canWithdraw ?? false,
399
+ });
400
+ if (useJson) {
401
+ printJson(jsonOk(result));
402
+ }
403
+ else {
404
+ console.log(chalk.green.bold("\n Pacifica Agent approved successfully!\n"));
405
+ console.log(` Name: ${chalk.cyan(agentName)}`);
406
+ console.log(` Agent Address: ${chalk.cyan(result.agentSolanaAddress)}`);
407
+ console.log(` Master Address: ${chalk.cyan(result.userSolanaAddress)}`);
408
+ console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
409
+ console.log();
410
+ }
411
+ }
412
+ finally {
413
+ releaseLock("pacifica");
414
+ }
415
+ }
416
+ catch (err) {
417
+ reportErrorAndExit(err);
418
+ }
419
+ return;
420
+ }
421
+ // ── Hyperliquid approve branch ──────────────────────────────────────
422
+ if (exchangeNorm === "hyperliquid") {
423
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
424
+ const agentName = opts.agentName ?? "perp-cli-hl";
425
+ const expiresIn = opts.expiresIn ?? "90d";
426
+ let expiresAt;
427
+ try {
428
+ expiresAt = parseExpiresIn(expiresIn);
429
+ }
430
+ catch (e) {
431
+ const msg = e instanceof Error ? e.message : String(e);
432
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
433
+ remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
434
+ }));
435
+ }
436
+ if (!masterName) {
437
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
438
+ remediation: "Run: perp setup or use --master <name>",
439
+ }));
440
+ }
441
+ const passphrase = await resolvePassphrase({ flag: opts.passphrase });
442
+ if (passphrase === null) {
443
+ reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
444
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
445
+ }));
446
+ }
447
+ try {
448
+ acquireLock("hyperliquid");
449
+ try {
450
+ const result = await runHlApproveFlow({
451
+ masterName,
452
+ passphrase: passphrase ?? "",
453
+ agentName,
454
+ expiresAt,
455
+ expiresAtIso: expiresAt.toISOString(),
456
+ nowIso: new Date().toISOString(),
457
+ canPerp: opts.canPerp ?? true,
458
+ canSpot: opts.canSpot ?? false,
459
+ canWithdraw: opts.canWithdraw ?? false,
460
+ });
461
+ if (useJson) {
462
+ printJson(jsonOk(result));
463
+ }
464
+ else {
465
+ console.log(chalk.green.bold("\n Hyperliquid Agent approved successfully!\n"));
466
+ console.log(` Name: ${chalk.cyan(agentName)}`);
467
+ console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
468
+ console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
469
+ console.log();
470
+ }
471
+ }
472
+ finally {
473
+ releaseLock("hyperliquid");
474
+ }
475
+ }
476
+ catch (err) {
477
+ reportErrorAndExit(err);
478
+ }
479
+ return;
480
+ }
481
+ // Wizard mode: enter only when ALL THREE conditions hold
482
+ const wizardMode = opts.master === undefined &&
483
+ opts.agentName === undefined &&
484
+ opts.expiresIn === undefined &&
485
+ process.stdin.isTTY === true &&
486
+ process.env["OWS_PASSPHRASE"] === undefined;
487
+ let masterName;
488
+ let agentName;
489
+ let expiresIn;
490
+ let canPerp;
491
+ let canSpot;
492
+ let canWithdraw;
493
+ let passphrase;
494
+ if (wizardMode) {
495
+ const rl = createInterface({ input, output });
496
+ try {
497
+ const settings = loadSettings();
498
+ const defMaster = settings.owsActiveWallet || "main";
499
+ const rawMaster = await rl.question(`Master wallet name [${defMaster}]: `);
500
+ masterName = rawMaster.trim() || defMaster;
501
+ const rawAgent = await rl.question(`Agent name [perp-cli-aster]: `);
502
+ agentName = rawAgent.trim() || "perp-cli-aster";
503
+ const rawExpiry = await rl.question(`Expires-in [90d]: `);
504
+ expiresIn = rawExpiry.trim() || "90d";
505
+ const rawPerp = await rl.question(`Can perp trade? [Y/n]: `);
506
+ canPerp = rawPerp.trim().toLowerCase() !== "n";
507
+ const rawSpot = await rl.question(`Can spot trade? [y/N]: `);
508
+ canSpot = rawSpot.trim().toLowerCase() === "y";
509
+ const rawWithdraw = await rl.question(`Can withdraw? [y/N]: `);
510
+ canWithdraw = rawWithdraw.trim().toLowerCase() === "y";
511
+ // Hidden passphrase prompt — readline doesn't support hidden natively;
512
+ // we use muted output pattern compatible with node:readline/promises
513
+ process.stdout.write("Master passphrase: ");
514
+ const rawPp = await rl.question("");
515
+ passphrase = rawPp.trim() || null;
516
+ }
517
+ finally {
518
+ rl.close();
519
+ }
520
+ }
521
+ else {
522
+ masterName = opts.master ?? loadSettings().owsActiveWallet;
523
+ agentName = opts.agentName ?? "perp-cli-aster";
524
+ expiresIn = opts.expiresIn ?? "90d";
525
+ canPerp = opts.canPerp ?? true;
526
+ canSpot = opts.canSpot ?? false;
527
+ canWithdraw = opts.canWithdraw ?? false;
528
+ // 3-path passphrase resolver (non-TTY path)
529
+ passphrase = await resolvePassphrase({ flag: opts.passphrase });
530
+ if (passphrase === null) {
531
+ // TTY but non-wizard (some flags were supplied) — throw PASSPHRASE_REQUIRED
532
+ reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY or wizard skipped", {
533
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
534
+ }));
535
+ }
536
+ }
537
+ if (!masterName) {
538
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
539
+ remediation: "Run: perp setup or use --master <name>",
540
+ }));
541
+ }
542
+ // Parse expires-in
543
+ let expiresAt;
544
+ try {
545
+ expiresAt = parseExpiresIn(expiresIn);
546
+ }
547
+ catch (e) {
548
+ const msg = e instanceof Error ? e.message : String(e);
549
+ reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
550
+ remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
551
+ }));
552
+ }
553
+ const expiredMs = expiresAt.getTime();
554
+ const expiresAtIso = expiresAt.toISOString();
555
+ const nonceMicros = Date.now() * 1000 + (Math.floor(Math.random() * 1000));
556
+ const nowIso = new Date().toISOString();
557
+ const asterChain = "Mainnet";
558
+ try {
559
+ // Step 1: Acquire lock at the START of the flow (before any OWS resources
560
+ // are created) so that concurrent approves are fully serialized, not just
561
+ // the final settings write.
562
+ acquireLock("aster");
563
+ try {
564
+ const result = await runApproveFlow({
565
+ masterName,
566
+ passphrase: passphrase ?? "",
567
+ agentName,
568
+ expiresAt,
569
+ expiredMs,
570
+ expiresAtIso,
571
+ nonceMicros,
572
+ nowIso,
573
+ canPerp,
574
+ canSpot,
575
+ canWithdraw,
576
+ asterChain,
577
+ builder: opts.builder,
578
+ maxFeeRate: opts.maxFeeRate,
579
+ builderName: opts.builderName,
580
+ ipWhitelist: opts.ipWhitelist,
581
+ });
582
+ if (useJson) {
583
+ printJson(jsonOk(result));
584
+ }
585
+ else {
586
+ console.log(chalk.green.bold("\n Agent approved successfully!\n"));
587
+ console.log(` Name: ${chalk.cyan(agentName)}`);
588
+ console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
589
+ console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
590
+ console.log(` API Key ID: ${chalk.gray(result.owsApiKeyId)}`);
591
+ console.log(` Policy ID: ${chalk.gray(result.policyId)}`);
592
+ console.log();
593
+ }
594
+ }
595
+ finally {
596
+ releaseLock("aster");
597
+ }
598
+ }
599
+ catch (err) {
600
+ reportErrorAndExit(err);
601
+ }
89
602
  });
90
- // ── Top-level schema alias (hidden) ──
91
- const schemaAlias = program
92
- .command("schema")
93
- .description("Output CLI schema as JSON (alias for agent schema)");
94
- schemaAlias._hidden = true;
95
- schemaAlias
96
- .action(() => {
97
- const errorCodeDocs = {};
98
- for (const [key, val] of Object.entries(ERROR_CODES)) {
99
- errorCodeDocs[key] = { status: val.status, retryable: val.retryable, description: key.toLowerCase().replace(/_/g, " ") };
100
- }
101
- const envelope = {
102
- schemaVersion: "2.0",
103
- cliVersion: _pkg.version,
104
- generatedAt: new Date().toISOString(),
105
- exchanges: ["pacifica", "hyperliquid", "lighter", "aster"],
106
- errorCodes: errorCodeDocs,
107
- commands: program.commands
108
- .filter(c => c.name() !== "help" && c.name() !== "schema")
109
- .map(c => extractSchema(c)),
110
- };
111
- printJson(jsonOk(envelope));
603
+ // ── agent list [<exchange>] ──────────────────────────────────────────────
604
+ agent
605
+ .command("list [exchange]")
606
+ .description("List registered agent wallets")
607
+ .option("--json", "Machine-readable output")
608
+ .action((exchange, opts) => {
609
+ const useJson = opts.json ?? isJson();
610
+ const all = listAgents(exchange);
611
+ const now = Date.now();
612
+ const rows = all.map(({ exchange: ex, meta }) => {
613
+ let status;
614
+ if (meta.status === "partial") {
615
+ status = "partial";
616
+ }
617
+ else if (new Date(meta.expiresAt).getTime() < now) {
618
+ status = "expired";
619
+ }
620
+ else {
621
+ status = "active";
622
+ }
623
+ return {
624
+ name: meta.agentName,
625
+ exchange: ex,
626
+ evmAddress: meta.agentEvmAddress,
627
+ expiresAt: meta.expiresAt,
628
+ status,
629
+ permissions: meta.permissions,
630
+ };
631
+ });
632
+ if (useJson) {
633
+ printJson(jsonOk(rows));
634
+ return;
635
+ }
636
+ if (rows.length === 0) {
637
+ console.log("No agents registered. Run `perp wallet agent approve <exchange> ...` to create one.");
638
+ return;
639
+ }
640
+ console.log(chalk.cyan.bold("\n Registered Agent Wallets\n"));
641
+ const header = ["NAME", "EXCHANGE", "EVM ADDR", "EXPIRES", "STATUS", "PERP", "SPOT", "WITHDRAW"];
642
+ const colWidths = [20, 10, 14, 26, 9, 6, 6, 10];
643
+ console.log(" " + header.map((h, i) => chalk.bold(h.padEnd(colWidths[i]))).join(" "));
644
+ console.log(" " + header.map((_, i) => "─".repeat(colWidths[i])).join(" "));
645
+ for (const r of rows) {
646
+ const addr = r.evmAddress ? r.evmAddress.slice(0, 6) + "…" + r.evmAddress.slice(-4) : "";
647
+ const statusColor = r.status === "active" ? chalk.green : r.status === "expired" ? chalk.red : chalk.yellow;
648
+ const cols = [
649
+ r.name.slice(0, 19),
650
+ r.exchange,
651
+ addr,
652
+ new Date(r.expiresAt).toLocaleDateString(),
653
+ statusColor(r.status),
654
+ r.permissions.canPerpTrade ? chalk.green("Y") : chalk.gray("N"),
655
+ r.permissions.canSpotTrade ? chalk.green("Y") : chalk.gray("N"),
656
+ r.permissions.canWithdraw ? chalk.green("Y") : chalk.gray("N"),
657
+ ];
658
+ console.log(" " + cols.map((c, i) => String(c).padEnd(colWidths[i])).join(" "));
659
+ }
660
+ console.log();
112
661
  });
113
- // ── agent capabilities ── what this CLI can do
662
+ // ── agent revoke <exchange> <agentName> ─────────────────────────────────
114
663
  agent
115
- .command("capabilities")
116
- .description("List high-level capabilities for agent planning")
117
- .action(() => {
118
- printJson(jsonOk({
119
- name: "perp-cli",
120
- version: _pkg.version,
121
- description: "Multi-DEX Perpetual Futures CLI (Pacifica, Hyperliquid, Lighter, Aster) with HIP-3 deployed dex support",
122
- exchanges: ["pacifica", "hyperliquid", "lighter", "aster"],
123
- capabilities: [
124
- {
125
- category: "market_data",
126
- commands: [
127
- "perp market list --json",
128
- "perp market prices --json",
129
- "perp market book <symbol> --json",
130
- "perp market trades <symbol> --json",
131
- "perp market funding <symbol> --json",
132
- "perp market kline <symbol> <interval> --json",
133
- ],
134
- description: "Read market data: prices, orderbooks, trades, funding rates, candles",
135
- },
136
- {
137
- category: "account",
138
- commands: [
139
- "perp account balance --json",
140
- "perp account positions --json",
141
- "perp account orders --json",
142
- "perp account history --json",
143
- "perp account trades --json",
144
- "perp portfolio --json",
145
- ],
146
- description: "Read account state: balances, positions, open orders, trade history",
147
- },
148
- {
149
- category: "trading",
150
- commands: [
151
- "perp trade check <symbol> <side> <size> --json",
152
- "perp trade market <symbol> <buy|sell> <size> --json [--smart] --client-id <id>",
153
- "perp trade limit <symbol> <buy|sell> <price> <size> --json --client-id <id>",
154
- "perp trade cancel <symbol> <orderId> --json",
155
- "perp trade cancel-all --json",
156
- "perp trade close <symbol> --json [--smart]",
157
- "perp trade close-all --json [--smart]",
158
- "perp trade flatten --json [--smart]",
159
- "perp trade reduce <symbol> <percent> --json [--smart]",
160
- "perp trade stop <symbol> <side> <stopPrice> <size> --json",
161
- "perp trade tpsl <symbol> <side> --tp <price> --sl <price> --json",
162
- "perp trade twap <symbol> <side> <size> <duration> --json",
163
- ],
164
- description: "Execute trades with pre-flight validation, client IDs for idempotency, and position management",
165
- },
166
- {
167
- category: "plan_execution",
168
- commands: [
169
- "perp plan validate <file> --json",
170
- "perp plan execute <file> --json",
171
- "perp plan execute <file> --dry-run --json",
172
- "perp plan example",
173
- ],
174
- description: "Composite multi-step execution plans with abort/skip/rollback semantics",
175
- },
176
- {
177
- category: "arbitrage",
178
- commands: [
179
- "perp arb funding --json",
180
- "perp arb scan --min 5 --json",
181
- "perp arb scan --json",
182
- "perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --json",
183
- "perp arb status --json",
184
- "perp arb close <SYM> --json",
185
- "perp arb close <SYM> --dry-run --json",
186
- "perp arb close <SYM> --pair <longEx>:<shortEx> --json",
187
- "perp arb status --funding --json",
188
- "perp arb status --funding --period 30 --json",
189
- "perp arb history --json",
190
- "perp arb scan --hip3 --json",
191
- "perp arb monitor --hip3 --min 10",
192
- "perp gap show --json",
193
- ],
194
- description: "Cross-exchange funding arb: scan → exec → status → close. Plus cross-dex arb (--hip3) and price gaps.",
195
- },
196
- {
197
- category: "risk_management",
198
- commands: [
199
- "perp risk status --json",
200
- "perp risk limits --json",
201
- "perp risk check --notional <usd> --leverage <n> --json",
202
- ],
203
- description: "Portfolio risk assessment, exposure limits, pre-trade risk checks",
204
- },
205
- {
206
- category: "streaming",
207
- commands: [
208
- "perp stream events --interval 5000",
209
- "perp stream prices",
210
- "perp stream book <symbol>",
211
- "perp stream trades <symbol>",
212
- ],
213
- description: "Real-time NDJSON event stream: position changes, order fills, liquidation warnings, balance updates",
214
- },
215
- {
216
- category: "analytics",
217
- commands: [
218
- "perp analytics summary --json",
219
- "perp analytics pnl --period 30d --json",
220
- "perp analytics funding --period 30d --json",
221
- "perp analytics funding --period 7d --daily --json",
222
- "perp analytics compare --json",
223
- "perp portfolio --json",
224
- "perp health --json",
225
- "perp history list --json",
226
- ],
227
- description: "Cross-exchange portfolio, PnL analytics, execution history, health checks",
228
- },
229
- {
230
- category: "discovery",
231
- commands: [
232
- "perp schema",
233
- "perp agent schema",
234
- "perp agent capabilities",
235
- "perp agent ping",
236
- "perp dex list --json",
237
- "perp dex markets <name> --json",
238
- ],
239
- description: "CLI schema discovery, connectivity checks, HIP-3 dex discovery",
240
- },
241
- ],
242
- agentFeatures: [
243
- "Structured JSON output (--json) on all commands",
244
- "Structured error codes with retryable flag (INSUFFICIENT_BALANCE, RATE_LIMITED, etc.)",
245
- "Client order IDs for idempotent retries (--client-id / --auto-id)",
246
- "Pre-trade validation (perp trade check) before execution",
247
- "Multi-step execution plans with rollback (perp plan execute)",
248
- "NDJSON event streaming for real-time monitoring",
249
- "HIP-3 cross-dex arbitrage scanning",
250
- ],
251
- notes: [
252
- "Always use --json flag for machine-readable output",
253
- "Use -e <exchange> to switch between pacifica, hyperliquid, lighter, aster",
254
- "Use --dex <name> for HIP-3 deployed perp dexes on Hyperliquid",
255
- "Use -n testnet for testnet mode",
256
- "Use --client-id or --auto-id on trade commands for retry safety",
257
- "Use perp trade check before execution for pre-flight validation",
258
- "Errors include { code, retryable, status } for automated handling",
259
- "Exit code 0 = success, 1 = error",
260
- ],
261
- }));
664
+ .command("revoke <exchange> <agentName>")
665
+ .description("Revoke an agent wallet registration")
666
+ .option("--force", "Skip Aster POST, only clear local state")
667
+ .option("--passphrase <pp>", "Master OWS passphrase")
668
+ .option("--json", "Machine-readable output")
669
+ .action(async (exchange, agentName, opts) => {
670
+ const useJson = opts.json ?? isJson();
671
+ try {
672
+ // Step 1: Check local meta (idempotent — absent = already revoked)
673
+ const meta = getAgent(exchange, agentName);
674
+ if (!meta) {
675
+ const result = { ok: true, data: { alreadyRevoked: true } };
676
+ if (useJson) {
677
+ printJson(result);
678
+ }
679
+ else {
680
+ console.log(`Agent "${agentName}" on ${exchange} not found locally — already revoked.`);
681
+ }
682
+ return;
683
+ }
684
+ if (!opts.force) {
685
+ // Step 2: Resolve passphrase
686
+ const passphrase = await resolvePassphrase({ flag: opts.passphrase });
687
+ if (passphrase === null) {
688
+ throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
689
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
690
+ });
691
+ }
692
+ if (exchange === "hyperliquid") {
693
+ // HL revoke: POST approveAgent with zero-address (best-effort, idempotent)
694
+ const masterName = meta.masterWalletName;
695
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
696
+ try {
697
+ await revokeHlAgent(masterSigner);
698
+ }
699
+ catch { /* best effort — HL revoke is idempotent */ }
700
+ }
701
+ else if (exchange === "pacifica") {
702
+ // PAC revoke: signed unbind_agent_wallet with the agent base58 address
703
+ const masterName = meta.masterWalletName;
704
+ const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
705
+ try {
706
+ await revokePacAgent(solanaSigner, meta.agentSolanaAddress ?? "");
707
+ }
708
+ catch { /* best effort — Pacifica revoke is idempotent */ }
709
+ }
710
+ else if (exchange === "lighter" || exchange === "lt") {
711
+ // LT revoke: Lighter has no on-chain revoke action; revoke is local-only.
712
+ // Slots persist on the L2 server until overwritten by a fresh ChangePubKey
713
+ // at the same index. revokeLtAgent is a no-op stub that exists for
714
+ // symmetry with the other DEX revoke helpers.
715
+ try {
716
+ await revokeLtAgent();
717
+ }
718
+ catch { /* best effort — LT revoke is idempotent */ }
719
+ }
720
+ else {
721
+ // Aster revoke: Step 3: Build DelAgent EIP-712
722
+ const masterName = meta.masterWalletName;
723
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
724
+ const userEvmAddress = masterSigner.getAddress();
725
+ const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
726
+ const delTypedData = buildDelAgentTypedData({
727
+ user: userEvmAddress,
728
+ agentAddress: meta.agentEvmAddress,
729
+ nonceMicros,
730
+ asterChain: "Mainnet",
731
+ });
732
+ const signature = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
733
+ // Step 4: POST DELETE /fapi/v3/agent
734
+ const baseUrl = "https://fapi.asterdex.com";
735
+ const qsEntries = [
736
+ ["agentAddress", meta.agentEvmAddress],
737
+ ["asterChain", "Mainnet"],
738
+ ["user", userEvmAddress],
739
+ ["nonce", String(nonceMicros)],
740
+ ["signature", signature],
741
+ ["signatureChainId", "56"],
742
+ ];
743
+ const qs = new URLSearchParams(Object.fromEntries(qsEntries));
744
+ const revokeRes = await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
745
+ method: "DELETE",
746
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
747
+ body: "",
748
+ });
749
+ // Idempotent: 404 = already revoked server-side, treat as success
750
+ if (!revokeRes.ok && revokeRes.status !== 404) {
751
+ const errText = await revokeRes.text().catch(() => "");
752
+ throw new PerpError("EXCHANGE_ERROR", `Aster agent delete failed (${revokeRes.status}): ${errText.slice(0, 200)}`, {
753
+ remediation: "Use --force to skip Aster POST and only clear local state",
754
+ });
755
+ }
756
+ }
757
+ }
758
+ // Step 5: Revoke OWS API key (Aster only — HL has no OWS key)
759
+ if (meta.owsApiKeyId) {
760
+ try {
761
+ const ows = loadOws();
762
+ ows.revokeApiKey(meta.owsApiKeyId);
763
+ }
764
+ catch { /* best effort */ }
765
+ }
766
+ // Step 6: Delete local agent entry
767
+ deleteAgent(exchange, agentName);
768
+ if (useJson) {
769
+ printJson(jsonOk({ revoked: true }));
770
+ }
771
+ else {
772
+ console.log(chalk.green(`Agent "${agentName}" on ${exchange} revoked successfully.`));
773
+ }
774
+ }
775
+ catch (err) {
776
+ reportErrorAndExit(err);
777
+ }
262
778
  });
263
- // ── agent exec ── execute a sequence of commands
779
+ // ── agent rotate <exchange> [<oldAgentName>] ─────────────────────────────
264
780
  agent
265
- .command("exec")
266
- .description("Execute a command and return structured JSON result")
267
- .argument("<command...>", "Command to execute (e.g., 'market list')")
268
- .action(async (args) => {
269
- // Re-parse the command through the program
270
- // This is a convenience wrapper that forces --json
271
- const fullArgs = ["node", "perp", "--json", ...args];
781
+ .command("rotate <exchange> [oldAgentName]")
782
+ .description("Rotate an agent wallet: revoke existing, then approve with same name")
783
+ .option("--passphrase <pp>", "Master OWS passphrase")
784
+ .option("--master <name>", "Master OWS wallet name")
785
+ .option("--agent-name <name>", "Agent name to rotate (default: first registered agent)")
786
+ .option("--expires-in <duration>", "New expiry (default: 90d)")
787
+ .option("--can-perp", "Allow perp trading (default: inherit or on)")
788
+ .option("--no-can-perp", "Disallow perp trading")
789
+ .option("--can-spot", "Allow spot trading (default: inherit or off)")
790
+ .option("--no-can-spot", "Disallow spot trading")
791
+ .option("--can-withdraw", "Allow withdrawals (default: inherit or off)")
792
+ .option("--no-can-withdraw", "Disallow withdrawals")
793
+ .option("--json", "Machine-readable output")
794
+ .action(async (exchange, oldAgentNameArg, opts) => {
795
+ const useJson = opts.json ?? isJson();
272
796
  try {
273
- await program.parseAsync(fullArgs);
797
+ // Determine which agent to rotate
798
+ const agentNameToRotate = oldAgentNameArg ?? opts.agentName ?? getAgent(exchange)?.agentName;
799
+ if (!agentNameToRotate) {
800
+ throw new PerpError("AGENT_NOT_REGISTERED", `No agent found for ${exchange}`, {
801
+ remediation: `Run: perp wallet agent approve ${exchange} to create one`,
802
+ });
803
+ }
804
+ // Resolve passphrase early (needed for both revoke and approve phases)
805
+ const passphrase = await resolvePassphrase({ flag: opts.passphrase });
806
+ if (passphrase === null) {
807
+ throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
808
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
809
+ });
810
+ }
811
+ const settings = loadSettings();
812
+ const masterName = opts.master ?? settings.owsActiveWallet;
813
+ // Capture existing agent meta before revoke (for permission inheritance)
814
+ const existing = getAgent(exchange, agentNameToRotate);
815
+ // Resolve permissions: explicit flags > old agent's permissions > defaults.
816
+ // With --no-can-perp/spot/withdraw pattern, opts values are undefined when not passed.
817
+ const permFlagsProvided = opts.canPerp !== undefined || opts.canSpot !== undefined || opts.canWithdraw !== undefined;
818
+ let canPerp;
819
+ let canSpot;
820
+ let canWithdraw;
821
+ if (permFlagsProvided) {
822
+ canPerp = opts.canPerp ?? true;
823
+ canSpot = opts.canSpot ?? false;
824
+ canWithdraw = opts.canWithdraw ?? false;
825
+ }
826
+ else if (existing?.permissions) {
827
+ // Preserve old agent's permission set when flags are absent
828
+ canPerp = existing.permissions.canPerpTrade;
829
+ canSpot = existing.permissions.canSpotTrade;
830
+ canWithdraw = existing.permissions.canWithdraw;
831
+ }
832
+ else {
833
+ canPerp = true;
834
+ canSpot = false;
835
+ canWithdraw = false;
836
+ }
837
+ // Revoke existing (if present) — best effort
838
+ if (existing) {
839
+ try {
840
+ if (exchange === "pacifica") {
841
+ // Pacifica master is a Solana keypair; can't reuse the EVM master signer.
842
+ const revokePacSigner = OwsSolanaSigner.create(existing.masterWalletName, passphrase);
843
+ await revokePacAgent(revokePacSigner, existing.agentSolanaAddress ?? "");
844
+ if (existing.owsApiKeyId) {
845
+ try {
846
+ loadOws().revokeApiKey(existing.owsApiKeyId);
847
+ }
848
+ catch { /* best effort */ }
849
+ }
850
+ deleteAgent(exchange, agentNameToRotate);
851
+ // Continue to the shared approve flow below.
852
+ // (skip the remaining EVM-revoke branch via early continuation)
853
+ }
854
+ else if (exchange === "lighter" || exchange === "lt") {
855
+ // LT rotate: no on-chain revoke; just clear local entry. The new
856
+ // approve picks the next free slot, leaving the old slot orphaned
857
+ // on the L2 server (it cannot be used again until overwritten via
858
+ // a fresh ChangePubKey at that index — which the next approve does
859
+ // implicitly when it picks max+1).
860
+ await revokeLtAgent();
861
+ if (existing.owsApiKeyId) {
862
+ try {
863
+ loadOws().revokeApiKey(existing.owsApiKeyId);
864
+ }
865
+ catch { /* best effort */ }
866
+ }
867
+ deleteAgent(exchange, agentNameToRotate);
868
+ // Continue to the shared approve flow below.
869
+ }
870
+ else {
871
+ const revokeMs = OwsEvmSigner.create(existing.masterWalletName, passphrase);
872
+ if (exchange === "hyperliquid") {
873
+ // HL: revoke via approveAgent with zero-address
874
+ await revokeHlAgent(revokeMs);
875
+ }
876
+ else {
877
+ // Aster: DELETE /fapi/v3/agent
878
+ const userEvmAddress = revokeMs.getAddress();
879
+ const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
880
+ const delTypedData = buildDelAgentTypedData({
881
+ user: userEvmAddress,
882
+ agentAddress: existing.agentEvmAddress,
883
+ nonceMicros,
884
+ asterChain: "Mainnet",
885
+ });
886
+ const sig = await revokeMs.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
887
+ const baseUrl = "https://fapi.asterdex.com";
888
+ const qs = new URLSearchParams(Object.fromEntries([
889
+ ["agentAddress", existing.agentEvmAddress],
890
+ ["asterChain", "Mainnet"],
891
+ ["user", userEvmAddress],
892
+ ["nonce", String(nonceMicros)],
893
+ ["signature", sig],
894
+ ["signatureChainId", "56"],
895
+ ]));
896
+ await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
897
+ method: "DELETE",
898
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
899
+ body: "",
900
+ });
901
+ }
902
+ if (existing.owsApiKeyId) {
903
+ try {
904
+ loadOws().revokeApiKey(existing.owsApiKeyId);
905
+ }
906
+ catch { /* best effort */ }
907
+ }
908
+ deleteAgent(exchange, agentNameToRotate);
909
+ }
910
+ }
911
+ catch { /* best effort on revoke — continue to approve */ }
912
+ }
913
+ const expiresIn = opts.expiresIn ?? "90d";
914
+ const expiresAt = parseExpiresIn(expiresIn);
915
+ const expiresAtIso = expiresAt.toISOString();
916
+ const nowIso = new Date().toISOString();
917
+ // Acquire lock and run the shared approve flow
918
+ acquireLock(exchange);
919
+ try {
920
+ let rotatedAgentAddress;
921
+ if (exchange === "hyperliquid") {
922
+ const result = await runHlApproveFlow({
923
+ masterName,
924
+ passphrase,
925
+ agentName: agentNameToRotate,
926
+ expiresAt,
927
+ expiresAtIso,
928
+ nowIso,
929
+ canPerp,
930
+ canSpot,
931
+ canWithdraw,
932
+ });
933
+ rotatedAgentAddress = result.agentAddress;
934
+ }
935
+ else if (exchange === "pacifica") {
936
+ const result = await runPacApproveFlow({
937
+ masterName,
938
+ passphrase,
939
+ agentName: agentNameToRotate,
940
+ expiresAt,
941
+ expiresAtIso,
942
+ nowIso,
943
+ canPerp,
944
+ canSpot,
945
+ canWithdraw,
946
+ });
947
+ rotatedAgentAddress = result.agentSolanaAddress;
948
+ }
949
+ else if (exchange === "lighter" || exchange === "lt") {
950
+ const result = await runLtApproveFlow({
951
+ masterName,
952
+ passphrase,
953
+ agentName: agentNameToRotate,
954
+ expiresAt,
955
+ expiresAtIso,
956
+ nowIso,
957
+ canPerp,
958
+ canSpot,
959
+ canWithdraw,
960
+ // No apiKeyIndex override on rotate — picks next free slot.
961
+ });
962
+ rotatedAgentAddress = `slot=${result.apiKeyIndex} pubkey=${result.publicKey}`;
963
+ }
964
+ else {
965
+ const expiredMs = expiresAt.getTime();
966
+ const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
967
+ const result = await runApproveFlow({
968
+ masterName,
969
+ passphrase,
970
+ agentName: agentNameToRotate,
971
+ expiresAt,
972
+ expiredMs,
973
+ expiresAtIso,
974
+ nonceMicros,
975
+ nowIso,
976
+ canPerp,
977
+ canSpot,
978
+ canWithdraw,
979
+ asterChain: "Mainnet",
980
+ });
981
+ rotatedAgentAddress = result.agentAddress;
982
+ }
983
+ if (useJson) {
984
+ printJson(jsonOk({ rotated: true, agentName: agentNameToRotate, agentEvmAddress: rotatedAgentAddress, expiresAt: expiresAtIso }));
985
+ }
986
+ else {
987
+ console.log(chalk.green(`Agent "${agentNameToRotate}" on ${exchange} rotated successfully.`));
988
+ console.log(` New agent address: ${chalk.cyan(rotatedAgentAddress)}`);
989
+ console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
990
+ }
991
+ }
992
+ finally {
993
+ releaseLock(exchange);
994
+ }
274
995
  }
275
996
  catch (err) {
276
- const msg = err instanceof Error ? err.message : String(err);
277
- console.error(JSON.stringify(jsonError("EXEC_FAILED", msg)));
278
- process.exit(1);
997
+ reportErrorAndExit(err);
279
998
  }
280
999
  });
281
- // ── agent ping ── health check
1000
+ // ── agent verify [exchange] [agentName] ──────────────────────────────────
282
1001
  agent
283
- .command("ping")
284
- .description("Health check — returns exchange connectivity status")
285
- .action(async () => {
286
- const results = {
287
- timestamp: new Date().toISOString(),
288
- cli_version: _pkg.version,
289
- };
290
- // Check exchange APIs
291
- const { pingPacifica, pingHyperliquid } = await import("../shared-api.js");
292
- const [pacPing, hlPing] = await Promise.all([pingPacifica(), pingHyperliquid()]);
293
- results.pacifica = {
294
- status: pacPing.ok ? "ok" : "error",
295
- latency_ms: pacPing.latencyMs,
296
- http_status: pacPing.status,
297
- };
298
- results.hyperliquid = {
299
- status: hlPing.ok ? "ok" : "error",
300
- latency_ms: hlPing.latencyMs,
301
- http_status: hlPing.status,
1002
+ .command("verify [exchange] [agentName]")
1003
+ .description("Verify agent wallet registration via DEX query API")
1004
+ .option("--master <name>", "Master OWS wallet name (Aster/PAC). Defaults to settings.owsActiveWallet")
1005
+ .option("--master-address <addr>", "Master EVM address (HL only)")
1006
+ .option("--account-index <n>", "Account index (Lighter only)")
1007
+ .option("--passphrase <pp>", "Master OWS passphrase (Aster/PAC). Falls back to OWS_PASSPHRASE env.")
1008
+ .option("--json", "Machine-readable output")
1009
+ .action(async (exchange, agentName, opts) => {
1010
+ const useJson = opts.json ?? isJson();
1011
+ const ts = new Date().toISOString();
1012
+ // Resolve passphrase via 3-path: flag > env > (no stdin prompt in verify)
1013
+ const passphrase = opts.passphrase ?? process.env["OWS_PASSPHRASE"] ?? "";
1014
+ const verifyOpts = {
1015
+ agentName,
1016
+ master: opts.master,
1017
+ masterAddress: opts.masterAddress,
1018
+ accountIndex: opts.accountIndex,
1019
+ passphrase,
302
1020
  };
303
- // Check relayer
1021
+ if (!exchange) {
1022
+ // Aggregate mode: all 4 DEXs in parallel
1023
+ const [asterResult, hlResult, pacResult, ltResult] = await Promise.allSettled([
1024
+ verifyAster(verifyOpts),
1025
+ verifyHyperliquid(verifyOpts),
1026
+ verifyPacifica(verifyOpts),
1027
+ verifyLighter(verifyOpts),
1028
+ ]);
1029
+ function slotResult(r) {
1030
+ if (r.status === "fulfilled") {
1031
+ const slot = { registered: r.value.registered, count: r.value.count, items: r.value.items };
1032
+ if (r.value.warnings && r.value.warnings.length > 0)
1033
+ slot.warnings = r.value.warnings;
1034
+ return slot;
1035
+ }
1036
+ const err = r.reason instanceof PerpError ? r.reason : null;
1037
+ return {
1038
+ error: {
1039
+ code: err ? err.structured.code : "EXCHANGE_ERROR",
1040
+ message: err ? err.structured.message : (r.reason instanceof Error ? r.reason.message : String(r.reason)),
1041
+ remediation: err ? err.structured.remediation : undefined,
1042
+ },
1043
+ };
1044
+ }
1045
+ const data = {
1046
+ aster: slotResult(asterResult),
1047
+ hyperliquid: slotResult(hlResult),
1048
+ pacifica: slotResult(pacResult),
1049
+ lighter: slotResult(ltResult),
1050
+ };
1051
+ if (useJson) {
1052
+ printJson({ ok: true, data, meta: { timestamp: ts } });
1053
+ }
1054
+ else {
1055
+ console.log(chalk.bold("Agent Verify — All DEXs"));
1056
+ for (const [dex, slot] of Object.entries(data)) {
1057
+ if ("error" in slot) {
1058
+ console.log(chalk.yellow(` ${dex}: ERROR — ${slot.error.message}`));
1059
+ }
1060
+ else {
1061
+ const s = slot;
1062
+ const icon = s.registered ? chalk.green("✓") : chalk.gray("○");
1063
+ console.log(` ${icon} ${dex}: ${s.count} agent(s) registered`);
1064
+ for (const w of s.warnings ?? []) {
1065
+ console.log(chalk.yellow(` [warn] ${w}`));
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ return;
1071
+ }
1072
+ // Single-DEX mode
304
1073
  try {
305
- const start = Date.now();
306
- const res = await fetch("http://localhost:3100/health", {
307
- signal: AbortSignal.timeout(2000),
1074
+ let result;
1075
+ const ex = exchange.toLowerCase();
1076
+ if (ex === "aster") {
1077
+ result = await verifyAster(verifyOpts);
1078
+ }
1079
+ else if (ex === "hyperliquid" || ex === "hl") {
1080
+ result = await verifyHyperliquid(verifyOpts);
1081
+ }
1082
+ else if (ex === "pacifica" || ex === "pac") {
1083
+ result = await verifyPacifica(verifyOpts);
1084
+ }
1085
+ else if (ex === "lighter" || ex === "lt") {
1086
+ result = await verifyLighter(verifyOpts);
1087
+ }
1088
+ else {
1089
+ throw new PerpError("INVALID_PARAMS", `Unknown exchange "${exchange}". Use: aster, hyperliquid, pacifica, lighter`, {
1090
+ remediation: "perp wallet agent verify <aster|hyperliquid|pacifica|lighter> [agentName]",
1091
+ });
1092
+ }
1093
+ if (useJson) {
1094
+ printJson({
1095
+ ok: true,
1096
+ data: { exchange: ex, registered: result.registered, count: result.count, items: result.items },
1097
+ meta: { timestamp: ts, ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}) },
1098
+ });
1099
+ }
1100
+ else {
1101
+ const icon = result.registered ? chalk.green("✓") : chalk.gray("○");
1102
+ console.log(`${icon} ${chalk.bold(ex)}: ${result.count} agent(s) registered`);
1103
+ if (result.count > 0) {
1104
+ if (ex === "aster") {
1105
+ console.log(chalk.dim(" agentAddress | agentName | expired | canPerp | canSpot | canWithdraw"));
1106
+ for (const item of result.items) {
1107
+ const i = item;
1108
+ const addr = String(i["agentAddress"] ?? "").substring(0, 20) + "…";
1109
+ const name = String(i["agentName"] ?? "").padEnd(16);
1110
+ const exp = i["expired"] !== undefined ? String(i["expired"]) : "—";
1111
+ const cp = i["canPerpTrade"] ? "yes" : "no";
1112
+ const cs = i["canSpotTrade"] ? "yes" : "no";
1113
+ const cw = i["canWithdraw"] ? "yes" : "no";
1114
+ console.log(` ${addr} | ${name} | ${exp} | ${cp.padEnd(7)} | ${cs.padEnd(7)} | ${cw}`);
1115
+ }
1116
+ }
1117
+ else if (ex === "hyperliquid" || ex === "hl") {
1118
+ console.log(chalk.dim(" address | validUntil | name"));
1119
+ for (const item of result.items) {
1120
+ const i = item;
1121
+ const addr = String(i["address"] ?? "").substring(0, 20) + "…";
1122
+ const vu = i["validUntil"] !== undefined ? String(i["validUntil"]) : "—";
1123
+ const name = String(i["name"] ?? "");
1124
+ console.log(` ${addr} | ${vu.padEnd(11)} | ${name}`);
1125
+ }
1126
+ }
1127
+ else if (ex === "lighter" || ex === "lt") {
1128
+ console.log(chalk.dim(" api_key_index | public_key (truncated) | nonce | transaction_time"));
1129
+ for (const item of result.items) {
1130
+ const i = item;
1131
+ const idx = String(i["api_key_index"] ?? "");
1132
+ const pk = String(i["public_key"] ?? "").substring(0, 20) + "…";
1133
+ const nonce = String(i["nonce"] ?? "");
1134
+ const tt = String(i["transaction_time"] ?? "");
1135
+ console.log(` ${idx.padEnd(13)} | ${pk.padEnd(23)} | ${nonce.padEnd(5)} | ${tt}`);
1136
+ }
1137
+ }
1138
+ else if (ex === "pacifica" || ex === "pac") {
1139
+ console.log(chalk.dim(" api_key (truncated) | created_at"));
1140
+ for (const item of result.items) {
1141
+ const i = item;
1142
+ const key = String(i["api_key"] ?? "").substring(0, 20) + "…";
1143
+ const created = String(i["created_at"] ?? "");
1144
+ console.log(` ${key.padEnd(24)} | ${created}`);
1145
+ }
1146
+ }
1147
+ }
1148
+ if (result.warnings && result.warnings.length > 0) {
1149
+ for (const w of result.warnings) {
1150
+ process.stderr.write(chalk.yellow(`[warn] ${w}\n`));
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ catch (err) {
1156
+ reportErrorAndExit(err);
1157
+ }
1158
+ });
1159
+ }
1160
+ /**
1161
+ * Core approve flow: Steps 2-12 (OWS wallet + policy + api-key + EIP-712 + Aster POST + persist).
1162
+ * Caller MUST hold the exchange lock before calling this function.
1163
+ * The function is re-entrant safe with setAgent because callerHoldsLock() will
1164
+ * return true when called from within a locked context.
1165
+ */
1166
+ async function runApproveFlow(opts) {
1167
+ const { masterName, passphrase, agentName, expiredMs, expiresAtIso, nonceMicros, nowIso, canPerp, canSpot, canWithdraw, asterChain, } = opts;
1168
+ const baseUrl = "https://fapi.asterdex.com";
1169
+ let owsApiKeyId;
1170
+ let owsPolicyId;
1171
+ let agentEvmAddress;
1172
+ let agentWalletName;
1173
+ // Steps 2-3: Resolve master signer
1174
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1175
+ const userEvmAddress = masterSigner.getAddress();
1176
+ // Step 4: Generate agent OWS wallet
1177
+ const ows = loadOws();
1178
+ agentWalletName = `agent-aster-${masterName}`;
1179
+ const agentWallet = ows.createWallet(agentWalletName, passphrase);
1180
+ const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
1181
+ if (!agentEvmAccount) {
1182
+ throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no EVM account`, {
1183
+ remediation: "Check OWS vault configuration",
1184
+ });
1185
+ }
1186
+ agentEvmAddress = agentEvmAccount.address;
1187
+ // Step 5: Build OWS policy
1188
+ const policyId = `aster-perp-${Date.now()}`;
1189
+ owsPolicyId = policyId;
1190
+ ows.createPolicy(JSON.stringify({
1191
+ id: policyId,
1192
+ name: `perp-cli-aster-${agentName}`,
1193
+ version: 1,
1194
+ created_at: nowIso,
1195
+ rules: [{ type: "allowed_chains", chain_ids: ["eip155:56"] }],
1196
+ expires_at: expiresAtIso,
1197
+ executable: null,
1198
+ action: "deny",
1199
+ }));
1200
+ // Step 6: Build OWS API key — store only the id, never the token
1201
+ const apiKeyResult = ows.createApiKey(`api-aster-${agentName}`, [agentWallet.id], [policyId], passphrase, expiresAtIso);
1202
+ owsApiKeyId = apiKeyResult.id;
1203
+ // Step 7 (Optional): Builder approval BEFORE approveAgent
1204
+ if (opts.builder) {
1205
+ if (!opts.maxFeeRate) {
1206
+ throw new PerpError("INVALID_PARAMS", "--max-fee-rate is required when --builder is set", {
1207
+ remediation: "Add --max-fee-rate <bps> to your command",
308
1208
  });
309
- results.relayer = {
310
- status: res.ok ? "ok" : "error",
311
- latency_ms: Date.now() - start,
312
- };
313
1209
  }
314
- catch {
315
- results.relayer = { status: "offline" };
1210
+ const builderTypedData = buildApproveBuilderTypedData({
1211
+ builder: opts.builder,
1212
+ maxFeeRate: opts.maxFeeRate,
1213
+ builderName: opts.builderName,
1214
+ user: userEvmAddress,
1215
+ nonceMicros: nonceMicros - 1,
1216
+ asterChain,
1217
+ });
1218
+ const builderSig = await masterSigner.signTypedData(builderTypedData.domain, builderTypedData.types, builderTypedData.message);
1219
+ const builderQsEntries = [
1220
+ ["builder", opts.builder],
1221
+ ["maxFeeRate", opts.maxFeeRate],
1222
+ ...(opts.builderName !== undefined ? [["builderName", opts.builderName]] : []),
1223
+ ["asterChain", asterChain],
1224
+ ["user", userEvmAddress],
1225
+ ["nonce", String(nonceMicros - 1)],
1226
+ ["signature", builderSig],
1227
+ ["signatureChainId", "56"],
1228
+ ];
1229
+ const builderRes = await fetch(`${baseUrl}/fapi/v3/approveBuilder?${new URLSearchParams(Object.fromEntries(builderQsEntries)).toString()}`, {
1230
+ method: "POST",
1231
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1232
+ body: "",
1233
+ });
1234
+ if (!builderRes.ok) {
1235
+ const errText = await builderRes.text().catch(() => "");
1236
+ // Aster builder POST failed — rollback api key (no Aster agent registered yet)
1237
+ if (owsApiKeyId) {
1238
+ try {
1239
+ ows.revokeApiKey(owsApiKeyId);
1240
+ }
1241
+ catch { /* best effort */ }
1242
+ }
1243
+ throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed (${builderRes.status}): ${errText.slice(0, 200)}`, {
1244
+ remediation: "Check builder address and max-fee-rate parameters",
1245
+ });
316
1246
  }
317
- if (isJson())
318
- return printJson(jsonOk(results));
319
- console.log(chalk.cyan.bold("\n Connectivity Check\n"));
320
- for (const [name, data] of Object.entries(results)) {
321
- if (typeof data !== "object" || !data) {
322
- console.log(` ${name}: ${data}`);
323
- continue;
1247
+ const builderResp = await builderRes.json();
1248
+ const builderOk = builderResp.code === "000000" || builderResp.code === 200;
1249
+ if (!builderOk) {
1250
+ if (owsApiKeyId) {
1251
+ try {
1252
+ ows.revokeApiKey(owsApiKeyId);
1253
+ }
1254
+ catch { /* best effort */ }
324
1255
  }
325
- const d = data;
326
- const icon = d.status === "ok" ? chalk.green("OK") : chalk.red(String(d.status).toUpperCase());
327
- const latency = d.latency_ms ? chalk.gray(` (${d.latency_ms}ms)`) : "";
328
- console.log(` ${name.padEnd(14)} ${icon}${latency}`);
1256
+ const errMsg = builderResp.msg ?? builderResp.message ?? String(builderResp.code);
1257
+ throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed: ${errMsg}`, {
1258
+ remediation: "Check builder address and max-fee-rate parameters",
1259
+ });
329
1260
  }
330
- console.log();
1261
+ }
1262
+ // Step 8: Build EIP-712 ApproveAgent payload
1263
+ const approveTypedData = buildApproveAgentTypedData({
1264
+ user: userEvmAddress,
1265
+ agentAddress: agentEvmAddress,
1266
+ agentName,
1267
+ expiredMs,
1268
+ canPerpTrade: canPerp,
1269
+ canSpotTrade: canSpot,
1270
+ canWithdraw,
1271
+ ipWhitelist: opts.ipWhitelist,
1272
+ builder: opts.builder,
1273
+ maxFeeRate: opts.maxFeeRate,
1274
+ builderName: opts.builderName,
1275
+ nonceMicros,
1276
+ asterChain,
331
1277
  });
332
- // ── agent plan ── given a goal, suggest a command sequence
333
- agent
334
- .command("plan")
335
- .description("Suggest a command sequence for a given trading goal")
336
- .argument("<goal>", "Natural language goal (e.g., 'buy 0.1 BTC on pacifica')")
337
- .action(async (goal) => {
338
- const g = goal.toLowerCase();
339
- const steps = [];
340
- if (g.includes("buy") || g.includes("long")) {
341
- const symbol = extractSymbol(g) || "BTC";
342
- const size = extractNumber(g) || "0.01";
343
- steps.push({ step: 1, command: `perp market book ${symbol} --json`, description: `Check ${symbol} orderbook` }, { step: 2, command: `perp account balance --json`, description: "Check available balance" }, { step: 3, command: `perp trade market ${symbol} buy ${size} --json`, description: `Buy ${size} ${symbol}` }, { step: 4, command: `perp account positions --json`, description: "Verify position opened" });
344
- }
345
- else if (g.includes("sell") || g.includes("short")) {
346
- const symbol = extractSymbol(g) || "BTC";
347
- const size = extractNumber(g) || "0.01";
348
- steps.push({ step: 1, command: `perp market book ${symbol} --json`, description: `Check ${symbol} orderbook` }, { step: 2, command: `perp account balance --json`, description: "Check available balance" }, { step: 3, command: `perp trade market ${symbol} sell ${size} --json`, description: `Sell ${size} ${symbol}` }, { step: 4, command: `perp account positions --json`, description: "Verify position opened" });
349
- }
350
- else if (g.includes("close") || g.includes("exit")) {
351
- const symbol = extractSymbol(g) || "ALL";
352
- steps.push({ step: 1, command: "perp account positions --json", description: "Get current positions" }, { step: 2, command: `perp trade cancel-all --json`, description: "Cancel any open orders" });
353
- if (symbol !== "ALL") {
354
- steps.push({ step: 3, command: `perp trade market ${symbol} sell <position_size> --json`, description: `Close ${symbol} position (adjust side/size from step 1)` });
355
- }
356
- }
357
- else if (g.includes("arb") || g.includes("arbitrage") || g.includes("funding")) {
358
- steps.push({ step: 1, command: "perp arb scan --min 5 --json", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --dry-run --json", description: "Dry-run arb execution" });
359
- }
360
- else if (g.includes("status") || g.includes("check") || g.includes("overview")) {
361
- steps.push({ step: 1, command: "perp portfolio --json", description: "Balances + positions + risk" }, { step: 2, command: "perp account positions --json", description: "Detailed positions" }, { step: 3, command: "perp account orders --json", description: "Open orders" });
362
- }
363
- else if (g.includes("deposit")) {
364
- const amount = extractNumber(g) || "100";
365
- steps.push({ step: 1, command: "perp wallet balance --json", description: "Check wallet balance" }, { step: 2, command: `perp deposit pacifica ${amount} --json`, description: `Deposit $${amount} to Pacifica` }, { step: 3, command: "perp account balance --json", description: "Verify deposit arrived" });
366
- }
367
- else if (g.includes("price") || g.includes("market")) {
368
- const symbol = extractSymbol(g);
369
- if (symbol) {
370
- steps.push({ step: 1, command: `perp market book ${symbol} --json`, description: `${symbol} orderbook` }, { step: 2, command: `perp market funding ${symbol} --json`, description: `${symbol} funding history` }, { step: 3, command: `perp market kline ${symbol} 1h --json`, description: `${symbol} hourly candles` });
1278
+ // Step 9: Sign
1279
+ const signature = await masterSigner.signTypedData(approveTypedData.domain, approveTypedData.types, approveTypedData.message);
1280
+ // Step 10: Build query string
1281
+ const qsEntries = [
1282
+ ["agentName", agentName],
1283
+ ["agentAddress", agentEvmAddress],
1284
+ ];
1285
+ if (opts.ipWhitelist !== undefined && opts.ipWhitelist !== null) {
1286
+ qsEntries.push(["ipWhitelist", opts.ipWhitelist]);
1287
+ }
1288
+ qsEntries.push(["expired", String(expiredMs)], ["canSpotTrade", String(canSpot)], ["canPerpTrade", String(canPerp)], ["canWithdraw", String(canWithdraw)]);
1289
+ if (opts.builder !== undefined && opts.builder !== null) {
1290
+ qsEntries.push(["builder", opts.builder]);
1291
+ qsEntries.push(["maxFeeRate", opts.maxFeeRate ?? ""]);
1292
+ if (opts.builderName !== undefined) {
1293
+ qsEntries.push(["builderName", opts.builderName]);
1294
+ }
1295
+ }
1296
+ qsEntries.push(["asterChain", asterChain], ["user", userEvmAddress], ["nonce", String(nonceMicros)], ["signature", signature], ["signatureChainId", "56"]);
1297
+ // Step 11: POST /fapi/v3/approveAgent
1298
+ let asterPostSucceeded = false;
1299
+ const agentRes = await fetch(`${baseUrl}/fapi/v3/approveAgent?${new URLSearchParams(Object.fromEntries(qsEntries)).toString()}`, {
1300
+ method: "POST",
1301
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1302
+ body: "",
1303
+ });
1304
+ if (agentRes.ok) {
1305
+ const resp = await agentRes.json();
1306
+ if (resp.code === "000000" || resp.code === 200) {
1307
+ asterPostSucceeded = true;
1308
+ }
1309
+ else {
1310
+ // Aster POST body indicated failure — rollback api key (Aster did not register)
1311
+ if (owsApiKeyId) {
1312
+ try {
1313
+ ows.revokeApiKey(owsApiKeyId);
1314
+ }
1315
+ catch { /* best effort */ }
371
1316
  }
372
- else {
373
- steps.push({ step: 1, command: "perp market prices --json", description: "All market prices" }, { step: 2, command: "perp gap show --json", description: "Cross-exchange price gaps" });
1317
+ const errMsg = resp.msg ?? resp.message ?? String(resp.code);
1318
+ throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed: ${errMsg}`, {
1319
+ remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1320
+ });
1321
+ }
1322
+ }
1323
+ else {
1324
+ // HTTP error — rollback api key (Aster did not register)
1325
+ if (owsApiKeyId) {
1326
+ try {
1327
+ ows.revokeApiKey(owsApiKeyId);
1328
+ }
1329
+ catch { /* best effort */ }
1330
+ }
1331
+ const errText = await agentRes.text().catch(() => "");
1332
+ throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed (${agentRes.status}): ${errText.slice(0, 200)}`, {
1333
+ remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1334
+ });
1335
+ }
1336
+ // Step 12: Persist agent meta
1337
+ // Note: setAgent is re-entrant — if caller holds the lock, it writes directly.
1338
+ try {
1339
+ setAgent("aster", {
1340
+ agentName,
1341
+ agentWalletName,
1342
+ agentEvmAddress,
1343
+ userEvmAddress,
1344
+ masterWalletName: masterName,
1345
+ owsApiKeyId,
1346
+ owsPolicyId,
1347
+ expiresAt: expiresAtIso,
1348
+ approvedAt: nowIso,
1349
+ permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1350
+ asterApprovalNonce: String(nonceMicros),
1351
+ status: "active",
1352
+ });
1353
+ }
1354
+ catch (persistErr) {
1355
+ // Settings persist failed AFTER Aster registration succeeded.
1356
+ // CRITICAL-2 fix: attempt BOTH remote DELETE and local revokeApiKey.
1357
+ // Only if BOTH succeed → APPROVE_FAILED (clean). Otherwise → APPROVE_PARTIAL.
1358
+ let deleteOk = false;
1359
+ let revokeOk = false;
1360
+ // Attempt remote DELETE /fapi/v3/agent
1361
+ try {
1362
+ const delNonce = Date.now() * 1000 + Math.floor(Math.random() * 1000);
1363
+ const delTypedData = buildDelAgentTypedData({
1364
+ user: userEvmAddress,
1365
+ agentAddress: agentEvmAddress,
1366
+ nonceMicros: delNonce,
1367
+ asterChain,
1368
+ });
1369
+ const delSig = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
1370
+ const delQs = new URLSearchParams(Object.fromEntries([
1371
+ ["agentAddress", agentEvmAddress],
1372
+ ["asterChain", asterChain],
1373
+ ["user", userEvmAddress],
1374
+ ["nonce", String(delNonce)],
1375
+ ["signature", delSig],
1376
+ ["signatureChainId", "56"],
1377
+ ]));
1378
+ const delRes = await fetch(`${baseUrl}/fapi/v3/agent?${delQs.toString()}`, {
1379
+ method: "DELETE",
1380
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1381
+ body: "",
1382
+ });
1383
+ deleteOk = delRes.ok || delRes.status === 404;
1384
+ }
1385
+ catch { /* best effort */ }
1386
+ // Attempt local API key revocation
1387
+ if (owsApiKeyId) {
1388
+ try {
1389
+ ows.revokeApiKey(owsApiKeyId);
1390
+ revokeOk = true;
1391
+ }
1392
+ catch { /* best effort */ }
1393
+ }
1394
+ else {
1395
+ revokeOk = true; // nothing to revoke
1396
+ }
1397
+ if (deleteOk && revokeOk) {
1398
+ // Clean rollback — throw APPROVE_FAILED (not partial)
1399
+ throw new PerpError("APPROVE_FAILED", `Agent approve failed: settings persist error after clean rollback. ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
1400
+ remediation: `Both remote Aster agent and local OWS API key have been cleaned up. Retry: perp wallet agent approve aster`,
1401
+ });
1402
+ }
1403
+ // Partial state — at least one cleanup failed. Save partial record for recovery.
1404
+ const failedSteps = [];
1405
+ if (!deleteOk)
1406
+ failedSteps.push("Aster DELETE /fapi/v3/agent");
1407
+ if (!revokeOk)
1408
+ failedSteps.push("OWS revokeApiKey");
1409
+ try {
1410
+ setAgent("aster", {
1411
+ agentName,
1412
+ agentWalletName: agentWalletName ?? "",
1413
+ agentEvmAddress: agentEvmAddress,
1414
+ userEvmAddress,
1415
+ masterWalletName: masterName,
1416
+ owsApiKeyId: owsApiKeyId ?? "",
1417
+ owsPolicyId: owsPolicyId ?? "",
1418
+ expiresAt: expiresAtIso,
1419
+ approvedAt: nowIso,
1420
+ permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1421
+ asterApprovalNonce: String(nonceMicros),
1422
+ status: "partial",
1423
+ });
1424
+ }
1425
+ catch { /* best effort — already in a bad state */ }
1426
+ throw new PerpError("APPROVE_PARTIAL", `Agent registered on Aster but persist+cleanup failed. Failed steps: ${failedSteps.join(", ")}. Persist error: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
1427
+ remediation: `Aster agent address: ${agentEvmAddress}. Run: perp wallet agent approve aster --rotate to re-register`,
1428
+ });
1429
+ }
1430
+ return {
1431
+ agentAddress: agentEvmAddress,
1432
+ agentName,
1433
+ expiresAt: expiresAtIso,
1434
+ owsApiKeyId,
1435
+ policyId: owsPolicyId,
1436
+ asterApprovalNonce: String(nonceMicros),
1437
+ userEvmAddress,
1438
+ };
1439
+ }
1440
+ /**
1441
+ * Parse an expires-in string to a Date.
1442
+ * Accepts: "30d", "90d", "180d", "1y", or any ISO-8601 datetime string.
1443
+ * Throws if malformed.
1444
+ */
1445
+ function parseExpiresIn(value) {
1446
+ const v = value.trim();
1447
+ // Match Nd (days) or Ny (years)
1448
+ const daysMatch = v.match(/^(\d+)d$/i);
1449
+ if (daysMatch) {
1450
+ const days = parseInt(daysMatch[1], 10);
1451
+ return new Date(Date.now() + days * 24 * 3600 * 1000);
1452
+ }
1453
+ const yearsMatch = v.match(/^(\d+)y$/i);
1454
+ if (yearsMatch) {
1455
+ const years = parseInt(yearsMatch[1], 10);
1456
+ return new Date(Date.now() + years * 365 * 24 * 3600 * 1000);
1457
+ }
1458
+ // Try ISO-8601
1459
+ const d = new Date(v);
1460
+ if (!isNaN(d.getTime())) {
1461
+ return d;
1462
+ }
1463
+ throw new PerpError("INVALID_PARAMS", `Invalid expires-in value: "${v}". Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime`, {
1464
+ remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
1465
+ });
1466
+ }
1467
+ // ── Hyperliquid agent approve/revoke helpers (Phase 2b) ──────────────────────
1468
+ /** EIP-712 domain for HL user-signed actions (chainId 42161 = Arbitrum) */
1469
+ const HL_USER_SIGNED_DOMAIN = {
1470
+ name: "HyperliquidSignTransaction",
1471
+ version: "1",
1472
+ chainId: 42161,
1473
+ verifyingContract: "0x0000000000000000000000000000000000000000",
1474
+ };
1475
+ /** EIP-712 types for HyperliquidTransaction:ApproveAgent */
1476
+ const HL_APPROVE_AGENT_TYPES = {
1477
+ "HyperliquidTransaction:ApproveAgent": [
1478
+ { name: "hyperliquidChain", type: "string" },
1479
+ { name: "agentAddress", type: "address" },
1480
+ { name: "agentName", type: "string" },
1481
+ { name: "nonce", type: "uint64" },
1482
+ ],
1483
+ };
1484
+ /**
1485
+ * Core HL approve flow.
1486
+ * Caller MUST hold the "hyperliquid" lock before calling this function.
1487
+ *
1488
+ * Steps:
1489
+ * 1. Resolve master via OwsEvmSigner.create
1490
+ * 2. Generate agent OWS wallet (eip155-only, name `agent-hl-<masterName>`)
1491
+ * 3. Build HL approveAgent action + EIP-712 message
1492
+ * 4. Master signs EIP-712 (user-signed domain, chainId 42161)
1493
+ * 5. POST https://api.hyperliquid.xyz/exchange
1494
+ * 6. On success: persist AgentMeta to settings.agents.hyperliquid[name]
1495
+ */
1496
+ async function runHlApproveFlow(opts) {
1497
+ const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw } = opts;
1498
+ const { ethers } = await import("ethers");
1499
+ // Step 1: Resolve master signer
1500
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1501
+ const userEvmAddress = masterSigner.getAddress();
1502
+ // Step 2: Generate agent OWS wallet
1503
+ const ows = loadOws();
1504
+ const agentWalletName = `agent-hl-${masterName}`;
1505
+ // HL agent wallet created with empty passphrase. This is intentional:
1506
+ // (1) Tier 1 hot-path must be prompt-free (P1 from agent_first_design.md).
1507
+ // (2) OWS provides at-rest encryption regardless of per-wallet passphrase.
1508
+ // (3) Agent blast-radius is limited (perp-only, no withdraw, no master ops).
1509
+ // At runtime, src/index.ts opens this wallet with "" matching the create-time empty.
1510
+ const agentWallet = ows.createWallet(agentWalletName, "");
1511
+ const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
1512
+ if (!agentEvmAccount) {
1513
+ throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no EVM account`, {
1514
+ remediation: "Check OWS vault configuration",
1515
+ });
1516
+ }
1517
+ const agentEvmAddress = agentEvmAccount.address;
1518
+ // Step 3: Build nonce (monotonic ms)
1519
+ const now = Date.now();
1520
+ const nonce = now;
1521
+ const sigChainIdHex = "0xa4b1"; // 42161 hex
1522
+ const action = {
1523
+ type: "approveAgent",
1524
+ hyperliquidChain: "Mainnet",
1525
+ signatureChainId: sigChainIdHex,
1526
+ agentAddress: agentEvmAddress,
1527
+ agentName,
1528
+ nonce,
1529
+ };
1530
+ // Step 4: Master signs EIP-712 (user-signed domain)
1531
+ const message = {
1532
+ hyperliquidChain: "Mainnet",
1533
+ agentAddress: agentEvmAddress,
1534
+ agentName,
1535
+ nonce,
1536
+ };
1537
+ const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
1538
+ const parsed = ethers.Signature.from(sigRaw);
1539
+ const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1540
+ // Step 5: POST /exchange
1541
+ const baseUrl = "https://api.hyperliquid.xyz";
1542
+ const payload = { action, nonce, signature, vaultAddress: null };
1543
+ const agentRes = await fetch(`${baseUrl}/exchange`, {
1544
+ method: "POST",
1545
+ headers: { "Content-Type": "application/json" },
1546
+ body: JSON.stringify(payload),
1547
+ });
1548
+ const text = await agentRes.text();
1549
+ let resp;
1550
+ try {
1551
+ resp = JSON.parse(text);
1552
+ }
1553
+ catch {
1554
+ throw new PerpError("EXCHANGE_ERROR", `HL exchange API parse error (${agentRes.status}): ${text.slice(0, 200)}`, {
1555
+ remediation: "Check Hyperliquid API status",
1556
+ });
1557
+ }
1558
+ if (resp?.status === "err") {
1559
+ throw new PerpError("APPROVE_PARTIAL", `HL approveAgent failed: ${typeof resp.response === "string" ? resp.response : JSON.stringify(resp)}`, {
1560
+ remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1561
+ });
1562
+ }
1563
+ // Step 6: Persist
1564
+ setAgent("hyperliquid", {
1565
+ agentName,
1566
+ agentWalletName,
1567
+ agentEvmAddress,
1568
+ userEvmAddress,
1569
+ masterWalletName: masterName,
1570
+ owsApiKeyId: "", // HL has no OWS API key model
1571
+ owsPolicyId: "", // HL has no OWS Policy model
1572
+ expiresAt: expiresAtIso,
1573
+ approvedAt: nowIso,
1574
+ permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1575
+ asterApprovalNonce: String(nonce), // reused field: stores HL nonce for diagnostics
1576
+ status: "active",
1577
+ });
1578
+ return { agentAddress: agentEvmAddress, agentName, expiresAt: expiresAtIso, userEvmAddress };
1579
+ }
1580
+ /**
1581
+ * Post a HL revoke action for the given agent.
1582
+ * Uses approveAgent with agentAddress set to the zero address (empty = revoke).
1583
+ * Per HL convention: sending approveAgent with an empty/zero agentAddress
1584
+ * clears the agent slot.
1585
+ */
1586
+ async function revokeHlAgent(masterSigner) {
1587
+ const { ethers } = await import("ethers");
1588
+ const nonce = Date.now();
1589
+ const sigChainIdHex = "0xa4b1";
1590
+ // Zero-address revoke: approveAgent with empty agentAddress (validUntil:0 convention)
1591
+ const revokeAddress = "0x0000000000000000000000000000000000000000";
1592
+ const action = {
1593
+ type: "approveAgent",
1594
+ hyperliquidChain: "Mainnet",
1595
+ signatureChainId: sigChainIdHex,
1596
+ agentAddress: revokeAddress,
1597
+ agentName: "",
1598
+ nonce,
1599
+ };
1600
+ const message = {
1601
+ hyperliquidChain: "Mainnet",
1602
+ agentAddress: revokeAddress,
1603
+ agentName: "",
1604
+ nonce,
1605
+ };
1606
+ const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
1607
+ const parsed = ethers.Signature.from(sigRaw);
1608
+ const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1609
+ const payload = { action, nonce, signature, vaultAddress: null };
1610
+ await fetch("https://api.hyperliquid.xyz/exchange", {
1611
+ method: "POST",
1612
+ headers: { "Content-Type": "application/json" },
1613
+ body: JSON.stringify(payload),
1614
+ });
1615
+ // Revoke is best-effort — don't throw on HTTP errors
1616
+ }
1617
+ /**
1618
+ * Core Pacifica approve flow.
1619
+ * Caller MUST hold the "pacifica" lock before calling this function.
1620
+ *
1621
+ * Steps:
1622
+ * 1. Resolve master via OwsSolanaSigner.create
1623
+ * 2. Generate agent OWS wallet (`agent-pac-<masterName>`) with empty passphrase
1624
+ * 3. Build canonical bind_agent_wallet message + master Ed25519 signature
1625
+ * 4. POST https://api.pacifica.fi/api/v1/agent/bind
1626
+ * 5. On success: persist AgentMeta to settings.agents.pacifica[name]
1627
+ * 6. On failure: rollback agent OWS wallet + emit APPROVE_PARTIAL/APPROVE_FAILED
1628
+ *
1629
+ * Off-chain auth — no on-chain transaction, no gas required.
1630
+ */
1631
+ async function runPacApproveFlow(opts) {
1632
+ const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, } = opts;
1633
+ const { buildBindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
1634
+ // Step 1: Resolve master Solana signer
1635
+ const masterSigner = OwsSolanaSigner.create(masterName, passphrase);
1636
+ const userSolanaAddress = masterSigner.getPublicKeyBase58();
1637
+ // Step 2: Generate agent OWS wallet (empty passphrase mirrors HL pattern;
1638
+ // the runtime opens with "" so the hot-path is prompt-free).
1639
+ const ows = loadOws();
1640
+ const agentWalletName = `agent-pac-${masterName}`;
1641
+ const agentWallet = ows.createWallet(agentWalletName, "");
1642
+ const agentSolAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("solana:"));
1643
+ if (!agentSolAccount) {
1644
+ throw new PerpError("INVALID_PARAMS", `Agent wallet "${agentWalletName}" has no Solana account`, {
1645
+ remediation: "Check OWS vault configuration",
1646
+ });
1647
+ }
1648
+ const agentSolanaAddress = agentSolAccount.address;
1649
+ // Step 3: Build canonical bind_agent_wallet payload + Ed25519 signature
1650
+ const bind = buildBindAgentMessage({
1651
+ account: userSolanaAddress,
1652
+ agentWallet: agentSolanaAddress,
1653
+ });
1654
+ const msgBytes = new TextEncoder().encode(bind.canonicalJson);
1655
+ const sigBytes = await masterSigner.signMessage(msgBytes);
1656
+ const signature = bs58.encode(sigBytes);
1657
+ // Step 4: POST /api/v1/agent/bind
1658
+ // FIXME(2c-spike): unverified — confirm against live mainnet. Pacifica's
1659
+ // public API documentation does not enumerate `agent/bind` explicitly; the
1660
+ // shape below mirrors `buildSignedRequest()` (account+signature+timestamp+
1661
+ // expiry_window+payload-flattened) which IS the documented Pacifica REST
1662
+ // signing pattern. See HypurrQuant_FE PacificaPerpAdapter.ts for the
1663
+ // production reference once available.
1664
+ const requestBody = {
1665
+ account: userSolanaAddress,
1666
+ signature,
1667
+ timestamp: bind.header.timestamp,
1668
+ expiry_window: bind.header.expiry_window,
1669
+ type: bind.header.type,
1670
+ ...bind.payload, // adds agent_wallet
1671
+ };
1672
+ let pacPostSucceeded = false;
1673
+ let resp;
1674
+ try {
1675
+ const httpRes = await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
1676
+ method: "POST",
1677
+ headers: { "Content-Type": "application/json" },
1678
+ body: JSON.stringify(requestBody),
1679
+ });
1680
+ if (httpRes.ok) {
1681
+ resp = await httpRes.json();
1682
+ // Pacifica REST envelope: success === true OR explicit data field
1683
+ if (resp.success === false || resp.error) {
1684
+ const errMsg = typeof resp.error === "string" ? resp.error : JSON.stringify(resp);
1685
+ throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed: ${errMsg}`, {
1686
+ remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1687
+ });
374
1688
  }
1689
+ pacPostSucceeded = true;
375
1690
  }
376
1691
  else {
377
- steps.push({ step: 1, command: "perp agent capabilities", description: "List all available capabilities" }, { step: 2, command: "perp portfolio --json", description: "Check account status" });
378
- }
379
- printJson(jsonOk({
380
- goal,
381
- exchange: "pacifica",
382
- steps,
383
- notes: [
384
- "All commands should include --json for structured output",
385
- "Adjust exchange with -e hyperliquid if needed",
386
- "Check return values before proceeding to next step",
387
- ],
388
- }));
1692
+ const errText = await httpRes.text().catch(() => "");
1693
+ throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed (${httpRes.status}): ${errText.slice(0, 200)}`, {
1694
+ remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1695
+ });
1696
+ }
1697
+ }
1698
+ catch (httpErr) {
1699
+ if (httpErr instanceof PerpError)
1700
+ throw httpErr;
1701
+ throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet network error: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}`, {
1702
+ remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1703
+ });
1704
+ }
1705
+ // Step 5: Persist
1706
+ // PAC has no OWS API key/Policy model — leave those as empty strings.
1707
+ // agentEvmAddress is set to the zero address; the canonical address lives
1708
+ // in agentSolanaAddress.
1709
+ try {
1710
+ setAgent("pacifica", {
1711
+ agentName,
1712
+ agentWalletName,
1713
+ agentEvmAddress: "0x0000000000000000000000000000000000000000",
1714
+ userEvmAddress: "0x0000000000000000000000000000000000000000",
1715
+ agentSolanaAddress,
1716
+ userSolanaAddress,
1717
+ masterWalletName: masterName,
1718
+ owsApiKeyId: "",
1719
+ owsPolicyId: "",
1720
+ expiresAt: expiresAtIso,
1721
+ approvedAt: nowIso,
1722
+ permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1723
+ asterApprovalNonce: String(bind.header.timestamp),
1724
+ status: "active",
1725
+ });
1726
+ }
1727
+ catch (persistErr) {
1728
+ // Settings persist failed AFTER Pacifica registration succeeded.
1729
+ // Best-effort revoke + APPROVE_PARTIAL.
1730
+ if (pacPostSucceeded) {
1731
+ try {
1732
+ await revokePacAgent(masterSigner, agentSolanaAddress);
1733
+ }
1734
+ catch { /* best effort */ }
1735
+ }
1736
+ throw new PerpError("APPROVE_PARTIAL", `Pacifica agent registered but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
1737
+ remediation: `Pacifica agent address: ${agentSolanaAddress}. Run: perp wallet agent approve pacifica --rotate to re-register.`,
1738
+ });
1739
+ }
1740
+ return {
1741
+ agentSolanaAddress,
1742
+ userSolanaAddress,
1743
+ agentName,
1744
+ expiresAt: expiresAtIso,
1745
+ };
1746
+ }
1747
+ /**
1748
+ * Revoke a Pacifica agent wallet (best-effort, idempotent).
1749
+ * Sends signed unbind_agent_wallet to /api/v1/agent/bind. Pacifica accepts
1750
+ * either a specific agent base58 or an empty string (revoke all) — we pass
1751
+ * the specific agent address when available.
1752
+ */
1753
+ async function revokePacAgent(masterSigner, agentSolanaAddress) {
1754
+ const { buildUnbindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
1755
+ const userSolanaAddress = masterSigner.getPublicKeyBase58();
1756
+ const unbind = buildUnbindAgentMessage({
1757
+ account: userSolanaAddress,
1758
+ agentWallet: agentSolanaAddress,
1759
+ });
1760
+ const msgBytes = new TextEncoder().encode(unbind.canonicalJson);
1761
+ const sigBytes = await masterSigner.signMessage(msgBytes);
1762
+ const signature = bs58.encode(sigBytes);
1763
+ const requestBody = {
1764
+ account: userSolanaAddress,
1765
+ signature,
1766
+ timestamp: unbind.header.timestamp,
1767
+ expiry_window: unbind.header.expiry_window,
1768
+ type: unbind.header.type,
1769
+ ...unbind.payload,
1770
+ };
1771
+ // FIXME(2c-spike): unverified endpoint — Pacifica may expose a separate
1772
+ // `/api/v1/agent/unbind` or accept unbind via the same /agent/bind. We POST
1773
+ // to /agent/bind because the action type discriminates server-side per the
1774
+ // signing convention. Confirm against live mainnet.
1775
+ await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
1776
+ method: "POST",
1777
+ headers: { "Content-Type": "application/json" },
1778
+ body: JSON.stringify(requestBody),
1779
+ });
1780
+ // Revoke is best-effort — don't throw on HTTP errors
1781
+ }
1782
+ /**
1783
+ * Pick the next free Lighter slot in [4, 254].
1784
+ *
1785
+ * Strategy: scan settings.agents.lighter; pick `max(usedSlots) + 1` capped to
1786
+ * 254. If none used, default to 4. If 4 itself is used (env-driven auto-setup
1787
+ * default), advance to next.
1788
+ */
1789
+ function pickNextFreeLtSlot(settings) {
1790
+ const ltMap = settings.agents?.lighter ?? {};
1791
+ const used = new Set();
1792
+ for (const meta of Object.values(ltMap)) {
1793
+ if (typeof meta.apiKeyIndex === "number")
1794
+ used.add(meta.apiKeyIndex);
1795
+ }
1796
+ // Slot 4 is the default Lighter env-key auto-setup index — treat as used so
1797
+ // agent-managed slots don't collide with the env-driven legacy path.
1798
+ used.add(4);
1799
+ for (let i = 5; i <= 254; i++) {
1800
+ if (!used.has(i))
1801
+ return i;
1802
+ }
1803
+ throw new PerpError("INVALID_PARAMS", "All Lighter slots [4, 254] are in use", {
1804
+ remediation: "Revoke an existing agent: perp wallet agent revoke lighter <name>",
389
1805
  });
390
1806
  }
391
- function extractSymbol(text) {
392
- const symbols = ["BTC", "ETH", "SOL", "ARB", "DOGE", "WIF", "JTO", "PYTH", "JUP", "ONDO", "SUI", "APT", "AVAX", "LINK", "OP", "MATIC", "NEAR", "AAVE", "UNI", "TIA"];
393
- const upper = text.toUpperCase();
394
- for (const s of symbols) {
395
- if (upper.includes(s))
396
- return s;
397
- }
398
- return null;
1807
+ /**
1808
+ * Core Lighter approve flow.
1809
+ * Caller MUST hold the "lighter" lock before calling this function.
1810
+ *
1811
+ * Steps:
1812
+ * 1. Resolve master via OwsEvmSigner.create
1813
+ * 2. Pick free slot in [4, 254] (or use explicit apiKeyIndex when supplied)
1814
+ * 3. Spin up a temporary LighterAdapter bound to the master signer; init() to
1815
+ * resolve accountIndex from L1 EVM address
1816
+ * 4. Call LighterAdapter.setupApiKey(slot) — generates secp256k1 keypair via
1817
+ * WASM generateAPIKey, signs L1 EIP-712 + L2 ChangePubKey, POSTs sendTx
1818
+ * 5. On success: create agent OWS wallet (empty passphrase) to securely store
1819
+ * the agent secp256k1 private key for hot-path reload at trade time
1820
+ * 6. Persist AgentMeta with apiKeyIndex/publicKey/accountIndex to settings
1821
+ * 7. On failure: emit APPROVE_PARTIAL/APPROVE_FAILED. Lighter has no on-chain
1822
+ * rollback — the slot stays orphaned on the L2 server until overwritten.
1823
+ *
1824
+ * Off-chain auth — no on-chain BNB/ETH gas required.
1825
+ */
1826
+ async function runLtApproveFlow(opts) {
1827
+ const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, apiKeyIndex: explicitSlot, } = opts;
1828
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1829
+ // Step 1: Resolve master EVM signer (Lighter L1 ChangePubKey is EVM EIP-712 + EIP-191).
1830
+ const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1831
+ const userEvmAddress = masterSigner.getAddress();
1832
+ // Step 2: Pick slot.
1833
+ const settings = loadSettings();
1834
+ let chosenSlot;
1835
+ if (explicitSlot !== undefined) {
1836
+ // Validate range (defensive — caller should have already checked, but the
1837
+ // helper is also used from rotate where no validation runs upstream).
1838
+ if (!Number.isInteger(explicitSlot) || explicitSlot < 4 || explicitSlot > 254) {
1839
+ throw new PerpError("INVALID_PARAMS", `apiKeyIndex must be an integer in [4, 254]; got ${explicitSlot}`, {
1840
+ remediation: "Slots 0-3 are reserved by the Lighter frontend.",
1841
+ });
1842
+ }
1843
+ chosenSlot = explicitSlot;
1844
+ }
1845
+ else {
1846
+ chosenSlot = pickNextFreeLtSlot(settings);
1847
+ }
1848
+ // Step 3: Spin up a LighterAdapter bound to the master EVM signer.
1849
+ // Constructor takes evmKey (empty since we'll inject signer); init() resolves
1850
+ // accountIndex from the L1 EVM address via REST.
1851
+ const adapter = new LighterAdapter("", false);
1852
+ adapter.setSigner(masterSigner);
1853
+ await adapter.init();
1854
+ if (adapter.accountIndex < 0) {
1855
+ throw new PerpError("INVALID_PARAMS", `No Lighter account found for master EVM address ${userEvmAddress}`, {
1856
+ remediation: "Deposit USDC to the master address on Lighter to create an account, then retry.",
1857
+ });
1858
+ }
1859
+ // Step 4: Generate keypair + ChangePubKey via existing production infra.
1860
+ // setupApiKey already does:
1861
+ // - generateAPIKey() (WASM)
1862
+ // - createClient with new key
1863
+ // - signChangePubKey
1864
+ // - signMessage(messageToSign) via EVM signer (EIP-191)
1865
+ // - append L1Sig to txInfo
1866
+ // - POST /api/v1/sendTx with retry on invalid-nonce
1867
+ let registered;
1868
+ try {
1869
+ registered = await adapter.setupApiKey(chosenSlot);
1870
+ }
1871
+ catch (e) {
1872
+ const msg = e instanceof Error ? e.message : String(e);
1873
+ throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey failed: ${msg}`, {
1874
+ remediation: `Slot ${chosenSlot} for cleanup: this slot may be partially registered on the L2 server. Retry with a different --api-key-index, or rotate.`,
1875
+ });
1876
+ }
1877
+ // Step 5: Persist the agent's 40-byte L2 private key (SSOT Rule #3 —
1878
+ // encrypted at-rest in `~/.perp/lighter-agents/<accountIndex>-<slot>.json`).
1879
+ //
1880
+ // Lighter's L2 secp256k1 key is a non-OWS-native curve, so it cannot live in
1881
+ // `~/.ows/wallets/`. The keystore module mirrors OWS's AES-256-GCM + scrypt
1882
+ // scheme and uses an empty passphrase — same threat model as HL/PAC agents
1883
+ // (file mode 0600 + obfuscation, NOT cryptographic strength against a local
1884
+ // attacker). `accountIndex` and `apiKeyIndex` already live in
1885
+ // settings.agents.lighter[name], so they don't need to be duplicated in env.
1886
+ const agentWalletName = `agent-lt-${masterName}`;
1887
+ void agentWalletName; // wallet bookkeeping only — not used to derive the L2 key
1888
+ const { saveLighterKey } = await import("../agent-wallet/lighter-keystore.js");
1889
+ saveLighterKey(adapter.accountIndex, chosenSlot, registered.privateKey, "");
1890
+ // Step 6: Persist
1891
+ try {
1892
+ setAgent("lighter", {
1893
+ agentName,
1894
+ agentWalletName,
1895
+ // Lighter agent's identity on the L2 is publicKey + apiKeyIndex.
1896
+ // We store the master EVM address as agentEvmAddress for diagnostic
1897
+ // continuity (Aster/HL strict typing); the agent's real identity is
1898
+ // captured in the new optional fields.
1899
+ agentEvmAddress: userEvmAddress,
1900
+ userEvmAddress,
1901
+ apiKeyIndex: chosenSlot,
1902
+ publicKey: registered.publicKey.replace(/^0x/, ""),
1903
+ accountIndex: adapter.accountIndex,
1904
+ masterWalletName: masterName,
1905
+ owsApiKeyId: "", // Lighter has no OWS API key model
1906
+ owsPolicyId: "", // Lighter has no OWS Policy model
1907
+ expiresAt: expiresAtIso,
1908
+ approvedAt: nowIso,
1909
+ permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1910
+ asterApprovalNonce: String(Date.now()), // reused field: not used by LT
1911
+ status: "active",
1912
+ });
1913
+ }
1914
+ catch (persistErr) {
1915
+ throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey succeeded but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
1916
+ remediation: `Lighter slot ${chosenSlot} is registered on the L2 server. Manually update settings.agents.lighter or re-run: perp wallet agent approve lighter --api-key-index <other slot>.`,
1917
+ });
1918
+ }
1919
+ return {
1920
+ apiKeyIndex: chosenSlot,
1921
+ publicKey: registered.publicKey.replace(/^0x/, ""),
1922
+ accountIndex: adapter.accountIndex,
1923
+ agentName,
1924
+ expiresAt: expiresAtIso,
1925
+ userEvmAddress,
1926
+ };
399
1927
  }
400
- function extractNumber(text) {
401
- const match = text.match(/(\d+\.?\d*)/);
402
- return match ? match[1] : null;
1928
+ /**
1929
+ * Revoke a Lighter agent (local-only, idempotent).
1930
+ *
1931
+ * Lighter's L2 has no on-chain revoke action — slots persist on the server
1932
+ * until overwritten by a fresh ChangePubKey at the same index. This helper
1933
+ * is a no-op stub that exists for symmetry with the other DEX revoke helpers
1934
+ * (revokeHlAgent, revokePacAgent) so the dispatch reads consistently. Caller
1935
+ * is responsible for `deleteAgent("lighter", name)` to clear local state.
1936
+ */
1937
+ async function revokeLtAgent() {
1938
+ // No-op. See AC-35: Lighter revoke is local-only.
1939
+ return;
403
1940
  }