perp-cli 0.12.15 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +278 -31
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +74 -84
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +33 -16
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,6 +1,14 @@
1
+ import { alignProtectionPrice, floorSize, formatComputedPrice, mulDecimal, slippagePrice, toUnitsExact, unitsToString } from "./precision.js";
2
+ import { unsupported } from "./trading-v2.js";
1
3
  import { LocalEvmSigner } from "../signer/index.js";
2
4
  import { isExpired } from "../agent-wallet/expiry.js";
3
- import { PerpError } from "../errors.js";
5
+ import { PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, unknownActionRemediation, parsePositiveDecimal, DEFINITE_ORDER_FAILURE_CODES } from "../errors.js";
6
+ import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
7
+ import { lighterFamilyNetworks, LIGHTER_RH_RATE_LIMIT, LIGHTER_RH_RESERVED_KEY_INDEXES, LIGHTER_GTT_EXPIRY_MS, LIGHTER_CLIENT_ORDER_INDEX_MAX, LIGHTER_REFERENCE_PRICE_SOURCE, } from "./venues/lighter-family.js";
8
+ import { acquireRate, rateLimitedError } from "./rate-limiter.js";
9
+ import { lighterWafChallenge } from "./lighter-public.js";
10
+ import { fetchAndCache, invalidateCache, setCached, withCache, TTL_ACCOUNT } from "../cache.js";
11
+ import { LighterWasmSigner } from "./lighter-signer.js";
4
12
  /** Lighter trigger orderType enum (matches WASM signer). */
