perp-cli 0.12.17 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +303 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +357 -459
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1386 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -6,8 +6,31 @@
6
6
  *
7
7
  * Composes with LighterAdapter for signing and API access.
8
8
  */
9
+ import { TX_EVENT_FILL_SOURCE } from "./spot-interface.js";
10
+ import { lighterWafChallenge } from "./lighter-public.js";
11
+ import { LIGHTER_TIF, lighterEventFilledBase, lighterEventOrderId, lighterGeneratedClientOrderIndex, lighterOrderIdOf, lighterOrderSignIndex, lighterPostSendTx, lighterRowOutcomeOf, lighterSelectAccount, lighterTakerFilledBase, lighterTxState, } from "./lighter.js";
12
+ import { alignProtectionPrice, floorSize, formatComputedPrice, requireOnTick, slippagePrice, subDecimal, toUnitsExact } from "./precision.js";
13
+ import { unsupported } from "./trading-v2.js";
14
+ import { PerpError, orderNotSent, parsePositiveDecimal } from "../errors.js";
15
+ /** The row match (market + send time) _placeOrder attached to its result; absent → null (no extra match). */
16
+ function lighterRowMatchOf(result) {
17
+ const m = result.rowMatch;
18
+ return m !== undefined && typeof m.marketIndex === "number" && typeof m.sentAtMs === "number" ? { marketIndex: m.marketIndex, sentAtMs: m.sentAtMs } : null;
19
+ }
20
+ /**
21
+ * `venueRaw.fillSource` of a spot IOC fill read from the order's accountOrders row
22
+ * (`filled_base_amount`, the venue's own field — never size − remaining).
23
+ */
24
+ export const ACCOUNT_ORDERS_FILL_SOURCE = "account-orders";
25
+ /** SpotOrderResult status of a final Lighter order outcome (cancelled with a fill → partial). */
26
+ function spotStatusOf(o) {
27
+ if (o.status === "partiallyFilled")
28
+ return "partial";
29
+ return o.status;
30
+ }
9
31
  export class LighterSpotAdapter {
10
- name = "lighter";
32
+ /** Venue id of the wrapped LighterAdapter ("lighter" | "lighter-rh"). */
33
+ name;
11
34
  _lt;
12
35
  _spotMarketMap = new Map(); // "ETH/USDC" → market_id
13
36
  _spotBaseMap = new Map(); // "ETH" → "ETH/USDC"
@@ -15,14 +38,20 @@ export class LighterSpotAdapter {
15
38
  _spotMinSize = new Map(); // min order sizes
16
39
  _spotPrices = new Map(); // "ETH/USDC" → markPrice (from init)
17
40
  _initialized = false;
18
- _clientOrderCounter = 0;
19
41
  constructor(ltAdapter) {
20
42
  this._lt = ltAdapter;
43
+ this.name = ltAdapter.name;
44
+ }
45
+ /** Quote/collateral symbol of the wrapped venue ("USDC" on Lighter, "USDG" on Lighter RH). */
46
+ get _quote() {
47
+ return this._lt.collateralSymbol;
21
48
  }
22
49
  async init() {
23
50
  if (this._initialized)
24
51
  return;
25
52
  await this._loadSpotMarkets();
53
+ // Unified vs Classic account decides spot↔perp transfers (venue spot accountModel "runtime").
54
+ await this._lt.resolveAccountTradingMode();
26
55
  this._initialized = true;
27
56
  }
28
57
  async _loadSpotMarkets() {
@@ -30,7 +59,8 @@ export class LighterSpotAdapter {
30
59
  // Use orderBooks API with filter=spot for accurate market config
31
60
  const res = await this._restGet("/orderBooks", { filter: "spot" });
32
61
  for (const ob of res.order_books ?? []) {
33
- if (ob.status !== "active")
62
+ // Spot only, by the venue's market_type (never inferred from the market id).
63
+ if (ob.status !== "active" || ob.market_type !== "spot")
34
64
  continue;
35
65
  const symbol = ob.symbol.toUpperCase(); // e.g., "ETH/USDC"
36
66
  this._spotMarketMap.set(symbol, ob.market_id);
@@ -44,7 +74,7 @@ export class LighterSpotAdapter {
44
74
  });
45
75
  // Cache price from orderBooks response (avoids N separate orderbook calls in getSpotMarkets)
46
76
  const price = ob.mark_price ?? (ob.best_ask_price && ob.best_bid_price
47
- ? String((parseFloat(ob.best_ask_price) + parseFloat(ob.best_bid_price)) / 2) : "0");
77
+ ? formatComputedPrice((parseFloat(ob.best_ask_price) + parseFloat(ob.best_bid_price)) / 2) : "0");
48
78
  if (price !== "0")
49
79
  this._spotPrices.set(symbol, price);
50
80
  const base = symbol.split("/")[0];
@@ -53,24 +83,17 @@ export class LighterSpotAdapter {
53
83
  }
54
84
  }
55
85
  catch (e) {
56
- // Fallback: use explorer API if orderBooks fails
57
- try {
58
- const res = await fetch("https://explorer.elliot.ai/api/markets");
59
- const markets = (await res.json());
60
- for (const m of markets) {
61
- if (!m.symbol.includes("/"))
62
- continue;
63
- const symbol = m.symbol.toUpperCase();
64
- this._spotMarketMap.set(symbol, m.market_index);
65
- this._spotDecimals.set(symbol, { size: 2, price: 4 });
66
- const base = symbol.split("/")[0];
67
- if (base)
68
- this._spotBaseMap.set(base, symbol);
69
- }
70
- }
71
- catch {
72
- console.error("[lt-spot] Failed to load spot markets:", e instanceof Error ? e.message : e);
73
- }
86
+ // No second source: the explorer market list carries no size / price decimals, and decimals
87
+ // are never assumed (Rule #2). Init fails; nothing is traded or quoted without venue decimals.
88
+ this._spotMarketMap.clear();
89
+ this._spotDecimals.clear();
90
+ this._spotBaseMap.clear();
91
+ if (e instanceof PerpError)
92
+ throw e;
93
+ throw new PerpError("EXCHANGE_ERROR", `Failed to load ${this._lt.name} ${this._lt.networkName} spot markets (/orderBooks: ${e instanceof Error ? e.message : String(e)}); refusing to trade without the venue's size / price decimals`, {
94
+ exchange: this._lt.name,
95
+ remediation: "Retry shortly — the venue's spot market metadata could not be read",
96
+ });
74
97
  }
75
98
  }
76
99
  /** Resolve a symbol (e.g., "ETH" or "ETH/USDC") to the full spot symbol */
@@ -85,7 +108,10 @@ export class LighterSpotAdapter {
85
108
  if (key.split("/")[0] === symbol.toUpperCase())
86
109
  return val;
87
110
  }
88
- throw new Error(`No decimal info for spot market ${symbol}. Ensure init() completed successfully.`);
111
+ throw new PerpError("SYMBOL_NOT_FOUND", `${this.name}: no venue size / price decimals for spot market ${symbol}; refusing to trade or quote it`, {
112
+ exchange: this.name,
113
+ remediation: `Check the symbol: perp -e ${this.name} market spot-list`,
114
+ });
89
115
  }
