perp-cli 0.12.17 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +303 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +357 -459
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1386 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,13 +1,45 @@
1
+ import { isDryRun as dryRunFlag, supportsDryRun } from "../cli-flags.js";
1
2
  import chalk from "chalk";
2
3
  import { printJson, formatUsd, jsonOk, jsonError } from "../utils.js";
3
4
  import { logExecution } from "../execution-log.js";
4
5
  import { hasPacificaSdk, hasEvmAddress, isWithdrawCapable, isUsdTransferCapable, hasLighterAccount } from "../exchanges/capabilities.js";
5
6
  import { registerFundsBridgeCommands } from "./bridge.js";
6
7
  import { registerFundsRebalanceCommands } from "./rebalance.js";
8
+ import { PerpError, isConnectFailure, isDefiniteOrderFailure, isTransportFailure, orderOutcomeUnknown, parsePositiveDecimal } from "../errors.js";
9
+ import { cliCredentialOptsFrom, requireMasterWallet } from "../config.js";
10
+ import { getVenue, requireCapabilities, READ_ONLY_CREDENTIALS } from "../exchanges/venue.js";
11
+ import { unsupported } from "../exchanges/trading-v2.js";
12
+ import { registerOnchainFundsCommands } from "./funds-onchain.js";
13
+ import { QFEX_BANKER_NETWORK, QFEX_WITHDRAW_DISABLED, QFEX_WITHDRAW_ENABLED, QFEX_WITHDRAW_NETWORK } from "../exchanges/venues/qfex.js";
14
+ import { loadSettings } from "../settings.js";
15
+ import { assertWithdrawAllowed, forgetWithdrawAttempt, recordWithdrawAttempt } from "../withdraw-gate.js";
16
+ import "../exchanges/venues/index.js";
17
+ /**
18
+ * Execution-log status of a failed funds action: an unknown outcome (ORDER_PENDING — the request
19
+ * was sent and may have executed) is logged "unverified", never "failed".
20
+ */
21
+ export function fundsFailStatus(err) {
22
+ return err instanceof PerpError && err.structured.code === "ORDER_PENDING" ? "unverified" : "failed";
23
+ }
24
+ /** `{ txHash }` of a broadcast transaction whose outcome is unknown (ORDER_PENDING details), for the execution log. */
25
+ function pendingTxMeta(err) {
26
+ const h = err instanceof PerpError ? err.structured.details?.txHash : undefined;
27
+ return typeof h === "string" ? { txHash: h } : {};
28
+ }
7
29
  const DEFAULT_RELAYER = "http://localhost:3100";
8
30
  function getRelayerUrl() {
9
31
  return process.env.PERP_RELAYER_URL || DEFAULT_RELAYER;
10
32
  }