5
13
  export const LIGHTER_ORDER_TYPE = {
6
14
  STOP_LOSS: 2,
@@ -25,17 +33,407 @@ export function classifyTriggerOrderType(side, triggerPrice, markPrice, isMarket
25
33
  return isMarket ? LIGHTER_ORDER_TYPE.STOP_LOSS : LIGHTER_ORDER_TYPE.STOP_LOSS_LIMIT;
26
34
  return isMarket ? LIGHTER_ORDER_TYPE.TAKE_PROFIT : LIGHTER_ORDER_TYPE.TAKE_PROFIT_LIMIT;
27
35
  }
36
+ /** Lighter order time-in-force enum (lighter-go / SDK `TimeInForce`): 0 IOC, 1 GTT, 2 post-only. */
37
+ export const LIGHTER_TIF = { IOC: 0, GTT: 1, POST_ONLY: 2 };
38
+ /** Decimal step for `decimals` digits ("1" for 0, "0.01" for 2). */
39
+ function lighterStep(decimals) {
40
+ return decimals <= 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
41
+ }
42
+ /**
43
+ * CLI format rule for a Lighter client order id (§13.10 #9): the venue's
44
+ * `clientOrderIndex` is numeric, so the id must be a plain decimal integer string
45
+ * (digits only, no sign / leading zero / exponent) in 1 ≤ n ≤ 2^48 − 1.
46
+ * Anything else → INVALID_PARAMS before anything is sent.
47
+ */
48
+ export function lighterClientOrderIndex(id) {
49
+ const n = /^[1-9][0-9]*$/.test(id) ? Number(id) : NaN;
50
+ if (!Number.isSafeInteger(n) || n > LIGHTER_CLIENT_ORDER_INDEX_MAX) {
51
+ throw new PerpError("INVALID_PARAMS", `Lighter client order id "${id}" must be a decimal integer from 1 to ${LIGHTER_CLIENT_ORDER_INDEX_MAX} (2^48 − 1)`, {
52
+ remediation: `Use a numeric client order id, e.g. ${Date.now()}`,
53
+ });
54
+ }
55
+ return n;
56
+ }
57
+ /**
58
+ * Generated Lighter client order index (uint48, 1 ≤ n ≤ 2^48 − 1). Layout: the low 38 bits
59
+ * of the millisecond timestamp (wraps every ~8.7 years) shifted left 10 bits, plus 10 random
60
+ * bits. Two orders collide only when sent in the same millisecond AND drawing the same
61
+ * random value (1/1024) — the former second-granularity timestamp + per-process counter
62
+ * collided across processes within the same second. Caller-given ids are validated
63
+ * separately (lighterClientOrderIndex).
64
+ */
65
+ export function lighterGeneratedClientOrderIndex() {
66
+ const ms = Date.now() % 2 ** 38;
67
+ const rnd = Math.floor(Math.random() * 1024);
68
+ const idx = ms * 1024 + rnd;
69
+ return idx >= 1 ? idx : 1;
70
+ }
71
+ /**
72
+ * /tx status codes (apidocs "Data Structures, Constants and Errors" → Transaction Status
73
+ * Mapping): 0 Failed · 1 Pending · 2 Executed · 3 Pending - Final State.
74
+ */
75
+ export const LIGHTER_TX_STATUS = { FAILED: 0, PENDING: 1, EXECUTED: 2, PENDING_FINAL: 3 };
76
+ /**
77
+ * Parsed `event_info` of a /tx row (JSON string on the wire), or null when absent / unparseable.
78
+ * Integers of 16+ digits (order ids such as Lighter RH's 32088147345014782 > 2^53) are kept as
79
+ * exact decimal strings — JSON.parse would round them to another order's id.
80
+ */
81
+ export function lighterEventInfo(eventInfo) {
82
+ let parsed = eventInfo;
83
+ if (typeof eventInfo === "string") {
84
+ if (eventInfo === "")
85
+ return null;
86
+ try {
87
+ parsed = JSON.parse(eventInfo.replace(/([:,[]\s*)(-?\d{16,})(?=\s*[,}\]])/g, '$1"$2"'));
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
94
+ }
95
+ /** Exact venue order id of an event_info order object's `i` (decimal string or safe integer), else null. */
96
+ export function lighterEventOrderId(i) {
97
+ if (typeof i === "string")
98
+ return /^\d+$/.test(i) && i !== "0" ? i : null;
99
+ return typeof i === "number" && Number.isSafeInteger(i) && i > 0 ? String(i) : null;
100
+ }
101
+ /**
102
+ * Classify a /tx response: 0 → rejected; 2 / 3 with executed_at > 0 → executed, unless the
103
+ * event_info carries a non-empty `ae` (app error) → rejected; 1, 2 / 3 not yet executed, a
104
+ * missing status or an unknown code → pending (keep polling; never guessed final).
105
+ */
106
+ export function lighterTxState(tx) {
107
+ const t = (typeof tx === "object" && tx !== null ? tx : {});
108
+ if (typeof t.status !== "number" || typeof t.hash !== "string")
109
+ return { kind: "pending", status: null };
110
+ const status = t.status;
111
+ if (status === LIGHTER_TX_STATUS.FAILED)
112
+ return { kind: "rejected", status, reason: "tx failed (status 0)" };
113
+ if (status === LIGHTER_TX_STATUS.EXECUTED || status === LIGHTER_TX_STATUS.PENDING_FINAL) {
114
+ if (!(Number(t.executed_at ?? 0) > 0))
115
+ return { kind: "pending", status };
116
+ const eventInfo = lighterEventInfo(t.event_info);
117
+ const ae = eventInfo?.ae;
118
+ if (typeof ae === "string" && ae !== "")
119
+ return { kind: "rejected", status, reason: ae };
120
+ return { kind: "executed", status, eventInfo };
121
+ }
122
+ return { kind: "pending", status };
123
+ }
124
+ /** Order status names by code (event_info `st`; accountOrders `status` strings). */
125
+ export const LIGHTER_ORDER_STATUS_NAMES = [
126
+ "in-progress", "pending", "open", "filled", "canceled", "canceled-post-only", "canceled-reduce-only",
127
+ "canceled-position-not-allowed", "canceled-margin-not-allowed", "canceled-too-much-slippage",
128
+ "canceled-not-enough-liquidity", "canceled-self-trade", "canceled-expired", "canceled-oco",
129
+ "canceled-child", "canceled-liquidation", "canceled-invalid-balance",
130
+ ];
131
+ /**
132
+ * Normalized outcome from a venue order status (code or name) and its filled size, or null
133
+ * when not final (0 in-progress) / unknown. 1 pending = an untriggered trigger order (it
134
+ * exists on the venue) → open.
135
+ */
136
+ export function lighterOrderOutcomeOf(st, filledSize, orderId, source) {
137
+ const name = typeof st === "number" ? LIGHTER_ORDER_STATUS_NAMES[st] : st;
138
+ if (name === undefined || !LIGHTER_ORDER_STATUS_NAMES.includes(name))
139
+ return null;
140
+ const filled = Number(filledSize) > 0;
141
+ let status;
142
+ if (name === "in-progress")
143
+ return null;
144
+ if (name === "pending" || name === "open")
145
+ status = "open";
146
+ else if (name === "filled")
147
+ status = "filled";
148
+ else
149
+ status = filled ? "partiallyFilled" : "cancelled";
150
+ return { status, final: true, venueStatus: name, filledSize, orderId, source };
151
+ }
152
+ /** event_info order status code 3 = "filled" (LIGHTER_ORDER_STATUS_NAMES[3]). */
153
+ const LIGHTER_ST_FILLED = 3;
154
+ /**
155
+ * Filled base units of an event_info order object (`to` taker / `no` modified order), proven
156
+ * ONLY when the venue status is 3 "filled" — the whole order (`is`) filled. Any other status →
157
+ * null: event_info carries no filled field, and the venue zeroes `rs` when it cancels the
158
+ * remainder (live testnet 2026-10-02: a crossing post-only came back canceled-post-only with
159
+ * is 89 / rs 0 while accountOrders reported filled_base_amount "0.000"), so is − rs is never a
160
+ * fill. Callers read accountOrders `filled_base_amount` instead (lighterRowOutcomeOf).
161
+ */
162
+ export function lighterEventFilledBase(o) {
163
+ const is = Number(o.is);
164
+ if (o.st !== LIGHTER_ST_FILLED || !Number.isSafeInteger(is) || is <= 0)
165
+ return null;
166
+ return is;
167
+ }
168
+ /**
169
+ * Outcome of an accountOrders row from the venue's own fields: `status` and `filled_base_amount`
170
+ * (a plain decimal string). A missing / malformed filled amount → null (never "0", never
171
+ * size − remaining).
172
+ */
173
+ export function lighterRowOutcomeOf(row, orderId) {
174
+ const filled = row.filled_base_amount;
175
+ if (typeof row.status !== "string" || typeof filled !== "string" || !/^\d+(\.\d+)?$/.test(filled))
176
+ return null;
177
+ return lighterOrderOutcomeOf(row.status, filled, orderId, "accountOrders");
178
+ }
179
+ /**
180
+ * Outcome of a modify (ModifyOrder, tx type 17) from its executed tx event_info: the new order
181
+ * `no` ({ i order id, u client index, is size, rs remaining, p price ticks, st status }). Final
182
+ * only when `no` is this order (i = orderId, or u = clientOrderIndex when i is not exact),
183
+ * carries the price ticks / base amount sent AND its status is "filled" (filled = is,
184
+ * lighterEventFilledBase). Any other status → null: the filled amount is read from accountOrders.
185
+ */
186
+ export function lighterModifyOutcomeOf(eventInfo, orderId, clientOrderIndex, sent, sizeDecimals) {
187
+ const no = eventInfo?.no;
188
+ if (typeof no !== "object" || no === null)
189
+ return null;
190
+ const o = no;
191
+ const is = Number(o.is);
192
+ const id = lighterEventOrderId(o.i);
193
+ const same = id !== null ? id === orderId
194
+ : clientOrderIndex !== null && clientOrderIndex > 0 && Number(o.u) === clientOrderIndex;
195
+ if (!same || Number(o.p) !== sent.priceTicks || is !== sent.baseAmount)
196
+ return null;
197
+ const filledBase = lighterEventFilledBase(o);
198
+ if (filledBase === null)
199
+ return null;
200
+ return lighterOrderOutcomeOf(LIGHTER_ST_FILLED, unitsToString(BigInt(filledBase), lighterStep(sizeDecimals)), orderId, "event_info");
201
+ }
202
+ /**
203
+ * The integer to sign a cancel / modify with, from the order's active-orders row: its client
204
+ * order index when set (non-zero, ≤ 2^48 − 1); else (client index 0, e.g. a front-end order)
205
+ * its order id — only when that decimal string is exactly representable as a JS number (the
206
+ * signer takes a number). Otherwise throws INVALID_PARAMS: never a different order, never cancel-all.
207
+ */
208
+ export function lighterOrderSignIndex(row, venue) {
209
+ const exact = (v) => {
210
+ const s = typeof v === "string" ? v : typeof v === "number" && Number.isSafeInteger(v) ? String(v) : null;
211
+ if (s === null || !/^\d+$/.test(s))
212
+ return null;
213
+ const n = Number(s);
214
+ return Number.isSafeInteger(n) && String(n) === s ? n : null;
215
+ };
216
+ const coi = exact(row.client_order_id) ?? exact(row.client_order_index);
217
+ if (coi !== null && coi > 0)
218
+ return coi;
219
+ const id = typeof row.order_id === "string" ? row.order_id : row.order_index;
220
+ const oid = exact(id);
221
+ if (oid !== null && oid > 0)
222
+ return oid;
223
+ throw new PerpError("INVALID_PARAMS", `${venue} order ${String(id)} has no client order index and its order id is not exactly representable (max 2^53 − 1); nothing was sent`, {
224
+ exchange: venue,
225
+ remediation: "Cancel it from the venue UI, or cancel every order of the market: perp trade cancel <symbol>",
226
+ });
227
+ }
228
+ /** Venue order id of an orders row as an exact string (order_id is a string on the wire; order_index a number). */
229
+ export function lighterOrderIdOf(row) {
230
+ if (typeof row.order_id === "string" && row.order_id !== "")
231
+ return row.order_id;
232
+ return row.order_index === undefined || row.order_index === null ? "" : String(row.order_index);
233
+ }
234
+ /**
235
+ * Filled base amount (integer base units) from an executed tx's `event_info` taker order `to`,
236
+ * proven only when the venue status is "filled" and its initial size equals what was sent
237
+ * (lighterEventFilledBase) → the whole sent amount. Otherwise null (a cancelled remainder
238
+ * zeroes `rs` — is − rs is never a fill): the caller reads the venue's filled amount
239
+ * (accountOrders `filled_base_amount`) or measures it. Shared by the perp and spot IOC paths.
240
+ */
241
+ export function lighterTakerFilledBase(eventInfo, sentBaseAmount) {
242
+ const parsed = lighterEventInfo(eventInfo);
243
+ const to = parsed?.to;
244
+ if (typeof to !== "object" || to === null)
245
+ return null;
246
+ const filled = lighterEventFilledBase(to);
247
+ return filled !== null && filled === sentBaseAmount ? filled : null;
248
+ }
249
+ /**
250
+ * A required open-order row field as a string (Rule #2): missing / empty / non-finite numeric →
251
+ * EXCHANGE_ERROR, never listed as price / size "0" or status "open".
252
+ */
253
+ export function lighterRowField(o, key, venue) {
254
+ const v = o[key];
255
+ if ((typeof v === "string" && v.trim() !== "") || (typeof v === "number" && Number.isFinite(v)))
256
+ return String(v);
257
+ throw new PerpError("EXCHANGE_ERROR", `${venue} open order ${lighterOrderIdOf(o) || "(no id)"}: row has no ${key} (got ${JSON.stringify(v ?? null)})`, {
258
+ exchange: venue, remediation: `Retry: perp -e ${venue} account orders`,
259
+ });
260
+ }
261
+ /** `iocOutcomeOf` parser shared by LighterAdapter and LighterSpotAdapter. */
262
+ export function lighterIocOutcomeOf(raw) {
263
+ if (typeof raw !== "object" || raw === null)
264
+ return null;
265
+ const o = raw.iocOutcome;
266
+ if (typeof o !== "object" || o === null)
267
+ return null;
268
+ const { filledSize, final } = o;
269
+ if (typeof filledSize !== "string" || final !== true)
270
+ return null;
271
+ return { filledSize, final: true };
272
+ }
273
+ /**
274
+ * An explicit non-200 `code` in a /sendTx response: the venue validated the tx and refused it
275
+ * before execution (e.g. "invalid ToAccount", "invalid nonce") — a definite rejection, nothing
276
+ * executed. Orders (place / cancel / modify / trigger) → ORDER_REJECTED; account actions
277
+ * (transfer / withdraw / leverage) → ACTION_REJECTED; a balance / margin refusal keeps its
278
+ * specific (also definite) code so shortfall handling still applies. Transport failures /
279
+ * unreadable bodies are NOT routed here (their outcome is unknown).
280
+ */
281
+ export function lighterSendTxRejection(venue, json, kind) {
282
+ const message = `${venue} sendTx rejected (code ${String(json.code)}): ${json.message ?? JSON.stringify(json)}`;
283
+ const specific = classifyError(new Error(json.message ?? "")).code;
284
+ const code = specific === "INSUFFICIENT_BALANCE" || specific === "MARGIN_INSUFFICIENT" ? specific
285
+ : kind === "order" ? "ORDER_REJECTED" : "ACTION_REJECTED";
286
+ return new PerpError(code, message, { exchange: venue, venueCode: json.code });
287
+ }
288
+ /**
289
+ * A /sendTx `code` that says the venue VALIDATED the tx and refused it (nothing executed): the
290
+ * documented validation / rate-limit ranges 20000–28999 (apidocs "Data Structures, Constants and
291
+ * Errors": 21xxx account / collateral / tx / order-book / order / asset errors, 23xxx rate limits).
292
+ * NOT definite: a missing / non-numeric code, 29xxx (29500 internal server error, 29501 process
293
+ * timeout — the tx may still execute) and anything outside the documented ranges.
294
+ */
295
+ export function lighterSendTxCodeIsDefinite(code) {
296
+ return typeof code === "number" && Number.isInteger(code) && code >= 20_000 && code <= 28_999;
297
+ }
298
+ /**
299
+ * Verdict of a parsed /sendTx body: code 200 → returns; a documented validation code → definite
300
+ * rejection (lighterSendTxRejection); any other / missing code → ORDER_PENDING (outcome unknown).
301
+ */
302
+ export function lighterSendTxVerdict(venue, json, kind, what) {
303
+ if (json.code === 200)
304
+ return;
305
+ if (lighterSendTxCodeIsDefinite(json.code))
306
+ throw lighterSendTxRejection(venue, json, kind);
307
+ throw orderOutcomeUnknown(venue, what, new Error(`sendTx answered code ${JSON.stringify(json.code ?? null)}: ${json.message ?? JSON.stringify(json)}`), kind === "order" ? undefined : unknownActionRemediation(venue, "action"));
308
+ }
309
+ /** Per-request timeout of POST /sendTx (fetch + body read). */
310
+ export const LIGHTER_SEND_TIMEOUT_MS = 15_000;
311
+ /**
312
+ * POST /sendTx (shared by the perp and spot adapters). Once the signed tx is on the wire only
313
+ * the venue's own answer settles it: no connection → not sent; a connection reset / timeout /
314
+ * HTTP 5xx / unreadable body → ORDER_PENDING; then lighterSendTxVerdict on the body.
315
+ */
316
+ export async function lighterPostSendTx(baseUrl, signed, venue, kind, what) {
317
+ const remediation = kind === "order" ? undefined : unknownActionRemediation(venue, "action");
318
+ let res;
319
+ let text;
320
+ try {
321
+ res = await fetch(`${baseUrl}/api/v1/sendTx`, {
322
+ method: "POST",
323
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
324
+ body: new URLSearchParams({ tx_type: String(signed.txType), tx_info: signed.txInfo }),
325
+ // A timeout after the send is a TimeoutError → sendFailure → ORDER_PENDING (never resubmit).
326
+ signal: AbortSignal.timeout(LIGHTER_SEND_TIMEOUT_MS),
327
+ });
328
+ text = await res.text();
329
+ }
330
+ catch (e) {
331
+ throw sendFailure(venue, what, e, remediation);
332
+ }
333
+ // A WAF challenge is the firewall's own response: the tx never reached the API. AWS WAF docs
334
+ // ("CAPTCHA and Challenge action behavior"): for a missing / invalid token it "blocks it from
335
+ // going to its intended destination" and answers "The HTTP status code `405 Method Not
336
+ // Allowed`". So this is a definite "not sent" — unlike an unreadable 2xx / 5xx body below.
337
+ const waf = lighterWafChallenge(venue, res.status, text);
338
+ if (waf !== null)
339
+ throw orderNotSent(venue, what, waf);
340
+ if (res.status >= 500) {
341
+ throw orderOutcomeUnknown(venue, what, new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`), remediation);
342
+ }
343
+ let json;
344
+ try {
345
+ json = JSON.parse(text);
346
+ }
347
+ catch {
348
+ throw orderOutcomeUnknown(venue, what, new Error(`unreadable response (HTTP ${res.status}): ${text.slice(0, 200)}`), remediation);
349
+ }
350
+ if (json === null || typeof json !== "object") {
351
+ throw orderOutcomeUnknown(venue, what, new Error(`unreadable response (HTTP ${res.status}): ${text.slice(0, 200)}`), remediation);
352
+ }
353
+ lighterSendTxVerdict(venue, json, kind, what);
354
+ return json;
355
+ }
356
+ /** created_at / timestamp of an orders row in ms (the venue reports seconds; ms / µs are normalized), or null. */
357
+ export function lighterRowCreatedMs(row) {
358
+ const v = Number(row.created_at ?? row.timestamp);
359
+ if (!Number.isFinite(v) || v <= 0)
360
+ return null;
361
+ return v < 1e11 ? v * 1000 : v < 1e14 ? v : Math.floor(v / 1000);
362
+ }
363
+ /**
364
+ * Configured leverage and margin mode of an account `positions[]` entry (GET /api/v1/account).
365
+ * Docs (apidocs.lighter.xyz/reference/account-1, PositionDetails): `initial_margin_fraction`
366
+ * "type": "string", example "20.00"; `margin_mode` "type": "integer", example 1. The fraction
367
+ * is a PERCENT on this read (live 2026-10-05: "5.00" on a 20x market, "6.66" after a 15x
368
+ * update) — the same value updateLeverage signs in basis points (`int(10_000 / leverage)`), so
369
+ * leverage = floor(10000 / bps), which returns the integer leverage that was set. margin_mode:
370
+ * 0 cross, 1 isolated (the values updateLeverage signs). Anything else throws — never a guess.
371
+ */
372
+ export function lighterPositionSettings(p, venue) {
373
+ const pct = typeof p.initial_margin_fraction === "string" && /^\d+(\.\d+)?$/.test(p.initial_margin_fraction) ? Number(p.initial_margin_fraction) : NaN;
374
+ const bps = Math.round(pct * 100);
375
+ if (!(bps > 0) || bps > 10_000) {
376
+ throw new PerpError("EXCHANGE_ERROR", `${venue} position ${String(p.symbol)}: unreadable initial_margin_fraction ${JSON.stringify(p.initial_margin_fraction)} — configured leverage unknown`, { exchange: venue });
377
+ }
378
+ if (p.margin_mode !== 0 && p.margin_mode !== 1) {
379
+ throw new PerpError("EXCHANGE_ERROR", `${venue} position ${String(p.symbol)}: margin_mode is ${JSON.stringify(p.margin_mode)} (expected 0 cross | 1 isolated)`, { exchange: venue });
380
+ }
381
+ return { leverage: Math.floor(10_000 / bps), marginMode: p.margin_mode === 1 ? "isolated" : "cross" };
382
+ }
383
+ /** Clock-skew allowance when requiring an order row to be created after the send (client vs venue clock). */
384
+ export const LIGHTER_ROW_SKEW_MS = 120_000;
385
+ /**
386
+ * The account to use from a `/account?by=l1_address` listing. A known account index → that
387
+ * account or null (never another one). Unknown index: the only account; with several, the single
388
+ * main account (account_type 0); otherwise INVALID_PARAMS — never "the first one".
389
+ */
390
+ export function lighterSelectAccount(accounts, accountIndex, venue) {
391
+ const list = Array.isArray(accounts) ? accounts : [];
392
+ if (accountIndex >= 0)
393
+ return list.find(a => a.account_index === accountIndex || a.index === accountIndex) ?? null;
394
+ if (list.length <= 1)
395
+ return list[0] ?? null;
396
+ const mains = list.filter(a => a.account_type === 0);
397
+ if (mains.length === 1)
398
+ return mains[0];
399
+ throw new PerpError("INVALID_PARAMS", `${venue}: the wallet has ${list.length} accounts (${list.map(a => String(a.account_index)).join(", ")}) and none is uniquely the main account; refusing to pick one`, {
400
+ exchange: venue, remediation: `Register an agent for the account you want: perp wallet agent approve ${venue}`,
401
+ });
402
+ }
403
+ /**
404
+ * On-disk lifetime of the perp market list (symbol → market id, size / price decimals) read from
405
+ * `GET /api/v1/orderBooks`, shared by every CLI process of the same venue + network (cache.ts).
406
+ * Public static metadata only — account state, orders, positions, balances and nonces are never
407
+ * cached here. It keeps the list off the per-invocation request budget (apidocs.lighter.xyz
408
+ * rate-limits: "Standard Accounts: 60 requests per rolling minute"). A market listed inside this
409
+ * window is unknown (SYMBOL_NOT_FOUND) until the entry expires.
410
+ */
411
+ export const LIGHTER_MARKET_LIST_TTL_MS = 5 * 60_000;
28
412
  export class LighterAdapter {
29
- name = "lighter";
30
- chain = "ethereum";
31
- aliases = ["lt"];
413
+ /** GTC→GTT resting-order lifetime (28 days) for the execution record (§13.10 #14). */
414
+ static gttExpiryMs = LIGHTER_GTT_EXPIRY_MS;
415
+ /** Venue id ("lighter" | "lighter-rh"). */
416
+ name;
417
+ venueId;
418
+ family = "lighter";
419
+ /** Settlement chain of the venue network ("ethereum" for Lighter, "robinhood" for Lighter RH). */
420
+ chain;
421
+ aliases;
422
+ networkName;
423
+ _network;
32
424
  _signer;
33
425
  _accountIndex = -1;
426
+ /**
427
+ * Account trading mode (`account_trading_mode`: Classic=0, Unified=1 — lighter-python
428
+ * models/account.py; docs.lighter.xyz "Unified Trading Accounts"). Unified: spot and perp
429
+ * share the USDC margin, so there is no spot↔perp transfer. Read by
430
+ * resolveAccountTradingMode() (LighterSpotAdapter.init); null = not read yet.
431
+ */
432
+ _tradingMode = null;
34
433
  _apiKeyIndex;
35
434
  _address;
36
435
  _marketMap = new Map(); // symbol → marketIndex
37
436
  _marketDecimals = new Map(); // symbol → decimals
38
- _clientOrderCounter = 0;
39
437
  _evmKey;
40
438
  _apiKey;
41
439
  _accountIndexInit;
@@ -58,25 +456,87 @@ export class LighterAdapter {
58
456
  /** --no-agent bypass flag. */
59
457
  _useNoAgent = false;
60
458
  /**
459
+ * Legacy constructor: this signature DEFINES the Lighter (main) venue — it is
460
+ * not a default for other venues. Lighter RH (and any explicit venue) is
461
+ * built with `LighterAdapter.forVenue()`, which passes `venue`.
462
+ *
61
463
  * @param evmKey EVM private key (0x-prefixed, 32 bytes) — for deposits & key registration
62
- * @param testnet Use testnet (chain ID 300) instead of mainnet (chain ID 304)
63
- * @param opts Optional: apiKey (40-byte Lighter signing key), accountIndex
464
+ * @param testnet Use the venue testnet instead of mainnet
465
+ * @param opts Optional: apiKey (40-byte Lighter signing key), accountIndex, apiKeyIndex
466
+ * @param venue @internal — set only by `forVenue()`; absent means the Lighter venue by definition
64
467
  */
65
- constructor(evmKey, testnet = false, opts) {
468
+ constructor(evmKey, testnet = false, opts, venue) {
469
+ const venueId = venue === undefined ? "lighter" : venue;
470
+ this.name = venueId;
471
+ this.venueId = venueId;
472
+ this.networkName = testnet ? "testnet" : "mainnet";
473
+ this._network = lighterFamilyNetworks(venueId)[this.networkName];
474
+ this.chain = this._network.settlementChain;
475
+ // Adapter-level short aliases (legacy surface). Venue aliases live in the venue definition.
476
+ this.aliases = venueId === "lighter" ? ["lt"] : [];
477
+ if (opts?.apiKeyIndex !== undefined)
478
+ LighterAdapter._assertKeyIndexAllowed(venueId, opts.apiKeyIndex);
66
479
  this._evmKey = evmKey;
67
- // SSOT Rule #3: L2 API key is no longer read from env. Caller must inject
68
- // via opts.apiKey (Tier 1 agent path uses setAgentSigner; auto-setup at
69
- // init() runs ChangePubKey when apiKey is empty AND we have an EVM key).
480
+ // SSOT Rule #3: L2 API key is never read from env. Caller injects it via
481
+ // opts.apiKey or setAgentSigner (Tier 1 agent path). init() never registers a
482
+ // key implicitly — without one the adapter stays read-only; slot keys are
483
+ // registered only by `perp wallet agent approve <venue>`.
70
484
  this._apiKey = opts?.apiKey ?? "";
71
485
  this._accountIndexInit = opts?.accountIndex ?? -1;
72
486
  this._apiKeyIndex = opts?.apiKeyIndex ?? 4;
73
487
  this._address = "";
74
488
  this._testnet = testnet;
75
489
  this._readOnly = !this._apiKey;
76
- this._baseUrl = testnet
77
- ? (process.env.LIGHTER_TESTNET_URL || "https://testnet.zklighter.elliot.ai")
78
- : "https://mainnet.zklighter.elliot.ai";
79
- this._chainId = testnet ? 300 : 304;
490
+ // LIGHTER_TESTNET_URL is a pre-existing operator override for the Lighter
491
+ // (main) testnet host only; it is kept to preserve that behavior and does
492
+ // not apply to Lighter RH.
493
+ const envTestnetUrl = process.env.LIGHTER_TESTNET_URL;
494
+ this._baseUrl = venueId === "lighter" && testnet && envTestnetUrl
495
+ ? envTestnetUrl
496
+ : this._network.restUrl;
497
+ if (this._network.signingChainId === null) {
498
+ throw new PerpError("INVALID_PARAMS", `${venueId} ${this.networkName} has no signing chain id`);
499
+ }
500
+ this._chainId = this._network.signingChainId;
501
+ }
502
+ /** Construct an adapter for an explicit Lighter-family venue. */
503
+ static forVenue(venue, evmKey, testnet, opts) {
504
+ return new LighterAdapter(evmKey, testnet, opts, venue);
505
+ }
506
+ /** Network config of this adapter's venue + network (SSOT: venues/lighter-family.ts). */
507
+ get network() {
508
+ return this._network;
509
+ }
510
+ /** Collateral symbol of this venue on its network ("USDC" on Lighter, "USDG" on Lighter RH mainnet, "USDC" on RH testnet). */
511
+ get collateralSymbol() {
512
+ const sym = this._network.collateral[0];
513
+ if (!sym)
514
+ throw new PerpError("INVALID_PARAMS", `${this.venueId} network config has no collateral`);
515
+ return sym;
516
+ }
517
+ /** Lighter asset index of the collateral (3 on both venues). */
518
+ get collateralAssetIndex() {
519
+ const idx = this._network.collateralAssetIndex;
520
+ if (idx === null)
521
+ throw new PerpError("INVALID_PARAMS", `${this.venueId} network config has no collateral asset index`);
522
+ return idx;
523
+ }
524
+ /**
525
+ * Cache key prefix for account reads shared with LighterSpotAdapter.
526
+ * "lt" is the historical Lighter prefix (kept so existing cache keys are unchanged);
527
+ * other venues use their venue id so accounts never collide across venues.
528
+ */
529
+ get accountCachePrefix() {
530
+ // Network is part of the key: the same L1 address has a mainnet and a testnet account.
531
+ return `${this.venueId === "lighter" ? "lt" : this.venueId}:${this.networkName}`;
532
+ }
533
+ /** Lighter RH reserves API key indexes {0,1,2,3,157} for its own interfaces. */
534
+ static _assertKeyIndexAllowed(venue, apiKeyIndex) {
535
+ if (venue === "lighter-rh" && LIGHTER_RH_RESERVED_KEY_INDEXES.includes(apiKeyIndex)) {
536
+ throw new PerpError("INVALID_PARAMS", `Lighter RH API key index ${apiKeyIndex} is reserved (reserved: ${LIGHTER_RH_RESERVED_KEY_INDEXES.join(", ")})`, {
537
+ remediation: "Use an API key index in 4–254 other than 157 (e.g. 4).",
538
+ });
539
+ }
80
540
  }
81
541
  get signer() {
82
542
  const self = this;
@@ -84,7 +544,7 @@ export class LighterAdapter {
84
544
  return Object.create(this._signer, {
85
545
  createAuthToken: {
86
546
  value: async (deadline) => {
87
- const token = await self._signer.createAuthToken(deadline, self._apiKeyIndex, self._accountIndex);
547
+ const token = self._signer.createAuthToken(deadline, self._apiKeyIndex, self._accountIndex);
88
548
  return { authToken: token };
89
549
  },
90
550
  },
@@ -100,6 +560,20 @@ export class LighterAdapter {
100
560
  get isReadOnly() {
101
561
  return this._readOnly;
102
562
  }
563
+ /**
564
+ * Sign an unsigned L1 transaction with the master wallet (on-chain deposit txs); the
565
+ * digest is derived from the tx inside the signer. Only an OWS master signer can sign
566
+ * L1 txs; anything else → NO_SIGNER_AVAILABLE.
567
+ */
568
+ async signL1Transaction(tx) {
569
+ const { OwsEvmSigner } = await import("../signer/ows-evm.js");
570
+ if (!(this._evmSigner instanceof OwsEvmSigner)) {
571
+ throw new PerpError("NO_SIGNER_AVAILABLE", `${this.venueId} on-chain deposit needs the OWS master wallet (no master signer loaded)`, {
572
+ remediation: `Run with the master wallet: perp -e ${this.venueId} --ows <wallet> ...`,
573
+ });
574
+ }
575
+ return this._evmSigner.signTransaction(tx);
576
+ }
103
577
  /** Inject an external EVM signer. Call before init() to skip LocalEvmSigner creation. */
104
578
  setSigner(signer) {
105
579
  this._evmSigner = signer;
@@ -112,6 +586,33 @@ export class LighterAdapter {
112
586
  this._accountIndexInit = -1;
113
587
  this._accountIndex = -1;
114
588
  }
589
+ /**
590
+ * Set the account owner's L1 address without attaching a signer — for an agent whose master
591
+ * wallet is not on this machine (API key imported from the website). Account reads are keyed
592
+ * by this address; nothing L1 can be signed (signL1Transaction / setupApiKey still refuse).
593
+ */
594
+ setAddress(address) {
595
+ this._address = address;
596
+ }
597
+ /**
598
+ * Prove the loaded agent API key is the one registered at its slot: sign an auth token with it
599
+ * and make one authenticated read (`GET /api/v1/accountActiveOrders`). Lighter validates the
600
+ * token against the public key registered at (account_index, api_key_index), so a key that does
601
+ * not match the slot is refused. Read-only — nothing is sent. Throws on any refusal.
602
+ */
603
+ async assertAgentKeyAccepted() {
604
+ this.ensureSigner();
605
+ const marketId = this._marketMap.values().next().value;
606
+ if (marketId === undefined)
607
+ throw new Error(`${this.name}: no markets loaded — cannot run the authenticated check`);
608
+ const res = await this.restGetAuth("/accountActiveOrders", {
609
+ account_index: String(this._accountIndex),
610
+ market_id: String(marketId),
611
+ });
612
+ if (res && typeof res.code === "number" && res.code !== 200) {
613
+ throw new Error(`${this.name} refused the API key's auth token (code ${res.code}: ${String(res.message ?? "")})`);
614
+ }
615
+ }
115
616
  /**
116
617
  * Tier 1: inject a Lighter agent's slot+L2 key (Phase 2d).
117
618
  *
@@ -126,6 +627,8 @@ export class LighterAdapter {
126
627
  * hot-path; only the L2 secp256k1 key is consulted.
127
628
  */
128
629
  setAgentSigner(meta, agentApiKey) {
630
+ if (typeof meta.apiKeyIndex === "number")
631
+ LighterAdapter._assertKeyIndexAllowed(this.venueId, meta.apiKeyIndex);
129
632
  this._agentMeta = meta;
130
633
  this._agentApiKey = agentApiKey;
131
634
  // Reset any prior accountIndex hint: the agent may belong to a different
@@ -159,34 +662,71 @@ export class LighterAdapter {
159
662
  * Tier 3 (pk): active when constructor was supplied an evmKey. Same role
160
663
  * as Tier 2 — signs L1 ChangePubKey for auto-setup.
161
664
  *
162
- * Lighter is unique among the 4 DEXs in that the actual order-signing is
665
+ * Lighter is unique among the venues in that the actual order-signing is
163
666
  * always done by the L2 secp256k1 key (`_apiKey`), regardless of tier. The
164
667
  * tiers differ in WHICH L2 key is loaded: Tier 1 uses the agent's slot/key
165
668
  * from AgentMeta; Tier 2/3 use whatever the env/auto-setup produced.
166
669
  */
167
670
  _resolveSigner() {
168
671
  if (!this._useNoAgent && this._agentMeta && this._agentApiKey) {
169
- if (isExpired(this._agentMeta)) {
170
- // Skip Tier 1 only if Tier 2/3 available; otherwise throw AGENT_EXPIRED
171
- if (!this._evmSigner && !this._evmKey) {
172
- throw new PerpError("AGENT_EXPIRED", "Agent wallet has expired", {
173
- remediation: "perp wallet agent approve lighter --rotate",
174
- });
175
- }
176
- // fall through
177
- }
178
- else {
179
- return { tier: "agent" };
180
- }
672
+ // An expired agent record is an explicit error — never a silent switch to the master
673
+ // signer (as on Hyperliquid / Pacifica); --no-agent is the opt-in.
674
+ if (isExpired(this._agentMeta))
675
+ throw this.agentExpired(this._agentMeta);
676
+ return { tier: "agent" };
181
677
  }
182
678
  if (this._evmSigner)
183
679
  return { tier: "master" };
184
680
  if (this._evmKey)
185
681
  return { tier: "pk" };
186
- throw new PerpError("NO_SIGNER_AVAILABLE", "No signing path configured for Lighter", {
187
- remediation: "Run one of: (a) perp wallet agent approve lighter --master <wallet>; (b) perp wallet generate && perp -e lighter --ows <wallet>; (c) export LIGHTER_PRIVATE_KEY=0x...",
682
+ // Lighter RH master key is OWS-only (design §10.3): no env-key remediation for it.
683
+ const remediation = this.venueId === "lighter"
684
+ ? "Run one of: (a) perp wallet agent approve lighter --master <wallet>; (b) perp wallet generate && perp -e lighter --ows <wallet>; (c) export LIGHTER_PRIVATE_KEY=0x..."
685
+ : `Run one of: (a) perp wallet agent approve ${this.name} --master <wallet>; (b) perp wallet generate && perp -e ${this.name} --ows <wallet>`;
686
+ throw new PerpError("NO_SIGNER_AVAILABLE", `No signing path configured for ${this.venueId === "lighter" ? "Lighter" : "Lighter RH"}`, { remediation });
687
+ }
688
+ agentExpired(meta) {
689
+ return new PerpError("AGENT_EXPIRED", `${this.name} agent API key (slot ${String(meta.apiKeyIndex)}) expired at ${meta.expiresAt}`, {
690
+ exchange: this.name,
691
+ remediation: `Re-approve the agent: perp wallet agent approve ${this.name} --rotate — or run this command without the agent: --no-agent`,
692
+ });
693
+ }
694
+ /**
695
+ * true = Unified Trading Account (spot shares perp margin), false = Classic. Throws when the
696
+ * mode was not read or the read failed — never assumes either mode (SSOT Rule #2).
697
+ */
698
+ get isUnifiedAccount() {
699
+ const m = this._tradingMode;
700
+ if (m !== null && "unified" in m)
701
+ return m.unified;
702
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} account trading mode unknown: ${m === null ? "not read" : m.error}`, {
703
+ exchange: this.name,
704
+ remediation: `Retry; check the account with: perp -e ${this.name} account info`,
188
705
  });
189
706
  }
707
+ /**
708
+ * Read `account_trading_mode` from the account (shared `acct:` cache). A failed or invalid
709
+ * read is kept and re-thrown by isUnifiedAccount, so callers that depend on the mode fail
710
+ * while public spot reads (markets, books) keep working.
711
+ */
712
+ async resolveAccountTradingMode() {
713
+ if (!this._address) {
714
+ this._tradingMode = { error: "no account address configured" };
715
+ return;
716
+ }
717
+ try {
718
+ const acct = await this.fetchAccount();
719
+ if (acct === null)
720
+ throw new Error(`no account found for ${this._address}`);
721
+ const mode = acct.account_trading_mode;
722
+ if (mode !== 0 && mode !== 1)
723
+ throw new Error(`account_trading_mode is ${JSON.stringify(mode)} (expected 0 classic | 1 unified)`);
724
+ this._tradingMode = { unified: mode === 1 };
725
+ }
726
+ catch (e) {
727
+ this._tradingMode = { error: extractErrorMessage(e) };
728
+ }
729
+ }
190
730
  async init() {
191
731
  // Phase 2d: when an agent is registered (Tier 1), swap _apiKey/_apiKeyIndex
192
732
  // to the agent's slot/key BEFORE any of the existing init logic runs. This
@@ -221,61 +761,44 @@ export class LighterAdapter {
221
761
  this._accountIndex = this._accountIndexInit;
222
762
  }
223
763
  else {
224
- // Retry account lookup on 429 (rate limit) — critical for _accountIndex
225
- for (let attempt = 0; attempt < 3; attempt++) {
226
- try {
227
- const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
228
- if (res.status === 429 && attempt < 2) {
229
- await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
230
- continue;
231
- }
232
- const json = await res.json();
233
- if (json.accounts && json.accounts.length > 0) {
234
- this._accountIndex = json.accounts[0].account_index;
235
- }
236
- break;
237
- }
238
- catch {
239
- if (attempt < 2) {
240
- await new Promise(r => setTimeout(r, 2000));
241
- continue;
242
- }
243
- }
244
- }
245
- }
246
- // Auto-generate API key if we have PK but no API key and account exists
247
- let signerReady = false;
248
- if (!this._apiKey && this._accountIndex >= 0) {
764
+ // One live read. A wallet without an account answers HTTP 400 {"code":21100,"message":"account
765
+ // not found"} (no `accounts`) → the adapter stays read-only; a rate limit or transport
766
+ // failure propagates (it is never read as "no account").
767
+ await this.rhRate("/account");
768
+ const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
769
+ const body = await res.text();
770
+ if (res.status === 429)
771
+ throw rateLimitedError(this.name, res.headers.get("retry-after"), body);
772
+ const waf = lighterWafChallenge(this.name, res.status, body);
773
+ if (waf !== null)
774
+ throw waf;
775
+ const json = JSON.parse(body);
776
+ let picked = null;
249
777
  try {
250
- const autoKeyIndex = 4; // default for auto-setup (0-3 reserved by Lighter frontend)
251
- const { privateKey: apiKey } = await this.setupApiKey(autoKeyIndex);
252
- this._apiKey = apiKey;
253
- this._apiKeyIndex = autoKeyIndex;
254
- // setupApiKey already configured the static WASM client — reuse it
255
- this._signer = LighterAdapter._wasmClient;
256
- signerReady = true;
257
- // SSOT Rule #3: persist L2 key to encrypted keystore so subsequent
258
- // process invocations can reload it without re-running ChangePubKey.
259
- try {
260
- const { saveLighterKey } = await import("../agent-wallet/lighter-keystore.js");
261
- saveLighterKey(this._accountIndex, autoKeyIndex, apiKey, "");
262
- }
263
- catch { /* non-critical — keystore save may fail in some contexts */ }
778
+ picked = lighterSelectAccount(json.accounts, -1, this.name);
264
779
  }
265
780
  catch (e) {
266
- // Auto-setup failed — log the error and continue in read-only mode
267
- const msg = e instanceof Error ? e.message : String(e);
268
- console.error(`[lighter] API key auto-setup failed: ${msg}. Trading will be read-only. Run 'perp wallet agent approve lighter' to register an agent.`);
781
+ // Several accounts and no unique main one: stay read-only (never the first one).
782
+ console.error(`[lighter] ${e instanceof Error ? e.message : String(e)}`);
783
+ }
784
+ if (picked !== null && typeof picked.account_index === "number") {
785
+ this._accountIndex = picked.account_index;
786
+ // The command's own account read (balance / positions) reuses this response.
787
+ setCached(this.accountCacheKey, picked, TTL_ACCOUNT);
269
788
  }
270
789
  }
790
+ // No implicit key registration (both Lighter-family venues): adapter creation also
791
+ // backs read-only paths (portfolio, risk, balances), which must never sign. A
792
+ // master-key init without an API key stays read-only; trading needs an explicit
793
+ // `perp wallet agent approve <venue>` (which registers + persists the slot key).
271
794
  // Initialize signer for trading if we have an API key (reuse singleton WASM client)
272
- if (this._apiKey && !signerReady) {
795
+ if (this._apiKey) {
273
796
  // Clean & validate API key before passing to WASM (Go hex decoder gives cryptic errors on non-hex)
274
797
  let cleanKey = this._apiKey.trim().replace(/['"` \t\n\r.]/g, "");
275
798
  if (cleanKey.startsWith("0x"))
276
799
  cleanKey = cleanKey.slice(2);
277
800
  if (cleanKey.length > 0 && !/^[0-9a-fA-F]+$/.test(cleanKey)) {
278
- console.error(`[lighter] API key contains invalid chars (expected hex). Run 'perp wallet agent approve lighter' to register an agent.`);
801
+ console.error(`[lighter] API key contains invalid chars (expected hex). Run 'perp wallet agent approve ${this.name}' to register an agent.`);
279
802
  this._apiKey = "";
280
803
  }
281
804
  else if (cleanKey.length === 0) {
@@ -283,14 +806,13 @@ export class LighterAdapter {
283
806
  }
284
807
  else if (this._accountIndex < 0) {
285
808
  // Account lookup failed or returned no accounts — cannot initialize WASM signer
286
- console.error(`[lighter] Account index not available (got ${this._accountIndex}). Trading will be read-only. Ensure the wallet has a Lighter account, or run 'perp wallet agent approve lighter' to register an agent.`);
809
+ console.error(`[lighter] Account index not available (got ${this._accountIndex}). Trading will be read-only. Ensure the wallet has a Lighter account, or run 'perp wallet agent approve ${this.name}' to register an agent.`);
287
810
  this._apiKey = "";
288
811
  }
289
812
  else {
290
813
  this._apiKey = cleanKey;
291
- const client = await LighterAdapter.getWasmClient();
292
- await client.createClient({
293
- url: this._baseUrl,
814
+ const client = await this._getWasmClient();
815
+ client.createClient({
294
816
  privateKey: this._apiKey,
295
817
  chainId: this._chainId,
296
818
  apiKeyIndex: this._apiKeyIndex,
@@ -303,100 +825,374 @@ export class LighterAdapter {
303
825
  // Build symbol → marketIndex map + decimals from orderBookDetails
304
826
  await this._refreshMarketMap();
305
827
  }
306
- /** Populate marketMap + decimals from /orderBooks API (safe to call multiple times) */
828
+ /**
829
+ * Populate the perp market map + decimals: ONE `GET /api/v1/orderBooks` (it carries
830
+ * supported_size_decimals / supported_price_decimals for every market), served from the on-disk
831
+ * market-list cache while fresh (LIGHTER_MARKET_LIST_TTL_MS). A failed read (429, transport,
832
+ * no / empty list) throws the real error — never an empty map, which every later symbol lookup
833
+ * would report as SYMBOL_NOT_FOUND. No second endpoint and no retry: each costs a request of
834
+ * the 60 / minute budget.
835
+ */
307
836
  async _refreshMarketMap() {
308
- // Retry up to 2 times with 1s delay on failure
309
- for (let attempt = 0; attempt < 2; attempt++) {
310
- try {
311
- // Prefer /orderBooks which has supported_size_decimals / supported_price_decimals
312
- const res = await this.restGet("/orderBooks", {});
313
- const books = res.order_books ?? [];
314
- if (books.length === 0 && attempt === 0) {
315
- // Empty response (possible rate limit) — retry after delay
316
- await new Promise(r => setTimeout(r, 1000));
317
- continue;
318
- }
319
- for (const d of books) {
320
- const sym = d.symbol.toUpperCase();
321
- // Skip spot markets (id ≥ 2048) — those are handled by lighter-spot.ts
322
- if (d.market_id >= 2048)
323
- continue;
324
- this._marketMap.set(sym, d.market_id);
325
- this._marketDecimals.set(sym, {
326
- size: d.supported_size_decimals,
327
- price: d.supported_price_decimals,
328
- });
329
- }
330
- if (this._marketMap.size > 0)
331
- return; // success
332
- }
333
- catch {
334
- // /orderBooks failed — try fallback below
837
+ const perps = await withCache(`mkt:${this.venueId}:${this.networkName}:orderBooks`, LIGHTER_MARKET_LIST_TTL_MS, async () => {
838
+ const res = await this.restGet("/orderBooks", {});
839
+ if (!Array.isArray(res.order_books) || res.order_books.length === 0) {
840
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks returned no order_books list — the market list is unknown`, {
841
+ exchange: this.name, remediation: `Retry; check the venue status: perp -e ${this.name} market list`,
842
+ });
335
843
  }
336
- // Fallback to /orderBookDetails
337
- try {
338
- const res = await this.restGet("/orderBookDetails", {});
339
- for (const d of res.order_book_details ?? []) {
340
- this._marketMap.set(d.symbol.toUpperCase(), d.market_id);
341
- this._marketDecimals.set(d.symbol.toUpperCase(), {
342
- size: d.size_decimals ?? 0,
343
- price: d.price_decimals ?? 0,
344
- });
844
+ const rows = [];
845
+ for (const d of res.order_books) {
846
+ // Perps only, by the venue's own market_type (spot markets are handled by
847
+ // lighter-spot.ts). Market ids are not a type: testnet perps are 4095–4097 and
848
+ // Lighter RH testnet perps 2048/2049.
849
+ if (d.market_type !== "perp")
850
+ continue;
851
+ if (typeof d.symbol !== "string" || !Number.isInteger(d.market_id) || !Number.isInteger(d.supported_size_decimals) || !Number.isInteger(d.supported_price_decimals)) {
852
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks: perp market row without symbol / market_id / decimals: ${JSON.stringify(d).slice(0, 160)}`, { exchange: this.name });
345
853
  }
346
- if (this._marketMap.size > 0)
347
- return; // success
854
+ rows.push({ symbol: d.symbol.toUpperCase(), marketId: d.market_id, size: d.supported_size_decimals, price: d.supported_price_decimals });
348
855
  }
349
- catch {
350
- // Both APIs failed this attempt
351
- }
352
- // Delay before retry
353
- if (attempt === 0)
354
- await new Promise(r => setTimeout(r, 1000));
856
+ return rows;
857
+ });
858
+ for (const m of perps) {
859
+ this._marketMap.set(m.symbol, m.marketId);
860
+ this._marketDecimals.set(m.symbol, { size: m.size, price: m.price });
355
861
  }
356
862
  }
357
863
  getMarketIndex(symbol) {
358
864
  const idx = this._marketMap.get(symbol.toUpperCase());
359
865
  if (idx === undefined)
360
- throw new Error(`Unknown Lighter market: ${symbol}`);
866
+ throw new PerpError("SYMBOL_NOT_FOUND", `Unknown Lighter market: ${symbol}`, { exchange: this.name });
361
867
  return idx;
362
868
  }
869
+ /**
870
+ * Lot step of a perp market from the loaded market list (size decimals of /orderBooks) —
871
+ * precision.lotStepOf uses it instead of a full getMarkets() read (/orderBookDetails +
872
+ * /funding-rates, two requests an order placement does not need).
873
+ */
874
+ lotStepOf(symbol) {
875
+ this.getMarketIndex(symbol);
876
+ const dec = this._marketDecimals.get(symbol.toUpperCase());
877
+ if (!dec)
878
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${symbol}: no size decimals loaded — lot step unknown`, { exchange: this.name });
879
+ return lighterStep(dec.size);
880
+ }
363
881
  /** Ensure market map is populated; lazy-refresh if empty */
364
882
  async ensureMarketMap() {
365
883
  if (this._marketMap.size === 0) {
366
884
  await this._refreshMarketMap();
367
885
  }
368
886
  }
369
- async getMarkPrice(symbol) {
887
+ /**
888
+ * Reference price for slippage-bounded orders — the ONE declared source
889
+ * (LIGHTER_REFERENCE_PRICE_SOURCE): mid of best bid / best ask from
890
+ * `/orderBookOrders`. A missing side or unreadable book → PRICE_STALE (no substitute).
891
+ */
892
+ async getReferencePrice(symbol) {
893
+ const marketId = this.getMarketIndex(symbol);
894
+ let res;
370
895
  try {
371
- const res = await this.restGet("/orderBookDetails", {});
372
- const m = res.order_book_details?.find(d => d.symbol.toUpperCase() === symbol.toUpperCase());
373
- const price = m?.last_trade_price ?? 0;
374
- if (price <= 0)
375
- throw new Error(`Cannot determine mark price for ${symbol}`);
376
- return price;
896
+ res = await this.restGet("/orderBookOrders", { market_id: String(marketId), limit: "20" });
377
897
  }
378
898
  catch (e) {
379
- if (e instanceof Error && e.message.startsWith("Cannot determine"))
380
- throw e;
381
- throw new Error(`Cannot determine mark price for ${symbol}`);
899
+ throw new PerpError("PRICE_STALE", `Cannot read ${this.name} orderbook for ${symbol} reference price: ${e instanceof Error ? e.message : String(e)}`, { exchange: this.name });
900
+ }
901
+ const prices = (rows) => (rows ?? []).map(r => Number(r.price)).filter(p => Number.isFinite(p) && p > 0);
902
+ const bids = prices(res.bids);
903
+ const asks = prices(res.asks);
904
+ if (bids.length === 0 || asks.length === 0) {
905
+ throw new PerpError("PRICE_STALE", `${this.name} ${symbol} orderbook has no ${bids.length === 0 ? "bids" : "asks"} — no reference price (orderbook mid)`, { exchange: this.name });
906
+ }
907
+ return (Math.max(...bids) + Math.min(...asks)) / 2;
908
+ }
909
+ /**
910
+ * Protection price = reference ± slippage bps, aligned to the price tick toward
911
+ * crossing (buy → up, sell → down; precision.alignProtectionPrice), as signer ticks.
912
+ */
913
+ protectionFor(symbol, side, reference, slippage, referenceSource) {
914
+ const dec = this._marketDecimals.get(symbol.toUpperCase());
915
+ if (!dec) {
916
+ throw new PerpError("ORDER_REJECTED", `No market decimals loaded for ${symbol}. Market data may not be initialized.`, { exchange: this.name });
917
+ }
918
+ const tick = lighterStep(dec.price);
919
+ const protectionPrice = alignProtectionPrice(slippagePrice(String(reference), side, slippage.bps), tick, side);
920
+ const priceTicks = Number(toUnitsExact(protectionPrice, tick, "protection price"));
921
+ const appliedBps = Math.round(Math.abs(Number(protectionPrice) / reference - 1) * 10_000 * 100) / 100;
922
+ return {
923
+ priceTicks,
924
+ protection: {
925
+ referenceSource, referencePrice: String(reference), protectionPrice,
926
+ slippageBps: slippage.bps, slippageSource: slippage.source, appliedBps,
927
+ },
928
+ };
929
+ }
930
+ /** Validated caller client order index, or a generated one. */
931
+ clientOrderIndexFor(given) {
932
+ if (given === undefined)
933
+ return this.nextClientOrderIndex();
934
+ return lighterClientOrderIndex(String(given));
935
+ }
936
+ /**
937
+ * /tx poll attempts (500 ms apart). Testnet sequencing is slower, so its window is 3× longer.
938
+ * Public: LighterSpotAdapter polls with the same window.
939
+ */
940
+ txPollAttempts(base) {
941
+ return this._testnet ? base * 3 : base;
942
+ }
943
+ /**
944
+ * Poll /tx by hash until the tx is executed or rejected (lighterTxState), or the poll
945
+ * window ends → `{ kind: "pending" }`. A tx not indexed yet (HTTP 400 "transaction not
946
+ * found") or a transient read failure polls again.
947
+ */
948
+ async pollTx(txHash, attempts) {
949
+ let last = { kind: "pending", status: null };
950
+ for (let attempt = 0; attempt < attempts; attempt++) {
951
+ await new Promise(r => setTimeout(r, 500));
952
+ let tx;
953
+ try {
954
+ tx = await this.restGet("/tx", { by: "hash", value: txHash });
955
+ }
956
+ catch (e) {
957
+ if (e instanceof PerpError && e.structured.code === "RATE_LIMITED") {
958
+ // HTTP 429: polling on every 500 ms would only extend the breach. Honour Retry-After
959
+ // when it fits in the remaining window, else stop — the caller reports the tx pending.
960
+ const retryAfterMs = e.structured.details?.retryAfterMs;
961
+ const remainingMs = (attempts - attempt - 1) * 500;
962
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0 && retryAfterMs <= remainingMs) {
963
+ await new Promise(r => setTimeout(r, retryAfterMs));
964
+ attempt += Math.ceil(retryAfterMs / 500);
965
+ continue;
966
+ }
967
+ console.error(`[${this.name}] Warning: /tx poll of ${txHash} rate limited (HTTP 429${typeof retryAfterMs === "number" ? `, Retry-After ${retryAfterMs} ms` : ""}) — polling stopped, outcome not confirmed`);
968
+ return last;
969
+ }
970
+ continue;
971
+ }
972
+ last = lighterTxState(tx);
973
+ if (last.kind !== "pending")
974
+ return last;
975
+ }
976
+ return last;
977
+ }
978
+ /**
979
+ * Definite IOC outcome (§13.10 #4) from the executed tx: poll /tx by hash. A taker order the
980
+ * venue reports "filled" (event_info `to.st` 3, `is` = sent) filled the whole order. Any other
981
+ * status (the remainder was cancelled — the venue zeroes `rs`, so is − rs is NOT a fill) →
982
+ * the venue's own `filled_base_amount` from accountOrders by client order index. Zero fill →
983
+ * ORDER_REJECTED; positive fill → the raw result carrying `iocOutcome` (final). Executed =
984
+ * status 2 / 3 with executed_at > 0 (lighterTxState); 1 Pending keeps polling. Status 0 or a
985
+ * non-empty event_info `ae` → ORDER_REJECTED. /tx not settled within the poll window → the
986
+ * accountOrders terminal row still settles it. No terminal row either → raw result without
987
+ * `iocOutcome` (outcome unknown; iocOutcomeOf() returns null).
988
+ */
989
+ async awaitIocOutcome(sendResult, symbol, sentBase, what, extra, match) {
990
+ const base = { ...sendResult, ...extra };
991
+ const txHash = sendResult.tx_hash;
992
+ if (typeof txHash !== "string" || txHash === "")
993
+ return base;
994
+ const dec = this._marketDecimals.get(symbol.toUpperCase());
995
+ const state = await this.pollTx(txHash, this.txPollAttempts(10));
996
+ if (state.kind === "rejected") {
997
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: tx rejected (status=${state.status}, ${state.reason}, hash=${txHash})`, { exchange: this.name, txHash, ...extra });
998
+ }
999
+ if (dec) {
1000
+ const step = lighterStep(dec.size);
1001
+ let filledBase = state.kind === "executed" ? lighterTakerFilledBase(state.eventInfo, sentBase) : null;
1002
+ let source = "event_info";
1003
+ if (filledBase === null) {
1004
+ // IOC: only a terminal row settles it. The venue's own terminal row (filled / canceled-*,
1005
+ // filled_base_amount) is final whether or not /tx reported the tx executed within the
1006
+ // poll window (live testnet 2026-10-02: /tx unsettled, accountOrders already
1007
+ // canceled-not-enough-liquidity, filled 0.000).
1008
+ const row = await this.orderRowOutcome(extra.clientOrderIndex, null, what, out => out.status !== "open", match);
1009
+ if (row !== null) {
1010
+ filledBase = Number(toUnitsExact(row.filledSize, step, "filled size"));
1011
+ source = "accountOrders";
1012
+ }
1013
+ }
1014
+ if (filledBase !== null) {
1015
+ if (filledBase === 0) {
1016
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: IOC filled nothing (hash=${txHash}) — the order was cancelled by the venue`, {
1017
+ exchange: this.name, txHash, ...extra,
1018
+ });
1019
+ }
1020
+ const outcome = {
1021
+ filledSize: unitsToString(BigInt(filledBase), step),
1022
+ final: true, filledBase, sentBase, txHash, txStatus: state.status, source,
1023
+ };
1024
+ return { ...base, iocOutcome: outcome };
1025
+ }
1026
+ }
1027
+ console.error(`[${this.name}] Warning: ${what}: no fill report from /tx or accountOrders (hash=${txHash}) — outcome unknown, verify the position`);
1028
+ return base;
1029
+ }
1030
+ /**
1031
+ * Poll /tx until the tx is executed (status 2 / 3 with executed_at > 0) — used for cancels
1032
+ * and trigger orders (TP/SL legs), which carry no fill to read. Status 0 or an event_info
1033
+ * `ae` → ORDER_REJECTED (definite); not executed within the poll window → ORDER_PENDING.
1034
+ */
1035
+ async awaitTxExecuted(sendResult, what, kind = "order") {
1036
+ const remediation = kind === "order"
1037
+ ? `Do NOT resubmit — check open orders: perp account orders -e ${this.name}`
1038
+ : unknownActionRemediation(this.name, what);
1039
+ const txHash = sendResult?.tx_hash;
1040
+ if (typeof txHash !== "string" || txHash === "") {
1041
+ throw orderOutcomeUnknown(this.name, what, new Error("the sendTx response carries no tx hash"), remediation);
1042
+ }
1043
+ const state = await this.pollTx(txHash, this.txPollAttempts(20));
1044
+ if (state.kind === "rejected") {
1045
+ throw new PerpError(kind === "order" ? "ORDER_REJECTED" : "ACTION_REJECTED", `${this.name} ${what}: tx rejected (status=${state.status}, ${state.reason}, hash=${txHash})`, { exchange: this.name, txHash });
1046
+ }
1047
+ if (state.kind === "executed")
1048
+ return;
1049
+ throw orderOutcomeUnknown(this.name, what, new Error(`tx ${txHash} not executed within the poll window`), remediation);
1050
+ }
1051
+ /**
1052
+ * Orders of this account by client order index (`GET /api/v1/accountOrders`, auth token in
1053
+ * the Authorization header; up to 20 indexes; last 10K active / 1K inactive within 24 h).
1054
+ * Throws on a failed read or a response without an `orders` array (never "no orders").
1055
+ */
1056
+ async getOrdersByClientIndex(clientOrderIndexes) {
1057
+ const auth = await this.getAuthToken();
1058
+ const url = new URL(`${this._baseUrl}/api/v1/accountOrders`);
1059
+ url.searchParams.set("account_index", String(this._accountIndex));
1060
+ url.searchParams.set("client_order_indexes", clientOrderIndexes.join(","));
1061
+ await this.rhRate("/accountOrders");
1062
+ const res = await fetch(url.toString(), { headers: { Authorization: auth } });
1063
+ const text = await res.text();
1064
+ if (res.status === 429)
1065
+ throw rateLimitedError(this.name, res.headers.get("retry-after"), text);
1066
+ const waf = lighterWafChallenge(this.name, res.status, text);
1067
+ if (waf !== null)
1068
+ throw waf;
1069
+ let json;
1070
+ try {
1071
+ json = JSON.parse(text);
1072
+ }
1073
+ catch {
1074
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountOrders: unreadable response (HTTP ${res.status})`, { exchange: this.name });
1075
+ }
1076
+ if (!res.ok || (typeof json.code === "number" && json.code !== 200) || !Array.isArray(json.orders)) {
1077
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountOrders failed (HTTP ${res.status}): ${String(json.message ?? text.slice(0, 120))}`, { exchange: this.name });
1078
+ }
1079
+ return json.orders;
1080
+ }
1081
+ /**
1082
+ * Our order's accountOrders row (by client order index; and order id when given) → outcome
1083
+ * from the venue's own `status` / `filled_base_amount` (lighterRowOutcomeOf — never
1084
+ * size − remaining). `settled` decides whether the row settles the question (e.g. a modify's
1085
+ * open row must carry the new price / size). Up to 4 reads 500 ms apart (indexing lag); a
1086
+ * failed read is warned. Unsettled → null. Public: LighterSpotAdapter resolves spot orders
1087
+ * with the same read (accountOrders lists spot and perp orders alike).
1088
+ */
1089
+ async orderRowOutcome(clientOrderIndex, orderId, what, settled = () => true, match = null) {
1090
+ // A client order index is not unique (it repeats across markets and over time): a row is ours
1091
+ // only on the market we sent to and created no earlier than the send (minus clock skew).
1092
+ const ours = (r) => {
1093
+ if (match === null)
1094
+ return true;
1095
+ if (Number(r.market_index) !== match.marketIndex)
1096
+ return false;
1097
+ const created = lighterRowCreatedMs(r);
1098
+ return created !== null && created >= match.sentAtMs - LIGHTER_ROW_SKEW_MS;
1099
+ };
1100
+ for (let attempt = 0; attempt < 4; attempt++) {
1101
+ if (attempt > 0)
1102
+ await new Promise(r => setTimeout(r, 500));
1103
+ try {
1104
+ const rows = await this.getOrdersByClientIndex([clientOrderIndex]);
1105
+ const row = rows.find(r => Number(r.client_order_index ?? r.client_order_id) === clientOrderIndex && (orderId === null || lighterOrderIdOf(r) === orderId) && ours(r));
1106
+ if (row === undefined)
1107
+ continue;
1108
+ const id = lighterOrderIdOf(row);
1109
+ const out = lighterRowOutcomeOf(row, id === "" ? orderId : id);
1110
+ if (out !== null && settled(out, row))
1111
+ return out;
1112
+ }
1113
+ catch (e) {
1114
+ console.error(`[${this.name}] Warning: ${what}: accountOrders read failed — ${e instanceof Error ? e.message : String(e)}`);
1115
+ }
1116
+ }
1117
+ return null;
1118
+ }
1119
+ /**
1120
+ * Final outcome of a placed resting / post-only order (§13.10 #4 analogue for non-IOC): poll
1121
+ * /tx (rejected → ORDER_REJECTED). Our order (`to`, matched by client order index) reported
1122
+ * "filled" in event_info → filled = is (the whole order). Any other status — open, or a
1123
+ * canceled-* whose remainder the venue zeroed (`rs` 0, so is − rs is NOT a fill) — or an
1124
+ * unsettled event → `accountOrders?client_order_indexes=<coi>` with the venue's
1125
+ * `filled_base_amount`. Still unknown → null.
1126
+ */
1127
+ async resolveOrderOutcome(sendResult, symbol, clientOrderIndex, what, match) {
1128
+ const txHash = sendResult?.tx_hash;
1129
+ const dec = this._marketDecimals.get(symbol.toUpperCase());
1130
+ if (typeof txHash === "string" && txHash !== "") {
1131
+ const state = await this.pollTx(txHash, this.txPollAttempts(10));
1132
+ if (state.kind === "rejected") {
1133
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: tx rejected (status=${state.status}, ${state.reason}, hash=${txHash})`, { exchange: this.name, txHash, clientOrderIndex });
1134
+ }
1135
+ const to = state.kind === "executed" ? state.eventInfo?.to : undefined;
1136
+ if (typeof to === "object" && to !== null && dec) {
1137
+ const o = to;
1138
+ const filledBase = Number(o.u) === clientOrderIndex ? lighterEventFilledBase(o) : null;
1139
+ if (filledBase !== null) {
1140
+ return lighterOrderOutcomeOf(LIGHTER_ST_FILLED, unitsToString(BigInt(filledBase), lighterStep(dec.size)), lighterEventOrderId(o.i), "event_info");
1141
+ }
1142
+ }
382
1143
  }
1144
+ return this.orderRowOutcome(clientOrderIndex, null, what, () => true, match);
383
1145
  }
1146
+ /**
1147
+ * Final IOC fill reported by marketOrder (`raw.iocOutcome`), or null
1148
+ * when the outcome is unknown (no usable /tx report). Zero fills never reach here —
1149
+ * they throw ORDER_REJECTED.
1150
+ */
1151
+ iocOutcomeOf(raw) {
1152
+ return lighterIocOutcomeOf(raw);
1153
+ }
1154
+ /**
1155
+ * Integer base amount / price ticks. `size` (decimal string, "0" = none) is FLOORED to the
1156
+ * size step with exact decimal arithmetic (floorSize) — never rounded up past the request;
1157
+ * a size that floors to 0 → SIZE_TOO_SMALL.
1158
+ */
384
1159
  toTicks(symbol, size, price) {
385
1160
  const dec = this._marketDecimals.get(symbol.toUpperCase());
386
1161
  if (!dec) {
387
- throw new Error(`No market decimals loaded for ${symbol}. Market data may not be initialized.`);
1162
+ throw new PerpError("ORDER_REJECTED", `No market decimals loaded for ${symbol}. Market data may not be initialized.`, { exchange: this.name });
388
1163
  }
389
- const baseAmount = Math.round(size * Math.pow(10, dec.size));
390
- const priceTicks = Math.round(price * Math.pow(10, dec.price));
391
- // Validate: size too small for this market's precision → baseAmount rounds to 0
392
- if (size > 0 && baseAmount <= 0) {
393
- const minSize = 1 / Math.pow(10, dec.size);
394
- throw new Error(`Order size ${size} too small for ${symbol} (sizeDecimals=${dec.size}, min=${minSize}). ` +
395
- `Increase size or use a different market.`);
1164
+ let baseAmount = 0;
1165
+ if (Number(size) > 0) {
1166
+ const step = lighterStep(dec.size);
1167
+ try {
1168
+ baseAmount = Number(toUnitsExact(floorSize(size, step), step, "size"));
1169
+ }
1170
+ catch (e) {
1171
+ if (e instanceof PerpError && e.structured.code === "SIZE_TOO_SMALL") {
1172
+ throw new PerpError("SIZE_TOO_SMALL", `Order size ${size} too small for ${symbol} (sizeDecimals=${dec.size}, min=${step}). Increase size or use a different market.`, { exchange: this.name });
1173
+ }
1174
+ throw e;
1175
+ }
396
1176
  }
397
- if (price > 0 && priceTicks <= 0) {
398
- const minPrice = 1 / Math.pow(10, dec.price);
399
- throw new Error(`Order price ${price} too small for ${symbol} (priceDecimals=${dec.price}, min=${minPrice}).`);
1177
+ // A caller's limit / trigger price is never rounded onto the grid: off-tick → INVALID_TICK
1178
+ // (rounding to the nearest tick could cross the book or move a trigger). "0" = no price.
1179
+ let priceTicks = 0;
1180
+ if (price !== "0") {
1181
+ if (!/^\d+(\.\d+)?$/.test(price) || !(Number(price) > 0)) {
1182
+ throw new PerpError("INVALID_PARAMS", `Order price "${price}" for ${symbol} must be a positive decimal`, { exchange: this.name });
1183
+ }
1184
+ const tick = lighterStep(dec.price);
1185
+ try {
1186
+ priceTicks = Number(toUnitsExact(price, tick, "price"));
1187
+ }
1188
+ catch (e) {
1189
+ if (e instanceof PerpError && e.structured.code === "INVALID_TICK") {
1190
+ throw new PerpError("INVALID_TICK", `${this.name} ${symbol}: price ${price} is not on the price tick ${tick} (priceDecimals=${dec.price}); nothing was sent`, {
1191
+ exchange: this.name, remediation: `Use a price that is a multiple of ${tick}`,
1192
+ });
1193
+ }
1194
+ throw e;
1195
+ }
400
1196
  }
401
1197
  return { baseAmount, priceTicks };
402
1198
  }
@@ -404,90 +1200,132 @@ export class LighterAdapter {
404
1200
  if (this._marketsCache && Date.now() - this._marketsCacheTime < LighterAdapter.CACHE_TTL)
405
1201
  return this._marketsCache;
406
1202
  const markets = [];
407
- try {
408
- // Ensure _marketDecimals is populated (has supported_size_decimals per symbol)
409
- await this.ensureMarketMap();
410
- // Fetch order book details and funding rates in parallel
411
- const [obRes, fundingRates] = await Promise.all([
412
- this.restGet("/orderBookDetails", {}),
413
- this.getFundingRates().catch(() => new Map()),
414
- ]);
415
- for (const d of obRes.order_book_details ?? []) {
416
- if (d.market_type !== "perp")
417
- continue;
418
- const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
419
- const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
420
- const fr = fundingRates.get(d.symbol);
421
- const dec = this._marketDecimals.get(d.symbol.toUpperCase());
422
- const fundingMark = fr && Number(fr.markPrice) > 0 ? fr.markPrice : null;
423
- markets.push({
424
- symbol: d.symbol,
425
- markPrice: fundingMark ?? String(d.last_trade_price ?? 0),
426
- indexPrice: String(d.last_trade_price ?? 0),
427
- fundingRate: fr?.rate ?? null,
428
- volume24h: String(d.daily_quote_token_volume ?? 0),
429
- openInterest: String(d.open_interest ?? 0),
430
- maxLeverage: maxLev,
431
- sizeDecimals: dec?.size,
432
- });
1203
+ // Ensure _marketDecimals is populated (has supported_size_decimals per symbol)
1204
+ await this.ensureMarketMap();
1205
+ // Fetch order book details and funding rates in parallel
1206
+ const [obRes, fundingRates] = await Promise.all([
1207
+ this.restGet("/orderBookDetails", {}),
1208
+ this.getFundingRates(),
1209
+ ]);
1210
+ // An absent list is a failed fetch, never "no markets" (SSOT Rule #2).
1211
+ if (!Array.isArray(obRes.order_book_details)) {
1212
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookDetails: missing order_book_details array`, { exchange: this.name });
1213
+ }
1214
+ const isPriceStr = (v) => typeof v === "string" && v.length > 0 && Number(v) > 0;
1215
+ const perps = obRes.order_book_details.filter(d => d.market_type === "perp");
1216
+ const unpriced = [];
1217
+ for (const d of perps) {
1218
+ const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
1219
+ const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
1220
+ const fr = fundingRates.get(d.symbol);
1221
+ const dec = this._marketDecimals.get(d.symbol.toUpperCase());
1222
+ // markPrice = the venue's MARK price: orderBookDetails `mark_price` (the source the public
1223
+ // data path lighter-public.ts uses; /funding-rates carries no mark price). last_trade_price
1224
+ // is a different quantity and is never substituted. A market without a mark is left out.
1225
+ if (!isPriceStr(d.mark_price)) {
1226
+ unpriced.push(d.symbol);
1227
+ continue;
433
1228
  }
1229
+ markets.push({
1230
+ symbol: d.symbol,
1231
+ markPrice: d.mark_price,
1232
+ indexPrice: isPriceStr(d.index_price) ? d.index_price : null,
1233
+ fundingRate: fr?.rate ?? null,
1234
+ volume24h: d.daily_quote_token_volume != null ? String(d.daily_quote_token_volume) : null,
1235
+ openInterest: d.open_interest != null ? String(d.open_interest) : null,
1236
+ maxLeverage: maxLev,
1237
+ sizeDecimals: dec ? dec.size : null,
1238
+ stepSize: null,
1239
+ fundingHours: null,
1240
+ marginTiers: null,
1241
+ });
1242
+ }
1243
+ if (unpriced.length > 0) {
1244
+ console.error(`[${this.name}] ${unpriced.length} market(s) without a mark price — not listed: ${unpriced.join(", ")}`);
1245
+ }
1246
+ if (perps.length > 0 && markets.length === 0) {
1247
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookDetails returned ${perps.length} perp market(s) but none with a mark price`, {
1248
+ exchange: this.name, remediation: "Retry later; the venue market data is incomplete",
1249
+ });
434
1250
  }
435
- catch { /* non-critical */ }
436
1251
  this._marketsCache = markets;
437
1252
  this._marketsCacheTime = Date.now();
438
1253
  return markets;
439
1254
  }
440
- async fetchAccount() {
1255
+ get accountCacheKey() {
1256
+ return `acct:${this.accountCachePrefix}:account:${this._address}`;
1257
+ }
1258
+ /** The in-flight cached account read: parallel callers (getBalance + getPositions) share one request. */
1259
+ _accountInFlight = null;
1260
+ /** `fresh`: always read live (write-through) — open-order listings must not see a pre-cancel / pre-place snapshot. */
1261
+ async fetchAccount(fresh = false) {
441
1262
  if (!this._address)
442
1263
  return null;
443
- const { withCache, TTL_ACCOUNT } = await import("../cache.js");
444
- return withCache(`acct:lt:account:${this._address}`, TTL_ACCOUNT, async () => {
1264
+ const read = async () => {
445
1265
  const res = await this.restGet("/account", {
446
1266
  by: "l1_address",
447
1267
  value: this._address,
448
1268
  });
449
- const acct = this._accountIndex >= 0
450
- ? res.accounts?.find(a => a.account_index === this._accountIndex || a.index === this._accountIndex)
451
- : res.accounts?.[0];
452
- return acct ?? res.accounts?.[0] ?? null;
453
- });
1269
+ return lighterSelectAccount(res.accounts, this._accountIndex, this.name);
1270
+ };
1271
+ if (fresh)
1272
+ return fetchAndCache(this.accountCacheKey, TTL_ACCOUNT, read);
1273
+ if (this._accountInFlight === null) {
1274
+ const p = withCache(this.accountCacheKey, TTL_ACCOUNT, read);
1275
+ this._accountInFlight = p;
1276
+ const clear = () => { if (this._accountInFlight === p)
1277
+ this._accountInFlight = null; };
1278
+ p.then(clear, clear);
1279
+ }
1280
+ return this._accountInFlight;
454
1281
  }
1282
+ /**
1283
+ * Book from GET /orderBookOrders?market_id=<id> on this venue's host (Lighter RH: its own host
1284
+ * and market ids, e.g. RH testnet TSLA = 112). Rule #2: an unknown symbol (SYMBOL_NOT_FOUND) or
1285
+ * a failed read throws — it is never reported as an empty book. An empty `bids`/`asks` is the
1286
+ * venue's real answer (no liquidity).
1287
+ */
455
1288
  async getOrderbook(symbol) {
456
- try {
457
- await this.ensureMarketMap();
458
- const marketId = this.getMarketIndex(symbol);
459
- const res = await this.restGet("/orderBookOrders", {
460
- market_id: String(marketId),
461
- limit: "50",
462
- });
463
- return {
464
- bids: (res.bids ?? []).map((l) => [l.price, l.remaining_base_amount ?? l.size ?? "0"]),
465
- asks: (res.asks ?? []).map((l) => [l.price, l.remaining_base_amount ?? l.size ?? "0"]),
466
- };
467
- }
468
- catch {
469
- return { bids: [], asks: [] };
470
- }
1289
+ await this.ensureMarketMap();
1290
+ const marketId = this.getMarketIndex(symbol);
1291
+ const res = await this.restGet("/orderBookOrders", {
1292
+ market_id: String(marketId),
1293
+ limit: "50",
1294
+ });
1295
+ const level = (l) => {
1296
+ const size = l.remaining_base_amount ?? l.size;
1297
+ if (typeof l.price !== "string" || typeof size !== "string") {
1298
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookOrders ${symbol}: level without price / size`, { exchange: this.name });
1299
+ }
1300
+ return [l.price, size];
1301
+ };
1302
+ return { bids: (res.bids ?? []).map(level), asks: (res.asks ?? []).map(level) };
471
1303
  }
472
1304
  async getBalance() {
473
1305
  if (!this._address)
474
1306
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
475
1307
  const acct = await this.fetchAccount();
476
- if (!acct)
477
- return { equity: "0", available: "0", marginUsed: "0", unrealizedPnl: "0" };
478
- const totalAsset = Number(acct.total_asset_value || 0);
479
- const available = Number(acct.available_balance || 0);
480
- const collateral = Number(acct.collateral || 0);
481
- const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + Number(p.unrealized_pnl || 0), 0) ?? 0;
482
- // Include spot USDC balance (separate from perp collateral)
1308
+ // Rule #2: no account is not a zero balance — say so (like getMarketSettings).
1309
+ if (!acct) {
1310
+ throw new PerpError("EXCHANGE_ERROR", `${this.name}: no account found for ${this._address}${this._accountIndex >= 0 ? ` (account index ${this._accountIndex})` : ""} — balance unknown`, {
1311
+ exchange: this.name,
1312
+ remediation: `A ${this.name} account is created by the first deposit; check the address / account index (perp -e ${this.name} wallet show) or deposit first`,
1313
+ });
1314
+ }
1315
+ const totalAsset = parseFiniteVenueNumber(acct.total_asset_value, "total_asset_value", "lighter");
1316
+ const available = parseFiniteVenueNumber(acct.available_balance, "available_balance", "lighter");
1317
+ const collateral = parseFiniteVenueNumber(acct.collateral, "collateral", "lighter");
1318
+ const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + parseFiniteVenueNumber(p.unrealized_pnl, "position.unrealized_pnl", "lighter"), 0) ?? 0;
1319
+ // Include spot collateral balance (USDC on Lighter, USDG on Lighter RH; separate from perp collateral)
1320
+ // An account without that asset holds none of it (0); an unreadable assets list / balance throws.
483
1321
  let spotUsdc = 0;
484
- try {
485
- const assets = (acct.assets ?? []);
486
- const usdcAsset = assets.find(a => a.symbol === "USDC");
487
- if (usdcAsset)
488
- spotUsdc = parseFloat(usdcAsset.balance || "0");
1322
+ const collateralSymbol = this.collateralSymbol;
1323
+ if (!Array.isArray(acct.assets)) {
1324
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} account has no assets list — spot ${collateralSymbol} balance unknown`, { exchange: this.name });
489
1325
  }
490
- catch { /* non-critical */ }
1326
+ const usdcAsset = acct.assets.find(a => a?.symbol === collateralSymbol);
1327
+ if (usdcAsset !== undefined)
1328
+ spotUsdc = parseFiniteVenueNumber(usdcAsset.balance, `assets.${collateralSymbol}.balance`, "lighter");
491
1329
  return {
492
1330
  equity: String(totalAsset + spotUsdc),
493
1331
  available: String(available),
@@ -502,9 +1340,10 @@ export class LighterAdapter {
502
1340
  if (!acct)
503
1341
  return [];
504
1342
  return (acct.positions ?? [])
505
- .filter((p) => Number(p.position || 0) !== 0)
1343
+ .filter((p) => parseFiniteVenueNumber(p.position, "position", "lighter") !== 0)
506
1344
  .map((p) => {
507
- const posSize = Number(p.position || 0);
1345
+ const posSize = parseFiniteVenueNumber(p.position, "position", "lighter");
1346
+ const positionValue = parseFiniteVenueNumber(p.position_value, "position_value", "lighter");
508
1347
  return {
509
1348
  symbol: String(p.symbol || `Market-${p.market_id}`),
510
1349
  side: (Number(p.sign) > 0 ? "long" : "short"),
@@ -513,23 +1352,35 @@ export class LighterAdapter {
513
1352
  markPrice: (() => {
514
1353
  if (posSize === 0)
515
1354
  return "0";
516
- const rawMark = Number(p.position_value || 0) / Math.abs(posSize);
1355
+ const rawMark = positionValue / Math.abs(posSize);
517
1356
  const priceDec = this._marketDecimals.get(String(p.symbol || "").toUpperCase())?.price;
518
- return String(priceDec !== undefined ? rawMark.toFixed(priceDec) : rawMark);
1357
+ return priceDec !== undefined ? rawMark.toFixed(priceDec) : formatComputedPrice(rawMark);
519
1358
  })(),
520
- liquidationPrice: String(p.liquidation_price || "N/A"),
1359
+ // The venue reports "0" when the position has no liquidation price (e.g. cross margin
1360
+ // well covered): that is "none", not a price.
1361
+ liquidationPrice: Number(p.liquidation_price) > 0 ? String(p.liquidation_price) : null,
521
1362
  unrealizedPnl: String(p.unrealized_pnl || "0"),
522
- // Compute actual leverage = notional / account equity (not max leverage from IMF)
523
- leverage: (() => {
524
- const notional = Math.abs(Number(p.position_value || 0));
525
- const equity = Number(acct.total_asset_value || 0);
526
- if (equity > 0 && notional > 0)
527
- return Math.round(notional / equity * 10) / 10;
528
- return 1;
529
- })(),
1363
+ // The leverage CONFIGURED for the market (what `trade leverage` set) — not notional / equity.
1364
+ ...lighterPositionSettings(p, this.name),
530
1365
  };
531
1366
  });
532
1367
  }
1368
+ /**
1369
+ * Per-market leverage / margin mode the venue exposes: every `positions[]` entry of the
1370
+ * account (the venue keeps an entry for a market once it was traded or configured, open
1371
+ * position or not).
1372
+ */
1373
+ async getMarketSettings() {
1374
+ if (!this._address)
1375
+ throw new Error("No private key configured — account data unavailable. Run: perp setup");
1376
+ const acct = await this.fetchAccount();
1377
+ if (acct === null)
1378
+ throw new PerpError("EXCHANGE_ERROR", `${this.name}: no account found for ${this._address}`, { exchange: this.name });
1379
+ if (!Array.isArray(acct.positions)) {
1380
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} account has no positions list — per-market settings unknown`, { exchange: this.name });
1381
+ }
1382
+ return acct.positions.map(p => ({ symbol: String(p.symbol), ...lighterPositionSettings(p, this.name) }));
1383
+ }
533
1384
  async getAuthToken() {
534
1385
  if (this._readOnly)
535
1386
  throw new Error("Auth requires API key");
@@ -546,271 +1397,600 @@ export class LighterAdapter {
546
1397
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
547
1398
  if (this._accountIndex < 0 || this._readOnly)
548
1399
  return [];
549
- try {
550
- // Check if account has any orders first
551
- const acct = await this.fetchAccount();
552
- const totalOrders = Number(acct?.total_order_count ?? 0);
553
- if (totalOrders === 0)
554
- return [];
555
- // Only query markets that have open orders (avoids 156-market fan-out)
556
- const activeMarketIds = new Set();
557
- for (const p of acct?.positions ?? []) {
558
- if (Number(p.open_order_count ?? 0) > 0 || Number(p.pending_order_count ?? 0) > 0) {
559
- activeMarketIds.add(Number(p.market_id));
560
- }
561
- }
562
- if (activeMarketIds.size === 0)
563
- return [];
564
- const reverseMap = new Map();
565
- for (const [sym, id] of this._marketMap)
566
- reverseMap.set(id, sym);
567
- const allOrders = [];
568
- const results = await Promise.allSettled(Array.from(activeMarketIds).map((marketId) => {
569
- const sym = reverseMap.get(marketId) ?? `Market-${marketId}`;
570
- return this.restGetAuth("/accountActiveOrders", {
571
- account_index: String(this._accountIndex),
572
- market_id: String(marketId),
573
- }).then(res => ({ sym, orders: (res.orders ?? []) }));
574
- }));
575
- for (const r of results) {
576
- if (r.status !== "fulfilled" || !r.value.orders.length)
577
- continue;
578
- for (const o of r.value.orders) {
579
- allOrders.push({
580
- orderId: String(o.order_id ?? o.order_index ?? ""),
581
- symbol: String(o.symbol ?? r.value.sym),
582
- side: o.is_ask ? "sell" : "buy",
583
- price: String(o.price ?? "0"),
584
- size: String(o.initial_base_amount ?? o.remaining_base_amount ?? "0"),
585
- filled: String(o.filled_base_amount ?? "0"),
586
- status: String(o.status ?? "open"),
587
- type: String(o.type ?? "limit"),
588
- });
589
- }
1400
+ // ONE request for every perp market. Docs (apidocs.lighter.xyz/reference/accountactiveorders):
1401
+ // market_id — "If not specified, returns active orders for all markets."; market_type —
1402
+ // "all" | "spot" | "perp". (The former per-market fan-out read every perp market — 235 on
1403
+ // Lighter mainnet — whenever an order rested on a market without a positions[] entry, far past
1404
+ // rate-limits "Standard Accounts: 60 requests per rolling minute".)
1405
+ // A failed read throws — a partial list would let cancel-all report success while orders
1406
+ // stay live (SSOT Rule #2).
1407
+ await this.ensureMarketMap();
1408
+ const res = await this.restGetAuth("/accountActiveOrders", {
1409
+ account_index: String(this._accountIndex),
1410
+ market_type: "perp",
1411
+ });
1412
+ if (!Array.isArray(res.orders)) {
1413
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountActiveOrders returned no orders array`, { exchange: this.name });
1414
+ }
1415
+ // The docs list `next_cursor` in the response but no cursor request parameter: a further
1416
+ // page cannot be read, and a first page is never reported as the whole list.
1417
+ if (typeof res.next_cursor === "string" && res.next_cursor !== "") {
1418
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountActiveOrders returned a next_cursor (more open orders than one page); the remaining orders cannot be listed`, {
1419
+ exchange: this.name, remediation: `List one market's orders with: perp trade status / cancel per symbol, or use the venue UI`,
1420
+ });
1421
+ }
1422
+ const symbolOf = new Map();
1423
+ for (const [sym, id] of this._marketMap)
1424
+ symbolOf.set(id, sym);
1425
+ return res.orders.map((o) => {
1426
+ const sym = symbolOf.get(Number(o.market_index));
1427
+ if (sym === undefined) {
1428
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} open order ${lighterOrderIdOf(o)} is on market_index ${JSON.stringify(o.market_index)}, which is not in the loaded perp market list`, { exchange: this.name, raw: o });
590
1429
  }
591
- return allOrders;
592
- }
593
- catch {
594
- return [];
1430
+ return {
1431
+ // The venue order id as an exact string (`order_id` is a string on the wire; the
1432
+ // numeric `order_index` is the same value). cancelOrder / modifyOrder look the row
1433
+ // up by this id and sign with its client order index (lighterOrderSignIndex).
1434
+ orderId: lighterOrderIdOf(o),
1435
+ symbol: sym,
1436
+ side: this.sideOfIsAsk(o.is_ask, "open order", o),
1437
+ price: lighterRowField(o, "price", this.name),
1438
+ size: lighterRowField(o, "initial_base_amount", this.name),
1439
+ filled: lighterRowField(o, "filled_base_amount", this.name),
1440
+ status: lighterRowField(o, "status", this.name),
1441
+ type: String(o.type ?? "limit"),
1442
+ triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
1443
+ };
1444
+ });
1445
+ }
1446
+ /** Order / trade side from Lighter's `is_ask`; a missing / non-boolean value throws (never defaults to "buy"). */
1447
+ sideOfIsAsk(isAsk, what, raw) {
1448
+ if (typeof isAsk !== "boolean") {
1449
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${what} row has no boolean is_ask (got ${JSON.stringify(isAsk)})`, { exchange: this.name, raw });
595
1450
  }
1451
+ return isAsk ? "sell" : "buy";
596
1452
  }
1453
+ /**
1454
+ * Slippage-bounded IOC (§13.2): protection price = orderbook mid ± opts.slippage.bps,
1455
+ * tick-aligned toward crossing. The returned raw sendTx result carries `protection`
1456
+ * and, when the venue reported the fill, `iocOutcome` (see iocOutcomeOf).
1457
+ * `opts.clientOrderIndex` (optional) = caller's numeric client order id (lighterClientOrderIndex rule).
1458
+ */
597
1459
  async marketOrder(symbol, side, size, opts) {
598
- this.ensureSigner();
599
- void opts; // lighter doesn't support reduceOnly on market orders
600
- await this.ensureMarketMap();
601
- const nonce = await this.getNextNonce();
602
- const marketIndex = this.getMarketIndex(symbol);
603
- const { baseAmount } = this.toTicks(symbol, parseFloat(size), 0);
604
- // Market orders need a max slippage price (buy=high, sell=low)
605
- const markPrice = await this.getMarkPrice(symbol);
606
- const slippagePrice = side === "buy" ? markPrice * 2 : markPrice * 0.5;
607
- const { priceTicks: slippageTicks } = this.toTicks(symbol, 0, slippagePrice);
608
- const signed = await this.signOrder({
609
- marketIndex,
610
- clientOrderIndex: this.nextClientOrderIndex(),
611
- baseAmount,
612
- price: Math.max(slippageTicks, 1),
613
- isAsk: side === "sell" ? 1 : 0,
614
- orderType: 1, // ORDER_TYPE_MARKET
615
- timeInForce: 0, // IOC (Immediate or Cancel)
616
- reduceOnly: 0,
617
- triggerPrice: 0,
618
- orderExpiry: 0, // DEFAULT_IOC_EXPIRY
619
- nonce,
620
- });
621
- const result = await this.sendTx(signed);
622
- // Verify fill via tx endpoint
623
- const txResult = result;
624
- if (txResult.tx_hash) {
625
- // Poll tx status (may take a moment to process)
626
- for (let attempt = 0; attempt < 10; attempt++) {
627
- await new Promise(r => setTimeout(r, 500));
628
- try {
629
- const tx = await this.restGet("/tx", { by: "hash", value: txResult.tx_hash });
630
- const status = Number(tx.status ?? 0);
631
- if (status >= 2 && status <= 3) {
632
- // status 2 = executed, 3 = committed. Parse event_info for fill details
633
- try {
634
- const eventInfo = JSON.parse(String(tx.event_info ?? "{}"));
635
- const takerOrder = eventInfo.to;
636
- if (takerOrder && Number(takerOrder.rs ?? -1) === 0) {
637
- return result; // fully filled
638
- }
639
- // Partially filled or unfilled
640
- if (takerOrder && Number(takerOrder.rs) > 0 && Number(takerOrder.is) > 0) {
641
- const filled = Number(takerOrder.is) - Number(takerOrder.rs);
642
- if (filled > 0)
643
- return result; // partially filled is OK for market orders
644
- }
645
- }
646
- catch { /* parse error, fall through to status=3 return */ }
647
- // status=3 means tx was executed (even if zero-fill IOC — strategy verifies position)
648
- return result;
649
- }
650
- if (status > 3) { // status 4+ = rejected/failed
651
- // status > 3 likely means rejected/cancelled
652
- throw new Error(`Market ${side} ${symbol}: tx rejected (status=${status}, hash=${txResult.tx_hash})`);
653
- }
654
- // status < 3: still processing, retry
655
- }
656
- catch (e) {
657
- if (e instanceof Error && e.message.startsWith("Market "))
658
- throw e;
659
- // tx query failed, retry
660
- }
661
- }
662
- // tx was accepted (sendTx 200) but verification timed out — warn, don't throw
663
- console.error(`[lighter] Warning: market ${side} ${symbol} tx not confirmed after 5s (hash=${txResult.tx_hash}) — assuming executed`);
1460
+ // Everything before sendTx (signer, market map, nonce, ticks, reference price, signing) is
1461
+ // pre-send: a failure there is a definite "not placed" (orderNotSent), never ORDER_PENDING.
1462
+ let signed;
1463
+ let baseAmount;
1464
+ let protection;
1465
+ let clientOrderIndex;
1466
+ try {
1467
+ this.ensureSigner();
1468
+ await this.ensureMarketMap();
1469
+ const nonce = await this.getNextNonce();
1470
+ const marketIndex = this.getMarketIndex(symbol);
1471
+ ({ baseAmount } = this.toTicks(symbol, size, "0"));
1472
+ const reference = await this.getReferencePrice(symbol);
1473
+ const p = this.protectionFor(symbol, side, reference, opts.slippage, LIGHTER_REFERENCE_PRICE_SOURCE);
1474
+ protection = p.protection;
1475
+ clientOrderIndex = this.clientOrderIndexFor(opts.clientOrderIndex);
1476
+ signed = await this.signOrder({
1477
+ marketIndex,
1478
+ clientOrderIndex,
1479
+ baseAmount,
1480
+ price: p.priceTicks,
1481
+ isAsk: side === "sell" ? 1 : 0,
1482
+ orderType: 1, // ORDER_TYPE_MARKET
1483
+ timeInForce: LIGHTER_TIF.IOC,
1484
+ // reduceOnly is a first-class order field (`ReduceOnly uint8 json:"ro"`),
1485
+ // not a limit-order-only one — the previous `void opts` dropped it and
1486
+ // every close/rollback that asked for reduce-only was sent as a plain
1487
+ // market order, so one sized above the live position could FLIP it. The
1488
+ // venue enforces the constraint itself (21732 "reduce only increases
1489
+ // position"), so passing it through converts a silent flip into an honest
1490
+ // rejection. Same defect 6812c86 fixed on Hyperliquid.
1491
+ reduceOnly: opts?.reduceOnly ? 1 : 0,
1492
+ triggerPrice: 0,
1493
+ orderExpiry: 0, // DEFAULT_IOC_EXPIRY
1494
+ nonce,
1495
+ });
664
1496
  }
665
- return result;
1497
+ catch (e) {
1498
+ throw orderNotSent(this.name, `market ${side} ${size} ${symbol}`, e);
1499
+ }
1500
+ const match = { marketIndex: this.getMarketIndex(symbol), sentAtMs: Date.now() };
1501
+ const result = await this.sendTx(signed, "order", `market ${side} ${size} ${symbol}`);
1502
+ return this.awaitIocOutcome(result, symbol, baseAmount, `market ${side} ${size} ${symbol}`, { protection, clientOrderIndex }, match);
666
1503
  }
1504
+ /**
1505
+ * TIF mapping (§13.3): GTC/GTT → GTT (28-day expiry, gttExpiryMs), IOC → IOC,
1506
+ * ALO / POST_ONLY → time-in-force 2 (POST_ONLY per apidocs trading.md and the official
1507
+ * lighter-go signer; 28-day expiry like GTT — testnet confirmation pending, design §11).
1508
+ * FOK and any other value → CAPABILITY_UNSUPPORTED (never silently GTT).
1509
+ * `opts.clientOrderIndex` as in marketOrder.
1510
+ * IOC: the same definite outcome as marketOrder (awaitIocOutcome — zero fill →
1511
+ * ORDER_REJECTED, positive fill → `iocOutcome`, no report → unknown).
1512
+ * GTT / post-only: the placement outcome is resolved (resolveOrderOutcome) and attached as
1513
+ * `orderOutcome` (open / filled / cancelled, final); unresolved → no `orderOutcome` (pending).
1514
+ */
667
1515
  async limitOrder(symbol, side, price, size, opts) {
668
- this.ensureSigner();
669
- await this.ensureMarketMap();
670
- const nonce = await this.getNextNonce();
671
- const marketIndex = this.getMarketIndex(symbol);
672
- const { baseAmount, priceTicks } = this.toTicks(symbol, parseFloat(size), parseFloat(price));
673
- // Map TIF string → Lighter numeric: IOC=0, GTT/GTC=1
674
- const isIoc = opts?.tif?.toUpperCase() === "IOC";
675
- const signed = await this.signOrder({
676
- marketIndex,
677
- clientOrderIndex: this.nextClientOrderIndex(),
678
- baseAmount,
679
- price: priceTicks,
680
- isAsk: side === "sell" ? 1 : 0,
681
- orderType: 0, // ORDER_TYPE_LIMIT
682
- timeInForce: isIoc ? 0 : 1, // 0=IOC (immediate fill or cancel), 1=GTT (rests on book)
683
- reduceOnly: opts?.reduceOnly ? 1 : 0,
684
- triggerPrice: 0,
685
- orderExpiry: isIoc ? 0 : -1, // IOC: no expiry; GTT: 28-day default
686
- nonce,
1516
+ let signed;
1517
+ let clientOrderIndex;
1518
+ let sentBase;
1519
+ let isIoc = false;
1520
+ const tif = (opts?.tif ?? "GTC").toUpperCase();
1521
+ try { // pre-send (see marketOrder)
1522
+ let timeInForce;
1523
+ if (tif === "GTC" || tif === "GTT")
1524
+ timeInForce = LIGHTER_TIF.GTT;
1525
+ else if (tif === "IOC")
1526
+ timeInForce = LIGHTER_TIF.IOC;
1527
+ else if (tif === "ALO" || tif === "POST_ONLY")
1528
+ timeInForce = LIGHTER_TIF.POST_ONLY;
1529
+ else
1530
+ throw unsupported(this.name, tif === "FOK" ? "fok" : "timeInForce", `time-in-force ${opts?.tif} is not offered`, "Use --tif GTC or IOC");
1531
+ this.ensureSigner();
1532
+ await this.ensureMarketMap();
1533
+ const nonce = await this.getNextNonce();
1534
+ const marketIndex = this.getMarketIndex(symbol);
1535
+ const ticks = this.toTicks(symbol, size, price);
1536
+ sentBase = ticks.baseAmount;
1537
+ isIoc = timeInForce === LIGHTER_TIF.IOC;
1538
+ clientOrderIndex = this.clientOrderIndexFor(opts?.clientOrderIndex);
1539
+ signed = await this.signOrder({
1540
+ marketIndex,
1541
+ clientOrderIndex,
1542
+ baseAmount: ticks.baseAmount,
1543
+ price: ticks.priceTicks,
1544
+ isAsk: side === "sell" ? 1 : 0,
1545
+ orderType: 0, // ORDER_TYPE_LIMIT
1546
+ timeInForce,
1547
+ reduceOnly: opts?.reduceOnly ? 1 : 0,
1548
+ triggerPrice: 0,
1549
+ // IOC: no expiry; GTT / post-only rest on the book: -1 = now + 28 days (LighterWasmSigner)
1550
+ orderExpiry: timeInForce === LIGHTER_TIF.IOC ? 0 : -1,
1551
+ nonce,
1552
+ });
1553
+ }
1554
+ catch (e) {
1555
+ throw orderNotSent(this.name, `limit ${side} ${size} ${symbol} @ ${price}`, e);
1556
+ }
1557
+ const match = { marketIndex: this.getMarketIndex(symbol), sentAtMs: Date.now() };
1558
+ const result = await this.sendTx(signed, "order", `limit ${tif} ${side} ${size} ${symbol} @ ${price}`);
1559
+ if (isIoc) {
1560
+ return this.awaitIocOutcome(result, symbol, sentBase, `limit IOC ${side} ${size} ${symbol} @ ${price}`, { clientOrderIndex }, match);
1561
+ }
1562
+ const what = `limit ${tif} ${side} ${size} ${symbol} @ ${price}`;
1563
+ const orderOutcome = await this.resolveOrderOutcome(result, symbol, clientOrderIndex, what, match);
1564
+ if (orderOutcome === null) {
1565
+ console.error(`[${this.name}] Warning: ${what}: placement not confirmed yet — verify with: perp account orders -e ${this.name}`);
1566
+ return { ...result, clientOrderIndex };
1567
+ }
1568
+ return { ...result, clientOrderIndex, orderOutcome };
1569
+ }
1570
+ /** Final placement outcome attached by limitOrder (`raw.orderOutcome`), or null when unresolved. */
1571
+ orderOutcomeOf(raw) {
1572
+ if (typeof raw !== "object" || raw === null)
1573
+ return null;
1574
+ const o = raw.orderOutcome;
1575
+ if (typeof o !== "object" || o === null || o.final !== true)
1576
+ return null;
1577
+ return o;
1578
+ }
1579
+ /**
1580
+ * The open order `orderId` (exact venue id string) of `symbol`, from a live authenticated
1581
+ * `/accountActiveOrders` read. Not listed open → ORDER_NOT_FOUND (nothing is sent).
1582
+ */
1583
+ async activeOrderRow(symbol, marketIndex, orderId, what) {
1584
+ const active = await this.restGetAuth("/accountActiveOrders", {
1585
+ account_index: String(this._accountIndex),
1586
+ market_id: String(marketIndex),
687
1587
  });
688
- return this.sendTx(signed);
1588
+ if (!Array.isArray(active.orders)) {
1589
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountActiveOrders returned no orders array for ${symbol.toUpperCase()}; nothing was ${what}`, { exchange: this.name });
1590
+ }
1591
+ const row = active.orders.find(o => lighterOrderIdOf(o) === orderId);
1592
+ if (row === undefined) {
1593
+ throw new PerpError("ORDER_NOT_FOUND", `${this.name} order ${orderId} is not among the open ${symbol.toUpperCase()} orders; nothing was ${what}`, { exchange: this.name });
1594
+ }
1595
+ return row;
689
1596
  }
1597
+ /**
1598
+ * A failure before a cancel / modify / leverage tx was sent: orderNotSent (definite — a 429 or
1599
+ * transport failure on the nonce / active-orders read is never an unknown write outcome). The
1600
+ * lookup's own verdict ORDER_NOT_FOUND keeps its code (nothing was sent either).
1601
+ */
1602
+ notSent(what, e) {
1603
+ if (e instanceof PerpError && e.structured.code === "ORDER_NOT_FOUND")
1604
+ return e;
1605
+ return orderNotSent(this.name, what, e);
1606
+ }
1607
+ /**
1608
+ * Cancel one open order. The order is looked up among the market's open orders and the
1609
+ * cancel is signed with its client order index (or its order id when that is 0 and exactly
1610
+ * representable — lighterOrderSignIndex); otherwise refused. Never falls back to cancel-all.
1611
+ */
690
1612
  async cancelOrder(symbol, orderId) {
691
- this.ensureSigner();
692
- await this.ensureMarketMap();
693
- // Lighter order IDs can exceed Number.MAX_SAFE_INTEGER (2^53-1).
694
- // The WASM signer only accepts JS number, so large IDs lose precision.
695
- // Fall back to cancelAllOrders when the ID is unsafe.
696
- const idNum = Number(orderId);
697
- if (!Number.isSafeInteger(idNum)) {
698
- return this.cancelAllOrders(symbol);
1613
+ let signed;
1614
+ let row;
1615
+ try { // pre-send (see marketOrder): market list, the order's active row, nonce, signing
1616
+ this.ensureSigner();
1617
+ await this.ensureMarketMap();
1618
+ if (!/^\d+$/.test(orderId)) {
1619
+ throw new PerpError("INVALID_PARAMS", `${this.name} order id "${orderId}" must be a decimal integer; nothing was cancelled`, { exchange: this.name });
1620
+ }
1621
+ const marketIndex = this.getMarketIndex(symbol);
1622
+ row = await this.activeOrderRow(symbol, marketIndex, orderId, "cancelled");
1623
+ const index = lighterOrderSignIndex(row, this.name);
1624
+ const nonce = await this.getNextNonce();
1625
+ signed = this._signer.signCancelOrder({
1626
+ marketIndex, orderIndex: index, nonce,
1627
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
1628
+ });
1629
+ if (signed.error)
1630
+ throw new PerpError("SIGNATURE_FAILED", `${this.name} cancel ${orderId}: signer error: ${signed.error}`, { exchange: this.name });
699
1631
  }
700
- const nonce = await this.getNextNonce();
701
- const marketIndex = this.getMarketIndex(symbol);
702
- const signed = await this._signer.signCancelOrder({
703
- marketIndex, orderIndex: idNum, nonce,
704
- apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
705
- });
706
- return this.sendTx(signed);
1632
+ catch (e) {
1633
+ throw this.notSent(`cancel ${orderId} ${symbol}`, e);
1634
+ }
1635
+ const result = await this.sendTx(signed, "order", `cancel ${orderId} ${symbol}`);
1636
+ // The cancelled order's client index rides along so cancelOutcome can read its final row.
1637
+ const coi = Number(row.client_order_index ?? row.client_order_id);
1638
+ return { ...result, cancelTarget: { orderId, clientOrderIndex: Number.isSafeInteger(coi) && coi > 0 ? coi : null } };
707
1639
  }
1640
+ /**
1641
+ * Cancel the PERP open orders (of `symbol`, or of every perp market) one by one, each cancel
1642
+ * polled to executed (awaitTxExecuted: rejected → ORDER_REJECTED, not executed in the poll
1643
+ * window → ORDER_PENDING). Never the account-wide CancelAllOrders tx (market 255): that also
1644
+ * cancels the account's SPOT orders, which a perp cancel-all / bot / plan must not touch.
1645
+ */
708
1646
  async cancelAllOrders(symbol) {
709
- this.ensureSigner();
710
- if (symbol) {
711
- // Cancel only orders for this symbol: fetch open orders, filter, cancel individually
712
- const orders = await this.getOpenOrders();
713
- const filtered = orders.filter(o => o.symbol.toUpperCase() === symbol.toUpperCase());
714
- const results = [];
715
- for (const o of filtered) {
716
- results.push(await this.cancelOrder(o.symbol, o.orderId));
1647
+ let orders;
1648
+ try { // pre-send: the open-orders listing — nothing is cancelled when it fails
1649
+ this.ensureSigner();
1650
+ orders = await this.getOpenOrders(); // perp markets only
1651
+ }
1652
+ catch (e) {
1653
+ throw this.notSent(`cancel-all${symbol ? ` ${symbol}` : ""}`, e);
1654
+ }
1655
+ const targets = symbol ? orders.filter(o => o.symbol.toUpperCase() === symbol.toUpperCase()) : orders;
1656
+ // Every target is attempted; one failed cancel must not leave the orders after it untouched
1657
+ // and unreported. All cancelled → the raw results (as before); any failure → one error
1658
+ // carrying the per-order outcome.
1659
+ const results = [];
1660
+ const perOrder = [];
1661
+ for (const o of targets) {
1662
+ try {
1663
+ const r = await this.cancelOrder(o.symbol, o.orderId);
1664
+ await this.awaitTxExecuted(r, `cancel ${o.orderId} ${o.symbol}`);
1665
+ results.push(r);
1666
+ perOrder.push({ orderId: o.orderId, symbol: o.symbol, cancelled: true, code: null, error: null });
1667
+ }
1668
+ catch (e) {
1669
+ perOrder.push({ orderId: o.orderId, symbol: o.symbol, cancelled: false, code: classifyError(e, this.name).code, error: extractErrorMessage(e) });
717
1670
  }
718
- return results;
719
1671
  }
720
- const nonce = await this.getNextNonce();
721
- const signed = await this._signer.signCancelAllOrders({
722
- timeInForce: 1, time: Date.now() + 3600_000, nonce,
723
- apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
1672
+ const failed = perOrder.filter(r => !r.cancelled);
1673
+ if (failed.length === 0)
1674
+ return results;
1675
+ const cancelled = perOrder.length - failed.length;
1676
+ // Some cancelled → ORDER_PARTIAL; none cancelled → ORDER_REJECTED only when every failure is a
1677
+ // definite refusal, otherwise the outcome of at least one cancel is unknown (ORDER_PENDING).
1678
+ const code = cancelled > 0 ? "ORDER_PARTIAL"
1679
+ : failed.every(r => r.code !== null && DEFINITE_ORDER_FAILURE_CODES.has(r.code)) ? "ORDER_REJECTED" : "ORDER_PENDING";
1680
+ throw new PerpError(code, `${this.name} cancel-all: ${cancelled} of ${perOrder.length} order(s) cancelled; failed: ${failed.map(r => `${r.orderId} ${r.symbol} (${r.error})`).join("; ")}`, {
1681
+ exchange: this.name,
1682
+ results: perOrder,
1683
+ remediation: `Check which orders are still open (perp account orders -e ${this.name}) and cancel the remaining ones individually`,
724
1684
  });
725
- return this.sendTx(signed);
726
1685
  }
727
1686
  async modifyOrder(symbol, orderId, price, size) {
728
- this.ensureSigner();
1687
+ let signed;
1688
+ let existing;
1689
+ let baseAmount;
1690
+ let priceTicks;
1691
+ try { // pre-send (see marketOrder)
1692
+ this.ensureSigner();
1693
+ await this.ensureMarketMap();
1694
+ const marketIndex = this.getMarketIndex(symbol);
1695
+ // The order must be an open order of this market, and not a trigger order: a modify is
1696
+ // signed with triggerPrice 0, which would turn a stop / take-profit into a plain limit.
1697
+ existing = await this.activeOrderRow(symbol, marketIndex, orderId, "modified");
1698
+ const type = String(existing.type ?? "").toLowerCase();
1699
+ if (/stop|take|trigger/.test(type) || Number(existing.trigger_price ?? 0) > 0) {
1700
+ throw unsupported(this.name, "amend", "trigger orders cannot be modified", "cancel and re-place");
1701
+ }
1702
+ const index = lighterOrderSignIndex(existing, this.name);
1703
+ const nonce = await this.getNextNonce();
1704
+ ({ baseAmount, priceTicks } = this.toTicks(symbol, size, price));
1705
+ signed = this._signer.signModifyOrder({
1706
+ marketIndex,
1707
+ index,
1708
+ baseAmount,
1709
+ price: priceTicks,
1710
+ triggerPrice: 0,
1711
+ nonce,
1712
+ orderVersion: 0, // unversioned (lighter-go #83: optional; if set must exceed the current version)
1713
+ apiKeyIndex: this._apiKeyIndex,
1714
+ accountIndex: this._accountIndex,
1715
+ });
1716
+ if (signed.error) {
1717
+ throw new Error(`Signer: ${signed.error}`);
1718
+ }
1719
+ }
1720
+ catch (e) {
1721
+ throw this.notSent(`modify ${orderId} ${symbol}`, e);
1722
+ }
1723
+ const result = await this.sendTx(signed, "order", `modify ${orderId} ${symbol}`);
1724
+ const coi = Number(existing.client_order_index ?? existing.client_order_id);
1725
+ const what = `modify ${orderId} ${symbol.toUpperCase()} → ${price} x ${size}`;
1726
+ const orderOutcome = await this.resolveModifyOutcome(result, symbol, orderId, Number.isSafeInteger(coi) && coi > 0 ? coi : null, { baseAmount, priceTicks }, what);
1727
+ if (orderOutcome === null) {
1728
+ console.error(`[${this.name}] Warning: ${what}: the edit is not confirmed yet — verify with: perp account orders -e ${this.name}`);
1729
+ return result;
1730
+ }
1731
+ return { ...result, orderOutcome };
1732
+ }
1733
+ /**
1734
+ * Final outcome of a modify: poll /tx (rejected → ORDER_REJECTED, the order is unchanged);
1735
+ * once executed, the new order `no` from event_info — final there only when it is "filled"
1736
+ * with the sent price / size (lighterModifyOutcomeOf). Otherwise `accountOrders?client_order_indexes=<coi>`: a terminal
1737
+ * row (filled / canceled-*) is final as is; an open row only with the new price and size.
1738
+ * Still unknown → null (pending).
1739
+ */
1740
+ async resolveModifyOutcome(sendResult, symbol, orderId, clientOrderIndex, sent, what) {
1741
+ const dec = this._marketDecimals.get(symbol.toUpperCase());
1742
+ if (!dec)
1743
+ return null;
1744
+ const txHash = sendResult?.tx_hash;
1745
+ if (typeof txHash === "string" && txHash !== "") {
1746
+ const state = await this.pollTx(txHash, this.txPollAttempts(10));
1747
+ if (state.kind === "rejected") {
1748
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: tx rejected (status=${state.status}, ${state.reason}, hash=${txHash}) — the order is unchanged`, { exchange: this.name, txHash, orderId });
1749
+ }
1750
+ if (state.kind === "executed") {
1751
+ const out = lighterModifyOutcomeOf(state.eventInfo, orderId, clientOrderIndex, sent, dec.size);
1752
+ if (out !== null)
1753
+ return out;
1754
+ }
1755
+ }
1756
+ if (clientOrderIndex === null)
1757
+ return null;
1758
+ // A terminal row (filled / canceled-*) is final as is; an open row only with the new price and size.
1759
+ const ticks = (v, d) => Math.round(Number(v) * 10 ** d);
1760
+ return this.orderRowOutcome(clientOrderIndex, orderId, what, (out, row) => out.status !== "open" || (ticks(row.price, dec.price) === sent.priceTicks && ticks(row.initial_base_amount, dec.size) === sent.baseAmount));
1761
+ }
1762
+ /**
1763
+ * After a cancel tx EXECUTED (awaitTxExecuted): confirm the order is gone. With a client
1764
+ * order index (attached by cancelOrder as `cancelTarget`), its accountOrders row: canceled-*
1765
+ * → "cancelled", filled → "filled", still open → "open". Without one, the market's open
1766
+ * orders: absent → "cancelled" (gone), listed → "open". Unreadable / not indexed after a
1767
+ * few reads → null (unknown).
1768
+ */
1769
+ async cancelOutcome(sendResult, symbol, orderId) {
1770
+ const t = sendResult?.cancelTarget;
1771
+ const coi = typeof t?.clientOrderIndex === "number" && t.clientOrderIndex > 0 ? t.clientOrderIndex : null;
1772
+ let last = null;
1773
+ for (let attempt = 0; attempt < 4; attempt++) {
1774
+ if (attempt > 0)
1775
+ await new Promise(r => setTimeout(r, 500));
1776
+ try {
1777
+ if (coi !== null) {
1778
+ const row = (await this.getOrdersByClientIndex([coi])).find(r => lighterOrderIdOf(r) === orderId);
1779
+ const out = row !== undefined && typeof row.status === "string" ? lighterOrderOutcomeOf(row.status, "0", orderId, "accountOrders") : null;
1780
+ if (out?.status === "cancelled")
1781
+ return "cancelled";
1782
+ if (out?.status === "filled")
1783
+ return "filled";
1784
+ if (out?.status === "open")
1785
+ last = "open";
1786
+ }
1787
+ else {
1788
+ await this.ensureMarketMap();
1789
+ await this.activeOrderRow(symbol, this.getMarketIndex(symbol), orderId, "cancelled");
1790
+ last = "open";
1791
+ }
1792
+ }
1793
+ catch (e) {
1794
+ if (coi === null && e instanceof PerpError && e.structured.code === "ORDER_NOT_FOUND")
1795
+ return "cancelled";
1796
+ }
1797
+ }
1798
+ return last;
1799
+ }
1800
+ /**
1801
+ * One order's venue row, for a per-order status query. Client order index →
1802
+ * `accountOrders?client_order_indexes=<coi>` (active and inactive within 24 h). Order id →
1803
+ * the market's `/accountActiveOrders`, then its `/accountInactiveOrders` (latest 100).
1804
+ * A failed read throws; not listed → ORDER_NOT_FOUND (never "cancelled").
1805
+ */
1806
+ async orderStatusRow(symbol, ref) {
1807
+ if (ref.kind === "clientOrderId") {
1808
+ const coi = lighterClientOrderIndex(ref.value);
1809
+ // A client order index is not unique (it repeats across markets and over time — see
1810
+ // orderRowOutcome's LighterRowMatch): only rows on `symbol`'s market count, and several
1811
+ // such rows are ambiguous (never guessed).
1812
+ await this.ensureMarketMap();
1813
+ const marketIndex = this.getMarketIndex(symbol);
1814
+ const rows = (await this.getOrdersByClientIndex([coi])).filter(r => Number(r.client_order_index ?? r.client_order_id) === coi && Number(r.market_index) === marketIndex);
1815
+ if (rows.length === 0) {
1816
+ throw new PerpError("ORDER_NOT_FOUND", `${this.name} order c:${ref.value} is not listed by accountOrders on ${symbol.toUpperCase()}`, { exchange: this.name });
1817
+ }
1818
+ if (rows.length > 1) {
1819
+ throw new PerpError("INVALID_PARAMS", `${this.name} client order id c:${ref.value} matches ${rows.length} ${symbol.toUpperCase()} orders (${rows.map(r => lighterOrderIdOf(r) || "?").join(", ")}); refusing to pick one`, {
1820
+ exchange: this.name, remediation: `Query by the venue order id instead: perp -e ${this.name} trade status ${symbol.toUpperCase()} <orderId>`,
1821
+ });
1822
+ }
1823
+ return rows[0];
1824
+ }
729
1825
  await this.ensureMarketMap();
730
- const nonce = await this.getNextNonce();
731
- const marketIndex = this.getMarketIndex(symbol);
732
- const { baseAmount, priceTicks } = this.toTicks(symbol, parseFloat(size), parseFloat(price));
733
- const idNum = Number(orderId);
734
- if (!Number.isSafeInteger(idNum)) {
735
- throw new Error(`Order ID ${orderId} exceeds MAX_SAFE_INTEGER — cancel and re-place instead`);
736
- }
737
- const signed = await this._signer.signModifyOrder({
738
- marketIndex,
739
- index: idNum,
740
- baseAmount,
741
- price: priceTicks,
742
- triggerPrice: 0,
743
- nonce,
744
- apiKeyIndex: this._apiKeyIndex,
745
- accountIndex: this._accountIndex,
746
- });
747
- if (signed.error) {
748
- throw new Error(`Signer: ${signed.error}`);
1826
+ const marketId = String(this.getMarketIndex(symbol));
1827
+ for (const path of ["/accountActiveOrders", "/accountInactiveOrders"]) {
1828
+ const params = { account_index: String(this._accountIndex), market_id: marketId };
1829
+ if (path === "/accountInactiveOrders")
1830
+ params.limit = "100";
1831
+ const res = await this.restGetAuth(path, params);
1832
+ if (!Array.isArray(res.orders)) {
1833
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${path.slice(1)} returned no orders array for ${symbol.toUpperCase()}`, { exchange: this.name });
1834
+ }
1835
+ const row = res.orders.find(o => lighterOrderIdOf(o) === ref.value);
1836
+ if (row !== undefined)
1837
+ return row;
749
1838
  }
750
- return this.sendTx(signed);
1839
+ throw new PerpError("ORDER_NOT_FOUND", `${this.name} order ${ref.value} is not among the ${symbol.toUpperCase()} open or latest 100 inactive orders`, { exchange: this.name });
751
1840
  }
1841
+ /**
1842
+ * Trigger order. Market trigger (no limitPrice): execution price bound = trigger price ±
1843
+ * `opts.slippage` bps (the caller's resolveSlippage() output — required; the adapter never
1844
+ * reads settings, §13.10 #1), tick-aligned toward crossing (§13.10 #3), recorded in the
1845
+ * result as `protection`. Accepted risk: a fast move through the bound leaves the
1846
+ * triggered order unfilled.
1847
+ * TP vs SL: `opts.kind` given (TP/SL legs) → that type as given (a trigger already crossed
1848
+ * is left for the venue to reject); omitted (plain `trade stop`) → classified from the
1849
+ * trigger vs the declared reference price (orderbook mid).
1850
+ * `size` must be a positive size (never "0" = "whole position", which Lighter does not have).
1851
+ * `opts.clientOrderIndex` as in marketOrder; the result carries the `clientOrderIndex` sent
1852
+ * and, once resolved (resolveOrderOutcome), the final `orderOutcome` (untriggered → open).
1853
+ */
752
1854
  async stopOrder(symbol, side, size, triggerPrice, opts) {
753
- this.ensureSigner();
754
- await this.ensureMarketMap();
755
- const nonce = await this.getNextNonce();
756
- const marketIndex = this.getMarketIndex(symbol);
757
- const { baseAmount } = this.toTicks(symbol, parseFloat(size), 0);
758
- const { priceTicks: triggerTicks } = this.toTicks(symbol, 0, parseFloat(triggerPrice));
759
- const triggerNum = parseFloat(triggerPrice);
760
- const markPrice = await this.getMarkPrice(symbol);
761
- const isMarket = !opts?.limitPrice;
762
- let priceTicks;
763
- if (isMarket) {
764
- const slippagePrice = side === "buy" ? markPrice * 2 : markPrice * 0.5;
765
- priceTicks = this.toTicks(symbol, 0, slippagePrice).priceTicks;
1855
+ let signed;
1856
+ let protection = null;
1857
+ let clientOrderIndex;
1858
+ try { // pre-send (see marketOrder)
1859
+ if (!(Number(size) > 0) || !Number.isFinite(Number(size))) {
1860
+ throw new PerpError("INVALID_PARAMS", `${this.name} stop order size must be a positive number (got "${size}") — Lighter has no whole-position trigger`, { exchange: this.name });
1861
+ }
1862
+ if (opts?.limitPrice === undefined && opts?.slippage === undefined) {
1863
+ throw new PerpError("INVALID_PARAMS", `${this.name} market trigger orders execute as a protection-priced limit; the order is missing its resolved slippage bound`, { exchange: this.name });
1864
+ }
1865
+ this.ensureSigner();
1866
+ await this.ensureMarketMap();
1867
+ const nonce = await this.getNextNonce();
1868
+ const marketIndex = this.getMarketIndex(symbol);
1869
+ const { baseAmount } = this.toTicks(symbol, size, "0");
1870
+ const { priceTicks: triggerTicks } = this.toTicks(symbol, "0", triggerPrice);
1871
+ const triggerNum = parseFloat(triggerPrice);
1872
+ const isMarket = !opts?.limitPrice;
1873
+ let priceTicks;
1874
+ if (isMarket) {
1875
+ const p = this.protectionFor(symbol, side, triggerNum, opts?.slippage, "triggerPrice");
1876
+ priceTicks = p.priceTicks;
1877
+ protection = p.protection;
1878
+ }
1879
+ else {
1880
+ priceTicks = this.toTicks(symbol, "0", opts.limitPrice).priceTicks;
1881
+ }
1882
+ const orderType = opts?.kind !== undefined
1883
+ ? (opts.kind === "sl"
1884
+ ? (isMarket ? LIGHTER_ORDER_TYPE.STOP_LOSS : LIGHTER_ORDER_TYPE.STOP_LOSS_LIMIT)
1885
+ : (isMarket ? LIGHTER_ORDER_TYPE.TAKE_PROFIT : LIGHTER_ORDER_TYPE.TAKE_PROFIT_LIMIT))
1886
+ : classifyTriggerOrderType(side, triggerNum, await this.getReferencePrice(symbol), isMarket);
1887
+ clientOrderIndex = this.clientOrderIndexFor(opts?.clientOrderIndex);
1888
+ signed = await this.signOrder({
1889
+ marketIndex,
1890
+ clientOrderIndex,
1891
+ baseAmount,
1892
+ price: Math.max(priceTicks, 1),
1893
+ isAsk: side === "sell" ? 1 : 0,
1894
+ orderType,
1895
+ timeInForce: isMarket ? LIGHTER_TIF.IOC : LIGHTER_TIF.GTT,
1896
+ reduceOnly: opts?.reduceOnly ? 1 : 0,
1897
+ triggerPrice: triggerTicks,
1898
+ orderExpiry: -1, // matches Lighter SDK DEFAULT_28_DAY_ORDER_EXPIRY for trigger orders
1899
+ nonce,
1900
+ });
766
1901
  }
767
- else {
768
- priceTicks = this.toTicks(symbol, 0, parseFloat(opts.limitPrice)).priceTicks;
769
- }
770
- const orderType = classifyTriggerOrderType(side, triggerNum, markPrice, isMarket);
771
- const signed = await this.signOrder({
772
- marketIndex,
773
- clientOrderIndex: this.nextClientOrderIndex(),
774
- baseAmount,
775
- price: Math.max(priceTicks, 1),
776
- isAsk: side === "sell" ? 1 : 0,
777
- orderType,
778
- timeInForce: isMarket ? 0 : 1, // IOC for market, GTT for limit
779
- reduceOnly: opts?.reduceOnly ? 1 : 0,
780
- triggerPrice: triggerTicks,
781
- orderExpiry: -1, // matches Lighter SDK DEFAULT_28_DAY_ORDER_EXPIRY for trigger orders
782
- nonce,
783
- });
784
- return this.sendTx(signed);
1902
+ catch (e) {
1903
+ throw orderNotSent(this.name, `stop ${side} ${size} ${symbol} @ ${triggerPrice}`, e);
1904
+ }
1905
+ const match = { marketIndex: this.getMarketIndex(symbol), sentAtMs: Date.now() };
1906
+ const result = await this.sendTx(signed, "order", `stop ${side} ${size} ${symbol} @ ${triggerPrice}`);
1907
+ const base = protection === null
1908
+ ? { ...result, clientOrderIndex }
1909
+ : { ...result, protection, clientOrderIndex };
1910
+ // Placement outcome as for a resting limit (resolveOrderOutcome: /tx rejected → ORDER_REJECTED,
1911
+ // else the accountOrders row by client order index). An untriggered trigger order is listed
1912
+ // "pending" / "open" (trigger_status "mark-price") → final "open"; unresolved → no orderOutcome (pending).
1913
+ const what = `stop ${side} ${size} ${symbol} @ ${triggerPrice}`;
1914
+ const orderOutcome = await this.resolveOrderOutcome(result, symbol, clientOrderIndex, what, match);
1915
+ if (orderOutcome === null) {
1916
+ console.error(`[${this.name}] Warning: ${what}: placement not confirmed yet — verify with: perp account orders -e ${this.name}`);
1917
+ return base;
1918
+ }
1919
+ return { ...base, orderOutcome };
785
1920
  }
786
1921
  async updateLeverage(symbol, leverage, marginMode = "cross") {
787
- this.ensureSigner();
788
- await this.ensureMarketMap();
789
- const nonce = await this.getNextNonce();
790
- const marketIndex = this.getMarketIndex(symbol);
791
- // fraction = initial margin fraction in basis points: 10000/leverage
792
- const fraction = Math.round(10000 / leverage);
793
- const mode = marginMode === "isolated" ? 1 : 0;
794
- const signed = await this._signer.signUpdateLeverage({
795
- marketIndex, fraction, marginMode: mode, nonce,
796
- apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
797
- });
798
- if (signed.error) {
799
- throw new Error(`Signer: ${signed.error}`);
1922
+ const what = `leverage ${leverage}x ${marginMode} ${symbol.toUpperCase()}`;
1923
+ let signed;
1924
+ try { // pre-send (see marketOrder): the leverage change was not sent
1925
+ this.ensureSigner();
1926
+ await this.ensureMarketMap();
1927
+ const nonce = await this.getNextNonce();
1928
+ const marketIndex = this.getMarketIndex(symbol);
1929
+ // fraction = initial margin fraction in basis points of 10,000 ("10x leverage -> 1000",
1930
+ // apidocs.lighter.xyz/docs/trading). Truncated like the official SDK
1931
+ // (lighter-python signer_client.update_leverage: `imf = int(10_000 / leverage)`).
1932
+ const fraction = Math.trunc(10000 / leverage);
1933
+ const mode = marginMode === "isolated" ? 1 : 0;
1934
+ signed = await this._signer.signUpdateLeverage({
1935
+ marketIndex, fraction, marginMode: mode, nonce,
1936
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
1937
+ });
1938
+ if (signed.error) {
1939
+ throw new Error(`Signer: ${signed.error}`);
1940
+ }
1941
+ }
1942
+ catch (e) {
1943
+ throw this.notSent(what, e);
800
1944
  }
801
- return this.sendTx(signed);
1945
+ const result = await this.sendTx(signed, "action", what);
1946
+ // The sendTx ack only says the tx was queued: executed / rejected comes from /tx.
1947
+ await this.awaitTxExecuted(result, what, "action");
1948
+ return result;
802
1949
  }
803
1950
  async withdraw(amount, destination, opts) {
804
- return this._withdrawRaw(parseFloat(amount), opts?.assetId ?? 3, opts?.routeType ?? 0);
1951
+ // Omitted --asset-id means "the venue collateral" (asset 3 on both Lighter venues).
1952
+ return this._withdrawRaw(Number(parsePositiveDecimal(amount, `${this.name} withdraw amount`)), opts?.assetId ?? this.collateralAssetIndex, opts?.routeType ?? 0);
805
1953
  }
806
- async _withdrawRaw(amount, assetId = 3, routeType = 0) {
1954
+ async _withdrawRaw(amount, assetId, routeType) {
807
1955
  this.ensureSigner();
808
1956
  const nonce = await this.getNextNonce();
1957
+ // USDC has 6 decimals. The low-level WasmSignerClient.signWithdraw passes
1958
+ // usdcAmount straight to the WASM module with NO scaling — only the SDK's
1959
+ // high-level SignerClient wrapper multiplies by 1e6. Since this adapter calls
1960
+ // the low-level signer, it must convert human USDC → smallest units itself,
1961
+ // or it would sign a withdrawal 1,000,000x too small.
1962
+ // Rule #2: the 1e6 factor is USDC's scale specifically (assetDetails id 3:
1963
+ // decimals 6, l1_decimals 6). Other assets do not share it — ETH (id 1) and
1964
+ // LIT (id 2) report decimals 8 with l1_decimals 18 — so applying 1e6 to them
1965
+ // signs a withdrawal ~100x too small, the same class of defect 966ffc0 fixed
1966
+ // for USDC. The docs say the amount follows "the ERC20's decimals" while the
1967
+ // L2 registry carries a different `decimals`, so the correct factor for a
1968
+ // non-USDC asset is not determinable from here. Refuse instead of guessing
1969
+ // at a funds path.
1970
+ // Lighter RH: the collateral (USDG on mainnet, USDC on testnet) is at the
1971
+ // same asset id 3 with the same 6/6 decimals (live /assetDetails), so the
1972
+ // 1e6 factor holds for it too.
1973
+ const collateralSymbol = this.collateralSymbol;
1974
+ const collateralAssetIndex = this.collateralAssetIndex;
1975
+ if (assetId !== collateralAssetIndex) {
1976
+ throw new PerpError("NOT_IMPLEMENTED", `Lighter withdraw only supports ${collateralSymbol} (asset id ${collateralAssetIndex}); asset id ${assetId} has a different decimal scale and is not implemented.`, {
1977
+ exchange: this.name,
1978
+ remediation: `Withdraw ${collateralSymbol} (omit --asset-id), or withdraw this asset from the Lighter UI.`,
1979
+ });
1980
+ }
1981
+ const scaledAmount = Math.floor(amount * 1_000_000);
1982
+ if (amount > 0 && scaledAmount <= 0) {
1983
+ throw new Error(`Lighter withdraw amount ${amount} ${collateralSymbol} rounds below the minimum unit (1e-6 ${collateralSymbol}).`);
1984
+ }
809
1985
  const signed = await this._signer.signWithdraw({
810
- usdcAmount: amount, assetIndex: assetId, routeType, nonce,
1986
+ usdcAmount: scaledAmount, assetIndex: assetId, routeType, nonce,
811
1987
  apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
812
1988
  });
813
- return this.sendTx(signed);
1989
+ const what = `withdraw ${amount} ${collateralSymbol}`;
1990
+ const result = await this.sendTx(signed, "action", what);
1991
+ // The sendTx ack only says the tx was queued: executed / rejected comes from /tx.
1992
+ await this.awaitTxExecuted(result, what, "action");
1993
+ return result;
814
1994
  }
815
1995
  // ── Interface aliases ──
816
1996
  async editOrder(symbol, orderId, price, size) {
@@ -825,7 +2005,9 @@ export class LighterAdapter {
825
2005
  const res = await this.restGet("/recentTrades", { market_id: String(marketId), limit: String(limit) });
826
2006
  const trades = (res.trades ?? []);
827
2007
  return trades.map((t) => ({
828
- time: Number(t.timestamp ?? 0) * 1000,
2008
+ // /recentTrades `timestamp` is already in milliseconds (13 digits) — do NOT
2009
+ // multiply by 1000 (that pushed trade times to year ~58000).
2010
+ time: Number(t.timestamp ?? 0),
829
2011
  symbol,
830
2012
  // /recentTrades returns is_maker_ask: maker was asking → taker bought
831
2013
  side: t.is_maker_ask ? "buy" : "sell",
@@ -844,7 +2026,9 @@ export class LighterAdapter {
844
2026
  }
845
2027
  async getKlines(symbol, interval, startTime, endTime) {
846
2028
  const res = await this.getCandles(symbol, interval, startTime, endTime);
847
- const candles = (res.candles ?? []);
2029
+ // /candles returns the array under key `c` (top keys: code, r, c) — `res.candles`
2030
+ // is always undefined, which silently rendered every Lighter kline set empty.
2031
+ const candles = (res.c ?? res.candles ?? []);
848
2032
  return candles.map((c) => ({
849
2033
  time: Number(c.start_timestamp ?? c.t ?? 0),
850
2034
  open: String(c.open ?? c.o ?? "0"),
@@ -855,46 +2039,133 @@ export class LighterAdapter {
855
2039
  trades: Number(c.trades_count ?? c.n ?? 0),
856
2040
  }));
857
2041
  }
2042
+ /**
2043
+ * Account-wide history: ONE request per 100 rows, whatever the number of markets (the former
2044
+ * per-market fan-out sent one request per perp market — 235 on Lighter mainnet — against
2045
+ * rate-limits "Standard Accounts: 60 requests per rolling minute", and dropped every failed
2046
+ * market). Docs (apidocs.lighter.xyz/reference): `limit` "minimum": 1, "maximum": 100 and a
2047
+ * `cursor` request parameter / `next_cursor` response field on accountInactiveOrders, trades
2048
+ * and positionFunding. Reads ⌈want / 100⌉ pages at most; a failed page throws — a partial
2049
+ * history is never returned (SSOT Rule #2).
2050
+ */
2051
+ async historyRows(path, listKey, params, want, auth) {
2052
+ if (!Number.isInteger(want) || want < 1) {
2053
+ throw new PerpError("INVALID_PARAMS", `${this.name} ${path}: limit must be a positive integer (got ${want})`, { exchange: this.name });
2054
+ }
2055
+ if (this._accountIndex < 0) {
2056
+ throw new PerpError("EXCHANGE_ERROR", `${this.name}: no account index is known for ${this._address} — account history cannot be read`, {
2057
+ exchange: this.name, remediation: `Check the account: perp -e ${this.name} account info`,
2058
+ });
2059
+ }
2060
+ if (auth)
2061
+ this.ensureSigner();
2062
+ await this.ensureMarketMap();
2063
+ const rows = [];
2064
+ let cursor = null;
2065
+ while (rows.length < want) {
2066
+ const query = { ...params, account_index: String(this._accountIndex), limit: String(Math.min(want - rows.length, 100)) };
2067
+ if (cursor !== null)
2068
+ query.cursor = cursor;
2069
+ const res = await (auth ? this.restGetAuth(path, query) : this.restGet(path, query));
2070
+ const page = res[listKey];
2071
+ if (!Array.isArray(page)) {
2072
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${path} returned no ${listKey} array (code ${JSON.stringify(res.code ?? null)}: ${JSON.stringify(res.message ?? null)})`, { exchange: this.name });
2073
+ }
2074
+ rows.push(...page);
2075
+ if (page.length === 0 || typeof res.next_cursor !== "string" || res.next_cursor === "")
2076
+ break;
2077
+ cursor = res.next_cursor;
2078
+ }
2079
+ return rows.slice(0, want);
2080
+ }
2081
+ /** Symbol of a history row's market id; a market no longer in the loaded perp list is named by its id. */
2082
+ historySymbol(marketId, what, raw) {
2083
+ if (typeof marketId !== "number" || !Number.isInteger(marketId)) {
2084
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${what} row has no integer market id (got ${JSON.stringify(marketId ?? null)})`, { exchange: this.name, raw });
2085
+ }
2086
+ for (const [sym, id] of this._marketMap)
2087
+ if (id === marketId)
2088
+ return sym;
2089
+ return `Market-${marketId}`;
2090
+ }
2091
+ /**
2092
+ * Order history: `GET /accountInactiveOrders` for the whole account. Docs (reference/
2093
+ * accountinactiveorders): account_index and limit are the only required parameters; market_id
2094
+ * is optional ("default": "255"), market_type is "all" | "spot" | "perp".
2095
+ */
858
2096
  async getOrderHistory(limit = 30) {
859
2097
  if (!this._address)
860
2098
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
861
- const raw = await this._getOrderHistoryRaw();
862
- const orders = (raw.orders ?? []);
863
- return orders.slice(0, limit).map((o) => ({
864
- orderId: String(o.order_index ?? o.order_id ?? ""),
865
- symbol: String(o.symbol ?? ""),
866
- side: o.is_ask ? "sell" : "buy",
2099
+ const orders = await this.historyRows("/accountInactiveOrders", "orders", { market_type: "perp" }, limit, true);
2100
+ return orders.map((o) => ({
2101
+ orderId: lighterOrderIdOf(o),
2102
+ symbol: this.historySymbol(o.market_index, "order history", o),
2103
+ side: this.sideOfIsAsk(o.is_ask, "order history", o),
867
2104
  price: String(o.price ?? "0"),
868
2105
  size: String(o.initial_base_amount ?? o.base_amount ?? ""),
869
2106
  filled: String(o.filled_base_amount ?? "0"),
870
2107
  status: String(o.status ?? "done"),
871
2108
  type: String(o.type ?? "limit"),
2109
+ triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
872
2110
  }));
873
2111
  }
2112
+ /**
2113
+ * Trade history: `GET /trades` for the whole account, newest first. Docs (reference/trades):
2114
+ * "Get trades for lighter accounts, including sub-accounts and public pools."; sort_by and
2115
+ * limit are the only required parameters, account_index / market_id are optional filters and
2116
+ * the endpoint lists no authorization parameter.
2117
+ * Row (Trade schema + live rows 2026-10-05): no `is_ask` / `symbol` / fee amount — the account
2118
+ * is `ask_account_id` (sold) or `bid_account_id` (bought); `timestamp` is unix milliseconds
2119
+ * (live 1791168722794); `taker_fee` / `maker_fee` are integers "omitted if zero" (websocket
2120
+ * reference, Trade JSON). The docs do not state their unit; live rows read as a RATE in 1e-6
2121
+ * of `usd_amount` (a liquidation row carries 10000 = 1%, maker 28–32 on unrelated notionals),
2122
+ * so the fee is usd_amount × fee / 1e6 for the side this account took.
2123
+ */
874
2124
  async getTradeHistory(limit = 30) {
875
2125
  if (!this._address)
876
2126
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
877
- const raw = await this._getTradeHistoryRaw(limit);
878
- const trades = (raw.trades ?? []);
879
- return trades.slice(0, limit).map((t) => ({
880
- time: Number(t.timestamp ?? 0) * 1000,
881
- symbol: String(t.symbol ?? ""),
882
- side: t.is_ask ? "sell" : "buy",
883
- price: String(t.price ?? "0"),
884
- size: String(t.base_amount ?? t.amount ?? ""),
885
- fee: String(t.fee ?? "0"),
886
- }));
2127
+ const trades = await this.historyRows("/trades", "trades", { sort_by: "timestamp", market_type: "perp" }, limit, false);
2128
+ return trades.map((t) => {
2129
+ const sold = t.ask_account_id === this._accountIndex;
2130
+ const bought = t.bid_account_id === this._accountIndex;
2131
+ if (!sold && !bought || typeof t.is_maker_ask !== "boolean") {
2132
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} trade ${JSON.stringify(t.trade_id ?? null)}: account ${this._accountIndex} is neither ask (${JSON.stringify(t.ask_account_id ?? null)}) nor bid (${JSON.stringify(t.bid_account_id ?? null)}), or is_maker_ask is not a boolean`, { exchange: this.name, raw: t });
2133
+ }
2134
+ // Both sides (self-trade) → reported as the taker side.
2135
+ const side = sold && bought ? (t.is_maker_ask ? "buy" : "sell") : sold ? "sell" : "buy";
2136
+ const feeRate = (side === "sell") === t.is_maker_ask && !(sold && bought) ? t.maker_fee : t.taker_fee;
2137
+ if (typeof t.timestamp !== "number" || typeof t.price !== "string" || typeof t.size !== "string" || typeof t.usd_amount !== "string"
2138
+ || (feeRate !== undefined && (typeof feeRate !== "number" || !Number.isInteger(feeRate)))) {
2139
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} trade ${JSON.stringify(t.trade_id ?? null)} has an unreadable timestamp / price / size / usd_amount / fee`, { exchange: this.name, raw: t });
2140
+ }
2141
+ return {
2142
+ time: t.timestamp,
2143
+ symbol: this.historySymbol(t.market_id, "trade history", t),
2144
+ side,
2145
+ price: t.price,
2146
+ size: t.size,
2147
+ fee: feeRate === undefined ? "0" : mulDecimal(mulDecimal(t.usd_amount, String(feeRate)), "0.000001"),
2148
+ };
2149
+ });
887
2150
  }
2151
+ /**
2152
+ * Funding payments: `GET /positionFunding` for the whole account, newest first. Docs
2153
+ * (reference/positionfunding): account_index and limit are the only required parameters;
2154
+ * `market_ids` ("comma-separated int64s") is an optional filter. `authorization` "is required
2155
+ * when fetching an account_index linked to a main account or a sub-account". Row (live public
2156
+ * pool read 2026-10-05): `timestamp` unix SECONDS (1791169200), `market_id`, `change` (signed
2157
+ * quote amount: a short at a positive rate has a positive change).
2158
+ */
888
2159
  async getFundingPayments(limit = 200) {
889
2160
  if (!this._address)
890
2161
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
891
- const raw = await this._getPositionFundingRaw();
892
- const items = (raw.funding ?? []);
893
- return items.slice(0, limit).map((f) => ({
894
- time: Number(f.timestamp ?? 0) * 1000,
895
- symbol: String(f.symbol ?? ""),
896
- payment: String(f.change ?? f.amount ?? f.payment ?? "0"),
897
- }));
2162
+ const items = await this.historyRows("/positionFunding", "position_fundings", { side: "all" }, limit, true);
2163
+ return items.map((f) => {
2164
+ if (typeof f.timestamp !== "number" || typeof f.change !== "string") {
2165
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} funding row has an unreadable timestamp / change`, { exchange: this.name, raw: f });
2166
+ }
2167
+ return { time: f.timestamp * 1000, symbol: this.historySymbol(f.market_id, "funding history", f), payment: f.change };
2168
+ });
898
2169
  }
899
2170
  /**
900
2171
  * Get funding rates for all markets.
@@ -902,23 +2173,31 @@ export class LighterAdapter {
902
2173
  */
903
2174
  async getFundingRates() {
904
2175
  const map = new Map();
905
- try {
906
- const res = await this.restGet("/funding-rates", {});
907
- const reverseMap = new Map();
908
- for (const [sym, idx] of this._marketMap) {
909
- reverseMap.set(idx, sym);
910
- }
911
- for (const fr of res.funding_rates ?? []) {
912
- const symbol = fr.symbol || reverseMap.get(fr.market_id);
913
- if (symbol) {
914
- map.set(symbol, {
915
- rate: String(fr.rate ?? fr.funding_rate ?? "0"),
916
- markPrice: fr.mark_price ?? "0",
917
- });
918
- }
919
- }
2176
+ const res = await this.restGet("/funding-rates", {});
2177
+ const reverseMap = new Map();
2178
+ for (const [sym, idx] of this._marketMap) {
2179
+ reverseMap.set(idx, sym);
2180
+ }
2181
+ for (const fr of res.funding_rates ?? []) {
2182
+ // /funding-rates is a MULTI-EXCHANGE aggregate (Binance/Bybit/HL/Lighter…).
2183
+ // The venue's own rows are labeled "lighter" on both the Lighter and the
2184
+ // Lighter RH hosts (live check 2026-09-30: RH returns 57 "lighter" rows = its 57 perps).
2185
+ // Only keep Lighter's own rate, or another venue's rate overwrites it for the
2186
+ // same symbol (last-write-wins). Mirrors src/api/public/lighter.ts.
2187
+ if (String(fr.exchange ?? "").toLowerCase() !== "lighter")
2188
+ continue;
2189
+ const symbol = fr.symbol || reverseMap.get(fr.market_id);
2190
+ if (!symbol)
2191
+ continue;
2192
+ const rate = fr.rate ?? fr.funding_rate;
2193
+ // No rate for this market → leave it out so callers see "unavailable", never a fake 0.
2194
+ if (rate == null)
2195
+ continue;
2196
+ map.set(symbol, {
2197
+ rate: venueRateField(rate, "funding-rates rate", this.name),
2198
+ markPrice: fr.mark_price ?? null,
2199
+ });
920
2200
  }
921
- catch { /* non-critical */ }
922
2201
  return map;
923
2202
  }
924
2203
  /**
@@ -935,100 +2214,6 @@ export class LighterAdapter {
935
2214
  count_back: String(countBack),
936
2215
  });
937
2216
  }
938
- /**
939
- * Get order history (inactive orders) — raw response.
940
- */
941
- async _getOrderHistoryRaw() {
942
- if (this._accountIndex < 0 || this._readOnly)
943
- return { orders: [] };
944
- const allOrders = [];
945
- const entries = Array.from(this._marketMap.entries());
946
- const results = await Promise.allSettled(entries.map(([sym, marketId]) => this.restGetAuth("/accountInactiveOrders", {
947
- account_index: String(this._accountIndex),
948
- market_id: String(marketId),
949
- }).then((res) => {
950
- const orders = (res.orders ?? []);
951
- return orders.map((o) => ({ ...o, symbol: o.symbol ?? sym }));
952
- })));
953
- for (const r of results) {
954
- if (r.status === "fulfilled")
955
- allOrders.push(...r.value);
956
- }
957
- return { orders: allOrders };
958
- }
959
- /**
960
- * Get trade history — raw response.
961
- */
962
- async _getTradeHistoryRaw(limit = 100) {
963
- if (this._accountIndex < 0)
964
- return { trades: [] };
965
- // Ensure market map is populated (init may have been rate-limited)
966
- if (this._marketMap.size === 0)
967
- await this._refreshMarketMap();
968
- const allTrades = [];
969
- const entries = Array.from(this._marketMap.entries());
970
- const results = await Promise.allSettled(entries.map(([sym, marketId]) => this.restGetAuth("/trades", {
971
- account_index: String(this._accountIndex),
972
- market_id: String(marketId),
973
- limit: String(Math.min(limit, 20)),
974
- }).then((res) => {
975
- const trades = (res.trades ?? []);
976
- return trades.map((t) => ({ ...t, symbol: t.symbol ?? sym }));
977
- })));
978
- for (const r of results) {
979
- if (r.status === "fulfilled")
980
- allTrades.push(...r.value);
981
- }
982
- // Sort by timestamp descending
983
- allTrades.sort((a, b) => Number(b.timestamp ?? 0) - Number(a.timestamp ?? 0));
984
- return { trades: allTrades.slice(0, limit) };
985
- }
986
- /**
987
- * Get position funding history — raw response.
988
- */
989
- async _getPositionFundingRaw() {
990
- if (this._accountIndex < 0)
991
- return { funding: [] };
992
- if (this._marketMap.size === 0)
993
- await this._refreshMarketMap();
994
- // Only query markets where the account has/had positions (avoids 156-market fan-out)
995
- const acct = await this.fetchAccount();
996
- const activeMarketIds = new Set();
997
- if (acct) {
998
- for (const p of acct.positions ?? []) {
999
- const pos = Number(p.position || 0);
1000
- const rpnl = Number(p.realized_pnl || 0);
1001
- if (pos !== 0 || rpnl !== 0) {
1002
- activeMarketIds.add(Number(p.market_id));
1003
- }
1004
- }
1005
- }
1006
- if (activeMarketIds.size === 0)
1007
- return { funding: [] };
1008
- // Resolve market_id → symbol from marketMap
1009
- const reverseMap = new Map();
1010
- for (const [sym, id] of this._marketMap)
1011
- reverseMap.set(id, sym);
1012
- const allFunding = [];
1013
- const results = await Promise.allSettled(Array.from(activeMarketIds).map((marketId) => {
1014
- const sym = reverseMap.get(marketId) ?? `Market-${marketId}`;
1015
- return this.restGetAuth("/positionFunding", {
1016
- account_index: String(this._accountIndex),
1017
- market_id: String(marketId),
1018
- limit: "100",
1019
- side: "all",
1020
- }).then((res) => {
1021
- const items = (res.position_fundings ?? res.funding ?? res.data ?? []);
1022
- return items.map((f) => ({ ...f, symbol: f.symbol ?? sym }));
1023
- });
1024
- }));
1025
- for (const r of results) {
1026
- if (r.status === "fulfilled")
1027
- allFunding.push(...r.value);
1028
- }
1029
- allFunding.sort((a, b) => Number(b.timestamp ?? 0) - Number(a.timestamp ?? 0));
1030
- return { funding: allFunding };
1031
- }
1032
2217
  /**
1033
2218
  * Get PnL chart data.
1034
2219
  */
@@ -1092,6 +2277,7 @@ export class LighterAdapter {
1092
2277
  * Create CCTP intent address for cross-chain deposit.
1093
2278
  */
1094
2279
  async createIntentAddress(chainId) {
2280
+ await this.rhRate("createIntentAddress");
1095
2281
  const res = await fetch(`${this._baseUrl}/api/v1/createIntentAddress`, {
1096
2282
  method: "POST",
1097
2283
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -1110,19 +2296,25 @@ export class LighterAdapter {
1110
2296
  return res.json();
1111
2297
  }
1112
2298
  /**
1113
- * Use a referral code.
2299
+ * Use a referral code (signed auth + POST). Called only from the explicit
2300
+ * `wallet manage referral set` command — never on adapter creation.
1114
2301
  */
1115
2302
  async useReferralCode(code) {
1116
2303
  // Referral endpoint requires POST with l1_address, referral_code, and auth (form-urlencoded)
1117
2304
  const auth = await this.getAuthToken();
2305
+ await this.rhRate("referral/use");
1118
2306
  const res = await fetch(`${this._baseUrl}/api/v1/referral/use`, {
1119
2307
  method: "POST",
1120
2308
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1121
2309
  body: new URLSearchParams({ l1_address: this._address, referral_code: code, auth }),
1122
2310
  });
1123
2311
  const json = await res.json();
1124
- if (json.code && json.code !== 200)
1125
- throw new Error(`Referral failed: ${json.message ?? JSON.stringify(json)}`);
2312
+ if (json.code && json.code !== 200) {
2313
+ throw new PerpError("EXCHANGE_ERROR", `Lighter referral rejected: ${json.message ?? JSON.stringify(json)}`, {
2314
+ exchange: "lighter",
2315
+ remediation: "A Lighter account can use a referral code only once; the venue message above says why it was rejected",
2316
+ });
2317
+ }
1126
2318
  return json;
1127
2319
  }
1128
2320
  /**
@@ -1164,38 +2356,57 @@ export class LighterAdapter {
1164
2356
  throw new Error(`Signer: ${JSON.stringify(e)}`);
1165
2357
  }
1166
2358
  }
1167
- async sendTx(signed) {
2359
+ /**
2360
+ * Lighter RH REST budget (standard account: 60 req/min, rate-limits.md):
2361
+ * every RH trading/account REST call and sendTx takes a token from the
2362
+ * `${venueId}:${network}` bucket first. Venue "lighter" is not metered here.
2363
+ */
2364
+ /** Take a token from the Lighter RH rate-limit bucket (no-op for venue "lighter"). Also used by LighterSpotAdapter. */
2365
+ async rhRate(endpoint) {
2366
+ if (this.venueId === "lighter-rh") {
2367
+ await acquireRate(`${this.venueId}:${this.networkName}`, LIGHTER_RH_RATE_LIMIT, endpoint);
2368
+ }
2369
+ }
2370
+ async sendTx(signed, kind, what = kind) {
2371
+ // Pre-send failures (nothing reached the venue): typed definite so order paths never report ORDER_PENDING.
1168
2372
  if (signed.error)
1169
- throw new Error(`Signer error: ${signed.error}`);
2373
+ throw new PerpError("SIGNATURE_FAILED", `Signer error: ${signed.error}`, { exchange: this.name });
1170
2374
  if (!signed.txInfo)
1171
- throw new Error("Signer returned empty txInfo");
2375
+ throw new PerpError("SIGNATURE_FAILED", "Signer returned empty txInfo", { exchange: this.name });
1172
2376
  // SSOT rule #2: refuse to silently default txType to 0 — different tx
1173
2377
  // types (createOrder=14, cancelOrder=15, updateLeverage=20, etc.) need
1174
2378
  // different routing on Lighter's side, and a 0 either rejects with a
1175
2379
  // generic error or silently misroutes.
1176
2380
  if (signed.txType === undefined || !Number.isFinite(signed.txType)) {
1177
- throw new Error(`Lighter signer returned no txType (received ${signed.txType}); refusing to send with default 0.`);
2381
+ throw new PerpError("SIGNATURE_FAILED", `Lighter signer returned no txType (received ${signed.txType}); refusing to send with default 0.`, { exchange: this.name });
2382
+ }
2383
+ await this.rhRate("sendTx");
2384
+ try {
2385
+ // Transport failure / 5xx / unreadable body / internal or missing code → ORDER_PENDING;
2386
+ // only a documented validation code is a definite rejection (lighterPostSendTx).
2387
+ return await lighterPostSendTx(this._baseUrl, { txType: signed.txType, txInfo: signed.txInfo }, this.name, kind, `${what} sendTx`);
2388
+ }
2389
+ finally {
2390
+ // The account (positions / orders / balance) changed or may have: drop the shared acct:
2391
+ // disk cache so no later read — this process or the next CLI call — serves the pre-tx
2392
+ // snapshot (e.g. the risk check's position read before a market order).
2393
+ invalidateCache("acct");
1178
2394
  }
1179
- const res = await fetch(`${this._baseUrl}/api/v1/sendTx`, {
1180
- method: "POST",
1181
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1182
- body: new URLSearchParams({
1183
- tx_type: String(signed.txType),
1184
- tx_info: signed.txInfo,
1185
- }),
1186
- });
1187
- const json = await res.json();
1188
- if (json.code !== 200)
1189
- throw new Error(`sendTx failed: ${json.message ?? JSON.stringify(json)}`);
1190
- return json;
1191
2395
  }
1192
2396
  async restGet(path, params) {
1193
2397
  const url = new URL(`${this._baseUrl}/api/v1${path}`);
1194
2398
  for (const [k, v] of Object.entries(params))
1195
2399
  url.searchParams.set(k, v);
2400
+ await this.rhRate(path);
1196
2401
  const res = await fetch(url.toString());
1197
2402
  if (!res.ok) {
1198
2403
  const text = await res.text();
2404
+ // apidocs.lighter.xyz/docs/rate-limits: a breached limit answers "HTTP 429".
2405
+ if (res.status === 429)
2406
+ throw rateLimitedError(this.name, res.headers.get("retry-after"), text);
2407
+ const waf = lighterWafChallenge(this.name, res.status, text);
2408
+ if (waf !== null)
2409
+ throw waf;
1199
2410
  const clean = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
1200
2411
  throw new Error(`GET ${path} failed (${res.status}): ${clean}`);
1201
2412
  }
@@ -1203,58 +2414,87 @@ export class LighterAdapter {
1203
2414
  }
1204
2415
  /** Generate a unique client order index (uint48). */
1205
2416
  nextClientOrderIndex() {
1206
- const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
1207
- return base + (this._clientOrderCounter++ % 655);
2417
+ return lighterGeneratedClientOrderIndex();
1208
2418
  }
1209
2419
  ensureSigner() {
2420
+ // init() never loads an expired agent's key, so without this the adapter would look like
2421
+ // one that simply has no API key.
2422
+ if (!this._useNoAgent && this._agentMeta && this._agentApiKey && isExpired(this._agentMeta))
2423
+ throw this.agentExpired(this._agentMeta);
1210
2424
  if (this._readOnly || this._accountIndex < 0 || !this._signer) {
1211
- throw new Error("This command requires a Lighter API key. Run `perp wallet agent approve lighter` to register an agent.");
1212
- }
1213
- }
1214
- static _wasmClient = null;
1215
- static async getWasmClient() {
1216
- if (LighterAdapter._wasmClient)
1217
- return LighterAdapter._wasmClient;
1218
- // Resolve WASM path from SDK package location (fixes --prefix / global installs)
1219
- const { dirname, join } = await import("node:path");
1220
- const { fileURLToPath } = await import("node:url");
1221
- const sdkEntry = import.meta.resolve("lighter-ts-sdk");
1222
- // sdkEntry → file:///…/node_modules/lighter-ts-sdk/dist/esm/index.js
1223
- // Walk up to package root: dist/esm/index.js → 3 levels
1224
- const sdkRoot = dirname(dirname(dirname(fileURLToPath(sdkEntry))));
1225
- const wasmPath = join(sdkRoot, "wasm", "lighter-signer.wasm");
1226
- const wasmExecPath = join(sdkRoot, "wasm", "wasm_exec.js");
1227
- // Prevent Go WASM runtime from killing the process on panic
1228
- const origExit = process.exit;
1229
- process.exit = ((code) => {
1230
- process.exit = origExit; // restore immediately
1231
- throw new Error(`Lighter WASM runtime exited with code ${code}`);
1232
- });
1233
- try {
1234
- const { WasmSignerClient } = await import("lighter-ts-sdk");
1235
- const client = new WasmSignerClient({ wasmPath, wasmExecPath });
1236
- await client.initialize();
1237
- LighterAdapter._wasmClient = client;
1238
- return client;
2425
+ throw new PerpError("NO_SIGNER_AVAILABLE", `This command requires a Lighter API key. Run \`perp wallet agent approve ${this.name}\` to register an agent.`, { exchange: this.name });
1239
2426
  }
1240
- finally {
1241
- process.exit = origExit;
2427
+ }
2428
+ /**
2429
+ * One WASM signer (one Go runtime) per `${venueId}:${network}` (design §10.5).
2430
+ * lighter-go keys its clients by (accountIndex, apiKeyIndex) only and keeps a
2431
+ * package-global chainId for L1 messages, so two venues must never share a
2432
+ * runtime. Keyed by venue+network rather than chainId because Lighter testnet
2433
+ * and Lighter RH testnet both use chainId 300.
2434
+ */
2435
+ static _wasmClients = new Map();
2436
+ get _wasmKey() {
2437
+ return `${this.venueId}:${this.networkName}`;
2438
+ }
2439
+ _getWasmClient() {
2440
+ const key = this._wasmKey;
2441
+ let p = LighterAdapter._wasmClients.get(key);
2442
+ if (!p) {
2443
+ p = LighterAdapter._createWasmClient();
2444
+ // A failed init must not be cached; the next call retries from scratch.
2445
+ p.catch(() => { if (LighterAdapter._wasmClients.get(key) === p)
2446
+ LighterAdapter._wasmClients.delete(key); });
2447
+ LighterAdapter._wasmClients.set(key, p);
1242
2448
  }
2449
+ return p;
1243
2450
  }
1244
2451
  /**
1245
- * Generate a new Lighter API key pair using the WASM signer.
1246
- * Returns { privateKey, publicKey } (both 0x-prefixed, 40 bytes).
2452
+ * Load one official signer runtime (vendored lighter-go v1.0.9 WASM, sha256-verified;
2453
+ * see lighter-signer.ts). The Go exports are captured synchronously right after go.run(),
2454
+ * so concurrent creations cannot overwrite each other's functions.
1247
2455
  */
1248
- static async generateApiKey() {
1249
- const client = await LighterAdapter.getWasmClient();
2456
+ static _createWasmClient() {
2457
+ return LighterWasmSigner.load();
2458
+ }
2459
+ /**
2460
+ * Generate a new Lighter API key pair using this venue's WASM signer
2461
+ * (key generation is chain-independent). Returns { privateKey, publicKey }.
2462
+ */
2463
+ async generateApiKey() {
2464
+ const client = await this._getWasmClient();
1250
2465
  return client.generateAPIKey();
1251
2466
  }
2467
+ /**
2468
+ * Read every API-key slot registered on this account (`GET /api/v1/apikeys`).
2469
+ * Used before a ChangePubKey (approve must not overwrite an in-use slot) and after
2470
+ * one (revoke must confirm the slot's public key actually changed). Throws on a
2471
+ * malformed response — callers must never treat an unreadable list as "all free".
2472
+ */
2473
+ async listApiKeys() {
2474
+ if (this._accountIndex < 0) {
2475
+ throw new Error("Account index not available. Call init() first.");
2476
+ }
2477
+ // api_key_index 255 = every slot (explicit, never rely on the server default).
2478
+ const res = await this.restGet("/apikeys", { account_index: String(this._accountIndex), api_key_index: "255" });
2479
+ if (!res || !Array.isArray(res.api_keys)) {
2480
+ throw new Error(`Lighter /apikeys returned no api_keys array (account_index=${this._accountIndex}); raw=${JSON.stringify(res).slice(0, 200)}`);
2481
+ }
2482
+ return res.api_keys.map((raw) => {
2483
+ const k = raw;
2484
+ const idx = Number(k.api_key_index);
2485
+ if (!Number.isInteger(idx) || typeof k.public_key !== "string") {
2486
+ throw new Error(`Lighter /apikeys returned a malformed entry: ${JSON.stringify(raw).slice(0, 200)}`);
2487
+ }
2488
+ return { apiKeyIndex: idx, publicKey: k.public_key };
2489
+ });
2490
+ }
1252
2491
  /**
1253
2492
  * Generate an API key and register it on-chain via ChangePubKey.
1254
2493
  * Uses WASM signer to sign + ETH key for L1 signature.
1255
2494
  * Returns the generated key pair.
1256
2495
  */
1257
2496
  async setupApiKey(apiKeyIndex = 4) {
2497
+ LighterAdapter._assertKeyIndexAllowed(this.venueId, apiKeyIndex);
1258
2498
  if (!this._evmSigner) {
1259
2499
  throw new Error("No EVM private key configured. Run: perp setup");
1260
2500
  }
@@ -1262,7 +2502,7 @@ export class LighterAdapter {
1262
2502
  throw new Error("Account index not available. Call init() first.");
1263
2503
  }
1264
2504
  // 1. Generate key pair
1265
- const { privateKey, publicKey } = await LighterAdapter.generateApiKey();
2505
+ const { privateKey, publicKey } = await this.generateApiKey();
1266
2506
  // 2. Get nonce from API
1267
2507
  // SSOT rule #2: refuse to silently default to nonce 0 — see getNextNonce.
1268
2508
  const nonceRes = await this.restGet("/nextNonce", {
@@ -1276,16 +2516,15 @@ export class LighterAdapter {
1276
2516
  `raw response=${JSON.stringify(nonceRes)}`);
1277
2517
  }
1278
2518
  // 3. Create signer client with new key and sign ChangePubKey
1279
- const client = await LighterAdapter.getWasmClient();
1280
- await client.createClient({
1281
- url: this._baseUrl,
2519
+ const client = await this._getWasmClient();
2520
+ client.createClient({
1282
2521
  privateKey,
1283
2522
  chainId: this._chainId,
1284
2523
  apiKeyIndex,
1285
2524
  accountIndex: this._accountIndex,
1286
2525
  });
1287
2526
  // Sign ChangePubKey
1288
- const signed = await client.signChangePubKey({
2527
+ const signed = client.signChangePubKey({
1289
2528
  pubkey: publicKey,
1290
2529
  nonce,
1291
2530
  apiKeyIndex,
@@ -1306,6 +2545,7 @@ export class LighterAdapter {
1306
2545
  if (signed.txType === undefined || !Number.isFinite(signed.txType)) {
1307
2546
  throw new Error(`Lighter signChangePubKey returned no txType (received ${signed.txType}); refusing to send with default 0.`);
1308
2547
  }
2548
+ await this.rhRate("sendTx");
1309
2549
  const sendRes = await fetch(`${this._baseUrl}/api/v1/sendTx`, {
1310
2550
  method: "POST",
1311
2551
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -1339,7 +2579,7 @@ export class LighterAdapter {
1339
2579
  continue;
1340
2580
  }
1341
2581
  const freshNonce = freshNonceCandidate + attempt;
1342
- const retrySigned = await client.signChangePubKey({
2582
+ const retrySigned = client.signChangePubKey({
1343
2583
  pubkey: publicKey, nonce: freshNonce, apiKeyIndex, accountIndex: this._accountIndex,
1344
2584
  });
1345
2585
  if (retrySigned.error || !retrySigned.txInfo || !retrySigned.messageToSign) {
@@ -1352,6 +2592,7 @@ export class LighterAdapter {
1352
2592
  }
1353
2593
  const retryTxInfo = JSON.parse(retrySigned.txInfo);
1354
2594
  retryTxInfo.L1Sig = await this._evmSigner.signMessage(retrySigned.messageToSign);
2595
+ await this.rhRate("sendTx");
1355
2596
  const retryRes = await fetch(`${this._baseUrl}/api/v1/sendTx`, {
1356
2597
  method: "POST",
1357
2598
  headers: { "Content-Type": "application/x-www-form-urlencoded" },