perp-cli 0.12.15 → 0.14.0

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 (361) hide show
  1. package/README.md +278 -31
  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 +286 -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 +74 -84
  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 +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  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 +316 -337
  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 +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  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 +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -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 +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -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 +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  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 +1383 -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 +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -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 +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -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 +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +33 -16
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,15 +1,28 @@
1
+ import { networkOf, parseNetwork } from "../cli-flags.js";
1
2
  import chalk from "chalk";
2
- import { createInterface } from "readline";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { existsSync, mkdirSync, readFileSync } from "fs";
5
+ import { writeKeystoreAtomic } from "../agent-wallet/venue-keystore.js";
4
6
  import { resolve } from "path";
5
- import { loadSettings, saveSettings } from "../settings.js";
6
- import { PerpError } from "../errors.js";
7
+ import { loadSettings, saveSettings, SHIPPED_DEFAULT_SLIPPAGE_BPS } from "../settings.js";
8
+ import { PerpError, classifyError } from "../errors.js";
7
9
  import { jsonOk, jsonError, printJson } from "../utils.js";
8
10
  import { resolvePassphrase } from "../agent-wallet/passphrase.js";
9
11
  import { loadOws } from "../signer/ows-loader.js";
12
+ import { getVenue, listVenuesWithRole, networkConfig, tryResolveVenueId } from "../exchanges/venue.js";
13
+ import "../exchanges/venues/index.js";
14
+ import { maxDefaultSlippageBps, parseSlippageSettingBps } from "../exchanges/slippage.js";
15
+ import { agentApproveUnsupportedReason, approveVenueAgent, assertLighterSlot, isLighterFamilyVenue, deprecatedPassphraseOption, printApproveOutcome, promptHidden, verifyVenueAgent, warnDeprecatedPassphrase, } from "./agent.js";
16
+ import { importOwsWallet } from "./wallet.js";
17
+ import { agentNetworkOf, getAgent, listAgents } from "../agent-wallet/store.js";
18
+ import { IMPORT_KEY_WARNING, IMPORT_SCOPE_NOTE, UNVERIFIED_IMPORT_VENUES, assertDistinctImportSecrets, deleteImportedAgentLocal, importSecretFromLines, importSecretLabel, importSecretLineCount, importVenueAgent, printImportOutcome, } from "./agent-import.js";
10
19
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
11
20
  const ENV_FILE = resolve(PERP_DIR, ".env");
12
21
  // ── .env helpers ──────────────────────────────────────────────
22
+ //
23
+ // Legacy read path only. `perp setup` never writes secrets here (SSOT Rule #3:
24
+ // master keys live in the OWS vault, agent keys in OWS / ~/.perp/<venue>-agents/).
25
+ // `settings env` still uses these for existing legacy-key users (`wallet set` imports into OWS).
13
26
  export { ENV_FILE, loadEnvFile, setEnvVar };
