perp-cli 0.12.17 → 0.14.0

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