90
116
  resolveSymbol(symbol) {
91
117
  const upper = symbol.toUpperCase();
@@ -95,8 +121,8 @@ export class LighterSpotAdapter {
95
121
  const full = this._spotBaseMap.get(upper);
96
122
  if (full)
97
123
  return full;
98
- // Try adding /USDC
99
- const withUsdc = `${upper}/USDC`;
124
+ // Try adding the venue quote (/USDC on Lighter, /USDG on Lighter RH)
125
+ const withUsdc = `${upper}/${this._quote}`;
100
126
  if (this._spotMarketMap.has(withUsdc))
101
127
  return withUsdc;
102
128
  return upper;
@@ -110,19 +136,25 @@ export class LighterSpotAdapter {
110
136
  if (missingPrices.length > 0) {
111
137
  const results = await Promise.allSettled(missingPrices.map(([symbol]) => this.getSpotOrderbook(symbol).then(ob => {
112
138
  if (ob.bids.length > 0 && ob.asks.length > 0) {
113
- this._spotPrices.set(symbol, String((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2));
139
+ this._spotPrices.set(symbol, formatComputedPrice((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2));
114
140
  }
115
141
  })));
116
142
  void results; // consume
117
143
  }
118
144
  return entries.map(([symbol]) => {
119
145
  const parts = symbol.split("/");
120
- const base = parts[0] ?? "";
121
- const quote = parts[1] ?? "USDC";
146
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
147
+ throw new PerpError("EXCHANGE_ERROR", `Lighter spot market symbol "${symbol}" is not in BASE/QUOTE form`, {
148
+ remediation: "The Lighter spot market list returned an unexpected symbol shape; report it or retry after the API is fixed.",
149
+ });
150
+ }
151
+ const [base, quote] = parts;
122
152
  const dec = this._getDecimals(symbol);
153
+ const px = this._spotPrices.get(symbol);
123
154
  return {
124
155
  symbol, baseToken: base, quoteToken: quote,
125
- markPrice: this._spotPrices.get(symbol) ?? "0", volume24h: "0",
156
+ // null = no mark and no two-sided book (never "0", Rule #2)
157
+ markPrice: px === undefined ? null : px, volume24h: null,
126
158
  sizeDecimals: dec.size, priceDecimals: dec.price,
127
159
  };
128
160
  });
@@ -148,48 +180,214 @@ export class LighterSpotAdapter {
148
180
  return { bids: [], asks: [] };
149
181
  }
150
182
  }
183
+ /**
184
+ * Spot token balances. Reads the account through the shared 15s `acct:` cache
185
+ * (same entry as LighterAdapter.fetchAccount()). Throws on any failure — never []
186
+ * (Rule #2: an empty list is indistinguishable from an empty wallet).
187
+ * Fill measurement / post-trade verification must use getSpotBalancesFresh().
188
+ */
151
189
  async getSpotBalances() {
152
- try {
153
- await this.init();
154
- const address = this._lt.address;
155
- if (!address)
156
- return [];
157
- // Reuse cached account data (shared with LighterAdapter.fetchAccount() — saves 1 API call)
158
- const { withCache, TTL_ACCOUNT } = await import("../cache.js");
159
- const acct = await withCache(`acct:lt:account:${address}`, TTL_ACCOUNT, async () => {
160
- const res = await this._restGet("/account", {
161
- by: "l1_address",
162
- value: address,
163
- });
164
- const found = res.accounts?.find(a => a.account_index === this._lt.accountIndex)
165
- ?? res.accounts?.[0];
166
- return found ?? null;
190
+ return this._spotBalances(false);
191
+ }
192
+ /** Uncached read (always hits /account, then refreshes the shared cache entry). */
193
+ async getSpotBalancesFresh() {
194
+ return this._spotBalances(true);
195
+ }
196
+ async _spotBalances(fresh) {
197
+ await this.init();
198
+ const address = this._lt.address;
199
+ if (!address) {
200
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balances: no account address configured`, {
201
+ exchange: this.name,
202
+ remediation: `Configure the ${this.name} account (perp setup) before reading spot balances`,
167
203
  });
168
- if (!acct)
169
- return [];
170
- const balances = [];
171
- // Spot token balances from the assets array only.
172
- // Perp USDC (collateral) is already reported by getBalance() — don't duplicate here.
173
- const assets = (acct.assets ?? []);
174
- for (const asset of assets) {
175
- const bal = parseFloat(asset.balance || "0");
176
- const locked = parseFloat(asset.locked_balance || "0");
177
- if (bal > 0) {
178
- balances.push({
179
- token: asset.symbol === "USDC" ? "USDC_SPOT" : asset.symbol,
180
- total: asset.balance,
181
- available: String(bal - locked),
182
- held: asset.locked_balance,
183
- });
204
+ }
205
+ const { withCache, fetchAndCache, TTL_ACCOUNT } = await import("../cache.js");
206
+ const key = `acct:${this._lt.accountCachePrefix}:account:${address}`;
207
+ const fetcher = async () => {
208
+ const res = await this._restGet("/account", {
209
+ by: "l1_address",
210
+ value: address,
211
+ });
212
+ return lighterSelectAccount(res.accounts, this._lt.accountIndex, this.name);
213
+ };
214
+ const acct = (fresh ? await fetchAndCache(key, TTL_ACCOUNT, fetcher) : await withCache(key, TTL_ACCOUNT, fetcher));
215
+ if (!acct) {
216
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balances: no account found for ${address}`, {
217
+ exchange: this.name,
218
+ remediation: `Check the ${this.name} account index / address (perp -e ${this.name} account info)`,
219
+ });
220
+ }
221
+ const balances = [];
222
+ const quote = this._quote;
223
+ if (acct.assets !== undefined && !Array.isArray(acct.assets)) {
224
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balances: account.assets is not an array`, { exchange: this.name, raw: acct.assets });
225
+ }
226
+ const assets = (acct.assets ?? []);
227
+ // Docs (docs.lighter.xyz/trading/unified-trading-accounts): "Unified Trading Accounts (UTA)
228
+ // enables unified margin on Spot and Perpetuals USDC balances." vs Simple: "perpetual and spot
229
+ // balances are maintained separately." Mode = this same response's account_trading_mode
230
+ // ("Classic=0 and Unified=1", apidocs reference/account-1); an unknown mode fails the quote row.
231
+ const quoteShared = () => {
232
+ const mode = acct.account_trading_mode;
233
+ if (mode !== 0 && mode !== 1) {
234
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balances: account_trading_mode is ${JSON.stringify(mode ?? null)} (expected 0 classic | 1 unified)`, {
235
+ exchange: this.name,
236
+ remediation: `Retry; check the account with: perp -e ${this.name} account info`,
237
+ });
238
+ }
239
+ return mode === 1;
240
+ };
241
+ const plainDecimal = (v, field) => {
242
+ if (typeof v !== "string" || !/^-?\d+(\.\d+)?$/.test(v)) {
243
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balances: ${field} is ${JSON.stringify(v ?? null)} (expected a decimal string)`, {
244
+ exchange: this.name,
245
+ remediation: `Retry; check the account with: perp -e ${this.name} account info`,
246
+ });
247
+ }
248
+ return v;
249
+ };
250
+ for (const asset of assets) {
251
+ const bal = Number(asset.balance);
252
+ const locked = Number(asset.locked_balance);
253
+ if (!Number.isFinite(bal) || !Number.isFinite(locked)) {
254
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot balance for ${asset.symbol} is not a number (balance=${asset.balance}, locked=${asset.locked_balance})`, { exchange: this.name, raw: asset });
255
+ }
256
+ if (asset.symbol === quote && quoteShared()) {
257
+ // Unified account: the quote is ONE balance for spot and perp, and the venue books it
258
+ // under the asset row's `margin_balance` with `balance` "0.000000" (GET /account,
259
+ // mainnet account 723334, 2026-10-05: assets[USDC] balance "0.000000", margin_balance
260
+ // "15.952247503314"; account available_balance "15.952247"). apidocs reference/account-1
261
+ // lists the asset fields `balance`, `locked_balance`, `margin_mode` ("enabled" |
262
+ // "disabled"), `margin_balance` and the account field `available_balance` without
263
+ // defining them further, so:
264
+ // total = balance + margin_balance (the quote the account holds, exact sum);
265
+ // available = account `available_balance` — the only "free" figure the venue reports
266
+ // for the unified margin, i.e. what is not committed as perp margin and
267
+ // therefore usable by a spot order. It is the venue's number, not
268
+ // total − held (it moves with perp margin use and unrealized PnL);
269
+ // held = locked_balance (quote locked by resting spot orders).
270
+ // Named by the bare quote symbol: it is not a separate spot balance (`_SPOT`).
271
+ const total = subDecimal(plainDecimal(asset.balance, `${quote} balance`), subDecimal("0", plainDecimal(asset.margin_balance, `${quote} margin_balance`)));
272
+ if (Number(total) > 0) {
273
+ balances.push({ token: quote, total, available: plainDecimal(acct.available_balance, "available_balance"), held: asset.locked_balance, shared: true });
184
274
  }
275
+ continue;
276
+ }
277
+ if (bal > 0) {
278
+ balances.push({
279
+ // The quote row carries the bare quote symbol ("USDC" / "USDG") on classic accounts too:
280
+ // `shared: false` is what says it is a separate spot balance, not a `_SPOT` suffix.
281
+ token: asset.symbol,
282
+ total: asset.balance,
283
+ available: String(bal - locked),
284
+ held: asset.locked_balance,
285
+ // Classic quote: a separate spot balance. Base assets are never perp margin here.
286
+ shared: false,
287
+ });
185
288
  }
186
- return balances;
187
289
  }
188
- catch {
189
- return [];
290
+ return balances;
291
+ }
292
+ async spotMarketOrder(symbol, side, size, opts) {
293
+ // Everything up to sendTx is pre-send: a failure there is a definite "not placed"
294
+ // (ORDER_REJECTED); sendTx transport failures are ORDER_PENDING (design §12.3).
295
+ let prep;
296
+ try {
297
+ prep = await this._prepareMarketOrder(symbol, side, size, opts.slippage.bps);
298
+ }
299
+ catch (e) {
300
+ throw orderNotSent(this.name, `spot ${side} ${size} ${symbol}`, e);
190
301
  }
302
+ const { resolved, marketId, baseAmount, priceTicks, dec, protection } = prep;
303
+ const result = await this._placeOrder({
304
+ marketIndex: marketId,
305
+ baseAmount,
306
+ price: Math.max(priceTicks, 1),
307
+ isAsk: side === "sell" ? 1 : 0,
308
+ orderType: 1, // MARKET
309
+ timeInForce: 0, // IOC
310
+ });
311
+ // Venue-final IOC outcome, as perps (LighterAdapter.awaitIocOutcome): the executed tx's
312
+ // event_info taker order "filled" (the whole sent amount, lighterTakerFilledBase); any other
313
+ // status → the order's accountOrders terminal row by client order index (filled_base_amount —
314
+ // a cancelled remainder zeroes `rs`, so is − rs is never a fill). Zero fill → ORDER_REJECTED;
315
+ // positive → a final SpotOrderResult. No report at all → the raw sendTx result is returned
316
+ // and spotMarketFill() measures the fill as a FRESH base-balance delta (zero → ORDER_PENDING).
317
+ const out = await this._iocOutcome(result, resolved, baseAmount, dec.size, `spot ${side} ${size} ${resolved}`, protection);
318
+ if (out !== null)
319
+ return out;
320
+ return { ...result, protection };
321
+ }
322
+ /** Poll /tx by hash (lighterTxState): rejected → ORDER_REJECTED; returns the last state + raw tx. */
323
+ async _awaitTx(txHash, what, attempts) {
324
+ let state = { kind: "pending", status: null };
325
+ let tx = null;
326
+ for (let attempt = 0; attempt < attempts; attempt++) {
327
+ await new Promise(r => setTimeout(r, 500));
328
+ try {
329
+ tx = await this._restGet("/tx", { by: "hash", value: txHash });
330
+ }
331
+ catch {
332
+ continue; // tx not indexed yet / transient read failure — poll again
333
+ }
334
+ // 0 Failed · 1 Pending · 2 Executed · 3 Pending-final; executed = 2/3 with executed_at > 0.
335
+ state = lighterTxState(tx);
336
+ if (state.kind === "rejected") {
337
+ // The venue did not execute it (definite): status 0 or an event_info app error.
338
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: tx rejected (status=${state.status}, ${state.reason}, hash=${txHash})`, { exchange: this.name, txHash });
339
+ }
340
+ if (state.kind === "executed")
341
+ break;
342
+ }
343
+ return { state, tx };
344
+ }
345
+ /**
346
+ * Final IOC outcome of a sent spot order (market or limit IOC) — see spotMarketOrder. Returns
347
+ * a final SpotOrderResult, throws ORDER_REJECTED for a zero fill, or null when neither /tx nor
348
+ * accountOrders settled it.
349
+ */
350
+ async _iocOutcome(result, resolved, sentBase, sizeDecimals, what, protection) {
351
+ const txHash = typeof result.tx_hash === "string" && result.tx_hash !== "" ? result.tx_hash : null;
352
+ if (txHash === null)
353
+ return null;
354
+ const step = sizeDecimals <= 0 ? "1" : `0.${"0".repeat(sizeDecimals - 1)}1`;
355
+ const { state, tx } = await this._awaitTx(txHash, what, this._lt.txPollAttempts(10));
356
+ let filledBase = state.kind === "executed" ? lighterTakerFilledBase(state.eventInfo, sentBase) : null;
357
+ let fillSource = TX_EVENT_FILL_SOURCE;
358
+ let row = null;
359
+ if (filledBase === null) {
360
+ // Only a terminal row settles an IOC (filled / canceled-*), whether or not /tx settled.
361
+ row = await this._lt.orderRowOutcome(result.clientOrderIndex, null, what, o => o.status !== "open", lighterRowMatchOf(result));
362
+ if (row !== null) {
363
+ filledBase = Number(toUnitsExact(row.filledSize, step, "filled size"));
364
+ fillSource = ACCOUNT_ORDERS_FILL_SOURCE;
365
+ }
366
+ }
367
+ if (filledBase === null) {
368
+ console.error(`[lighter-spot] ${what}: no fill report from /tx or accountOrders (hash=${txHash}) — fill will be measured from a fresh balance read`);
369
+ return null;
370
+ }
371
+ if (filledBase === 0) {
372
+ throw new PerpError("ORDER_REJECTED", `${this.name} ${what}: IOC filled nothing (hash=${txHash}${row !== null ? `, venue status ${row.venueStatus}` : ""}) — the order was cancelled by the venue`, {
373
+ exchange: this.name, txHash,
374
+ });
375
+ }
376
+ return {
377
+ venue: this.name,
378
+ symbol: resolved,
379
+ orderId: txHash,
380
+ status: filledBase >= sentBase ? "filled" : "partial",
381
+ filledSize: (filledBase / 10 ** sizeDecimals).toFixed(sizeDecimals),
382
+ avgPrice: null,
383
+ finality: "final", // IOC: the remainder (if any) is cancelled by the venue
384
+ receivedToken: null,
385
+ remediation: null,
386
+ venueRaw: { sendTx: result, tx, fillSource, row, ...(protection !== null ? { protection } : {}) },
387
+ };
191
388
  }
192
- async spotMarketOrder(symbol, side, size) {
389
+ /** Pre-send part of spotMarketOrder: market, min size, reference price, ticks. */
390
+ async _prepareMarketOrder(symbol, side, size, slippageBps) {
193
391
  await this.init();
194
392
  const resolved = this.resolveSymbol(symbol);
195
393
  const marketId = this._spotMarketMap.get(resolved);
@@ -202,7 +400,7 @@ export class LighterSpotAdapter {
202
400
  if (minSize && sizeNum < minSize.base) {
203
401
  throw new Error(`Size ${size} below min_base_amount ${minSize.base} for ${resolved}`);
204
402
  }
205
- // Use orderbook-based price with 5% slippage (like Hyperliquid spot adapter)
403
+ // Protection price = best opposite quote ± the resolved slippage bound (design §13.2)
206
404
  const ob = await this.getSpotOrderbook(resolved);
207
405
  let refPrice;
208
406
  if (side === "buy") {
@@ -219,40 +417,30 @@ export class LighterSpotAdapter {
219
417
  if (minSize && sizeNum * refPrice < minSize.quote) {
220
418
  throw new Error(`Order value $${(sizeNum * refPrice).toFixed(2)} below min_quote_amount $${minSize.quote} for ${resolved}`);
221
419
  }
222
- const slippagePrice = side === "buy" ? refPrice * 1.05 : refPrice * 0.95;
223
420
  const dec = this._getDecimals(resolved);
224
- const baseAmount = Math.round(sizeNum * Math.pow(10, dec.size));
225
- const priceTicks = Math.round(slippagePrice * Math.pow(10, dec.price));
226
- const result = await this._placeOrder({
227
- marketIndex: marketId,
228
- baseAmount,
229
- price: Math.max(priceTicks, 1),
230
- isAsk: side === "sell" ? 1 : 0,
231
- orderType: 1, // MARKET
232
- timeInForce: 0, // IOC
233
- });
234
- // Verify fill via tx endpoint (best-effort: tx was accepted by sendTx, so likely executed)
235
- const txResult = result;
236
- if (txResult.tx_hash) {
237
- for (let attempt = 0; attempt < 10; attempt++) {
238
- await new Promise(r => setTimeout(r, 500));
239
- try {
240
- const tx = await this._restGet("/tx", { by: "hash", value: txResult.tx_hash });
241
- const status = Number(tx.status ?? 0);
242
- if (status >= 2 && status <= 3)
243
- return result; // status 2=executed, 3=committed
244
- if (status > 3)
245
- throw new Error(`Spot ${side} ${symbol}: tx rejected (status=${status})`);
246
- }
247
- catch (e) {
248
- if (e instanceof Error && e.message.startsWith("Spot "))
249
- throw e;
250
- }
251
- }
252
- // tx was accepted (sendTx 200) but verification timed out — warn, don't throw
253
- console.error(`[lighter-spot] Warning: spot ${side} ${symbol} tx not confirmed after 5s (hash=${txResult.tx_hash}) — assuming executed`);
254
- }
255
- return result;
421
+ // Tick conversion rounds toward crossing (buy → up, sell → down), never to nearest.
422
+ const tick = dec.price <= 0 ? "1" : `0.${"0".repeat(dec.price - 1)}1`;
423
+ const protectionPrice = alignProtectionPrice(slippagePrice(String(refPrice), side, slippageBps), tick, side);
424
+ // Size floored to the size step (exact decimals; never rounded up past the request); 0 → SIZE_TOO_SMALL.
425
+ const sizeStep = dec.size <= 0 ? "1" : `0.${"0".repeat(dec.size - 1)}1`;
426
+ const baseAmount = Number(toUnitsExact(floorSize(size, sizeStep), sizeStep, "size"));
427
+ const priceTicks = Number(toUnitsExact(protectionPrice, tick, "protection price"));
428
+ const appliedBps = Math.round(Math.abs(Number(protectionPrice) / refPrice - 1) * 10_000 * 100) / 100;
429
+ return { resolved, marketId, baseAmount, priceTicks, dec, protection: { referencePrice: String(refPrice), protectionPrice, appliedBps } };
430
+ }
431
+ /**
432
+ * Final IOC fill of a spotMarketOrder / IOC spotLimitOrder result (SpotOrderResult read from
433
+ * the tx event_info or the accountOrders row), or null when the outcome is unknown (raw sendTx
434
+ * result → balance delta).
435
+ */
436
+ iocOutcomeOf(raw) {
437
+ if (typeof raw !== "object" || raw === null)
438
+ return null;
439
+ const r = raw;
440
+ const src = r.venueRaw?.fillSource;
441
+ if (r.finality !== "final" || typeof r.filledSize !== "string" || (src !== TX_EVENT_FILL_SOURCE && src !== ACCOUNT_ORDERS_FILL_SOURCE))
442
+ return null;
443
+ return { filledSize: r.filledSize, final: true };
256
444
  }
257
445
  async spotLimitOrder(symbol, side, price, size, opts) {
258
446
  await this.init();
@@ -262,35 +450,95 @@ export class LighterSpotAdapter {
262
450
  throw new Error(`Unknown Lighter spot market: ${symbol}`);
263
451
  }
264
452
  const dec = this._getDecimals(resolved);
265
- // Auto-round to exchange precision — caller doesn't need to know decimals
266
- let sizeNum = Number(Number(size).toFixed(dec.size));
267
- const priceNum = Number(Number(price).toFixed(dec.price));
268
- // Auto-ceil size to min_base_amount if just below (within 10%)
453
+ const sizeStep = dec.size <= 0 ? "1" : `0.${"0".repeat(dec.size - 1)}1`;
454
+ const tick = dec.price <= 0 ? "1" : `0.${"0".repeat(dec.price - 1)}1`;
455
+ // Size floored to the size step (never rounded up); price must already be on tick
456
+ // (INVALID_TICK, never rounded — design §10.10).
457
+ const sentSize = floorSize(size, sizeStep);
458
+ const sentPrice = requireOnTick(price, tick);
459
+ // Venue minimums: refused, never bumped up to the minimum (the order would exceed the request).
269
460
  const minSize = this._spotMinSize.get(resolved);
270
- if (minSize && sizeNum > 0 && sizeNum < minSize.base && sizeNum >= minSize.base * 0.9) {
271
- sizeNum = minSize.base;
272
- }
273
- // Validate min order size
274
461
  if (minSize) {
275
- if (sizeNum < minSize.base) {
276
- throw new Error(`Size ${sizeNum} below min_base_amount ${minSize.base} for ${resolved}`);
462
+ if (Number(sentSize) < minSize.base) {
463
+ throw new PerpError("SIZE_TOO_SMALL", `Size ${sentSize} below min_base_amount ${minSize.base} for ${resolved}`, {
464
+ exchange: "lighter", remediation: `Use a size of at least ${minSize.base}`,
465
+ });
277
466
  }
278
- if (sizeNum * priceNum < minSize.quote) {
279
- throw new Error(`Order value $${(sizeNum * priceNum).toFixed(2)} below min_quote_amount $${minSize.quote} for ${resolved}`);
467
+ if (Number(sentSize) * Number(sentPrice) < minSize.quote) {
468
+ throw new PerpError("SIZE_TOO_SMALL", `Order value $${(Number(sentSize) * Number(sentPrice)).toFixed(2)} below min_quote_amount $${minSize.quote} for ${resolved}`, {
469
+ exchange: "lighter", remediation: `Increase the size so size × price is at least $${minSize.quote}`,
470
+ });
280
471
  }
281
472
  }
282
- const baseAmount = Math.round(sizeNum * Math.pow(10, dec.size));
283
- const priceTicks = Math.round(priceNum * Math.pow(10, dec.price));
284
- const isIoc = opts?.tif?.toUpperCase() === "IOC";
473
+ const baseAmount = Number(toUnitsExact(sentSize, sizeStep, "size"));
474
+ const priceTicks = Number(toUnitsExact(sentPrice, tick, "price"));
475
+ const timeInForce = this._limitTif(opts?.tif);
285
476
  const result = await this._placeOrder({
286
477
  marketIndex: marketId,
287
478
  baseAmount,
288
479
  price: priceTicks,
289
480
  isAsk: side === "sell" ? 1 : 0,
290
481
  orderType: 0, // LIMIT
291
- timeInForce: isIoc ? 0 : 1,
482
+ timeInForce,
292
483
  });
293
- return result;
484
+ const what = `spot limit ${(opts?.tif ?? "GTC").toUpperCase()} ${side} ${sentSize} ${resolved} @ ${sentPrice}`;
485
+ if (timeInForce === LIGHTER_TIF.IOC) {
486
+ // Same venue-final IOC outcome as spotMarketOrder; unresolved → raw (the CLI reports pending).
487
+ return (await this._iocOutcome(result, resolved, baseAmount, dec.size, what, null)) ?? result;
488
+ }
489
+ const out = await this._placementOutcome(result, baseAmount, dec.size, what);
490
+ if (out === null) {
491
+ console.error(`[lighter-spot] ${what}: placement not confirmed yet — verify with: perp trade spot-orders ${resolved} -e ${this.name}`);
492
+ return result;
493
+ }
494
+ return {
495
+ venue: this.name,
496
+ symbol: resolved,
497
+ orderId: out.orderId,
498
+ status: spotStatusOf(out),
499
+ filledSize: out.filledSize,
500
+ avgPrice: null,
501
+ finality: "final",
502
+ receivedToken: null,
503
+ remediation: null,
504
+ venueRaw: { sendTx: result, outcome: out },
505
+ };
506
+ }
507
+ /**
508
+ * TIF mapping, as perps (§13.3): GTC/GTT → GTT, IOC → IOC, ALO / POST_ONLY → post-only (2).
509
+ * FOK and anything else → CAPABILITY_UNSUPPORTED (never silently GTT).
510
+ */
511
+ _limitTif(tif) {
512
+ const t = (tif ?? "GTC").toUpperCase();
513
+ if (t === "GTC" || t === "GTT")
514
+ return LIGHTER_TIF.GTT;
515
+ if (t === "IOC")
516
+ return LIGHTER_TIF.IOC;
517
+ if (t === "ALO" || t === "POST_ONLY")
518
+ return LIGHTER_TIF.POST_ONLY;
519
+ throw unsupported(this.name, t === "FOK" ? "fok" : "timeInForce", `spot time-in-force ${tif} is not offered`, "Use --tif GTC, IOC or ALO");
520
+ }
521
+ /**
522
+ * Final placement outcome of a resting (GTT / post-only) spot limit, as perps
523
+ * (LighterAdapter.resolveOrderOutcome): /tx rejected → ORDER_REJECTED; the executed tx's
524
+ * event_info taker order (this client order index) "filled" → filled; otherwise the order's
525
+ * accountOrders row by client order index (status + filled_base_amount: open, filled,
526
+ * canceled-* — e.g. a crossing post-only). Unresolved → null (pending).
527
+ */
528
+ async _placementOutcome(result, sentBase, sizeDecimals, what) {
529
+ const txHash = typeof result.tx_hash === "string" && result.tx_hash !== "" ? result.tx_hash : null;
530
+ if (txHash !== null) {
531
+ const { state } = await this._awaitTx(txHash, what, this._lt.txPollAttempts(10));
532
+ const to = state.kind === "executed" ? state.eventInfo?.to : undefined;
533
+ if (typeof to === "object" && to !== null) {
534
+ const o = to;
535
+ const filledBase = Number(o.u) === result.clientOrderIndex ? lighterEventFilledBase(o) : null;
536
+ if (filledBase !== null && filledBase === sentBase) {
537
+ return { status: "filled", final: true, venueStatus: "filled", filledSize: (filledBase / 10 ** sizeDecimals).toFixed(sizeDecimals), orderId: lighterEventOrderId(o.i), source: "event_info" };
538
+ }
539
+ }
540
+ }
541
+ return this._lt.orderRowOutcome(result.clientOrderIndex, null, what, () => true, lighterRowMatchOf(result));
294
542
  }
295
543
  async spotCancelOrder(symbol, orderId) {
296
544
  await this.init();
@@ -299,106 +547,178 @@ export class LighterSpotAdapter {
299
547
  if (marketId === undefined) {
300
548
  throw new Error(`Unknown Lighter spot market: ${symbol}`);
301
549
  }
302
- const idNum = Number(orderId);
303
- // If order ID is safe integer, cancel directly
304
- if (Number.isSafeInteger(idNum)) {
305
- const signer = this._lt.signer;
306
- const nonce = await this._getNextNonce();
307
- const signed = await signer.signCancelOrder({
308
- marketIndex: marketId,
309
- orderIndex: idNum,
310
- nonce,
311
- apiKeyIndex: this._lt._apiKeyIndex,
312
- accountIndex: this._lt.accountIndex,
313
- });
314
- return this._sendTx(signed);
550
+ if (!/^\d+$/.test(orderId)) {
551
+ throw new PerpError("INVALID_PARAMS", `${this.name} spot order id "${orderId}" must be a decimal integer; nothing was cancelled`, { exchange: this.name });
552
+ }
553
+ // Look the order up among the market's open orders and sign with its client order index
554
+ // (or its order id when that is 0 and exactly representable); otherwise refuse — never
555
+ // cancel some other order of the market in its place.
556
+ const data = await this._restGetAuth("/accountActiveOrders", {
557
+ account_index: String(this._lt.accountIndex),
558
+ market_id: String(marketId),
559
+ });
560
+ if (!Array.isArray(data.orders)) {
561
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} accountActiveOrders returned no orders array for ${resolved}; nothing was cancelled`, { exchange: this.name });
315
562
  }
316
- // Order ID exceeds MAX_SAFE_INTEGER → try lookup from open orders
317
- // Note: getSpotOpenOrders() can return empty due to block finality delay.
318
- // Prefer spotCancelByClientIndex() with the clientOrderIndex from spotLimitOrder().
319
- const openOrders = await this.getSpotOpenOrders();
320
- const symbolOrders = openOrders.filter(o => o.symbol.split("/")[0].toUpperCase() === resolved.toUpperCase() && o.clientOrderIndex);
321
- if (symbolOrders.length >= 1) {
322
- return this.spotCancelByClientIndex(resolved, symbolOrders[0].clientOrderIndex);
563
+ const row = data.orders.find(o => lighterOrderIdOf(o) === orderId);
564
+ if (row === undefined) {
565
+ throw new PerpError("ORDER_NOT_FOUND", `${this.name} spot order ${orderId} is not among the open ${resolved} orders; nothing was cancelled`, { exchange: this.name });
323
566
  }
324
- throw new Error(`Cannot cancel Lighter spot order ${orderId}: exceeds MAX_SAFE_INTEGER and no clientOrderIndex found`);
325
- }
326
- /** Cancel all orders across all markets (spot + perp) */
327
- async spotCancelAllOrders(_symbol) {
328
- if (this._lt.isReadOnly)
329
- throw new Error("Cancel requires API key");
567
+ const index = lighterOrderSignIndex(row, this.name);
330
568
  const signer = this._lt.signer;
331
569
  const nonce = await this._getNextNonce();
332
- const apiKeyIndex = this._lt._apiKeyIndex;
333
- // time must be in milliseconds, in the future
334
- const signed = await signer.signCancelAllOrders({
335
- timeInForce: 1,
336
- time: Date.now() + 3600_000,
570
+ const signed = signer.signCancelOrder({
571
+ marketIndex: marketId,
572
+ orderIndex: index,
337
573
  nonce,
338
- apiKeyIndex,
574
+ apiKeyIndex: this._lt._apiKeyIndex,
339
575
  accountIndex: this._lt.accountIndex,
340
576
  });
341
- return this._sendTx(signed);
577
+ if (signed.error)
578
+ throw new PerpError("SIGNATURE_FAILED", `${this.name} spot cancel ${orderId}: signer error: ${signed.error}`, { exchange: this.name });
579
+ const sent = await this._sendTx(signed, "order");
580
+ const coi = Number(row.client_order_index ?? row.client_order_id);
581
+ return this._cancelOutcome(sent, resolved, marketId, orderId, Number.isSafeInteger(coi) && coi > 0 ? coi : null);
582
+ }
583
+ /**
584
+ * Venue-final outcome of a sent spot cancel, as perps (awaitTxExecuted + cancelOutcome): /tx
585
+ * rejected → ORDER_REJECTED (the order is untouched). Then the order's own row — by client
586
+ * order index (accountOrders), else by order id among the market's active, then latest 100
587
+ * inactive orders: canceled-* → cancelled (a fill before the cancel → partial, filledSize from
588
+ * filled_base_amount), filled → filled (it filled before the cancel landed). Still open / not
589
+ * readable after a few reads → finality "pending" (do NOT resubmit).
590
+ */
591
+ async _cancelOutcome(sent, resolved, marketId, orderId, coi) {
592
+ const what = `spot cancel ${orderId} ${resolved}`;
593
+ const txHash = sent?.tx_hash;
594
+ if (typeof txHash === "string" && txHash !== "")
595
+ await this._awaitTx(txHash, what, this._lt.txPollAttempts(20));
596
+ let out = null;
597
+ for (let attempt = 0; attempt < 4 && (out === null || out.status === "open"); attempt++) {
598
+ if (attempt > 0)
599
+ await new Promise(r => setTimeout(r, 500));
600
+ try {
601
+ const row = await this._orderRow(marketId, orderId, coi);
602
+ out = row === null ? null : lighterRowOutcomeOf(row, orderId);
603
+ }
604
+ catch (e) {
605
+ console.error(`[lighter-spot] ${what}: order row read failed — ${e instanceof Error ? e.message : String(e)}`);
606
+ }
607
+ }
608
+ const base = { venue: this.name, symbol: resolved, orderId, avgPrice: null, receivedToken: null, venueRaw: { sendTx: sent, outcome: out } };
609
+ if (out === null || out.status === "open") {
610
+ return {
611
+ ...base, status: "pending", filledSize: null, finality: "pending",
612
+ remediation: `Cancel sent; the order is ${out === null ? "not readable yet" : "still listed open"} — do NOT resubmit. Check: perp trade spot-orders ${resolved} -e ${this.name}`,
613
+ };
614
+ }
615
+ return {
616
+ ...base, status: spotStatusOf(out), filledSize: out.filledSize, finality: "final",
617
+ remediation: out.status === "filled" ? `Order ${orderId} filled before the cancel landed` : null,
618
+ };
619
+ }
620
+ /** The order's venue row: accountOrders by client order index, else active → latest 100 inactive by order id; not listed → null. */
621
+ async _orderRow(marketId, orderId, coi) {
622
+ if (coi !== null) {
623
+ return (await this._lt.getOrdersByClientIndex([coi])).find(r => lighterOrderIdOf(r) === orderId) ?? null;
624
+ }
625
+ for (const path of ["/accountActiveOrders", "/accountInactiveOrders"]) {
626
+ const params = { account_index: String(this._lt.accountIndex), market_id: String(marketId) };
627
+ if (path === "/accountInactiveOrders")
628
+ params.limit = "100";
629
+ const res = await this._restGetAuth(path, params);
630
+ if (!Array.isArray(res.orders))
631
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} ${path.slice(1)} returned no orders array`, { exchange: this.name });
632
+ const row = res.orders.find(o => lighterOrderIdOf(o) === orderId);
633
+ if (row !== undefined)
634
+ return row;
635
+ }
636
+ return null;
637
+ }
638
+ /**
639
+ * Cancel the SPOT open orders (of `symbol`, or of every spot market) one by one. Never the
640
+ * account-wide CancelAllOrders tx (market 255): it would also cancel the account's perp orders.
641
+ */
642
+ async spotCancelAllOrders(symbol) {
643
+ if (this._lt.isReadOnly)
644
+ throw new Error("Cancel requires API key");
645
+ const results = [];
646
+ for (const o of await this.getSpotOpenOrders(symbol ?? null)) {
647
+ results.push(await this.spotCancelOrder(o.symbol, o.orderId));
648
+ }
649
+ return results;
342
650
  }
343
651
  /** Transfer USDC from perp account to spot account (required before spot buys) */
344
652
  async transferUsdcToSpot(amount) {
345
- return this._selfTransfer(amount, 0, 1); // route: Perp(0) → Spot(1)
653
+ return this._selfTransfer(String(amount), 0, 1); // route: Perp(0) → Spot(1)
346
654
  }
347
655
  /** Transfer USDC from spot account back to perp account */
348
656
  async transferUsdcToPerp(amount) {
349
- return this._selfTransfer(amount, 1, 0); // route: Spot(1) → Perp(0)
657
+ return this._selfTransfer(String(amount), 1, 0); // route: Spot(1) → Perp(0)
658
+ }
659
+ /** SpotTransferCapable (§12.4 #4): only the venue collateral (USDC on Lighter, USDG on Lighter RH). */
660
+ async transferToSpot(amount, asset) {
661
+ return this._selfTransfer(this._transferAmount(amount, asset), 0, 1);
662
+ }
663
+ async transferToPerp(amount, asset) {
664
+ return this._selfTransfer(this._transferAmount(amount, asset), 1, 0);
665
+ }
666
+ _transferAmount(amount, asset) {
667
+ if (asset !== this._quote) {
668
+ throw new PerpError("INVALID_PARAMS", `${this.name} spot↔perp transfer only moves ${this._quote} (got ${asset})`, { exchange: this.name });
669
+ }
670
+ return parsePositiveDecimal(amount, "Transfer amount");
350
671
  }
351
672
  /**
352
- * Internal self-transfer using raw WASM signTransfer.
353
- * TS SDK's is_spot_account mapping is buggy (sets both fromRoute and toRoute
354
- * to the same value), so we call the WASM module directly with explicit routes.
355
- * Route types: 0 = Perp, 1 = Spot
673
+ * Internal self-transfer (same account, perp ↔ spot route) signed with the official
674
+ * WASM SignTransfer and explicit routes. Route types: 0 = Perp, 1 = Spot. Same-owner
675
+ * transfer → no L1 signature needed (wasm-signer.md).
356
676
  */
357
677
  async _selfTransfer(amount, fromRoute, toRoute) {
358
678
  if (this._lt.isReadOnly)
359
679
  throw new Error("Transfer requires API key");
680
+ // Collateral scale: USDC (Lighter) and USDG (Lighter RH) both have 6 decimals at asset id 3.
681
+ // Exact decimal → integer units from the string (no float multiply: 0.29 * 1e6 = 289999.99…);
682
+ // more than 6 decimals → INVALID_TICK, never silently truncated. Checked before any request.
683
+ const scaled = toUnitsExact(parsePositiveDecimal(amount, "Transfer amount"), "0.000001", "transfer amount");
684
+ if (scaled > BigInt(Number.MAX_SAFE_INTEGER)) {
685
+ throw new PerpError("INVALID_PARAMS", `${this.name} transfer amount ${amount} is too large`, { exchange: this.name });
686
+ }
687
+ const scaledAmount = Number(scaled);
688
+ // Docs (apidocs.lighter.xyz/docs/deposits-transfers-and-withdrawals, "Transfer between Lighter
689
+ // accounts"): "Get the fee: transferFeeInfo (GET /api/v1/transferFeeInfo?account_index=…&
690
+ // to_account_index=…, authenticated). The fee is charged **on top** of the amount and must
691
+ // match exactly." Read before the nonce so the nonce is taken immediately before signing.
692
+ const feeInfo = await this._restGetAuth("/transferFeeInfo", {
693
+ account_index: String(this._lt.accountIndex),
694
+ to_account_index: String(this._lt.accountIndex), // self
695
+ });
696
+ const usdcFee = feeInfo.transfer_fee_usdc;
697
+ if (feeInfo.code !== 200 || typeof usdcFee !== "number" || !Number.isSafeInteger(usdcFee) || usdcFee < 0) {
698
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} transferFeeInfo returned no usable transfer_fee_usdc (code ${JSON.stringify(feeInfo.code ?? null)}, fee ${JSON.stringify(usdcFee ?? null)}); transfer not sent`, {
699
+ exchange: this.name,
700
+ remediation: "Nothing was sent. Retry; the transfer fee must be read from the venue and signed exactly",
701
+ });
702
+ }
360
703
  const nonce = await this._getNextNonce();
361
704
  const apiKeyIndex = this._lt._apiKeyIndex;
362
- const USDC_SCALE = 1_000_000; // 6 decimals
363
- const scaledAmount = Math.floor(amount * USDC_SCALE);
705
+ const collateralAssetIndex = this._lt.collateralAssetIndex;
364
706
  const memo = "0x" + "00".repeat(32);
365
- // Access the raw WASM module to bypass SDK's buggy route mapping
366
- // Path: signer → WasmSignerClient → wallet (WasmSigner) → wasmModule
367
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
- const signerAny = this._lt.signer;
369
- const wasmModule = (signerAny.wallet?.wasmModule ?? signerAny.wasmModule);
370
- if (wasmModule?.signTransfer) {
371
- const result = wasmModule.signTransfer(this._lt.accountIndex, 3, // toAccountIndex (self), assetIndex = USDC
372
- fromRoute, toRoute, scaledAmount, 0, memo, nonce, apiKeyIndex, this._lt.accountIndex);
373
- if (result.error)
374
- throw new Error(`Transfer sign error: ${result.error}`);
375
- return this._sendTx({ txType: result.txType ?? 12, txInfo: result.txInfo });
376
- }
377
- // Fallback: use SDK signTransfer with explicit route_from/route_to
378
- // The SDK's signTransfer internally calls wasmModule.signTransfer with 10 args
379
- const signer = this._lt.signer;
380
- // Try SDK's higher-level transferSameMasterAccount first (if available)
381
- const client = signer;
382
- if (client.transferSameMasterAccount) {
383
- const [txInfo, txHash, err] = await client.transferSameMasterAccount({
384
- toAccountIndex: this._lt.accountIndex,
385
- usdcAmount: amount, // SDK scales internally
386
- asset_id: 3,
387
- fee: 0, memo,
388
- });
389
- if (err)
390
- throw new Error(`Transfer error: ${err}`);
391
- return { code: 200, tx_hash: txHash };
392
- }
393
- // Last resort: SDK signTransfer
394
- const signed = await signer.signTransfer({
395
- toAccountIndex: this._lt.accountIndex,
396
- usdcAmount: scaledAmount, asset_id: 3,
397
- is_spot_account: toRoute === 1,
398
- fee: 0, memo, nonce, apiKeyIndex,
707
+ const signed = this._lt.signer.signTransfer({
708
+ toAccountIndex: this._lt.accountIndex, // self
709
+ assetIndex: collateralAssetIndex,
710
+ fromRouteType: fromRoute,
711
+ toRouteType: toRoute,
712
+ amount: scaledAmount,
713
+ usdcFee,
714
+ memo,
715
+ nonce,
716
+ apiKeyIndex,
399
717
  accountIndex: this._lt.accountIndex,
400
718
  });
401
- return this._sendTx(signed);
719
+ if (signed.error)
720
+ throw new Error(`Transfer sign error: ${signed.error}`);
721
+ return this._sendTx(signed, "action");
402
722
  }
403
723
  /** Get the spot market ID for a symbol */
404
724
  getSpotMarketId(symbol) {
@@ -407,32 +727,47 @@ export class LighterSpotAdapter {
407
727
  /** Check if a base token has a spot market */
408
728
  hasSpotMarket(base) {
409
729
  return this._spotBaseMap.has(base.toUpperCase()) ||
410
- this._spotMarketMap.has(`${base.toUpperCase()}/USDC`);
730
+ this._spotMarketMap.has(`${base.toUpperCase()}/${this._quote}`);
411
731
  }
412
- /** Query open orders across all spot markets (market_id ≥ 2048). */
413
- async getSpotOpenOrders() {
732
+ /** Query open orders across all spot markets (the market_type "spot" markets loaded at init). */
733
+ async getSpotOpenOrders(symbol = null) {
414
734
  await this.init();
415
735
  if (this._lt.isReadOnly)
416
736
  return [];
417
737
  const allOrders = [];
738
+ const only = symbol === null ? null : this.resolveSymbol(symbol);
739
+ // A missing / non-boolean is_ask must not silently become "buy".
740
+ const sideOf = (isAsk, market, raw) => {
741
+ if (typeof isAsk !== "boolean") {
742
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot open order on ${market} has no boolean is_ask (got ${JSON.stringify(isAsk)})`, { exchange: this.name, raw });
743
+ }
744
+ return isAsk ? "sell" : "buy";
745
+ };
418
746
  for (const [sym, marketId] of this._spotMarketMap) {
419
- try {
420
- const data = await this._restGetAuth("/accountActiveOrders", {
421
- account_index: String(this._lt.accountIndex),
422
- market_id: String(marketId),
747
+ if (only !== null && sym !== only)
748
+ continue;
749
+ const data = await this._restGetAuth("/accountActiveOrders", {
750
+ account_index: String(this._lt.accountIndex),
751
+ market_id: String(marketId),
752
+ });
753
+ for (const o of data.orders ?? []) {
754
+ const field = (name) => {
755
+ const v = o[name];
756
+ if (v === undefined || v === null || v === "") {
757
+ throw new PerpError("EXCHANGE_ERROR", `${this.name} spot open order on ${sym} has no ${name}`, { exchange: this.name, raw: o });
758
+ }
759
+ return String(v);
760
+ };
761
+ allOrders.push({
762
+ orderId: lighterOrderIdOf(o) || field("order_id"),
763
+ symbol: sym,
764
+ side: sideOf(o.is_ask, sym, o),
765
+ price: field("price"),
766
+ size: field("remaining_base_amount"),
767
+ filled: field("filled_base_amount"),
768
+ clientOrderIndex: o.client_order_index != null ? Number(o.client_order_index) : undefined,
423
769
  });
424
- for (const o of data.orders ?? []) {
425
- allOrders.push({
426
- orderId: String(o.order_id ?? o.order_index ?? ""),
427
- symbol: sym,
428
- side: o.is_ask ? "sell" : "buy",
429
- price: String(o.price ?? "0"),
430
- size: String(o.remaining_base_amount ?? o.initial_base_amount ?? "0"),
431
- clientOrderIndex: o.client_order_index != null ? Number(o.client_order_index) : undefined,
432
- });
433
- }
434
770
  }
435
- catch { /* skip market on error */ }
436
771
  }
437
772
  return allOrders;
438
773
  }
@@ -446,19 +781,35 @@ export class LighterSpotAdapter {
446
781
  const signer = this._lt.signer;
447
782
  const nonce = await this._getNextNonce();
448
783
  const apiKeyIndex = this._lt._apiKeyIndex;
449
- const signed = await signer.signCancelOrder({
784
+ const signed = signer.signCancelOrder({
450
785
  marketIndex: marketId,
451
786
  orderIndex: clientOrderIndex,
452
787
  nonce,
453
788
  apiKeyIndex,
454
789
  accountIndex: this._lt.accountIndex,
455
790
  });
456
- return this._sendTx(signed);
791
+ if (signed.error)
792
+ throw new PerpError("SIGNATURE_FAILED", `${this.name} spot cancel (client index ${clientOrderIndex}): signer error: ${signed.error}`, { exchange: this.name });
793
+ return this._sendTx(signed, "order");
457
794
  }
458
795
  // ── Private helpers ──
459
796
  async _placeOrder(opts) {
797
+ // Pre-send (key, nonce, signature): a failure is a definite "not placed".
798
+ let signed;
799
+ let clientOrderIndex;
800
+ try {
801
+ ({ signed, clientOrderIndex } = await this._signOrder(opts));
802
+ }
803
+ catch (e) {
804
+ throw orderNotSent(this.name, "spot order", e);
805
+ }
806
+ const rowMatch = { marketIndex: opts.marketIndex, sentAtMs: Date.now() };
807
+ const txResult = await this._sendTx(signed, "place");
808
+ return { ...txResult, clientOrderIndex, rowMatch };
809
+ }
810
+ async _signOrder(opts) {
460
811
  if (this._lt.isReadOnly) {
461
- throw new Error("Spot trading requires a Lighter API key. Run `perp wallet agent approve lighter` to register an agent.");
812
+ throw new Error(`Spot trading requires a Lighter API key. Run \`perp wallet agent approve ${this._lt.name}\` to register an agent.`);
462
813
  }
463
814
  const nonce = await this._getNextNonce();
464
815
  const signer = this._lt.signer;
@@ -467,7 +818,7 @@ export class LighterSpotAdapter {
467
818
  // This value is used for cancel operations instead of the exchange-assigned
468
819
  // order_id which can exceed Number.MAX_SAFE_INTEGER.
469
820
  const clientOrderIndex = this._nextClientOrderIndex();
470
- const signed = await signer.signCreateOrder({
821
+ const signed = signer.signCreateOrder({
471
822
  marketIndex: opts.marketIndex,
472
823
  clientOrderIndex,
473
824
  baseAmount: opts.baseAmount,
@@ -485,15 +836,16 @@ export class LighterSpotAdapter {
485
836
  if (signed.error) {
486
837
  throw new Error(`Signer: ${signed.error}`);
487
838
  }
488
- const txResult = await this._sendTx(signed);
489
- return { ...txResult, clientOrderIndex };
839
+ if (!signed.txInfo)
840
+ throw new Error("Signer returned empty txInfo");
841
+ if (signed.txType === undefined || !Number.isFinite(signed.txType)) {
842
+ throw new Error(`Lighter signer returned no txType (received ${signed.txType}); refusing to send with default 0.`);
843
+ }
844
+ return { signed, clientOrderIndex };
490
845
  }
491
- /** Generate a unique client order index (uint48). Uses timestamp + counter. */
846
+ /** Generated client order index (uint48): ms timestamp + random bits — see lighterGeneratedClientOrderIndex. */
492
847
  _nextClientOrderIndex() {
493
- // Timestamp in seconds gives ~10 digits. Add counter for uniqueness within same second.
494
- // uint48 max = 281_474_976_710_655. Date.now()/1000 ≈ 1.7 trillion (fits).
495
- const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
496
- return base + (this._clientOrderCounter++ % 655);
848
+ return lighterGeneratedClientOrderIndex();
497
849
  }
498
850
  async _getNextNonce() {
499
851
  const apiKeyIndex = this._lt._apiKeyIndex;
@@ -511,7 +863,15 @@ export class LighterSpotAdapter {
511
863
  }
512
864
  return nonce;
513
865
  }
514
- async _sendTx(signed) {
866
+ /**
867
+ * POST /sendTx. `kind` "place" (order placement): failures are typed (design
868
+ * §12.3): before the request → ORDER_REJECTED; transport error, HTTP 5xx or an
869
+ * unreadable body after it → ORDER_PENDING (the tx may have been accepted — never
870
+ * resubmit); an explicit non-200 code → ORDER_REJECTED. "order" (cancels) / "action"
871
+ * (transfers): an explicit non-200 code → ORDER_REJECTED / ACTION_REJECTED
872
+ * (lighterSendTxRejection — validated and refused before execution).
873
+ */
874
+ async _sendTx(signed, kind) {
515
875
  if (signed.error)
516
876
  throw new Error(`Signer error: ${signed.error}`);
517
877
  if (!signed.txInfo)
@@ -520,27 +880,29 @@ export class LighterSpotAdapter {
520
880
  throw new Error(`Lighter signer returned no txType (received ${signed.txType}); refusing to send with default 0.`);
521
881
  }
522
882
  const baseUrl = this._lt._baseUrl;
523
- const res = await fetch(`${baseUrl}/api/v1/sendTx`, {
524
- method: "POST",
525
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
526
- body: new URLSearchParams({
527
- tx_type: String(signed.txType),
528
- tx_info: signed.txInfo,
529
- }),
530
- });
531
- const json = await res.json();
532
- if (json.code !== 200)
533
- throw new Error(`sendTx failed: ${json.message ?? JSON.stringify(json)}`);
534
- return json;
883
+ try {
884
+ await this._lt.rhRate("sendTx");
885
+ }
886
+ catch (e) {
887
+ throw orderNotSent(this.name, kind === "place" ? "spot order" : `spot ${kind}`, e);
888
+ }
889
+ // Same send contract for placements, cancels and transfers (lighterPostSendTx): transport
890
+ // failure / 5xx / unreadable body / internal or missing code → ORDER_PENDING; only a
891
+ // documented validation code is a definite rejection.
892
+ return lighterPostSendTx(baseUrl, { txType: signed.txType, txInfo: signed.txInfo }, this.name, kind === "action" ? "action" : "order", kind === "place" ? "spot order sendTx" : `spot ${kind} sendTx`);
535
893
  }
536
894
  async _restGet(path, params) {
537
895
  const baseUrl = this._lt._baseUrl;
538
896
  const url = new URL(`${baseUrl}/api/v1${path}`);
539
897
  for (const [k, v] of Object.entries(params))
540
898
  url.searchParams.set(k, v);
899
+ await this._lt.rhRate(path);
541
900
  const res = await fetch(url.toString());
542
901
  if (!res.ok) {
543
902
  const text = await res.text();
903
+ const waf = lighterWafChallenge(this._lt.name, res.status, text);
904
+ if (waf !== null)
905
+ throw waf;
544
906
  const clean = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
545
907
  throw new Error(`GET ${path} failed (${res.status}): ${clean}`);
546
908
  }