perp-cli 0.12.17 → 0.14.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 (363) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +303 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +357 -459
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1386 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,17 +1,39 @@
1
+ import { Option } from "commander";
2
+ import { networkOf } from "../cli-flags.js";
1
3
  import { createRequire } from "node:module";
2
4
  import { createInterface } from "node:readline/promises";
3
5
  import { stdin as input, stdout as output } from "node:process";
4
6
  import { printJson, jsonOk } from "../utils.js";
5
- import { PerpError } from "../errors.js";
7
+ import { PerpError, classifyError } from "../errors.js";
6
8
  import chalk from "chalk";
7
9
  import bs58 from "bs58";
8
10
  import { loadSettings } from "../settings.js";
9
- import { getAgent, setAgent, deleteAgent, listAgents, acquireLock, releaseLock } from "../agent-wallet/store.js";
11
+ import { getAgent, setAgent, deleteAgent, listAgents, acquireLock, releaseLock, getVenueAgent, getVenueAgentForNetwork, setVenueAgent, deleteVenueAgent, listVenueAgents, agentNetworkOf, recordedAgentNetworkOf, assertAgentNetwork, getAgentForNetwork, agentSourceOf, } from "../agent-wallet/store.js";
12
+ import { getVenue, networkConfig, resolveVenueId } from "../exchanges/venue.js";
13
+ import { listConfiguredAccountVenues } from "../config.js";
14
+ import "../exchanges/venues/index.js";
15
+ import { LIGHTER_RH_RESERVED_KEY_INDEXES } from "../exchanges/venues/lighter-family.js";
16
+ import { HL_USER_SIGNED } from "../exchanges/venues/hyperliquid.js";
17
+ import { unsupported } from "../exchanges/trading-v2.js";
10
18
  import { resolvePassphrase } from "../agent-wallet/passphrase.js";
11
19
  import { OwsEvmSigner } from "../signer/ows-evm.js";
12
20
  import { OwsSolanaSigner } from "../signer/ows-solana.js";
13
21
  import { loadOws } from "../signer/ows-loader.js";
14
- import { buildApproveAgentTypedData, buildDelAgentTypedData, buildApproveBuilderTypedData, } from "../exchanges/aster-typed-data.js";
22
+ import { ASTER_DOMAIN_A, buildApproveAgentTypedData, buildDelAgentTypedData, } from "../exchanges/aster-typed-data.js";
23
+ /**
24
+ * Subcommand `--passphrase <pp>`: still accepted (existing scripts) but deprecated like the root
25
+ * flag — hidden from help, and a stderr warning when it was given on the command line (it is
26
+ * visible in ps / shell history). Use with `.hook("preAction", warnDeprecatedPassphrase)`.
27
+ */
28
+ export function deprecatedPassphraseOption(what = "Master OWS passphrase", flags = "--passphrase <pp>") {
29
+ return new Option(flags, `DEPRECATED (visible in ps / shell history) — ${what}; prefer OWS_PASSPHRASE env or stdin`).hideHelp();
30
+ }
31
+ export const ARGV_PASSPHRASE_WARNING = "[warn] --passphrase on the command line is DEPRECATED and visible in ps / shell history. Use the OWS_PASSPHRASE env var or pipe it on stdin.\n";
32
+ /** preAction hook paired with deprecatedPassphraseOption(): warns once per command run. */
33
+ export function warnDeprecatedPassphrase(cmd) {
34
+ if (cmd.getOptionValueSource("passphrase") === "cli")
35
+ process.stderr.write(ARGV_PASSPHRASE_WARNING);
36
+ }
15
37
  /**
16
38
  * Emit a structured JSON error envelope to stdout and exit non-zero.
17
39
  * Stdout (not stderr) so machine consumers can read the envelope from a
@@ -19,12 +41,106 @@ import { buildApproveAgentTypedData, buildDelAgentTypedData, buildApproveBuilder
19
41
  */
20
42
  function reportErrorAndExit(err) {
21
43
  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 } };
44
+ // Route through the central classifier so generic Errors thrown from
45
+ // dependencies (e.g. NAPI "decryption failed: aead::Error" from OWS vault
46
+ // unlock) get a meaningful code + remediation instead of a bare UNKNOWN.
47
+ // PerpError instances pass through unchanged via classifyError's first
48
+ // branch so explicitly typed errors keep their structured shape.
49
+ const envelope = { ok: false, error: classifyError(err), meta: { timestamp: ts } };
25
50
  process.stdout.write(JSON.stringify(envelope) + "\n");
26
51
  process.exit(1);
27
52
  }
53
+ export function isLighterFamilyVenue(v) {
54
+ return getVenue(v).family === "lighter";
55
+ }
56
+ /** Validate an explicit Lighter-family API key slot: 4..254, minus venue-reserved indexes. */
57
+ export function assertLighterSlot(venue, n) {
58
+ if (!Number.isInteger(n) || n < 4 || n > 254) {
59
+ throw new PerpError("INVALID_PARAMS", `--api-key-index must be an integer in [4, 254]; got ${n}`, {
60
+ remediation: "Slots 0-3 are reserved by the Lighter frontend. Use 4-254.",
61
+ });
62
+ }
63
+ if (venue === "lighter-rh" && LIGHTER_RH_RESERVED_KEY_INDEXES.includes(n)) {
64
+ throw new PerpError("INVALID_PARAMS", `Lighter RH API key index ${n} is reserved (${LIGHTER_RH_RESERVED_KEY_INDEXES.join(", ")})`, {
65
+ remediation: "Use an index in 4-254 other than 157.",
66
+ });
67
+ }
68
+ }
69
+ /** Read one line from a TTY without echo (raw mode). */
70
+ export function promptHidden(question) {
71
+ return new Promise((resolve, reject) => {
72
+ const stdin = process.stdin;
73
+ process.stdout.write(question);
74
+ stdin.setRawMode(true);
75
+ stdin.resume();
76
+ stdin.setEncoding("utf8");
77
+ let buf = "";
78
+ const cleanup = () => {
79
+ stdin.off("data", onData);
80
+ stdin.setRawMode(false);
81
+ stdin.pause();
82
+ };
83
+ const onData = (chunk) => {
84
+ for (const c of chunk) {
85
+ if (c === "\r" || c === "\n") {
86
+ cleanup();
87
+ process.stdout.write("\n");
88
+ resolve(buf);
89
+ return;
90
+ }
91
+ if (c === "\u0003") {
92
+ cleanup();
93
+ reject(new PerpError("INVALID_PARAMS", "Cancelled"));
94
+ return;
95
+ }
96
+ if (c === "\u007f" || c === "\b") {
97
+ buf = buf.slice(0, -1);
98
+ continue;
99
+ }
100
+ buf += c;
101
+ }
102
+ };
103
+ stdin.on("data", onData);
104
+ });
105
+ }
106
+ /** Read all of a piped (non-TTY) stdin. */
107
+ function readPipedStdin() {
108
+ return new Promise((resolve, reject) => {
109
+ const chunks = [];
110
+ process.stdin.setEncoding("utf-8");
111
+ process.stdin.on("data", (c) => chunks.push(typeof c === "string" ? c : c.toString("utf-8")));
112
+ process.stdin.on("end", () => resolve(chunks.join("")));
113
+ process.stdin.on("error", reject);
114
+ });
115
+ }
116
+ /**
117
+ * Ondo API key id + secret, from stdin only (design §10.4 — never flags).
118
+ * TTY: key id prompt, then hidden secret prompt. Piped: line 1 = key id, line 2 = secret.
119
+ */
120
+ /** Lighter ChangePubKey settlement window when confirming a slot (docs example: 30 s, 1 s apart; kept under the 60 req/min budget). */
121
+ const LIGHTER_SLOT_CONFIRM_TIMEOUT_MS = 30_000;
122
+ const LIGHTER_SLOT_CONFIRM_POLL_MS = 3_000;
123
+ async function readOndoKeyFromStdin() {
124
+ if (process.stdin.isTTY === true) {
125
+ const rl = createInterface({ input, output });
126
+ let keyId;
127
+ try {
128
+ keyId = (await rl.question("Ondo API key id (ondoKeyId_…): ")).trim();
129
+ }
130
+ finally {
131
+ rl.close();
132
+ }
133
+ const secret = (await promptHidden("Ondo API secret (hidden): ")).trim();
134
+ return { keyId, secret };
135
+ }
136
+ const lines = (await readPipedStdin()).split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
137
+ if (lines.length !== 2) {
138
+ throw new PerpError("INVALID_PARAMS", `Expected 2 lines on stdin (key id, secret); got ${lines.length}`, {
139
+ remediation: "printf '%s\\n%s\\n' \"$KEY_ID\" \"$SECRET\" | perp wallet agent approve ondo",
140
+ });
141
+ }
142
+ return { keyId: lines[0], secret: lines[1] };
143
+ }
28
144
  const _require = createRequire(import.meta.url);
29
145
  const _pkg = _require("../../package.json");
30
146
  // ── Per-DEX verify implementations ───────────────────────────────────────
@@ -50,30 +166,34 @@ async function verifyAster(_opts) {
50
166
  * settings.agents.hyperliquid[name], asserts live response contains expected
51
167
  * agentEvmAddress. Mismatch → meta.warnings populated (non-fatal).
52
168
  */
53
- async function verifyHyperliquid(opts) {
54
- const settings = loadSettings();
55
- // Resolve master EVM address: flag > named agent's userEvmAddress > first registered agent
169
+ async function verifyHyperliquid(network, opts) {
170
+ // The named agent must belong to the selected network; otherwise use the
171
+ // first agent registered on it (a mainnet agent is never checked on testnet).
172
+ const namedMeta = opts.agentName !== undefined ? getAgent("hyperliquid", opts.agentName) : null;
173
+ if (namedMeta !== null)
174
+ assertAgentNetwork("hyperliquid", namedMeta, network);
175
+ // Resolve master EVM address: flag > named agent's userEvmAddress > first agent on this network
176
+ // > the selected OWS master wallet's EVM account
56
177
  let masterAddress = opts.masterAddress;
57
178
  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
- }
179
+ const meta = namedMeta ?? getAgentForNetwork("hyperliquid", network);
180
+ if (meta?.userEvmAddress)
181
+ masterAddress = meta.userEvmAddress;
69
182
  }
70
183
  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",
184
+ // No local agent record (e.g. right after a revoke): the selected OWS master wallet
185
+ // (--master, else the active wallet) names the account. Reading its address needs no passphrase.
186
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
187
+ if (masterName)
188
+ masterAddress = OwsEvmSigner.create(masterName).getAddress();
189
+ }
190
+ if (!masterAddress) {
191
+ throw new PerpError("INVALID_PARAMS", "Master EVM address required for Hyperliquid verify: no local agent record and no OWS master wallet is selected. Use --master-address.", {
192
+ remediation: "perp wallet agent verify hyperliquid --master-address 0xYOUR_ADDRESS (or select the master wallet: --master <walletName>)",
73
193
  });
74
194
  }
75
195
  const user = masterAddress.toLowerCase();