33
+ function relayerUnavailable() {
34
+ return new PerpError("EXCHANGE_UNREACHABLE", `The relayer at ${getRelayerUrl()} is not reachable; nothing was sent`, {
35
+ remediation: "Start the relayer / set PERP_RELAYER_URL, or drop --relay for a direct deposit (you pay gas)",
36
+ });
37
+ }
38
+ /** A broadcast deposit whose confirmation could not be read: unknown outcome, carrying the signature. */
39
+ export function depositOutcomeUnknown(signature, e) {
40
+ const pending = orderOutcomeUnknown("pacifica", `deposit transaction ${signature}`, e, `Do NOT deposit again yet — the transaction was broadcast and may still confirm. Check signature ${signature} on a Solana explorer, then: perp -e pacifica account balance`);
41
+ return new PerpError("ORDER_PENDING", pending.message, { exchange: "pacifica", signature, remediation: pending.structured.remediation });
42
+ }
11
43
  async function relayerAvailable() {
12
44
  try {
13
45
  const res = await fetch(`${getRelayerUrl()}/health`, { signal: AbortSignal.timeout(2000) });
@@ -17,30 +49,158 @@ async function relayerAvailable() {
17
49
  return false;
18
50
  }
19
51
  }
52
+ /**
53
+ * Local withdraw-gate verdict for a Hyperliquid destination that is not (known to be) the account
54
+ * owner: settings.withdraw.hyperliquid allowlist + maxPerTx / maxPerDay (the QFEX gate mechanism,
55
+ * src/withdraw-gate.ts). "passes" or "would refuse: …"; never throws a POLICY_DENIED (dry runs).
56
+ */
57
+ export function hyperliquidGateVerdict(network, amount, destination, nowMs) {
58
+ const gate = loadSettings().withdraw.hyperliquid;
59
+ try {
60
+ assertWithdrawAllowed("hyperliquid", network, amount, destination, gate === undefined ? null : gate, nowMs);
61
+ }
62
+ catch (e) {
63
+ if (e instanceof PerpError && e.structured.code === "POLICY_DENIED")
64
+ return `would refuse: ${e.message}`;
65
+ throw e;
66
+ }
67
+ return "passes (destination allowlisted in settings withdraw.hyperliquid, within maxPerTx / maxPerDay)";
68
+ }
69
+ /**
70
+ * Enforce settings.withdraw.hyperliquid for a master-signed withdraw3 / usdSend to `destination`
71
+ * and record the attempt in the withdraw ledger BEFORE it is sent. Returns the ledger id (forget it
72
+ * only on a definite refusal), or null when the destination is the owner (no gate, no ledger).
73
+ */
74
+ export function hyperliquidGateEnforce(network, amount, destination, owner, nowMs) {
75
+ if (owner !== null && destination.toLowerCase() === owner.toLowerCase())
76
+ return null;
77
+ const gate = loadSettings().withdraw.hyperliquid;
78
+ assertWithdrawAllowed("hyperliquid", network, amount, destination, gate === undefined ? null : gate, nowMs);
79
+ return recordWithdrawAttempt("hyperliquid", network, amount, destination, nowMs);
80
+ }
81
+ /** Display names of `VenueNetworkConfig.settlementChain` / withdraw-network ids. */
82
+ const CHAIN_LABELS = {
83
+ ethereum: "Ethereum",
84
+ robinhood: "Robinhood Chain",
85
+ arbitrum: "Arbitrum",
86
+ solana: "Solana",
87
+ bnb: "BNB Chain",
88
+ hyperliquid: "Hyperliquid",
89
+ };
90
+ /** Label for a chain id. An unmapped id is a programming error (covered by a unit test over every venue). */
91
+ export function chainLabel(chain) {
92
+ const label = CHAIN_LABELS[chain];
93
+ if (label === undefined)
94
+ throw new Error(`no display label for chain "${chain}" (add it to CHAIN_LABELS)`);
95
+ return label;
96
+ }
97
+ /** Withdraw asset declared by the venue definition (capabilities.withdrawAssets[0]). */
98
+ function withdrawAssetOf(def) {
99
+ const assets = requireCapabilities(def).withdrawAssets;
100
+ if (assets === null || assets.length === 0) {
101
+ throw new Error(`${def.id} declares no withdrawAssets but has a withdraw command`);
102
+ }
103
+ return assets[0];
104
+ }
105
+ /** Withdrawal chain of venues that settle off-chain and pay out on a public chain. */
106
+ const OFFCHAIN_WITHDRAW_CHAIN = {
107
+ qfex: QFEX_WITHDRAW_NETWORK,
108
+ };
109
+ /** Chain a venue's withdrawals arrive on (QFEX settles off-chain and pays out on Arbitrum). */
110
+ function withdrawChainOf(def, net) {
111
+ const payout = OFFCHAIN_WITHDRAW_CHAIN[def.id];
112
+ if (payout !== undefined)
113
+ return payout;
114
+ const cfg = def.networks[net];
115
+ if (cfg === null) {
116
+ throw new PerpError("NOT_IMPLEMENTED", `${def.displayName} has no ${net} environment`, {
117
+ remediation: `Run without --network testnet for ${def.id}`,
118
+ });
119
+ }
120
+ return cfg.settlementChain;
121
+ }
122
+ /** "-e lighter: USDC → Ethereum; -e lighter-rh: USDG → Robinhood Chain" — from the venue definitions. */
123
+ export function lighterFamilyWithdrawSummary() {
124
+ return ["lighter", "lighter-rh"].map(id => {
125
+ const def = getVenue(id);
126
+ return `-e ${id}: ${withdrawAssetOf(def)} → ${chainLabel(def.networks.mainnet.settlementChain)}`;
127
+ }).join("; ");
128
+ }
20
129
  export function registerFundsCommands(program, getAdapter, isJson, getNetwork, getAdapterForExchange) {
21
130
  const funds = program.command("funds").description("Fund movement — deposit, withdraw, transfer, bridge, rebalance");
131
+ /** Global --dry-run (root flag; never re-declared on a subcommand — the root would swallow it). */
132
+ const isDryRun = () => dryRunFlag(program);
133
+ /**
134
+ * Master-only fund movement: NO_SIGNER_AVAILABLE (do it on the website / import the master)
135
+ * when no master wallet is on this machine — runs after the dry-run return, before any key or
136
+ * adapter is loaded. An imported agent / API key never signs these.
137
+ */
138
+ const requireMaster = (venue, action) => requireMasterWallet(venue, cliCredentialOptsFrom(program.opts()), action);
139
+ /**
140
+ * Dry-run exit for a fund movement: log + print the plan (`sent: false`) and send nothing.
141
+ * Every caller returns this BEFORE loading a key, building an adapter, or contacting a relayer.
142
+ */
143
+ const printFundsPlan = (plan) => {
144
+ logExecution({
145
+ type: "bridge", exchange: plan.venue, symbol: plan.asset, side: plan.action,
146
+ size: plan.amount, status: "simulated", dryRun: true, meta: { ...plan },
147
+ });
148
+ if (isJson()) {
149
+ printJson(jsonOk({ dryRun: true, sent: false, ...plan }));
150
+ return;
151
+ }
152
+ console.log(chalk.yellow(`\n [dry-run] Would ${plan.action} ${plan.amount} ${plan.asset} (${plan.venue}, ${plan.network})`));
153
+ for (const [k, v] of Object.entries(plan)) {
154
+ if (["action", "venue", "network", "asset", "amount"].includes(k))
155
+ continue;
156
+ console.log(` ${k}: ${String(v)}`);
157
+ }
158
+ console.log(chalk.gray(" Nothing was sent.\n"));
159
+ };
160
+ /**
161
+ * Deposit routes whose chain RPC / bridge contract / token addresses are
162
+ * mainnet deployments (hardcoded below) have no testnet equivalent: a -n
163
+ * testnet run must fail before any key is loaded, never sign on mainnet.
164
+ */
165
+ const requireMainnetDepositRoute = (route) => {
166
+ const network = getNetwork();
167
+ if (network === "mainnet")
168
+ return;
169
+ throw new PerpError("NO_BRIDGE_ROUTE", `${route} has no ${network} route — its chain RPC and contracts are mainnet deployments`, {
170
+ remediation: "Run without -n testnet to deposit on mainnet, or fund the testnet account from the venue's testnet faucet",
171
+ });
172
+ };
22
173
  // ═══════════════════════════════════════════════════════
23
174
  // DEPOSIT
24
175
  // ═══════════════════════════════════════════════════════
25
176
  const deposit = funds.command("deposit").description("Deposit funds into exchange accounts");
26
177
  // ── Pacifica (Solana) ──
27
- deposit
28
- .command("pacifica <amount>")
29
- .description("Deposit USDC into Pacifica")
30
- .option("--no-relay", "Skip relayer, pay gas yourself")
31
- .action(async (amount, opts) => {
32
- const amountNum = parseFloat(amount);
178
+ supportsDryRun(deposit.command("pacifica <amount>"))
179
+ .description("Deposit USDC into Pacifica (supports --dry-run)")
180
+ .option("--relay", "Opt in to the gas-sponsoring relayer (PERP_RELAYER_URL); default: direct deposit, you pay gas")
181
+ .option("--no-relay", "Direct deposit, pay gas yourself (the default)")
182
+ .action(async (amount, rawOpts) => {
183
+ const opts = { relay: rawOpts.relay === true };
184
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
33
185
  if (isNaN(amountNum) || amountNum <= 0)
34
186
  throw new Error("Invalid amount");
35
187
  if (amountNum < 10)
36
188
  throw new Error("Minimum deposit is 10 USDC");
37
- // Try relayer first (gas-sponsored)
38
- if (opts.relay && await relayerAvailable()) {
189
+ if (isDryRun()) {
190
+ return printFundsPlan({ action: "deposit", venue: "pacifica", network: getNetwork(), asset: "USDC", amount: String(amountNum), chain: "solana", method: opts.relay ? "relayer" : "direct" });
191
+ }
192
+ await requireMaster("pacifica", "Pacifica deposit");
193
+ // Relayer (gas-sponsored) only when asked for with --relay; an unreachable relayer is an
194
+ // error, never a silent switch to a direct deposit.
195
+ if (opts.relay && !(await relayerAvailable()))
196
+ throw relayerUnavailable();
197
+ if (opts.relay) {
39
198
  if (!isJson())
40
199
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} via relayer (gasless)...\n`));
41
- const adapter = await getAdapter();
200
+ // The subcommand names the venue: fetch it directly, independent of -e.
201
+ const adapter = await getAdapterForExchange("pacifica");
42
202
  if (adapter.name !== "pacifica")
43
- throw new Error("Requires --exchange pacifica");
203
+ throw new Error("pacifica venue did not produce a Pacifica adapter");
44
204
  const { PacificaAdapter } = await import("../exchanges/pacifica.js");
45
205
  const pacAdapter = adapter;
46
206
  try {
@@ -48,7 +208,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
48
208
  method: "POST",
49
209
  headers: { "Content-Type": "application/json" },
50
210
  body: JSON.stringify({
51
- userPubkey: pacAdapter.keypair.publicKey.toBase58(),
211
+ userPubkey: pacAdapter.masterSolanaSigner().getPublicKeyBase58(),
52
212
  amount: amountNum,
53
213
  testnet: getNetwork() === "testnet",
54
214
  }),
@@ -57,14 +217,31 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
57
217
  const err = await buildRes.json();
58
218
  throw new Error(err.error || "Relayer build failed");
59
219
  }
60
- const { transaction, fee, netAmount } = (await buildRes.json());
220
+ const built = (await buildRes.json());
221
+ if (typeof built.transaction !== "string" || typeof built.fee !== "number" || typeof built.netAmount !== "number") {
222
+ throw new PerpError("POLICY_DENIED", "The relayer's build response is missing transaction / fee / netAmount; nothing was signed", {
223
+ remediation: "Drop --relay for a direct deposit (you pay gas)",
224
+ });
225
+ }
226
+ const { transaction, fee, netAmount } = built;
61
227
  if (!isJson()) {
62
228
  console.log(` Fee: $${formatUsd(fee)} (gas sponsored)`);
63
229
  console.log(` Net: $${formatUsd(netAmount)} to Pacifica\n`);
64
230
  }
65
231
  const { Transaction } = await import("@solana/web3.js");
232
+ const { signSolanaTransaction } = await import("../pacifica/sign-tx.js");
66
233
  const tx = Transaction.from(Buffer.from(transaction, "base64"));
67
- tx.partialSign(pacAdapter.keypair);
234
+ // Never blind-sign a relayer-built transaction with the master key: it must be exactly the
235
+ // pinned Pacifica deposit (+ compute budget, + the declared, capped fee transfer) — else refuse.
236
+ const { assertRelayedDepositTransaction } = await import("../pacifica/deposit.js");
237
+ const { PublicKey: SolPublicKey } = await import("@solana/web3.js");
238
+ await assertRelayedDepositTransaction(tx, {
239
+ user: new SolPublicKey(pacAdapter.masterSolanaSigner().getPublicKeyBase58()),
240
+ requestedAmount: amount.trim(), network: getNetwork(),
241
+ declared: { fee, netAmount, feeRecipient: typeof built.feeRecipient === "string" ? built.feeRecipient : null },
242
+ });
243
+ // The relayer pays the fee and adds its signature after ours.
244
+ signSolanaTransaction(pacAdapter.masterSolanaSigner(), tx, true);
68
245
  const signed = tx.serialize().toString("base64");
69
246
  const submitRes = await fetch(`${getRelayerUrl()}/deposit/pacifica/submit`, {
70
247
  method: "POST",
@@ -94,7 +271,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
94
271
  catch (err) {
95
272
  logExecution({
96
273
  type: "bridge", exchange: "pacifica", symbol: "USDC", side: "deposit",
97
- size: String(amountNum), status: "failed", dryRun: false,
274
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
98
275
  error: err instanceof Error ? err.message : String(err),
99
276
  meta: { action: "deposit", method: "relay" },
100
277
  });
@@ -106,9 +283,10 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
106
283
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} USDC into Pacifica...\n`));
107
284
  console.log(chalk.gray(" Requires: SOL for gas (~0.005 SOL) + USDC on Solana\n"));
108
285
  }
109
- const adapter = await getAdapter();
286
+ // The subcommand names the venue: fetch it directly, independent of -e.
287
+ const adapter = await getAdapterForExchange("pacifica");
110
288
  if (adapter.name !== "pacifica")
111
- throw new Error("Requires --exchange pacifica");
289
+ throw new Error("pacifica venue did not produce a Pacifica adapter");
112
290
  const { PacificaAdapter: PacAdapterCls } = await import("../exchanges/pacifica.js");
113
291
  const pacDirect = adapter;
114
292
  try {
@@ -117,7 +295,12 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
117
295
  const network = getNetwork();
118
296
  const rpcUrl = network === "testnet" ? "https://api.devnet.solana.com" : "https://api.mainnet-beta.solana.com";
119
297
  const connection = new Connection(rpcUrl, "confirmed");
120
- const solBalance = await connection.getBalance(pacDirect.keypair.publicKey);
298
+ const { PublicKey } = await import("@solana/web3.js");
299
+ const { getNetworkConfig } = await import("../pacifica/constants.js");
300
+ const { signSolanaTransaction } = await import("../pacifica/sign-tx.js");
301
+ const masterSigner = pacDirect.masterSolanaSigner();
302
+ const owner = new PublicKey(masterSigner.getPublicKeyBase58());
303
+ const solBalance = await connection.getBalance(owner);
121
304
  if (solBalance < 5_000_000) {
122
305
  if (isJson()) {
123
306
  printJson(jsonError("INSUFFICIENT_BALANCE", "Insufficient SOL for gas. Need ~0.005 SOL."));
@@ -128,9 +311,9 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
128
311
  }
129
312
  process.exit(1);
130
313
  }
131
- const { PublicKey } = await import("@solana/web3.js");
132
- const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
133
- const tokenAccounts = await connection.getParsedTokenAccountsByOwner(pacDirect.keypair.publicKey, { mint: USDC_MINT });
314
+ // Testnet collateral is the devnet USDP mint, mainnet is USDC (pacifica/constants.ts).
315
+ const USDC_MINT = new PublicKey(getNetworkConfig(network).usdcMint);
316
+ const tokenAccounts = await connection.getParsedTokenAccountsByOwner(owner, { mint: USDC_MINT });
134
317
  const usdcBalance = tokenAccounts.value.length > 0
135
318
  ? tokenAccounts.value[0].account.data.parsed.info.tokenAmount.uiAmount ?? 0
136
319
  : 0;
@@ -143,13 +326,38 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
143
326
  }
144
327
  process.exit(1);
145
328
  }
146
- const ix = await buildDepositInstruction(pacDirect.keypair.publicKey, amountNum, network);
329
+ const ix = await buildDepositInstruction(owner, amount.trim(), network);
147
330
  const tx = new Transaction().add(ix);
148
- tx.feePayer = pacDirect.keypair.publicKey;
331
+ tx.feePayer = owner;
149
332
  tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
150
- tx.sign(pacDirect.keypair);
151
- const sig = await connection.sendRawTransaction(tx.serialize());
152
- await connection.confirmTransaction(sig, "confirmed");
333
+ signSolanaTransaction(masterSigner, tx, false);
334
+ // The signature is known before the send: a transport failure after the request left
335
+ // (reset, timeout — not a refused connection) is an unknown outcome carrying it.
336
+ const signedSig = tx.signature;
337
+ let sig;
338
+ try {
339
+ sig = await connection.sendRawTransaction(tx.serialize());
340
+ }
341
+ catch (e) {
342
+ if (signedSig !== null && isTransportFailure(e) && !isConnectFailure(e)) {
343
+ throw depositOutcomeUnknown((await import("bs58")).default.encode(signedSig), e);
344
+ }
345
+ throw e;
346
+ }
347
+ // Broadcast: from here a confirmation timeout / RPC error is an UNKNOWN outcome (the
348
+ // transaction may still land) — reported with the signature, never as "failed".
349
+ let confirmation;
350
+ try {
351
+ confirmation = await connection.confirmTransaction(sig, "confirmed");
352
+ }
353
+ catch (e) {
354
+ throw depositOutcomeUnknown(sig, e);
355
+ }
356
+ if (confirmation?.value?.err !== undefined && confirmation.value.err !== null) {
357
+ throw new PerpError("ACTION_REJECTED", `Pacifica deposit transaction ${sig} failed on-chain: ${JSON.stringify(confirmation.value.err)} — nothing was deposited`, {
358
+ exchange: "pacifica", signature: sig,
359
+ });
360
+ }
153
361
  logExecution({
154
362
  type: "bridge", exchange: "pacifica", symbol: "USDC", side: "deposit",
155
363
  size: String(amountNum), status: "success", dryRun: false,
@@ -162,45 +370,52 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
162
370
  console.log(` Signature: ${sig}\n`);
163
371
  }
164
372
  catch (err) {
373
+ const pendingSig = err instanceof PerpError ? err.structured.details?.signature : undefined;
165
374
  logExecution({
166
375
  type: "bridge", exchange: "pacifica", symbol: "USDC", side: "deposit",
167
- size: String(amountNum), status: "failed", dryRun: false,
376
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
168
377
  error: err instanceof Error ? err.message : String(err),
169
- meta: { action: "deposit", method: "direct" },
378
+ meta: { action: "deposit", method: "direct", ...(typeof pendingSig === "string" ? { signature: pendingSig } : {}) },
170
379
  });
171
380
  throw err;
172
381
  }
173
382
  });
174
383
  // ── Hyperliquid (Arbitrum — USDC transfer to Bridge2) ──
175
- deposit
176
- .command("hyperliquid <amount>")
177
- .description("Deposit USDC into Hyperliquid (Arbitrum)")
178
- .option("--no-relay", "Skip relayer, pay gas yourself")
179
- .action(async (amount, opts) => {
180
- const amountNum = parseFloat(amount);
384
+ supportsDryRun(deposit.command("hyperliquid <amount>"))
385
+ .description("Deposit USDC into Hyperliquid (Arbitrum; supports --dry-run)")
386
+ .option("--relay", "Opt in to the gas-sponsoring relayer (PERP_RELAYER_URL); default: direct deposit, you pay gas")
387
+ .option("--no-relay", "Direct deposit, pay gas yourself (the default)")
388
+ .action(async (amount, rawOpts) => {
389
+ const opts = { relay: rawOpts.relay === true };
390
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
181
391
  if (isNaN(amountNum) || amountNum <= 0)
182
392
  throw new Error("Invalid amount");
183
393
  if (amountNum < 5)
184
394
  throw new Error("Minimum deposit is $5 for Hyperliquid");
185
- // Try relayer
186
- if (opts.relay && await relayerAvailable()) {
395
+ // Relayer and direct Bridge2 transfer both target Arbitrum One + the mainnet HL bridge.
396
+ requireMainnetDepositRoute("Hyperliquid deposit (Arbitrum Bridge2 / relayer)");
397
+ if (isDryRun()) {
398
+ return printFundsPlan({ action: "deposit", venue: "hyperliquid", network: getNetwork(), asset: "USDC", amount: String(amountNum), chain: "arbitrum", method: opts.relay ? "relayer" : "USDC transfer to Bridge2" });
399
+ }
400
+ await requireMaster("hyperliquid", "Hyperliquid deposit");
401
+ // Relayer only when asked for with --relay; an unreachable relayer is an error.
402
+ if (opts.relay && !(await relayerAvailable()))
403
+ throw relayerUnavailable();
404
+ // The depositing wallet is the OWS master (no raw key): its address for the relayer, its
405
+ // transaction signer for the direct Bridge2 transfer.
406
+ const evm = await import("../evm-master-tx.js");
407
+ const signer = await evm.loadMasterEvmSigner("hyperliquid", cliCredentialOptsFrom(program.opts()), "Hyperliquid deposit");
408
+ const depositor = signer.getAddress();
409
+ if (opts.relay) {
187
410
  if (!isJson())
188
411
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} via relayer (gasless)...\n`));
189
- const { ethers } = await import("ethers");
190
- const { loadPrivateKey } = await import("../config.js");
191
- let pk;
192
- try {
193
- pk = await loadPrivateKey("hyperliquid");
194
- }
195
- catch (err) {
196
- throw new Error(`Private key not configured for Hyperliquid. Run: perp setup`);
197
- }
198
- const wallet = new ethers.Wallet(pk);
412
+ // The relayer is given the account address and the amount only. Nothing it returns is
413
+ // ever signed with the master key on this path.
199
414
  try {
200
415
  const res = await fetch(`${getRelayerUrl()}/deposit/hyperliquid`, {
201
416
  method: "POST",
202
417
  headers: { "Content-Type": "application/json" },
203
- body: JSON.stringify({ userAddress: wallet.address, amount: amountNum }),
418
+ body: JSON.stringify({ userAddress: depositor, amount: amountNum }),
204
419
  });
205
420
  const result = (await res.json());
206
421
  if (!res.ok)
@@ -222,7 +437,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
222
437
  catch (err) {
223
438
  logExecution({
224
439
  type: "bridge", exchange: "hyperliquid", symbol: "USDC", side: "deposit",
225
- size: String(amountNum), status: "failed", dryRun: false,
440
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
226
441
  error: err instanceof Error ? err.message : String(err),
227
442
  meta: { action: "deposit", method: "relay" },
228
443
  });
@@ -234,150 +449,140 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
234
449
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} USDC into Hyperliquid...\n`));
235
450
  console.log(chalk.gray(" Requires: ETH on Arbitrum for gas (~0.0001 ETH) + USDC on Arbitrum\n"));
236
451
  }
237
- const { ethers } = await import("ethers");
238
- const { loadPrivateKey } = await import("../config.js");
239
- const pk = await loadPrivateKey("hyperliquid");
240
- const provider = new ethers.JsonRpcProvider("https://arb1.arbitrum.io/rpc");
241
- const wallet = new ethers.Wallet(pk, provider);
242
452
  const HL_BRIDGE = "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7";
243
453
  const USDC_ARB = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
244
- const ethBal = await provider.getBalance(wallet.address);
245
- if (ethBal < ethers.parseEther("0.0001")) {
246
- if (isJson()) {
247
- printJson(jsonError("INSUFFICIENT_BALANCE", "Insufficient ETH on Arbitrum for gas."));
248
- }
249
- else {
250
- console.error(chalk.red(" Insufficient ETH on Arbitrum for gas."));
251
- }
252
- process.exit(1);
253
- }
254
- const usdc = new ethers.Contract(USDC_ARB, [
255
- "function transfer(address to, uint256 amount) returns (bool)",
256
- "function balanceOf(address) view returns (uint256)",
257
- ], wallet);
258
- const amountRaw = ethers.parseUnits(amount, 6);
259
- const usdcBal = await usdc.balanceOf(wallet.address);
454
+ const ARB_RPC = "https://arb1.arbitrum.io/rpc";
455
+ const { parseUnits, formatUnits } = await import("ethers");
456
+ const rpc = evm.evmProvider(ARB_RPC, 42161);
457
+ await evm.assertRpcChainId(rpc, ARB_RPC, 42161, "hyperliquid");
458
+ const amountRaw = parseUnits(amount, await evm.erc20Decimals(rpc, USDC_ARB));
459
+ const usdcBal = await evm.erc20Balance(rpc, USDC_ARB, depositor);
260
460
  if (usdcBal < amountRaw) {
261
- if (isJson()) {
262
- printJson(jsonError("INSUFFICIENT_BALANCE", `Insufficient USDC. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
263
- }
264
- else {
265
- console.error(chalk.red(` Insufficient USDC. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
266
- }
267
- process.exit(1);
461
+ throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on Arbitrum. Have: $${formatUsd(Number(formatUnits(usdcBal, 6)))}`, { exchange: "hyperliquid" });
268
462
  }
269
463
  try {
270
464
  if (!isJson())
271
465
  console.log(chalk.gray(" Sending USDC to Hyperliquid Bridge2..."));
272
- const tx = await usdc.transfer(HL_BRIDGE, amountRaw);
273
- const receipt = await tx.wait();
466
+ // Simulated, signed by the OWS master, broadcast; a lost response / receipt timeout after
467
+ // the broadcast is ORDER_PENDING carrying the tx hash (never a retryable error).
468
+ const { txHash } = await evm.sendMasterTx(rpc, {
469
+ venue: "hyperliquid", chainLabel: "Arbitrum", chainId: 42161, from: depositor, to: USDC_ARB, value: 0n,
470
+ data: evm.ERC20.encodeFunctionData("transfer", [HL_BRIDGE, amountRaw]), label: "USDC transfer to Hyperliquid Bridge2",
471
+ }, tx => signer.signTransaction(tx));
274
472
  logExecution({
275
473
  type: "bridge", exchange: "hyperliquid", symbol: "USDC", side: "deposit",
276
474
  size: String(amountNum), status: "success", dryRun: false,
277
- meta: { action: "deposit", method: "direct", txHash: receipt.hash },
475
+ meta: { action: "deposit", method: "direct", txHash },
278
476
  });
279
477
  if (isJson())
280
- return printJson(jsonOk({ txHash: receipt.hash, amount: amountNum, method: "direct" }));
478
+ return printJson(jsonOk({ txHash, amount: amountNum, method: "direct" }));
281
479
  console.log(chalk.green(` Deposit confirmed!`));
282
480
  console.log(` Amount: $${formatUsd(amountNum)} USDC`);
283
- console.log(` TX Hash: ${receipt.hash}`);
481
+ console.log(` TX Hash: ${txHash}`);
284
482
  console.log(chalk.gray(`\n Funds appear in ~1-3 minutes.\n`));
285
483
  }
286
484
  catch (err) {
287
485
  logExecution({
288
486
  type: "bridge", exchange: "hyperliquid", symbol: "USDC", side: "deposit",
289
- size: String(amountNum), status: "failed", dryRun: false,
487
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
290
488
  error: err instanceof Error ? err.message : String(err),
291
- meta: { action: "deposit", method: "direct" },
489
+ meta: { action: "deposit", method: "direct", ...pendingTxMeta(err) },
292
490
  });
293
491
  throw err;
294
492
  }
295
493
  });
296
494
  // ── Lighter ──
297
495
  const lighterDeposit = deposit.command("lighter").description("Deposit USDC into Lighter");
298
- lighterDeposit
299
- .command("ethereum <amount>")
300
- .description("Deposit USDC via Ethereum L1 (min 1 USDC, gas: $3-10+)")
301
- .option("--asset-id <id>", "Asset index (default: 2 = USDC)", "2")
496
+ supportsDryRun(lighterDeposit.command("ethereum <amount>"))
497
+ .description("Deposit USDC via Ethereum L1 (min 1 USDC, gas: $3-10+; supports --dry-run)")
498
+ // Asset index comes from GET /api/v1/assetDetails, which the deposit docs
499
+ // name as the source for the contract's `_assetIndex`. Live registry:
500
+ // 3 = USDC (l1_decimals 6), 2 = LIT (l1_decimals 18), 1 = ETH. The old
501
+ // "2 = USDC" default contradicted this function's own USDC ERC20 transfer
502
+ // and its parseUnits(amount, 6). Same defect as the withdraw side, which
503
+ // was corrected to 3 in 966ffc0; the deposit side was missed then.
504
+ .option("--asset-id <id>", "Asset index (default: 3 = USDC, per /api/v1/assetDetails)", "3")
302
505
  .option("--route <type>", "Route: 0=perps, 1=spot (default: 0)", "0")
303
506
  .action(async (amount, opts) => {
304
- const amountNum = parseFloat(amount);
507
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
305
508
  if (isNaN(amountNum) || amountNum <= 0)
306
509
  throw new Error("Invalid amount");
307
510
  if (amountNum < 1)
308
511
  throw new Error("Minimum deposit via Ethereum is 1 USDC");
512
+ // deposit(address _to, uint16 _assetIndex, uint8 _routeType, uint256 _amount) — Lighter docs
513
+ // (deposits-transfers-and-withdrawals): "`_routeType`: whether you want to deposit the asset
514
+ // to your perps (0), or spot account (1)". Strict: plain integers only ("3abc", "1e1", "" refused).
515
+ if (!/^\d+$/.test(opts.assetId) || Number(opts.assetId) > 65_535) {
516
+ throw new PerpError("INVALID_PARAMS", `--asset-id must be an integer asset index 0-65535 (got "${opts.assetId}")`, {
517
+ exchange: "lighter", remediation: "Use the asset index from GET /api/v1/assetDetails (3 = USDC)",
518
+ });
519
+ }
520
+ if (opts.route !== "0" && opts.route !== "1") {
521
+ throw new PerpError("INVALID_PARAMS", `--route must be 0 (perps) or 1 (spot) (got "${opts.route}")`, { exchange: "lighter", remediation: "Pass --route 0 or --route 1" });
522
+ }
523
+ const assetIndex = Number(opts.assetId);
524
+ const routeType = Number(opts.route);
525
+ requireMainnetDepositRoute("Lighter deposit via Ethereum L1");
526
+ if (isDryRun()) {
527
+ return printFundsPlan({ action: "deposit", venue: "lighter", network: getNetwork(), asset: "USDC", amount: String(amountNum), chain: "ethereum", contract: "0x3B4D794a66304f130a4db8f2551b0070dfcf5ca7", assetIndex: opts.assetId, routeType: opts.route, steps: "approve USDC (if allowance short) → deposit()" });
528
+ }
529
+ await requireMaster("lighter", "Lighter deposit");
309
530
  if (!isJson()) {
310
531
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} USDC into Lighter via Ethereum L1...\n`));
311
532
  console.log(chalk.yellow(" Warning: Ethereum L1 gas fees may be high ($3-10+)\n"));
312
533
  }
313
- const { ethers } = await import("ethers");
314
- const { loadPrivateKey } = await import("../config.js");
315
- const pk = await loadPrivateKey("lighter");
316
- const provider = new ethers.JsonRpcProvider("https://eth.llamarpc.com");
317
- const wallet = new ethers.Wallet(pk, provider);
318
- const LIGHTER_CONTRACT = "0x3B4D794a66304f130a4db8f2551b0070dfcf5ca7";
534
+ // EIP-55 checksummed (the previous mixed-case literal failed ethers' checksum validation).
535
+ const LIGHTER_CONTRACT = "0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7";
319
536
  const USDC_ETH = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
320
- const usdc = new ethers.Contract(USDC_ETH, [
321
- "function approve(address spender, uint256 amount) returns (bool)",
322
- "function allowance(address owner, address spender) view returns (uint256)",
323
- "function balanceOf(address) view returns (uint256)",
324
- ], wallet);
325
- const lighter = new ethers.Contract(LIGHTER_CONTRACT, [
326
- "function deposit(address _to, uint8 _assetIndex, uint8 _routeType, uint256 _amount) payable",
327
- ], wallet);
328
- const amountRaw = ethers.parseUnits(amount, 6);
329
- const ethBal = await provider.getBalance(wallet.address);
330
- if (ethBal < ethers.parseEther("0.003")) {
331
- if (isJson()) {
332
- printJson(jsonError("INSUFFICIENT_BALANCE", "Insufficient ETH on Ethereum for gas. Need ~0.003 ETH ($3+)."));
333
- }
334
- else {
335
- console.error(chalk.red(" Insufficient ETH on Ethereum for gas. Need ~0.003 ETH ($3+)."));
336
- }
337
- process.exit(1);
338
- }
339
- const usdcBal = await usdc.balanceOf(wallet.address);
537
+ const ETH_RPC = "https://eth.llamarpc.com";
538
+ const { Interface, parseUnits, formatUnits } = await import("ethers");
539
+ const evm = await import("../evm-master-tx.js");
540
+ // OWS master wallet signs both L1 transactions (no raw key).
541
+ const signer = await evm.loadMasterEvmSigner("lighter", cliCredentialOptsFrom(program.opts()), "Lighter deposit");
542
+ const depositor = signer.getAddress();
543
+ const zkLighter = new Interface(["function deposit(address _to, uint16 _assetIndex, uint8 _routeType, uint256 _amount) payable"]);
544
+ const rpc = evm.evmProvider(ETH_RPC, 1);
545
+ await evm.assertRpcChainId(rpc, ETH_RPC, 1, "lighter");
546
+ const amountRaw = parseUnits(amount, await evm.erc20Decimals(rpc, USDC_ETH));
547
+ const usdcBal = await evm.erc20Balance(rpc, USDC_ETH, depositor);
340
548
  if (usdcBal < amountRaw) {
341
- if (isJson()) {
342
- printJson(jsonError("INSUFFICIENT_BALANCE", `Insufficient USDC on Ethereum. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
343
- }
344
- else {
345
- console.error(chalk.red(` Insufficient USDC on Ethereum. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
346
- }
347
- process.exit(1);
549
+ throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on Ethereum. Have: $${formatUsd(Number(formatUnits(usdcBal, 6)))}`, { exchange: "lighter" });
348
550
  }
349
- const allowance = await usdc.allowance(wallet.address, LIGHTER_CONTRACT);
350
- if (allowance < amountRaw) {
351
- if (!isJson())
352
- console.log(chalk.gray(" Approving USDC for Lighter..."));
353
- const approveTx = await usdc.approve(LIGHTER_CONTRACT, amountRaw);
354
- await approveTx.wait();
355
- if (!isJson())
356
- console.log(chalk.gray(" Approved."));
357
- }
358
- if (!isJson())
359
- console.log(chalk.gray(" Calling deposit()..."));
551
+ const base = { venue: "lighter", chainLabel: "Ethereum", chainId: 1, from: depositor, value: 0n };
552
+ const sign = (tx) => signer.signTransaction(tx);
360
553
  try {
361
- const tx = await lighter.deposit(wallet.address, parseInt(opts.assetId), parseInt(opts.route), amountRaw);
362
- const receipt = await tx.wait();
554
+ const allowance = await evm.erc20Allowance(rpc, USDC_ETH, depositor, LIGHTER_CONTRACT);
555
+ if (allowance < amountRaw) {
556
+ if (!isJson())
557
+ console.log(chalk.gray(" Approving USDC for Lighter..."));
558
+ await evm.sendMasterTx(rpc, { ...base, to: USDC_ETH, data: evm.ERC20.encodeFunctionData("approve", [LIGHTER_CONTRACT, amountRaw]), label: "USDC approve" }, sign);
559
+ if (!isJson())
560
+ console.log(chalk.gray(" Approved."));
561
+ }
562
+ if (!isJson())
563
+ console.log(chalk.gray(" Calling deposit()..."));
564
+ const { txHash } = await evm.sendMasterTx(rpc, {
565
+ ...base, to: LIGHTER_CONTRACT, label: "Lighter L1 deposit",
566
+ data: zkLighter.encodeFunctionData("deposit", [depositor, assetIndex, routeType, amountRaw]),
567
+ }, sign);
363
568
  logExecution({
364
569
  type: "bridge", exchange: "lighter", symbol: "USDC", side: "deposit",
365
570
  size: String(amountNum), status: "success", dryRun: false,
366
- meta: { action: "deposit", method: "ethereum", txHash: receipt.hash },
571
+ meta: { action: "deposit", method: "ethereum", txHash },
367
572
  });
368
573
  if (isJson())
369
- return printJson(jsonOk({ txHash: receipt.hash, amount: amountNum, chain: "ethereum", method: "direct" }));
574
+ return printJson(jsonOk({ txHash, amount: amountNum, chain: "ethereum", method: "direct" }));
370
575
  console.log(chalk.green(`\n Deposit confirmed!`));
371
576
  console.log(` Amount: $${formatUsd(amountNum)} USDC`);
372
- console.log(` TX Hash: ${receipt.hash}`);
577
+ console.log(` TX Hash: ${txHash}`);
373
578
  console.log(chalk.gray(`\n Funds appear in your Lighter perps account shortly.\n`));
374
579
  }
375
580
  catch (err) {
376
581
  logExecution({
377
582
  type: "bridge", exchange: "lighter", symbol: "USDC", side: "deposit",
378
- size: String(amountNum), status: "failed", dryRun: false,
583
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
379
584
  error: err instanceof Error ? err.message : String(err),
380
- meta: { action: "deposit", method: "ethereum" },
585
+ meta: { action: "deposit", method: "ethereum", ...pendingTxMeta(err) },
381
586
  });
382
587
  throw err;
383
588
  }
@@ -387,11 +592,10 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
387
592
  base: { chainId: 8453, rpc: "https://mainnet.base.org", usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "Base" },
388
593
  avalanche: { chainId: 43114, rpc: "https://api.avax.network/ext/bc/C/rpc", usdc: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", name: "Avalanche" },
389
594
  };
390
- lighterDeposit
391
- .command("cctp <chain> <amount>")
392
- .description("Deposit USDC via CCTP (arbitrum, base, avalanche — min 5 USDC)")
595
+ supportsDryRun(lighterDeposit.command("cctp <chain> <amount>"))
596
+ .description("Deposit USDC via CCTP (arbitrum, base, avalanche — min 5 USDC; supports --dry-run)")
393
597
  .action(async (chain, amount) => {
394
- const amountNum = parseFloat(amount);
598
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
395
599
  if (isNaN(amountNum) || amountNum <= 0)
396
600
  throw new Error("Invalid amount");
397
601
  if (amountNum < 5)
@@ -400,84 +604,88 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
400
604
  const chainInfo = CCTP_CHAINS[chainKey];
401
605
  if (!chainInfo)
402
606
  throw new Error(`Unsupported chain: ${chain}. Use: arbitrum, base, avalanche`);
607
+ // The intent address comes from the -n adapter, but the USDC transfer is on a mainnet chain.
608
+ requireMainnetDepositRoute(`Lighter CCTP deposit (${chainInfo.name})`);
609
+ if (isDryRun()) {
610
+ return printFundsPlan({ action: "deposit", venue: "lighter", network: getNetwork(), asset: "USDC", amount: String(amountNum), chain: chainKey, method: "CCTP: create intent address → USDC transfer to it" });
611
+ }
612
+ await requireMaster("lighter", "Lighter CCTP deposit");
403
613
  if (!isJson())
404
614
  console.log(chalk.cyan(`\n Depositing $${formatUsd(amountNum)} USDC into Lighter via CCTP (${chainInfo.name})...\n`));
405
- const { ethers } = await import("ethers");
406
- const { loadPrivateKey } = await import("../config.js");
407
- const pk = await loadPrivateKey("lighter");
408
- const provider = new ethers.JsonRpcProvider(chainInfo.rpc);
409
- const wallet = new ethers.Wallet(pk, provider);
615
+ const { parseUnits, formatUnits } = await import("ethers");
616
+ const evm = await import("../evm-master-tx.js");
617
+ // OWS master wallet signs the USDC transfer (no raw key).
618
+ const signer = await evm.loadMasterEvmSigner("lighter", cliCredentialOptsFrom(program.opts()), "Lighter CCTP deposit");
619
+ const depositor = signer.getAddress();
410
620
  if (!isJson())
411
621
  console.log(chalk.gray(" Creating intent address..."));
412
- const intentRes = await fetch("https://mainnet.zklighter.elliot.ai/api/v1/createIntentAddress", {
413
- method: "POST",
414
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
415
- body: new URLSearchParams({
416
- chain_id: String(chainInfo.chainId),
417
- from_addr: wallet.address,
418
- amount: "0",
419
- is_external_deposit: "true",
420
- }),
421
- });
422
- if (!intentRes.ok) {
423
- const err = await intentRes.text();
424
- throw new Error(`Failed to create intent address: ${err}`);
425
- }
426
- const intentData = (await intentRes.json());
427
- const intentAddress = String(intentData.intent_address || intentData.address || "");
428
- if (!intentAddress)
429
- throw new Error("No intent address returned");
430
- if (!isJson())
431
- console.log(` Intent address: ${intentAddress}`);
432
- const gasBal = await provider.getBalance(wallet.address);
433
- if (gasBal < ethers.parseEther("0.00005")) {
434
- if (isJson()) {
435
- printJson(jsonError("INSUFFICIENT_BALANCE", `Insufficient gas token on ${chainInfo.name}. Need native token for gas (~$0.01).`));
436
- }
437
- else {
438
- console.error(chalk.red(` Insufficient gas token on ${chainInfo.name}. Need native token for gas (~$0.01).`));
622
+ // Network-aware: the adapter was built for the selected -n network (venue REST URL).
623
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
624
+ const lt = await getAdapterForExchange("lighter");
625
+ if (!(lt instanceof LighterAdapter))
626
+ throw new Error("lighter venue did not produce a LighterAdapter");
627
+ if (lt.address.toLowerCase() !== depositor.toLowerCase()) {
628
+ throw new PerpError("INVALID_PARAMS", `Lighter adapter address ${lt.address || "(none)"} differs from the depositing wallet ${depositor}`, {
629
+ remediation: "Run the deposit with the master wallet that owns the Lighter account (--ows <wallet>)",
630
+ });
631
+ }
632
+ // The USDC goes to this address: only the documented `intent_address` field is accepted
633
+ // (apidocs.lighter.xyz/reference/createintentaddress → CreateIntentAddressResp, required:
634
+ // "code", "intent_address"). The endpoint is unauthenticated and the docs offer no
635
+ // authenticated read of an account's intent address to cross-check against, so the address
636
+ // is requested twice and both answers must name the same address.
637
+ const readIntentAddress = async () => {
638
+ const intentData = (await lt.createIntentAddress(chainInfo.chainId));
639
+ const addr = intentData?.intent_address;
640
+ if (typeof addr !== "string" || !evm.EVM_ADDRESS_RE.test(addr) || /^0x0{40}$/.test(addr)) {
641
+ throw new PerpError("EXCHANGE_ERROR", `Lighter returned no intent_address for chain ${chainInfo.chainId} (got ${JSON.stringify(intentData).slice(0, 160)}) — nothing was sent`, { exchange: "lighter" });
439
642
  }
440
- process.exit(1);
643
+ return addr;
644
+ };
645
+ const intentAddress = await readIntentAddress();
646
+ const intentAddressAgain = await readIntentAddress();
647
+ if (intentAddress.toLowerCase() !== intentAddressAgain.toLowerCase()) {
648
+ throw new PerpError("EXCHANGE_ERROR", `Lighter returned two different intent addresses for chain ${chainInfo.chainId} (${intentAddress} then ${intentAddressAgain}) — nothing was sent`, {
649
+ exchange: "lighter",
650
+ remediation: "Do not send funds to either address. Retry later, or deposit from the Lighter web app",
651
+ });
441
652
  }
442
- const usdc = new ethers.Contract(chainInfo.usdc, [
443
- "function transfer(address to, uint256 amount) returns (bool)",
444
- "function balanceOf(address) view returns (uint256)",
445
- ], wallet);
446
- const amountRaw = ethers.parseUnits(amount, 6);
447
- const usdcBal = await usdc.balanceOf(wallet.address);
653
+ if (!isJson())
654
+ console.log(` Intent address: ${intentAddress}`);
655
+ const rpc = evm.evmProvider(chainInfo.rpc, chainInfo.chainId);
656
+ await evm.assertRpcChainId(rpc, chainInfo.rpc, chainInfo.chainId, "lighter");
657
+ const amountRaw = parseUnits(amount, await evm.erc20Decimals(rpc, chainInfo.usdc));
658
+ const usdcBal = await evm.erc20Balance(rpc, chainInfo.usdc, depositor);
448
659
  if (usdcBal < amountRaw) {
449
- if (isJson()) {
450
- printJson(jsonError("INSUFFICIENT_BALANCE", `Insufficient USDC on ${chainInfo.name}. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
451
- }
452
- else {
453
- console.error(chalk.red(` Insufficient USDC on ${chainInfo.name}. Have: $${formatUsd(Number(ethers.formatUnits(usdcBal, 6)))}`));
454
- }
455
- process.exit(1);
660
+ throw new PerpError("INSUFFICIENT_BALANCE", `Insufficient USDC on ${chainInfo.name}. Have: $${formatUsd(Number(formatUnits(usdcBal, 6)))}`, { exchange: "lighter" });
456
661
  }
457
662
  if (!isJson())
458
663
  console.log(chalk.gray(` Sending USDC to intent address on ${chainInfo.name}...`));
459
664
  try {
460
- const tx = await usdc.transfer(intentAddress, amountRaw);
461
- const receipt = await tx.wait();
665
+ const { txHash } = await evm.sendMasterTx(rpc, {
666
+ venue: "lighter", chainLabel: chainInfo.name, chainId: chainInfo.chainId, from: depositor, to: chainInfo.usdc, value: 0n,
667
+ data: evm.ERC20.encodeFunctionData("transfer", [intentAddress, amountRaw]), label: "USDC transfer to the Lighter CCTP intent address",
668
+ }, tx => signer.signTransaction(tx));
462
669
  logExecution({
463
670
  type: "bridge", exchange: "lighter", symbol: "USDC", side: "deposit",
464
671
  size: String(amountNum), status: "success", dryRun: false,
465
- meta: { action: "deposit", method: "cctp", chain: chainKey, txHash: receipt.hash },
672
+ meta: { action: "deposit", method: "cctp", chain: chainKey, txHash },
466
673
  });
467
674
  if (isJson())
468
- return printJson(jsonOk({ txHash: receipt.hash, amount: amountNum, chain: chainKey, intentAddress, method: "cctp" }));
675
+ return printJson(jsonOk({ txHash, amount: amountNum, chain: chainKey, intentAddress, method: "cctp" }));
469
676
  console.log(chalk.green(`\n USDC sent to intent address!`));
470
677
  console.log(` Amount: $${formatUsd(amountNum)} USDC`);
471
- console.log(` TX Hash: ${receipt.hash}`);
678
+ console.log(` TX Hash: ${txHash}`);
472
679
  console.log(` Chain: ${chainInfo.name}`);
473
- console.log(chalk.gray(`\n CCTP bridging takes ~1-3 minutes. Funds will appear in Lighter automatically.\n`));
680
+ // apidocs.lighter.xyz/docs/create-accounts-programmatically: "Legacy CCTP route: around 15–20 minutes."
681
+ console.log(chalk.gray(`\n CCTP deposits take around 15-20 minutes to credit. Funds will appear in Lighter automatically.\n`));
474
682
  }
475
683
  catch (err) {
476
684
  logExecution({
477
685
  type: "bridge", exchange: "lighter", symbol: "USDC", side: "deposit",
478
- size: String(amountNum), status: "failed", dryRun: false,
686
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
479
687
  error: err instanceof Error ? err.message : String(err),
480
- meta: { action: "deposit", method: "cctp", chain: chainKey },
688
+ meta: { action: "deposit", method: "cctp", chain: chainKey, ...pendingTxMeta(err) },
481
689
  });
482
690
  throw err;
483
691
  }
@@ -512,23 +720,157 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
512
720
  console.log(` Command: ${chalk.green("perp funds deposit lighter cctp avalanche <amount>")}`);
513
721
  console.log();
514
722
  });
723
+ // ── Lighter RH (Robinhood Chain contract deposit) ──
724
+ {
725
+ const rhDef = getVenue("lighter-rh");
726
+ const rhAsset = rhDef.networks.mainnet.collateral[0];
727
+ const rhChain = chainLabel(rhDef.networks.mainnet.settlementChain);
728
+ supportsDryRun(deposit.command("lighter-rh <amount>"))
729
+ .description(`Deposit ${rhAsset} into ${rhDef.displayName} via the ${rhChain} contract (approve + deposit; min 1 ${rhAsset}; supports --dry-run)`)
730
+ .option("--route <type>", "Route: 0=perps, 1=spot (default: 0)", "0")
731
+ .action(async (amount, opts) => {
732
+ if (opts.route !== "0" && opts.route !== "1") {
733
+ throw new PerpError("INVALID_PARAMS", `--route must be 0 (perps) or 1 (spot), got "${opts.route}"`);
734
+ }
735
+ const net = getNetwork();
736
+ const { planLighterRhDeposit, executeLighterRhDeposit, requireLighterRhDepositNetwork } = await import("../exchanges/lighter-rh-deposit.js");
737
+ // Uniform CAPABILITY_UNSUPPORTED on testnet, before any credential is needed.
738
+ requireLighterRhDepositNetwork(net);
739
+ // The deposit is an L1 contract call from the master wallet; the plan needs its address too.
740
+ if (!isDryRun())
741
+ await requireMaster("lighter-rh", `${rhDef.displayName} deposit`);
742
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
743
+ const lt = await getAdapterForExchange("lighter-rh");
744
+ if (!(lt instanceof LighterAdapter))
745
+ throw new Error("lighter-rh venue did not produce a LighterAdapter");
746
+ const plan = await planLighterRhDeposit({ network: net, amount, depositor: lt.address, routeType: opts.route === "1" ? 1 : 0 });
747
+ const view = {
748
+ venue: rhDef.id, network: net, asset: plan.asset, amount: plan.amount, depositor: plan.depositor,
749
+ contract: plan.contract, token: plan.token, chainId: plan.chainId, routeType: plan.routeType,
750
+ needsApprove: plan.needsApprove,
751
+ };
752
+ if (isDryRun()) {
753
+ logExecution({
754
+ type: "bridge", exchange: rhDef.id, symbol: plan.asset, side: "deposit",
755
+ size: plan.amount, status: "simulated", dryRun: true,
756
+ meta: { action: "deposit", method: "contract", contract: plan.contract },
757
+ });
758
+ if (isJson())
759
+ return printJson(jsonOk({ dryRun: true, sent: false, ...view }));
760
+ console.log(chalk.yellow(`\n [dry-run] Would deposit ${plan.amount} ${plan.asset} into ${rhDef.displayName}`));
761
+ console.log(` From: ${plan.depositor} (${rhChain}, chainId ${plan.chainId})`);
762
+ console.log(` Contract: ${plan.contract}`);
763
+ console.log(` Steps: ${plan.needsApprove ? `approve ${plan.asset} → ` : ""}deposit(route ${plan.routeType})`);
764
+ console.log(chalk.gray(" Nothing was sent.\n"));
765
+ return;
766
+ }
767
+ if (!isJson()) {
768
+ console.log(chalk.cyan(`\n Depositing ${plan.amount} ${plan.asset} into ${rhDef.displayName} on ${rhChain}...\n`));
769
+ if (plan.needsApprove)
770
+ console.log(chalk.gray(` Approving ${plan.asset} for ${plan.contract}...`));
771
+ }
772
+ try {
773
+ const res = await executeLighterRhDeposit(plan, tx => lt.signL1Transaction(tx));
774
+ logExecution({
775
+ type: "bridge", exchange: rhDef.id, symbol: plan.asset, side: "deposit",
776
+ size: plan.amount, status: "success", dryRun: false,
777
+ meta: { action: "deposit", method: "contract", txHash: res.depositTxHash, approveTxHash: res.approveTxHash },
778
+ });
779
+ if (isJson())
780
+ return printJson(jsonOk({ ...view, ...res, method: "contract" }));
781
+ console.log(chalk.green(`\n Deposit confirmed!`));
782
+ console.log(` Amount: ${plan.amount} ${plan.asset}`);
783
+ if (res.approveTxHash !== null)
784
+ console.log(` Approve: ${res.approveTxHash}`);
785
+ console.log(` TX Hash: ${res.depositTxHash}`);
786
+ console.log(chalk.gray(`\n Funds appear in your ${rhDef.displayName} account shortly.\n`));
787
+ }
788
+ catch (err) {
789
+ logExecution({
790
+ type: "bridge", exchange: rhDef.id, symbol: plan.asset, side: "deposit",
791
+ size: plan.amount, status: fundsFailStatus(err), dryRun: false,
792
+ error: err instanceof Error ? err.message : String(err),
793
+ // An unknown-outcome (ORDER_PENDING) failure carries the broadcast tx hash: keep it in the log.
794
+ meta: { action: "deposit", method: "contract", ...pendingTxMeta(err) },
795
+ });
796
+ throw err;
797
+ }
798
+ });
799
+ }
800
+ // ═══════════════════════════════════════════════════════
801
+ // FAUCET (testnet tooling)
802
+ // ═══════════════════════════════════════════════════════
803
+ const faucet = funds.command("faucet").description("[testnet tooling] Mint venue test collateral (refused on mainnet)");
804
+ supportsDryRun(faucet.command("pacifica <amount>"))
805
+ .description("[testnet tooling] Mint test USDP on Pacifica testnet (Solana devnet mint_test_usdc; master wallet; simulated before send; supports --dry-run)")
806
+ .action(async (amount) => {
807
+ const network = getNetwork();
808
+ if (network !== "testnet") {
809
+ throw unsupported("Pacifica", `the test-USDP faucet on ${network}`, "mint_test_usdc exists only on the devnet program", "Run on testnet: perp -n testnet funds faucet pacifica <amount>");
810
+ }
811
+ const { testUsdcBaseUnits, mintTestUsdc, PACIFICA_DEVNET_RPC } = await import("../pacifica/faucet.js");
812
+ const { getNetworkConfig } = await import("../pacifica/constants.js");
813
+ const baseUnits = testUsdcBaseUnits(amount);
814
+ const cfg = getNetworkConfig("testnet");
815
+ if (isDryRun()) {
816
+ return printFundsPlan({
817
+ action: "deposit", venue: "pacifica", network, asset: "USDP (test)", amount,
818
+ method: "faucet mint_test_usdc (simulated, then sent)", baseUnits: baseUnits.toString(),
819
+ programId: cfg.programId, mint: cfg.usdcMint, rpc: PACIFICA_DEVNET_RPC,
820
+ });
821
+ }
822
+ await requireMaster("pacifica", "Pacifica testnet faucet");
823
+ // The subcommand names the venue: fetch it directly, independent of -e.
824
+ const adapter = await getAdapterForExchange("pacifica");
825
+ if (adapter.name !== "pacifica")
826
+ throw new Error("pacifica venue did not produce a Pacifica adapter");
827
+ const { PacificaAdapter } = await import("../exchanges/pacifica.js");
828
+ const pac = adapter;
829
+ const { Connection } = await import("@solana/web3.js");
830
+ try {
831
+ const { signature, plan } = await mintTestUsdc(new Connection(PACIFICA_DEVNET_RPC, "confirmed"), pac.masterSolanaSigner(), amount);
832
+ logExecution({
833
+ type: "bridge", exchange: "pacifica", symbol: "USDP", side: "deposit",
834
+ size: amount, status: "success", dryRun: false,
835
+ meta: { action: "faucet", network, signature, userAta: plan.userAta },
836
+ });
837
+ if (isJson())
838
+ return printJson(jsonOk({ signature, amount, network, method: "faucet", wallet: plan.user, userAta: plan.userAta }));
839
+ console.log(chalk.green(`\n Minted ${amount} test USDP on Pacifica testnet`));
840
+ console.log(` Wallet: ${plan.user}`);
841
+ console.log(` Signature: ${signature}\n`);
842
+ }
843
+ catch (err) {
844
+ logExecution({
845
+ type: "bridge", exchange: "pacifica", symbol: "USDP", side: "deposit",
846
+ size: amount, status: fundsFailStatus(err), dryRun: false,
847
+ error: err instanceof Error ? err.message : String(err),
848
+ meta: { action: "faucet", network },
849
+ });
850
+ throw err;
851
+ }
852
+ });
515
853
  // ═══════════════════════════════════════════════════════
516
854
  // WITHDRAW
517
855
  // ═══════════════════════════════════════════════════════
518
856
  const withdraw = funds.command("withdraw").description("Withdraw funds from exchange accounts");
519
857
  // ── Pacifica ──
520
- withdraw
521
- .command("pacifica <amount>")
522
- .description("Withdraw USDC from Pacifica to your Solana wallet")
523
- .option("--to <address>", "Destination Solana address (default: your wallet)")
524
- .action(async (amount, opts) => {
525
- const amountNum = parseFloat(amount);
858
+ supportsDryRun(withdraw.command("pacifica <amount>"))
859
+ .description("Withdraw USDC from Pacifica to your own Solana wallet (Pacifica has no destination field; supports --dry-run)")
860
+ .action(async (amount) => {
861
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
526
862
  if (isNaN(amountNum) || amountNum <= 0)
527
863
  throw new Error("Invalid amount");
528
- const adapter = await getAdapter();
864
+ if (isDryRun()) {
865
+ return printFundsPlan({ action: "withdraw", venue: "pacifica", network: getNetwork(), asset: "USDC", amount: String(amountNum), destination: "account owner's Solana wallet" });
866
+ }
867
+ await requireMaster("pacifica", "Pacifica withdraw");
868
+ // The subcommand names the venue: fetch it directly, independent of -e.
869
+ const adapter = await getAdapterForExchange("pacifica");
529
870
  if (!hasPacificaSdk(adapter) || !isWithdrawCapable(adapter))
530
- throw new Error("Requires --exchange pacifica");
531
- const dest = opts.to || adapter.publicKey;
871
+ throw new Error("pacifica venue did not produce a withdraw-capable Pacifica adapter");
872
+ // Pacifica withdrawals always go to the account owner's own wallet.
873
+ const dest = adapter.publicKey;
532
874
  if (!isJson()) {
533
875
  console.log(chalk.cyan(`\n Withdrawing $${formatUsd(amountNum)} USDC from Pacifica...\n`));
534
876
  console.log(` Destination: ${dest}`);
@@ -549,7 +891,7 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
549
891
  catch (err) {
550
892
  logExecution({
551
893
  type: "bridge", exchange: "pacifica", symbol: "USDC", side: "withdraw",
552
- size: String(amountNum), status: "failed", dryRun: false,
894
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
553
895
  error: err instanceof Error ? err.message : String(err),
554
896
  meta: { action: "withdraw", destination: dest },
555
897
  });
@@ -557,21 +899,52 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
557
899
  }
558
900
  });
559
901
  // ── Hyperliquid ──
560
- withdraw
561
- .command("hyperliquid <amount>")
562
- .description("Withdraw USDC from Hyperliquid to your Arbitrum wallet")
563
- .option("--to <address>", "Destination EVM address (default: your wallet)")
902
+ supportsDryRun(withdraw.command("hyperliquid <amount>"))
903
+ .description("Withdraw USDC from Hyperliquid over CCTP (native USDC) to your wallet on Arbitrum (default), Ethereum or Base — another address only when allowlisted in settings withdraw.hyperliquid; supports --dry-run")
904
+ .option("--chain <chain>", "Destination chain: arbitrum | ethereum | base", "arbitrum")
905
+ .option("--to <address>", "Destination EVM address (default: your wallet; any other address must be on the settings withdraw.hyperliquid allowlist)")
564
906
  .action(async (amount, opts) => {
565
- const amountNum = parseFloat(amount);
907
+ const { HL_CCTP_ROUTES } = await import("../exchanges/hyperliquid.js");
908
+ const chain = opts.chain.toLowerCase();
909
+ if (!(chain in HL_CCTP_ROUTES)) {
910
+ throw new PerpError("INVALID_PARAMS", `--chain must be one of ${Object.keys(HL_CCTP_ROUTES).join(", ")} (got "${opts.chain}")`);
911
+ }
912
+ const route = HL_CCTP_ROUTES[chain];
913
+ const feeNote = route.forwardingFeeUsdc !== null
914
+ ? `CCTP forwarding fee ${route.forwardingFeeUsdc} USDC (deducted on the way) + a small Hyperliquid send fee`
915
+ : "CCTP forwarding fee for this chain is not published — it is deducted on the way";
916
+ const amountStr = parsePositiveDecimal(amount, "amount");
917
+ const amountNum = Number(amountStr);
566
918
  if (isNaN(amountNum) || amountNum <= 0)
567
919
  throw new Error("Invalid amount");
568
- const adapter = await getAdapter();
569
- if (adapter.name !== "hyperliquid" || !hasEvmAddress(adapter) || !isWithdrawCapable(adapter))
570
- throw new Error("Requires --exchange hyperliquid");
571
- const dest = opts.to || adapter.address;
920
+ if (opts.to !== undefined && !/^0x[0-9a-fA-F]{40}$/.test(opts.to)) {
921
+ throw new PerpError("INVALID_PARAMS", `--to must be an EVM address (got "${opts.to}")`);
922
+ }
923
+ if (isDryRun()) {
924
+ // The owner address is not resolved in a dry run (no key is loaded): an explicit --to is
925
+ // judged as a non-owner destination; it needs no gate if it is the owner's own address.
926
+ const withdrawGate = opts.to === undefined
927
+ ? "not needed — destination is the account owner's own address"
928
+ : `${hyperliquidGateVerdict(getNetwork(), amountStr, opts.to, Date.now())} (not needed if --to is the account owner's own address; checked again when sent)`;
929
+ return printFundsPlan({ action: "withdraw", venue: "hyperliquid", network: getNetwork(), asset: "USDC", amount: String(amountNum), destination: opts.to !== undefined ? opts.to : "account owner's address", chain, method: "CCTP (sendToEvmWithData)", cctpDomain: route.cctpDomain, fee: feeNote, withdrawGate });
930
+ }
931
+ await requireMaster("hyperliquid", "Hyperliquid withdraw");
932
+ // The subcommand names the venue: fetch it directly, independent of -e (like ondo / arcus).
933
+ const adapter = await getAdapterForExchange("hyperliquid");
934
+ if (!hasEvmAddress(adapter) || !isWithdrawCapable(adapter))
935
+ throw new Error("hyperliquid venue did not produce a withdraw-capable EVM adapter");
936
+ // HyperliquidAdapter.withdraw takes the CCTP destination chain.
937
+ const hlWithdraw = adapter.withdraw.bind(adapter);
938
+ const dest = opts.to === undefined ? adapter.address : opts.to;
939
+ // Owner-only unless the destination passes settings.withdraw.hyperliquid (checked before any read / send).
940
+ const toOwner = dest.toLowerCase() === adapter.address.toLowerCase();
941
+ if (!toOwner) {
942
+ const gate = loadSettings().withdraw.hyperliquid;
943
+ assertWithdrawAllowed("hyperliquid", getNetwork(), amountStr, dest, gate === undefined ? null : gate, Date.now());
944
+ }
572
945
  if (!isJson()) {
573
946
  console.log(chalk.cyan(`\n Withdrawing $${formatUsd(amountNum)} USDC from Hyperliquid...\n`));
574
- console.log(` Destination: ${dest}`);
947
+ console.log(` Destination: ${dest}${toOwner ? "" : " (allowlisted in settings withdraw.hyperliquid)"}`);
575
948
  }
576
949
  const balance = await adapter.getBalance();
577
950
  const available = Number(balance.available);
@@ -584,23 +957,27 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
584
957
  }
585
958
  process.exit(1);
586
959
  }
960
+ const attempt = hyperliquidGateEnforce(getNetwork(), amountStr, dest, adapter.address, Date.now());
587
961
  try {
588
- const result = await adapter.withdraw(String(amountNum), dest);
962
+ const result = await hlWithdraw(amountStr, dest, { chain: chain });
589
963
  logExecution({
590
964
  type: "bridge", exchange: "hyperliquid", symbol: "USDC", side: "withdraw",
591
965
  size: String(amountNum), status: "success", dryRun: false,
592
- meta: { action: "withdraw", destination: dest },
966
+ meta: { action: "withdraw", destination: dest, chain, method: "cctp" },
593
967
  });
594
968
  if (isJson())
595
- return printJson(jsonOk(result));
596
- console.log(chalk.green(`\n Withdrawal submitted!`));
597
- console.log(` Amount: $${formatUsd(amountNum)} USDC`);
969
+ return printJson(jsonOk({ result, chain, method: "cctp", fee: feeNote }));
970
+ console.log(chalk.green(`\n Withdrawal submitted (CCTP → ${chain})!`));
971
+ console.log(` Amount: $${formatUsd(amountNum)} USDC — ${feeNote}`);
598
972
  console.log(chalk.gray(`\n Processing may take a few minutes.\n`));
599
973
  }
600
974
  catch (err) {
975
+ // Only a definite refusal proves nothing left the account; an unknown outcome keeps counting.
976
+ if (attempt !== null && isDefiniteOrderFailure(err))
977
+ forgetWithdrawAttempt(attempt);
601
978
  logExecution({
602
979
  type: "bridge", exchange: "hyperliquid", symbol: "USDC", side: "withdraw",
603
- size: String(amountNum), status: "failed", dryRun: false,
980
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
604
981
  error: err instanceof Error ? err.message : String(err),
605
982
  meta: { action: "withdraw", destination: dest },
606
983
  });
@@ -608,62 +985,338 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
608
985
  }
609
986
  });
610
987
  // ── Lighter ──
611
- withdraw
612
- .command("lighter <amount>")
613
- .description("Withdraw USDC from Lighter to your Ethereum L1 wallet")
614
- .option("--asset-id <id>", "Asset ID (default: 2 = USDC)", "2")
988
+ supportsDryRun(withdraw.command("lighter <amount>"))
989
+ // Serves both Lighter-family venues (-e lighter / -e lighter-rh); text derived from their definitions.
990
+ .description(`Withdraw collateral from a Lighter-family venue to your own L1 wallet (${lighterFamilyWithdrawSummary()}; supports --dry-run)`)
991
+ .option("--asset-id <id>", "Asset index (default: 3 = the venue collateral, per /api/v1/assetDetails)", "3")
615
992
  .option("--route <type>", "Route type: 0=perp, 1=spot (default: 0)", "0")
616
993
  .action(async (amount, opts) => {
617
- const amountNum = parseFloat(amount);
994
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
618
995
  if (isNaN(amountNum) || amountNum <= 0)
619
996
  throw new Error("Invalid amount");
997
+ if (isDryRun()) {
998
+ const venue = getVenue(program.opts().exchange);
999
+ if (venue.family !== "lighter")
1000
+ throw new Error("Requires --exchange lighter or lighter-rh");
1001
+ return printFundsPlan({ action: "withdraw", venue: venue.id, network: getNetwork(), asset: withdrawAssetOf(venue), amount: String(amountNum), destination: "account owner's L1 wallet", chain: withdrawChainOf(venue, getNetwork()), assetIndex: opts.assetId, routeType: opts.route });
1002
+ }
1003
+ {
1004
+ const venue = getVenue(program.opts().exchange);
1005
+ if (venue.family === "lighter")
1006
+ await requireMaster(venue.id, `${venue.displayName} withdraw`);
1007
+ }
620
1008
  const adapter = await getAdapter();
621
1009
  if (!hasLighterAccount(adapter) || !isWithdrawCapable(adapter))
622
- throw new Error("Requires --exchange lighter");
1010
+ throw new Error("Requires --exchange lighter or lighter-rh");
1011
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1012
+ if (!(adapter instanceof LighterAdapter))
1013
+ throw new Error("Requires --exchange lighter or lighter-rh");
1014
+ const venueName = getVenue(adapter.venueId).displayName;
1015
+ const asset = adapter.collateralSymbol;
1016
+ const chain = chainLabel(adapter.network.settlementChain);
623
1017
  if (!isJson()) {
624
- console.log(chalk.cyan(`\n Withdrawing $${formatUsd(amountNum)} USDC from Lighter...\n`));
1018
+ console.log(chalk.cyan(`\n Withdrawing ${formatUsd(amountNum)} ${asset} from ${venueName} to ${chain}...\n`));
625
1019
  console.log(` Account Index: ${adapter.accountIndex}`);
626
1020
  console.log(` Address: ${adapter.address}`);
627
1021
  }
628
1022
  try {
629
1023
  const result = await adapter.withdraw(String(amountNum), "", { assetId: parseInt(opts.assetId), routeType: parseInt(opts.route) });
630
1024
  logExecution({
631
- type: "bridge", exchange: "lighter", symbol: "USDC", side: "withdraw",
1025
+ type: "bridge", exchange: adapter.venueId, symbol: asset, side: "withdraw",
632
1026
  size: String(amountNum), status: "success", dryRun: false,
633
1027
  meta: { action: "withdraw" },
634
1028
  });
635
1029
  if (isJson())
636
1030
  return printJson(jsonOk(result));
637
1031
  console.log(chalk.green(`\n Withdrawal submitted!`));
638
- console.log(` Amount: $${formatUsd(amountNum)} USDC`);
639
- console.log(chalk.gray(`\n Standard withdrawal takes ~12 hours. Use Lighter web for fast withdrawal.\n`));
1032
+ console.log(` Amount: ${formatUsd(amountNum)} ${asset}`);
1033
+ console.log(chalk.gray(`\n Standard (secure) withdrawal is processed by the ${chain} contract and can take hours. Use the ${venueName} web app for a fast withdrawal.\n`));
640
1034
  }
641
1035
  catch (err) {
642
1036
  logExecution({
643
- type: "bridge", exchange: "lighter", symbol: "USDC", side: "withdraw",
644
- size: String(amountNum), status: "failed", dryRun: false,
1037
+ type: "bridge", exchange: adapter.venueId, symbol: asset, side: "withdraw",
1038
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
645
1039
  error: err instanceof Error ? err.message : String(err),
646
1040
  meta: { action: "withdraw" },
647
1041
  });
648
1042
  throw err;
649
1043
  }
650
1044
  });
1045
+ // ── Arcus — uniform surface (design §13.1): same <amount> + --to,
1046
+ // same jsonOk(result), global --dry-run plans without sending. ──
1047
+ // Ondo Perps: the API does not allow withdrawals (address-book destinations only; adding one is
1048
+ // refused for API keys). The command stays so the refusal is explicit — also under --dry-run.
1049
+ supportsDryRun(withdraw.command("ondo [amount]"))
1050
+ .description("Not available: Ondo Perps withdrawals are web-app only (refused, also with --dry-run)")
1051
+ .option("--to <address>", "Ignored — the API cannot withdraw")
1052
+ .action(async () => {
1053
+ throw unsupported("Ondo Perps", "withdraw", "the Ondo Perps API does not allow withdrawals", "Withdraw from the Ondo Perps web app");
1054
+ });
1055
+ for (const venueId of ["arcus"]) {
1056
+ const def = getVenue(venueId);
1057
+ const asset = withdrawAssetOf(def);
1058
+ const toHelp = "Destination EVM address (must equal the Arcus account owner — the default)";
1059
+ supportsDryRun(withdraw.command(`${venueId} <amount>`))
1060
+ .description(`Withdraw ${asset} from ${def.displayName} to your ${chainLabel(withdrawChainOf(def, "mainnet"))} wallet (supports --dry-run)`)
1061
+ .option("--to <address>", toHelp)
1062
+ .action(async (amount, opts) => {
1063
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
1064
+ if (!/^\d+(\.\d+)?$/.test(amount) || isNaN(amountNum) || amountNum <= 0) {
1065
+ throw new PerpError("INVALID_PARAMS", `Withdraw amount must be a positive decimal (got "${amount}")`);
1066
+ }
1067
+ const net = getNetwork();
1068
+ const chain = chainLabel(withdrawChainOf(def, net));
1069
+ const adapter = await getAdapterForExchange(venueId);
1070
+ if (!isWithdrawCapable(adapter))
1071
+ throw new Error(`${def.id} adapter has no withdraw()`);
1072
+ const { ArcusAdapter } = await import("../exchanges/arcus/adapter.js");
1073
+ if (!(adapter instanceof ArcusAdapter))
1074
+ throw new Error("arcus venue did not produce an ArcusAdapter");
1075
+ const owner = adapter.withdrawDestination;
1076
+ if (opts.to !== undefined && opts.to.toLowerCase() !== owner.toLowerCase()) {
1077
+ throw new PerpError("INVALID_PARAMS", `Arcus only withdraws to the account owner ${owner}`, {
1078
+ remediation: `Omit --to (defaults to ${owner})`,
1079
+ });
1080
+ }
1081
+ const destination = owner;
1082
+ const plan = { venue: def.id, network: net, asset, amount: String(amountNum), destination, chain };
1083
+ if (isDryRun()) {
1084
+ logExecution({
1085
+ type: "bridge", exchange: def.id, symbol: asset, side: "withdraw",
1086
+ size: String(amountNum), status: "simulated", dryRun: true,
1087
+ meta: { action: "withdraw", destination },
1088
+ });
1089
+ if (isJson())
1090
+ return printJson(jsonOk({ dryRun: true, sent: false, ...plan }));
1091
+ console.log(chalk.yellow(`\n [dry-run] Would withdraw ${formatUsd(amountNum)} ${asset} from ${def.displayName} (${net})`));
1092
+ console.log(` Destination: ${destination} (${chain})`);
1093
+ console.log(chalk.gray(" Nothing was sent.\n"));
1094
+ return;
1095
+ }
1096
+ if (!isJson()) {
1097
+ console.log(chalk.cyan(`\n Withdrawing ${formatUsd(amountNum)} ${asset} from ${def.displayName}...\n`));
1098
+ console.log(` Destination: ${destination} (${chain})`);
1099
+ }
1100
+ try {
1101
+ const result = await adapter.withdraw(String(amountNum), destination);
1102
+ logExecution({
1103
+ type: "bridge", exchange: def.id, symbol: asset, side: "withdraw",
1104
+ size: String(amountNum), status: "success", dryRun: false,
1105
+ meta: { action: "withdraw", destination },
1106
+ });
1107
+ if (isJson())
1108
+ return printJson(jsonOk(result));
1109
+ console.log(chalk.green(`\n Withdrawal submitted!`));
1110
+ console.log(` Amount: ${formatUsd(amountNum)} ${asset}`);
1111
+ console.log(chalk.gray(`\n Track it in the ${def.displayName} app until it arrives on ${chain}.\n`));
1112
+ }
1113
+ catch (err) {
1114
+ logExecution({
1115
+ type: "bridge", exchange: def.id, symbol: asset, side: "withdraw",
1116
+ size: String(amountNum), status: fundsFailStatus(err), dryRun: false,
1117
+ error: err instanceof Error ? err.message : String(err),
1118
+ meta: { action: "withdraw", destination },
1119
+ });
1120
+ throw err;
1121
+ }
1122
+ });
1123
+ }
1124
+ // ── QFEX (design §14.6, §14.12 #8) — custodial; deposit = address display, withdraw = banker POST
1125
+ // gated by settings.withdraw.qfex inside QfexAdapter.withdraw(); switched off (QFEX_WITHDRAW_ENABLED). ──
1126
+ /** QFEX adapter for the active network — the default imported key, or the one named by --agent. */
1127
+ const qfexAdapterFor = async (agentName) => {
1128
+ const { QfexAdapter: Cls } = await import("../exchanges/qfex/adapter.js");
1129
+ let adapter;
1130
+ if (agentName === undefined) {
1131
+ adapter = await getAdapterForExchange("qfex");
1132
+ }
1133
+ else {
1134
+ const { getVenueAgent } = await import("../agent-wallet/store.js");
1135
+ const meta = getVenueAgent("qfex", agentName);
1136
+ if (meta === null) {
1137
+ throw new PerpError("AGENT_NOT_REGISTERED", `No QFEX API key named "${agentName}"`, { remediation: "List keys: perp wallet agent list qfex" });
1138
+ }
1139
+ const def = getVenue("qfex");
1140
+ if (def.create === null)
1141
+ throw new Error("qfex venue has no adapter factory");
1142
+ adapter = await def.create({
1143
+ network: getNetwork(), credentials: { ...READ_ONLY_CREDENTIALS, agent: meta }, noAgent: false, hlDex: null, readOnly: false, noBuilder: true,
1144
+ });
1145
+ }
1146
+ if (!(adapter instanceof Cls))
1147
+ throw new Error("qfex venue did not produce a QfexAdapter");
1148
+ return adapter;
1149
+ };
1150
+ supportsDryRun(withdraw.command(QFEX_WITHDRAW_ENABLED ? "qfex <amount>" : "qfex [amount]"))
1151
+ .description(QFEX_WITHDRAW_ENABLED
1152
+ ? "Withdraw USDC from QFEX to an allowlisted Arbitrum One address (needs settings withdraw.qfex and a key with the \"Deposit and withdraw\" permission; supports --dry-run)"
1153
+ : "Not available: QFEX withdrawals are disabled in perp-cli for now — use the QFEX web app (refused, also with --dry-run)")
1154
+ .option("--to <address>", "Destination EVM address on Arbitrum One (required; must be on the settings withdraw.qfex allowlist)")
1155
+ .option("--agent <name>", "Imported QFEX key to use (a key with the \"Deposit and withdraw\" permission, bound to the primary account)")
1156
+ .action(async (amount, opts) => {
1157
+ if (!QFEX_WITHDRAW_ENABLED)
1158
+ throw unsupported("QFEX", "withdraw", QFEX_WITHDRAW_DISABLED.reason, QFEX_WITHDRAW_DISABLED.remediation);
1159
+ const def = getVenue("qfex");
1160
+ const net = getNetwork();
1161
+ if (!/^\d+(\.\d+)?$/.test(amount) || !(Number(amount) > 0)) {
1162
+ throw new PerpError("INVALID_PARAMS", `Withdraw amount must be a positive decimal (got "${amount}")`);
1163
+ }
1164
+ if (opts.to === undefined) {
1165
+ throw new PerpError("INVALID_PARAMS", "QFEX withdraw needs a destination address (the API key has no owner address)", {
1166
+ remediation: `perp funds withdraw qfex ${amount} --to <0x address on the settings withdraw.qfex allowlist>`,
1167
+ });
1168
+ }
1169
+ const destination = opts.to;
1170
+ const chain = chainLabel(withdrawChainOf(def, net));
1171
+ if (isDryRun()) {
1172
+ // The local gate needs no key and no network: a dry run shows whether it would refuse.
1173
+ const gate = loadSettings().withdraw.qfex;
1174
+ assertWithdrawAllowed("qfex", net, amount, destination, gate === undefined ? null : gate, Date.now());
1175
+ return printFundsPlan({
1176
+ action: "withdraw", venue: "qfex", network: net, asset: "USDC", amount, destination, chain, bankerNetwork: QFEX_BANKER_NETWORK,
1177
+ fee: "taken from the amount by QFEX (not published)", // docs.qfex.com/api-reference/programmatic-funding: "`POST /withdraw` is not idempotent" and 403 =
1178
+ // "API key lacks **Deposit and withdraw** permission, or the account cannot use funding".
1179
+ // 2FA is documented for creating the API key (api-reference/api-keys), not for the withdrawal call.
1180
+ note: "POST /withdraw is not idempotent; QFEX answers 403 when the key lacks the \"Deposit and withdraw\" permission or the account cannot use funding",
1181
+ });
1182
+ }
1183
+ const adapter = await qfexAdapterFor(opts.agent);
1184
+ if (!isJson()) {
1185
+ console.log(chalk.cyan(`\n Withdrawing ${amount} USDC from QFEX...\n`));
1186
+ console.log(` Destination: ${destination} (${chain})`);
1187
+ }
1188
+ try {
1189
+ const result = await adapter.withdraw(amount, destination);
1190
+ logExecution({ type: "bridge", exchange: "qfex", symbol: "USDC", side: "withdraw", size: amount, status: "success", dryRun: false, meta: { action: "withdraw", destination } });
1191
+ if (isJson())
1192
+ return printJson(jsonOk(result));
1193
+ console.log(chalk.green(`\n Withdrawal accepted by QFEX.`));
1194
+ console.log(` Amount: ${amount} USDC (fee taken from it; not published)`);
1195
+ console.log(chalk.gray(`\n ${result.note}\n`));
1196
+ }
1197
+ catch (err) {
1198
+ logExecution({ type: "bridge", exchange: "qfex", symbol: "USDC", side: "withdraw", size: amount, status: fundsFailStatus(err), dryRun: false, error: err instanceof Error ? err.message : String(err), meta: { action: "withdraw", destination } });
1199
+ throw err;
1200
+ }
1201
+ });
1202
+ supportsDryRun(deposit.command("qfex"))
1203
+ .description("Show the QFEX deposit address for USDC on Arbitrum One (perp-cli sends nothing — transfer from your own wallet; supports --dry-run)")
1204
+ // --chain, not --network: the root -n/--network (mainnet | testnet) would swallow a subcommand --network.
1205
+ .option("--chain <chain>", "Deposit chain (arbitrum only; TRON USDT is not supported)", "arbitrum")
1206
+ .option("--agent <name>", "Imported QFEX key to use (needs the \"Deposit and withdraw\" permission)")
1207
+ .action(async (opts) => {
1208
+ const requested = opts.chain.toLowerCase();
1209
+ if (requested === "tron") {
1210
+ throw unsupported("qfex", "depositTron", "only USDC on Arbitrum One is supported; how a TRON USDT deposit is credited as USDC margin is not documented", "Deposit USDC on Arbitrum One, or use the QFEX web app for other rails");
1211
+ }
1212
+ if (requested !== QFEX_WITHDRAW_NETWORK) {
1213
+ throw new PerpError("INVALID_PARAMS", `Unsupported QFEX deposit chain "${opts.chain}"`, { remediation: "Use --chain arbitrum" });
1214
+ }
1215
+ const net = getNetwork();
1216
+ if (isDryRun()) {
1217
+ return printFundsPlan({
1218
+ action: "deposit", venue: "qfex", network: net, asset: "USDC", amount: "(you choose)", chain: chainLabel(QFEX_WITHDRAW_NETWORK),
1219
+ method: `GET ${QFEX_BANKER_NETWORK} deposit address from the QFEX banker, then YOU send USDC to it from your wallet`,
1220
+ });
1221
+ }
1222
+ const adapter = await qfexAdapterFor(opts.agent);
1223
+ const addr = await adapter.depositAddress();
1224
+ const info = {
1225
+ venue: "qfex", network: net, address: addr.address, depositNetwork: addr.network, asset: addr.asset,
1226
+ minimum: null, confirmations: null,
1227
+ warning: "Send ONLY USDC on Arbitrum One to this address. Any other asset or network is lost. Minimum amount and confirmations: see the QFEX funding screen. Deposits credit the primary account.",
1228
+ };
1229
+ if (isJson())
1230
+ return printJson(jsonOk(info));
1231
+ console.log(chalk.cyan.bold(`\n QFEX deposit address (${net})\n`));
1232
+ console.log(` Address: ${chalk.green(addr.address)}`);
1233
+ console.log(` Network: ${addr.network} (Arbitrum One) Asset: ${addr.asset}`);
1234
+ console.log(chalk.yellow(`\n ${info.warning}`));
1235
+ console.log(chalk.gray(" perp-cli did not send anything — transfer from your own wallet or exchange.\n"));
1236
+ });
1237
+ // ── Aster deposit / withdraw, Ondo deposit (OWS-master on-chain legs; address book refused) ──
1238
+ registerOnchainFundsCommands({
1239
+ program, funds, deposit, withdraw, isJson, isDryRun, getNetwork, getAdapterForExchange,
1240
+ requireMaster, printFundsPlan, failStatus: fundsFailStatus,
1241
+ });
651
1242
  // ═══════════════════════════════════════════════════════
652
1243
  // TRANSFER (HL internal)
653
1244
  // ═══════════════════════════════════════════════════════
654
- funds
655
- .command("transfer <amount> <destination>")
656
- .description("Transfer USDC between Hyperliquid accounts (internal, instant)")
657
- .action(async (amount, destination) => {
658
- const amountNum = parseFloat(amount);
1245
+ supportsDryRun(funds.command("transfer <amount> <destination>"))
1246
+ .description("Transfer USDC to another Hyperliquid account (internal, instant; the destination must be on the settings withdraw.hyperliquid allowlist), or — with -e qfex — between your QFEX accounts (destination: primary | <account uuid>; supports --dry-run)")
1247
+ .option("--from <account>", "QFEX only: source account (primary | <account uuid>; default: the account the key is bound to)")
1248
+ .option("--agent <name>", "QFEX only: imported key to use")
1249
+ .action(async (amount, destination, opts) => {
1250
+ const amountNum = Number(parsePositiveDecimal(amount, "amount"));
659
1251
  if (isNaN(amountNum) || amountNum <= 0)
660
1252
  throw new Error("Invalid amount");
661
- const adapter = await getAdapter();
1253
+ const exchangeOpt = program.opts().exchange;
1254
+ if (exchangeOpt !== undefined && getVenue(exchangeOpt).id === "qfex") {
1255
+ if (isDryRun()) {
1256
+ return printFundsPlan({ action: "transfer", venue: "qfex", network: getNetwork(), asset: "USDC", amount, from: opts.from ?? "the key's account", destination });
1257
+ }
1258
+ const adapter = await qfexAdapterFor(opts.agent);
1259
+ // "primary" is resolved from the venue's own account list (is_master) — never assumed.
1260
+ const accounts = await adapter.listAccounts();
1261
+ const resolve = (name) => {
1262
+ if (name.toLowerCase() !== "primary")
1263
+ return name.toLowerCase();
1264
+ const primary = accounts.filter(a => a.isPrimary);
1265
+ if (primary.length !== 1)
1266
+ throw new PerpError("EXCHANGE_ERROR", `QFEX lists ${primary.length} primary accounts`, { exchange: "qfex" });
1267
+ return primary[0].accountId;
1268
+ };
1269
+ const from = resolve(opts.from === undefined ? adapter.accountId : opts.from);
1270
+ const to = resolve(destination);
1271
+ for (const id of [from, to]) {
1272
+ if (!accounts.some(a => a.accountId === id)) {
1273
+ throw new PerpError("INVALID_PARAMS", `QFEX account ${id} is not one of your accounts`, { remediation: `Your accounts: ${accounts.map(a => `${a.accountId}${a.isPrimary ? " (primary)" : ""}`).join(", ")}` });
1274
+ }
1275
+ }
1276
+ if (!isJson())
1277
+ console.log(chalk.cyan(`\n Transferring ${amount} USDC on QFEX: ${from} → ${to}...\n`));
1278
+ const result = await adapter.transferBetweenAccounts(amount, from, to);
1279
+ logExecution({ type: "bridge", exchange: "qfex", symbol: "USDC", side: "transfer", size: amount, status: "success", dryRun: false, meta: { action: "transfer", from, to } });
1280
+ if (isJson())
1281
+ return printJson(jsonOk(result));
1282
+ console.log(chalk.green(" Transfer accepted by QFEX."));
1283
+ console.log(chalk.gray(" Verify both balances: perp account balance -e qfex\n"));
1284
+ return;
1285
+ }
1286
+ if (opts.from !== undefined || opts.agent !== undefined) {
1287
+ throw new PerpError("INVALID_PARAMS", "--from / --agent apply to QFEX transfers only", { remediation: "Add -e qfex, or drop the flags for a Hyperliquid transfer" });
1288
+ }
1289
+ // usdSend pays another account: the destination must pass settings.withdraw.hyperliquid
1290
+ // (allowlist + maxPerTx / maxPerDay, shared with withdrawals to non-owner addresses).
1291
+ const amountStr = parsePositiveDecimal(amount, "amount");
1292
+ const gateOf = () => {
1293
+ const g = loadSettings().withdraw.hyperliquid;
1294
+ return g === undefined ? null : g;
1295
+ };
1296
+ if (isDryRun()) {
1297
+ // The gate needs no key and no network: a dry run refuses exactly as the live send would.
1298
+ assertWithdrawAllowed("hyperliquid", getNetwork(), amountStr, destination, gateOf(), Date.now());
1299
+ return printFundsPlan({ action: "transfer", venue: "hyperliquid", network: getNetwork(), asset: "USDC", amount: String(amountNum), destination, withdrawGate: "passes (destination allowlisted in settings withdraw.hyperliquid, within maxPerTx / maxPerDay)" });
1300
+ }
1301
+ await requireMaster("hyperliquid", "Hyperliquid transfer");
1302
+ assertWithdrawAllowed("hyperliquid", getNetwork(), amountStr, destination, gateOf(), Date.now());
1303
+ // Not -e qfex → the Hyperliquid transfer (as the dry-run plan says): fetch it directly, independent of -e.
1304
+ const adapter = await getAdapterForExchange("hyperliquid");
662
1305
  if (!isUsdTransferCapable(adapter))
663
- throw new Error("Requires --exchange hyperliquid");
1306
+ throw new Error("hyperliquid venue did not produce a USD-transfer-capable adapter");
664
1307
  if (!isJson())
665
1308
  console.log(chalk.cyan(`\n Transferring $${formatUsd(amountNum)} USDC to ${destination}...\n`));
666
- const result = await adapter.usdTransfer(amountNum, destination);
1309
+ // owner = null: every usdSend destination is gated (sending to another account is the point).
1310
+ const attempt = hyperliquidGateEnforce(getNetwork(), amountStr, destination, null, Date.now());
1311
+ let result;
1312
+ try {
1313
+ result = await adapter.usdTransfer(amountNum, destination);
1314
+ }
1315
+ catch (err) {
1316
+ if (attempt !== null && isDefiniteOrderFailure(err))
1317
+ forgetWithdrawAttempt(attempt);
1318
+ throw err;
1319
+ }
667
1320
  if (isJson())
668
1321
  return printJson(jsonOk(result));
669
1322
  console.log(chalk.green(` Transfer complete!`));
@@ -685,11 +1338,20 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
685
1338
  pacifica: { chain: "Solana", token: "USDC", min: 10, gas: "SOL ~0.005", method: "on-chain program" },
686
1339
  hyperliquid: { chain: "Arbitrum", token: "USDC", min: 5, gas: "ETH ~0.0001", method: "USDC transfer to Bridge2" },
687
1340
  lighter: { chain: "Ethereum L1 / Arbitrum / Base / Avalanche", token: "USDC", min: "1 (L1) or 5 (CCTP)", gas: "$3-10 (L1), ~$0.01 (CCTP)", method: "deposit() or CCTP intent" },
1341
+ "lighter-rh": { chain: "Robinhood Chain", token: "USDG", min: 1, gas: "Robinhood Chain ETH", method: "approve + deposit() (mainnet only)" },
1342
+ qfex: { chain: "Arbitrum", token: "USDC", min: null, method: "address display only: perp funds deposit qfex, then send USDC from your wallet" },
1343
+ aster: { chain: "BNB Chain (default) / Arbitrum", token: "USDT (default) / USDC", min: null, gas: "BNB / Arbitrum ETH", method: "approve + vault depositFor(token, forAddress, amount, broker 1) (mainnet only)", command: "perp funds deposit aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]" },
1344
+ ondo: { chain: "Arbitrum (default) / Ethereum", token: "USDC", min: null, gas: "ETH", method: "USDC transfer to the account's funding address (mainnet only)", command: "perp funds deposit ondo <amount> [--chain arbitrum|ethereum] [--probe <amount>]" },
688
1345
  },
689
1346
  withdraw: {
690
1347
  pacifica: { chain: "Solana", token: "USDC", speed: "~10s", fee: "none" },
691
1348
  hyperliquid: { chain: "Arbitrum", token: "USDC", speed: "minutes", fee: "$1" },
692
1349
  lighter: { chain: "Ethereum L1", token: "USDC", speed: "~12 hours (standard), minutes (fast)", fee: "varies" },
1350
+ "lighter-rh": { chain: "Robinhood Chain", token: "USDG", command: "perp -e lighter-rh funds withdraw lighter <amount>" },
1351
+ ondo: { supported: false, note: "the Ondo Perps API does not allow withdrawals — withdraw in the Ondo Perps web app" },
1352
+ qfex: { supported: false, note: "QFEX withdrawals are disabled in perp-cli for now — withdraw in the QFEX web app" },
1353
+ arcus: { chain: "Robinhood Chain", token: "USDG", command: "perp funds withdraw arcus <amount>" },
1354
+ aster: { chain: "BNB Chain (default) / Arbitrum", token: "USDT (default) / USDC", command: "perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]", fee: "POST /fapi/v3/aster/user-withdraw-info (taken from the amount)", requires: "mainnet; API wallet approved with --can-withdraw --ip-whitelist; destination = the account's own address" },
693
1355
  },
694
1356
  }));
695
1357
  }
@@ -714,6 +1376,15 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
714
1376
  console.log(` L1: ${chalk.green("perp funds deposit lighter ethereum <amount>")} — gas $3-10+`);
715
1377
  console.log(` CCTP: ${chalk.green("perp funds deposit lighter cctp <chain> <amount>")} — gas ~$0.01`);
716
1378
  console.log(` Chains: arbitrum, base, avalanche`);
1379
+ console.log(chalk.white.bold("\n Lighter RH") + chalk.gray(" (Robinhood Chain)"));
1380
+ console.log(` Token: USDG | Min: 1 | Gas: Robinhood Chain ETH (mainnet only)`);
1381
+ console.log(` Command: ${chalk.green("perp funds deposit lighter-rh <amount>")}`);
1382
+ console.log(chalk.white.bold("\n Aster") + chalk.gray(" (BNB Chain / Arbitrum vault contract)"));
1383
+ console.log(` Token: USDT (default) or USDC | Gas: BNB / Arbitrum ETH (mainnet only)`);
1384
+ console.log(` Command: ${chalk.green("perp funds deposit aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]")}`);
1385
+ console.log(chalk.white.bold("\n Ondo Perps") + chalk.gray(" (Arbitrum / Ethereum — USDC transfer to your funding address)"));
1386
+ console.log(` Token: USDC | Gas: ETH (mainnet only)`);
1387
+ console.log(` Command: ${chalk.green("perp funds deposit ondo <amount> [--chain arbitrum|ethereum] [--probe <amount>]")}`);
717
1388
  // Withdraw info
718
1389
  console.log(chalk.white.bold("\n ── Withdraw ──\n"));
719
1390
  console.log(chalk.white.bold(" Pacifica") + chalk.gray(" → Solana"));
@@ -725,6 +1396,15 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
725
1396
  console.log(chalk.white.bold("\n Lighter") + chalk.gray(" → Ethereum L1"));
726
1397
  console.log(` Speed: ~12h (standard) | Fee: varies`);
727
1398
  console.log(` Command: ${chalk.green("perp -e lt funds withdraw lighter <amount>")}`);
1399
+ console.log(chalk.white.bold("\n Lighter RH") + chalk.gray(" → Robinhood Chain (USDG)"));
1400
+ console.log(` Command: ${chalk.green("perp -e lighter-rh funds withdraw lighter <amount>")}`);
1401
+ console.log(chalk.white.bold("\n Ondo Perps") + chalk.gray(" — not available over the API"));
1402
+ console.log(chalk.gray(" Withdraw in the Ondo Perps web app."));
1403
+ console.log(chalk.white.bold("\n Arcus") + chalk.gray(" → Robinhood Chain (USDG, to the account owner)"));
1404
+ console.log(` Command: ${chalk.green("perp funds withdraw arcus <amount>")}`);
1405
+ console.log(chalk.white.bold("\n Aster") + chalk.gray(" → BNB Chain / Arbitrum (USDT, to the account's own address; mainnet only)"));
1406
+ console.log(` Needs an API wallet approved with --can-withdraw --ip-whitelist; fee from user-withdraw-info`);
1407
+ console.log(` Command: ${chalk.green("perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]")}`);
728
1408
  // Transfer + Bridge
729
1409
  console.log(chalk.white.bold("\n ── Transfer & Bridge ──\n"));
730
1410
  console.log(chalk.white.bold(" Internal Transfer") + chalk.gray(" (Hyperliquid → Hyperliquid)"));
@@ -739,11 +1419,11 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
739
1419
  console.log(` Check: ${chalk.green("perp funds rebalance check")}`);
740
1420
  console.log(` Plan: ${chalk.green("perp funds rebalance plan")}`);
741
1421
  console.log(` Execute: ${chalk.green("perp funds rebalance execute --auto-bridge")}`);
742
- console.log(chalk.gray("\n Use --no-relay to skip relayer and pay gas yourself.\n"));
1422
+ console.log(chalk.gray("\n Deposits are direct by default (you pay gas). Add --relay to use the gas-sponsoring relayer.\n"));
743
1423
  });
744
1424
  // ═══════════════════════════════════════════════════════
745
1425
  // NESTED SUBTREES — bridge (multi-provider) + rebalance
746
1426
  // ═══════════════════════════════════════════════════════
747
- registerFundsBridgeCommands(funds, isJson);
748
- registerFundsRebalanceCommands(funds, getAdapterForExchange, isJson);
1427
+ registerFundsBridgeCommands(funds, isJson, getNetwork);
1428
+ registerFundsRebalanceCommands(funds, getAdapterForExchange, isJson, getNetwork);
749
1429
  }