14
27
  function ensurePerpDir() {
15
28
  if (!existsSync(PERP_DIR))
@@ -35,14 +48,15 @@ function writeEnvFile(env) {
35
48
  ensurePerpDir();
36
49
  const lines = [
37
50
  "# perp-cli configuration",
38
- "# Generated by 'perp setup' — edit freely",
51
+ "# Legacy key file — prefer the OWS vault (perp wallet import)",
39
52
  "",
40
53
  ];
41
54
  for (const [key, value] of Object.entries(env)) {
42
55
  lines.push(`${key}=${value}`);
43
56
  }
44
57
  lines.push("");
45
- writeFileSync(ENV_FILE, lines.join("\n"), { mode: 0o600 });
58
+ // Atomic, explicit 0600 (writeFileSync's mode is ignored when the file already exists).
59
+ writeKeystoreAtomic(ENV_FILE, lines.join("\n"));
46
60
  }
47
61
  function setEnvVar(key, value) {
48
62
  ensurePerpDir();
@@ -52,30 +66,13 @@ function setEnvVar(key, value) {
52
66
  // Also set in current process
53
67
  process.env[key] = value;
54
68
  }
55
- // ── Interactive helpers ───────────────────────────────────────
56
- function ask(rl, question) {
57
- return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
58
- }
59
- function askChoice(rl, question, choices) {
60
- return new Promise((resolve) => {
61
- const choiceStr = choices.map((c, i) => `${i + 1}) ${c}`).join(" ");
62
- rl.question(`${question} [${choiceStr}]: `, (answer) => {
63
- const trimmed = answer.trim();
64
- const idx = parseInt(trimmed) - 1;
65
- if (idx >= 0 && idx < choices.length)
66
- return resolve(choices[idx]);
67
- const match = choices.find((c) => c.toLowerCase().startsWith(trimmed.toLowerCase()));
68
- resolve(match || choices[0]);
69
- });
70
- });
71
- }
72
- // ── Exchange → env var mapping ────────────────────────────────
69
+ // ── Exchange → env var mapping (legacy venues only) ───────────
73
70
  // v0.12.4 retired the legacy HMAC ASTER_API_KEY/SECRET path (Plan v3.0).
74
71
  // v0.12.6 restores Aster as a normal EVM exchange: `ASTER_PRIVATE_KEY`
75
72
  // (Tier 3 PK direct path) is collected like HL/LT, and the agent flow
76
73
  // (`wallet agent approve aster`) is the recommended Tier 1 onboarding.
77
74
  // Removing Aster entirely was over-correction — the entry must exist so
78
- // `wallet import` / `wallet set` / setup wizard treat all 4 DEX uniformly.
75
+ // `wallet import` / `wallet set` treat all 4 legacy DEX uniformly.
79
76
  export const EXCHANGE_ENV_MAP = {
80
77
  pacifica: { envKey: "PACIFICA_PRIVATE_KEY", chain: "solana", label: "Pacifica (Solana)" },
81
78
  hyperliquid: { envKey: "HL_PRIVATE_KEY", chain: "evm", label: "Hyperliquid (EVM)" },
@@ -117,201 +114,772 @@ export async function validateKey(chain, key) {
117
114
  }
118
115
  }
119
116
  }
120
- // ── Commands ──────────────────────────────────────────────────
121
- export function registerInitCommand(program) {
122
- // ── perp setup (interactive + non-interactive) ──
123
- program
124
- .command("setup")
125
- .alias("init")
126
- .description("Setup wizard — interactive by default, scriptable with --non-interactive")
127
- .option("--non-interactive", "Run without prompts; requires --passphrase or OWS_PASSPHRASE env")
128
- .option("--wallet-name <name>", "OWS wallet alias to create (default: 'main')")
129
- .option("--passphrase <pp>", "OWS vault passphrase (fallback: OWS_PASSPHRASE env / stdin)")
130
- .option("--default-exchange <name>", "Default exchange (pacifica/hyperliquid/lighter/aster)")
131
- .action(async (opts, command) => {
132
- // optsWithGlobals() merges parent + subcommand options so --passphrase
133
- // works whether placed on `perp --passphrase X setup` (parent) or
134
- // `perp setup --passphrase X` (subcommand). Commander v13 silently
135
- // shadows the subcommand flag when the parent defines an identically
136
- // named flag — the visible bug was non-interactive setup throwing
137
- // PASSPHRASE_REQUIRED with --passphrase set on the subcommand.
138
- const mergedOpts = command.optsWithGlobals();
139
- const resolvedOpts = {
140
- ...opts,
141
- passphrase: mergedOpts.passphrase ?? opts.passphrase,
142
- };
143
- // Non-interactive mode: skip all readline prompts, drive everything from
144
- // flags + OWS_PASSPHRASE env. This is the agent-friendly onboarding path
145
- // (CI / Docker / scripted init) — see plan v3.0 "agent-friendly five
146
- // guarantees" P1 (no-prompt) and P3 (passphrase 3-path).
147
- if (resolvedOpts.nonInteractive) {
148
- return runNonInteractiveSetup(resolvedOpts);
117
+ // ── Setup building blocks (shared by interactive + non-interactive) ──────────
118
+ /** Names (never values) of legacy private-key variables present in ~/.perp/.env. */
119
+ export function legacyEnvKeyNames() {
120
+ const env = loadEnvFile();
121
+ return Object.values(EXCHANGE_ENV_MAP).map(i => i.envKey).filter(k => env[k] !== undefined && env[k] !== "");
122
+ }
123
+ /** Trading venues from the registry (no hardcoded list). */
124
+ export function setupVenues() {
125
+ return listVenuesWithRole("trading");
126
+ }
127
+ /** Resolve one venue id/alias for setup: must be a trading venue with an environment on `network`. */
128
+ export function resolveSetupVenue(input, network) {
129
+ const id = tryResolveVenueId(input.trim());
130
+ if (id === null || !getVenue(id).roles.includes("trading")) {
131
+ throw new PerpError("INVALID_PARAMS", `Unknown trading venue "${input}"`, {
132
+ remediation: `Use one of: ${setupVenues().map(v => v.id).join(", ")}`,
133
+ });
134
+ }
135
+ networkConfig(getVenue(id), network); // throws NOT_IMPLEMENTED when the venue has no such network
136
+ return id;
137
+ }
138
+ /** Parse a comma-separated venue list (ids or aliases), de-duplicated in input order. */
139
+ export function parseVenueList(csv, network) {
140
+ const parts = csv.split(/[,\s]+/).filter(p => p.length > 0);
141
+ if (parts.length === 0) {
142
+ throw new PerpError("INVALID_PARAMS", "No venues given", {
143
+ remediation: `Pass --venues <list>, e.g. --venues ${setupVenues().slice(0, 2).map(v => v.id).join(",")}`,
144
+ });
145
+ }
146
+ return [...new Set(parts.map(p => resolveSetupVenue(p, network)))];
147
+ }
148
+ /** Parse `<venue>=<bps>` per-venue slippage overrides; each bps must fit that venue's maxSlippageBps. */
149
+ export function parseVenueSlippage(entries, network) {
150
+ const out = {};
151
+ for (const entry of entries) {
152
+ const eq = entry.indexOf("=");
153
+ if (eq <= 0) {
154
+ throw new PerpError("INVALID_PARAMS", `Invalid slippage override "${entry}"`, {
155
+ remediation: "Use <venue>=<bps>, e.g. --slippage arcus=100",
156
+ });
149
157
  }
150
- const rl = createInterface({ input: process.stdin, output: process.stdout });
151
- try {
152
- console.log(chalk.cyan.bold("\n perp-cli Setup Wizard\n"));
153
- // Show current state
154
- const env = loadEnvFile();
155
- const configured = [];
156
- for (const [exchange, info] of Object.entries(EXCHANGE_ENV_MAP)) {
157
- if (env[info.envKey] || process.env[info.envKey]) {
158
- configured.push(exchange);
159
- }
160
- }
161
- if (configured.length > 0) {
162
- console.log(chalk.white(" Already configured:"));
163
- for (const ex of configured) {
164
- const info = EXCHANGE_ENV_MAP[ex];
165
- const key = env[info.envKey] || process.env[info.envKey] || "";
166
- const masked = key.slice(0, 6) + "..." + key.slice(-4);
167
- console.log(` ${chalk.cyan(ex.padEnd(14))} ${chalk.gray(masked)}`);
168
- }
169
- console.log();
170
- const action = await askChoice(rl, " Add/update keys or exit?", ["update", "exit"]);
171
- if (action === "exit") {
172
- rl.close();
173
- return;
174
- }
158
+ const venue = resolveSetupVenue(entry.slice(0, eq), network);
159
+ out[venue] = parseSlippageSettingBps(`slippageBps.${venue}`, entry.slice(eq + 1).trim(), getVenue(venue).maxSlippageBps);
160
+ }
161
+ return out;
162
+ }
163
+ /** Write the wizard's choices to ~/.perp/settings.json (metadata only — never secrets). */
164
+ export function applySetupSettings(change) {
165
+ const settings = loadSettings();
166
+ if (change.owsActiveWallet !== null)
167
+ settings.owsActiveWallet = change.owsActiveWallet;
168
+ if (change.defaultExchange !== null)
169
+ settings.defaultExchange = change.defaultExchange;
170
+ if (change.defaultSlippageBps !== null)
171
+ settings.defaultSlippageBps = change.defaultSlippageBps;
172
+ for (const [venue, bps] of Object.entries(change.slippageBps)) {
173
+ settings.slippageBps[venue] = bps;
174
+ }
175
+ saveSettings(settings);
176
+ return settings;
177
+ }
178
+ /** Agent names already registered for `venue` on `network` in settings. */
179
+ function registeredAgentNames(venue, network) {
180
+ if (venue === "ondo" || venue === "arcus" || venue === "qfex") {
181
+ const map = loadSettings().agents?.[venue];
182
+ return map ? Object.values(map).filter(m => m.network === network).map(m => m.agentName) : [];
183
+ }
184
+ return listAgents(venue).filter(({ meta }) => agentNetworkOf(venue, meta) === network).map(({ meta }) => meta.agentName);
185
+ }
186
+ function rotateHint(venue) {
187
+ if (venue === "qfex")
188
+ return "perp wallet agent import qfex --replace";
189
+ return venue === "ondo" || venue === "arcus"
190
+ ? `perp wallet agent approve ${venue} --rotate`
191
+ : `perp wallet agent rotate ${venue}`;
192
+ }
193
+ /** Why the wizard cannot register an agent for `venue` on `network`; null when it can (uniform wording, design §13.6). */
194
+ function approveBlocker(venue, network) {
195
+ const reason = agentApproveUnsupportedReason(venue, network, "approve");
196
+ return reason === null ? null : `${getVenue(venue).displayName} agent management on ${network} not supported — ${reason}`;
197
+ }
198
+ /** Real follow-up commands for the configured venues (all exist in this CLI). */
199
+ export function nextStepCommands(venues, network) {
200
+ const net = network === "testnet" ? " -n testnet" : "";
201
+ const cmds = [
202
+ `perp${net} wallet agent verify`,
203
+ `perp${net} portfolio`,
204
+ "perp funds info",
205
+ ];
206
+ // `funds deposit` has subcommands for these venues only.
207
+ for (const v of venues) {
208
+ if (v === "pacifica" || v === "hyperliquid")
209
+ cmds.push(`perp${net} funds deposit ${v} <amount>`);
210
+ if (v === "lighter")
211
+ cmds.push(`perp${net} funds deposit lighter --help`);
212
+ }
213
+ for (const v of venues)
214
+ cmds.push(`perp${net} -e ${v} market list`);
215
+ cmds.push("perp settings show");
216
+ return cmds;
217
+ }
218
+ /** Plain-language slippage explanation (design §13.2, §13.10 #3). */
219
+ export function slippageExplanation(venues) {
220
+ const lines = [
221
+ "Market orders are sent as immediate-or-cancel (IOC) limit orders. The limit price is the",
222
+ "reference price moved by the slippage bound, so a market order never fills worse than that.",
223
+ `The shipped default is ${SHIPPED_DEFAULT_SLIPPAGE_BPS} bps (${SHIPPED_DEFAULT_SLIPPAGE_BPS / 100}%). 1 bps = 0.01%.`,
224
+ "The same bound applies to entries, closes, and TP/SL / stop triggers. Triggers use the trigger",
225
+ "price as the reference, so in a fast move a stop may NOT fill if price gaps beyond the bound.",
226
+ "A larger bound fills more reliably but can cost more; a smaller bound protects price but may",
227
+ "leave orders unfilled. Precedence: --slippage-bps flag → per-venue setting → global default.",
228
+ ];
229
+ if (venues.length > 0) {
230
+ lines.push(`Per-venue maximum: ${venues.map(v => `${v} ${getVenue(v).maxSlippageBps}`).join(", ")} bps.`);
231
+ }
232
+ lines.push(`The global default must be ≤ ${maxDefaultSlippageBps()} bps (the tightest venue maximum).`);
233
+ return lines;
234
+ }
235
+ function owsWalletInfo(name) {
236
+ const w = loadOws().listWallets().find(x => x.name === name);
237
+ if (!w)
238
+ return null;
239
+ const evm = w.accounts.find(a => a.chainId.startsWith("eip155:"));
240
+ const sol = w.accounts.find(a => a.chainId.startsWith("solana:"));
241
+ return { name: w.name, evm: evm ? evm.address : null, solana: sol ? sol.address : null };
242
+ }
243
+ /** Plain description of what approving `venue` signs (shown before confirmation). */
244
+ function signingSummary(p, master) {
245
+ const def = getVenue(p.venue);
246
+ const expiry = `valid for ${p.expiresIn}`;
247
+ switch (p.venue) {
248
+ case "hyperliquid":
249
+ return [`EIP-712 ApproveAgent signed by master EVM ${master.evm}: authorizes a new agent key to trade (${expiry}).`];
250
+ case "aster":
251
+ return [`EIP-712 ApproveAgent signed by master EVM ${master.evm}: perp=${p.canPerp ? "yes" : "no"}, spot=${p.canSpot ? "yes" : "no"}, withdraw=no (${expiry}).`];
252
+ case "pacifica":
253
+ return [`Ed25519 bind_agent_wallet message signed by master Solana ${master.solana}: binds a new agent key (${expiry}).`];
254
+ case "lighter":
255
+ case "lighter-rh":
256
+ return [`${def.displayName} ChangePubKey (L1 EIP-712 + L2) signed by master EVM ${master.evm}: registers a new API key in slot ${p.apiKeyIndex === null ? "<next free>" : p.apiKeyIndex}.`];
257
+ case "arcus":
258
+ return [`EIP-712 createApiKey signed by master EVM ${master.evm}: registers an Ed25519 API key for account index ${p.accountIndex} (${expiry}).`];
259
+ case "ondo":
260
+ return ["No signature. Your Ondo API key is stored encrypted in ~/.perp/ondo-agents/ and checked with one read-only account request."];
261
+ case "qfex":
262
+ return ["No signature. Your QFEX API key is stored encrypted in ~/.perp/qfex-agents/ and checked with read-only requests."];
263
+ }
264
+ }
265
+ function failed(venue, err) {
266
+ return { venue, status: "failed", error: classifyError(err, venue) };
267
+ }
268
+ function ttyPrompter() {
269
+ return {
270
+ async ask(question) {
271
+ // One readline per question so raw-mode hidden prompts never share stdin with it.
272
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
273
+ try {
274
+ return (await rl.question(question)).trim();
175
275
  }
176
- // Select exchanges
177
- console.log(chalk.white(" Exchanges:"));
178
- console.log(` 1) ${chalk.cyan("Pacifica")} ${chalk.gray("— Solana perps (USDC on Solana)")}`);
179
- console.log(` 2) ${chalk.cyan("Hyperliquid")} ${chalk.gray("— EVM perps (USDC on Arbitrum)")}`);
180
- console.log(` 3) ${chalk.cyan("Lighter")} ${chalk.gray("— EVM perps (USDC on Ethereum/Arb)")}`);
181
- console.log(` 4) ${chalk.cyan("Aster")} ${chalk.gray("— BNB Chain perps (use 'wallet agent approve aster')")}`);
182
- console.log();
183
- const exchangeChoice = await ask(rl, " Which exchange(s)? (1,2,3,4 or 'all'): ");
184
- const selected = parseExchangeChoice(exchangeChoice);
185
- if (selected.length === 0) {
186
- console.log(chalk.red("\n No exchanges selected.\n"));
276
+ finally {
187
277
  rl.close();
188
- return;
189
- }
190
- // Collect keys per exchange
191
- // EVM exchanges can share the same key
192
- const evmExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "evm");
193
- const solExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "solana");
194
- const newEnv = { ...env };
195
- if (evmExchanges.length > 0) {
196
- console.log(chalk.cyan.bold("\n EVM Private Key") + chalk.gray(` (for ${evmExchanges.join(", ")})`));
197
- // Check if they share one key
198
- let sharedKey = "";
199
- if (evmExchanges.length > 1) {
200
- const share = await askChoice(rl, " Use same key for all EVM exchanges?", ["yes", "no"]);
201
- if (share === "yes") {
202
- sharedKey = await ask(rl, " EVM private key (0x...): ");
203
- const { valid, address } = await validateKey("evm", sharedKey);
204
- if (!valid) {
205
- console.log(chalk.red(" Invalid EVM key."));
206
- rl.close();
207
- return;
208
- }
209
- const normalized = sharedKey.startsWith("0x") ? sharedKey : `0x${sharedKey}`;
210
- console.log(` ${chalk.green("OK")} ${chalk.gray(address)}`);
211
- for (const ex of evmExchanges) {
212
- newEnv[EXCHANGE_ENV_MAP[ex].envKey] = normalized;
213
- }
214
- }
278
+ }
279
+ },
280
+ askHidden: (question) => promptHidden(question),
281
+ };
282
+ }
283
+ async function askYesNo(p, question, defaultYes) {
284
+ for (;;) {
285
+ const a = (await p.ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
286
+ if (a === "")
287
+ return defaultYes;
288
+ if (a === "y" || a === "yes")
289
+ return true;
290
+ if (a === "n" || a === "no")
291
+ return false;
292
+ console.log(chalk.yellow(" Please answer y or n."));
293
+ }
294
+ }
295
+ async function askNewPassphrase(p) {
296
+ for (;;) {
297
+ const pp = await p.askHidden(" Vault passphrase (hidden; empty = unencrypted): ");
298
+ const again = await p.askHidden(" Repeat passphrase: ");
299
+ if (pp === again)
300
+ return pp;
301
+ console.log(chalk.yellow(" Passphrases do not match — try again."));
302
+ }
303
+ }
304
+ /**
305
+ * Step 1 — pick / create / import the master OWS wallet, or skip it (name null: no master on this
306
+ * computer — every venue then uses an API key imported from the exchange website).
307
+ */
308
+ async function stepMasterWallet(p) {
309
+ console.log(chalk.cyan.bold("\n Step 1/7 — Master wallet (OWS encrypted vault)\n"));
310
+ const ows = loadOws();
311
+ const active = loadSettings().owsActiveWallet;
312
+ // agent-<venue>-<master> wallets are delegated keys, never masters.
313
+ const wallets = ows.listWallets().filter(w => !w.name.startsWith("agent-"));
314
+ wallets.forEach((w, i) => {
315
+ const evm = w.accounts.find(a => a.chainId.startsWith("eip155:"));
316
+ const sol = w.accounts.find(a => a.chainId.startsWith("solana:"));
317
+ const mark = w.name === active ? chalk.cyan(" (active)") : "";
318
+ console.log(` ${i + 1}) ${chalk.white.bold(w.name)}${mark} ${chalk.gray(`EVM ${evm ? evm.address : "-"} SOL ${sol ? sol.address : "-"}`)}`);
319
+ });
320
+ if (wallets.length === 0)
321
+ console.log(chalk.gray(" (no wallets in the vault yet)"));
322
+ console.log(` c) create a new wallet`);
323
+ console.log(` i) import an existing key / mnemonic`);
324
+ console.log(` s) skip — no master wallet on this computer (use API keys created on the exchange websites)`);
325
+ for (;;) {
326
+ const a = (await p.ask("\n Choose: ")).toLowerCase();
327
+ const idx = Number(a);
328
+ if (Number.isInteger(idx) && idx >= 1 && idx <= wallets.length)
329
+ return { name: wallets[idx - 1].name, passphrase: null };
330
+ if (a === "s") {
331
+ console.log(chalk.gray(` No master wallet: you will import API keys created on each exchange website. ${IMPORT_SCOPE_NOTE}`));
332
+ return { name: null, passphrase: null };
333
+ }
334
+ if (a === "c" || a === "i") {
335
+ const name = await p.ask(" New wallet name: ");
336
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(name) || name.startsWith("agent-")) {
337
+ console.log(chalk.yellow(" Use 1-64 letters, digits, '.', '_' or '-' (not starting with 'agent-')."));
338
+ continue;
339
+ }
340
+ if (ows.listWallets().some(w => w.name === name)) {
341
+ console.log(chalk.yellow(` A wallet named "${name}" already exists.`));
342
+ continue;
343
+ }
344
+ if (a === "c") {
345
+ const pp = await askNewPassphrase(p);
346
+ const w = ows.createWallet(name, pp);
347
+ console.log(chalk.green(` Created ${w.name}.`) + chalk.gray(" Back it up with: perp wallet backup"));
348
+ return { name, passphrase: pp };
349
+ }
350
+ const kind = (await p.ask(" Import type — 1) EVM key 2) Solana key 3) EVM + Solana keys 4) mnemonic: ")).trim();
351
+ let input;
352
+ if (kind === "1")
353
+ input = { kind: "single", chain: "evm", key: await p.askHidden(" EVM private key (hidden): ") };
354
+ else if (kind === "2")
355
+ input = { kind: "single", chain: "solana", key: await p.askHidden(" Solana private key (hidden): ") };
356
+ else if (kind === "3")
357
+ input = { kind: "dual", evm: await p.askHidden(" EVM private key (hidden): "), solana: await p.askHidden(" Solana private key (hidden): ") };
358
+ else if (kind === "4")
359
+ input = { kind: "mnemonic", mnemonic: await p.askHidden(" Mnemonic (hidden): ") };
360
+ else {
361
+ console.log(chalk.yellow(" Choose 1-4."));
362
+ continue;
363
+ }
364
+ const pp = await askNewPassphrase(p);
365
+ try {
366
+ const w = await importOwsWallet(name, pp, input);
367
+ console.log(chalk.green(` Imported ${w.name} into the OWS vault.`));
368
+ return { name, passphrase: pp };
369
+ }
370
+ catch (err) {
371
+ // importOwsWallet throws a PerpError whose message is already redacted (redactSecrets);
372
+ // anything else gets a fixed message — key material must never be echoed.
373
+ console.log(chalk.red(err instanceof PerpError ? ` ${err.message}` : " Import failed: the key / mnemonic was rejected (input not shown)."));
374
+ continue;
375
+ }
376
+ }
377
+ console.log(chalk.yellow(` Enter ${wallets.length > 0 ? `1-${wallets.length}, ` : ""}c, i, or s.`));
378
+ }
379
+ }
380
+ /** `preselected` is the global -n network: the blank answer keeps it. */
381
+ async function stepNetwork(p, preselected) {
382
+ console.log(chalk.cyan.bold("\n Step 2/7 — Network\n"));
383
+ const withTestnet = setupVenues().filter(v => v.networks.testnet !== null).map(v => v.id);
384
+ console.log(chalk.gray(` testnet is available for: ${withTestnet.join(", ")}`));
385
+ const def = preselected === "mainnet" ? "1" : "2";
386
+ for (;;) {
387
+ const a = (await p.ask(` 1) mainnet 2) testnet [${def}]: `)).toLowerCase();
388
+ if (a === "")
389
+ return preselected;
390
+ if (a === "1" || a === "mainnet")
391
+ return "mainnet";
392
+ if (a === "2" || a === "testnet")
393
+ return "testnet";
394
+ }
395
+ }
396
+ async function stepVenues(p, network) {
397
+ console.log(chalk.cyan.bold("\n Step 3/7 — Venues\n"));
398
+ const all = setupVenues();
399
+ const offered = all.filter(v => v.networks[network] !== null);
400
+ offered.forEach((v, i) => {
401
+ const net = networkConfig(v, network);
402
+ console.log(` ${i + 1}) ${chalk.cyan(v.displayName.padEnd(12))} ${chalk.gray(`${v.id} — ${net.collateral[0]}${net.collateral.length > 1 ? " +" + (net.collateral.length - 1) : ""} collateral on ${net.settlementChain}`)}`);
403
+ if (v.restrictedRegions.length > 0) {
404
+ console.log(chalk.yellow(` ⚠ not available to residents of: ${v.restrictedRegions.join(", ")}`));
405
+ }
406
+ });
407
+ const missing = all.filter(v => v.networks[network] === null);
408
+ if (missing.length > 0)
409
+ console.log(chalk.gray(` (no ${network}: ${missing.map(v => v.id).join(", ")})`));
410
+ for (;;) {
411
+ const a = (await p.ask("\n Venues (e.g. 1,3 or 'all'): ")).toLowerCase();
412
+ if (a === "all")
413
+ return offered.map(v => v.id);
414
+ const parts = a.split(/[,\s]+/).filter(x => x.length > 0);
415
+ const picked = [];
416
+ let bad = null;
417
+ for (const part of parts) {
418
+ const n = Number(part);
419
+ if (Number.isInteger(n) && n >= 1 && n <= offered.length)
420
+ picked.push(offered[n - 1].id);
421
+ else {
422
+ const id = tryResolveVenueId(part);
423
+ if (id !== null && offered.some(v => v.id === id))
424
+ picked.push(id);
425
+ else
426
+ bad = part;
427
+ }
428
+ }
429
+ if (bad !== null)
430
+ console.log(chalk.yellow(` Unknown choice "${bad}".`));
431
+ else if (picked.length === 0)
432
+ console.log(chalk.yellow(" Pick at least one venue."));
433
+ else
434
+ return [...new Set(picked)];
435
+ }
436
+ }
437
+ async function venueApproveParams(p, venue, network, master) {
438
+ const base = {
439
+ venue, network, master, agentName: null, expiresIn: "90d",
440
+ canPerp: true, canSpot: false, canWithdraw: false, rotate: false, passphrase: null,
441
+ apiKeyIndex: null, forceSlot: false, accountIndex: 0, aster: {}, ondoKey: null,
442
+ };
443
+ if (venue === "aster") {
444
+ base.canSpot = await askYesNo(p, " Also allow the agent to trade Aster spot?", false);
445
+ }
446
+ else if (isLighterFamilyVenue(venue)) {
447
+ const allowed = venue === "lighter-rh" ? "4-254 except 157" : "4-254";
448
+ console.log(chalk.gray(` API key slots 0-3${venue === "lighter-rh" ? " and 157" : ""} are reserved by the venue's frontend.`));
449
+ for (;;) {
450
+ const a = await p.ask(` API key slot (${allowed}, blank = next free): `);
451
+ if (a === "")
452
+ break;
453
+ try {
454
+ assertLighterSlot(venue, Number(a));
455
+ base.apiKeyIndex = Number(a);
456
+ break;
457
+ }
458
+ catch (err) {
459
+ console.log(chalk.yellow(` ${err instanceof Error ? err.message : String(err)}`));
460
+ }
461
+ }
462
+ }
463
+ else if (venue === "arcus") {
464
+ console.log(chalk.yellow(" Arcus API access is whitelist-gated: your master EVM address must be on the Arcus access"));
465
+ console.log(chalk.yellow(" whitelist (request at https://arcus.xyz), otherwise registration is rejected."));
466
+ for (;;) {
467
+ const a = await p.ask(" Arcus account index (0-9, or 255 for all; trading needs 0-9) [0]: ");
468
+ const n = a === "" ? 0 : Number(a);
469
+ if (Number.isInteger(n) && ((n >= 0 && n <= 9) || n === 255)) {
470
+ base.accountIndex = n;
471
+ break;
472
+ }
473
+ console.log(chalk.yellow(" Enter 0-9 or 255."));
474
+ }
475
+ }
476
+ else if (venue === "ondo") {
477
+ console.log(chalk.gray(" Create an API key in the Ondo Perps web app first. The secret is read hidden and stored encrypted."));
478
+ const keyId = await p.ask(" Ondo API key id (blank = skip): ");
479
+ if (keyId === "")
480
+ return null;
481
+ const secret = await p.askHidden(" Ondo API secret (hidden): ");
482
+ base.ondoKey = { keyId, secret };
483
+ }
484
+ return base;
485
+ }
486
+ /** Prompt for the public inputs + the hidden key of a website-created key (`wallet agent import`). */
487
+ async function venueImportParams(p, venue, network) {
488
+ const def = getVenue(venue);
489
+ console.log(chalk.gray(` Create the key on the ${def.displayName} ${network} website first (API / agent wallet page).`));
490
+ const params = {
491
+ venue, network, agentName: null, masterAddress: null, accountIndex: null, apiKeyIndex: null, expiresIn: null, replace: false,
492
+ accountId: null,
493
+ // QFEX: the resolved account UUID is shown and confirmed before the key is stored.
494
+ confirmAccount: venue !== "qfex" ? null : async (binding) => {
495
+ console.log(chalk.white(` This key will trade QFEX account ${chalk.cyan(binding.accountId)} (${binding.isPrimaryAccount ? "primary" : "subaccount"}).`));
496
+ return askYesNo(p, " Store the key for this account?", false);
497
+ },
498
+ };
499
+ const askInt = async (q, optional) => {
500
+ for (;;) {
501
+ const a = await p.ask(q);
502
+ if (a === "" && optional)
503
+ return null;
504
+ const n = Number(a);
505
+ if (a !== "" && Number.isInteger(n) && n >= 0)
506
+ return n;
507
+ console.log(chalk.yellow(" Enter a non-negative integer."));
508
+ }
509
+ };
510
+ if (venue === "hyperliquid") {
511
+ const a = await p.ask(" Master address (blank = read it from Hyperliquid): ");
512
+ params.masterAddress = a === "" ? null : a;
513
+ }
514
+ else if (venue === "aster" || venue === "pacifica" || venue === "arcus") {
515
+ for (;;) {
516
+ const a = await p.ask(` Master address you log in with on ${def.displayName} (${venue === "pacifica" ? "Solana" : "EVM"}; blank = skip venue): `);
517
+ if (a === "")
518
+ return null;
519
+ params.masterAddress = a;
520
+ break;
521
+ }
522
+ if (venue === "arcus")
523
+ params.accountIndex = await askInt(" Account index the key is scoped to (blank = as registered): ", true);
524
+ }
525
+ else if (isLighterFamilyVenue(venue)) {
526
+ params.accountIndex = await askInt(" Account index (shown with the API key): ", false);
527
+ params.apiKeyIndex = await askInt(" API key index / slot of the key: ", false);
528
+ }
529
+ let secret;
530
+ if (venue === "ondo") {
531
+ const keyId = await p.ask(" Ondo API key id (ondoKeyId_…; blank = skip): ");
532
+ if (keyId === "")
533
+ return null;
534
+ secret = { kind: "ondo", keyId, secret: await p.askHidden(" Ondo API secret (hidden): ") };
535
+ }
536
+ else if (venue === "qfex") {
537
+ console.log(chalk.gray(" No signature. Your QFEX API key is stored encrypted in ~/.perp/qfex-agents/ and checked with read-only requests."));
538
+ console.log(chalk.gray(" Create it in QFEX Developer Settings (2FA). For trading, leave \"Deposit and withdraw\" OFF."));
539
+ const publicKey = await p.ask(" QFEX public key (blank = skip): ");
540
+ if (publicKey === "")
541
+ return null;
542
+ secret = { kind: "qfex", publicKey, secret: await p.askHidden(" QFEX secret (hidden): ") };
543
+ const sub = await p.ask(" Subaccount id (blank = primary): ");
544
+ params.accountId = sub === "" ? null : sub;
545
+ }
546
+ else {
547
+ const key = await p.askHidden(` ${importSecretLabel(venue)} (hidden; blank = skip): `);
548
+ if (key === "")
549
+ return null;
550
+ secret = { kind: "key", key };
551
+ }
552
+ return { ...params, secret };
553
+ }
554
+ async function stepAgents(p, venues, network, master, getPassphrase) {
555
+ console.log(chalk.cyan.bold("\n Step 4/7 — Agent wallets / API keys\n"));
556
+ console.log(chalk.gray(" An agent is a separate trading key. perp-cli signs orders with it, so the master key is not"));
557
+ console.log(chalk.gray(" needed for every trade. Two ways to get one per venue:"));
558
+ console.log(chalk.gray(" approve — this computer's master wallet authorizes a new agent (one signature)"));
559
+ console.log(chalk.gray(" import — paste an agent / API key you created on the exchange website (no master needed here)"));
560
+ console.log(chalk.gray(` ${IMPORT_SCOPE_NOTE}`));
561
+ console.log(chalk.gray(" Neither moves funds. Withdrawal permission is never granted here."));
562
+ const info = master !== null ? owsWalletInfo(master) : null;
563
+ if (master !== null && !info)
564
+ throw new PerpError("KEY_NOT_FOUND", `OWS wallet "${master}" not found`, { remediation: "perp wallet list" });
565
+ const results = [];
566
+ for (const venue of venues) {
567
+ const def = getVenue(venue);
568
+ console.log(chalk.white.bold(`\n ${def.displayName}`));
569
+ const existing = registeredAgentNames(venue, network);
570
+ if (existing.length > 0) {
571
+ const reason = `already registered (${existing.join(", ")}); to replace: ${rotateHint(venue)}`;
572
+ console.log(chalk.gray(` Skipped: ${reason}.`));
573
+ results.push({ venue, status: "skipped", reason });
574
+ continue;
575
+ }
576
+ // Why "approve" is not offered for this venue (null = it is). Import is always offered.
577
+ let approveBlocked = venue === "ondo" ? "Ondo keys are always created on the website"
578
+ : venue === "qfex" ? "QFEX keys are always created on the website (2FA)"
579
+ : approveBlocker(venue, network);
580
+ if (approveBlocked === null && master === null)
581
+ approveBlocked = "no master wallet on this computer";
582
+ if (approveBlocked === null && info !== null && ((def.masterKeyKind === "solana" && info.solana === null) || (def.masterKeyKind === "evm" && info.evm === null))) {
583
+ approveBlocked = `wallet "${master}" has no ${def.masterKeyKind} account`;
584
+ }
585
+ let method;
586
+ if (approveBlocked === null) {
587
+ for (;;) {
588
+ const a = (await p.ask(" 1) approve with this computer's master wallet 2) import a key created on the website 3) skip [1]: ")).toLowerCase();
589
+ if (a === "" || a === "1" || a === "y" || a === "yes") {
590
+ method = "approve";
591
+ break;
215
592
  }
216
- if (!sharedKey) {
217
- for (const ex of evmExchanges) {
218
- const info = EXCHANGE_ENV_MAP[ex];
219
- const key = await ask(rl, ` ${info.label} private key (0x...): `);
220
- if (!key) {
221
- console.log(chalk.gray(" Skipped."));
222
- continue;
223
- }
224
- const { valid, address } = await validateKey("evm", key);
225
- if (!valid) {
226
- console.log(chalk.red(" Invalid key, skipped."));
227
- continue;
228
- }
229
- const normalized = key.startsWith("0x") ? key : `0x${key}`;
230
- newEnv[info.envKey] = normalized;
231
- console.log(` ${chalk.green("OK")} ${chalk.gray(address)}`);
232
- }
593
+ if (a === "2" || a === "import") {
594
+ method = "import";
595
+ break;
233
596
  }
234
- }
235
- if (solExchanges.length > 0) {
236
- console.log(chalk.cyan.bold("\n Solana Private Key") + chalk.gray(" (for Pacifica)"));
237
- const key = await ask(rl, " Solana private key (base58): ");
238
- if (key) {
239
- const { valid, address } = await validateKey("solana", key);
240
- if (valid) {
241
- newEnv.PACIFICA_PRIVATE_KEY = key;
242
- console.log(` ${chalk.green("OK")} ${chalk.gray(address)}`);
243
- }
244
- else {
245
- console.log(chalk.red(" Invalid Solana key, skipped."));
246
- }
597
+ if (a === "3" || a === "n" || a === "no") {
598
+ method = "skip";
599
+ break;
247
600
  }
601
+ console.log(chalk.yellow(" Enter 1, 2 or 3."));
248
602
  }
249
- // v0.12.4: API-key (Aster legacy HMAC) onboarding removed from the
250
- // wizard. Aster users follow the redirect above to
251
- // `perp wallet agent approve aster`.
252
- // Default exchange
253
- const settings = loadSettings();
254
- if (selected.length === 1) {
255
- settings.defaultExchange = selected[0];
256
- }
257
- else if (!settings.defaultExchange) {
258
- const defaultEx = await askChoice(rl, "\n Default exchange?", selected);
259
- settings.defaultExchange = defaultEx;
260
- }
261
- saveSettings(settings);
262
- // Write .env
263
- writeEnvFile(newEnv);
264
- // Summary
265
- console.log(chalk.cyan.bold("\n Setup Complete!\n"));
266
- console.log(` Config: ${chalk.gray("~/.perp/.env")}`);
267
- console.log(` Default: ${chalk.cyan(settings.defaultExchange || selected[0])}`);
268
- for (const ex of selected) {
269
- const info = EXCHANGE_ENV_MAP[ex];
270
- const key = newEnv[info.envKey];
271
- if (key) {
272
- console.log(` ${chalk.cyan(ex.padEnd(14))} ${chalk.green("configured")}`);
603
+ }
604
+ else {
605
+ console.log(chalk.gray(` Approve here is not available (${approveBlocked}).`));
606
+ method = (await askYesNo(p, ` Import a key created on the ${def.displayName} website?`, true)) ? "import" : "skip";
607
+ }
608
+ if (method === "skip") {
609
+ results.push({ venue, status: "skipped", reason: approveBlocked ?? "skipped by user" });
610
+ continue;
611
+ }
612
+ try {
613
+ if (method === "import") {
614
+ const params = await venueImportParams(p, venue, network);
615
+ if (params === null) {
616
+ results.push({ venue, status: "skipped", reason: "skipped by user" });
617
+ continue;
273
618
  }
619
+ const outcome = await importVenueAgent(params);
620
+ printImportOutcome(outcome);
621
+ results.push({ venue, status: "imported", agentName: outcome.agentName, verification: outcome.verification });
622
+ continue;
274
623
  }
275
- console.log(chalk.white.bold("\n Next steps:"));
276
- console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
277
- if (selected.includes("hyperliquid")) {
278
- console.log(` ${chalk.green("perp deposit hyperliquid <amt>")} deposit USDC`);
624
+ const params = await venueApproveParams(p, venue, network, master);
625
+ if (params === null) {
626
+ results.push({ venue, status: "skipped", reason: "skipped by user" });
627
+ continue;
279
628
  }
280
- if (selected.includes("pacifica")) {
281
- console.log(` ${chalk.green("perp deposit pacifica <amt>")} deposit USDC`);
629
+ console.log(chalk.white(`\n ${network === "mainnet" ? chalk.red.bold("MAINNET") : "testnet"} — this will:`));
630
+ for (const line of signingSummary(params, info))
631
+ console.log(` ${line}`);
632
+ console.log(chalk.gray(" No deposit, withdrawal, or trade is made."));
633
+ if (!(await askYesNo(p, " Sign and submit?", false))) {
634
+ results.push({ venue, status: "skipped", reason: "not confirmed" });
635
+ continue;
282
636
  }
283
- console.log(` ${chalk.green("perp market list")} browse markets`);
284
- console.log(` ${chalk.green("perp dashboard")} live monitoring\n`);
285
- rl.close();
637
+ params.passphrase = await getPassphrase();
638
+ const outcome = await approveVenueAgent(params);
639
+ printApproveOutcome(outcome);
640
+ results.push({ venue, status: "approved", agentName: "meta" in outcome ? outcome.meta.agentName : outcome.agentName });
286
641
  }
287
642
  catch (err) {
288
- rl.close();
289
- throw err;
643
+ const r = failed(venue, err);
644
+ if (r.status === "failed") {
645
+ console.log(chalk.red(` ✗ ${def.displayName}: [${r.error.code}] ${r.error.message}`));
646
+ if (r.error.remediation)
647
+ console.log(chalk.gray(` ${r.error.remediation}`));
648
+ }
649
+ results.push(r);
290
650
  }
651
+ }
652
+ return results;
653
+ }
654
+ async function stepSlippage(p, venues, network) {
655
+ console.log(chalk.cyan.bold("\n Step 5/7 — Slippage bound\n"));
656
+ for (const line of slippageExplanation(venues))
657
+ console.log(chalk.gray(` ${line}`));
658
+ const s = loadSettings();
659
+ const shipped = s.defaultSlippageBps === SHIPPED_DEFAULT_SLIPPAGE_BPS ? " (shipped default)" : "";
660
+ console.log(`\n Current default: ${chalk.cyan(String(s.defaultSlippageBps))} bps${chalk.gray(shipped)}`);
661
+ for (const [v, bps] of Object.entries(s.slippageBps))
662
+ console.log(` Current ${v}: ${chalk.cyan(String(bps))} bps`);
663
+ let defaultSlippageBps = null;
664
+ const max = maxDefaultSlippageBps();
665
+ for (;;) {
666
+ const a = await p.ask(`\n Global default in bps (blank = keep ${s.defaultSlippageBps}): `);
667
+ if (a === "")
668
+ break;
669
+ try {
670
+ defaultSlippageBps = parseSlippageSettingBps("defaultSlippageBps", a, max);
671
+ break;
672
+ }
673
+ catch (err) {
674
+ console.log(chalk.yellow(` ${err instanceof Error ? err.message : String(err)}`));
675
+ }
676
+ }
677
+ const slippageBps = {};
678
+ for (;;) {
679
+ const a = await p.ask(" Per-venue overrides as venue=bps, comma-separated (blank = none): ");
680
+ if (a === "")
681
+ break;
682
+ try {
683
+ Object.assign(slippageBps, parseVenueSlippage(a.split(/[,\s]+/).filter(x => x.length > 0), network));
684
+ break;
685
+ }
686
+ catch (err) {
687
+ console.log(chalk.yellow(` ${err instanceof PerpError ? err.structured.message : String(err)}`));
688
+ }
689
+ }
690
+ return { defaultSlippageBps, slippageBps };
691
+ }
692
+ async function stepDefaultVenue(p, venues) {
693
+ console.log(chalk.cyan.bold("\n Step 6/7 — Default venue\n"));
694
+ if (venues.length === 1) {
695
+ console.log(` ${chalk.cyan(venues[0])} (only selected venue)`);
696
+ return venues[0];
697
+ }
698
+ venues.forEach((v, i) => console.log(` ${i + 1}) ${v}`));
699
+ for (;;) {
700
+ const n = Number(await p.ask(" Default venue for commands without -e: "));
701
+ if (Number.isInteger(n) && n >= 1 && n <= venues.length)
702
+ return venues[n - 1];
703
+ }
704
+ }
705
+ async function stepSummary(venues, network, master, results, getPassphrase) {
706
+ console.log(chalk.cyan.bold("\n Step 7/7 — Summary\n"));
707
+ const s = loadSettings();
708
+ console.log(` Wallet: ${master !== null ? chalk.white.bold(s.owsActiveWallet) : chalk.gray("none on this computer (imported API keys only)")}`);
709
+ console.log(` Network: ${network}`);
710
+ console.log(` Default: ${chalk.cyan(s.defaultExchange)}`);
711
+ console.log(` Slippage: ${s.defaultSlippageBps} bps default${Object.entries(s.slippageBps).map(([v, b]) => `, ${v} ${b}`).join("")}`);
712
+ for (const r of results) {
713
+ const text = r.status === "approved" ? chalk.green(`agent ${r.agentName} registered`)
714
+ : r.status === "imported" ? (r.verification.status === "verified"
715
+ ? chalk.green(`key ${r.agentName} imported (verified)`)
716
+ : chalk.yellow(`key ${r.agentName} imported — NOT verified: ${r.verification.method}`))
717
+ : r.status === "skipped" ? chalk.gray(`skipped — ${r.reason}`)
718
+ : chalk.red(`failed — [${r.error.code}] ${r.error.message}`);
719
+ console.log(` ${r.venue.padEnd(12)} ${text}`);
720
+ }
721
+ // Read-only live check for every venue whose agent was approved here (imported keys were
722
+ // checked at import time — the result is shown above).
723
+ const approvedHere = new Set(results.filter(r => r.status === "approved").map(r => r.venue));
724
+ const toVerify = master === null ? [] : venues.filter(v => approvedHere.has(v) && registeredAgentNames(v, network).length > 0 && approveBlocker(v, network) === null);
725
+ if (toVerify.length > 0) {
726
+ console.log(chalk.white.bold("\n Agent verification (read-only):"));
727
+ for (const v of toVerify) {
728
+ try {
729
+ const r = await verifyVenueAgent(v, network, { master, passphrase: await getPassphrase() });
730
+ const icon = r.registered ? chalk.green("✓") : chalk.yellow("○");
731
+ console.log(` ${icon} ${v}: ${r.count} key(s) on the venue`);
732
+ for (const w of r.warnings ?? [])
733
+ console.log(chalk.yellow(` [warn] ${w}`));
734
+ }
735
+ catch (err) {
736
+ const e = classifyError(err, v);
737
+ console.log(chalk.gray(` – ${v}: [${e.code}] ${e.message}`));
738
+ }
739
+ }
740
+ }
741
+ if (results.some(r => r.status === "imported"))
742
+ console.log(chalk.gray(`\n ${IMPORT_SCOPE_NOTE}`));
743
+ console.log(chalk.white.bold("\n Next steps:"));
744
+ for (const c of nextStepCommands(venues, network))
745
+ console.log(` ${chalk.green(c)}`);
746
+ console.log();
747
+ }
748
+ /**
749
+ * Optional builder-fee step — offered only when settings `builder.<venue>` already holds a
750
+ * value for a chosen venue (builder fees ship off). Explains the fee and approves only after
751
+ * an explicit "y" (default No). Never configures a builder itself.
752
+ */
753
+ export async function stepBuilder(p, venues, network, master, getPassphrase) {
754
+ const { builderTargetOf } = await import("../exchanges/builder-fee.js");
755
+ const builder = loadSettings().builder;
756
+ const configured = venues.filter(v => builderTargetOf(builder, v) !== null);
757
+ if (configured.length === 0)
758
+ return;
759
+ const { builderApprovalPlan, approveBuilderOnVenue } = await import("./builder.js");
760
+ console.log(chalk.cyan.bold("\n Optional — Builder fees\n"));
761
+ console.log(chalk.gray(" A builder fee is an extra fee on YOUR perp orders, paid to the builder in settings builder.<venue>."));
762
+ console.log(chalk.gray(" It is attached only after the venue approval is confirmed. Default: skip.\n"));
763
+ for (const v of configured) {
764
+ try {
765
+ const plan = builderApprovalPlan(v, network, master);
766
+ const fee = plan.feeKind === "share" ? "fee share (you pay the normal fee)" : `${plan.feeKind === "max" ? "up to " : ""}${plan.feeBps} bps`;
767
+ if (plan.approval === "none") {
768
+ console.log(` ${v.padEnd(12)} ${plan.identity} ${fee} — no approval step; attached as configured (remove: perp settings unset builder.${v})`);
769
+ continue;
770
+ }
771
+ if (plan.master === null) {
772
+ console.log(` ${v.padEnd(12)} ${chalk.yellow(`no master wallet here — approve builder ${plan.identity} (${fee}) on the ${getVenue(v).displayName} website`)}`);
773
+ continue;
774
+ }
775
+ if (!(await askYesNo(p, ` Approve builder ${plan.identity} to charge ${fee} on your ${v} perp orders (cap ${plan.capBps} bps)?`, false))) {
776
+ console.log(chalk.gray(` ${v}: skipped — no fee is attached until approved (perp wallet builder approve ${v})`));
777
+ continue;
778
+ }
779
+ await approveBuilderOnVenue(plan, await getPassphrase());
780
+ console.log(chalk.green(` ${v}: builder ${plan.identity} approved (${fee})`));
781
+ }
782
+ catch (err) {
783
+ const e = classifyError(err, v);
784
+ console.log(chalk.yellow(` ${v}: builder approval not done — [${e.code}] ${e.message}`));
785
+ }
786
+ }
787
+ }
788
+ /** Interactive `perp setup`. All prompts go through `p`; nothing secret is printed or written to .env. */
789
+ export async function runInteractiveSetup(p, passphraseFlag, preselectedNetwork) {
790
+ console.log(chalk.cyan.bold("\n perp-cli Setup Wizard\n"));
791
+ const legacy = legacyEnvKeyNames();
792
+ if (legacy.length > 0) {
793
+ console.log(chalk.yellow(` ⚠ ${ENV_FILE} holds raw private keys (${legacy.join(", ")}).`));
794
+ console.log(chalk.yellow(" They keep working, but consider moving them into the encrypted OWS vault:"));
795
+ console.log(chalk.yellow(" perp wallet import --name <name> [--chain evm|solana|dual] (hidden prompt / stdin), then"));
796
+ console.log(chalk.yellow(" perp settings env remove <NAME> for each entry. This wizard does not touch them.\n"));
797
+ }
798
+ const master = await stepMasterWallet(p);
799
+ let passphrase = master.passphrase;
800
+ const getPassphrase = async () => {
801
+ if (passphrase !== null)
802
+ return passphrase;
803
+ if (master.name === null)
804
+ throw new PerpError("NO_SIGNER_AVAILABLE", "No master wallet on this computer");
805
+ const resolved = await resolvePassphrase({ flag: passphraseFlag });
806
+ passphrase = resolved !== null ? resolved : await p.askHidden(` Passphrase for wallet "${master.name}" (hidden): `);
807
+ return passphrase;
808
+ };
809
+ const network = await stepNetwork(p, preselectedNetwork);
810
+ const venues = await stepVenues(p, network);
811
+ const results = await stepAgents(p, venues, network, master.name, getPassphrase);
812
+ const slip = await stepSlippage(p, venues, network);
813
+ const defaultVenue = await stepDefaultVenue(p, venues);
814
+ applySetupSettings({
815
+ owsActiveWallet: master.name,
816
+ defaultExchange: defaultVenue,
817
+ defaultSlippageBps: slip.defaultSlippageBps,
818
+ slippageBps: slip.slippageBps,
291
819
  });
820
+ await stepBuilder(p, venues, network, master.name, getPassphrase);
821
+ await stepSummary(venues, network, master.name, results, getPassphrase);
822
+ }
823
+ // ── Commands ──────────────────────────────────────────────────
824
+ function collect(value, prev) {
825
+ return [...prev, value];
292
826
  }
293
- function parseExchangeChoice(input) {
294
- const lower = input.toLowerCase().trim();
295
- if (lower === "all" || lower === "1,2,3,4")
296
- return ["pacifica", "hyperliquid", "lighter", "aster"];
297
- if (lower === "1,2,3")
298
- return ["pacifica", "hyperliquid", "lighter"];
299
- const exchanges = [];
300
- const parts = lower.split(/[,\s]+/);
301
- for (const p of parts) {
302
- if (p === "1" || p.startsWith("pac"))
303
- exchanges.push("pacifica");
304
- else if (p === "2" || p.startsWith("hyp") || p === "hl")
305
- exchanges.push("hyperliquid");
306
- else if (p === "3" || p.startsWith("lig") || p === "lt")
307
- exchanges.push("lighter");
308
- else if (p === "4" || p.startsWith("ast"))
309
- exchanges.push("aster");
310
- }
311
- return [...new Set(exchanges)];
827
+ export function registerInitCommand(program) {
828
+ program
829
+ .command("setup")
830
+ .alias("init")
831
+ .description("Setup wizard — OWS wallet, venues, agent wallets, slippage, default venue (scriptable with --non-interactive)")
832
+ .option("--non-interactive", "Run without prompts (scripts / CI)")
833
+ .option("--wallet <name>", "Existing OWS wallet to use as master")
834
+ .option("--wallet-name <name>", "Create a new OWS wallet with this name as master")
835
+ .addOption(deprecatedPassphraseOption("OWS vault passphrase")).hook("preAction", warnDeprecatedPassphrase)
836
+ .option("--venues <list>", "Comma-separated trading venues to configure")
837
+ .option("--default-venue <venue>", "Default venue (settings.defaultExchange)")
838
+ .option("--default-exchange <venue>", "Alias of --default-venue")
839
+ .option("--slippage-bps <n>", "Global default slippage bound in bps (settings.defaultSlippageBps)")
840
+ .option("--slippage <venue=bps>", "Per-venue slippage bound (repeatable)", collect, [])
841
+ .option("--approve-agents", "Register an agent wallet / API key on each --venues venue (requires --yes)")
842
+ .option("--yes", "Confirm signing for --approve-agents")
843
+ .option("--ondo-secret-stdin", "Read the Ondo API key id + secret from stdin (2 lines) for --approve-agents")
844
+ .option("--aster-can-spot", "Aster agent: also allow spot trading")
845
+ .option("--lighter-api-key-index <n>", "Lighter agent API key slot (4-254; default: next free)")
846
+ .option("--lighter-rh-api-key-index <n>", "Lighter RH agent API key slot (4-254 except 157; default: next free)")
847
+ .option("--arcus-account-index <n>", "Arcus subaccount for the API key: 0-9, or 255 for all")
848
+ .option("--import-agents <venues>", "Import keys created on the exchange WEBSITES for these --venues (requires --yes); keys on stdin, one line per venue in this order (ondo: 2 lines, key id then secret; qfex: 2 lines, public key then secret). No master wallet needed when every venue is imported")
849
+ .option("--master-address <venue=address>", "Master / owner public address for an --import-agents venue (repeatable; required for aster, pacifica, arcus)", collect, [])
850
+ .option("--lighter-account-index <n>", "Lighter account index of the imported API key (--import-agents)")
851
+ .option("--lighter-rh-account-index <n>", "Lighter RH account index of the imported API key (--import-agents)")
852
+ .option("--qfex-account-id <uuid>", "QFEX account the imported key trades (--import-agents; required — it confirms the account the key is bound to)")
853
+ .option("--allow-unverified <venues>", "Accept storing these --import-agents keys UNVERIFIED (required for pacifica: no read-only check exists); rolled back if another import in the batch fails")
854
+ .action(async (opts, command) => {
855
+ // optsWithGlobals() merges parent + subcommand options so --passphrase /
856
+ // --wallet work whether placed on `perp --passphrase X setup` (parent) or
857
+ // `perp setup --passphrase X` (subcommand). Commander v13 binds an
858
+ // identically named flag to the parent, so opts.<flag> alone would be
859
+ // undefined. The network is the root -n/--network flag (validated).
860
+ const merged = command.optsWithGlobals();
861
+ const resolvedOpts = {
862
+ ...opts,
863
+ passphrase: merged.passphrase ?? opts.passphrase,
864
+ network: networkOf(command),
865
+ wallet: merged.wallet ?? opts.wallet,
866
+ };
867
+ if (resolvedOpts.nonInteractive) {
868
+ return runNonInteractiveSetup(resolvedOpts);
869
+ }
870
+ if (process.stdin.isTTY !== true) {
871
+ const err = new PerpError("INVALID_PARAMS", "Interactive setup needs a terminal", {
872
+ remediation: "Run in a terminal, or use: perp setup --non-interactive --wallet <name> --venues <list> ...",
873
+ });
874
+ console.error(chalk.red(`\n Error [${err.structured.code}]: ${err.structured.message}`));
875
+ console.error(chalk.gray(` Remediation: ${err.structured.remediation}\n`));
876
+ process.exitCode = 1;
877
+ return;
878
+ }
879
+ await runInteractiveSetup(ttyPrompter(), resolvedOpts.passphrase, resolvedOpts.network);
880
+ });
312
881
  }
313
882
  // ── Non-interactive setup (agent / CI / Docker) ──────────────────────────────
314
- const VALID_EXCHANGES = ["pacifica", "hyperliquid", "lighter", "aster"];
315
883
  /**
316
884
  * Test seam: emit JSON envelopes when the global --json flag is present.
317
885
  * Mirrors the inline pattern used elsewhere (see src/index.ts:87) instead of
@@ -320,79 +888,373 @@ const VALID_EXCHANGES = ["pacifica", "hyperliquid", "lighter", "aster"];
320
888
  function setupIsJson() {
321
889
  return process.argv.includes("--json");
322
890
  }
891
+ /** Parse `<venue>=<address>` entries (--master-address). */
892
+ function parseVenueAddresses(entries, network) {
893
+ const out = {};
894
+ for (const entry of entries) {
895
+ const eq = entry.indexOf("=");
896
+ if (eq <= 0 || eq === entry.length - 1) {
897
+ throw new PerpError("INVALID_PARAMS", `Invalid --master-address "${entry}"`, { remediation: "Use <venue>=<address>, e.g. --master-address aster=0xYOU" });
898
+ }
899
+ out[resolveSetupVenue(entry.slice(0, eq), network)] = entry.slice(eq + 1).trim();
900
+ }
901
+ return out;
902
+ }
903
+ /** All of piped stdin (keys for --import-agents). */
904
+ function readSetupStdin() {
905
+ return new Promise((resolveRead, reject) => {
906
+ const chunks = [];
907
+ process.stdin.setEncoding("utf-8");
908
+ process.stdin.on("data", (c) => chunks.push(typeof c === "string" ? c : c.toString("utf-8")));
909
+ process.stdin.on("end", () => resolveRead(chunks.join("")));
910
+ process.stdin.on("error", reject);
911
+ });
912
+ }
913
+ function parseSlotFlag(flag, raw) {
914
+ if (raw === undefined)
915
+ return null;
916
+ const n = Number(raw);
917
+ if (!Number.isInteger(n))
918
+ throw new PerpError("INVALID_PARAMS", `${flag} must be an integer (got "${raw}")`);
919
+ return n;
920
+ }
323
921
  /**
324
- * Drive `perp setup` end-to-end without TTY. Required:
325
- * - --passphrase or OWS_PASSPHRASE env (3-path resolver)
326
- * Optional:
327
- * - --wallet-name (default: "main")
328
- * - --default-exchange (must be one of pacifica/hyperliquid/lighter/aster)
922
+ * Drive `perp setup` end-to-end without TTY.
329
923
  *
330
- * Side effects:
331
- * 1. Creates an OWS wallet via createWallet(name, passphrase)
332
- * 2. Sets settings.owsActiveWallet (if unset) + settings.defaultExchange (if provided)
924
+ * Master wallet: exactly one of --wallet <existing> or --wallet-name <new>.
925
+ * Passphrase (--passphrase / OWS_PASSPHRASE / stdin) is required to create a
926
+ * wallet or to sign agent approvals. With --ondo-secret-stdin, stdin carries
927
+ * the Ondo key, so the passphrase must come from the flag or env.
333
928
  *
334
- * Errors are returned as INVALID_PARAMS / PASSPHRASE_REQUIRED — never via
335
- * readline. JSON envelope is emitted on stdout when --json is present.
929
+ * Side effects, in order: validate everything → create wallet (if asked) →
930
+ * write settings (owsActiveWallet, defaultExchange, slippage) → register
931
+ * agents (only with --approve-agents --yes; a failure on one venue does not
932
+ * stop the others). Never deposits, withdraws, or trades; never writes .env.
336
933
  */
337
934
  export async function runNonInteractiveSetup(opts) {
338
935
  const isJson = setupIsJson();
339
936
  try {
340
- // Validate --default-exchange shape early so users hit a precise error
341
- // before passphrase resolution.
342
- if (opts.defaultExchange !== undefined &&
343
- !VALID_EXCHANGES.includes(opts.defaultExchange)) {
344
- throw new PerpError("INVALID_PARAMS", `Invalid --default-exchange "${opts.defaultExchange}"`, {
345
- remediation: `Use one of: ${VALID_EXCHANGES.join(", ")}`,
937
+ // ── Validate all inputs before any side effect ──
938
+ const network = parseNetwork(opts.network);
939
+ const venues = opts.venues !== undefined ? parseVenueList(opts.venues, network) : [];
940
+ if (opts.defaultVenue !== undefined && opts.defaultExchange !== undefined && opts.defaultVenue !== opts.defaultExchange) {
941
+ throw new PerpError("INVALID_PARAMS", "--default-venue and --default-exchange disagree", { remediation: "Pass only --default-venue" });
942
+ }
943
+ const defaultRaw = opts.defaultVenue !== undefined ? opts.defaultVenue : opts.defaultExchange;
944
+ let defaultVenue = null;
945
+ if (defaultRaw !== undefined) {
946
+ const id = tryResolveVenueId(defaultRaw);
947
+ if (id === null || !getVenue(id).roles.includes("trading")) {
948
+ throw new PerpError("INVALID_PARAMS", `Invalid --default-venue "${defaultRaw}"`, {
949
+ remediation: `Use one of: ${(venues.length > 0 ? venues : setupVenues().map(v => v.id)).join(", ")}`,
950
+ });
951
+ }
952
+ if (venues.length > 0 && !venues.includes(id)) {
953
+ throw new PerpError("INVALID_PARAMS", `--default-venue ${id} is not in --venues`, { remediation: `Use one of: ${venues.join(", ")}` });
954
+ }
955
+ defaultVenue = id;
956
+ }
957
+ else if (venues.length === 1) {
958
+ defaultVenue = venues[0];
959
+ }
960
+ const defaultSlippageBps = opts.slippageBps !== undefined
961
+ ? parseSlippageSettingBps("defaultSlippageBps", opts.slippageBps, maxDefaultSlippageBps())
962
+ : null;
963
+ const venueSlippage = parseVenueSlippage(opts.slippage, network);
964
+ if (opts.approveAgents && !opts.yes) {
965
+ throw new PerpError("INVALID_PARAMS", "--approve-agents signs approvals with your master wallet and needs --yes", {
966
+ remediation: "Re-run with --approve-agents --yes",
967
+ });
968
+ }
969
+ if (opts.approveAgents && venues.length === 0) {
970
+ throw new PerpError("INVALID_PARAMS", "--approve-agents needs --venues", { remediation: "Pass --venues <list>" });
971
+ }
972
+ // Ondo in --import-agents is read from the shared stdin instead of --ondo-secret-stdin.
973
+ const ondoImported = opts.importAgents !== undefined && parseVenueList(opts.importAgents, network).includes("ondo");
974
+ const approvingOndo = opts.approveAgents === true && venues.includes("ondo") && !ondoImported;
975
+ if (opts.ondoSecretStdin && !approvingOndo) {
976
+ throw new PerpError("INVALID_PARAMS", "--ondo-secret-stdin is only used with --approve-agents and ondo in --venues", {
977
+ remediation: "Add --approve-agents --yes --venues ondo,... or drop --ondo-secret-stdin",
978
+ });
979
+ }
980
+ if (approvingOndo && !opts.ondoSecretStdin) {
981
+ throw new PerpError("INVALID_PARAMS", "Ondo agent registration reads the API key from stdin", {
982
+ remediation: "printf '%s\\n%s\\n' \"$KEY_ID\" \"$SECRET\" | perp setup --non-interactive ... --ondo-secret-stdin",
983
+ });
984
+ }
985
+ // Raw slots are what an --import-agents key uses (the website picked them); an approve slot must be 4-254.
986
+ const lighterSlotRaw = parseSlotFlag("--lighter-api-key-index", opts.lighterApiKeyIndex);
987
+ const lighterRhSlotRaw = parseSlotFlag("--lighter-rh-api-key-index", opts.lighterRhApiKeyIndex);
988
+ const importsVenue = (v) => opts.importAgents !== undefined && parseVenueList(opts.importAgents, network).includes(v);
989
+ const lighterSlot = importsVenue("lighter") ? null : lighterSlotRaw;
990
+ if (lighterSlot !== null)
991
+ assertLighterSlot("lighter", lighterSlot);
992
+ const lighterRhSlot = importsVenue("lighter-rh") ? null : lighterRhSlotRaw;
993
+ if (lighterRhSlot !== null)
994
+ assertLighterSlot("lighter-rh", lighterRhSlot);
995
+ const arcusAccountIndex = parseSlotFlag("--arcus-account-index", opts.arcusAccountIndex);
996
+ if (arcusAccountIndex !== null && !((arcusAccountIndex >= 0 && arcusAccountIndex <= 9) || arcusAccountIndex === 255)) {
997
+ throw new PerpError("INVALID_PARAMS", `--arcus-account-index must be 0-9 or 255 (got ${arcusAccountIndex})`);
998
+ }
999
+ // ── Website-created keys (--import-agents): validated here, keys read from stdin below ──
1000
+ const importVenues = opts.importAgents !== undefined ? parseVenueList(opts.importAgents, network) : [];
1001
+ if (importVenues.length > 0) {
1002
+ if (!opts.yes) {
1003
+ throw new PerpError("INVALID_PARAMS", "--import-agents stores and live-checks the given keys and needs --yes", { remediation: "Re-run with --import-agents <venues> --yes" });
1004
+ }
1005
+ const outside = importVenues.filter(v => !venues.includes(v));
1006
+ if (outside.length > 0) {
1007
+ throw new PerpError("INVALID_PARAMS", `--import-agents venue(s) not in --venues: ${outside.join(", ")}`, { remediation: "List every imported venue in --venues too" });
1008
+ }
1009
+ if (opts.ondoSecretStdin) {
1010
+ throw new PerpError("INVALID_PARAMS", "--ondo-secret-stdin cannot be combined with --import-agents (both read stdin)", {
1011
+ remediation: "Put ondo in --import-agents (its key id + secret are its 2 stdin lines)",
1012
+ });
1013
+ }
1014
+ if (process.stdin.isTTY === true) {
1015
+ throw new PerpError("INVALID_PARAMS", "--import-agents reads the keys from stdin, but stdin is a terminal", {
1016
+ remediation: "Pipe the keys, one line per --import-agents venue in order (ondo: key id, secret): printf '%s\\n' \"$HL_AGENT_KEY\" \"$LT_KEY\" | perp setup --non-interactive ... --import-agents hyperliquid,lighter --yes",
1017
+ });
1018
+ }
1019
+ }
1020
+ // M3: a venue whose key cannot be checked is stored only with explicit consent.
1021
+ const allowUnverified = opts.allowUnverified !== undefined ? parseVenueList(opts.allowUnverified, network) : [];
1022
+ const strayAllow = allowUnverified.filter(v => !importVenues.includes(v));
1023
+ if (strayAllow.length > 0) {
1024
+ throw new PerpError("INVALID_PARAMS", `--allow-unverified ${strayAllow.join(",")}: not in --import-agents`, { remediation: "List only --import-agents venues in --allow-unverified" });
1025
+ }
1026
+ const unverifiedNotAllowed = importVenues.filter(v => UNVERIFIED_IMPORT_VENUES.includes(v) && !allowUnverified.includes(v));
1027
+ if (unverifiedNotAllowed.length > 0) {
1028
+ throw new PerpError("INVALID_PARAMS", `--import-agents ${unverifiedNotAllowed.join(",")}: the key cannot be checked against the venue and would be stored UNVERIFIED`, {
1029
+ remediation: `Accept that explicitly with --allow-unverified ${unverifiedNotAllowed.join(",")} (it is removed again if another import in the batch fails), or import it alone: perp wallet agent import ${unverifiedNotAllowed[0]}`,
346
1030
  });
347
1031
  }
348
- // Resolve passphrase. resolvePassphrase already throws PASSPHRASE_REQUIRED
349
- // when stdin is non-TTY and nothing was provided. In TTY mode it returns
350
- // null — non-interactive mode must NOT fall back to a prompt.
351
- const resolved = await resolvePassphrase({ flag: opts.passphrase });
352
- if (resolved === null) {
353
- throw new PerpError("PASSPHRASE_REQUIRED", "Non-interactive setup requires a passphrase", {
354
- remediation: "Provide --passphrase <pp> or set OWS_PASSPHRASE env var",
1032
+ const masterAddresses = parseVenueAddresses(opts.masterAddress, network);
1033
+ for (const v of Object.keys(masterAddresses)) {
1034
+ if (!importVenues.includes(v))
1035
+ throw new PerpError("INVALID_PARAMS", `--master-address ${v}=… is only used for --import-agents venues`);
1036
+ }
1037
+ const lighterAccount = parseSlotFlag("--lighter-account-index", opts.lighterAccountIndex);
1038
+ const lighterRhAccount = parseSlotFlag("--lighter-rh-account-index", opts.lighterRhAccountIndex);
1039
+ const qfexAccountId = opts.qfexAccountId === undefined ? null : opts.qfexAccountId;
1040
+ if (qfexAccountId !== null && !importVenues.includes("qfex")) {
1041
+ throw new PerpError("INVALID_PARAMS", "--qfex-account-id is only used with qfex in --import-agents");
1042
+ }
1043
+ if (importVenues.includes("qfex") && qfexAccountId === null) {
1044
+ throw new PerpError("INVALID_PARAMS", "--import-agents qfex needs --qfex-account-id <uuid>: the account the key trades must be named explicitly", {
1045
+ remediation: "Find the account id in the QFEX web app (or run `perp wallet agent import qfex` in a terminal, which shows it for confirmation)",
355
1046
  });
356
1047
  }
357
- const pp = resolved;
358
- const walletName = opts.walletName ?? "main";
359
- // Create OWS wallet
1048
+ const approveVenues = opts.approveAgents === true ? venues.filter(v => !importVenues.includes(v)) : [];
1049
+ if (opts.wallet !== undefined && opts.walletName !== undefined) {
1050
+ throw new PerpError("INVALID_PARAMS", "Pass either --wallet <existing> or --wallet-name <new>, not both");
1051
+ }
1052
+ // No master wallet on this computer is fine when every agent comes from the website.
1053
+ const noMaster = opts.wallet === undefined && opts.walletName === undefined;
1054
+ if (noMaster && (importVenues.length === 0 || approveVenues.length > 0)) {
1055
+ throw new PerpError("INVALID_PARAMS", "No master wallet given", {
1056
+ remediation: "Pass --wallet <existing OWS wallet> (see: perp wallet list) or --wallet-name <new name> — or, without a master on this computer, import website-created keys for every venue: --import-agents <venues> --yes",
1057
+ });
1058
+ }
1059
+ // ── Passphrase: needed to create a wallet or sign approvals (Ondo / imports sign nothing) ──
1060
+ const needsPassphrase = opts.walletName !== undefined || approveVenues.some(v => v !== "ondo");
1061
+ let passphrase = null;
1062
+ if (needsPassphrase) {
1063
+ if ((opts.ondoSecretStdin || importVenues.length > 0) && opts.passphrase === undefined && !("OWS_PASSPHRASE" in process.env)) {
1064
+ throw new PerpError("PASSPHRASE_REQUIRED", "stdin carries the keys, so the passphrase must come from --passphrase or OWS_PASSPHRASE", {
1065
+ remediation: "Set OWS_PASSPHRASE or pass --passphrase",
1066
+ });
1067
+ }
1068
+ // resolvePassphrase already throws PASSPHRASE_REQUIRED when stdin is
1069
+ // non-TTY and nothing was provided. In TTY mode it returns null —
1070
+ // non-interactive mode must NOT fall back to a prompt.
1071
+ passphrase = await resolvePassphrase({ flag: opts.passphrase });
1072
+ if (passphrase === null) {
1073
+ throw new PerpError("PASSPHRASE_REQUIRED", "Non-interactive setup requires a passphrase", {
1074
+ remediation: "Provide --passphrase <pp> or set OWS_PASSPHRASE env var",
1075
+ });
1076
+ }
1077
+ }
1078
+ // ── Imported keys: read all stdin lines now (before any side effect) ──
1079
+ const importSecrets = new Map();
1080
+ if (importVenues.length > 0) {
1081
+ const lines = (await readSetupStdin()).split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
1082
+ const want = importVenues.reduce((n, v) => n + importSecretLineCount(v), 0);
1083
+ if (lines.length !== want) {
1084
+ throw new PerpError("INVALID_PARAMS", `--import-agents ${importVenues.join(",")} expects ${want} line(s) on stdin (one per venue in order; ondo: key id, then secret; qfex: public key, then secret); got ${lines.length}`, {
1085
+ remediation: "Pipe exactly one key line per venue, in --import-agents order",
1086
+ });
1087
+ }
1088
+ let at = 0;
1089
+ for (const v of importVenues) {
1090
+ const n = importSecretLineCount(v);
1091
+ importSecrets.set(v, importSecretFromLines(v, lines.slice(at, at + n)));
1092
+ at += n;
1093
+ }
1094
+ assertDistinctImportSecrets([...importSecrets.entries()]);
1095
+ }
1096
+ // ── Master wallet ──
360
1097
  const ows = loadOws();
361
- const w = ows.createWallet(walletName, pp);
362
- // Update settings
363
- const settings = loadSettings();
364
- if (!settings.owsActiveWallet) {
365
- settings.owsActiveWallet = walletName;
1098
+ let wallet;
1099
+ let created;
1100
+ if (noMaster) {
1101
+ wallet = null;
1102
+ created = false;
1103
+ }
1104
+ else if (opts.walletName !== undefined) {
1105
+ wallet = ows.createWallet(opts.walletName, passphrase);
1106
+ created = true;
1107
+ }
1108
+ else {
1109
+ const name = opts.wallet;
1110
+ const found = ows.listWallets().find(w => w.name === name);
1111
+ if (!found) {
1112
+ throw new PerpError("KEY_NOT_FOUND", `OWS wallet "${name}" not found`, {
1113
+ remediation: "List wallets with: perp wallet list (or create one with --wallet-name <name>)",
1114
+ });
1115
+ }
1116
+ wallet = found;
1117
+ created = false;
366
1118
  }
367
- if (opts.defaultExchange) {
368
- settings.defaultExchange = opts.defaultExchange;
1119
+ // ── Settings ──
1120
+ const settings = applySetupSettings({
1121
+ owsActiveWallet: wallet !== null ? wallet.name : null,
1122
+ defaultExchange: defaultVenue,
1123
+ defaultSlippageBps,
1124
+ slippageBps: venueSlippage,
1125
+ });
1126
+ // ── Agents ──
1127
+ const agents = [];
1128
+ for (const venue of importVenues) {
1129
+ try {
1130
+ const outcome = await importVenueAgent({
1131
+ venue, network, agentName: null,
1132
+ masterAddress: masterAddresses[venue] ?? null,
1133
+ accountIndex: venue === "lighter" ? lighterAccount : venue === "lighter-rh" ? lighterRhAccount : venue === "arcus" ? arcusAccountIndex : null,
1134
+ apiKeyIndex: venue === "lighter" ? lighterSlotRaw : venue === "lighter-rh" ? lighterRhSlotRaw : null,
1135
+ expiresIn: null, replace: false,
1136
+ secret: importSecrets.get(venue),
1137
+ accountId: venue === "qfex" ? qfexAccountId : null,
1138
+ // Non-interactive: only the explicitly named account counts as confirmed.
1139
+ confirmAccount: venue !== "qfex" ? null : async (binding) => {
1140
+ if (qfexAccountId !== null && qfexAccountId.toLowerCase() === binding.accountId)
1141
+ return true;
1142
+ throw new PerpError("INVALID_PARAMS", `QFEX key resolves to account ${binding.accountId}, not --qfex-account-id ${String(qfexAccountId)}`, {
1143
+ remediation: `Re-run with --qfex-account-id ${binding.accountId} if that is the account you intend to trade`,
1144
+ });
1145
+ },
1146
+ });
1147
+ agents.push({ venue, status: "imported", agentName: outcome.agentName, verification: outcome.verification });
1148
+ }
1149
+ catch (err) {
1150
+ agents.push(failed(venue, err));
1151
+ }
1152
+ }
1153
+ // M3: one failed import → the batch's UNVERIFIED imports are removed again (never left behind
1154
+ // unchecked while the user is fixing the batch).
1155
+ const failedImports = agents.filter(a => a.status === "failed").map(a => a.venue);
1156
+ if (failedImports.length > 0) {
1157
+ for (let i = 0; i < agents.length; i++) {
1158
+ const a = agents[i];
1159
+ if (a.status !== "imported" || a.verification.status !== "unverified")
1160
+ continue;
1161
+ const meta = getAgent(a.venue, a.agentName);
1162
+ if (meta !== null)
1163
+ await deleteImportedAgentLocal(a.venue, network, meta);
1164
+ agents[i] = failed(a.venue, new PerpError("APPROVE_FAILED", `${a.venue} key "${a.agentName}" was stored UNVERIFIED and has been removed again because ${failedImports.join(", ")} failed in the same batch`, {
1165
+ remediation: `Re-run the whole batch once ${failedImports.join(", ")} is fixed`,
1166
+ }));
1167
+ }
1168
+ }
1169
+ if (wallet !== null && approveVenues.length > 0) {
1170
+ for (const venue of approveVenues) {
1171
+ const blocker = approveBlocker(venue, network);
1172
+ if (blocker !== null) {
1173
+ agents.push({ venue, status: "skipped", reason: blocker });
1174
+ continue;
1175
+ }
1176
+ try {
1177
+ const outcome = await approveVenueAgent({
1178
+ venue, network, master: wallet.name, agentName: null, expiresIn: "90d",
1179
+ canPerp: true, canSpot: venue === "aster" && opts.asterCanSpot === true, canWithdraw: false,
1180
+ rotate: false, passphrase,
1181
+ apiKeyIndex: venue === "lighter" ? lighterSlot : venue === "lighter-rh" ? lighterRhSlot : null,
1182
+ forceSlot: false,
1183
+ accountIndex: arcusAccountIndex !== null ? arcusAccountIndex : 0,
1184
+ aster: {},
1185
+ ondoKey: null, // venue === "ondo" → read from stdin (--ondo-secret-stdin)
1186
+ });
1187
+ agents.push({ venue, status: "approved", agentName: "meta" in outcome ? outcome.meta.agentName : outcome.agentName });
1188
+ }
1189
+ catch (err) {
1190
+ agents.push(failed(venue, err));
1191
+ }
1192
+ }
1193
+ }
1194
+ const data = {
1195
+ mode: "non-interactive",
1196
+ walletName: wallet !== null ? wallet.name : null,
1197
+ walletId: wallet !== null ? wallet.id : null,
1198
+ walletCreated: created,
1199
+ accounts: wallet !== null ? wallet.accounts : [],
1200
+ owsActiveWallet: settings.owsActiveWallet,
1201
+ network,
1202
+ venues,
1203
+ defaultExchange: settings.defaultExchange === "" ? null : settings.defaultExchange,
1204
+ defaultSlippageBps: settings.defaultSlippageBps,
1205
+ slippageBps: settings.slippageBps,
1206
+ agents,
1207
+ warnings: agents.some(a => a.status === "imported") ? [IMPORT_KEY_WARNING] : [],
1208
+ legacyEnvKeys: legacyEnvKeyNames(),
1209
+ nextSteps: nextStepCommands(venues, network),
1210
+ };
1211
+ const failures = agents.filter(a => a.status === "failed");
1212
+ if (failures.length > 0) {
1213
+ throw new PerpError("APPROVE_FAILED", `Agent registration failed for: ${failures.map(f => f.venue).join(", ")} (settings were saved)`, {
1214
+ result: data,
1215
+ remediation: `Fix the reported error, then: perp wallet agent ${failures.every(f => importVenues.includes(f.venue)) ? "import" : "approve"} <venue>`,
1216
+ });
369
1217
  }
370
- saveSettings(settings);
371
1218
  if (isJson) {
372
- printJson(jsonOk({
373
- walletName: w.name,
374
- walletId: w.id,
375
- accounts: w.accounts,
376
- owsActiveWallet: settings.owsActiveWallet,
377
- defaultExchange: settings.defaultExchange ?? null,
378
- mode: "non-interactive",
379
- }));
1219
+ printJson(jsonOk(data));
380
1220
  return;
381
1221
  }
382
1222
  console.log(chalk.cyan.bold("\n perp-cli Setup (non-interactive) — Done\n"));
383
- console.log(` Wallet: ${chalk.white.bold(w.name)}`);
1223
+ console.log(` Wallet: ${wallet !== null ? `${chalk.white.bold(wallet.name)}${created ? chalk.gray(" (created)") : ""}` : chalk.gray("none on this computer (imported keys only)")}`);
384
1224
  console.log(` Active wallet: ${chalk.cyan(settings.owsActiveWallet)}`);
385
- if (settings.defaultExchange) {
1225
+ console.log(` Network: ${network}`);
1226
+ if (venues.length > 0)
1227
+ console.log(` Venues: ${venues.join(", ")}`);
1228
+ if (settings.defaultExchange)
386
1229
  console.log(` Default exchange: ${chalk.cyan(settings.defaultExchange)}`);
1230
+ console.log(` Slippage: ${settings.defaultSlippageBps} bps default${Object.entries(settings.slippageBps).map(([v, b]) => `, ${v} ${b}`).join("")}`);
1231
+ for (const a of agents) {
1232
+ const text = a.status === "approved" ? chalk.green(`agent ${a.agentName}`)
1233
+ : a.status === "imported" ? (a.verification.status === "verified"
1234
+ ? chalk.green(`imported ${a.agentName} (verified)`)
1235
+ : chalk.yellow(`imported ${a.agentName} — NOT verified: ${a.verification.method}`))
1236
+ : chalk.gray(`skipped — ${a.status === "skipped" ? a.reason : ""}`);
1237
+ console.log(` ${a.venue.padEnd(17)} ${text}`);
387
1238
  }
388
- console.log(chalk.gray(`\n Vault: ~/.ows/ (AES-256-GCM encrypted)\n`));
1239
+ if (importVenues.length > 0) {
1240
+ console.log(chalk.gray(`\n ${IMPORT_SCOPE_NOTE}`));
1241
+ console.log(chalk.yellow(` [warn] ${IMPORT_KEY_WARNING}`));
1242
+ }
1243
+ if (data.legacyEnvKeys.length > 0) {
1244
+ console.log(chalk.yellow(`\n ⚠ ${ENV_FILE} holds raw private keys (${data.legacyEnvKeys.join(", ")}); consider moving them to the OWS vault (perp wallet import).`));
1245
+ }
1246
+ console.log(chalk.gray(`\n Vault: ~/.ows/ (AES-256-GCM encrypted)`));
1247
+ console.log(chalk.white.bold("\n Next steps:"));
1248
+ for (const c of data.nextSteps)
1249
+ console.log(` ${chalk.green(c)}`);
1250
+ console.log();
389
1251
  }
390
1252
  catch (err) {
391
1253
  if (isJson) {
392
1254
  if (err instanceof PerpError) {
393
1255
  printJson(jsonError(err.structured.code, err.structured.message, {
394
- ...(err.structured.remediation
395
- ? { details: { remediation: err.structured.remediation } }
1256
+ ...(err.structured.remediation || err.structured.details
1257
+ ? { details: { ...err.structured.details, ...(err.structured.remediation ? { remediation: err.structured.remediation } : {}) } }
396
1258
  : {}),
397
1259
  }));
398
1260
  }
@@ -408,6 +1270,11 @@ export async function runNonInteractiveSetup(opts) {
408
1270
  if (err.structured.remediation) {
409
1271
  console.error(chalk.gray(` Remediation: ${err.structured.remediation}\n`));
410
1272
  }
1273
+ const result = err.structured.details?.result;
1274
+ for (const a of result?.agents ?? []) {
1275
+ if (a.status === "failed")
1276
+ console.error(chalk.red(` ${a.venue}: [${a.error.code}] ${a.error.message}`));
1277
+ }
411
1278
  }
412
1279
  else {
413
1280
  const msg = err instanceof Error ? err.message : String(err);