76
- const resp = await fetch("https://api.hyperliquid.xyz/info", {
196
+ const resp = await fetch(`${networkConfig(getVenue("hyperliquid"), network).restUrl}/info`, {
77
197
  method: "POST",
78
198
  headers: { "Content-Type": "application/json" },
79
199
  body: JSON.stringify({ type: "extraAgents", user }),
@@ -88,7 +208,7 @@ async function verifyHyperliquid(opts) {
88
208
  const warnings = [];
89
209
  // Cross-check: AC-28 — verify persisted agentEvmAddress matches live response
90
210
  if (opts.agentName) {
91
- const agentMeta = settings.agents?.hyperliquid?.[opts.agentName];
211
+ const agentMeta = namedMeta;
92
212
  if (agentMeta?.agentEvmAddress) {
93
213
  const expected = agentMeta.agentEvmAddress.toLowerCase();
94
214
  const found = items.some((item) => typeof item === "object" &&
@@ -104,67 +224,103 @@ async function verifyHyperliquid(opts) {
104
224
  return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
105
225
  }
106
226
  /**
107
- * Verify Pacifica agent registration.
227
+ * Verify Pacifica agent registration — POST /agent/list, master-signed.
108
228
  *
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.
229
+ * Mirrors the official SDK `list_agent_wallets` (pacifica-fi/python-sdk
230
+ * rest/api_agent_keys_detailed.py): the signed message nests an EMPTY payload
231
+ * under `data` ({"data":{},"expiry_window":…,"timestamp":…,"type":"list_agent_wallets"}),
232
+ * and the request body is `{account, agent_wallet: null, signature, timestamp,
233
+ * expiry_window}` — no `type` field. The earlier `list_api_keys` →
234
+ * /account/api_keys shape signed a header without `data` and was rejected
235
+ * live with "signature does not match signer and canonical payload".
236
+ *
237
+ * When an agent record exists for this network (named, or the first one),
238
+ * `registered` reflects whether ITS agent pubkey is in the live list.
114
239
  */
115
- async function verifyPacifica(opts) {
240
+ async function verifyPacifica(network, opts) {
116
241
  const settings = loadSettings();
117
242
  const masterName = opts.master ?? settings.owsActiveWallet;
118
243
  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",
244
+ // Pacifica lists agent bindings only via a master-signed request (no agent-signed read exists).
245
+ throw new PerpError("NO_SIGNER_AVAILABLE", "Pacifica agent verify is signed by the master wallet, and none is configured on this computer", {
246
+ remediation: "Check the agent on the Pacifica website (API keys), or add the master wallet and run: perp wallet agent verify pacifica --master <walletName>",
121
247
  });
122
248
  }
249
+ const namedMeta = opts.agentName !== undefined ? getAgent("pacifica", opts.agentName) : null;
250
+ if (namedMeta !== null)
251
+ assertAgentNetwork("pacifica", namedMeta, network);
252
+ const expectedMeta = namedMeta ?? getAgentForNetwork("pacifica", network);
253
+ const expectedAgent = expectedMeta?.agentSolanaAddress ?? null;
123
254
  const passphrase = opts.passphrase ?? "";
124
255
  const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
125
256
  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);
257
+ const { buildListAgentWalletsMessage } = await import("../exchanges/pacifica-typed-data.js");
258
+ const built = buildListAgentWalletsMessage();
259
+ const sigBytes = await solanaSigner.signMessage(new TextEncoder().encode(built.canonicalJson));
137
260
  const body = {
138
261
  account,
139
262
  agent_wallet: null,
140
- signature: sigBase58,
141
- timestamp,
142
- expiry_window: expiryWindow,
143
- type,
263
+ signature: bs58.encode(sigBytes),
264
+ timestamp: built.header.timestamp,
265
+ expiry_window: built.header.expiry_window,
144
266
  };
145
- const resp = await fetch("https://api.pacifica.fi/api/v1/account/api_keys", {
267
+ const resp = await fetch(`${networkConfig(getVenue("pacifica"), network).restUrl}/agent/list`, {
146
268
  method: "POST",
147
269
  headers: { "Content-Type": "application/json" },
148
270
  body: JSON.stringify(body),
149
271
  });
150
272
  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)}`, {
273
+ const text = await resp.text().catch(() => "");
274
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica verify failed: HTTP ${resp.status}: ${text.slice(0, 200)}`, {
153
275
  remediation: "Check master wallet name and passphrase. Run: perp wallet agent verify pacifica --master <name> --passphrase $PP",
154
276
  });
155
277
  }
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 : [];
278
+ const json = await resp.json();
279
+ const items = parsePacAgentList(json);
280
+ const listed = items.map(pacAgentPubkeyOf);
281
+ const warnings = [];
282
+ if (expectedAgent !== null && expectedMeta !== null) {
283
+ const found = listed.includes(expectedAgent);
284
+ if (!found) {
285
+ warnings.push(`Agent "${expectedMeta.agentName}" (${expectedAgent}) is not bound to ${account} on Pacifica ${network}.`);
286
+ }
287
+ return { registered: found, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
288
+ }
166
289
  return { registered: items.length > 0, count: items.length, items };
167
290
  }
291
+ /** Extract the agent list from a Pacifica /agent/list envelope; throws on an unrecognized shape. */
292
+ export function parsePacAgentList(json) {
293
+ if (json !== null && typeof json === "object") {
294
+ const env = json;
295
+ if (env.success === false) {
296
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica verify rejected: ${JSON.stringify(env.error ?? env).slice(0, 200)}`, {
297
+ remediation: "Check master wallet name and passphrase. Run: perp wallet agent verify pacifica --master <name>",
298
+ });
299
+ }
300
+ const data = env.data;
301
+ if (Array.isArray(data))
302
+ return data;
303
+ if (data !== null && typeof data === "object" && Array.isArray(data.agent_wallets)) {
304
+ return data.agent_wallets;
305
+ }
306
+ }
307
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /agent/list returned an unrecognized shape: ${JSON.stringify(json).slice(0, 200)}`, {
308
+ remediation: "Check the agent on the Pacifica website (API keys) and report the response shape.",
309
+ });
310
+ }
311
+ /** Agent pubkey of one /agent/list entry (a bare base58 string or an object carrying it). */
312
+ function pacAgentPubkeyOf(item) {
313
+ if (typeof item === "string")
314
+ return item;
315
+ if (item !== null && typeof item === "object") {
316
+ const o = item;
317
+ for (const k of ["agent_wallet", "address", "public_key"]) {
318
+ if (typeof o[k] === "string")
319
+ return o[k];
320
+ }
321
+ }
322
+ return null;
323
+ }
168
324
  /**
169
325
  * Verify Lighter agent registration.
170
326
  *
@@ -174,36 +330,31 @@ async function verifyPacifica(opts) {
174
330
  * + AgentMeta with per-DEX fields (accountIndex, publicKey, etc.), the
175
331
  * cross-check branch below becomes live.
176
332
  */
177
- async function verifyLighter(opts) {
178
- const settings = loadSettings();
333
+ async function verifyLighter(venue, network, opts) {
179
334
  const accountIndexStr = opts.accountIndex;
180
- // Resolve accountIndex: flag > named agent > first registered agent
335
+ // Resolve accountIndex: flag > named agent > first agent on this network
336
+ const namedMeta = opts.agentName !== undefined ? getAgent(venue, opts.agentName) : null;
337
+ if (namedMeta !== null)
338
+ assertAgentNetwork(venue, namedMeta, network);
181
339
  let accountIndex;
182
340
  if (accountIndexStr !== undefined) {
183
341
  accountIndex = parseInt(accountIndexStr, 10);
184
342
  }
185
343
  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
- }
344
+ const meta = namedMeta ?? getAgentForNetwork(venue, network);
345
+ if (meta?.accountIndex !== undefined)
346
+ accountIndex = meta.accountIndex;
197
347
  }
198
348
  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>",
349
+ throw new PerpError("INVALID_PARAMS", `Account index required for ${venue} verify. Use --account-index.`, {
350
+ remediation: `perp wallet agent verify ${venue} --account-index <n>`,
201
351
  });
202
352
  }
203
- const resp = await fetch(`https://mainnet.zklighter.elliot.ai/api/v1/apikeys?account_index=${accountIndex}`, { method: "GET" });
353
+ const restUrl = networkConfig(getVenue(venue), network).restUrl;
354
+ const resp = await fetch(`${restUrl}/api/v1/apikeys?account_index=${accountIndex}`, { method: "GET" });
204
355
  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}`,
356
+ throw new PerpError("EXCHANGE_ERROR", `${venue} verify failed: HTTP ${resp.status}`, {
357
+ remediation: `Check account index. Run: perp wallet agent verify ${venue} --account-index ${accountIndex}`,
207
358
  });
208
359
  }
209
360
  const data = await resp.json();
@@ -216,11 +367,7 @@ async function verifyLighter(opts) {
216
367
  const warnings = [];
217
368
  // Cross-check if agentName provided
218
369
  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];
370
+ const agentMeta = namedMeta;
224
371
  if (agentMeta?.publicKey) {
225
372
  const expected = agentMeta.publicKey;
226
373
  const found = items.some((item) => typeof item === "object" &&
@@ -228,12 +375,660 @@ async function verifyLighter(opts) {
228
375
  "public_key" in item &&
229
376
  item.public_key === expected);
230
377
  if (!found) {
231
- warnings.push(`Agent "${opts.agentName}" (expected public_key ${agentMeta.publicKey}) not found in Lighter live response.`);
378
+ warnings.push(`Agent "${opts.agentName}" (expected public_key ${agentMeta.publicKey}) not found in ${venue} live response.`);
232
379
  }
233
380
  }
234
381
  }
235
382
  return { registered: items.length > 0, count: items.length, items, warnings: warnings.length > 0 ? warnings : undefined };
236
383
  }
384
+ /** Ondo: authenticate GET /v1/account with the stored key (proves the key is live). */
385
+ async function verifyOndo(network, opts) {
386
+ const meta = getVenueAgent("ondo", opts.agentName);
387
+ if (!meta) {
388
+ throw new PerpError("AGENT_NOT_REGISTERED", "No Ondo Perps API key registered", { remediation: "perp wallet agent approve ondo" });
389
+ }
390
+ const { loadOndoCredentials } = await import("../exchanges/ondo/agent.js");
391
+ const { OndoClient } = await import("../exchanges/ondo/client.js");
392
+ const def = getVenue("ondo");
393
+ const client = new OndoClient({
394
+ restUrl: networkConfig(def, network).restUrl,
395
+ network,
396
+ rateLimit: def.rateLimit,
397
+ credentials: loadOndoCredentials(meta.keyId),
398
+ });
399
+ const account = await client.get("/v1/account", null);
400
+ return { registered: true, count: 1, items: [{ agentName: meta.agentName, keyId: meta.keyId, accountID: account.accountID, accountState: account.accountState }] };
401
+ }
402
+ /** Arcus: GET /v1/apiKeys?address=<owner> and cross-check the stored key. */
403
+ async function verifyArcus(network, opts) {
404
+ const meta = getVenueAgent("arcus", opts.agentName);
405
+ if (!meta) {
406
+ throw new PerpError("AGENT_NOT_REGISTERED", "No Arcus API key registered", { remediation: "perp wallet agent approve arcus --master <ows-wallet>" });
407
+ }
408
+ const { listArcusApiKeys } = await import("../exchanges/arcus/agent.js");
409
+ const keys = await listArcusApiKeys(network, meta.ownerAddress);
410
+ const warnings = [];
411
+ const hit = keys.find(k => k.apiKey.toLowerCase() === meta.apiKeyHex.toLowerCase());
412
+ if (!hit)
413
+ warnings.push(`Arcus key ${meta.apiKeyHex} ("${meta.agentName}") is not listed for ${meta.ownerAddress}`);
414
+ else if (hit.status !== "ACTIVE")
415
+ warnings.push(`Arcus key ${meta.apiKeyHex} status is ${hit.status}`);
416
+ return { registered: keys.length > 0, count: keys.length, items: keys, warnings: warnings.length > 0 ? warnings : undefined };
417
+ }
418
+ /**
419
+ * QFEX: a signed read as the bound account (GET /user/positions) and a Trade WebSocket
420
+ * authentication with that account. Read-only; nothing is cached from a denial.
421
+ */
422
+ async function verifyQfex(network, opts) {
423
+ const meta = opts.agentName !== undefined ? getVenueAgent("qfex", opts.agentName) : getVenueAgentForNetwork("qfex", network);
424
+ if (!meta) {
425
+ throw new PerpError("AGENT_NOT_REGISTERED", `No QFEX API key imported for ${network}`, { remediation: `perp${network === "testnet" ? " -n testnet" : ""} wallet agent import qfex` });
426
+ }
427
+ if (meta.network !== network) {
428
+ throw new PerpError("AGENT_NOT_REGISTERED", `QFEX key "${meta.agentName}" was imported for ${meta.network}, not ${network}`, { remediation: `perp${meta.network === "testnet" ? " -n testnet" : ""} wallet agent verify qfex ${meta.agentName}` });
429
+ }
430
+ const { loadQfexCredentials } = await import("../exchanges/qfex/agent.js");
431
+ const { QfexClient } = await import("../exchanges/qfex/client.js");
432
+ const { QFEX_ENDPOINTS } = await import("../exchanges/venues/qfex.js");
433
+ const { QfexTradeSession, QFEX_TIMEOUTS, QFEX_IDLE_CLOSE_MS } = await import("../exchanges/qfex/ws-transport.js");
434
+ const credentials = loadQfexCredentials(meta.publicKey);
435
+ const ep = QFEX_ENDPOINTS[network];
436
+ const client = new QfexClient({ restUrl: ep.rest, bankerUrl: ep.banker, network, credentials, accountId: meta.accountId });
437
+ await client.get("/user/positions", null);
438
+ await new QfexTradeSession({
439
+ url: ep.tradeWs, network, credentials, accountId: meta.accountId, builderCode: null,
440
+ rateLimit: getVenue("qfex").rateLimit, timeouts: QFEX_TIMEOUTS, idleCloseMs: QFEX_IDLE_CLOSE_MS,
441
+ }).verifyAuth();
442
+ return {
443
+ registered: true, count: 1,
444
+ items: [{ agentName: meta.agentName, publicKey: meta.publicKey, accountId: meta.accountId, isPrimaryAccount: meta.isPrimaryAccount, network: meta.network, checks: "signed GET /user/positions as the account; Trade WebSocket authentication" }],
445
+ };
446
+ }
447
+ /**
448
+ * Confirmation of the account a QFEX key was resolved to (design §14.12 #9). In a terminal: a
449
+ * y/N prompt showing the UUID. Without one (piped stdin, --json): only an explicit --account-id
450
+ * that equals the resolved account counts — the resolved UUID is printed on stderr either way.
451
+ */
452
+ export function confirmImportAccount(explicitAccountId, json) {
453
+ return async (binding) => {
454
+ const what = `QFEX account ${binding.accountId} (${binding.isPrimaryAccount ? "primary" : "subaccount"})`;
455
+ process.stderr.write(` This key will trade ${what} — ${binding.method}\n`);
456
+ if (explicitAccountId !== null && explicitAccountId.toLowerCase() === binding.accountId)
457
+ return true;
458
+ if (json || process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
459
+ throw new PerpError("INVALID_PARAMS", `QFEX import needs the account confirmed: the key resolves to ${what}`, {
460
+ remediation: `Re-run with that account named explicitly: perp wallet agent import qfex --account-id ${binding.accountId}`,
461
+ });
462
+ }
463
+ const { createInterface } = await import("node:readline/promises");
464
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
465
+ try {
466
+ const a = (await rl.question(` Store the key for ${what}? [y/N]: `)).trim().toLowerCase();
467
+ return a === "y" || a === "yes";
468
+ }
469
+ finally {
470
+ rl.close();
471
+ }
472
+ };
473
+ }
474
+ /** Dispatch a verify to the venue-specific implementation. */
475
+ async function verifyVenue(ex, network, opts) {
476
+ if (ex === "aster")
477
+ return verifyAster(opts);
478
+ if (ex === "hyperliquid")
479
+ return verifyHyperliquid(network, opts);
480
+ if (ex === "pacifica")
481
+ return verifyPacifica(network, opts);
482
+ if (isLighterFamilyVenue(ex))
483
+ return verifyLighter(ex, network, opts);
484
+ if (ex === "ondo")
485
+ return verifyOndo(network, opts);
486
+ if (ex === "arcus")
487
+ return verifyArcus(network, opts);
488
+ if (ex === "qfex")
489
+ return verifyQfex(network, opts);
490
+ throw new PerpError("NOT_IMPLEMENTED", `${ex} has no agents to verify`, {
491
+ remediation: "perp wallet agent verify <aster|hyperliquid|pacifica|lighter|lighter-rh|ondo|arcus|qfex> [agentName]",
492
+ });
493
+ }
494
+ /**
495
+ * Why agent `op` cannot run for `venue` on `network`; null when it can. Every
496
+ * other venue resolves endpoints / signing chain from the selected network
497
+ * (venue definition) — never a mainnet fallback.
498
+ *
499
+ * Aster testnet: approve uses POST /fapi/v3/registerAndApproveAgent, documented
500
+ * for testnet (asterdex/api-docs V3 aster-finance-futures-api-testnet.md,
501
+ * 2026-05-12). Revoke / rotate need the master-signed DelAgent (AsterChain,
502
+ * Domain A) flow, which the testnet docs do not document.
503
+ */
504
+ export function agentApproveUnsupportedReason(venue, network, op) {
505
+ if (venue === "aster" && network === "testnet" && op !== "approve") {
506
+ return "the testnet API documents only registerAndApproveAgent — no agent delete / rotate flow";
507
+ }
508
+ return null;
509
+ }
510
+ /** Throws the uniform CAPABILITY_UNSUPPORTED error when agentApproveUnsupportedReason() is non-null. */
511
+ export function assertAgentApproveSupported(venue, network, op) {
512
+ if (venue === "qfex") {
513
+ // Programmatic key creation needs a website session (SIWE + MFA) — not a CLI path.
514
+ throw unsupported("qfex", "agentApprove", "API keys are created (and revoked) on the QFEX website, behind 2FA", `Create the key in QFEX Developer Settings, then import it: perp${network === "testnet" ? " -n testnet" : ""} wallet agent import qfex`);
515
+ }
516
+ const reason = agentApproveUnsupportedReason(venue, network, op);
517
+ if (reason === null)
518
+ return;
519
+ throw unsupported(getVenue(venue).displayName, `agent ${op} on ${network}`, reason, "Revoke the testnet API agent at https://www.asterdex-testnet.com/en/api-wallet (Pro API), then approve a new one: perp -n testnet wallet agent approve aster");
520
+ }
521
+ /**
522
+ * Aster master-signed Domain A structs (ApproveAgent / DelAgent) carry an
523
+ * AsterChain field and are documented for mainnet only — refuse anything else.
524
+ */
525
+ function assertAsterDomainAMainnet(network) {
526
+ if (network === "mainnet")
527
+ return;
528
+ throw unsupported("Aster", `master-signed agent operations on ${network}`, "the AsterChain-bearing approveAgent / DelAgent structs are documented for mainnet only", "On testnet use: perp -n testnet wallet agent approve aster (registerAndApproveAgent)");
529
+ }
530
+ /** Default agent name per venue and network (the `--agent-name` default of `wallet agent approve`). */
531
+ export function defaultAgentName(venue, network) {
532
+ // Short suffix: Hyperliquid limits agent names to 16 characters ("perp-cli-hl-tn" = 14).
533
+ const suffix = network === "testnet" ? "-tn" : "";
534
+ switch (venue) {
535
+ case "aster": return `perp-cli-aster${suffix}`;
536
+ case "hyperliquid": return `perp-cli-hl${suffix}`;
537
+ case "pacifica": return `perp-cli-pac${suffix}`;
538
+ case "lighter": return `perp-cli-lt${suffix}`;
539
+ case "lighter-rh": return `perp-cli-lrh${suffix}`;
540
+ case "ondo": return `perp-cli-ondo${suffix}`;
541
+ case "arcus": return `perp-cli-arcus${suffix}`;
542
+ case "qfex": return `perp-cli-qfex${suffix}`;
543
+ }
544
+ }
545
+ /**
546
+ * Local OWS agent wallet name. Testnet agents get their own wallet so a
547
+ * testnet approve never collides with (or reuses) the mainnet agent key.
548
+ */
549
+ function agentWalletNameFor(prefix, masterName, network) {
550
+ return network === "testnet" ? `agent-${prefix}-${masterName}-testnet` : `agent-${prefix}-${masterName}`;
551
+ }
552
+ /**
553
+ * The agent's OWS wallet name is fixed per venue + master + network, and OWS refuses a duplicate
554
+ * name. An existing wallet of that name is refused here, before anything is created or signed.
555
+ */
556
+ function assertAgentWalletNameFree(venue, agentWalletName, network) {
557
+ if (!loadOws().listWallets().some((w) => w.name === agentWalletName))
558
+ return;
559
+ const net = network === "testnet" ? " -n testnet" : "";
560
+ const owner = listAgents(venue).find(a => a.meta.agentWalletName === agentWalletName);
561
+ if (owner !== undefined) {
562
+ throw new PerpError("INVALID_PARAMS", `${venue} agent "${owner.meta.agentName}" already exists (its OWS wallet "${agentWalletName}" is in use)`, {
563
+ exchange: venue,
564
+ remediation: `Replace it (revoke + new key): perp${net} wallet agent approve ${venue} --rotate, or perp${net} wallet agent rotate ${venue} ${owner.meta.agentName}`,
565
+ });
566
+ }
567
+ throw new PerpError("INVALID_PARAMS", `OWS wallet "${agentWalletName}" already exists but no ${venue} agent record uses it (left over from an interrupted approve)`, {
568
+ exchange: venue,
569
+ remediation: `If no ${venue} agent on the venue uses its key, delete it (ows wallet delete ${agentWalletName}) and re-run the approve`,
570
+ });
571
+ }
572
+ /** Refuse to overwrite a legacy-shaped agent record registered on another network. */
573
+ function assertAgentNameFree(venue, agentName, network) {
574
+ const existing = getAgent(venue, agentName);
575
+ if (existing !== null && agentNetworkOf(venue, existing) !== network) {
576
+ throw new PerpError("INVALID_PARAMS", `${venue} agent "${agentName}" is already registered on ${agentNetworkOf(venue, existing)}`, {
577
+ remediation: `Choose another --agent-name for the ${network} agent (default: ${defaultAgentName(venue, network)})`,
578
+ });
579
+ }
580
+ }
581
+ /** Ondo / Arcus: refuse (even with --rotate) to replace a record registered on another network. */
582
+ function assertVenueAgentNameFree(venue, agentName, network) {
583
+ const existing = getVenueAgent(venue, agentName);
584
+ if (existing !== null && existing.network !== network) {
585
+ throw new PerpError("INVALID_PARAMS", `${venue} agent "${agentName}" is already registered on ${existing.network}`, {
586
+ remediation: `Choose another --agent-name for the ${network} agent (default: ${defaultAgentName(venue, network)})`,
587
+ });
588
+ }
589
+ }
590
+ function parseExpiresInParam(expiresIn) {
591
+ try {
592
+ return parseExpiresIn(expiresIn);
593
+ }
594
+ catch (e) {
595
+ const msg = e instanceof Error ? e.message : String(e);
596
+ throw new PerpError("INVALID_PARAMS", msg, { remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime" });
597
+ }
598
+ }
599
+ function requireMaster(master) {
600
+ const name = master ?? loadSettings().owsActiveWallet;
601
+ if (!name) {
602
+ // No master wallet on this computer: approving signs with the master, so it cannot run here.
603
+ throw new PerpError("NO_SIGNER_AVAILABLE", "Agent approval is signed by the master wallet, and none is configured on this computer (no --master, settings.owsActiveWallet empty)", {
604
+ remediation: "Create the agent / API key on the exchange website and import it: perp wallet agent import <venue> (hidden prompt / stdin). Or add the master wallet (perp wallet import --name <name>) and pass --master <name>.",
605
+ });
606
+ }
607
+ return name;
608
+ }
609
+ function requirePassphrase(passphrase) {
610
+ if (passphrase === null) {
611
+ throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
612
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
613
+ });
614
+ }
615
+ return passphrase;
616
+ }
617
+ /**
618
+ * Register one agent wallet / API key on `venue`. Throws on any failure (the
619
+ * caller decides whether to exit or continue with other venues). Holds the
620
+ * per-venue lock for the duration of the venue round-trip.
621
+ */
622
+ export async function approveVenueAgent(p) {
623
+ assertAgentApproveSupported(p.venue, p.network, "approve");
624
+ const agentName = p.agentName ?? defaultAgentName(p.venue, p.network);
625
+ // Ondo: HMAC key entered on stdin only (design §10.4)
626
+ if (p.venue === "ondo") {
627
+ assertVenueAgentNameFree("ondo", agentName, p.network);
628
+ if (getVenueAgent("ondo", agentName) && !p.rotate) {
629
+ throw new PerpError("INVALID_PARAMS", `Ondo agent "${agentName}" already exists`, {
630
+ remediation: "Pass --rotate to replace it, or choose another --agent-name",
631
+ });
632
+ }
633
+ const { approveOndoAgent } = await import("../exchanges/ondo/agent.js");
634
+ const { keyId, secret } = p.ondoKey ?? await readOndoKeyFromStdin();
635
+ const meta = await approveOndoAgent({ agentName, keyId, secret, network: p.network });
636
+ setVenueAgent("ondo", meta);
637
+ return { venue: "ondo", meta };
638
+ }
639
+ // Arcus: OWS Ed25519 agent + EIP-712 createApiKey (design §10.4)
640
+ if (p.venue === "arcus") {
641
+ const masterName = requireMaster(p.master);
642
+ assertVenueAgentNameFree("arcus", agentName, p.network);
643
+ if (getVenueAgent("arcus", agentName) && !p.rotate) {
644
+ throw new PerpError("INVALID_PARAMS", `Arcus agent "${agentName}" already exists`, {
645
+ remediation: "Pass --rotate to re-register it, or choose another --agent-name",
646
+ });
647
+ }
648
+ const expiresAt = parseExpiresIn(p.expiresIn);
649
+ const validDays = Math.ceil((expiresAt.getTime() - Date.now()) / 86_400_000);
650
+ const { approveArcusAgent } = await import("../exchanges/arcus/agent.js");
651
+ acquireLock("arcus");
652
+ try {
653
+ const meta = await approveArcusAgent({
654
+ agentName, masterOwsWallet: masterName, passphrase: p.passphrase, accountIndex: p.accountIndex, network: p.network, validDays,
655
+ });
656
+ setVenueAgent("arcus", meta);
657
+ return { venue: "arcus", meta };
658
+ }
659
+ finally {
660
+ releaseLock("arcus");
661
+ }
662
+ }
663
+ const expiresAt = parseExpiresInParam(p.expiresIn);
664
+ const masterName = requireMaster(p.master);
665
+ const expiresAtIso = expiresAt.toISOString();
666
+ const nowIso = new Date().toISOString();
667
+ assertAgentNameFree(p.venue, agentName, p.network);
668
+ // --rotate: the same path as `wallet agent rotate` (venue-confirmed revoke → delete the old
669
+ // local key → approve), for the OWS-stored agents (the fixed `agent-<dex>-<master>` wallet name
670
+ // would otherwise collide with the previous agent's) and the Lighter-family keystore agents.
671
+ // Without --rotate an existing agent of this name is never silently replaced.
672
+ if (p.venue === "pacifica" || p.venue === "hyperliquid" || p.venue === "aster" || isLighterFamilyVenue(p.venue)) {
673
+ if (!p.rotate) {
674
+ if (getAgent(p.venue, agentName) !== null) {
675
+ const net = p.network === "testnet" ? " -n testnet" : "";
676
+ throw new PerpError("INVALID_PARAMS", `${p.venue} agent "${agentName}" already exists on this computer`, {
677
+ exchange: p.venue,
678
+ remediation: `Replace it (revoke the old key, register a fresh one): perp${net} wallet agent approve ${p.venue} --rotate — or choose another --agent-name`,
679
+ });
680
+ }
681
+ }
682
+ else if (!isLighterFamilyVenue(p.venue) || getAgent(p.venue, agentName) !== null) {
683
+ return rotateVenueAgent({
684
+ exchange: p.venue, network: p.network, agentNameToRotate: agentName, master: p.master,
685
+ passphrase: async () => p.passphrase, expiresIn: p.expiresIn,
686
+ canPerp: p.canPerp, canSpot: p.canSpot, canWithdraw: p.canWithdraw, ipWhitelist: p.aster.ipWhitelist,
687
+ });
688
+ }
689
+ }
690
+ // Lighter family (lighter, lighter-rh): slot must be 4..254 minus venue-reserved
691
+ if (isLighterFamilyVenue(p.venue)) {
692
+ const ltVenue = p.venue;
693
+ if (p.apiKeyIndex !== null)
694
+ assertLighterSlot(ltVenue, p.apiKeyIndex);
695
+ const passphrase = requirePassphrase(p.passphrase);
696
+ acquireLock(ltVenue);
697
+ try {
698
+ const result = await runLtApproveFlow({
699
+ venue: ltVenue, network: p.network, masterName, passphrase, agentName, expiresAt, expiresAtIso, nowIso,
700
+ canPerp: p.canPerp, canSpot: p.canSpot, canWithdraw: p.canWithdraw,
701
+ apiKeyIndex: p.apiKeyIndex ?? undefined,
702
+ forceSlot: p.forceSlot,
703
+ });
704
+ return { venue: ltVenue, agentName, expiresAt: expiresAtIso, result };
705
+ }
706
+ finally {
707
+ releaseLock(ltVenue);
708
+ }
709
+ }
710
+ const passphrase = requirePassphrase(p.passphrase);
711
+ if (p.venue === "pacifica") {
712
+ acquireLock("pacifica");
713
+ try {
714
+ const result = await runPacApproveFlow({
715
+ network: p.network, masterName, passphrase, agentName, expiresAt, expiresAtIso, nowIso,
716
+ canPerp: p.canPerp, canSpot: p.canSpot, canWithdraw: p.canWithdraw,
717
+ });
718
+ return { venue: "pacifica", agentName, expiresAt: expiresAtIso, result };
719
+ }
720
+ finally {
721
+ releaseLock("pacifica");
722
+ }
723
+ }
724
+ if (p.venue === "hyperliquid") {
725
+ acquireLock("hyperliquid");
726
+ try {
727
+ const result = await runHlApproveFlow({
728
+ network: p.network, masterName, passphrase, agentName, expiresAt, expiresAtIso, nowIso,
729
+ canPerp: p.canPerp, canSpot: p.canSpot, canWithdraw: p.canWithdraw,
730
+ });
731
+ return { venue: "hyperliquid", agentName, expiresAt: expiresAtIso, result };
732
+ }
733
+ finally {
734
+ releaseLock("hyperliquid");
735
+ }
736
+ }
737
+ // Aster. Acquire the lock at the START of the flow (before any OWS resources
738
+ // are created) so that concurrent approves are fully serialized, not just
739
+ // the final settings write.
740
+ const nonceMicros = Date.now() * 1000 + (Math.floor(Math.random() * 1000));
741
+ acquireLock("aster");
742
+ try {
743
+ const result = await runApproveFlow({
744
+ network: p.network, masterName, passphrase, agentName, expiresAt,
745
+ expiredMs: expiresAt.getTime(), expiresAtIso, nonceMicros, nowIso,
746
+ canPerp: p.canPerp, canSpot: p.canSpot, canWithdraw: p.canWithdraw,
747
+ ipWhitelist: p.aster.ipWhitelist,
748
+ });
749
+ return { venue: "aster", agentName, expiresAt: expiresAtIso, result };
750
+ }
751
+ finally {
752
+ releaseLock("aster");
753
+ }
754
+ }
755
+ /**
756
+ * Loud warning for a QFEX key that has — or may have (it cannot be checked without acting) — the
757
+ * "Deposit and withdraw" permission. Such a key can withdraw with the key alone, and imported keys
758
+ * are stored with an empty passphrase (file permissions only). Not a block: a funding key is needed
759
+ * for a QFEX withdrawal. Shown at import and in `wallet agent list`. null = no warning.
760
+ */
761
+ export function qfexWithdrawKeyWarning(meta) {
762
+ if (meta.permissions.depositWithdraw === false)
763
+ return null;
764
+ const has = meta.permissions.depositWithdraw === true ? "HAS" : "MAY HAVE (cannot be checked without withdrawing)";
765
+ return `QFEX key "${meta.agentName}" ${has} the "Deposit and withdraw" permission and is stored with an EMPTY passphrase (protected by file permissions only) — anyone who can read ~/.perp can withdraw with it. Delete it right after use: perp wallet agent revoke qfex ${meta.agentName} --local-only, and delete the key on the QFEX website.`;
766
+ }
767
+ /**
768
+ * Rotate an agent: venue-confirmed revoke of the existing agent (when one is recorded), delete
769
+ * its dead OWS wallet and local record, then approve a fresh agent under the same name. The single
770
+ * path of `wallet agent rotate` and `wallet agent approve --rotate`.
771
+ */
772
+ export async function rotateVenueAgent(p) {
773
+ const { exchange, network, agentNameToRotate } = p;
774
+ const settings = loadSettings();
775
+ // Capture existing agent meta before revoke (for permission inheritance).
776
+ // It must belong to the selected network — rotate never crosses networks.
777
+ // The OWS agent wallet name derives from the master (one per venue + master + network), so
778
+ // under a NEW agent name the agent to replace is the record that owns that wallet.
779
+ const named = getAgent(exchange, agentNameToRotate);
780
+ if (named !== null)
781
+ assertAgentNetwork(exchange, named, network);
782
+ const walletOwner = () => {
783
+ if (isLighterFamilyVenue(exchange))
784
+ return null;
785
+ const master = p.master || settings.owsActiveWallet;
786
+ if (!master)
787
+ return null;
788
+ const walletName = agentWalletNameFor(exchange === "hyperliquid" ? "hl" : exchange === "pacifica" ? "pac" : "aster", master, network);
789
+ const owner = listAgents(exchange).find(a => a.meta.agentWalletName === walletName && agentNetworkOf(exchange, a.meta) === network);
790
+ return owner === undefined ? null : owner.meta;
791
+ };
792
+ const existing = named ?? walletOwner();
793
+ if (existing !== null && agentSourceOf(exchange, existing) === "imported") {
794
+ const net = network === "testnet" ? " -n testnet" : "";
795
+ throw new PerpError("INVALID_PARAMS", `${exchange} agent "${agentNameToRotate}" was imported from the exchange website — this CLI cannot rotate it (no master wallet signature)`, {
796
+ remediation: `Create a new key on the ${getVenue(exchange).displayName} website (and delete the old one there), then: perp${net} wallet agent import ${exchange} --agent-name ${agentNameToRotate} --replace`,
797
+ });
798
+ }
799
+ // The new agent is approved by the old agent's master unless --master says
800
+ // otherwise; resolved before the revoke so an unknown master changes nothing.
801
+ const masterName = p.master || existing?.masterWalletName || settings.owsActiveWallet;
802
+ if (!masterName) {
803
+ throw new PerpError("INVALID_PARAMS", `No master wallet for rotating ${exchange} agent "${agentNameToRotate}": no --master, no master on the agent record, and settings.owsActiveWallet is empty`, {
804
+ remediation: `Pass the master that registered it: perp wallet agent rotate ${exchange} ${agentNameToRotate} --master <ows-wallet>`,
805
+ });
806
+ }
807
+ // Resolve permissions BEFORE anything is revoked: explicit flags > old agent's trade
808
+ // permissions > defaults. With --no-can-perp/spot pattern, opts values are undefined when not
809
+ // passed. canWithdraw is NEVER inherited: only an explicit --can-withdraw grants it.
810
+ const permFlagsProvided = p.canPerp !== undefined || p.canSpot !== undefined;
811
+ let canPerp;
812
+ let canSpot;
813
+ if (permFlagsProvided) {
814
+ canPerp = p.canPerp ?? true;
815
+ canSpot = p.canSpot ?? false;
816
+ }
817
+ else if (existing?.permissions) {
818
+ // Preserve old agent's trade permissions when flags are absent
819
+ canPerp = existing.permissions.canPerpTrade;
820
+ canSpot = existing.permissions.canSpotTrade;
821
+ }
822
+ else {
823
+ canPerp = true;
824
+ canSpot = false;
825
+ }
826
+ const canWithdraw = p.canWithdraw === true;
827
+ // Aster: --can-withdraw without --ip-whitelist is refused here, before the passphrase is read
828
+ // and before the old agent is revoked (a later refusal would leave no agent at all).
829
+ if (exchange === "aster")
830
+ assertAsterWithdrawIpWhitelist(canWithdraw, p.ipWhitelist);
831
+ // Resolve passphrase (needed for both revoke and approve phases)
832
+ const passphrase = await p.passphrase();
833
+ if (passphrase === null) {
834
+ throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
835
+ remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
836
+ });
837
+ }
838
+ // Revoke the existing agent (if present) BEFORE approving the new one.
839
+ // Fail closed: if the venue does not confirm the revoke, abort with the
840
+ // old agent's local record (and keys) untouched and no new agent
841
+ // approved — the user keeps a working, recorded agent and can retry.
842
+ // The local record is deleted only after a confirmed revoke, so a later
843
+ // approve failure leaves no agent but nothing still authorized either.
844
+ if (existing) {
845
+ try {
846
+ if (exchange === "pacifica") {
847
+ // Pacifica master is a Solana keypair; can't reuse the EVM master signer.
848
+ const revokePacSigner = OwsSolanaSigner.create(existing.masterWalletName, passphrase);
849
+ await revokePacAgent(revokePacSigner, existing.agentSolanaAddress ?? null, network);
850
+ }
851
+ else if (isLighterFamilyVenue(exchange)) {
852
+ // LT rotate: kill the old slot (ChangePubKey to a throwaway key) and delete
853
+ // its keystore file before the new approve registers a fresh slot.
854
+ await revokeLtAgent(exchange, network, existing, passphrase);
855
+ }
856
+ else if (exchange === "hyperliquid") {
857
+ const revokeMs = OwsEvmSigner.create(existing.masterWalletName, passphrase);
858
+ await revokeHlAgent(revokeMs, network, existing.agentName, existing.agentEvmAddress);
859
+ }
860
+ else {
861
+ // Aster: DELETE /fapi/v3/agent — the response decides success.
862
+ const revokeMs = OwsEvmSigner.create(existing.masterWalletName, passphrase);
863
+ await assertAsterAgentDeleted(await deleteAsterAgent(revokeMs, existing.agentEvmAddress, network), existing.agentEvmAddress, `The old agent "${existing.agentName}" and its local record were kept and no new agent was approved. Retry the rotate, or delete the agent in the Aster UI and run: perp wallet agent revoke aster ${existing.agentName} --force`);
864
+ }
865
+ }
866
+ catch (revokeErr) {
867
+ if (revokeErr instanceof PerpError && (revokeErr.structured.code === "APPROVE_FAILED" || revokeErr.structured.code === "APPROVE_PARTIAL"))
868
+ throw revokeErr;
869
+ throw new PerpError("APPROVE_FAILED", `Rotate aborted: revoking the previous ${exchange} agent "${existing.agentName}" failed — it may still be authorized. ${revokeErr instanceof Error ? revokeErr.message : String(revokeErr)}`, {
870
+ exchange,
871
+ remediation: `The old agent and its local record were kept and no new agent was approved. Retry: perp wallet agent rotate ${exchange} ${existing.agentName}`,
872
+ });
873
+ }
874
+ if (existing.owsApiKeyId) {
875
+ try {
876
+ loadOws().revokeApiKey(existing.owsApiKeyId);
877
+ }
878
+ catch { /* best effort */ }
879
+ }
880
+ // Confirmed revoke → the old OWS agent wallet is dead; delete it so the approve
881
+ // below can create the same `agent-<dex>-<master>` name (OWS refuses duplicates).
882
+ if (!isLighterFamilyVenue(exchange))
883
+ deleteRevokedAgentWallet(exchange, existing);
884
+ deleteAgent(exchange, existing.agentName);
885
+ }
886
+ const expiresAt = parseExpiresIn(p.expiresIn);
887
+ const expiresAtIso = expiresAt.toISOString();
888
+ const nowIso = new Date().toISOString();
889
+ // Acquire lock and run the shared approve flow
890
+ acquireLock(exchange);
891
+ try {
892
+ if (exchange === "hyperliquid") {
893
+ const result = await runHlApproveFlow({
894
+ network,
895
+ masterName,
896
+ passphrase,
897
+ agentName: agentNameToRotate,
898
+ expiresAt,
899
+ expiresAtIso,
900
+ nowIso,
901
+ canPerp,
902
+ canSpot,
903
+ canWithdraw,
904
+ });
905
+ return { venue: "hyperliquid", agentName: agentNameToRotate, expiresAt: expiresAtIso, result };
906
+ }
907
+ else if (exchange === "pacifica") {
908
+ const result = await runPacApproveFlow({
909
+ network,
910
+ masterName,
911
+ passphrase,
912
+ agentName: agentNameToRotate,
913
+ expiresAt,
914
+ expiresAtIso,
915
+ nowIso,
916
+ canPerp,
917
+ canSpot,
918
+ canWithdraw,
919
+ });
920
+ return { venue: "pacifica", agentName: agentNameToRotate, expiresAt: expiresAtIso, result };
921
+ }
922
+ else if (isLighterFamilyVenue(exchange)) {
923
+ const result = await runLtApproveFlow({
924
+ venue: exchange,
925
+ network,
926
+ masterName,
927
+ passphrase,
928
+ agentName: agentNameToRotate,
929
+ expiresAt,
930
+ expiresAtIso,
931
+ nowIso,
932
+ canPerp,
933
+ canSpot,
934
+ canWithdraw,
935
+ // No apiKeyIndex override on rotate — picks next slot free locally and on-chain.
936
+ forceSlot: false,
937
+ });
938
+ return { venue: exchange, agentName: agentNameToRotate, expiresAt: expiresAtIso, result };
939
+ }
940
+ else {
941
+ const expiredMs = expiresAt.getTime();
942
+ const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
943
+ const result = await runApproveFlow({
944
+ network,
945
+ masterName,
946
+ passphrase,
947
+ agentName: agentNameToRotate,
948
+ expiresAt,
949
+ expiredMs,
950
+ expiresAtIso,
951
+ nonceMicros,
952
+ nowIso,
953
+ canPerp,
954
+ canSpot,
955
+ canWithdraw,
956
+ ipWhitelist: p.ipWhitelist,
957
+ });
958
+ return { venue: "aster", agentName: agentNameToRotate, expiresAt: expiresAtIso, result };
959
+ }
960
+ }
961
+ finally {
962
+ releaseLock(exchange);
963
+ }
964
+ }
965
+ /** Display identity of a rotated agent (EVM / Solana address, or the Lighter slot + public key). */
966
+ function rotatedAgentAddressOf(o) {
967
+ if (o.venue === "hyperliquid" || o.venue === "aster")
968
+ return o.result.agentAddress;
969
+ if (o.venue === "pacifica")
970
+ return o.result.agentSolanaAddress;
971
+ return `slot=${o.result.apiKeyIndex} pubkey=${o.result.publicKey}`;
972
+ }
973
+ /** Live (read-only) registration check for `venue` — `wallet agent verify <venue>`. */
974
+ export async function verifyVenueAgent(venue, network, opts) {
975
+ return verifyVenue(venue, network, { master: opts.master ?? undefined, passphrase: opts.passphrase });
976
+ }
977
+ /** Human-readable summary of a successful approve (no secrets — public ids/addresses only). */
978
+ export function printApproveOutcome(outcome) {
979
+ switch (outcome.venue) {
980
+ case "ondo":
981
+ console.log(chalk.green.bold("\n Ondo Perps API key registered!\n"));
982
+ console.log(` Name: ${chalk.cyan(outcome.meta.agentName)}`);
983
+ console.log(` Key ID: ${chalk.cyan(outcome.meta.keyId)}`);
984
+ console.log(` Keystore: ${chalk.gray(outcome.meta.keystorePath)}`);
985
+ console.log();
986
+ return;
987
+ case "arcus":
988
+ console.log(chalk.green.bold("\n Arcus API key registered!\n"));
989
+ console.log(` Name: ${chalk.cyan(outcome.meta.agentName)}`);
990
+ console.log(` API Key: ${chalk.cyan(outcome.meta.apiKeyHex)}`);
991
+ console.log(` Owner: ${chalk.cyan(outcome.meta.ownerAddress)}`);
992
+ console.log(` Account Index: ${chalk.cyan(String(outcome.meta.accountIndex))}`);
993
+ console.log(` Valid Until: ${chalk.cyan(outcome.meta.validUntil)}`);
994
+ console.log();
995
+ return;
996
+ case "lighter":
997
+ case "lighter-rh":
998
+ console.log(chalk.green.bold(`\n ${getVenue(outcome.venue).displayName} Agent approved successfully!\n`));
999
+ console.log(` Name: ${chalk.cyan(outcome.agentName)}`);
1000
+ console.log(` API Key Index: ${chalk.cyan(String(outcome.result.apiKeyIndex))}`);
1001
+ console.log(` Public Key: ${chalk.cyan(outcome.result.publicKey)}`);
1002
+ console.log(` Account Index: ${chalk.cyan(String(outcome.result.accountIndex))}`);
1003
+ console.log(` Expires: ${chalk.cyan(outcome.expiresAt)}`);
1004
+ console.log();
1005
+ return;
1006
+ case "pacifica":
1007
+ console.log(chalk.green.bold("\n Pacifica Agent approved successfully!\n"));
1008
+ console.log(` Name: ${chalk.cyan(outcome.agentName)}`);
1009
+ console.log(` Agent Address: ${chalk.cyan(outcome.result.agentSolanaAddress)}`);
1010
+ console.log(` Master Address: ${chalk.cyan(outcome.result.userSolanaAddress)}`);
1011
+ console.log(` Expires: ${chalk.cyan(outcome.expiresAt)}`);
1012
+ console.log();
1013
+ return;
1014
+ case "hyperliquid":
1015
+ console.log(chalk.green.bold("\n Hyperliquid Agent approved successfully!\n"));
1016
+ console.log(` Name: ${chalk.cyan(outcome.agentName)}`);
1017
+ console.log(` Agent Address: ${chalk.cyan(outcome.result.agentAddress)}`);
1018
+ console.log(` Expires: ${chalk.cyan(outcome.expiresAt)}`);
1019
+ console.log();
1020
+ return;
1021
+ case "aster":
1022
+ console.log(chalk.green.bold("\n Agent approved successfully!\n"));
1023
+ console.log(` Name: ${chalk.cyan(outcome.agentName)}`);
1024
+ console.log(` Agent Address: ${chalk.cyan(outcome.result.agentAddress)}`);
1025
+ console.log(` Expires: ${chalk.cyan(outcome.expiresAt)}`);
1026
+ console.log(` API Key ID: ${chalk.gray(outcome.result.owsApiKeyId)}`);
1027
+ console.log(` Policy ID: ${chalk.gray(outcome.result.policyId)}`);
1028
+ console.log();
1029
+ return;
1030
+ }
1031
+ }
237
1032
  /**
238
1033
  * Register the per-DEX agent-wallet management subtree (`approve` /
239
1034
  * `list` / `revoke` / `rotate` / `verify`) under the supplied parent
@@ -248,7 +1043,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
248
1043
  // ── agent approve <exchange> ── register an agent wallet ──────────────────
249
1044
  agent
250
1045
  .command("approve <exchange>")
251
- .description("Register an agent wallet for an exchange (Phase 2a/b/c/d: aster, hyperliquid, pacifica, lighter)")
1046
+ .description("Register an agent wallet / API key for an exchange (aster, hyperliquid, pacifica, lighter, lighter-rh, ondo, arcus)")
252
1047
  .option("--master <name>", "Master OWS wallet name")
253
1048
  .option("--agent-name <name>", "Agent name (default: perp-cli-aster)")
254
1049
  .option("--expires-in <duration>", "Expiry: 30d, 90d, 180d, 1y, or ISO-8601 datetime (default: 90d)")
@@ -259,12 +1054,14 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
259
1054
  .option("--can-withdraw", "Allow withdrawals (default: off)", false)
260
1055
  .option("--no-withdraw", "Disallow withdrawals")
261
1056
  .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.")
1057
+ .addOption(deprecatedPassphraseOption()).hook("preAction", warnDeprecatedPassphrase)
1058
+ .addOption(new Option("--builder <addr>", "Removed — use: perp wallet builder approve <venue>").hideHelp())
1059
+ .addOption(new Option("--max-fee-rate <rate>", "Removed — use: perp wallet builder approve <venue>").hideHelp())
1060
+ .addOption(new Option("--builder-name <name>", "Removed — use: perp wallet builder approve <venue>").hideHelp())
1061
+ .option("--ip-whitelist <list>", "Aster IP whitelist, comma- or space-separated (required with --can-withdraw)")
1062
+ .option("--api-key-index <n>", "Lighter / Lighter RH slot index (4-254; RH also excludes 157). Default: next slot free locally and on-chain.")
1063
+ .option("--force", "Lighter / Lighter RH: overwrite an --api-key-index slot that already holds an on-chain key (kills whatever signs with it)")
1064
+ .option("--account-index <n>", "Arcus subaccount the key is scoped to: 0-9, or 255 for all (trading needs 0-9)", "0")
268
1065
  .option("--json", "Machine-readable output")
269
1066
  .action(async (exchange, opts, command) => {
270
1067
  const useJson = opts.json ?? isJson();
@@ -275,333 +1072,183 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
275
1072
  // was approve throwing PASSPHRASE_REQUIRED even with --passphrase set.
276
1073
  const mergedOpts = command.optsWithGlobals();
277
1074
  const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
278
- // Normalize aliases
279
- const exchangeNorm = exchange === "lt" ? "lighter"
280
- : exchange === "hl" ? "hyperliquid"
281
- : exchange === "pac" ? "pacifica"
282
- : exchange === "ast" ? "aster"
283
- : exchange;
284
- // Phase 2d: Aster + HL + PAC + LT all implemented.
285
- if (exchangeNorm !== "aster" &&
286
- exchangeNorm !== "hyperliquid" &&
287
- exchangeNorm !== "pacifica" &&
288
- exchangeNorm !== "lighter") {
289
- reportErrorAndExit(new PerpError("NOT_IMPLEMENTED", `${exchange} agent approve not implemented`, {
290
- remediation: "Use one of: aster, hyperliquid, pacifica, lighter",
291
- }));
292
- }
293
- // ── Lighter approve branch (Phase 2d) ───────────────────────────────
294
- if (exchangeNorm === "lighter") {
295
- const masterName = opts.master ?? loadSettings().owsActiveWallet;
296
- const agentName = opts.agentName ?? "perp-cli-lt";
297
- const expiresIn = opts.expiresIn ?? "90d";
298
- let expiresAt;
299
- try {
300
- expiresAt = parseExpiresIn(expiresIn);
301
- }
302
- catch (e) {
303
- const msg = e instanceof Error ? e.message : String(e);
304
- reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
305
- remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
306
- }));
307
- }
308
- if (!masterName) {
309
- reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
310
- remediation: "Run: perp setup or use --master <name>",
311
- }));
312
- }
313
- // Slot validation: explicit value must be 4..254. Otherwise pick free slot.
314
- let chosenSlot;
315
- if (opts.apiKeyIndex !== undefined) {
316
- const n = parseInt(opts.apiKeyIndex, 10);
317
- if (!Number.isInteger(n) || n < 4 || n > 254) {
318
- reportErrorAndExit(new PerpError("INVALID_PARAMS", `--api-key-index must be an integer in [4, 254]; got ${opts.apiKeyIndex}`, {
319
- remediation: "Slots 0-3 are reserved by the Lighter frontend. Use 4-254.",
320
- }));
321
- }
322
- chosenSlot = n;
323
- }
324
- const passphrase = await resolvePassphrase({ flag: passphraseFlag });
325
- if (passphrase === null) {
326
- reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
327
- remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
328
- }));
329
- }
330
- try {
331
- acquireLock("lighter");
332
- try {
333
- const result = await runLtApproveFlow({
334
- masterName,
335
- passphrase: passphrase ?? "",
336
- agentName,
337
- expiresAt,
338
- expiresAtIso: expiresAt.toISOString(),
339
- nowIso: new Date().toISOString(),
340
- canPerp: opts.canPerp ?? true,
341
- canSpot: opts.canSpot ?? false,
342
- canWithdraw: opts.canWithdraw ?? false,
343
- apiKeyIndex: chosenSlot,
344
- });
345
- if (useJson) {
346
- printJson(jsonOk(result));
347
- }
348
- else {
349
- console.log(chalk.green.bold("\n Lighter Agent approved successfully!\n"));
350
- console.log(` Name: ${chalk.cyan(agentName)}`);
351
- console.log(` API Key Index: ${chalk.cyan(String(result.apiKeyIndex))}`);
352
- console.log(` Public Key: ${chalk.cyan(result.publicKey)}`);
353
- console.log(` Account Index: ${chalk.cyan(String(result.accountIndex))}`);
354
- console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
355
- console.log();
356
- }
357
- }
358
- finally {
359
- releaseLock("lighter");
360
- }
361
- }
362
- catch (err) {
363
- reportErrorAndExit(err);
364
- }
365
- return;
366
- }
367
- // ── Pacifica approve branch (Phase 2c) ──────────────────────────────
368
- if (exchangeNorm === "pacifica") {
369
- const masterName = opts.master ?? loadSettings().owsActiveWallet;
370
- const agentName = opts.agentName ?? "perp-cli-pac";
371
- const expiresIn = opts.expiresIn ?? "90d";
372
- let expiresAt;
373
- try {
374
- expiresAt = parseExpiresIn(expiresIn);
375
- }
376
- catch (e) {
377
- const msg = e instanceof Error ? e.message : String(e);
378
- reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
379
- remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
380
- }));
381
- }
382
- if (!masterName) {
383
- reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
384
- remediation: "Run: perp setup or use --master <name>",
385
- }));
386
- }
387
- const passphrase = await resolvePassphrase({ flag: passphraseFlag });
388
- if (passphrase === null) {
389
- reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
390
- remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
391
- }));
392
- }
393
- try {
394
- acquireLock("pacifica");
395
- try {
396
- const result = await runPacApproveFlow({
397
- masterName,
398
- passphrase: passphrase ?? "",
399
- agentName,
400
- expiresAt,
401
- expiresAtIso: expiresAt.toISOString(),
402
- nowIso: new Date().toISOString(),
403
- canPerp: opts.canPerp ?? true,
404
- canSpot: opts.canSpot ?? false,
405
- canWithdraw: opts.canWithdraw ?? false,
406
- });
407
- if (useJson) {
408
- printJson(jsonOk(result));
409
- }
410
- else {
411
- console.log(chalk.green.bold("\n Pacifica Agent approved successfully!\n"));
412
- console.log(` Name: ${chalk.cyan(agentName)}`);
413
- console.log(` Agent Address: ${chalk.cyan(result.agentSolanaAddress)}`);
414
- console.log(` Master Address: ${chalk.cyan(result.userSolanaAddress)}`);
415
- console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
416
- console.log();
417
- }
418
- }
419
- finally {
420
- releaseLock("pacifica");
421
- }
422
- }
423
- catch (err) {
424
- reportErrorAndExit(err);
425
- }
426
- return;
427
- }
428
- // ── Hyperliquid approve branch ──────────────────────────────────────
429
- if (exchangeNorm === "hyperliquid") {
430
- const masterName = opts.master ?? loadSettings().owsActiveWallet;
431
- const agentName = opts.agentName ?? "perp-cli-hl";
432
- const expiresIn = opts.expiresIn ?? "90d";
433
- let expiresAt;
434
- try {
435
- expiresAt = parseExpiresIn(expiresIn);
436
- }
437
- catch (e) {
438
- const msg = e instanceof Error ? e.message : String(e);
439
- reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
440
- remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
441
- }));
442
- }
443
- if (!masterName) {
444
- reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
445
- remediation: "Run: perp setup or use --master <name>",
446
- }));
447
- }
448
- const passphrase = await resolvePassphrase({ flag: passphraseFlag });
449
- if (passphrase === null) {
450
- reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
451
- remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
452
- }));
1075
+ try {
1076
+ if (opts.builder !== undefined || opts.maxFeeRate !== undefined || opts.builderName !== undefined) {
1077
+ // Builder fees are opt-in and approved only for the builder configured in settings.
1078
+ throw new PerpError("INVALID_PARAMS", "--builder / --max-fee-rate / --builder-name moved out of agent approve", {
1079
+ remediation: "Configure the builder (perp settings set builder.<venue> <address|code>,<feeBps>), then: perp wallet builder approve <venue>",
1080
+ });
453
1081
  }
454
- try {
455
- acquireLock("hyperliquid");
1082
+ const network = networkOf(command);
1083
+ // Normalize aliases (UNKNOWN_VENUE for anything unregistered)
1084
+ const venue = resolveVenueId(exchange);
1085
+ // Venues whose keys exist only as website-created imports (qfex) are refused before any prompt / passphrase read.
1086
+ if (getVenue(venue).masterKeyKind === "none")
1087
+ assertAgentApproveSupported(venue, network, "approve");
1088
+ let master = opts.master ?? null;
1089
+ let agentName = opts.agentName ?? null;
1090
+ let expiresIn = opts.expiresIn ?? "90d";
1091
+ let canPerp = opts.canPerp ?? true;
1092
+ let canSpot = opts.canSpot ?? false;
1093
+ let canWithdraw = opts.canWithdraw ?? false;
1094
+ let passphrase = null;
1095
+ // Aster wizard mode: enter only when no identity flags were given, stdin
1096
+ // is a TTY and OWS_PASSPHRASE is unset.
1097
+ const asterWizard = venue === "aster" &&
1098
+ opts.master === undefined &&
1099
+ opts.agentName === undefined &&
1100
+ opts.expiresIn === undefined &&
1101
+ process.stdin.isTTY === true &&
1102
+ process.env["OWS_PASSPHRASE"] === undefined;
1103
+ if (asterWizard) {
1104
+ const rl = createInterface({ input, output });
456
1105
  try {
457
- const result = await runHlApproveFlow({
458
- masterName,
459
- passphrase: passphrase ?? "",
460
- agentName,
461
- expiresAt,
462
- expiresAtIso: expiresAt.toISOString(),
463
- nowIso: new Date().toISOString(),
464
- canPerp: opts.canPerp ?? true,
465
- canSpot: opts.canSpot ?? false,
466
- canWithdraw: opts.canWithdraw ?? false,
467
- });
468
- if (useJson) {
469
- printJson(jsonOk(result));
470
- }
471
- else {
472
- console.log(chalk.green.bold("\n Hyperliquid Agent approved successfully!\n"));
473
- console.log(` Name: ${chalk.cyan(agentName)}`);
474
- console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
475
- console.log(` Expires: ${chalk.cyan(expiresAt.toISOString())}`);
476
- console.log();
477
- }
1106
+ const settings = loadSettings();
1107
+ const defMaster = settings.owsActiveWallet || "main";
1108
+ const rawMaster = await rl.question(`Master wallet name [${defMaster}]: `);
1109
+ master = rawMaster.trim() || defMaster;
1110
+ const rawAgent = await rl.question(`Agent name [perp-cli-aster]: `);
1111
+ agentName = rawAgent.trim() || "perp-cli-aster";
1112
+ const rawExpiry = await rl.question(`Expires-in [90d]: `);
1113
+ expiresIn = rawExpiry.trim() || "90d";
1114
+ const rawPerp = await rl.question(`Can perp trade? [Y/n]: `);
1115
+ canPerp = rawPerp.trim().toLowerCase() !== "n";
1116
+ const rawSpot = await rl.question(`Can spot trade? [y/N]: `);
1117
+ canSpot = rawSpot.trim().toLowerCase() === "y";
1118
+ const rawWithdraw = await rl.question(`Can withdraw? [y/N]: `);
1119
+ canWithdraw = rawWithdraw.trim().toLowerCase() === "y";
1120
+ // Hidden passphrase prompt — readline doesn't support hidden natively;
1121
+ // we use muted output pattern compatible with node:readline/promises
1122
+ process.stdout.write("Master passphrase: ");
1123
+ const rawPp = await rl.question("");
1124
+ // Empty input = unencrypted vault wallet (historical wizard behavior).
1125
+ passphrase = rawPp.trim();
478
1126
  }
479
1127
  finally {
480
- releaseLock("hyperliquid");
1128
+ rl.close();
481
1129
  }
482
1130
  }
483
- catch (err) {
484
- reportErrorAndExit(err);
485
- }
486
- return;
487
- }
488
- // Wizard mode: enter only when ALL THREE conditions hold
489
- const wizardMode = opts.master === undefined &&
490
- opts.agentName === undefined &&
491
- opts.expiresIn === undefined &&
492
- process.stdin.isTTY === true &&
493
- process.env["OWS_PASSPHRASE"] === undefined;
494
- let masterName;
495
- let agentName;
496
- let expiresIn;
497
- let canPerp;
498
- let canSpot;
499
- let canWithdraw;
500
- let passphrase;
501
- if (wizardMode) {
502
- const rl = createInterface({ input, output });
503
- try {
504
- const settings = loadSettings();
505
- const defMaster = settings.owsActiveWallet || "main";
506
- const rawMaster = await rl.question(`Master wallet name [${defMaster}]: `);
507
- masterName = rawMaster.trim() || defMaster;
508
- const rawAgent = await rl.question(`Agent name [perp-cli-aster]: `);
509
- agentName = rawAgent.trim() || "perp-cli-aster";
510
- const rawExpiry = await rl.question(`Expires-in [90d]: `);
511
- expiresIn = rawExpiry.trim() || "90d";
512
- const rawPerp = await rl.question(`Can perp trade? [Y/n]: `);
513
- canPerp = rawPerp.trim().toLowerCase() !== "n";
514
- const rawSpot = await rl.question(`Can spot trade? [y/N]: `);
515
- canSpot = rawSpot.trim().toLowerCase() === "y";
516
- const rawWithdraw = await rl.question(`Can withdraw? [y/N]: `);
517
- canWithdraw = rawWithdraw.trim().toLowerCase() === "y";
518
- // Hidden passphrase prompt — readline doesn't support hidden natively;
519
- // we use muted output pattern compatible with node:readline/promises
520
- process.stdout.write("Master passphrase: ");
521
- const rawPp = await rl.question("");
522
- passphrase = rawPp.trim() || null;
523
- }
524
- finally {
525
- rl.close();
1131
+ else if (venue !== "ondo") {
1132
+ // Aster: --can-withdraw without --ip-whitelist is refused before any passphrase read.
1133
+ if (venue === "aster")
1134
+ assertAsterWithdrawIpWhitelist(canWithdraw, opts.ipWhitelist);
1135
+ // No master wallet on this computer → refuse before asking for a passphrase.
1136
+ requireMaster(master);
1137
+ // Ondo reads its key from stdin, so the passphrase resolver (which may
1138
+ // consume a piped stdin) must not run for it.
1139
+ passphrase = await resolvePassphrase({ flag: passphraseFlag });
526
1140
  }
527
- }
528
- else {
529
- masterName = opts.master ?? loadSettings().owsActiveWallet;
530
- agentName = opts.agentName ?? "perp-cli-aster";
531
- expiresIn = opts.expiresIn ?? "90d";
532
- canPerp = opts.canPerp ?? true;
533
- canSpot = opts.canSpot ?? false;
534
- canWithdraw = opts.canWithdraw ?? false;
535
- // 3-path passphrase resolver (non-TTY path)
536
- passphrase = await resolvePassphrase({ flag: passphraseFlag });
537
- if (passphrase === null) {
538
- // TTY but non-wizard (some flags were supplied) — throw PASSPHRASE_REQUIRED
539
- reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY or wizard skipped", {
540
- remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
541
- }));
1141
+ const outcome = await approveVenueAgent({
1142
+ venue,
1143
+ network,
1144
+ master,
1145
+ agentName,
1146
+ expiresIn,
1147
+ canPerp,
1148
+ canSpot,
1149
+ canWithdraw,
1150
+ rotate: opts.rotate === true,
1151
+ passphrase,
1152
+ apiKeyIndex: opts.apiKeyIndex !== undefined ? Number(opts.apiKeyIndex) : null,
1153
+ forceSlot: opts.force === true,
1154
+ accountIndex: Number(opts.accountIndex),
1155
+ aster: { ipWhitelist: opts.ipWhitelist },
1156
+ ondoKey: null,
1157
+ });
1158
+ if (useJson) {
1159
+ printJson(jsonOk("meta" in outcome ? outcome.meta : outcome.result));
1160
+ return;
542
1161
  }
1162
+ printApproveOutcome(outcome);
543
1163
  }
544
- if (!masterName) {
545
- reportErrorAndExit(new PerpError("INVALID_PARAMS", "Master wallet name is required. Use --master or set owsActiveWallet in settings.", {
546
- remediation: "Run: perp setup or use --master <name>",
547
- }));
548
- }
549
- // Parse expires-in
550
- let expiresAt;
551
- try {
552
- expiresAt = parseExpiresIn(expiresIn);
1164
+ catch (err) {
1165
+ reportErrorAndExit(err);
553
1166
  }
554
- catch (e) {
555
- const msg = e instanceof Error ? e.message : String(e);
556
- reportErrorAndExit(new PerpError("INVALID_PARAMS", msg, {
557
- remediation: "Use: 30d, 90d, 180d, 1y, or ISO-8601 datetime",
558
- }));
559
- }
560
- const expiredMs = expiresAt.getTime();
561
- const expiresAtIso = expiresAt.toISOString();
562
- const nonceMicros = Date.now() * 1000 + (Math.floor(Math.random() * 1000));
563
- const nowIso = new Date().toISOString();
564
- const asterChain = "Mainnet";
1167
+ });
1168
+ // ── agent import <exchange> ── register a key created on the exchange website ──
1169
+ // SSOT Rule #3: the key is read from a hidden prompt or stdin — never argv.
1170
+ agent
1171
+ .command("import <exchange> [key]")
1172
+ .description("Import an agent wallet / API key created on the exchange WEBSITE (master wallet not needed on this computer; key via hidden prompt or stdin — never argv)")
1173
+ .option("--master-address <address>", "Master / owner public address (aster, pacifica, arcus: required; hyperliquid, lighter: optional cross-check)")
1174
+ .option("--account-index <n>", "Lighter / Lighter RH: account index (required). Arcus: subaccount the key is scoped to (optional cross-check)")
1175
+ .option("--api-key-index <n>", "Lighter / Lighter RH: the key's API key slot (required)")
1176
+ .option("--account-id <uuid>", "QFEX: the account the key trades (default: the primary account; required without a terminal — it is the confirmation)")
1177
+ .option("--agent-name <name>", "Local name for the key (default: web-<venue>[-testnet])")
1178
+ .option("--expires-in <duration>", "Expiry shown on the website (30d, 1y, ISO) — recorded when the venue does not report it")
1179
+ .option("--replace", "Replace an existing imported key of the same name (its local copy is deleted)")
1180
+ .addOption(new Option("--secret <secret>", "Refused — keys are never accepted on argv").hideHelp())
1181
+ .option("--json", "Machine-readable output")
1182
+ .addHelpText("after", `
1183
+ Input (never argv): a hidden prompt in a terminal, or stdin when piped —
1184
+ one line with the private key; ondo: two lines (API key id, then secret);
1185
+ qfex: two lines (API public key, then secret).
1186
+
1187
+ Per venue:
1188
+ hyperliquid API wallet private key (More → API on the website). Verified: info userRole = agent of the master.
1189
+ aster API wallet (signer) private key + --master-address (user). Verified: signed GET /fapi/v3/balance.
1190
+ pacifica agent wallet private key + --master-address (account). NOT verifiable without the master (reported "unverified").
1191
+ lighter API key private key + --account-index + --api-key-index. Verified: slot holds a key and an auth token signed by the key is accepted.
1192
+ lighter-rh same as lighter.
1193
+ arcus API key private key (Ed25519) + --master-address (owner). Verified: GET /v1/apiKeys lists its public key ACTIVE.
1194
+ ondo API key id + secret. Verified: authenticated GET /v1/account.
1195
+ qfex API public key + secret [--account-id <uuid>]. Verified: signed reads as the account + Trade WebSocket
1196
+ auth. The key is bound to ONE account UUID, which is shown and must be confirmed (y/N in a terminal;
1197
+ piped: pass --account-id <uuid> equal to it). QFEX keys are created only on the website (2FA).
1198
+
1199
+ The imported key trades. Deposits / withdrawals / transfers / referral / agent approvals need the
1200
+ master wallet: do them on the website, or add the master with 'perp wallet import'.
1201
+ The CLI cannot revoke an imported key: 'wallet agent revoke <venue> <name> --local-only' removes
1202
+ the local copy only — revoke it on the website.
1203
+
1204
+ Examples:
1205
+ perp -n testnet wallet agent import hyperliquid
1206
+ pbpaste | perp wallet agent import aster --master-address 0xYOU
1207
+ perp wallet agent import lighter --account-index 12345 --api-key-index 5
1208
+ printf '%s\\n%s\\n' "$KEY_ID" "$SECRET" | perp wallet agent import ondo
1209
+ printf '%s\\n%s\\n' "$PUBLIC_KEY" "$SECRET" | perp -n testnet wallet agent import qfex --account-id <uuid>`)
1210
+ .action(async (exchange, argvKey, opts, command) => {
1211
+ const useJson = opts.json ?? isJson();
565
1212
  try {
566
- // Step 1: Acquire lock at the START of the flow (before any OWS resources
567
- // are created) so that concurrent approves are fully serialized, not just
568
- // the final settings write.
569
- acquireLock("aster");
570
- try {
571
- const result = await runApproveFlow({
572
- masterName,
573
- passphrase: passphrase ?? "",
574
- agentName,
575
- expiresAt,
576
- expiredMs,
577
- expiresAtIso,
578
- nonceMicros,
579
- nowIso,
580
- canPerp,
581
- canSpot,
582
- canWithdraw,
583
- asterChain,
584
- builder: opts.builder,
585
- maxFeeRate: opts.maxFeeRate,
586
- builderName: opts.builderName,
587
- ipWhitelist: opts.ipWhitelist,
1213
+ // (-k / --private-key is a root option: the root preAction already rejects it.)
1214
+ if (argvKey !== undefined || opts.secret !== undefined) {
1215
+ throw new PerpError("INVALID_PARAMS", "Refusing a key on the command line: it is visible in shell history and the process list", {
1216
+ remediation: "Enter it at the hidden prompt (run in a terminal) or pipe it on stdin (e.g. pbpaste | perp wallet agent import <venue> ...). Clear the key from your shell history.",
588
1217
  });
589
- if (useJson) {
590
- printJson(jsonOk(result));
591
- }
592
- else {
593
- console.log(chalk.green.bold("\n Agent approved successfully!\n"));
594
- console.log(` Name: ${chalk.cyan(agentName)}`);
595
- console.log(` Agent Address: ${chalk.cyan(result.agentAddress)}`);
596
- console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
597
- console.log(` API Key ID: ${chalk.gray(result.owsApiKeyId)}`);
598
- console.log(` Policy ID: ${chalk.gray(result.policyId)}`);
599
- console.log();
600
- }
601
1218
  }
602
- finally {
603
- releaseLock("aster");
1219
+ const network = networkOf(command);
1220
+ const venue = resolveVenueId(exchange);
1221
+ const int = (flag, raw) => {
1222
+ if (raw === undefined)
1223
+ return null;
1224
+ const n = Number(raw);
1225
+ if (!Number.isInteger(n) || n < 0)
1226
+ throw new PerpError("INVALID_PARAMS", `${flag} must be a non-negative integer (got "${raw}")`);
1227
+ return n;
1228
+ };
1229
+ if (opts.accountId !== undefined && venue !== "qfex") {
1230
+ throw new PerpError("INVALID_PARAMS", "--account-id applies to qfex only", { remediation: "Lighter / Arcus use --account-index" });
604
1231
  }
1232
+ const { importVenueAgent, readImportSecret, printImportOutcome } = await import("./agent-import.js");
1233
+ const accountId = opts.accountId ?? null;
1234
+ const params = {
1235
+ venue,
1236
+ network,
1237
+ agentName: opts.agentName ?? null,
1238
+ masterAddress: opts.masterAddress ?? null,
1239
+ accountIndex: int("--account-index", opts.accountIndex),
1240
+ apiKeyIndex: int("--api-key-index", opts.apiKeyIndex),
1241
+ expiresIn: opts.expiresIn ?? null,
1242
+ replace: opts.replace === true,
1243
+ accountId,
1244
+ confirmAccount: venue === "qfex" ? confirmImportAccount(accountId, useJson) : null,
1245
+ };
1246
+ const outcome = await importVenueAgent({ ...params, secret: await readImportSecret(venue) });
1247
+ if (useJson) {
1248
+ printJson(jsonOk(outcome));
1249
+ return;
1250
+ }
1251
+ printImportOutcome(outcome);
605
1252
  }
606
1253
  catch (err) {
607
1254
  reportErrorAndExit(err);
@@ -614,7 +1261,8 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
614
1261
  .option("--json", "Machine-readable output")
615
1262
  .action((exchange, opts) => {
616
1263
  const useJson = opts.json ?? isJson();
617
- const all = listAgents(exchange);
1264
+ const all = listAgents(exchange === undefined ? undefined : resolveVenueId(exchange))
1265
+ .filter(({ exchange: ex }) => exchange === undefined || ex === resolveVenueId(exchange));
618
1266
  const now = Date.now();
619
1267
  const rows = all.map(({ exchange: ex, meta }) => {
620
1268
  let status;
@@ -630,12 +1278,40 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
630
1278
  return {
631
1279
  name: meta.agentName,
632
1280
  exchange: ex,
1281
+ // null = untagged record whose network cannot be determined (shown as "unknown").
1282
+ network: recordedAgentNetworkOf(ex, meta),
1283
+ // "approved" = signed by a master wallet on this machine; "imported" = created on the website.
1284
+ source: agentSourceOf(ex, meta),
633
1285
  evmAddress: meta.agentEvmAddress,
1286
+ // "" = unknown (imported key whose venue does not report an expiry).
634
1287
  expiresAt: meta.expiresAt,
635
1288
  status,
636
- permissions: meta.permissions,
1289
+ // null = set on the exchange website, not known locally (imported keys).
1290
+ permissions: agentSourceOf(ex, meta) === "imported" ? null : meta.permissions,
1291
+ warning: null,
637
1292
  };
638
1293
  });
1294
+ // Ondo / Arcus keys use venue-specific metas (no EVM agent address / permission set).
1295
+ const venueFilter = exchange === undefined ? null : resolveVenueId(exchange);
1296
+ for (const { exchange: ex, meta } of listVenueAgents()) {
1297
+ if (venueFilter !== null && venueFilter !== ex)
1298
+ continue;
1299
+ const expiresAt = meta.kind === "arcus" ? meta.validUntil : null;
1300
+ const status = expiresAt !== null && expiresAt !== "" && new Date(expiresAt).getTime() < now ? "expired" : "active";
1301
+ rows.push({
1302
+ name: meta.agentName,
1303
+ exchange: ex,
1304
+ network: meta.network,
1305
+ source: agentSourceOf(ex, meta),
1306
+ evmAddress: meta.kind === "arcus" ? meta.ownerAddress : "",
1307
+ // Ondo keys carry no expiry; shown as "—".
1308
+ expiresAt: expiresAt ?? "",
1309
+ status,
1310
+ // QFEX permissions are set on the website and cannot be read back → null (unknown).
1311
+ permissions: meta.kind === "qfex" ? null : { canPerpTrade: true, canSpotTrade: false, canWithdraw: false },
1312
+ warning: meta.kind === "qfex" ? qfexWithdrawKeyWarning(meta) : null,
1313
+ });
1314
+ }
639
1315
  if (useJson) {
640
1316
  printJson(jsonOk(rows));
641
1317
  return;
@@ -645,8 +1321,8 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
645
1321
  return;
646
1322
  }
647
1323
  console.log(chalk.cyan.bold("\n Registered Agent Wallets\n"));
648
- const header = ["NAME", "EXCHANGE", "EVM ADDR", "EXPIRES", "STATUS", "PERP", "SPOT", "WITHDRAW"];
649
- const colWidths = [20, 10, 14, 26, 9, 6, 6, 10];
1324
+ const header = ["NAME", "EXCHANGE", "NETWORK", "SOURCE", "EVM ADDR", "EXPIRES", "STATUS", "PERP", "SPOT", "WITHDRAW"];
1325
+ const colWidths = [20, 10, 8, 8, 14, 26, 9, 6, 6, 10];
650
1326
  console.log(" " + header.map((h, i) => chalk.bold(h.padEnd(colWidths[i]))).join(" "));
651
1327
  console.log(" " + header.map((_, i) => "─".repeat(colWidths[i])).join(" "));
652
1328
  for (const r of rows) {
@@ -655,30 +1331,137 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
655
1331
  const cols = [
656
1332
  r.name.slice(0, 19),
657
1333
  r.exchange,
1334
+ r.network ?? "unknown",
1335
+ r.source,
658
1336
  addr,
659
- new Date(r.expiresAt).toLocaleDateString(),
1337
+ r.expiresAt ? new Date(r.expiresAt).toLocaleDateString() : r.source === "imported" ? "unknown" : "—",
660
1338
  statusColor(r.status),
661
- r.permissions.canPerpTrade ? chalk.green("Y") : chalk.gray("N"),
662
- r.permissions.canSpotTrade ? chalk.green("Y") : chalk.gray("N"),
663
- r.permissions.canWithdraw ? chalk.green("Y") : chalk.gray("N"),
1339
+ ...(r.permissions === null
1340
+ ? [chalk.gray("?"), chalk.gray("?"), chalk.gray("?")]
1341
+ : [
1342
+ r.permissions.canPerpTrade ? chalk.green("Y") : chalk.gray("N"),
1343
+ r.permissions.canSpotTrade ? chalk.green("Y") : chalk.gray("N"),
1344
+ r.permissions.canWithdraw === null ? chalk.gray("?") : r.permissions.canWithdraw ? chalk.green("Y") : chalk.gray("N"),
1345
+ ]),
664
1346
  ];
665
1347
  console.log(" " + cols.map((c, i) => String(c).padEnd(colWidths[i])).join(" "));
666
1348
  }
1349
+ if (rows.some(r => r.source === "imported")) {
1350
+ console.log(chalk.gray("\n imported = created on the exchange website; PERP/SPOT/WITHDRAW \"?\" = set on the website. The CLI cannot revoke these (revoke on the website)."));
1351
+ }
1352
+ for (const r of rows)
1353
+ if (r.warning !== null)
1354
+ console.log(chalk.red.bold(`\n [WARNING] ${r.warning}`));
667
1355
  console.log();
668
1356
  });
669
1357
  // ── agent revoke <exchange> <agentName> ─────────────────────────────────
670
1358
  agent
671
1359
  .command("revoke <exchange> <agentName>")
672
- .description("Revoke an agent wallet registration")
673
- .option("--force", "Skip Aster POST, only clear local state")
674
- .option("--passphrase <pp>", "Master OWS passphrase")
1360
+ .description("Revoke an agent wallet registration (imported keys: --local-only — revoke them on the website)")
1361
+ .option("--force", "Skip the venue revoke call, only clear local state")
1362
+ .option("--local-only", "Only remove the local record (and, for imported keys, the local key copy) — nothing is sent; the key stays authorized on the exchange")
1363
+ .addOption(deprecatedPassphraseOption()).hook("preAction", warnDeprecatedPassphrase)
1364
+ .option("--master <name>", "Master OWS wallet (Arcus revoke). Defaults to settings.owsActiveWallet")
675
1365
  .option("--json", "Machine-readable output")
676
- .action(async (exchange, agentName, opts, command) => {
1366
+ .action(async (exchangeArg, agentName, opts, command) => {
677
1367
  const useJson = opts.json ?? isJson();
1368
+ let exchange;
1369
+ let network;
1370
+ const localOnly = opts.force === true || opts.localOnly === true;
1371
+ try {
1372
+ exchange = resolveVenueId(exchangeArg);
1373
+ network = networkOf(command);
1374
+ // An imported key is only ever removed locally, so it needs no venue support for revoke
1375
+ // (e.g. an imported Aster testnet key).
1376
+ const rec = exchange === "ondo" || exchange === "arcus" || exchange === "qfex" ? getVenueAgent(exchange, agentName) : getAgent(exchange, agentName);
1377
+ // QFEX keys only exist as imports: a missing record is "already removed", never an approve-capability error.
1378
+ if (exchange !== "qfex" && (rec === null || agentSourceOf(exchange, rec) !== "imported"))
1379
+ assertAgentApproveSupported(exchange, network, "revoke");
1380
+ }
1381
+ catch (e) {
1382
+ reportErrorAndExit(e);
1383
+ }
678
1384
  // optsWithGlobals(): see wallet agent approve for full rationale.
679
1385
  const mergedOpts = command.optsWithGlobals();
680
1386
  const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
681
1387
  try {
1388
+ // ── Ondo: the key stays valid on the venue — revoke it in the Ondo Perps UI ──
1389
+ if (exchange === "ondo") {
1390
+ const ondoMeta = getVenueAgent("ondo", agentName);
1391
+ if (!ondoMeta) {
1392
+ if (useJson)
1393
+ printJson({ ok: true, data: { alreadyRevoked: true } });
1394
+ else
1395
+ console.log(`Agent "${agentName}" on ondo not found locally — already revoked.`);
1396
+ return;
1397
+ }
1398
+ const { revokeOndoAgentLocal } = await import("../exchanges/ondo/agent.js");
1399
+ const keystoreRemoved = revokeOndoAgentLocal(ondoMeta.keyId);
1400
+ deleteVenueAgent("ondo", agentName);
1401
+ const note = "The key is still valid on Ondo Perps — delete it in the Ondo Perps UI (API Keys).";
1402
+ if (useJson)
1403
+ printJson(jsonOk({ revoked: true, local: true, keystoreRemoved, note }));
1404
+ else
1405
+ console.log(chalk.green(`Agent "${agentName}" on ondo removed locally.`) + chalk.yellow(`\n ${note}`));
1406
+ return;
1407
+ }
1408
+ // ── QFEX: local removal only — the key is revoked in QFEX Developer Settings (website, 2FA) ──
1409
+ if (exchange === "qfex") {
1410
+ const qfexMeta = getVenueAgent("qfex", agentName);
1411
+ if (!qfexMeta) {
1412
+ if (useJson)
1413
+ printJson({ ok: true, data: { alreadyRevoked: true } });
1414
+ else
1415
+ console.log(`Agent "${agentName}" on qfex not found locally — already removed.`);
1416
+ return;
1417
+ }
1418
+ const { revokeQfexAgentLocal } = await import("../exchanges/qfex/agent.js");
1419
+ const keystoreRemoved = revokeQfexAgentLocal(qfexMeta.publicKey);
1420
+ deleteVenueAgent("qfex", agentName);
1421
+ const note = "The key is still valid on QFEX — revoke it in QFEX Developer Settings (website) to invalidate it.";
1422
+ if (useJson)
1423
+ printJson(jsonOk({ revoked: true, local: true, keystoreRemoved, note }));
1424
+ else
1425
+ console.log(chalk.green(`Agent "${agentName}" on qfex removed locally.`) + chalk.yellow(`\n ${note}`));
1426
+ return;
1427
+ }
1428
+ // ── Arcus: EIP-712 RevokeApiKey signed by the master wallet ──
1429
+ if (exchange === "arcus") {
1430
+ const arcusMeta = getVenueAgent("arcus", agentName);
1431
+ if (!arcusMeta) {
1432
+ if (useJson)
1433
+ printJson({ ok: true, data: { alreadyRevoked: true } });
1434
+ else
1435
+ console.log(`Agent "${agentName}" on arcus not found locally — already revoked.`);
1436
+ return;
1437
+ }
1438
+ if (arcusMeta.network !== network) {
1439
+ throw new PerpError("AGENT_NOT_REGISTERED", `Arcus API key "${agentName}" is registered on ${arcusMeta.network}, not ${network}`, {
1440
+ remediation: arcusMeta.network === "mainnet" ? "Run without -n testnet to revoke it" : "Run with -n testnet to revoke it",
1441
+ });
1442
+ }
1443
+ if (agentSourceOf("arcus", arcusMeta) === "imported") {
1444
+ await removeImportedLocally("arcus", network, arcusMeta, agentName, localOnly, useJson);
1445
+ return;
1446
+ }
1447
+ if (!localOnly) {
1448
+ const masterName = opts.master ?? loadSettings().owsActiveWallet;
1449
+ if (!masterName) {
1450
+ throw new PerpError("INVALID_PARAMS", "Master wallet name is required to revoke an Arcus key", {
1451
+ remediation: "Use --master <name>, or --force to clear local state only",
1452
+ });
1453
+ }
1454
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
1455
+ const { revokeArcusAgent } = await import("../exchanges/arcus/agent.js");
1456
+ await revokeArcusAgent({ meta: arcusMeta, masterOwsWallet: masterName, passphrase, network });
1457
+ }
1458
+ deleteVenueAgent("arcus", agentName);
1459
+ if (useJson)
1460
+ printJson(jsonOk({ revoked: true }));
1461
+ else
1462
+ console.log(chalk.green(`Agent "${agentName}" on arcus revoked successfully.`));
1463
+ return;
1464
+ }
682
1465
  // Step 1: Check local meta (idempotent — absent = already revoked)
683
1466
  const meta = getAgent(exchange, agentName);
684
1467
  if (!meta) {
@@ -691,7 +1474,13 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
691
1474
  }
692
1475
  return;
693
1476
  }
694
- if (!opts.force) {
1477
+ // A mainnet agent is never revoked (or locally forgotten) from a testnet run, and vice versa.
1478
+ assertAgentNetwork(exchange, meta, network);
1479
+ if (agentSourceOf(exchange, meta) === "imported") {
1480
+ await removeImportedLocally(exchange, network, meta, agentName, localOnly, useJson);
1481
+ return;
1482
+ }
1483
+ if (!localOnly) {
695
1484
  // Step 2: Resolve passphrase
696
1485
  const passphrase = await resolvePassphrase({ flag: passphraseFlag });
697
1486
  if (passphrase === null) {
@@ -700,69 +1489,37 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
700
1489
  });
701
1490
  }
702
1491
  if (exchange === "hyperliquid") {
703
- // HL revoke: POST approveAgent with zero-address (best-effort, idempotent)
1492
+ // HL revoke: POST approveAgent with zero-address under the agent's name.
1493
+ // Rule #2: do NOT swallow — a failed/unknown revoke throws APPROVE_FAILED
1494
+ // before the local record is deleted (Step 6), like Pacifica / Aster.
704
1495
  const masterName = meta.masterWalletName;
705
1496
  const masterSigner = OwsEvmSigner.create(masterName, passphrase);
706
- try {
707
- await revokeHlAgent(masterSigner);
708
- }
709
- catch { /* best effort — HL revoke is idempotent */ }
1497
+ await revokeHlAgent(masterSigner, network, meta.agentName, meta.agentEvmAddress);
710
1498
  }
711
1499
  else if (exchange === "pacifica") {
712
- // PAC revoke: signed unbind_agent_wallet with the agent base58 address
1500
+ // PAC revoke: signed revoke_agent_wallet → POST /agent/revoke.
1501
+ // Rule #2: do NOT swallow. A refused revoke leaves the agent key
1502
+ // authorized to trade; suppressing it here would delete the local
1503
+ // record (Step 6) and leave the user believing the key is dead.
1504
+ // Mirrors the Aster branch below: fail, and offer --force for a
1505
+ // local-only cleanup.
713
1506
  const masterName = meta.masterWalletName;
714
1507
  const solanaSigner = OwsSolanaSigner.create(masterName, passphrase);
715
- try {
716
- await revokePacAgent(solanaSigner, meta.agentSolanaAddress ?? "");
717
- }
718
- catch { /* best effort — Pacifica revoke is idempotent */ }
1508
+ await revokePacAgent(solanaSigner, meta.agentSolanaAddress ?? null, network);
719
1509
  }
720
- else if (exchange === "lighter" || exchange === "lt") {
721
- // LT revoke: Lighter has no on-chain revoke action; revoke is local-only.
722
- // Slots persist on the L2 server until overwritten by a fresh ChangePubKey
723
- // at the same index. revokeLtAgent is a no-op stub that exists for
724
- // symmetry with the other DEX revoke helpers.
725
- try {
726
- await revokeLtAgent();
727
- }
728
- catch { /* best effort — LT revoke is idempotent */ }
1510
+ else if (isLighterFamilyVenue(exchange)) {
1511
+ // LT / LT-RH revoke: ChangePubKey the slot to a throwaway key (private half
1512
+ // discarded) — the old agent key stops working — then delete its keystore file.
1513
+ // Rule #2: a failed ChangePubKey throws before the local record is deleted.
1514
+ await revokeLtAgent(exchange, network, meta, passphrase);
1515
+ }
1516
+ else if (exchange !== "aster") {
1517
+ throw new PerpError("UNKNOWN_VENUE", `No revoke path for ${exchange}`);
729
1518
  }
730
1519
  else {
731
- // Aster revoke: Step 3: Build DelAgent EIP-712
732
- const masterName = meta.masterWalletName;
733
- const masterSigner = OwsEvmSigner.create(masterName, passphrase);
734
- const userEvmAddress = masterSigner.getAddress();
735
- const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
736
- const delTypedData = buildDelAgentTypedData({
737
- user: userEvmAddress,
738
- agentAddress: meta.agentEvmAddress,
739
- nonceMicros,
740
- asterChain: "Mainnet",
741
- });
742
- const signature = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
743
- // Step 4: POST DELETE /fapi/v3/agent
744
- const baseUrl = "https://fapi.asterdex.com";
745
- const qsEntries = [
746
- ["agentAddress", meta.agentEvmAddress],
747
- ["asterChain", "Mainnet"],
748
- ["user", userEvmAddress],
749
- ["nonce", String(nonceMicros)],
750
- ["signature", signature],
751
- ["signatureChainId", "56"],
752
- ];
753
- const qs = new URLSearchParams(Object.fromEntries(qsEntries));
754
- const revokeRes = await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
755
- method: "DELETE",
756
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
757
- body: "",
758
- });
759
- // Idempotent: 404 = already revoked server-side, treat as success
760
- if (!revokeRes.ok && revokeRes.status !== 404) {
761
- const errText = await revokeRes.text().catch(() => "");
762
- throw new PerpError("EXCHANGE_ERROR", `Aster agent delete failed (${revokeRes.status}): ${errText.slice(0, 200)}`, {
763
- remediation: "Use --force to skip Aster POST and only clear local state",
764
- });
765
- }
1520
+ // Aster revoke: signed DelAgent → DELETE /fapi/v3/agent
1521
+ const masterSigner = OwsEvmSigner.create(meta.masterWalletName, passphrase);
1522
+ await assertAsterAgentDeleted(await deleteAsterAgent(masterSigner, meta.agentEvmAddress, network), meta.agentEvmAddress, "Retry, or use --force to clear only the local record (the agent stays authorized on Aster until deleted in the Aster UI).");
766
1523
  }
767
1524
  }
768
1525
  // Step 5: Revoke OWS API key (Aster only — HL has no OWS key)
@@ -773,9 +1530,26 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
773
1530
  }
774
1531
  catch { /* best effort */ }
775
1532
  }
776
- // Step 6: Delete local agent entry
1533
+ // Step 5b: the venue confirmed the revoke — delete the now-dead OWS agent wallet, so a
1534
+ // later approve/rotate can recreate `agent-<dex>-<master>` (OWS refuses duplicate names).
1535
+ if (!localOnly && (exchange === "hyperliquid" || exchange === "pacifica" || exchange === "aster")) {
1536
+ deleteRevokedAgentWallet(exchange, meta);
1537
+ }
1538
+ // Step 6: Delete local agent entry (--force: also the Lighter-family slot keystore file)
1539
+ if (localOnly && isLighterFamilyVenue(exchange) && typeof meta.accountIndex === "number" && typeof meta.apiKeyIndex === "number") {
1540
+ const { deleteLighterFamilyKey } = await import("../agent-wallet/lighter-keystore.js");
1541
+ deleteLighterFamilyKey(exchange, network, meta.accountIndex, meta.apiKeyIndex);
1542
+ }
777
1543
  deleteAgent(exchange, agentName);
778
- if (useJson) {
1544
+ if (localOnly) {
1545
+ // Local-only: nothing was sent, so the key is NOT revoked on the venue.
1546
+ const note = `The agent key was NOT revoked on ${exchange} and may still be authorized — revoke it in the venue UI, or re-run without --force.`;
1547
+ if (useJson)
1548
+ printJson(jsonOk({ revoked: false, localOnly: true, note }));
1549
+ else
1550
+ console.log(chalk.yellow(`Agent "${agentName}" on ${exchange} removed locally only. ${note}`));
1551
+ }
1552
+ else if (useJson) {
779
1553
  printJson(jsonOk({ revoked: true }));
780
1554
  }
781
1555
  else {
@@ -790,220 +1564,58 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
790
1564
  agent
791
1565
  .command("rotate <exchange> [oldAgentName]")
792
1566
  .description("Rotate an agent wallet: revoke existing, then approve with same name")
793
- .option("--passphrase <pp>", "Master OWS passphrase")
794
- .option("--master <name>", "Master OWS wallet name")
1567
+ .addOption(deprecatedPassphraseOption()).hook("preAction", warnDeprecatedPassphrase)
1568
+ .option("--master <name>", "Master OWS wallet name (default: the old agent's master, then settings.owsActiveWallet)")
795
1569
  .option("--agent-name <name>", "Agent name to rotate (default: first registered agent)")
796
1570
  .option("--expires-in <duration>", "New expiry (default: 90d)")
797
1571
  .option("--can-perp", "Allow perp trading (default: inherit or on)")
798
1572
  .option("--no-can-perp", "Disallow perp trading")
799
1573
  .option("--can-spot", "Allow spot trading (default: inherit or off)")
800
1574
  .option("--no-can-spot", "Disallow spot trading")
801
- .option("--can-withdraw", "Allow withdrawals (default: inherit or off)")
802
- .option("--no-can-withdraw", "Disallow withdrawals")
1575
+ .option("--can-withdraw", "Allow withdrawals (default: off — never inherited from the old agent; Aster also needs --ip-whitelist)")
1576
+ .option("--no-can-withdraw", "Disallow withdrawals (the default)")
1577
+ .option("--ip-whitelist <list>", "Aster IP whitelist, comma- or space-separated (required with --can-withdraw)")
803
1578
  .option("--json", "Machine-readable output")
804
- .action(async (exchange, oldAgentNameArg, opts, command) => {
1579
+ .action(async (exchangeArg, oldAgentNameArg, opts, command) => {
805
1580
  const useJson = opts.json ?? isJson();
806
1581
  // optsWithGlobals(): see wallet agent approve for full rationale.
807
1582
  const mergedOpts = command.optsWithGlobals();
808
1583
  const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
809
1584
  try {
810
- // Determine which agent to rotate
811
- const agentNameToRotate = oldAgentNameArg ?? opts.agentName ?? getAgent(exchange)?.agentName;
1585
+ const exchange = resolveVenueId(exchangeArg);
1586
+ if (exchange !== "aster" && exchange !== "hyperliquid" && exchange !== "pacifica" && !isLighterFamilyVenue(exchange)) {
1587
+ throw new PerpError("NOT_IMPLEMENTED", `rotate is not supported for ${exchange}`, {
1588
+ remediation: `Run: perp wallet agent revoke ${exchange} <name>, then perp wallet agent approve ${exchange}`,
1589
+ });
1590
+ }
1591
+ const network = networkOf(command);
1592
+ // Determine which agent to rotate (default: first agent registered on this network)
1593
+ const agentNameToRotate = oldAgentNameArg ?? opts.agentName ?? getAgentForNetwork(exchange, network)?.agentName;
1594
+ const imported = agentNameToRotate !== undefined ? getAgent(exchange, agentNameToRotate) : null;
1595
+ if (imported === null || agentSourceOf(exchange, imported) !== "imported")
1596
+ assertAgentApproveSupported(exchange, network, "rotate");
812
1597
  if (!agentNameToRotate) {
813
1598
  throw new PerpError("AGENT_NOT_REGISTERED", `No agent found for ${exchange}`, {
814
1599
  remediation: `Run: perp wallet agent approve ${exchange} to create one`,
815
1600
  });
816
1601
  }
817
- // Resolve passphrase early (needed for both revoke and approve phases)
818
- const passphrase = await resolvePassphrase({ flag: passphraseFlag });
819
- if (passphrase === null) {
820
- throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
821
- remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
822
- });
823
- }
824
- const settings = loadSettings();
825
- const masterName = opts.master ?? settings.owsActiveWallet;
826
- // Capture existing agent meta before revoke (for permission inheritance)
827
- const existing = getAgent(exchange, agentNameToRotate);
828
- // Resolve permissions: explicit flags > old agent's permissions > defaults.
829
- // With --no-can-perp/spot/withdraw pattern, opts values are undefined when not passed.
830
- const permFlagsProvided = opts.canPerp !== undefined || opts.canSpot !== undefined || opts.canWithdraw !== undefined;
831
- let canPerp;
832
- let canSpot;
833
- let canWithdraw;
834
- if (permFlagsProvided) {
835
- canPerp = opts.canPerp ?? true;
836
- canSpot = opts.canSpot ?? false;
837
- canWithdraw = opts.canWithdraw ?? false;
838
- }
839
- else if (existing?.permissions) {
840
- // Preserve old agent's permission set when flags are absent
841
- canPerp = existing.permissions.canPerpTrade;
842
- canSpot = existing.permissions.canSpotTrade;
843
- canWithdraw = existing.permissions.canWithdraw;
1602
+ const outcome = await rotateVenueAgent({
1603
+ exchange, network, agentNameToRotate,
1604
+ master: opts.master || null,
1605
+ passphrase: () => resolvePassphrase({ flag: passphraseFlag }),
1606
+ expiresIn: opts.expiresIn ?? "90d",
1607
+ canPerp: opts.canPerp, canSpot: opts.canSpot, canWithdraw: opts.canWithdraw,
1608
+ ipWhitelist: opts.ipWhitelist,
1609
+ });
1610
+ const rotatedAgentAddress = rotatedAgentAddressOf(outcome);
1611
+ const expiresAtIso = outcome.expiresAt;
1612
+ if (useJson) {
1613
+ printJson(jsonOk({ rotated: true, agentName: agentNameToRotate, agentEvmAddress: rotatedAgentAddress, expiresAt: expiresAtIso }));
844
1614
  }
845
1615
  else {
846
- canPerp = true;
847
- canSpot = false;
848
- canWithdraw = false;
849
- }
850
- // Revoke existing (if present) — best effort
851
- if (existing) {
852
- try {
853
- if (exchange === "pacifica") {
854
- // Pacifica master is a Solana keypair; can't reuse the EVM master signer.
855
- const revokePacSigner = OwsSolanaSigner.create(existing.masterWalletName, passphrase);
856
- await revokePacAgent(revokePacSigner, existing.agentSolanaAddress ?? "");
857
- if (existing.owsApiKeyId) {
858
- try {
859
- loadOws().revokeApiKey(existing.owsApiKeyId);
860
- }
861
- catch { /* best effort */ }
862
- }
863
- deleteAgent(exchange, agentNameToRotate);
864
- // Continue to the shared approve flow below.
865
- // (skip the remaining EVM-revoke branch via early continuation)
866
- }
867
- else if (exchange === "lighter" || exchange === "lt") {
868
- // LT rotate: no on-chain revoke; just clear local entry. The new
869
- // approve picks the next free slot, leaving the old slot orphaned
870
- // on the L2 server (it cannot be used again until overwritten via
871
- // a fresh ChangePubKey at that index — which the next approve does
872
- // implicitly when it picks max+1).
873
- await revokeLtAgent();
874
- if (existing.owsApiKeyId) {
875
- try {
876
- loadOws().revokeApiKey(existing.owsApiKeyId);
877
- }
878
- catch { /* best effort */ }
879
- }
880
- deleteAgent(exchange, agentNameToRotate);
881
- // Continue to the shared approve flow below.
882
- }
883
- else {
884
- const revokeMs = OwsEvmSigner.create(existing.masterWalletName, passphrase);
885
- if (exchange === "hyperliquid") {
886
- // HL: revoke via approveAgent with zero-address
887
- await revokeHlAgent(revokeMs);
888
- }
889
- else {
890
- // Aster: DELETE /fapi/v3/agent
891
- const userEvmAddress = revokeMs.getAddress();
892
- const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
893
- const delTypedData = buildDelAgentTypedData({
894
- user: userEvmAddress,
895
- agentAddress: existing.agentEvmAddress,
896
- nonceMicros,
897
- asterChain: "Mainnet",
898
- });
899
- const sig = await revokeMs.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
900
- const baseUrl = "https://fapi.asterdex.com";
901
- const qs = new URLSearchParams(Object.fromEntries([
902
- ["agentAddress", existing.agentEvmAddress],
903
- ["asterChain", "Mainnet"],
904
- ["user", userEvmAddress],
905
- ["nonce", String(nonceMicros)],
906
- ["signature", sig],
907
- ["signatureChainId", "56"],
908
- ]));
909
- await fetch(`${baseUrl}/fapi/v3/agent?${qs.toString()}`, {
910
- method: "DELETE",
911
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
912
- body: "",
913
- });
914
- }
915
- if (existing.owsApiKeyId) {
916
- try {
917
- loadOws().revokeApiKey(existing.owsApiKeyId);
918
- }
919
- catch { /* best effort */ }
920
- }
921
- deleteAgent(exchange, agentNameToRotate);
922
- }
923
- }
924
- catch { /* best effort on revoke — continue to approve */ }
925
- }
926
- const expiresIn = opts.expiresIn ?? "90d";
927
- const expiresAt = parseExpiresIn(expiresIn);
928
- const expiresAtIso = expiresAt.toISOString();
929
- const nowIso = new Date().toISOString();
930
- // Acquire lock and run the shared approve flow
931
- acquireLock(exchange);
932
- try {
933
- let rotatedAgentAddress;
934
- if (exchange === "hyperliquid") {
935
- const result = await runHlApproveFlow({
936
- masterName,
937
- passphrase,
938
- agentName: agentNameToRotate,
939
- expiresAt,
940
- expiresAtIso,
941
- nowIso,
942
- canPerp,
943
- canSpot,
944
- canWithdraw,
945
- });
946
- rotatedAgentAddress = result.agentAddress;
947
- }
948
- else if (exchange === "pacifica") {
949
- const result = await runPacApproveFlow({
950
- masterName,
951
- passphrase,
952
- agentName: agentNameToRotate,
953
- expiresAt,
954
- expiresAtIso,
955
- nowIso,
956
- canPerp,
957
- canSpot,
958
- canWithdraw,
959
- });
960
- rotatedAgentAddress = result.agentSolanaAddress;
961
- }
962
- else if (exchange === "lighter" || exchange === "lt") {
963
- const result = await runLtApproveFlow({
964
- masterName,
965
- passphrase,
966
- agentName: agentNameToRotate,
967
- expiresAt,
968
- expiresAtIso,
969
- nowIso,
970
- canPerp,
971
- canSpot,
972
- canWithdraw,
973
- // No apiKeyIndex override on rotate — picks next free slot.
974
- });
975
- rotatedAgentAddress = `slot=${result.apiKeyIndex} pubkey=${result.publicKey}`;
976
- }
977
- else {
978
- const expiredMs = expiresAt.getTime();
979
- const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
980
- const result = await runApproveFlow({
981
- masterName,
982
- passphrase,
983
- agentName: agentNameToRotate,
984
- expiresAt,
985
- expiredMs,
986
- expiresAtIso,
987
- nonceMicros,
988
- nowIso,
989
- canPerp,
990
- canSpot,
991
- canWithdraw,
992
- asterChain: "Mainnet",
993
- });
994
- rotatedAgentAddress = result.agentAddress;
995
- }
996
- if (useJson) {
997
- printJson(jsonOk({ rotated: true, agentName: agentNameToRotate, agentEvmAddress: rotatedAgentAddress, expiresAt: expiresAtIso }));
998
- }
999
- else {
1000
- console.log(chalk.green(`Agent "${agentNameToRotate}" on ${exchange} rotated successfully.`));
1001
- console.log(` New agent address: ${chalk.cyan(rotatedAgentAddress)}`);
1002
- console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
1003
- }
1004
- }
1005
- finally {
1006
- releaseLock(exchange);
1616
+ console.log(chalk.green(`Agent "${agentNameToRotate}" on ${exchange} rotated successfully.`));
1617
+ console.log(` New agent address: ${chalk.cyan(rotatedAgentAddress)}`);
1618
+ console.log(` Expires: ${chalk.cyan(expiresAtIso)}`);
1007
1619
  }
1008
1620
  }
1009
1621
  catch (err) {
@@ -1017,7 +1629,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1017
1629
  .option("--master <name>", "Master OWS wallet name (Aster/PAC). Defaults to settings.owsActiveWallet")
1018
1630
  .option("--master-address <addr>", "Master EVM address (HL only)")
1019
1631
  .option("--account-index <n>", "Account index (Lighter only)")
1020
- .option("--passphrase <pp>", "Master OWS passphrase (Aster/PAC). Falls back to OWS_PASSPHRASE env.")
1632
+ .addOption(deprecatedPassphraseOption()).hook("preAction", warnDeprecatedPassphrase)
1021
1633
  .option("--json", "Machine-readable output")
1022
1634
  .action(async (exchange, agentName, opts, command) => {
1023
1635
  const useJson = opts.json ?? isJson();
@@ -1035,13 +1647,16 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1035
1647
  passphrase,
1036
1648
  };
1037
1649
  if (!exchange) {
1038
- // Aggregate mode: all 4 DEXs in parallel
1039
- const [asterResult, hlResult, pacResult, ltResult] = await Promise.allSettled([
1040
- verifyAster(verifyOpts),
1041
- verifyHyperliquid(verifyOpts),
1042
- verifyPacifica(verifyOpts),
1043
- verifyLighter(verifyOpts),
1044
- ]);
1650
+ // Aggregate mode: every account venue with configured credentials, in parallel
1651
+ let network;
1652
+ try {
1653
+ network = networkOf(command);
1654
+ }
1655
+ catch (err) {
1656
+ reportErrorAndExit(err);
1657
+ }
1658
+ const venues = await listConfiguredAccountVenues();
1659
+ const results = await Promise.allSettled(venues.map(v => verifyVenue(v, network, verifyOpts)));
1045
1660
  function slotResult(r) {
1046
1661
  if (r.status === "fulfilled") {
1047
1662
  const slot = { registered: r.value.registered, count: r.value.count, items: r.value.items };
@@ -1058,12 +1673,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1058
1673
  },
1059
1674
  };
1060
1675
  }
1061
- const data = {
1062
- aster: slotResult(asterResult),
1063
- hyperliquid: slotResult(hlResult),
1064
- pacifica: slotResult(pacResult),
1065
- lighter: slotResult(ltResult),
1066
- };
1676
+ const data = Object.fromEntries(venues.map((v, i) => [v, slotResult(results[i])]));
1067
1677
  if (useJson) {
1068
1678
  printJson({ ok: true, data, meta: { timestamp: ts } });
1069
1679
  }
@@ -1087,25 +1697,8 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1087
1697
  }
1088
1698
  // Single-DEX mode
1089
1699
  try {
1090
- let result;
1091
- const ex = exchange.toLowerCase();
1092
- if (ex === "aster") {
1093
- result = await verifyAster(verifyOpts);
1094
- }
1095
- else if (ex === "hyperliquid" || ex === "hl") {
1096
- result = await verifyHyperliquid(verifyOpts);
1097
- }
1098
- else if (ex === "pacifica" || ex === "pac") {
1099
- result = await verifyPacifica(verifyOpts);
1100
- }
1101
- else if (ex === "lighter" || ex === "lt") {
1102
- result = await verifyLighter(verifyOpts);
1103
- }
1104
- else {
1105
- throw new PerpError("INVALID_PARAMS", `Unknown exchange "${exchange}". Use: aster, hyperliquid, pacifica, lighter`, {
1106
- remediation: "perp wallet agent verify <aster|hyperliquid|pacifica|lighter> [agentName]",
1107
- });
1108
- }
1700
+ const ex = resolveVenueId(exchange);
1701
+ const result = await verifyVenue(ex, networkOf(command), verifyOpts);
1109
1702
  if (useJson) {
1110
1703
  printJson({
1111
1704
  ok: true,
@@ -1130,7 +1723,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1130
1723
  console.log(` ${addr} | ${name} | ${exp} | ${cp.padEnd(7)} | ${cs.padEnd(7)} | ${cw}`);
1131
1724
  }
1132
1725
  }
1133
- else if (ex === "hyperliquid" || ex === "hl") {
1726
+ else if (ex === "hyperliquid") {
1134
1727
  console.log(chalk.dim(" address | validUntil | name"));
1135
1728
  for (const item of result.items) {
1136
1729
  const i = item;
@@ -1140,7 +1733,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1140
1733
  console.log(` ${addr} | ${vu.padEnd(11)} | ${name}`);
1141
1734
  }
1142
1735
  }
1143
- else if (ex === "lighter" || ex === "lt") {
1736
+ else if (isLighterFamilyVenue(ex)) {
1144
1737
  console.log(chalk.dim(" api_key_index | public_key (truncated) | nonce | transaction_time"));
1145
1738
  for (const item of result.items) {
1146
1739
  const i = item;
@@ -1151,7 +1744,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1151
1744
  console.log(` ${idx.padEnd(13)} | ${pk.padEnd(23)} | ${nonce.padEnd(5)} | ${tt}`);
1152
1745
  }
1153
1746
  }
1154
- else if (ex === "pacifica" || ex === "pac") {
1747
+ else if (ex === "pacifica") {
1155
1748
  console.log(chalk.dim(" api_key (truncated) | created_at"));
1156
1749
  for (const item of result.items) {
1157
1750
  const i = item;
@@ -1173,6 +1766,47 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1173
1766
  }
1174
1767
  });
1175
1768
  }
1769
+ /**
1770
+ * Revoke for an IMPORTED key: the CLI holds no master wallet signature for it, so it never claims
1771
+ * a venue revoke. Without --local-only → refused; with it → the local key copy and record are
1772
+ * deleted and the user is told the key is still authorized on the exchange.
1773
+ */
1774
+ async function removeImportedLocally(exchange, network, meta, agentName, localOnly, useJson) {
1775
+ const where = `${getVenue(exchange).displayName} website`;
1776
+ if (!localOnly) {
1777
+ throw new PerpError("INVALID_PARAMS", `${exchange} agent "${agentName}" was imported from the exchange website — this CLI cannot revoke it (it has no master wallet signature for it)`, {
1778
+ remediation: `Revoke / delete the key on the ${where}, then remove the local copy: perp${network === "testnet" ? " -n testnet" : ""} wallet agent revoke ${exchange} ${agentName} --local-only`,
1779
+ });
1780
+ }
1781
+ const { deleteImportedAgentLocal } = await import("./agent-import.js");
1782
+ const { keyRemoved } = await deleteImportedAgentLocal(exchange, network, meta);
1783
+ const note = `The key is still authorized on the exchange — revoke it on the ${where}.`;
1784
+ if (useJson)
1785
+ printJson(jsonOk({ revoked: false, localOnly: true, keyRemoved, note }));
1786
+ else
1787
+ console.log(chalk.yellow(`Imported agent "${agentName}" on ${exchange} removed from this computer${keyRemoved ? " (local key deleted)" : ""}. ${note}`));
1788
+ }
1789
+ /**
1790
+ * After a venue-CONFIRMED revoke, delete the agent's OWS wallet (its key can no longer trade).
1791
+ * Absent wallet = nothing to do. A delete failure throws (Rule #2) with the exact cleanup step.
1792
+ */
1793
+ function deleteRevokedAgentWallet(exchange, meta) {
1794
+ const name = meta.agentWalletName;
1795
+ if (!name)
1796
+ return;
1797
+ const ows = loadOws();
1798
+ if (!ows.listWallets().some((w) => w.name === name))
1799
+ return;
1800
+ try {
1801
+ ows.deleteWallet(name);
1802
+ }
1803
+ catch (e) {
1804
+ throw new PerpError("APPROVE_PARTIAL", `${exchange} agent "${meta.agentName}" was revoked, but deleting its OWS wallet "${name}" failed: ${e instanceof Error ? e.message : String(e)}`, {
1805
+ exchange,
1806
+ remediation: `The key is revoked on ${exchange}. Delete the wallet manually (ows wallet delete ${name}), then re-run.`,
1807
+ });
1808
+ }
1809
+ }
1176
1810
  // ── Pre-venue rollback helpers ───────────────────────────────────────────────
1177
1811
  //
1178
1812
  // When `wallet agent approve <dex>` fails BEFORE the venue accepted the agent
@@ -1212,11 +1846,12 @@ function tryDeleteOrphanWallet(ows, walletName) {
1212
1846
  * (some users may need it for the venue UI).
1213
1847
  */
1214
1848
  function buildPreVenueRemediation(opts) {
1215
- const { cleanupOk, dex, masterName, agentAddress, hint } = opts;
1216
- const retry = `perp wallet agent approve ${dex} --master ${masterName}`;
1849
+ const { cleanupOk, dex, network, masterName, agentAddress, hint } = opts;
1850
+ const retry = `perp${network === "testnet" ? " -n testnet" : ""} wallet agent approve ${dex} --master ${masterName}`;
1851
+ const walletName = agentWalletNameFor(dex === "hyperliquid" ? "hl" : dex === "pacifica" ? "pac" : "aster", masterName, network);
1217
1852
  const head = cleanupOk
1218
1853
  ? `Local agent wallet cleaned up. Deposit funds and retry: ${retry}`
1219
- : `Manual cleanup required — local agent wallet may still exist. Run: perp wallet remove agent-${dex === "hyperliquid" ? "hl" : dex === "pacifica" ? "pac" : "aster"}-${masterName}, then retry: ${retry}`;
1854
+ : `Manual cleanup required — local agent wallet may still exist. Run: perp wallet remove ${walletName}, then retry: ${retry}`;
1220
1855
  const parts = [head];
1221
1856
  if (hint)
1222
1857
  parts.push(hint);
@@ -1230,8 +1865,20 @@ function buildPreVenueRemediation(opts) {
1230
1865
  * return true when called from within a locked context.
1231
1866
  */
1232
1867
  async function runApproveFlow(opts) {
1233
- const { masterName, passphrase, agentName, expiredMs, expiresAtIso, nonceMicros, nowIso, canPerp, canSpot, canWithdraw, asterChain, } = opts;
1234
- const baseUrl = "https://fapi.asterdex.com";
1868
+ const { network, masterName, passphrase, agentName, expiredMs, expiresAtIso, nonceMicros, nowIso, canPerp, canSpot, canWithdraw, } = opts;
1869
+ assertAgentApproveSupported("aster", network, "approve");
1870
+ // Withdraw permission requires an IP whitelist on both networks (V3 docs: mandatory when
1871
+ // canWithdraw is true) — refused before any OWS resource is created or anything is signed.
1872
+ assertAsterWithdrawIpWhitelist(canWithdraw, opts.ipWhitelist);
1873
+ if (network === "testnet") {
1874
+ // Validate registerAndApproveAgent inputs before any OWS resource is created.
1875
+ if (/[&=]/.test(agentName) || /[&=]/.test(opts.ipWhitelist ?? "")) {
1876
+ throw new PerpError("INVALID_PARAMS", "Aster registerAndApproveAgent: agent name and IP whitelist must not contain '&' or '='", {
1877
+ remediation: "Choose another --agent-name / --ip-whitelist",
1878
+ });
1879
+ }
1880
+ }
1881
+ const baseUrl = networkConfig(getVenue("aster"), network).restUrl;
1235
1882
  let owsApiKeyId;
1236
1883
  let owsPolicyId;
1237
1884
  let agentEvmAddress;
@@ -1241,7 +1888,8 @@ async function runApproveFlow(opts) {
1241
1888
  const userEvmAddress = masterSigner.getAddress();
1242
1889
  // Step 4: Generate agent OWS wallet
1243
1890
  const ows = loadOws();
1244
- agentWalletName = `agent-aster-${masterName}`;
1891
+ agentWalletName = agentWalletNameFor("aster", masterName, network);
1892
+ assertAgentWalletNameFree("aster", agentWalletName, network);
1245
1893
  const agentWallet = ows.createWallet(agentWalletName, passphrase);
1246
1894
  const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
1247
1895
  if (!agentEvmAccount) {
@@ -1266,110 +1914,23 @@ async function runApproveFlow(opts) {
1266
1914
  // Step 6: Build OWS API key — store only the id, never the token
1267
1915
  const apiKeyResult = ows.createApiKey(`api-aster-${agentName}`, [agentWallet.id], [policyId], passphrase, expiresAtIso);
1268
1916
  owsApiKeyId = apiKeyResult.id;
1269
- // Step 7 (Optional): Builder approval BEFORE approveAgent
1270
- if (opts.builder) {
1271
- if (!opts.maxFeeRate) {
1272
- throw new PerpError("INVALID_PARAMS", "--max-fee-rate is required when --builder is set", {
1273
- remediation: "Add --max-fee-rate <bps> to your command",
1274
- });
1275
- }
1276
- const builderTypedData = buildApproveBuilderTypedData({
1277
- builder: opts.builder,
1278
- maxFeeRate: opts.maxFeeRate,
1279
- builderName: opts.builderName,
1280
- user: userEvmAddress,
1281
- nonceMicros: nonceMicros - 1,
1282
- asterChain,
1917
+ // Steps 8-11: sign + POST. Testnet uses the documented unauthenticated
1918
+ // POST /fapi/v3/registerAndApproveAgent (asterdex/api-docs V3 testnet, 2026-05-12);
1919
+ // the AsterChain-bearing Domain A approveAgent struct is documented for mainnet only.
1920
+ // Mainnet keeps approveAgent: it is the live-verified path, and the docs describe
1921
+ // registerAndApproveAgent as "equivalent to the standalone registration step followed
1922
+ // by approveAgent" — an alternative, not a replacement.
1923
+ const endpoint = network === "testnet" ? "registerAndApproveAgent" : "approveAgent";
1924
+ const agentRes = network === "testnet"
1925
+ ? await postAsterRegisterAndApproveAgent({
1926
+ baseUrl, masterSigner, user: userEvmAddress, agentAddress: agentEvmAddress, agentName,
1927
+ expiredMs, nonceMicros, canPerp, canSpot, canWithdraw, ipWhitelist: opts.ipWhitelist,
1928
+ })
1929
+ : await postAsterApproveAgent({
1930
+ network, baseUrl, masterSigner, user: userEvmAddress, agentAddress: agentEvmAddress, agentName,
1931
+ expiredMs, nonceMicros, canPerp, canSpot, canWithdraw, ipWhitelist: opts.ipWhitelist,
1283
1932
  });
1284
- const builderSig = await masterSigner.signTypedData(builderTypedData.domain, builderTypedData.types, builderTypedData.message);
1285
- const builderQsEntries = [
1286
- ["builder", opts.builder],
1287
- ["maxFeeRate", opts.maxFeeRate],
1288
- ...(opts.builderName !== undefined ? [["builderName", opts.builderName]] : []),
1289
- ["asterChain", asterChain],
1290
- ["user", userEvmAddress],
1291
- ["nonce", String(nonceMicros - 1)],
1292
- ["signature", builderSig],
1293
- ["signatureChainId", "56"],
1294
- ];
1295
- const builderRes = await fetch(`${baseUrl}/fapi/v3/approveBuilder?${new URLSearchParams(Object.fromEntries(builderQsEntries)).toString()}`, {
1296
- method: "POST",
1297
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1298
- body: "",
1299
- });
1300
- if (!builderRes.ok) {
1301
- const errText = await builderRes.text().catch(() => "");
1302
- // Aster builder POST failed — pre-venue (no Aster agent registered yet).
1303
- // Rollback both: api key + orphan local OWS wallet.
1304
- if (owsApiKeyId) {
1305
- try {
1306
- ows.revokeApiKey(owsApiKeyId);
1307
- }
1308
- catch { /* best effort */ }
1309
- }
1310
- tryDeleteOrphanWallet(ows, agentWalletName);
1311
- throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed (${builderRes.status}): ${errText.slice(0, 200)}`, {
1312
- remediation: "Check builder address and max-fee-rate parameters",
1313
- });
1314
- }
1315
- const builderResp = await builderRes.json();
1316
- const builderOk = builderResp.code === "000000" || builderResp.code === 200;
1317
- if (!builderOk) {
1318
- if (owsApiKeyId) {
1319
- try {
1320
- ows.revokeApiKey(owsApiKeyId);
1321
- }
1322
- catch { /* best effort */ }
1323
- }
1324
- tryDeleteOrphanWallet(ows, agentWalletName);
1325
- const errMsg = builderResp.msg ?? builderResp.message ?? String(builderResp.code);
1326
- throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed: ${errMsg}`, {
1327
- remediation: "Check builder address and max-fee-rate parameters",
1328
- });
1329
- }
1330
- }
1331
- // Step 8: Build EIP-712 ApproveAgent payload
1332
- const approveTypedData = buildApproveAgentTypedData({
1333
- user: userEvmAddress,
1334
- agentAddress: agentEvmAddress,
1335
- agentName,
1336
- expiredMs,
1337
- canPerpTrade: canPerp,
1338
- canSpotTrade: canSpot,
1339
- canWithdraw,
1340
- ipWhitelist: opts.ipWhitelist,
1341
- builder: opts.builder,
1342
- maxFeeRate: opts.maxFeeRate,
1343
- builderName: opts.builderName,
1344
- nonceMicros,
1345
- asterChain,
1346
- });
1347
- // Step 9: Sign
1348
- const signature = await masterSigner.signTypedData(approveTypedData.domain, approveTypedData.types, approveTypedData.message);
1349
- // Step 10: Build query string
1350
- const qsEntries = [
1351
- ["agentName", agentName],
1352
- ["agentAddress", agentEvmAddress],
1353
- ];
1354
- if (opts.ipWhitelist !== undefined && opts.ipWhitelist !== null) {
1355
- qsEntries.push(["ipWhitelist", opts.ipWhitelist]);
1356
- }
1357
- qsEntries.push(["expired", String(expiredMs)], ["canSpotTrade", String(canSpot)], ["canPerpTrade", String(canPerp)], ["canWithdraw", String(canWithdraw)]);
1358
- if (opts.builder !== undefined && opts.builder !== null) {
1359
- qsEntries.push(["builder", opts.builder]);
1360
- qsEntries.push(["maxFeeRate", opts.maxFeeRate ?? ""]);
1361
- if (opts.builderName !== undefined) {
1362
- qsEntries.push(["builderName", opts.builderName]);
1363
- }
1364
- }
1365
- qsEntries.push(["asterChain", asterChain], ["user", userEvmAddress], ["nonce", String(nonceMicros)], ["signature", signature], ["signatureChainId", "56"]);
1366
- // Step 11: POST /fapi/v3/approveAgent
1367
1933
  let asterPostSucceeded = false;
1368
- const agentRes = await fetch(`${baseUrl}/fapi/v3/approveAgent?${new URLSearchParams(Object.fromEntries(qsEntries)).toString()}`, {
1369
- method: "POST",
1370
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1371
- body: "",
1372
- });
1373
1934
  if (agentRes.ok) {
1374
1935
  const resp = await agentRes.json();
1375
1936
  if (resp.code === "000000" || resp.code === 200) {
@@ -1386,9 +1947,9 @@ async function runApproveFlow(opts) {
1386
1947
  }
1387
1948
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1388
1949
  const errMsg = resp.msg ?? resp.message ?? String(resp.code);
1389
- throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed: ${errMsg}`, {
1950
+ throw new PerpError("APPROVE_PARTIAL", `Aster ${endpoint} failed: ${errMsg}`, {
1390
1951
  remediation: buildPreVenueRemediation({
1391
- cleanupOk, dex: "aster", masterName, agentAddress: agentEvmAddress,
1952
+ cleanupOk, dex: "aster", network, masterName, agentAddress: agentEvmAddress,
1392
1953
  }),
1393
1954
  });
1394
1955
  }
@@ -1403,9 +1964,9 @@ async function runApproveFlow(opts) {
1403
1964
  }
1404
1965
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1405
1966
  const errText = await agentRes.text().catch(() => "");
1406
- throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed (${agentRes.status}): ${errText.slice(0, 200)}`, {
1967
+ throw new PerpError("APPROVE_PARTIAL", `Aster ${endpoint} failed (${agentRes.status}): ${errText.slice(0, 200)}`, {
1407
1968
  remediation: buildPreVenueRemediation({
1408
- cleanupOk, dex: "aster", masterName, agentAddress: agentEvmAddress,
1969
+ cleanupOk, dex: "aster", network, masterName, agentAddress: agentEvmAddress,
1409
1970
  }),
1410
1971
  });
1411
1972
  }
@@ -1425,6 +1986,7 @@ async function runApproveFlow(opts) {
1425
1986
  permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1426
1987
  asterApprovalNonce: String(nonceMicros),
1427
1988
  status: "active",
1989
+ network,
1428
1990
  });
1429
1991
  }
1430
1992
  catch (persistErr) {
@@ -1435,27 +1997,7 @@ async function runApproveFlow(opts) {
1435
1997
  let revokeOk = false;
1436
1998
  // Attempt remote DELETE /fapi/v3/agent
1437
1999
  try {
1438
- const delNonce = Date.now() * 1000 + Math.floor(Math.random() * 1000);
1439
- const delTypedData = buildDelAgentTypedData({
1440
- user: userEvmAddress,
1441
- agentAddress: agentEvmAddress,
1442
- nonceMicros: delNonce,
1443
- asterChain,
1444
- });
1445
- const delSig = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
1446
- const delQs = new URLSearchParams(Object.fromEntries([
1447
- ["agentAddress", agentEvmAddress],
1448
- ["asterChain", asterChain],
1449
- ["user", userEvmAddress],
1450
- ["nonce", String(delNonce)],
1451
- ["signature", delSig],
1452
- ["signatureChainId", "56"],
1453
- ]));
1454
- const delRes = await fetch(`${baseUrl}/fapi/v3/agent?${delQs.toString()}`, {
1455
- method: "DELETE",
1456
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1457
- body: "",
1458
- });
2000
+ const delRes = await deleteAsterAgent(masterSigner, agentEvmAddress, network);
1459
2001
  deleteOk = delRes.ok || delRes.status === 404;
1460
2002
  }
1461
2003
  catch { /* best effort */ }
@@ -1496,6 +2038,7 @@ async function runApproveFlow(opts) {
1496
2038
  permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1497
2039
  asterApprovalNonce: String(nonceMicros),
1498
2040
  status: "partial",
2041
+ network,
1499
2042
  });
1500
2043
  }
1501
2044
  catch { /* best effort — already in a bad state */ }
@@ -1513,12 +2056,147 @@ async function runApproveFlow(opts) {
1513
2056
  userEvmAddress,
1514
2057
  };
1515
2058
  }
2059
+ /** Mainnet: master-signed Domain A ApproveAgent → POST /fapi/v3/approveAgent. */
2060
+ async function postAsterApproveAgent(o) {
2061
+ assertAsterDomainAMainnet(o.network);
2062
+ const asterChain = asterChainOf(o.network);
2063
+ const approveTypedData = buildApproveAgentTypedData({
2064
+ user: o.user,
2065
+ agentAddress: o.agentAddress,
2066
+ agentName: o.agentName,
2067
+ expiredMs: o.expiredMs,
2068
+ canPerpTrade: o.canPerp,
2069
+ canSpotTrade: o.canSpot,
2070
+ canWithdraw: o.canWithdraw,
2071
+ ipWhitelist: o.ipWhitelist,
2072
+ nonceMicros: o.nonceMicros,
2073
+ asterChain,
2074
+ });
2075
+ const signature = await o.masterSigner.signTypedData(approveTypedData.domain, approveTypedData.types, approveTypedData.message);
2076
+ const qsEntries = [
2077
+ ["agentName", o.agentName],
2078
+ ["agentAddress", o.agentAddress],
2079
+ ];
2080
+ if (o.ipWhitelist !== undefined && o.ipWhitelist !== null) {
2081
+ qsEntries.push(["ipWhitelist", o.ipWhitelist]);
2082
+ }
2083
+ qsEntries.push(["expired", String(o.expiredMs)], ["canSpotTrade", String(o.canSpot)], ["canPerpTrade", String(o.canPerp)], ["canWithdraw", String(o.canWithdraw)], ["asterChain", asterChain], ["user", o.user], ["nonce", String(o.nonceMicros)], ["signature", signature], ["signatureChainId", String(ASTER_DOMAIN_A.chainId)]);
2084
+ return fetch(`${o.baseUrl}/fapi/v3/approveAgent?${new URLSearchParams(Object.fromEntries(qsEntries)).toString()}`, {
2085
+ method: "POST",
2086
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2087
+ body: "",
2088
+ });
2089
+ }
2090
+ /** Aster: an API wallet with withdraw permission must carry an IP whitelist (pre-flight, nothing signed). */
2091
+ export function assertAsterWithdrawIpWhitelist(canWithdraw, ipWhitelist) {
2092
+ if (!canWithdraw || asterSpaceIpWhitelist(ipWhitelist) !== "")
2093
+ return;
2094
+ throw new PerpError("INVALID_PARAMS", "Aster requires an IP whitelist when an API wallet is granted the withdraw permission", {
2095
+ remediation: "Pass --ip-whitelist \"<ip> <ip/cidr>\" together with --can-withdraw, or drop --can-withdraw",
2096
+ });
2097
+ }
2098
+ /**
2099
+ * Normalize the `--ip-whitelist` flag (comma- or space-separated) to the
2100
+ * space-delimited form registerAndApproveAgent signs; "" when absent.
2101
+ */
2102
+ export function asterSpaceIpWhitelist(raw) {
2103
+ if (raw === undefined)
2104
+ return "";
2105
+ return raw.split(/[,\s]+/).filter(s => s.length > 0).join(" ");
2106
+ }
2107
+ /**
2108
+ * Testnet: POST /fapi/v3/registerAndApproveAgent (unauthenticated; the master
2109
+ * EVM wallet signs the literal param string as Message{msg}, domain chainId =
2110
+ * signatureChainId = 56). Params are sent in the signed order plus `signature`.
2111
+ */
2112
+ async function postAsterRegisterAndApproveAgent(o) {
2113
+ const { buildRegisterAndApproveAgentTypedData, ASTER_REGISTER_AGENT_EVM_CHAIN_ID } = await import("../exchanges/aster-typed-data.js");
2114
+ const td = buildRegisterAndApproveAgentTypedData({
2115
+ user: o.user,
2116
+ nonceMicros: o.nonceMicros,
2117
+ agentName: o.agentName,
2118
+ agentAddress: o.agentAddress,
2119
+ expiredMs: o.expiredMs,
2120
+ signatureChainId: ASTER_REGISTER_AGENT_EVM_CHAIN_ID,
2121
+ canSpotTrade: o.canSpot,
2122
+ canPerpTrade: o.canPerp,
2123
+ canWithdraw: o.canWithdraw,
2124
+ ipWhitelist: asterSpaceIpWhitelist(o.ipWhitelist),
2125
+ });
2126
+ const signature = await o.masterSigner.signTypedData(td.domain, td.types, td.message);
2127
+ const qs = new URLSearchParams([...td.params, ["signature", signature]]);
2128
+ return fetch(`${o.baseUrl}/fapi/v3/registerAndApproveAgent?${qs.toString()}`, {
2129
+ method: "POST",
2130
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2131
+ body: "",
2132
+ });
2133
+ }
2134
+ /** Aster `AsterChain` field for the network (signed into Domain A messages and sent as a query param). */
2135
+ function asterChainOf(network) {
2136
+ return network === "testnet" ? "Testnet" : "Mainnet";
2137
+ }
2138
+ /**
2139
+ * Master-signed DelAgent → DELETE /fapi/v3/agent on the selected network's
2140
+ * host. Returns the raw response; callers decide how to treat non-2xx.
2141
+ */
2142
+ async function deleteAsterAgent(masterSigner, agentAddress, network) {
2143
+ assertAgentApproveSupported("aster", network, "revoke");
2144
+ assertAsterDomainAMainnet(network);
2145
+ const userEvmAddress = masterSigner.getAddress();
2146
+ const asterChain = asterChainOf(network);
2147
+ const nonceMicros = Date.now() * 1000 + Math.floor(Math.random() * 1000);
2148
+ const delTypedData = buildDelAgentTypedData({ user: userEvmAddress, agentAddress, nonceMicros, asterChain });
2149
+ const signature = await masterSigner.signTypedData(delTypedData.domain, delTypedData.types, delTypedData.message);
2150
+ const qs = new URLSearchParams(Object.fromEntries([
2151
+ ["agentAddress", agentAddress],
2152
+ ["asterChain", asterChain],
2153
+ ["user", userEvmAddress],
2154
+ ["nonce", String(nonceMicros)],
2155
+ ["signature", signature],
2156
+ ["signatureChainId", String(ASTER_DOMAIN_A.chainId)],
2157
+ ]));
2158
+ return fetch(`${networkConfig(getVenue("aster"), network).restUrl}/fapi/v3/agent?${qs.toString()}`, {
2159
+ method: "DELETE",
2160
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2161
+ body: "",
2162
+ });
2163
+ }
2164
+ /**
2165
+ * Decide an Aster DELETE /fapi/v3/agent response. 2xx = deleted; 404 = already
2166
+ * gone server-side (idempotent). Anything else — or a 2xx JSON body carrying a
2167
+ * negative Aster error `code` — throws APPROVE_FAILED: the agent may still be
2168
+ * authorized, so the caller must keep its local record.
2169
+ */
2170
+ async function assertAsterAgentDeleted(res, agentAddress, nextStep) {
2171
+ if (res.status === 404)
2172
+ return;
2173
+ const text = await res.text();
2174
+ const remediation = `The agent key may still be authorized to trade. ${nextStep} Agent: ${agentAddress}`;
2175
+ if (!res.ok) {
2176
+ throw new PerpError("APPROVE_FAILED", `Aster agent delete failed (${res.status}): ${text.slice(0, 200)}`, {
2177
+ exchange: "aster",
2178
+ remediation,
2179
+ });
2180
+ }
2181
+ let body = null;
2182
+ try {
2183
+ body = JSON.parse(text);
2184
+ }
2185
+ catch { /* non-JSON 2xx body: the venue accepted it */ }
2186
+ const code = body !== null && typeof body === "object" ? body.code : null;
2187
+ if (typeof code === "number" && code < 0) {
2188
+ throw new PerpError("APPROVE_FAILED", `Aster agent delete rejected: ${text.slice(0, 200)}`, {
2189
+ exchange: "aster",
2190
+ remediation,
2191
+ });
2192
+ }
2193
+ }
1516
2194
  /**
1517
2195
  * Parse an expires-in string to a Date.
1518
2196
  * Accepts: "30d", "90d", "180d", "1y", or any ISO-8601 datetime string.
1519
2197
  * Throws if malformed.
1520
2198
  */
1521
- function parseExpiresIn(value) {
2199
+ export function parseExpiresIn(value) {
1522
2200
  const v = value.trim();
1523
2201
  // Match Nd (days) or Ny (years)
1524
2202
  const daysMatch = v.match(/^(\d+)d$/i);
@@ -1541,13 +2219,22 @@ function parseExpiresIn(value) {
1541
2219
  });
1542
2220
  }
1543
2221
  // ── Hyperliquid agent approve/revoke helpers (Phase 2b) ──────────────────────
1544
- /** EIP-712 domain for HL user-signed actions (chainId 42161 = Arbitrum) */
1545
- const HL_USER_SIGNED_DOMAIN = {
1546
- name: "HyperliquidSignTransaction",
1547
- version: "1",
1548
- chainId: 42161,
1549
- verifyingContract: "0x0000000000000000000000000000000000000000",
1550
- };
2222
+ /**
2223
+ * EIP-712 domain for HL user-signed actions on `network`. chainId must equal
2224
+ * the action's signatureChainId (HL_USER_SIGNED in the venue definition).
2225
+ */
2226
+ function hlUserSignedDomain(network) {
2227
+ return {
2228
+ name: "HyperliquidSignTransaction",
2229
+ version: "1",
2230
+ chainId: HL_USER_SIGNED[network].signatureChainId,
2231
+ verifyingContract: "0x0000000000000000000000000000000000000000",
2232
+ };
2233
+ }
2234
+ /** POST /exchange URL for `network` (venue definition restUrl). */
2235
+ function hlExchangeUrl(network) {
2236
+ return `${networkConfig(getVenue("hyperliquid"), network).restUrl}/exchange`;
2237
+ }
1551
2238
  /** EIP-712 types for HyperliquidTransaction:ApproveAgent */
1552
2239
  const HL_APPROVE_AGENT_TYPES = {
1553
2240
  "HyperliquidTransaction:ApproveAgent": [
@@ -1557,6 +2244,8 @@ const HL_APPROVE_AGENT_TYPES = {
1557
2244
  { name: "nonce", type: "uint64" },
1558
2245
  ],
1559
2246
  };
2247
+ /** Hyperliquid approveAgent: "The expiration can be at most 180 days in the future" (exchange-endpoint docs). */
2248
+ const HL_AGENT_MAX_VALIDITY_MS = 180 * 86_400_000;
1560
2249
  /**
1561
2250
  * Core HL approve flow.
1562
2251
  * Caller MUST hold the "hyperliquid" lock before calling this function.
@@ -1565,24 +2254,52 @@ const HL_APPROVE_AGENT_TYPES = {
1565
2254
  * 1. Resolve master via OwsEvmSigner.create
1566
2255
  * 2. Generate agent OWS wallet (eip155-only, name `agent-hl-<masterName>`)
1567
2256
  * 3. Build HL approveAgent action + EIP-712 message
1568
- * 4. Master signs EIP-712 (user-signed domain, chainId 42161)
1569
- * 5. POST https://api.hyperliquid.xyz/exchange
2257
+ * 4. Master signs EIP-712 (user-signed domain; hyperliquidChain + signatureChainId per network)
2258
+ * 5. POST <network restUrl>/exchange
1570
2259
  * 6. On success: persist AgentMeta to settings.agents.hyperliquid[name]
1571
2260
  */
1572
2261
  async function runHlApproveFlow(opts) {
1573
- const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw } = opts;
2262
+ const { network, masterName, passphrase, agentName, expiresAt, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw } = opts;
2263
+ const { hyperliquidChain, signatureChainIdHex } = HL_USER_SIGNED[network];
2264
+ // Hyperliquid rejects extra-agent names outside 1..16 characters; refuse before any wallet is created.
2265
+ // The check covers the name itself: the venue docs state no length rule, and the documented
2266
+ // expiry suffix below is by itself longer than 16 characters.
2267
+ if (agentName.length < 1 || agentName.length > 16) {
2268
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid agent name "${agentName}" must be 1-16 characters (got ${agentName.length})`, {
2269
+ remediation: "Pass a shorter --agent-name",
2270
+ });
2271
+ }
2272
+ if (agentName.includes("valid_until")) {
2273
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid agent name "${agentName}" must not contain "valid_until" (the venue reads it as the expiry suffix)`, {
2274
+ remediation: "Pass a different --agent-name; set the expiry with --expires-in",
2275
+ });
2276
+ }
2277
+ // Docs (for-developers/api/exchange-endpoint, approveAgent → agentName): "A custom expiration can
2278
+ // be set by appending `valid_until {timestamp}` after the name. The expiration can be at most
2279
+ // 180 days in the future". The timestamp is sent in milliseconds, the unit of every other HL API
2280
+ // timestamp (nonce; extraAgents `validUntil`). Without the suffix the venue applies its own
2281
+ // expiry and the stored expiresAt would not be the venue's.
2282
+ const validityMs = expiresAt.getTime() - Date.now();
2283
+ if (!(validityMs > 0) || validityMs > HL_AGENT_MAX_VALIDITY_MS) {
2284
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid agent expiry ${expiresAtIso} must be in the future and at most 180 days away (venue limit)`, {
2285
+ exchange: "hyperliquid",
2286
+ remediation: "Pass --expires-in 180d or less (e.g. --expires-in 90d)",
2287
+ });
2288
+ }
2289
+ const wireAgentName = `${agentName} valid_until ${expiresAt.getTime()}`;
1574
2290
  const { ethers } = await import("ethers");
1575
2291
  // Step 1: Resolve master signer
1576
2292
  const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1577
2293
  const userEvmAddress = masterSigner.getAddress();
1578
2294
  // Step 2: Generate agent OWS wallet
1579
2295
  const ows = loadOws();
1580
- const agentWalletName = `agent-hl-${masterName}`;
2296
+ const agentWalletName = agentWalletNameFor("hl", masterName, network);
1581
2297
  // HL agent wallet created with empty passphrase. This is intentional:
1582
2298
  // (1) Tier 1 hot-path must be prompt-free (P1 from agent_first_design.md).
1583
2299
  // (2) OWS provides at-rest encryption regardless of per-wallet passphrase.
1584
2300
  // (3) Agent blast-radius is limited (perp-only, no withdraw, no master ops).
1585
2301
  // At runtime, src/index.ts opens this wallet with "" matching the create-time empty.
2302
+ assertAgentWalletNameFree("hyperliquid", agentWalletName, network);
1586
2303
  const agentWallet = ows.createWallet(agentWalletName, "");
1587
2304
  const agentEvmAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("eip155:"));
1588
2305
  if (!agentEvmAccount) {
@@ -1594,29 +2311,36 @@ async function runHlApproveFlow(opts) {
1594
2311
  // Step 3: Build nonce (monotonic ms)
1595
2312
  const now = Date.now();
1596
2313
  const nonce = now;
1597
- const sigChainIdHex = "0xa4b1"; // 42161 hex
1598
2314
  const action = {
1599
2315
  type: "approveAgent",
1600
- hyperliquidChain: "Mainnet",
1601
- signatureChainId: sigChainIdHex,
2316
+ hyperliquidChain,
2317
+ signatureChainId: signatureChainIdHex,
1602
2318
  agentAddress: agentEvmAddress,
1603
- agentName,
2319
+ agentName: wireAgentName,
1604
2320
  nonce,
1605
2321
  };
1606
2322
  // Step 4: Master signs EIP-712 (user-signed domain)
1607
2323
  const message = {
1608
- hyperliquidChain: "Mainnet",
2324
+ hyperliquidChain,
1609
2325
  agentAddress: agentEvmAddress,
1610
- agentName,
2326
+ agentName: wireAgentName,
1611
2327
  nonce,
1612
2328
  };
1613
- const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
2329
+ let sigRaw;
2330
+ try {
2331
+ sigRaw = await masterSigner.signTypedData(hlUserSignedDomain(network), HL_APPROVE_AGENT_TYPES, message);
2332
+ }
2333
+ catch (e) {
2334
+ // Nothing was sent: the fresh agent wallet is an orphan — remove it so a retry
2335
+ // (e.g. after a wrong passphrase) does not hit "wallet name already exists".
2336
+ tryDeleteOrphanWallet(ows, agentWalletName);
2337
+ throw e;
2338
+ }
1614
2339
  const parsed = ethers.Signature.from(sigRaw);
1615
2340
  const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1616
2341
  // Step 5: POST /exchange
1617
- const baseUrl = "https://api.hyperliquid.xyz";
1618
2342
  const payload = { action, nonce, signature, vaultAddress: null };
1619
- const agentRes = await fetch(`${baseUrl}/exchange`, {
2343
+ const agentRes = await fetch(hlExchangeUrl(network), {
1620
2344
  method: "POST",
1621
2345
  headers: { "Content-Type": "application/json" },
1622
2346
  body: JSON.stringify(payload),
@@ -1634,18 +2358,27 @@ async function runHlApproveFlow(opts) {
1634
2358
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1635
2359
  throw new PerpError("EXCHANGE_ERROR", `HL exchange API parse error (${agentRes.status}): ${text.slice(0, 200)}`, {
1636
2360
  remediation: buildPreVenueRemediation({
1637
- cleanupOk, dex: "hyperliquid", masterName, agentAddress: agentEvmAddress,
2361
+ cleanupOk, dex: "hyperliquid", network, masterName, agentAddress: agentEvmAddress,
1638
2362
  hint: "Check Hyperliquid API status",
1639
2363
  }),
1640
2364
  });
1641
2365
  }
2366
+ // Same acceptance rule as revokeHlAgent: only HTTP 2xx + status "ok" is a registered agent.
2367
+ // Anything else but an explicit "err" is an UNKNOWN outcome: the agent may or may not be
2368
+ // authorized, so the local agent wallet is kept (deleting it could orphan a live key).
2369
+ if (!agentRes.ok || (resp?.status !== "ok" && resp?.status !== "err")) {
2370
+ throw new PerpError("APPROVE_PARTIAL", `HL approveAgent outcome unknown (HTTP ${agentRes.status}): ${text.slice(0, 200)}`, {
2371
+ exchange: "hyperliquid",
2372
+ remediation: `Check the Hyperliquid UI (More → API) for agent ${agentEvmAddress}. If it is listed, re-run approve with --rotate; if not, delete the local OWS wallet "${agentWalletName}" and retry.`,
2373
+ });
2374
+ }
1642
2375
  if (resp?.status === "err") {
1643
2376
  // Pre-venue rejection — Hyperliquid did NOT register the agent. Roll
1644
2377
  // back the orphan local OWS wallet so retry can proceed cleanly.
1645
2378
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1646
2379
  throw new PerpError("APPROVE_PARTIAL", `HL approveAgent failed: ${typeof resp.response === "string" ? resp.response : JSON.stringify(resp)}`, {
1647
2380
  remediation: buildPreVenueRemediation({
1648
- cleanupOk, dex: "hyperliquid", masterName, agentAddress: agentEvmAddress,
2381
+ cleanupOk, dex: "hyperliquid", network, masterName, agentAddress: agentEvmAddress,
1649
2382
  }),
1650
2383
  });
1651
2384
  }
@@ -1663,51 +2396,89 @@ async function runHlApproveFlow(opts) {
1663
2396
  permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1664
2397
  asterApprovalNonce: String(nonce), // reused field: stores HL nonce for diagnostics
1665
2398
  status: "active",
2399
+ network,
1666
2400
  });
1667
2401
  return { agentAddress: agentEvmAddress, agentName, expiresAt: expiresAtIso, userEvmAddress };
1668
2402
  }
1669
2403
  /**
1670
2404
  * Post a HL revoke action for the given agent.
1671
- * Uses approveAgent with agentAddress set to the zero address (empty = revoke).
1672
- * Per HL convention: sending approveAgent with an empty/zero agentAddress
1673
- * clears the agent slot.
2405
+ * Uses approveAgent with agentAddress set to the zero address under the
2406
+ * agent's own name — HL keys agent slots by name (the unnamed slot is "").
2407
+ *
2408
+ * Rule #2: the venue must confirm with `{status:"ok"}`. Any other answer
2409
+ * (HTTP error, `{status:"err"}`, unparseable body) throws APPROVE_FAILED —
2410
+ * the agent key may still be authorized, so the caller must not forget it.
1674
2411
  */
1675
- async function revokeHlAgent(masterSigner) {
2412
+ /** Hyperliquid's answer to a zero-address approveAgent for a named agent it no longer holds (pruned / expired). */
2413
+ const HL_AGENT_ALREADY_GONE = "Extra agent does not exist.";
2414
+ async function revokeHlAgent(masterSigner, network, agentName, agentAddress) {
1676
2415
  const { ethers } = await import("ethers");
2416
+ const { hyperliquidChain, signatureChainIdHex } = HL_USER_SIGNED[network];
1677
2417
  const nonce = Date.now();
1678
- const sigChainIdHex = "0xa4b1";
1679
2418
  // Zero-address revoke: approveAgent with empty agentAddress (validUntil:0 convention)
1680
2419
  const revokeAddress = "0x0000000000000000000000000000000000000000";
1681
2420
  const action = {
1682
2421
  type: "approveAgent",
1683
- hyperliquidChain: "Mainnet",
1684
- signatureChainId: sigChainIdHex,
2422
+ hyperliquidChain,
2423
+ signatureChainId: signatureChainIdHex,
1685
2424
  agentAddress: revokeAddress,
1686
- agentName: "",
2425
+ agentName,
1687
2426
  nonce,
1688
2427
  };
1689
2428
  const message = {
1690
- hyperliquidChain: "Mainnet",
2429
+ hyperliquidChain,
1691
2430
  agentAddress: revokeAddress,
1692
- agentName: "",
2431
+ agentName,
1693
2432
  nonce,
1694
2433
  };
1695
- const sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_APPROVE_AGENT_TYPES, message);
2434
+ const sigRaw = await masterSigner.signTypedData(hlUserSignedDomain(network), HL_APPROVE_AGENT_TYPES, message);
1696
2435
  const parsed = ethers.Signature.from(sigRaw);
1697
2436
  const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1698
2437
  const payload = { action, nonce, signature, vaultAddress: null };
1699
- await fetch("https://api.hyperliquid.xyz/exchange", {
2438
+ const res = await fetch(hlExchangeUrl(network), {
1700
2439
  method: "POST",
1701
2440
  headers: { "Content-Type": "application/json" },
1702
2441
  body: JSON.stringify(payload),
1703
2442
  });
1704
- // Revoke is best-effort — don't throw on HTTP errors
2443
+ const text = await res.text();
2444
+ const remediation = `The agent key may still be authorized to trade. Retry, or remove the API wallet in the Hyperliquid UI (More → API). Agent: ${agentAddress} ("${agentName}")`;
2445
+ if (!res.ok) {
2446
+ throw new PerpError("APPROVE_FAILED", `Hyperliquid revoke failed (${res.status}): ${text.slice(0, 200)}`, {
2447
+ exchange: "hyperliquid",
2448
+ remediation,
2449
+ });
2450
+ }
2451
+ let body;
2452
+ try {
2453
+ body = JSON.parse(text);
2454
+ }
2455
+ catch {
2456
+ throw new PerpError("APPROVE_FAILED", `Hyperliquid revoke returned an unreadable response (${res.status}): ${text.slice(0, 200)}`, {
2457
+ exchange: "hyperliquid",
2458
+ remediation,
2459
+ });
2460
+ }
2461
+ const status = body !== null && typeof body === "object" ? body.status : null;
2462
+ // The venue no longer knows this agent — nothing is left to revoke. HL docs (for-developers/api/
2463
+ // nonces-and-api-wallets, "API wallet pruning"): "API wallets and their associated nonce state may
2464
+ // be pruned in the following cases: 1. The wallet is deregistered. … 2. The wallet expires.
2465
+ // 3. The account that registered the agent no longer has funds." Only this exact venue answer
2466
+ // (live mainnet 2026-10-05, on an expired agent) counts as "already gone".
2467
+ if (status === "err" && body.response === HL_AGENT_ALREADY_GONE)
2468
+ return;
2469
+ if (status !== "ok") {
2470
+ throw new PerpError("APPROVE_FAILED", `Hyperliquid revoke rejected: ${text.slice(0, 200)}`, {
2471
+ exchange: "hyperliquid",
2472
+ remediation,
2473
+ });
2474
+ }
1705
2475
  }
1706
2476
  // ── HL userSetAbstraction (account-mode write) helper ────────────────────────
1707
2477
  /** EIP-712 types for HyperliquidTransaction:UserSetAbstraction */
1708
2478
  const HL_USER_SET_ABSTRACTION_TYPES = {
1709
2479
  "HyperliquidTransaction:UserSetAbstraction": [
1710
2480
  { name: "hyperliquidChain", type: "string" },
2481
+ { name: "user", type: "address" },
1711
2482
  { name: "abstraction", type: "string" },
1712
2483
  { name: "nonce", type: "uint64" },
1713
2484
  ],
@@ -1722,7 +2493,7 @@ const HL_ABSTRACTION_TO_WIRE = {
1722
2493
  * Send a Hyperliquid `userSetAbstraction` action.
1723
2494
  *
1724
2495
  * Reuses the same EIP-712 user-signed domain (`HyperliquidSignTransaction`,
1725
- * chainId 42161) and `OwsEvmSigner.signTypedData` plumbing as
2496
+ * per-network signatureChainId) and `OwsEvmSigner.signTypedData` plumbing as
1726
2497
  * `runHlApproveFlow`; the only differences are the type struct
1727
2498
  * (`UserSetAbstraction` vs `ApproveAgent`) and the action payload.
1728
2499
  *
@@ -1734,26 +2505,32 @@ export async function runHlSetAbstractionFlow(opts) {
1734
2505
  const { ethers } = await import("ethers");
1735
2506
  const masterSigner = OwsEvmSigner.create(masterName, passphrase);
1736
2507
  const userEvmAddress = masterSigner.getAddress();
1737
- const hyperliquidChain = (isTestnet ? "Testnet" : "Mainnet");
1738
- const sigChainIdHex = "0xa4b1"; // 42161 hex
2508
+ const network = isTestnet ? "testnet" : "mainnet";
2509
+ const { hyperliquidChain, signatureChainIdHex } = HL_USER_SIGNED[network];
1739
2510
  const abstraction = HL_ABSTRACTION_TO_WIRE[mode];
1740
2511
  const nonce = Date.now();
2512
+ // Field set and order per hyperliquid-python-sdk USER_SET_ABSTRACTION_SIGN_TYPES
2513
+ // (hyperliquidChain, user, abstraction, nonce); `user` is the lower-cased master address.
2514
+ // Without `user` in the signed struct HL recovers a different signer (live testnet 2026-10-05:
2515
+ // "Must deposit before performing actions. User: 0x5e33…").
2516
+ const user = userEvmAddress.toLowerCase();
1741
2517
  const action = {
1742
2518
  type: "userSetAbstraction",
1743
2519
  hyperliquidChain,
1744
- signatureChainId: sigChainIdHex,
1745
- user: userEvmAddress,
2520
+ signatureChainId: signatureChainIdHex,
2521
+ user,
1746
2522
  abstraction,
1747
2523
  nonce,
1748
2524
  };
1749
2525
  const message = {
1750
2526
  hyperliquidChain,
2527
+ user,
1751
2528
  abstraction,
1752
2529
  nonce,
1753
2530
  };
1754
2531
  let sigRaw;
1755
2532
  try {
1756
- sigRaw = await masterSigner.signTypedData(HL_USER_SIGNED_DOMAIN, HL_USER_SET_ABSTRACTION_TYPES, message);
2533
+ sigRaw = await masterSigner.signTypedData(hlUserSignedDomain(network), HL_USER_SET_ABSTRACTION_TYPES, message);
1757
2534
  }
1758
2535
  catch (sigErr) {
1759
2536
  throw new PerpError("SIGNATURE_FAILED", `HL userSetAbstraction signing failed: ${sigErr instanceof Error ? sigErr.message : String(sigErr)}`, {
@@ -1762,9 +2539,8 @@ export async function runHlSetAbstractionFlow(opts) {
1762
2539
  }
1763
2540
  const parsed = ethers.Signature.from(sigRaw);
1764
2541
  const signature = { r: parsed.r, s: parsed.s, v: parsed.v };
1765
- const baseUrl = isTestnet ? "https://api.hyperliquid-testnet.xyz" : "https://api.hyperliquid.xyz";
1766
2542
  const payload = { action, nonce, signature, vaultAddress: null };
1767
- const res = await fetch(`${baseUrl}/exchange`, {
2543
+ const res = await fetch(hlExchangeUrl(network), {
1768
2544
  method: "POST",
1769
2545
  headers: { "Content-Type": "application/json" },
1770
2546
  body: JSON.stringify(payload),
@@ -1801,14 +2577,14 @@ export async function runHlSetAbstractionFlow(opts) {
1801
2577
  * 1. Resolve master via OwsSolanaSigner.create
1802
2578
  * 2. Generate agent OWS wallet (`agent-pac-<masterName>`) with empty passphrase
1803
2579
  * 3. Build canonical bind_agent_wallet message + master Ed25519 signature
1804
- * 4. POST https://api.pacifica.fi/api/v1/agent/bind
2580
+ * 4. POST <network restUrl>/agent/bind (api.pacifica.fi / test-api.pacifica.fi)
1805
2581
  * 5. On success: persist AgentMeta to settings.agents.pacifica[name]
1806
2582
  * 6. On failure: rollback agent OWS wallet + emit APPROVE_PARTIAL/APPROVE_FAILED
1807
2583
  *
1808
2584
  * Off-chain auth — no on-chain transaction, no gas required.
1809
2585
  */
1810
2586
  async function runPacApproveFlow(opts) {
1811
- const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, } = opts;
2587
+ const { network, masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, } = opts;
1812
2588
  const { buildBindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
1813
2589
  // Step 1: Resolve master Solana signer
1814
2590
  const masterSigner = OwsSolanaSigner.create(masterName, passphrase);
@@ -1816,7 +2592,8 @@ async function runPacApproveFlow(opts) {
1816
2592
  // Step 2: Generate agent OWS wallet (empty passphrase mirrors HL pattern;
1817
2593
  // the runtime opens with "" so the hot-path is prompt-free).
1818
2594
  const ows = loadOws();
1819
- const agentWalletName = `agent-pac-${masterName}`;
2595
+ const agentWalletName = agentWalletNameFor("pac", masterName, network);
2596
+ assertAgentWalletNameFree("pacifica", agentWalletName, network);
1820
2597
  const agentWallet = ows.createWallet(agentWalletName, "");
1821
2598
  const agentSolAccount = agentWallet.accounts.find((a) => a.chainId.startsWith("solana:"));
1822
2599
  if (!agentSolAccount) {
@@ -1851,7 +2628,7 @@ async function runPacApproveFlow(opts) {
1851
2628
  let pacPostSucceeded = false;
1852
2629
  let resp;
1853
2630
  try {
1854
- const httpRes = await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
2631
+ const httpRes = await fetch(`${networkConfig(getVenue("pacifica"), network).restUrl}/agent/bind`, {
1855
2632
  method: "POST",
1856
2633
  headers: { "Content-Type": "application/json" },
1857
2634
  body: JSON.stringify(requestBody),
@@ -1865,7 +2642,7 @@ async function runPacApproveFlow(opts) {
1865
2642
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1866
2643
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed: ${errMsg}`, {
1867
2644
  remediation: buildPreVenueRemediation({
1868
- cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
2645
+ cleanupOk, dex: "pacifica", network, masterName, agentAddress: agentSolanaAddress,
1869
2646
  }),
1870
2647
  });
1871
2648
  }
@@ -1877,7 +2654,7 @@ async function runPacApproveFlow(opts) {
1877
2654
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1878
2655
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed (${httpRes.status}): ${errText.slice(0, 200)}`, {
1879
2656
  remediation: buildPreVenueRemediation({
1880
- cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
2657
+ cleanupOk, dex: "pacifica", network, masterName, agentAddress: agentSolanaAddress,
1881
2658
  }),
1882
2659
  });
1883
2660
  }
@@ -1889,7 +2666,7 @@ async function runPacApproveFlow(opts) {
1889
2666
  const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1890
2667
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet network error: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}`, {
1891
2668
  remediation: buildPreVenueRemediation({
1892
- cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
2669
+ cleanupOk, dex: "pacifica", network, masterName, agentAddress: agentSolanaAddress,
1893
2670
  }),
1894
2671
  });
1895
2672
  }
@@ -1913,6 +2690,7 @@ async function runPacApproveFlow(opts) {
1913
2690
  permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
1914
2691
  asterApprovalNonce: String(bind.header.timestamp),
1915
2692
  status: "active",
2693
+ network,
1916
2694
  });
1917
2695
  }
1918
2696
  catch (persistErr) {
@@ -1920,7 +2698,7 @@ async function runPacApproveFlow(opts) {
1920
2698
  // Best-effort revoke + APPROVE_PARTIAL.
1921
2699
  if (pacPostSucceeded) {
1922
2700
  try {
1923
- await revokePacAgent(masterSigner, agentSolanaAddress);
2701
+ await revokePacAgent(masterSigner, agentSolanaAddress, network);
1924
2702
  }
1925
2703
  catch { /* best effort */ }
1926
2704
  }
@@ -1936,54 +2714,91 @@ async function runPacApproveFlow(opts) {
1936
2714
  };
1937
2715
  }
1938
2716
  /**
1939
- * Revoke a Pacifica agent wallet (best-effort, idempotent).
1940
- * Sends signed unbind_agent_wallet to /api/v1/agent/bind. Pacifica accepts
1941
- * either a specific agent base58 or an empty string (revoke all) — we pass
1942
- * the specific agent address when available.
2717
+ * Revoke a Pacifica agent wallet.
2718
+ *
2719
+ * Endpoint + operation type follow the official Pacifica SDK
2720
+ * (`rest/api_agent_keys_detailed.py`): `revoke_agent_wallet` → POST
2721
+ * /agent/revoke. A missing agent address is refused (it must never widen into
2722
+ * `revoke_all_agent_wallets`, which would also kill agents this CLI does not own).
2723
+ *
2724
+ * Rule #2: this THROWS on a rejected revoke. It previously POSTed an
2725
+ * `unbind_agent_wallet` type to /agent/bind — neither of which Pacifica
2726
+ * exposes — and discarded the response entirely, so a refused revoke was
2727
+ * reported to the user as success while the agent key stayed authorized to
2728
+ * trade. Callers that genuinely want best-effort must catch explicitly.
1943
2729
  */
1944
- async function revokePacAgent(masterSigner, agentSolanaAddress) {
1945
- const { buildUnbindAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
2730
+ export async function revokePacAgent(masterSigner, agentSolanaAddress, network) {
2731
+ if (agentSolanaAddress === null || agentSolanaAddress.length === 0) {
2732
+ throw new PerpError("AGENT_NOT_REGISTERED", "Pacifica agent record has no agent address — refusing to send a revoke (it would revoke ALL agents)", {
2733
+ exchange: "pacifica",
2734
+ remediation: "Revoke the agent in the Pacifica UI, then clear the local record: perp wallet agent revoke pacifica <name> --force",
2735
+ });
2736
+ }
2737
+ const { buildRevokeAgentMessage } = await import("../exchanges/pacifica-typed-data.js");
1946
2738
  const userSolanaAddress = masterSigner.getPublicKeyBase58();
1947
- const unbind = buildUnbindAgentMessage({
1948
- account: userSolanaAddress,
1949
- agentWallet: agentSolanaAddress,
1950
- });
1951
- const msgBytes = new TextEncoder().encode(unbind.canonicalJson);
2739
+ const built = buildRevokeAgentMessage({ account: userSolanaAddress, agentWallet: agentSolanaAddress });
2740
+ const path = "/agent/revoke";
2741
+ const msgBytes = new TextEncoder().encode(built.canonicalJson);
1952
2742
  const sigBytes = await masterSigner.signMessage(msgBytes);
1953
2743
  const signature = bs58.encode(sigBytes);
1954
2744
  const requestBody = {
1955
2745
  account: userSolanaAddress,
1956
2746
  signature,
1957
- timestamp: unbind.header.timestamp,
1958
- expiry_window: unbind.header.expiry_window,
1959
- type: unbind.header.type,
1960
- ...unbind.payload,
2747
+ timestamp: built.header.timestamp,
2748
+ expiry_window: built.header.expiry_window,
2749
+ type: built.header.type,
2750
+ ...built.payload,
1961
2751
  };
1962
- // FIXME(2c-spike): unverified endpoint — Pacifica may expose a separate
1963
- // `/api/v1/agent/unbind` or accept unbind via the same /agent/bind. We POST
1964
- // to /agent/bind because the action type discriminates server-side per the
1965
- // signing convention. Confirm against live mainnet.
1966
- await fetch("https://api.pacifica.fi/api/v1/agent/bind", {
2752
+ const res = await fetch(`${networkConfig(getVenue("pacifica"), network).restUrl}${path}`, {
1967
2753
  method: "POST",
1968
2754
  headers: { "Content-Type": "application/json" },
1969
2755
  body: JSON.stringify(requestBody),
1970
2756
  });
1971
- // Revoke is best-effort — don't throw on HTTP errors
2757
+ const text = await res.text().catch(() => "");
2758
+ if (!res.ok) {
2759
+ throw new PerpError("APPROVE_FAILED", `Pacifica revoke failed (${res.status}): ${text.slice(0, 200)}`, {
2760
+ exchange: "pacifica",
2761
+ remediation: `The agent key may still be authorized to trade. Retry, or revoke it from the Pacifica UI. Agent: ${agentSolanaAddress}`,
2762
+ });
2763
+ }
2764
+ // Pacifica wraps REST results in `{ success, error, data }` — a 200 with
2765
+ // success:false is still a refusal, so check the envelope, not just the code.
2766
+ try {
2767
+ const parsed = JSON.parse(text);
2768
+ if (parsed && parsed.success === false) {
2769
+ throw new PerpError("APPROVE_FAILED", `Pacifica revoke rejected: ${JSON.stringify(parsed.error ?? parsed)}`, {
2770
+ exchange: "pacifica",
2771
+ remediation: `The agent key may still be authorized to trade. Retry, or revoke it from the Pacifica UI. Agent: ${agentSolanaAddress}`,
2772
+ });
2773
+ }
2774
+ }
2775
+ catch (e) {
2776
+ if (e instanceof PerpError)
2777
+ throw e;
2778
+ // Non-JSON 2xx body: the venue accepted it. Nothing to assert.
2779
+ }
2780
+ }
2781
+ /** A slot is occupied on-chain when /apikeys lists a non-empty, non-zero public key for it. */
2782
+ function isLtSlotKeyOccupied(publicKey) {
2783
+ return !/^(0x)?0*$/.test(publicKey.trim());
1972
2784
  }
1973
2785
  /**
1974
- * Pick the next free Lighter slot in [4, 254].
2786
+ * Pick the first free Lighter slot in [5, 254].
1975
2787
  *
1976
- * Strategy: scan settings.agents.lighter; pick `max(usedSlots) + 1` capped to
1977
- * 254. If none used, default to 4. If 4 itself is used (env-driven auto-setup
1978
- * default), advance to next.
2788
+ * A slot is used when any local agent record (any network — conservative) holds it,
2789
+ * when the venue reserves it, or when the account's on-chain key list shows a key on
2790
+ * it (`onChainOccupied`) — an auto pick must never overwrite a key some other client
2791
+ * (web app, another machine) is using.
1979
2792
  */
1980
- function pickNextFreeLtSlot(settings) {
1981
- const ltMap = settings.agents?.lighter ?? {};
1982
- const used = new Set();
2793
+ export function pickNextFreeLtSlot(settings, venue, onChainOccupied) {
2794
+ const ltMap = settings.agents?.[venue] ?? {};
2795
+ const used = new Set(venue === "lighter-rh" ? LIGHTER_RH_RESERVED_KEY_INDEXES : []);
1983
2796
  for (const meta of Object.values(ltMap)) {
1984
2797
  if (typeof meta.apiKeyIndex === "number")
1985
2798
  used.add(meta.apiKeyIndex);
1986
2799
  }
2800
+ for (const slot of onChainOccupied)
2801
+ used.add(slot);
1987
2802
  // Slot 4 is the default Lighter env-key auto-setup index — treat as used so
1988
2803
  // agent-managed slots don't collide with the env-driven legacy path.
1989
2804
  used.add(4);
@@ -1991,8 +2806,8 @@ function pickNextFreeLtSlot(settings) {
1991
2806
  if (!used.has(i))
1992
2807
  return i;
1993
2808
  }
1994
- throw new PerpError("INVALID_PARAMS", "All Lighter slots [4, 254] are in use", {
1995
- remediation: "Revoke an existing agent: perp wallet agent revoke lighter <name>",
2809
+ throw new PerpError("INVALID_PARAMS", "All Lighter slots [4, 254] are in use (locally or on-chain)", {
2810
+ remediation: `Revoke an existing agent: perp wallet agent revoke ${venue} <name>`,
1996
2811
  });
1997
2812
  }
1998
2813
  /**
@@ -2015,38 +2830,60 @@ function pickNextFreeLtSlot(settings) {
2015
2830
  * Off-chain auth — no on-chain BNB/ETH gas required.
2016
2831
  */
2017
2832
  async function runLtApproveFlow(opts) {
2018
- const { masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, apiKeyIndex: explicitSlot, } = opts;
2833
+ const { venue, network, masterName, passphrase, agentName, expiresAtIso, nowIso, canPerp, canSpot, canWithdraw, apiKeyIndex: explicitSlot, forceSlot, } = opts;
2834
+ const venueName = getVenue(venue).displayName;
2019
2835
  const { LighterAdapter } = await import("../exchanges/lighter.js");
2020
2836
  // Step 1: Resolve master EVM signer (Lighter L1 ChangePubKey is EVM EIP-712 + EIP-191).
2021
2837
  const masterSigner = OwsEvmSigner.create(masterName, passphrase);
2022
2838
  const userEvmAddress = masterSigner.getAddress();
2023
- // Step 2: Pick slot.
2024
- const settings = loadSettings();
2025
- let chosenSlot;
2026
2839
  if (explicitSlot !== undefined) {
2027
2840
  // Validate range (defensive — caller should have already checked, but the
2028
2841
  // helper is also used from rotate where no validation runs upstream).
2029
- if (!Number.isInteger(explicitSlot) || explicitSlot < 4 || explicitSlot > 254) {
2030
- throw new PerpError("INVALID_PARAMS", `apiKeyIndex must be an integer in [4, 254]; got ${explicitSlot}`, {
2031
- remediation: "Slots 0-3 are reserved by the Lighter frontend.",
2032
- });
2033
- }
2034
- chosenSlot = explicitSlot;
2035
- }
2036
- else {
2037
- chosenSlot = pickNextFreeLtSlot(settings);
2842
+ assertLighterSlot(venue, explicitSlot);
2038
2843
  }
2039
- // Step 3: Spin up a LighterAdapter bound to the master EVM signer.
2844
+ // Step 2: Spin up a LighterAdapter bound to the master EVM signer.
2040
2845
  // Constructor takes evmKey (empty since we'll inject signer); init() resolves
2041
2846
  // accountIndex from the L1 EVM address via REST.
2042
- const adapter = new LighterAdapter("", false);
2847
+ const adapter = LighterAdapter.forVenue(venue, "", network === "testnet");
2043
2848
  adapter.setSigner(masterSigner);
2044
2849
  await adapter.init();
2045
2850
  if (adapter.accountIndex < 0) {
2046
- throw new PerpError("INVALID_PARAMS", `No Lighter account found for master EVM address ${userEvmAddress}`, {
2047
- remediation: "Deposit USDC to the master address on Lighter to create an account, then retry.",
2851
+ throw new PerpError("INVALID_PARAMS", `No ${venueName} account found for master EVM address ${userEvmAddress}`, {
2852
+ remediation: `Deposit ${adapter.collateralSymbol} to the master address on ${venueName} to create an account, then retry.`,
2048
2853
  });
2049
2854
  }
2855
+ // Step 3: Pick the slot against the account's LIVE key list. Overwriting an occupied slot
2856
+ // silently kills whatever client (web app, another machine, another agent) signs with it.
2857
+ const onChain = await adapter.listApiKeys();
2858
+ const onChainOccupied = new Set(onChain.filter((k) => isLtSlotKeyOccupied(k.publicKey)).map((k) => k.apiKeyIndex));
2859
+ let chosenSlot;
2860
+ if (explicitSlot !== undefined) {
2861
+ chosenSlot = explicitSlot;
2862
+ const localHolder = listAgents(venue).find(({ meta }) => meta.accountIndex === adapter.accountIndex && meta.apiKeyIndex === chosenSlot && agentNetworkOf(venue, meta) === network);
2863
+ if (localHolder) {
2864
+ throw new PerpError("INVALID_PARAMS", `${venueName} slot ${chosenSlot} on account ${adapter.accountIndex} is held by local ${network} agent "${localHolder.meta.agentName}"`, {
2865
+ remediation: `Rotate it (perp${network === "testnet" ? " -n testnet" : ""} wallet agent rotate ${venue} ${localHolder.meta.agentName}), or pick another --api-key-index`,
2866
+ });
2867
+ }
2868
+ if (onChainOccupied.has(chosenSlot) && !forceSlot) {
2869
+ throw new PerpError("INVALID_PARAMS", `${venueName} slot ${chosenSlot} on account ${adapter.accountIndex} already holds an on-chain API key — approving would overwrite it and break whatever signs with it`, {
2870
+ remediation: `Omit --api-key-index to auto-pick a free slot, or pass --force to deliberately overwrite slot ${chosenSlot}`,
2871
+ });
2872
+ }
2873
+ }
2874
+ else {
2875
+ chosenSlot = pickNextFreeLtSlot(loadSettings(), venue, onChainOccupied);
2876
+ }
2877
+ // Before any venue write: account indexes are per-network and pre-tagging keystore files are keyed
2878
+ // by accountIndex + slot only (migrated to the network-tagged path on first load), so refuse to
2879
+ // reuse another network's account+slot.
2880
+ for (const { meta } of listAgents(venue)) {
2881
+ if (meta.accountIndex === adapter.accountIndex && meta.apiKeyIndex === chosenSlot && agentNetworkOf(venue, meta) !== network) {
2882
+ throw new PerpError("INVALID_PARAMS", `${venueName} keystore slot ${adapter.accountIndex}-${chosenSlot} already holds ${agentNetworkOf(venue, meta)} agent "${meta.agentName}"`, {
2883
+ remediation: `Pick another slot: perp${network === "testnet" ? " -n testnet" : ""} wallet agent approve ${venue} --api-key-index <4-254>`,
2884
+ });
2885
+ }
2886
+ }
2050
2887
  // Step 4: Generate keypair + ChangePubKey via existing production infra.
2051
2888
  // setupApiKey already does:
2052
2889
  // - generateAPIKey() (WASM)
@@ -2061,7 +2898,7 @@ async function runLtApproveFlow(opts) {
2061
2898
  }
2062
2899
  catch (e) {
2063
2900
  const msg = e instanceof Error ? e.message : String(e);
2064
- throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey failed: ${msg}`, {
2901
+ throw new PerpError("APPROVE_PARTIAL", `${venueName} ChangePubKey failed: ${msg}`, {
2065
2902
  remediation: `Slot ${chosenSlot} for cleanup: this slot may be partially registered on the L2 server. Retry with a different --api-key-index, or rotate.`,
2066
2903
  });
2067
2904
  }
@@ -2074,13 +2911,23 @@ async function runLtApproveFlow(opts) {
2074
2911
  // (file mode 0600 + obfuscation, NOT cryptographic strength against a local
2075
2912
  // attacker). `accountIndex` and `apiKeyIndex` already live in
2076
2913
  // settings.agents.lighter[name], so they don't need to be duplicated in env.
2077
- const agentWalletName = `agent-lt-${masterName}`;
2914
+ const agentWalletName = venue === "lighter" ? `agent-lt-${masterName}` : `agent-${venue}-${masterName}`;
2078
2915
  void agentWalletName; // wallet bookkeeping only — not used to derive the L2 key
2079
- const { saveLighterKey } = await import("../agent-wallet/lighter-keystore.js");
2080
- saveLighterKey(adapter.accountIndex, chosenSlot, registered.privateKey, "");
2916
+ const { saveLighterFamilyKey } = await import("../agent-wallet/lighter-keystore.js");
2917
+ try {
2918
+ saveLighterFamilyKey(venue, network, adapter.accountIndex, chosenSlot, registered.privateKey, "");
2919
+ }
2920
+ catch (saveErr) {
2921
+ // The slot already holds the new key on-chain, but its private half could not be stored —
2922
+ // the slot is unusable from this machine until it is re-registered.
2923
+ throw new PerpError("APPROVE_PARTIAL", `${venueName} ChangePubKey succeeded on slot ${chosenSlot} (account ${adapter.accountIndex}) but saving its key to the keystore failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`, {
2924
+ exchange: venue,
2925
+ remediation: `Slot ${chosenSlot} now holds a key whose private half was not saved. Fix the keystore directory (permissions / disk space under ~/.perp), then re-register the slot: perp${network === "testnet" ? " -n testnet" : ""} wallet agent approve ${venue} --api-key-index ${chosenSlot} --force`,
2926
+ });
2927
+ }
2081
2928
  // Step 6: Persist
2082
2929
  try {
2083
- setAgent("lighter", {
2930
+ setAgent(venue, {
2084
2931
  agentName,
2085
2932
  agentWalletName,
2086
2933
  // Lighter agent's identity on the L2 is publicKey + apiKeyIndex.
@@ -2100,11 +2947,12 @@ async function runLtApproveFlow(opts) {
2100
2947
  permissions: { canPerpTrade: canPerp, canSpotTrade: canSpot, canWithdraw },
2101
2948
  asterApprovalNonce: String(Date.now()), // reused field: not used by LT
2102
2949
  status: "active",
2950
+ network,
2103
2951
  });
2104
2952
  }
2105
2953
  catch (persistErr) {
2106
- throw new PerpError("APPROVE_PARTIAL", `Lighter ChangePubKey succeeded but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
2107
- 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>.`,
2954
+ throw new PerpError("APPROVE_PARTIAL", `${venueName} ChangePubKey succeeded but persist failed: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`, {
2955
+ remediation: `${venueName} slot ${chosenSlot} is registered on the L2 server. Manually update settings.agents.${venue} or re-run: perp wallet agent approve ${venue} --api-key-index <other slot>.`,
2108
2956
  });
2109
2957
  }
2110
2958
  return {
@@ -2117,15 +2965,70 @@ async function runLtApproveFlow(opts) {
2117
2965
  };
2118
2966
  }
2119
2967
  /**
2120
- * Revoke a Lighter agent (local-only, idempotent).
2121
- *
2122
- * Lighter's L2 has no on-chain revoke action — slots persist on the server
2123
- * until overwritten by a fresh ChangePubKey at the same index. This helper
2124
- * is a no-op stub that exists for symmetry with the other DEX revoke helpers
2125
- * (revokeHlAgent, revokePacAgent) so the dispatch reads consistently. Caller
2126
- * is responsible for `deleteAgent("lighter", name)` to clear local state.
2968
+ * Revoke a Lighter-family agent: Lighter has no "remove key" tx, so the slot's public key
2969
+ * is replaced (master-signed ChangePubKey) by a freshly generated throwaway key whose private
2970
+ * half is discarded — the old agent key can no longer sign. Then the slot keystore file is
2971
+ * deleted. Throws APPROVE_FAILED (nothing deleted locally) when the ChangePubKey is not accepted.
2127
2972
  */
2128
- async function revokeLtAgent() {
2129
- // No-op. See AC-35: Lighter revoke is local-only.
2130
- return;
2973
+ async function revokeLtAgent(venue, network, meta, passphrase) {
2974
+ const venueName = getVenue(venue).displayName;
2975
+ if (typeof meta.accountIndex !== "number" || typeof meta.apiKeyIndex !== "number") {
2976
+ throw new PerpError("AGENT_NOT_REGISTERED", `${venueName} agent "${meta.agentName}" has no accountIndex/apiKeyIndex — cannot locate its slot`, {
2977
+ remediation: `Revoke the key in the ${venueName} web app (API keys), then: perp wallet agent revoke ${venue} ${meta.agentName} --force`,
2978
+ });
2979
+ }
2980
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
2981
+ const adapter = LighterAdapter.forVenue(venue, "", network === "testnet");
2982
+ adapter.setSigner(OwsEvmSigner.create(meta.masterWalletName, passphrase));
2983
+ await adapter.init();
2984
+ if (adapter.accountIndex !== meta.accountIndex) {
2985
+ throw new PerpError("APPROVE_FAILED", `${venueName} master account ${adapter.accountIndex} does not own agent "${meta.agentName}" (account ${meta.accountIndex})`, {
2986
+ remediation: `Run with the master wallet that registered it (--master / settings.owsActiveWallet), or revoke it in the ${venueName} web app`,
2987
+ });
2988
+ }
2989
+ let throwawayPublicKey;
2990
+ try {
2991
+ // Throwaway key: the returned private key is intentionally dropped (never persisted or logged).
2992
+ throwawayPublicKey = (await adapter.setupApiKey(meta.apiKeyIndex)).publicKey;
2993
+ }
2994
+ catch (e) {
2995
+ throw new PerpError("APPROVE_FAILED", `${venueName} revoke failed: ChangePubKey on slot ${meta.apiKeyIndex} was not accepted — ${e instanceof Error ? e.message : String(e)}`, {
2996
+ exchange: venue,
2997
+ remediation: `The agent key may still be authorized. Retry, or revoke it in the ${venueName} web app. Slot: ${meta.apiKeyIndex}`,
2998
+ });
2999
+ }
3000
+ // sendTx acceptance is not settlement: re-read the slot and require its public key to be the
3001
+ // throwaway one before deleting the local key (otherwise the old key may still be live and the
3002
+ // user would lose the only local copy of a still-authorized key's record).
3003
+ const norm = (k) => k.trim().replace(/^0x/i, "").toLowerCase();
3004
+ // Docs (apidocs.lighter.xyz "Create accounts programmatically"): "`code: 200` only means the API
3005
+ // accepted the transaction. It still has to execute before the key can sign orders, so poll
3006
+ // apikeys until it returns your public key" — their example polls every 1 s for 30 s. Live
3007
+ // (lighter-rh mainnet 2026-10-05) the slot showed the new key a few seconds after the send.
3008
+ let slotKey;
3009
+ try {
3010
+ const deadline = Date.now() + LIGHTER_SLOT_CONFIRM_TIMEOUT_MS;
3011
+ for (;;) {
3012
+ const keys = await adapter.listApiKeys();
3013
+ const entry = keys.find((k) => k.apiKeyIndex === meta.apiKeyIndex);
3014
+ slotKey = entry ? entry.publicKey : null;
3015
+ if ((slotKey !== null && norm(slotKey) === norm(throwawayPublicKey)) || Date.now() + LIGHTER_SLOT_CONFIRM_POLL_MS > deadline)
3016
+ break;
3017
+ await new Promise((r) => setTimeout(r, LIGHTER_SLOT_CONFIRM_POLL_MS));
3018
+ }
3019
+ }
3020
+ catch (e) {
3021
+ throw new PerpError("APPROVE_PARTIAL", `${venueName} revoke sent ChangePubKey for slot ${meta.apiKeyIndex}, but re-reading the slot failed: ${e instanceof Error ? e.message : String(e)}`, {
3022
+ exchange: venue,
3023
+ remediation: `Local key kept. Check the slot: perp${network === "testnet" ? " -n testnet" : ""} wallet agent verify ${venue} --agent-name ${meta.agentName}; once the slot shows a different key, re-run the revoke.`,
3024
+ });
3025
+ }
3026
+ if (slotKey === null || norm(slotKey) !== norm(throwawayPublicKey) || (meta.publicKey !== undefined && norm(slotKey) === norm(meta.publicKey))) {
3027
+ throw new PerpError("APPROVE_PARTIAL", `${venueName} revoke sent ChangePubKey for slot ${meta.apiKeyIndex}, but the slot does not show the replacement key yet (current: ${slotKey === null ? "none" : slotKey.slice(0, 20) + "…"})`, {
3028
+ exchange: venue,
3029
+ remediation: `Local key kept — the old agent key may still be authorized. Wait a few seconds and re-run: perp${network === "testnet" ? " -n testnet" : ""} wallet agent revoke ${venue} ${meta.agentName}`,
3030
+ });
3031
+ }
3032
+ const { deleteLighterFamilyKey } = await import("../agent-wallet/lighter-keystore.js");
3033
+ deleteLighterFamilyKey(venue, network, meta.accountIndex, meta.apiKeyIndex);
2131
3034
  }