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
@@ -18,10 +18,142 @@
18
18
  * Docs: https://docs.asterdex.com/product/aster-perpetuals/api/api-documentation
19
19
  * Base: https://fapi.asterdex.com
20
20
  */
21
+ import { accountCacheEpoch } from "../cache.js";
21
22
  import { isExpired } from "../agent-wallet/expiry.js";
22
- import { classifyError, PerpError } from "../errors.js";
23
- import { buildOrderTypedData } from "./aster-typed-data.js";
23
+ import { classifyError, DEFINITE_ORDER_FAILURE_CODES, isTransportFailure, orderNotSent, orderOutcomeUnknown, PerpError, sendFailure } from "../errors.js";
24
+ import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
25
+ import { buildOrderTypedData, buildWithdrawTypedData } from "./aster-typed-data.js";
26
+ import { alignProtectionPrice, requireOnTick, requirePositive, slippagePrice, subDecimal } from "./precision.js";
27
+ import { unsupported } from "./trading-v2.js";
28
+ import { networkConfig } from "./venue.js";
29
+ import { approvalFromMax, bpsToRate, builderGateOf, NO_BUILDER, rateToBps, requireFeeBps } from "./builder-fee.js";
30
+ import { ASTER_CLIENT_ID_RE, ASTER_FUNDS_CHAINS, asterVenue } from "./venues/aster.js";
31
+ import { assertAsterWithdrawAmount, assertAsterWithdrawSignature, parseAsterWithdrawLimits, requireAsterFundsNetwork, } from "./aster-funds.js";
32
+ import { nextAsterNonce } from "./aster-nonce.js";
33
+ /** Common TIF (§13.3) → Aster perp timeInForce. Live exchangeInfo lists GTC/IOC/GTX/HIDDEN — no FOK. */
34
+ function asterTif(raw) {
35
+ const t = raw.toUpperCase();
36
+ if (t === "GTC" || t === "IOC" || t === "GTX")
37
+ return t;
38
+ if (t === "ALO")
39
+ return "GTX";
40
+ if (t === "FOK") {
41
+ throw unsupported("Aster", "fok", "perp symbols offer timeInForce GTC/IOC/GTX only (exchangeInfo has no FOK)", "Use --tif IOC, GTC or ALO (post-only)");
42
+ }
43
+ throw new PerpError("INVALID_PARAMS", `Unsupported time-in-force "${raw}" for Aster`, {
44
+ exchange: "aster",
45
+ remediation: "Use --tif GTC, IOC or ALO (post-only)",
46
+ });
47
+ }
48
+ /** Plain non-negative decimal string of a venue quantity field, or null when absent/unreadable. */
49
+ function plainQty(v) {
50
+ const s = typeof v === "number" && Number.isFinite(v) ? String(v) : v;
51
+ if (typeof s !== "string" || !/^\d+(\.\d+)?$/.test(s))
52
+ return null;
53
+ return subDecimal(s, "0");
54
+ }
55
+ /**
56
+ * Definite IOC outcome of a raw Aster order response (perp and spot share the
57
+ * Binance-compatible shape) — contract in ./ioc-outcome.ts. Final only for a terminal
58
+ * status (FILLED / EXPIRED / CANCELED) with a reported executedQty, on an IOC (or
59
+ * MARKET) order; anything else → null (caller measures).
60
+ */
61
+ export function asterIocOutcome(raw) {
62
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
63
+ return null;
64
+ const r = raw;
65
+ const status = typeof r.status === "string" ? r.status.toUpperCase() : "";
66
+ if (status !== "FILLED" && status !== "EXPIRED" && status !== "CANCELED")
67
+ return null;
68
+ const tif = typeof r.timeInForce === "string" ? r.timeInForce.toUpperCase() : null;
69
+ const type = typeof r.type === "string" ? r.type.toUpperCase() : null;
70
+ if (tif !== null && tif !== "IOC" && type !== "MARKET")
71
+ return null;
72
+ const filledSize = plainQty(r.executedQty);
73
+ return filledSize === null ? null : { filledSize, final: true };
74
+ }
75
+ /**
76
+ * Order-placement refusals by documented venue code (asterdex/api-docs V3 futures "Error
77
+ * Codes"; "Codes are universal,but messages can vary"). [PerpError code, docs name, remediation].
78
+ * A documented code that is not listed here is a plain ORDER_REJECTED.
79
+ */
80
+ const ASTER_ORDER_REJECTIONS = {
81
+ [-1003]: ["ORDER_REJECTED", "TOO_MANY_REQUESTS", "The order was not placed. Wait, then place it again at a lower request rate"],
82
+ [-1015]: ["ORDER_REJECTED", "TOO_MANY_ORDERS", "The order was not placed. Wait, then place it again at a lower order rate"],
83
+ [-1121]: ["SYMBOL_NOT_FOUND", "BAD_SYMBOL", "Check the symbol: perp market list -e aster"],
84
+ [-2010]: ["ORDER_REJECTED", "NEW_ORDER_REJECTED", null],
85
+ [-2018]: ["INSUFFICIENT_BALANCE", "BALANCE_NOT_SUFFICIENT", "Check the balance: perp account balance -e aster"],
86
+ [-2019]: ["MARGIN_INSUFFICIENT", "MARGIN_NOT_SUFFICIEN", "Reduce the size, raise the leverage or add margin: perp account balance -e aster"],
87
+ [-2020]: ["ORDER_REJECTED", "UNABLE_TO_FILL", null],
88
+ [-2021]: ["ORDER_REJECTED", "ORDER_WOULD_IMMEDIATELY_TRIGGER", "Use a trigger price on the other side of the current mark / last price"],
89
+ [-2022]: ["ORDER_REJECTED", "REDUCE_ONLY_REJECT", "A reduce-only order needs an open position on the opposite side (and no larger than it): perp account positions -e aster"],
90
+ [-2024]: ["ORDER_REJECTED", "POSITION_NOT_SUFFICIENT", "Check the position size: perp account positions -e aster"],
91
+ [-2025]: ["ORDER_REJECTED", "MAX_OPEN_ORDER_EXCEEDED", "Cancel open orders first: perp account orders -e aster"],
92
+ [-2027]: ["ORDER_REJECTED", "MAX_LEVERAGE_RATIO", "Lower the leverage or the position size for this market"],
93
+ [-4004]: ["SIZE_TOO_SMALL", "QTY_LESS_THAN_MIN_QTY", "Increase the size: perp market info <symbol> -e aster"],
94
+ [-4005]: ["SIZE_TOO_LARGE", "QTY_GREATER_THAN_MAX_QTY", "Reduce the size: perp market info <symbol> -e aster"],
95
+ [-4014]: ["INVALID_TICK", "PRICE_NOT_INCREASED_BY_TICK_SIZE", "Use a price on the market's tick size"],
96
+ [-4023]: ["ORDER_REJECTED", "QTY_NOT_INCREASED_BY_STEP_SIZE", "Use a size on the market's lot step"],
97
+ [-4050]: ["MARGIN_INSUFFICIENT", "CROSS_BALANCE_INSUFFICIENT", "Check the balance: perp account balance -e aster"],
98
+ [-4051]: ["MARGIN_INSUFFICIENT", "ISOLATED_BALANCE_INSUFFICIENT", "Check the balance: perp account balance -e aster"],
99
+ [-4164]: ["SIZE_TOO_SMALL", "MIN_NOTIONAL", "Increase the order notional (size × price)"],
100
+ [-5050]: ["DEPOSIT_REQUIRED", "DEPOSIT_REQUIRED", "Deposit first: perp funds deposit aster <amount>"],
101
+ };
102
+ /**
103
+ * Codes whose docs text says the request may still have been processed — never a definite
104
+ * refusal: -1000 UNKNOWN "An unknown error occured while processing the request." · -1001
105
+ * DISCONNECTED "Internal error; unable to process your request." · -1006 UNEXPECTED_RESP
106
+ * "Execution status unknown." · -1007 TIMEOUT "Send status unknown; execution status unknown."
107
+ */
108
+ const ASTER_UNKNOWN_OUTCOME_CODES = new Set([-1000, -1001, -1006, -1007]);
109
+ /** The docs' error-code sections: 10xx / 11xx, 20xx, 40xx (incl. 41xx), 50xx. */
110
+ function asterDocumentedCode(code) {
111
+ return Number.isInteger(code) && ((code <= -1000 && code >= -1199) || (code <= -2000 && code >= -2099)
112
+ || (code <= -4000 && code >= -4199) || (code <= -5000 && code >= -5099));
113
+ }
114
+ /**
115
+ * Verdict of a failed POST / PUT /fapi/v3/order that the venue ANSWERED with an HTTP 4xx and a
116
+ * documented error code: the order was refused — a definite, non-retryable PerpError (never the
117
+ * retryable EXCHANGE_ERROR, which callers must treat as an unknown write outcome). Live mainnet
118
+ * 2026-10-05: a reduce-only order without a position → 400 {"code":-2022,"msg":"ReduceOnly Order
119
+ * is rejected."}. Unchanged: HTTP 5xx, no / undocumented code, and the "status unknown" codes —
120
+ * those become ORDER_PENDING unless the error was already classified definite (e.g. the venue's
121
+ * -1000 "Signature check failed" → SIGNATURE_FAILED).
122
+ */
123
+ export function asterOrderRejection(e) {
124
+ const d = e.structured.details;
125
+ const http = typeof d?.httpStatus === "number" ? d.httpStatus : null;
126
+ const code = typeof d?.venueCode === "number" ? d.venueCode : null;
127
+ if (http === null || http < 400 || http >= 500 || code === null || !asterDocumentedCode(code))
128
+ return e;
129
+ const definite = DEFINITE_ORDER_FAILURE_CODES.has(e.structured.code);
130
+ if (ASTER_UNKNOWN_OUTCOME_CODES.has(code))
131
+ return definite ? e : orderOutcomeUnknown("aster", "order", e);
132
+ const known = ASTER_ORDER_REJECTIONS[code];
133
+ if (known === undefined && definite)
134
+ return e;
135
+ const [perpCode, name, remediation] = known ?? ["ORDER_REJECTED", null, null];
136
+ return new PerpError(perpCode, `Aster rejected the order (${code}${name !== null ? ` ${name}` : ""}): ${e.message}`, {
137
+ exchange: "aster", httpStatus: http, venueCode: code,
138
+ ...(remediation !== null ? { remediation } : e.structured.remediation !== undefined ? { remediation: e.structured.remediation } : {}),
139
+ });
140
+ }
141
+ /**
142
+ * An Aster client order id as sent / queried. V3 futures "New Order" `newClientOrderId`: "A unique
143
+ * id among open orders. Automatically generated if not sent. Can only be string following the
144
+ * rule: ^[\.A-Z\:/a-z0-9_-]{1,36}$". Anything else → INVALID_PARAMS, nothing sent.
145
+ */
146
+ export function requireAsterClientOrderId(id) {
147
+ if (!ASTER_CLIENT_ID_RE.test(id)) {
148
+ throw new PerpError("INVALID_PARAMS", `Aster client order id "${id}" must be 1-36 chars of A-Z a-z 0-9 . : / _ -`, {
149
+ exchange: "aster", remediation: "Pass a shorter id of that charset as --client-id, or use --auto-id",
150
+ });
151
+ }
152
+ return id;
153
+ }
24
154
  // ── AsterAdapter ──────────────────────────────────────────────────────────────
155
+ /** Max 1000-row pages of 7-day commission income read for symbol discovery (20 000 fills). */
156
+ export const ASTER_INCOME_MAX_PAGES = 20;
25
157
  export class AsterAdapter {
26
158
  name = "aster";
27
159
  chain = "bnb";
@@ -40,18 +172,25 @@ export class AsterAdapter {
40
172
  _marketsCache = null;
41
173
  _marketsCacheTime = 0;
42
174
  _fundingHoursCache = new Map();
175
+ /** When the venue-wide funding-interval table was last loaded (0 = never). */
176
+ _fundingInfoLoadedAt = 0;
177
+ /** API symbol → price filters; null = listed but PRICE_FILTER / PERCENT_PRICE unreadable. */
178
+ _priceFilters = new Map();
179
+ _priceFiltersLoadedAt = 0;
43
180
  _accountCache = null;
44
181
  _positionsCache = null;
45
182
  _ordersCache = null;
46
183
  static CACHE_TTL = 30_000;
47
184
  static ACCOUNT_CACHE_TTL = 5_000;
48
- // Nonce counter — ensures microsecond-precision uniqueness within process
49
- _nonceCounter = 0;
185
+ /**
186
+ * Builder-fee gate (settings builder.aster + confirmed master approveBuilder); null = not
187
+ * configured. Set by createAster — the adapter never reads settings.
188
+ */
189
+ builderFee = null;
50
190
  constructor(privateKey, testnet = false) {
51
191
  this._testnet = testnet;
52
- this._baseUrl = testnet
53
- ? (process.env.ASTER_TESTNET_URL || "https://testnet.asterdex.com")
54
- : "https://fapi.asterdex.com";
192
+ // SSOT: REST host from the venue definition (venues/aster.ts networks).
193
+ this._baseUrl = networkConfig(asterVenue, testnet ? "testnet" : "mainnet").restUrl;
55
194
  if (privateKey) {
56
195
  this._pendingPk = privateKey;
57
196
  }
@@ -59,7 +198,7 @@ export class AsterAdapter {
59
198
  // ── Lifecycle ──
60
199
  async init() {
61
200
  // Verify connectivity by fetching server time
62
- await this._publicGet("/fapi/v1/time");
201
+ await this._publicGet("/fapi/v3/time");
63
202
  // Build LocalEvmSigner from ctor PK now that we're in an async context
64
203
  if (this._pendingPk) {
65
204
  const { LocalEvmSigner } = await import("../signer/index.js");
@@ -81,6 +220,48 @@ export class AsterAdapter {
81
220
  setNoAgent(noAgent) {
82
221
  this._useNoAgent = noAgent;
83
222
  }
223
+ // ── Builder fee ──
224
+ /** Builder decision for the next perp order (cached approval read; never throws). */
225
+ async builderDecision() {
226
+ const gate = builderGateOf(this);
227
+ return gate === null ? NO_BUILDER : gate.decide({ hip3: false });
228
+ }
229
+ /** `builder` + `feeRate` (decimal) order params, or none (not configured / not approved). */
230
+ async _builderParams() {
231
+ const d = await this.builderDecision();
232
+ return d.applied === null ? {} : { builder: d.applied.identity, feeRate: bpsToRate(requireFeeBps(d.applied)) };
233
+ }
234
+ /** Owner (`user`) address of the active signer — the account a builder approval belongs to. */
235
+ get userAddress() {
236
+ return this._resolveSigner().userAddress;
237
+ }
238
+ /**
239
+ * Builder approvals of this account: GET /fapi/v3/builder (asterdex/api-docs demo
240
+ * `getBuilders`, signed by the API wallet). The response shape is not documented, so
241
+ * anything but a list of rows carrying an address + maxFeeRate is "unknown" (never guessed).
242
+ */
243
+ async builderApprovalOf(target) {
244
+ const raw = await this.signedRequest(this._baseUrl, "GET", "/fapi/v3/builder", {});
245
+ const rec = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
246
+ const list = Array.isArray(raw) ? raw : rec !== null && Array.isArray(rec.rows) ? rec.rows : rec !== null && Array.isArray(rec.data) ? rec.data : null;
247
+ if (list === null)
248
+ return { status: "unknown", reason: `unrecognized GET /fapi/v3/builder response ${JSON.stringify(raw).slice(0, 120)}` };
249
+ for (const r of list) {
250
+ if (r === null || typeof r !== "object")
251
+ return { status: "unknown", reason: "GET /fapi/v3/builder row is not an object" };
252
+ const row = r;
253
+ const addr = typeof row.builder === "string" ? row.builder : typeof row.builderAddress === "string" ? row.builderAddress : null;
254
+ if (addr === null)
255
+ return { status: "unknown", reason: "GET /fapi/v3/builder row has no builder address" };
256
+ if (addr.toLowerCase() !== target.identity.toLowerCase())
257
+ continue;
258
+ const max = rateToBps(row.maxFeeRate);
259
+ if (max === null)
260
+ return { status: "unknown", reason: `unreadable maxFeeRate ${JSON.stringify(row.maxFeeRate)}` };
261
+ return approvalFromMax(max, requireFeeBps(target));
262
+ }
263
+ return approvalFromMax(null, requireFeeBps(target));
264
+ }
84
265
  // ── Status accessors ──
85
266
  get isReadOnly() {
86
267
  return !this._agentSigner && !this._masterSigner && !this._pkSigner;
@@ -93,6 +274,38 @@ export class AsterAdapter {
93
274
  return null;
94
275
  }
95
276
  }
277
+ // ── Spot host access (design §12, used by AsterSpotAdapter) ──
278
+ /**
279
+ * Aster spot REST host. Spot shares the perp EIP-712 Domain B signing
280
+ * (chainId 1666 mainnet / 714 testnet) and the per-agent nonce space.
281
+ * Hosts per the official spot V3 docs (mainnet + testnet variants).
282
+ */
283
+ get spotBaseUrl() {
284
+ return this._testnet ? "https://sapi.asterdex-testnet.com" : "https://sapi.asterdex.com";
285
+ }
286
+ /**
287
+ * Signed request against an arbitrary Aster host with the active signer.
288
+ * POST is single-shot; GET/DELETE use the same 429 retry loop as perp.
289
+ */
290
+ async signedRequest(host, method, path, params) {
291
+ const r = this._resolveSigner();
292
+ if (method === "POST")
293
+ return this._signedPostTo(host, path, params, r);
294
+ return this._signedRequestWithRetry(host, method, path, params, r);
295
+ }
296
+ /**
297
+ * Agent meta of the active signer, or null when the active signer is not an
298
+ * agent. Throws exactly like signed calls when no signer is usable.
299
+ */
300
+ activeAgentMeta() {
301
+ const r = this._resolveSigner();
302
+ if (r.tier !== "agent")
303
+ return null;
304
+ if (!this._agentMeta) {
305
+ throw new PerpError("AGENT_NOT_REGISTERED", "Aster agent signer is active but its metadata is missing", { exchange: "aster" });
306
+ }
307
+ return this._agentMeta;
308
+ }
96
309
  // ── Symbol helpers ──
97
310
  /** CLI symbol → Aster API symbol (ETH → ETHUSDT) */
98
311
  _toApi(symbol) {
@@ -106,32 +319,56 @@ export class AsterAdapter {
106
319
  return symbol.replace(/USDT$/, "").replace(/BUSD$/, "");
107
320
  }
108
321
  // ── Market Data ──
109
- /** Get funding interval for a symbol (lazy bootstrap). */
322
+ /**
323
+ * Get funding interval for a symbol.
324
+ *
325
+ * Source of truth is `GET /fapi/v3/fundingInfo` (weight 1), which states
326
+ * `fundingIntervalHours` per symbol for the whole venue in a single call.
327
+ * This previously inferred the interval by differencing the last two
328
+ * `fundingRate` timestamps and bucketing to 1/4/8h — an estimate that is
329
+ * wrong for a newly listed symbol (fewer than two settlements), across a
330
+ * schedule change, or whenever a settlement is skipped/delayed, and Aster
331
+ * really does mix 1h, 4h and 8h symbols. Funding-interval misconfiguration is
332
+ * exactly what produced the v0.7.23 Aster funding bug, so it is read, not
333
+ * guessed.
334
+ */
110
335
  async getFundingHours(symbol) {
111
336
  const key = symbol.toUpperCase();
112
337
  const cached = this._fundingHoursCache.get(key);
113
338
  if (cached !== undefined)
114
339
  return cached;
115
- try {
116
- const apiSym = this._toApi(key);
117
- const data = await this._publicGet("/fapi/v1/fundingRate", { symbol: apiSym, limit: "2" });
118
- if (Array.isArray(data) && data.length >= 2) {
119
- const hours = Math.abs(data[1].fundingTime - data[0].fundingTime) / 3600000;
120
- const rounded = hours <= 1.5 ? 1 : hours <= 5 ? 4 : 8;
121
- this._fundingHoursCache.set(key, rounded);
122
- return rounded;
123
- }
340
+ await this._loadFundingInfo();
341
+ return this._fundingHoursCache.get(key);
342
+ }
343
+ /** Populate the funding-interval cache for every symbol in one call. */
344
+ async _loadFundingInfo() {
345
+ if (this._fundingInfoLoadedAt && Date.now() - this._fundingInfoLoadedAt < AsterAdapter.CACHE_TTL)
346
+ return;
347
+ const rows = await this._publicGet("/fapi/v3/fundingInfo", {});
348
+ if (!Array.isArray(rows)) {
349
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/fundingInfo did not return an array", { exchange: "aster" });
124
350
  }
125
- catch { /* non-critical */ }
126
- return undefined;
351
+ for (const row of rows) {
352
+ const hours = Number(row?.fundingIntervalHours);
353
+ if (!row?.symbol || !Number.isFinite(hours) || hours <= 0)
354
+ continue;
355
+ this._fundingHoursCache.set(this._fromApi(String(row.symbol)), hours);
356
+ }
357
+ this._fundingInfoLoadedAt = Date.now();
127
358
  }
128
359
  async getMarkets() {
129
360
  if (this._marketsCache && Date.now() - this._marketsCacheTime < AsterAdapter.CACHE_TTL) {
130
361
  return this._marketsCache;
131
362
  }
132
363
  const [info, tickers] = await Promise.all([
133
- this._publicGet("/fapi/v1/exchangeInfo"),
134
- this._publicGet("/fapi/v1/ticker/24hr"),
364
+ this._publicGet("/fapi/v3/exchangeInfo"),
365
+ this._publicGet("/fapi/v3/ticker/24hr"),
366
+ // Populate the funding-interval table (weight 1, whole venue in one call).
367
+ // The per-symbol read below consulted this cache but nothing ever filled
368
+ // it during getMarkets, so `fundingHours` came back undefined for every
369
+ // market — and Aster genuinely mixes 1h / 4h / 8h symbols, which funding
370
+ // math depends on.
371
+ this._loadFundingInfo(),
135
372
  ]);
136
373
  const tickerMap = new Map();
137
374
  for (const t of tickers ?? []) {
@@ -139,10 +376,11 @@ export class AsterAdapter {
139
376
  }
140
377
  let premiumMap = new Map();
141
378
  try {
142
- const premiums = await this._publicGet("/fapi/v1/premiumIndex");
379
+ const premiums = await this._publicGet("/fapi/v3/premiumIndex");
143
380
  premiumMap = new Map(premiums.map(p => [String(p.symbol), p]));
144
381
  }
145
382
  catch { /* non-critical */ }
383
+ this._ingestPriceFilters(info?.symbols ?? []);
146
384
  const tradingSymbols = (info?.symbols ?? [])
147
385
  .filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING");
148
386
  const result = tradingSymbols.map((s) => {
@@ -156,29 +394,136 @@ export class AsterAdapter {
156
394
  return {
157
395
  symbol: this._fromApi(sym),
158
396
  markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
159
- indexPrice: String(premium?.indexPrice ?? "0"),
397
+ indexPrice: premium?.indexPrice != null ? String(premium.indexPrice) : null,
160
398
  fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
161
- volume24h: String(ticker?.quoteVolume ?? ticker?.volume ?? "0"),
162
- openInterest: "0",
399
+ volume24h: ticker?.quoteVolume != null ? String(ticker.quoteVolume) : null,
400
+ // /fapi/v3/exchangeInfo and ticker/24hr carry no open interest.
401
+ openInterest: null,
163
402
  maxLeverage: maxLev,
164
- sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : undefined,
165
- stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : undefined,
166
- fundingHours,
403
+ sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : null,
404
+ stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : null,
405
+ fundingHours: fundingHours ?? null,
406
+ marginTiers: null,
167
407
  };
168
408
  });
169
409
  this._marketsCache = result;
170
410
  this._marketsCacheTime = Date.now();
171
411
  return result;
172
412
  }
413
+ /** Parse PRICE_FILTER + PERCENT_PRICE for every exchangeInfo symbol into the cache. */
414
+ _ingestPriceFilters(symbols) {
415
+ this._priceFilters.clear();
416
+ for (const s of symbols) {
417
+ const filters = (Array.isArray(s.filters) ? s.filters : []);
418
+ const pf = filters.find(f => f.filterType === "PRICE_FILTER");
419
+ const pp = filters.find(f => f.filterType === "PERCENT_PRICE");
420
+ let entry = null;
421
+ const tick = pf?.tickSize, minP = pf?.minPrice, maxP = pf?.maxPrice;
422
+ if (typeof tick === "string" && Number(tick) > 0 && typeof minP === "string" && typeof maxP === "string") {
423
+ if (!pp) {
424
+ entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: null };
425
+ }
426
+ else {
427
+ const up = Number(pp.multiplierUp), down = Number(pp.multiplierDown);
428
+ if (up > 0 && down > 0 && Number.isFinite(up) && Number.isFinite(down)) {
429
+ entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: { up, down } };
430
+ }
431
+ }
432
+ }
433
+ this._priceFilters.set(String(s.symbol), entry);
434
+ }
435
+ this._priceFiltersLoadedAt = Date.now();
436
+ }
437
+ /** PRICE_FILTER / PERCENT_PRICE of an API symbol (exchangeInfo, cached CACHE_TTL). */
438
+ async _symbolPriceFilters(apiSymbol) {
439
+ if (!this._priceFiltersLoadedAt || Date.now() - this._priceFiltersLoadedAt >= AsterAdapter.CACHE_TTL) {
440
+ const info = await this._publicGet("/fapi/v3/exchangeInfo");
441
+ if (!Array.isArray(info?.symbols)) {
442
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/exchangeInfo returned no symbols", { exchange: "aster" });
443
+ }
444
+ this._ingestPriceFilters(info.symbols);
445
+ }
446
+ if (!this._priceFilters.has(apiSymbol)) {
447
+ throw new PerpError("SYMBOL_NOT_FOUND", `Aster market ${apiSymbol} not found in exchangeInfo`, { exchange: "aster" });
448
+ }
449
+ const f = this._priceFilters.get(apiSymbol);
450
+ if (!f) {
451
+ throw new PerpError("EXCHANGE_ERROR", `Aster ${apiSymbol}: PRICE_FILTER or PERCENT_PRICE missing/unreadable in exchangeInfo`, { exchange: "aster" });
452
+ }
453
+ return f;
454
+ }
455
+ /**
456
+ * Declared protection-price reference (§13.10 #5): the venue mark price from
457
+ * GET /fapi/v3/premiumIndex — the same price PERCENT_PRICE is evaluated against.
458
+ * No runtime substitute (no mid / last fallback): unavailable → PRICE_STALE.
459
+ */
460
+ async referencePrice(symbol) {
461
+ const apiSymbol = this._toApi(symbol);
462
+ let mark;
463
+ try {
464
+ const res = await this._publicGet("/fapi/v3/premiumIndex", { symbol: apiSymbol });
465
+ mark = res?.markPrice;
466
+ }
467
+ catch (e) {
468
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} mark price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
469
+ }
470
+ const str = typeof mark === "number" ? String(mark) : mark;
471
+ if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
472
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} premiumIndex returned no usable markPrice (${String(mark)})`, { exchange: "aster" });
473
+ }
474
+ return str;
475
+ }
476
+ /**
477
+ * Reject (never clamp) a price outside PRICE_FILTER min/max or the side's PERCENT_PRICE
478
+ * bound around `mark` — checked before anything is sent.
479
+ */
480
+ _checkPriceBand(apiSymbol, f, price, side, mark) {
481
+ const p = Number(price);
482
+ if (Number(f.minPrice) > 0 && p < Number(f.minPrice)) {
483
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} below PRICE_FILTER minPrice ${f.minPrice}`, { exchange: "aster" });
484
+ }
485
+ if (Number(f.maxPrice) > 0 && p > Number(f.maxPrice)) {
486
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} above PRICE_FILTER maxPrice ${f.maxPrice}`, { exchange: "aster" });
487
+ }
488
+ if (f.percent === null)
489
+ return;
490
+ const m = Number(mark);
491
+ const bound = side === "buy" ? m * f.percent.up : m * f.percent.down;
492
+ if (side === "buy" ? p > bound : p < bound) {
493
+ const bandBps = Math.round(Math.abs((side === "buy" ? f.percent.up : f.percent.down) - 1) * 10_000);
494
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: ${side} protection price ${price} is outside PERCENT_PRICE (${side === "buy" ? "≤" : "≥"} mark ${mark} × ${side === "buy" ? f.percent.up : f.percent.down})`, {
495
+ exchange: "aster",
496
+ remediation: `Use --slippage-bps below ${bandBps} for ${this._fromApi(apiSymbol)} (PERCENT_PRICE band)`,
497
+ });
498
+ }
499
+ }
500
+ /**
501
+ * Protection price an IOC market order on `symbol` would be sent with now:
502
+ * mark ± slippage.bps, aligned toward crossing on PRICE_FILTER.tickSize, validated
503
+ * against PRICE_FILTER / PERCENT_PRICE. appliedBps = the bound after tick alignment.
504
+ */
505
+ async computeProtectionPrice(symbol, side, slippage) {
506
+ const apiSymbol = this._toApi(symbol);
507
+ const f = await this._symbolPriceFilters(apiSymbol);
508
+ const ref = await this.referencePrice(symbol);
509
+ const protectionPrice = alignProtectionPrice(slippagePrice(ref, side, slippage.bps), f.tickSize, side);
510
+ this._checkPriceBand(apiSymbol, f, protectionPrice, side, ref);
511
+ const appliedBps = Math.abs(Number(protectionPrice) - Number(ref)) / Number(ref) * 10_000;
512
+ return { referencePrice: ref, protectionPrice, appliedBps };
513
+ }
514
+ /** Definite IOC outcome of a raw order response (see asterIocOutcome). */
515
+ iocOutcomeOf(raw) {
516
+ return asterIocOutcome(raw);
517
+ }
173
518
  async getOrderbook(symbol) {
174
- const res = await this._publicGet("/fapi/v1/depth", { symbol: this._toApi(symbol), limit: "50" });
519
+ const res = await this._publicGet("/fapi/v3/depth", { symbol: this._toApi(symbol), limit: "50" });
175
520
  return {
176
521
  bids: res?.bids ?? [],
177
522
  asks: res?.asks ?? [],
178
523
  };
179
524
  }
180
525
  async getRecentTrades(symbol, limit = 20) {
181
- const trades = await this._publicGet("/fapi/v1/trades", {
526
+ const trades = await this._publicGet("/fapi/v3/trades", {
182
527
  symbol: this._toApi(symbol),
183
528
  limit: String(limit),
184
529
  });
@@ -192,18 +537,18 @@ export class AsterAdapter {
192
537
  }));
193
538
  }
194
539
  async getFundingHistory(symbol, limit = 10) {
195
- const data = await this._publicGet("/fapi/v1/fundingRate", {
540
+ const data = await this._publicGet("/fapi/v3/fundingRate", {
196
541
  symbol: this._toApi(symbol),
197
542
  limit: String(limit),
198
543
  });
199
544
  return (data ?? []).map((d) => ({
200
545
  time: Number(d.fundingTime ?? 0),
201
- rate: String(d.fundingRate ?? "0"),
546
+ rate: venueRateField(d.fundingRate, "fundingRate fundingRate", "aster"),
202
547
  price: d.markPrice ? String(d.markPrice) : null,
203
548
  }));
204
549
  }
205
550
  async getKlines(symbol, interval, startTime, endTime) {
206
- const data = await this._publicGet("/fapi/v1/klines", {
551
+ const data = await this._publicGet("/fapi/v3/klines", {
207
552
  symbol: this._toApi(symbol),
208
553
  interval,
209
554
  startTime: String(startTime),
@@ -222,66 +567,138 @@ export class AsterAdapter {
222
567
  }
223
568
  // ── Account ──
224
569
  async getBalance() {
225
- if (this._accountCache && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
570
+ if (this._accountCache && this._accountCache.epoch === accountCacheEpoch() && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
226
571
  return this._accountCache.data;
227
572
  }
228
573
  const r = this._resolveSigner();
229
- // v3: /fapi/v2/account is HMAC-only; v3 EIP-712 uses /fapi/v3/accountWithJoinMargin.
574
+ // v3 EIP-712 account endpoint. (The legacy /fapi/v2/account was HMAC-only and
575
+ // is no longer documented in the Aster V3 spec.)
230
576
  const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
231
- const totalWallet = Number(account.totalWalletBalance ?? 0);
232
- const unrealizedPnl = Number(account.totalUnrealizedProfit ?? 0);
233
- const available = Number(account.availableBalance ?? 0);
234
- const marginUsed = Number(account.totalInitialMargin ?? 0);
577
+ const totalWallet = parseFiniteVenueNumber(account.totalWalletBalance, "totalWalletBalance", "aster");
578
+ const unrealizedPnl = parseFiniteVenueNumber(account.totalUnrealizedProfit, "totalUnrealizedProfit", "aster");
579
+ const available = parseFiniteVenueNumber(account.availableBalance, "availableBalance", "aster");
580
+ const marginUsed = parseFiniteVenueNumber(account.totalInitialMargin, "totalInitialMargin", "aster");
235
581
  const result = {
236
582
  equity: String(totalWallet + unrealizedPnl),
237
583
  available: String(available),
238
584
  marginUsed: String(marginUsed),
239
585
  unrealizedPnl: String(unrealizedPnl),
240
586
  };
241
- this._accountCache = { data: result, time: Date.now() };
587
+ this._accountCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
242
588
  return result;
243
589
  }
590
+ /**
591
+ * How much of ONE asset the perp account can transfer out: that asset's row of
592
+ * GET /fapi/v3/accountWithJoinMargin `assets[]` — `maxWithdrawAmount` ("maximum amount for
593
+ * transfer out", Aster futures API v3 "Account Information v3"). The account-level
594
+ * `availableBalance` (getBalance) is margin across ALL collateral assets in multi-asset mode
595
+ * and is not an amount of any one asset. Read uncached. A missing row / field is an error,
596
+ * never 0.
597
+ */
598
+ async getPerpAssetTransferable(asset) {
599
+ const r = this._resolveSigner();
600
+ const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
601
+ if (!Array.isArray(account.assets)) {
602
+ throw new PerpError("EXCHANGE_ERROR", "aster /fapi/v3/accountWithJoinMargin: response has no assets list", { exchange: "aster" });
603
+ }
604
+ const row = account.assets.find(a => a.asset === asset);
605
+ if (row === undefined || row.maxWithdrawAmount === undefined || row.maxWithdrawAmount === null) {
606
+ throw new PerpError("EXCHANGE_ERROR", `aster /fapi/v3/accountWithJoinMargin: no ${asset} maxWithdrawAmount in the assets list`, { exchange: "aster" });
607
+ }
608
+ return parseFiniteVenueNumber(row.maxWithdrawAmount, `assets[${asset}].maxWithdrawAmount`, "aster");
609
+ }
244
610
  async getPositions() {
245
- if (this._positionsCache && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
611
+ if (this._positionsCache && this._positionsCache.epoch === accountCacheEpoch() && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
246
612
  return this._positionsCache.data;
247
613
  }
248
614
  const r = this._resolveSigner();
249
- // v3: /fapi/v2/positionRisk has no v3 equivalent. Positions live inside
250
- // /fapi/v3/accountWithJoinMargin under `positions`. Mark price + liquidation
251
- // price are NOT included there — fetch from the public premiumIndex.
615
+ // Positions are enumerated from /fapi/v3/accountWithJoinMargin (`positions`
616
+ // array); that payload omits both mark and liquidation price. The signed
617
+ // Position Information v3 endpoint (GET /fapi/v3/positionRisk) supplies both
618
+ // per open symbol, so it replaces the older public premiumIndex mark-price probe.
252
619
  const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
253
620
  const positions = account.positions ?? [];
254
- const open = positions.filter((p) => Number(p.positionAmt ?? 0) !== 0);
255
- // Fetch mark prices for the open symbols (best-effort; non-fatal on failure)
256
- const markMap = new Map();
621
+ const open = positions.filter((p) => parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0);
622
+ // Hedge mode returns TWO rows per symbol (LONG and SHORT). This adapter
623
+ // keys everything by symbol alone — the riskMap below, and the mapped
624
+ // result — so the two legs collide and one silently inherits the other's
625
+ // mark/liquidation price. Orders are safe (the venue rejects them with
626
+ // -4061 because we never send positionSide), but positions and liquidation
627
+ // distance would be quietly wrong, so fail honestly instead.
628
+ const bySymbol = new Map();
629
+ for (const p of open) {
630
+ const k = String(p.symbol ?? "");
631
+ bySymbol.set(k, (bySymbol.get(k) ?? 0) + 1);
632
+ }
633
+ const dupes = [...bySymbol.entries()].filter(([, n]) => n > 1).map(([s]) => s);
634
+ if (dupes.length > 0) {
635
+ throw new PerpError("NOT_IMPLEMENTED", `Aster hedge mode is not supported: ${dupes.join(", ")} has both LONG and SHORT positions, which this adapter cannot represent (positions are keyed by symbol).`, {
636
+ exchange: "aster",
637
+ remediation: "Switch the account to One-way Mode (POST /fapi/v3/positionSide/dual with dualSidePosition=false) or close one side, then retry.",
638
+ });
639
+ }
640
+ // Enrich open positions with mark + liquidation price from /fapi/v3/positionRisk
641
+ // (best-effort; a transient failure leaves them "0" rather than failing the whole
642
+ // positions read). Response fields per Aster V3 spec: markPrice, liquidationPrice.
643
+ const riskMap = new Map();
257
644
  if (open.length > 0) {
258
645
  try {
259
- const premiums = await this._publicGet("/fapi/v1/premiumIndex");
260
- for (const p of premiums ?? []) {
261
- markMap.set(String(p.symbol), String(p.markPrice ?? "0"));
646
+ const risk = await this._signedGetEip712("/fapi/v3/positionRisk", {}, r);
647
+ for (const pr of risk ?? []) {
648
+ // Aster V3 spec (userPositionRisk notes, same field): "`liquidationPrice` is reported as `0`
649
+ // whenever the underlying value would be negative" — i.e. the position has no liquidation
650
+ // price (e.g. a small cross long the margin fully covers). 0 is not a price → null.
651
+ const liq = pr.liquidationPrice != null ? String(pr.liquidationPrice) : null;
652
+ riskMap.set(String(pr.symbol), {
653
+ mark: String(pr.markPrice ?? "0"),
654
+ liq: liq !== null && liq.trim() !== "" && Number(liq) === 0 ? null : liq,
655
+ });
262
656
  }
263
657
  }
264
- catch { /* non-critical */ }
658
+ catch (e) {
659
+ // NOT non-critical. A "0" liquidation price is not a neutral default:
660
+ // risk.ts filters positions on `liquidationPrice !== null && > 0` and
661
+ // event-stream.ts gates the liquidation warning on `liq > 0`, so a
662
+ // silent "0" drops EVERY Aster position out of liquidation monitoring —
663
+ // and the likeliest cause of this call failing (rate limiting)
664
+ // correlates with the volatility that makes those alerts matter most.
665
+ // Surface it, and report the values as unknown rather than as a price.
666
+ process.stderr.write(`[aster] positionRisk fetch failed — mark and liquidation prices are UNAVAILABLE this cycle, ` +
667
+ `so liquidation warnings are suppressed for Aster positions: ` +
668
+ `${e instanceof Error ? e.message : String(e)}\n`);
669
+ }
265
670
  }
266
671
  const result = open.map((p) => {
267
- const amt = Number(p.positionAmt ?? 0);
672
+ const amt = parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster");
268
673
  const apiSym = String(p.symbol ?? "");
674
+ const risk = riskMap.get(apiSym);
269
675
  return {
270
676
  symbol: this._fromApi(apiSym),
271
677
  side: amt > 0 ? "long" : "short",
272
678
  size: String(Math.abs(amt)),
273
679
  entryPrice: String(p.entryPrice ?? "0"),
274
- markPrice: markMap.get(apiSym) ?? "0",
275
- liquidationPrice: "0", // v3 accountWithJoinMargin does not expose this
680
+ // Unknown mark is "N/A" (markPrice is a required string); unknown liq is null. Never "0" — a zero reads downstream as a real price.
681
+ // Matches the Pacifica adapter's convention for an absent liq price.
682
+ markPrice: risk?.mark ?? "N/A",
683
+ liquidationPrice: risk ? risk.liq : null,
276
684
  unrealizedPnl: String(p.unrealizedProfit ?? "0"),
277
- leverage: Number(p.leverage ?? 1),
685
+ leverage: parseFiniteVenueNumber(p.leverage, "position.leverage", "aster", { defaultValue: 1 }),
278
686
  };
279
687
  });
280
- this._positionsCache = { data: result, time: Date.now() };
688
+ this._positionsCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
281
689
  return result;
282
690
  }
691
+ /**
692
+ * Drop the in-process open-orders snapshot (after any order write, or before a verifying
693
+ * re-list). An order write also changes positions and balances, so those snapshots go too.
694
+ */
695
+ invalidateOpenOrdersCache() {
696
+ this._ordersCache = null;
697
+ this._positionsCache = null;
698
+ this._accountCache = null;
699
+ }
283
700
  async getOpenOrders() {
284
- if (this._ordersCache && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
701
+ if (this._ordersCache && this._ordersCache.epoch === accountCacheEpoch() && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
285
702
  return this._ordersCache.data;
286
703
  }
287
704
  const r = this._resolveSigner();
@@ -295,68 +712,142 @@ export class AsterAdapter {
295
712
  filled: String(o.executedQty ?? "0"),
296
713
  status: String(o.status ?? ""),
297
714
  type: String(o.type ?? ""),
715
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
298
716
  }));
299
- this._ordersCache = { data: result, time: Date.now() };
717
+ this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
300
718
  return result;
301
719
  }
302
- async getOrderHistory(limit = 30) {
720
+ /**
721
+ * Symbols with account activity: open positions, open orders, and every symbol with a
722
+ * COMMISSION income row in the last 7 days (the venue's default window). Aster's userTrades /
723
+ * allOrders both require `symbol`, so this is the per-symbol fan-out set. Income is read in
724
+ * pages of 1000 (the endpoint max; startTime / endTime are inclusive) walking back from now;
725
+ * more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
726
+ * symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
727
+ */
728
+ async _tradedApiSymbols(r) {
303
729
  const positions = await this.getPositions();
304
730
  const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
305
- if (apiSymbols.size === 0) {
306
- for (const s of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"]) {
307
- apiSymbols.add(s);
731
+ const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
732
+ if (!Array.isArray(open)) {
733
+ throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
734
+ }
735
+ for (const o of open) {
736
+ if (typeof o.symbol === "string" && o.symbol !== "")
737
+ apiSymbols.add(o.symbol);
738
+ }
739
+ const startTime = Date.now() - 7 * 86_400_000;
740
+ let endTime = Date.now();
741
+ for (let page = 0;; page++) {
742
+ if (page >= ASTER_INCOME_MAX_PAGES) {
743
+ throw new PerpError("EXCHANGE_ERROR", `Aster commission history exceeds ${ASTER_INCOME_MAX_PAGES * 1000} rows in 7 days — the symbol list would be incomplete`, {
744
+ exchange: "aster", remediation: "Check fills per symbol in the Aster web app",
745
+ });
746
+ }
747
+ const income = await this._signedGetEip712("/fapi/v3/income", {
748
+ incomeType: "COMMISSION", startTime: String(startTime), endTime: String(endTime), limit: "1000",
749
+ }, r);
750
+ if (!Array.isArray(income)) {
751
+ throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(income).slice(0, 120)})`, { exchange: "aster" });
752
+ }
753
+ let oldest = Infinity;
754
+ for (const row of income) {
755
+ if (typeof row.symbol === "string" && row.symbol !== "")
756
+ apiSymbols.add(row.symbol);
757
+ const t = Number(row.time);
758
+ if (!Number.isFinite(t)) {
759
+ throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
760
+ }
761
+ oldest = Math.min(oldest, t);
308
762
  }
763
+ if (income.length < 1000 || oldest <= startTime)
764
+ break;
765
+ endTime = oldest - 1;
309
766
  }
767
+ return apiSymbols;
768
+ }
769
+ /**
770
+ * Recent orders across the symbols with account activity (see _tradedApiSymbols), newest first.
771
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
772
+ */
773
+ async getOrderHistory(limit = 30) {
310
774
  const r = this._resolveSigner();
311
- const allOrders = [];
775
+ const apiSymbols = await this._tradedApiSymbols(r);
776
+ const field = (o, k) => {
777
+ const v = o[k];
778
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
779
+ return String(v);
780
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "aster" });
781
+ };
782
+ const rows = [];
312
783
  for (const sym of apiSymbols) {
313
- try {
314
- const orders = await this._signedGetEip712("/fapi/v3/allOrders", {
315
- symbol: sym,
316
- limit: String(limit),
317
- }, r);
318
- for (const o of orders ?? []) {
319
- allOrders.push({
320
- orderId: String(o.orderId ?? ""),
321
- symbol: this._fromApi(String(o.symbol ?? "")),
322
- side: String(o.side).toLowerCase(),
323
- price: String(o.price ?? "0"),
324
- size: String(o.origQty ?? "0"),
325
- filled: String(o.executedQty ?? "0"),
326
- status: String(o.status ?? ""),
327
- type: String(o.type ?? ""),
328
- });
784
+ const orders = await this._signedGetEip712("/fapi/v3/allOrders", { symbol: sym, limit: String(limit) }, r);
785
+ if (!Array.isArray(orders)) {
786
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders for ${sym} is not a list (${JSON.stringify(orders).slice(0, 120)})`, { exchange: "aster" });
787
+ }
788
+ for (const o of orders) {
789
+ const side = field(o, "side").toLowerCase();
790
+ if (side !== "buy" && side !== "sell") {
791
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with unknown side "${side}"`, { exchange: "aster" });
329
792
  }
793
+ const time = Number(field(o, "time"));
794
+ if (!Number.isFinite(time))
795
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with a non-numeric time`, { exchange: "aster" });
796
+ rows.push({
797
+ time,
798
+ order: {
799
+ orderId: field(o, "orderId"),
800
+ symbol: this._fromApi(field(o, "symbol")),
801
+ side,
802
+ price: field(o, "price"),
803
+ size: field(o, "origQty"),
804
+ filled: field(o, "executedQty"),
805
+ status: field(o, "status"),
806
+ type: field(o, "type"),
807
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
808
+ },
809
+ });
330
810
  }
331
- catch { /* skip */ }
332
811
  }
333
- return allOrders.slice(0, limit);
812
+ return rows.sort((x, y) => y.time - x.time).slice(0, limit).map(x => x.order);
334
813
  }
814
+ /**
815
+ * Recent fills. `GET /fapi/v3/userTrades` requires a symbol (asterdex/api-docs V3), so the
816
+ * symbols come from the open positions plus every symbol with a COMMISSION income row in the
817
+ * venue's default window (`GET /fapi/v3/income`: "the recent 7-day data" when no time is sent).
818
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
819
+ */
335
820
  async getTradeHistory(limit = 30) {
336
- const positions = await this.getPositions();
337
- const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
338
- if (apiSymbols.size === 0)
339
- apiSymbols.add("BTCUSDT");
340
821
  const r = this._resolveSigner();
822
+ const apiSymbols = await this._tradedApiSymbols(r);
823
+ const field = (t, k) => {
824
+ const v = t[k];
825
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
826
+ return String(v);
827
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a readable ${k}: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
828
+ };
341
829
  const allTrades = [];
342
830
  for (const sym of apiSymbols) {
343
- try {
344
- const trades = await this._signedGetEip712("/fapi/v3/userTrades", {
345
- symbol: sym,
346
- limit: String(limit),
347
- }, r);
348
- for (const t of trades ?? []) {
349
- allTrades.push({
350
- time: Number(t.time ?? 0),
351
- symbol: this._fromApi(String(t.symbol ?? "")),
352
- side: t.buyer ? "buy" : "sell",
353
- price: String(t.price ?? "0"),
354
- size: String(t.qty ?? "0"),
355
- fee: String(t.commission ?? "0"),
356
- });
831
+ const trades = await this._signedGetEip712("/fapi/v3/userTrades", { symbol: sym, limit: String(limit) }, r);
832
+ if (!Array.isArray(trades)) {
833
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades for ${sym} is not a list (${JSON.stringify(trades).slice(0, 120)})`, { exchange: "aster" });
834
+ }
835
+ for (const t of trades) {
836
+ if (typeof t.buyer !== "boolean") {
837
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a boolean buyer: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
357
838
  }
839
+ const time = Number(field(t, "time"));
840
+ if (!Number.isFinite(time))
841
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row with a non-numeric time`, { exchange: "aster" });
842
+ allTrades.push({
843
+ time,
844
+ symbol: this._fromApi(field(t, "symbol")),
845
+ side: t.buyer ? "buy" : "sell",
846
+ price: field(t, "price"),
847
+ size: field(t, "qty"),
848
+ fee: field(t, "commission"),
849
+ });
358
850
  }
359
- catch { /* skip */ }
360
851
  }
361
852
  return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
362
853
  }
@@ -372,24 +863,71 @@ export class AsterAdapter {
372
863
  payment: String(f.income ?? "0"),
373
864
  }));
374
865
  }
866
+ /**
867
+ * Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
868
+ * Information v3", one row per symbol, position or not — docs example:
869
+ * `"marginType": "isolated"`, `"leverage": "10"`, `"positionAmt": "0.000"`; rows[].marginType
870
+ * "Margin type: `isolated` or `cross`"). An unreadable row throws — never a default.
871
+ */
872
+ async getMarketSettings() {
873
+ const rows = await this._signedGetEip712("/fapi/v3/positionRisk", {}, this._resolveSigner());
874
+ if (!Array.isArray(rows)) {
875
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/positionRisk did not return an array — per-market settings unknown", { exchange: "aster" });
876
+ }
877
+ const seen = new Map();
878
+ for (const row of rows) {
879
+ const leverage = Number(row.leverage);
880
+ const type = typeof row.marginType === "string" ? row.marginType.toLowerCase() : "";
881
+ // "crossed" is the request enum spelling (CROSSED); the documented read value is "cross".
882
+ const marginMode = type === "isolated" ? "isolated" : type === "cross" || type === "crossed" ? "cross" : null;
883
+ if (typeof row.symbol !== "string" || !(leverage > 0) || marginMode === null) {
884
+ throw new PerpError("EXCHANGE_ERROR", `Aster positionRisk row without a readable symbol / leverage / marginType: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
885
+ }
886
+ // Hedge mode lists a symbol once per position side; leverage / margin type are per symbol.
887
+ seen.set(row.symbol, { symbol: this._fromApi(row.symbol), leverage, marginMode });
888
+ }
889
+ return [...seen.values()];
890
+ }
375
891
  // ── Trading ──
376
892
  async marketOrder(symbol, side, size, opts) {
893
+ // Design §13.2 / §13.10 #6: never native MARKET (no price cap) — an IOC LIMIT at the
894
+ // protection price (mark ± opts.slippage.bps, tick-aligned toward crossing, inside
895
+ // PRICE_FILTER / PERCENT_PRICE). newOrderRespType=RESULT makes the response carry the
896
+ // final IOC status (FILLED / EXPIRED) and executedQty.
377
897
  const r = this._resolveSigner();
378
898
  const apiSymbol = this._toApi(symbol);
899
+ const clientOrderId = opts.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
900
+ let protectionPrice;
901
+ let protection;
902
+ try {
903
+ protection = await this.computeProtectionPrice(symbol, side, opts.slippage);
904
+ ({ protectionPrice } = protection);
905
+ }
906
+ catch (e) {
907
+ throw orderNotSent("aster", `market ${side} ${size} ${symbol}`, e);
908
+ }
379
909
  const params = {
380
910
  symbol: apiSymbol,
381
911
  side: side.toUpperCase(),
382
- type: "MARKET",
912
+ type: "LIMIT",
913
+ timeInForce: "IOC",
914
+ price: protectionPrice,
383
915
  quantity: size,
916
+ newOrderRespType: "RESULT",
384
917
  };
385
- if (opts?.reduceOnly)
918
+ if (clientOrderId !== null)
919
+ params.newClientOrderId = clientOrderId;
920
+ if (opts.reduceOnly)
386
921
  params.reduceOnly = "true";
922
+ Object.assign(params, await this._builderParams());
387
923
  const result = await this._signedPostEip712("/fapi/v3/order", params, r);
388
924
  const ro = result;
389
925
  const executedQty = Number(ro.executedQty ?? 0);
390
926
  const orderId = String(ro.orderId ?? "");
927
+ // The protection actually sent rides along with the venue response (`protection`), read by
928
+ // the TradingV2 wrapper's execution record (the venue does not echo the reference).
391
929
  if (executedQty > 0 && ro.status === "FILLED")
392
- return result;
930
+ return { ...ro, protection };
393
931
  if (ro.status === "NEW" && orderId) {
394
932
  for (let i = 0; i < 3; i++) {
395
933
  await new Promise(res => setTimeout(res, 1000));
@@ -400,9 +938,10 @@ export class AsterAdapter {
400
938
  }, r);
401
939
  const filledQty = Number(check.executedQty ?? 0);
402
940
  if (filledQty > 0)
403
- return check;
404
- if (check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") {
405
- throw new Error(`Market ${side} ${symbol}: order ${check.status} (orderId: ${orderId})`);
941
+ return { ...check, protection };
942
+ if ((check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") && check.executedQty !== undefined && check.executedQty !== null) {
943
+ // Terminal status with a REPORTED 0 executed: nothing traded (definite).
944
+ throw new PerpError("ORDER_REJECTED", `Market ${side} ${symbol}: order ${check.status} (orderId: ${orderId})`, { exchange: "aster", orderId });
406
945
  }
407
946
  }
408
947
  catch (e) {
@@ -410,45 +949,165 @@ export class AsterAdapter {
410
949
  throw e;
411
950
  }
412
951
  }
952
+ let cancelledQty = null;
953
+ let cancelRow = null;
413
954
  try {
414
- await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
955
+ const c = await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
956
+ const q = Number(c?.executedQty);
957
+ cancelledQty = Number.isFinite(q) ? q : null;
958
+ cancelRow = c;
415
959
  }
416
- catch { /* best effort cancel */ }
417
- throw new Error(`Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`);
960
+ catch { /* cancel failed: the order may still be live — outcome stays unknown below */ }
961
+ // The cancel answered with executedQty > 0: the order PARTIALLY filled before the cancel —
962
+ // report that fill (the cancel row carries executedQty / status CANCELED; asterIocOutcome
963
+ // reads it), never "not filled".
964
+ if (cancelledQty !== null && cancelledQty > 0 && cancelRow !== null) {
965
+ console.error(`[aster] market ${side} ${symbol}: order ${orderId} partially filled (${String(cancelRow.executedQty)}) before the remainder was cancelled`);
966
+ return { ...cancelRow, protection };
967
+ }
968
+ const msg = `Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`;
969
+ // Definite only when the cancel was confirmed with 0 executed; otherwise it may have filled / be live.
970
+ if (cancelledQty === 0)
971
+ throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
972
+ throw new Error(msg);
418
973
  }
419
974
  if (executedQty === 0) {
420
- throw new Error(`Market ${side} ${symbol}: order accepted but 0 filled (status: ${ro.status}, orderId: ${orderId})`);
975
+ const msg = `Market ${side} ${symbol}: order accepted but 0 filled (status: ${ro.status}, orderId: ${orderId})`;
976
+ // A terminal status (EXPIRED IOC / CANCELED / REJECTED) with 0 executed is a definite no-fill.
977
+ if ((ro.status === "EXPIRED" || ro.status === "CANCELED" || ro.status === "REJECTED") && ro.executedQty !== undefined && ro.executedQty !== null) {
978
+ throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
979
+ }
980
+ throw new Error(msg);
421
981
  }
422
- return result;
982
+ return { ...ro, protection };
423
983
  }
424
984
  async limitOrder(symbol, side, price, size, opts) {
425
985
  const r = this._resolveSigner();
426
- const params = {
427
- symbol: this._toApi(symbol),
428
- side: side.toUpperCase(),
429
- type: "LIMIT",
430
- price,
431
- quantity: size,
432
- timeInForce: opts?.tif?.toUpperCase() || "GTC",
433
- };
434
- if (opts?.reduceOnly)
435
- params.reduceOnly = "true";
986
+ const apiSymbol = this._toApi(symbol);
987
+ const clientOrderId = opts?.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
988
+ let params;
989
+ try {
990
+ // Common TIF names (§13.3): GTC / IOC / ALO→GTX (GTX accepted as alias); FOK unsupported.
991
+ const tif = asterTif(opts?.tif ?? "GTC");
992
+ // Price must already be on PRICE_FILTER.tickSize — validated, never rounded (INVALID_TICK).
993
+ const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
994
+ params = {
995
+ symbol: apiSymbol,
996
+ side: side.toUpperCase(),
997
+ type: "LIMIT",
998
+ price: px,
999
+ quantity: size,
1000
+ timeInForce: tif,
1001
+ };
1002
+ // IOC / GTX: ask for the final status + executedQty in the response (iocOutcomeOf). Docs (futures
1003
+ // V3, New Order): with `newOrderRespType` RESULT, "`LIMIT` order with special `timeInForce`: the
1004
+ // final status result of the order(FILLED or EXPIRED) will be returned directly". The default ACK
1005
+ // answers NEW for a post-only order that then expires because it would cross (live mainnet 2026-10-05).
1006
+ if (tif === "IOC" || tif === "GTX")
1007
+ params.newOrderRespType = "RESULT";
1008
+ if (clientOrderId !== null)
1009
+ params.newClientOrderId = clientOrderId;
1010
+ if (opts?.reduceOnly)
1011
+ params.reduceOnly = "true";
1012
+ Object.assign(params, await this._builderParams());
1013
+ }
1014
+ catch (e) {
1015
+ throw orderNotSent("aster", `limit ${side} ${size} ${symbol} @ ${price}`, e);
1016
+ }
436
1017
  return this._signedPostEip712("/fapi/v3/order", params, r);
437
1018
  }
1019
+ /**
1020
+ * Native modify (PUT /fapi/v3/order, V3 futures docs "Modify Order"): LIMIT orders
1021
+ * only; TIF (incl. GTX) and reduceOnly stay on the order. `size` is the NEW UNFILLED
1022
+ * quantity (§13.10 #10); the venue's `quantity` is the order total, so
1023
+ * quantity = size + executedQty (read just before). A fill landing between the read
1024
+ * and the PUT can only shrink the remainder, never over-size it. A venue rejection
1025
+ * leaves the original order untouched (docs).
1026
+ */
438
1027
  async editOrder(symbol, orderId, price, size) {
439
- // Aster has no atomic edit — cancel + replace
440
- const openOrders = await this.getOpenOrders();
441
- const existing = openOrders.find(o => o.orderId === orderId);
442
- const side = existing?.side ?? "buy";
443
- await this.cancelOrder(symbol, orderId);
444
- return this.limitOrder(symbol, side, price, size);
1028
+ const r = this._resolveSigner();
1029
+ const apiSymbol = this._toApi(symbol);
1030
+ const rawOrders = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
1031
+ const existing = (rawOrders ?? []).find(o => String(o.orderId ?? "") === orderId);
1032
+ if (!existing) {
1033
+ // Rule #2: never guess the order — fail honestly if it is no longer open.
1034
+ throw new PerpError("ORDER_NOT_FOUND", `Aster order ${orderId} is not among the open orders; refusing to edit (cannot guess its side).`, { exchange: "aster" });
1035
+ }
1036
+ const type = String(existing.type ?? "").toUpperCase();
1037
+ if (type !== "LIMIT") {
1038
+ // Venue modify supports LIMIT only; a STOP/TP must be cancelled and re-placed explicitly.
1039
+ throw new PerpError("INVALID_PARAMS", `Aster editOrder only supports plain LIMIT orders (order ${orderId} is ${type || "unknown"}). Cancel and re-place it explicitly.`, { exchange: "aster" });
1040
+ }
1041
+ const executed = plainQty(existing.executedQty);
1042
+ if (executed === null) {
1043
+ throw new PerpError("EXCHANGE_ERROR", `Aster order ${orderId}: openOrders row has no readable executedQty (${String(existing.executedQty)})`, { exchange: "aster" });
1044
+ }
1045
+ requirePositive(size, "size");
1046
+ const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
1047
+ return this._perpWrite("PUT /fapi/v3/order", () => this._signedPostTo(this._baseUrl, "/fapi/v3/order", {
1048
+ symbol: apiSymbol,
1049
+ orderId,
1050
+ side: String(existing.side ?? "").toUpperCase(),
1051
+ quantity: subDecimal(size, `-${executed}`),
1052
+ price: px,
1053
+ }, r, "PUT"), true);
445
1054
  }
446
1055
  async cancelOrder(symbol, orderId) {
1056
+ return this._cancelOne(symbol, { orderId }, orderId);
1057
+ }
1058
+ /**
1059
+ * Cancel one order by client order id — V3 futures "Cancel Order" (DELETE /fapi/v3/order):
1060
+ * "Either `orderId` or `origClientOrderId` must be sent."
1061
+ */
1062
+ async cancelOrderByClientId(symbol, clientOrderId) {
1063
+ const id = requireAsterClientOrderId(clientOrderId);
1064
+ return this._cancelOne(symbol, { origClientOrderId: id }, `c:${id}`);
1065
+ }
1066
+ /**
1067
+ * One order's row — V3 futures "Query Order" (GET /fapi/v3/order, symbol + orderId or
1068
+ * origClientOrderId). Docs: CANCELED / EXPIRED orders with no fill older than 7 days "will not
1069
+ * be found". -2013 NO_SUCH_ORDER → ORDER_NOT_FOUND; any other failure throws as is.
1070
+ */
1071
+ async queryOrderRow(symbol, ref) {
1072
+ const key = ref.kind === "orderId" ? { orderId: ref.value } : { origClientOrderId: requireAsterClientOrderId(ref.value) };
1073
+ const label = ref.kind === "orderId" ? ref.value : `c:${ref.value}`;
1074
+ let row;
1075
+ try {
1076
+ row = await this._signedGetEip712("/fapi/v3/order", { symbol: this._toApi(symbol), ...key }, this._resolveSigner());
1077
+ }
1078
+ catch (e) {
1079
+ if (e instanceof PerpError && e.structured.details?.venueCode === -2013) {
1080
+ throw new PerpError("ORDER_NOT_FOUND", `Aster has no order ${label} on ${symbol.toUpperCase()} (-2013 NO_SUCH_ORDER)`, {
1081
+ exchange: "aster", venueCode: -2013, remediation: "List orders: perp account orders -e aster / perp history orders -e aster",
1082
+ });
1083
+ }
1084
+ throw e;
1085
+ }
1086
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
1087
+ throw new PerpError("EXCHANGE_ERROR", `Aster GET /fapi/v3/order ${label}: unexpected response shape`, { exchange: "aster" });
1088
+ }
1089
+ return row;
1090
+ }
1091
+ async _cancelOne(symbol, key, orderId) {
447
1092
  const r = this._resolveSigner();
448
- return this._signedDeleteEip712("/fapi/v3/order", {
449
- symbol: this._toApi(symbol),
450
- orderId,
451
- }, r);
1093
+ try {
1094
+ return await this._signedDeleteEip712("/fapi/v3/order", {
1095
+ symbol: this._toApi(symbol),
1096
+ ...key,
1097
+ }, r);
1098
+ }
1099
+ catch (e) {
1100
+ // Docs (futures V3 error codes): "-2011 CANCEL_REJECTED" — the venue refused the cancel; the
1101
+ // order is not open (already filled / cancelled, or unknown). Live mainnet 2026-10-05, cancel of
1102
+ // a filled order: 400 {"code":-2011,"msg":"Unknown order sent."}. Definite: nothing was cancelled.
1103
+ if (e instanceof PerpError && e.structured.details?.venueCode === -2011) {
1104
+ throw new PerpError("ORDER_REJECTED", `Aster refused to cancel order ${orderId} on ${symbol} (-2011 CANCEL_REJECTED): it is not an open order`, {
1105
+ exchange: "aster", orderId, venueCode: -2011,
1106
+ remediation: `Check its final state: perp trade status ${orderId} --symbol ${symbol} -e aster`,
1107
+ });
1108
+ }
1109
+ throw e;
1110
+ }
452
1111
  }
453
1112
  async cancelAllOrders(symbol) {
454
1113
  const r = this._resolveSigner();
@@ -473,39 +1132,229 @@ export class AsterAdapter {
473
1132
  marginType: marginMode === "cross" ? "CROSSED" : "ISOLATED",
474
1133
  }, r);
475
1134
  }
476
- catch { /* may fail if already set */ }
1135
+ catch (e) {
1136
+ // Rule #2: only -4046 NO_NEED_TO_CHANGE_MARGIN_TYPE ("No need to change
1137
+ // margin type") is a benign no-op. Every other rejection — notably a
1138
+ // refusal because the symbol has an open position or open orders —
1139
+ // means the account is STILL on the previous margin mode. Swallowing
1140
+ // those left the user believing they had switched to isolated while the
1141
+ // position stayed cross, and the leverage change below then applied to
1142
+ // a cross position.
1143
+ const msg = e instanceof Error ? e.message : String(e);
1144
+ const benign = msg.includes("-4046") || /no need to change margin type/i.test(msg);
1145
+ if (benign) { /* already on the requested margin type */ }
1146
+ else if (e instanceof PerpError && typeof e.structured.details?.venueCode === "number" && [-4047, -4048, -4050, -4051].includes(e.structured.details.venueCode)) {
1147
+ // The venue refused the margin-type change; the leverage request below is NOT sent, so
1148
+ // nothing changed. Docs ("Error Codes"): -4047 "Margin type cannot be changed if there
1149
+ // exists open orders." · -4048 "Margin type cannot be changed if there exists position."
1150
+ // · -4050 "Cross balance insufficient." · -4051 "Isolated balance insufficient." The
1151
+ // "Change Margin Type" section states no balance or Multi-Assets-mode condition, so
1152
+ // for -4050 / -4051 the venue's own text is reported as is (live mainnet 2026-10-05:
1153
+ // ISOLATED on a flat SOL → 400 -4050) with the two account facts to check.
1154
+ const code = e.structured.details.venueCode;
1155
+ const why = {
1156
+ [-4047]: ["THERE_EXISTS_OPEN_ORDERS", `Cancel the ${symbol} open orders first: perp trade cancel ${symbol} -e aster`],
1157
+ [-4048]: ["THERE_EXISTS_QUANTITY", `Close the ${symbol} position first: perp trade close ${symbol} -e aster`],
1158
+ [-4050]: ["CROSS_BALANCE_INSUFFICIENT", `The venue docs do not state the condition. Check the account's margin-asset balance (perp account balance -e aster) and whether it is in Multi-Assets mode (GET /fapi/v3/multiAssetsMargin; set in the Aster app) — or keep cross: perp trade leverage ${symbol} ${leverage} -e aster`],
1159
+ [-4051]: ["ISOLATED_BALANCE_INSUFFICIENT", `The venue docs do not state the condition. Check the account's margin-asset balance: perp account balance -e aster`],
1160
+ };
1161
+ throw new PerpError("ACTION_REJECTED", `Aster refused to switch ${symbol} to ${marginMode} margin (${code} ${why[code][0]}: ${msg}) — margin mode and leverage are unchanged`, {
1162
+ exchange: "aster", venueCode: code, httpStatus: e.structured.details.httpStatus, remediation: why[code][1],
1163
+ });
1164
+ }
1165
+ else
1166
+ throw e;
1167
+ }
477
1168
  }
478
1169
  return this._signedPostEip712("/fapi/v3/leverage", {
479
1170
  symbol: this._toApi(symbol),
480
1171
  leverage: String(leverage),
481
1172
  }, r);
482
1173
  }
1174
+ /**
1175
+ * Trigger order. Type follows the venue trigger rules (V3 docs, "Condition orders"):
1176
+ * STOP*: BUY fires at price >= stopPrice, SELL at <= ; TAKE_PROFIT*: the reverse. So the
1177
+ * type is chosen from the trigger vs the current trigger-source price and the order side
1178
+ * (the side closing a long is SELL): BUY above / SELL below → STOP, otherwise
1179
+ * TAKE_PROFIT. (Always STOP_MARKET made every TP fail with -2021.)
1180
+ * Trigger source: workingType MARK_PRICE compared with the premiumIndex mark (default),
1181
+ * or CONTRACT_PRICE with the last price when triggerType is "last".
1182
+ * size "0" = whole position → closePosition=true (STOP_MARKET / TAKE_PROFIT_MARKET only;
1183
+ * the venue forbids quantity and reduceOnly with it).
1184
+ * `opts.kind` (TP/SL legs): sl → STOP(_MARKET), tp → TAKE_PROFIT(_MARKET) as given, with
1185
+ * no price read; a trigger already crossed is rejected by the venue (-2021).
1186
+ */
483
1187
  async stopOrder(symbol, side, size, triggerPrice, opts) {
484
1188
  const r = this._resolveSigner();
485
- const params = {
486
- symbol: this._toApi(symbol),
487
- side: side.toUpperCase(),
488
- quantity: size,
489
- stopPrice: triggerPrice,
490
- type: opts?.limitPrice ? "STOP" : "STOP_MARKET",
491
- };
492
- if (opts?.limitPrice) {
493
- params.price = opts.limitPrice;
494
- params.timeInForce = "GTC";
1189
+ const apiSymbol = this._toApi(symbol);
1190
+ const what = `stop ${side} ${size} ${symbol} @ ${triggerPrice}`;
1191
+ let params;
1192
+ try {
1193
+ if (opts?.triggerType === "mid") {
1194
+ throw unsupported("Aster", "triggerType", "stop orders trigger on MARK_PRICE or CONTRACT_PRICE only", "Use triggerType mark or last");
1195
+ }
1196
+ const f = await this._symbolPriceFilters(apiSymbol);
1197
+ const stopPrice = requireOnTick(triggerPrice, f.tickSize);
1198
+ const useLast = opts?.triggerType === "last";
1199
+ let isStop;
1200
+ if (opts?.kind !== undefined) {
1201
+ isStop = opts.kind === "sl";
1202
+ }
1203
+ else {
1204
+ const ref = useLast ? await this._lastPrice(apiSymbol) : await this.referencePrice(symbol);
1205
+ const t = Number(stopPrice), p = Number(ref);
1206
+ if (t === p) {
1207
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: trigger ${stopPrice} equals the current ${useLast ? "last" : "mark"} price — it would trigger immediately`, { exchange: "aster" });
1208
+ }
1209
+ isStop = side === "buy" ? t > p : t < p;
1210
+ }
1211
+ const closeAll = Number(size) === 0;
1212
+ if (!closeAll)
1213
+ requirePositive(size, "size");
1214
+ params = {
1215
+ symbol: apiSymbol,
1216
+ side: side.toUpperCase(),
1217
+ stopPrice,
1218
+ workingType: useLast ? "CONTRACT_PRICE" : "MARK_PRICE",
1219
+ };
1220
+ if (opts?.limitPrice) {
1221
+ if (closeAll) {
1222
+ throw new PerpError("INVALID_PARAMS", "Aster closePosition (whole-position trigger) only exists for market triggers — pass an explicit size with a limit price", { exchange: "aster" });
1223
+ }
1224
+ params.type = isStop ? "STOP" : "TAKE_PROFIT";
1225
+ params.price = requireOnTick(opts.limitPrice, f.tickSize);
1226
+ params.timeInForce = "GTC";
1227
+ }
1228
+ else {
1229
+ params.type = isStop ? "STOP_MARKET" : "TAKE_PROFIT_MARKET";
1230
+ }
1231
+ if (closeAll) {
1232
+ params.closePosition = "true";
1233
+ }
1234
+ else {
1235
+ params.quantity = size;
1236
+ if (opts?.reduceOnly)
1237
+ params.reduceOnly = "true";
1238
+ }
1239
+ Object.assign(params, await this._builderParams());
1240
+ }
1241
+ catch (e) {
1242
+ throw orderNotSent("aster", what, e);
495
1243
  }
496
- if (opts?.reduceOnly)
497
- params.reduceOnly = "true";
498
1244
  return this._signedPostEip712("/fapi/v3/order", params, r);
499
1245
  }
500
- // ── Withdraw (master-only: never delegates to agent or PK) ──
501
- async withdraw(amount, _destination, _opts) {
502
- if (!this._masterSigner) {
503
- throw new PerpError("NO_SIGNER_AVAILABLE", "Aster withdrawal requires OWS master wallet (--ows). Agent and PK signers cannot authorize withdrawals.", {
504
- remediation: "Use: perp -e aster --ows <walletName> funds withdraw",
1246
+ /** Last traded price (GET /fapi/v3/ticker/price) — the CONTRACT_PRICE trigger source. */
1247
+ async _lastPrice(apiSymbol) {
1248
+ let price;
1249
+ try {
1250
+ price = (await this._publicGet("/fapi/v3/ticker/price", { symbol: apiSymbol }))?.price;
1251
+ }
1252
+ catch (e) {
1253
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} last price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
1254
+ }
1255
+ const str = typeof price === "number" ? String(price) : price;
1256
+ if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
1257
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} ticker/price returned no usable price (${String(price)})`, { exchange: "aster" });
1258
+ }
1259
+ return str;
1260
+ }
1261
+ // ── Withdraw (mainnet only): POST /fapi/v3/aster/user-withdraw ──
1262
+ /** Asset withdrawn by `withdraw`: `requested` (must be in capabilities.withdrawAssets), else withdrawAssets[0]. */
1263
+ _withdrawAsset(requested) {
1264
+ const assets = asterVenue.capabilities?.withdrawAssets;
1265
+ if (assets === null || assets === undefined || assets.length === 0)
1266
+ throw new Error("aster venue definition declares no withdrawAssets");
1267
+ if (requested === undefined)
1268
+ return assets[0];
1269
+ const a = requested.toUpperCase();
1270
+ if (!assets.includes(a)) {
1271
+ throw new PerpError("INVALID_PARAMS", `Aster withdraws ${assets.join(" / ")} only (got "${requested}")`, { exchange: "aster", remediation: `Use --asset ${assets.join(" or --asset ")}` });
1272
+ }
1273
+ return a;
1274
+ }
1275
+ /**
1276
+ * Withdrawable perp balance + fee for the withdraw asset on `chain`
1277
+ * (POST /fapi/v3/aster/user-withdraw-info, signed by the API wallet). Read-only.
1278
+ */
1279
+ async withdrawLimits(chain, asset) {
1280
+ requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
1281
+ const raw = await this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/user-withdraw-info", {});
1282
+ return parseAsterWithdrawLimits(raw, this._withdrawAsset(asset), ASTER_FUNDS_CHAINS[chain].chainId);
1283
+ }
1284
+ /** Deposit / withdraw history (POST /fapi/v3/aster/deposit-withdraw-history, signed by the API wallet). Read-only. */
1285
+ async depositWithdrawHistory() {
1286
+ return this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/deposit-withdraw-history", {});
1287
+ }
1288
+ /**
1289
+ * Withdraw the venue collateral (USDT) from the perp account to the account owner's OWN address
1290
+ * on BNB Chain (default) or Arbitrum. Two signatures (asterdex/api-docs
1291
+ * demo/aster-deposit-withdrawal.md, API-demo futures-v3-demo/58_withdrawEvm.js):
1292
+ * 1. `userSignature` — the master wallet's EIP-712 "Aster" Action over receiver / chain / token /
1293
+ * amount / fee / nonce, recovered locally and compared to the account owner before sending;
1294
+ * 2. the V3 envelope signed by the API wallet, which must have canWithdraw (and an IP whitelist).
1295
+ * The fee and the maximum come from user-withdraw-info; the identical amount / fee strings go
1296
+ * into the signature and the request. Once the request left, an unreadable result is
1297
+ * ORDER_PENDING (never resubmit).
1298
+ */
1299
+ async withdraw(amount, destination, opts) {
1300
+ requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
1301
+ if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
1302
+ throw new PerpError("INVALID_PARAMS", "Aster withdraw takes no assetId/routeType (use --asset USDT|USDC and --chain bsc|arbitrum)");
1303
+ }
1304
+ const chain = opts?.chain === undefined ? "bsc" : opts.chain;
1305
+ const pins = ASTER_FUNDS_CHAINS[chain];
1306
+ const asset = this._withdrawAsset(opts?.asset);
1307
+ const r = this._resolveSigner();
1308
+ if (this._agentMeta === undefined || this._agentMeta.permissions.canWithdraw !== true) {
1309
+ throw new PerpError("POLICY_DENIED", "The active Aster API wallet was approved without the withdraw permission", {
1310
+ exchange: "aster",
1311
+ remediation: "Re-approve it with withdraw + a mandatory IP whitelist: perp wallet agent approve aster --can-withdraw --ip-whitelist <your public ip> --rotate — or withdraw in the Aster web app",
505
1312
  });
506
1313
  }
507
- // Aster withdrawal via spot/wallet API (not futures API) — not yet implemented.
508
- throw new Error("Aster withdrawal requires the spot API (not available in futures mode). Use the Aster web UI to withdraw.");
1314
+ const user = r.userAddress;
1315
+ if (!/^0x[0-9a-fA-F]{40}$/.test(destination) || destination.toLowerCase() !== user.toLowerCase()) {
1316
+ throw new PerpError("INVALID_PARAMS", `Aster only withdraws to the account's own address ${user} (got "${destination}")`, {
1317
+ exchange: "aster", remediation: `Omit --to (defaults to ${user})`,
1318
+ });
1319
+ }
1320
+ const master = this._masterSigner ?? this._pkSigner;
1321
+ if (master === undefined) {
1322
+ throw new PerpError("NO_SIGNER_AVAILABLE", "Aster withdraw needs the master wallet to sign the withdraw message; only the API wallet is loaded", {
1323
+ exchange: "aster", remediation: "Run with the master wallet: perp --ows <wallet> funds withdraw aster <amount> — or withdraw in the Aster web app",
1324
+ });
1325
+ }
1326
+ if (master.getAddress().toLowerCase() !== user.toLowerCase()) {
1327
+ throw new PerpError("INVALID_PARAMS", `The loaded master wallet ${master.getAddress()} is not the Aster account owner ${user}`, {
1328
+ exchange: "aster", remediation: "Select the wallet that owns the Aster account (--ows <wallet>)",
1329
+ });
1330
+ }
1331
+ const limits = await this.withdrawLimits(chain, asset);
1332
+ assertAsterWithdrawAmount(amount, limits, asset);
1333
+ const userNonce = (BigInt(Date.now()) * 1000n).toString();
1334
+ const typed = buildWithdrawTypedData({
1335
+ chainId: pins.chainId, chainName: pins.withdrawChainName, receiver: user, asset, amount, fee: limits.fee, userNonce,
1336
+ });
1337
+ let userSignature;
1338
+ try {
1339
+ userSignature = await master.signTypedData(typed.domain, typed.types, typed.message);
1340
+ }
1341
+ catch (e) {
1342
+ throw orderNotSent("aster", "withdraw", e);
1343
+ }
1344
+ assertAsterWithdrawSignature(typed, userSignature, user);
1345
+ const path = "/fapi/v3/aster/user-withdraw";
1346
+ const raw = await this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, {
1347
+ chainId: String(pins.chainId), asset, amount, fee: limits.fee, receiver: user, userNonce, userSignature,
1348
+ }, r));
1349
+ const res = (raw !== null && typeof raw === "object" ? raw : {});
1350
+ if (typeof res.withdrawId !== "string" && typeof res.withdrawId !== "number") {
1351
+ // 2xx without the documented withdrawId: the request was accepted on the wire — outcome unknown.
1352
+ throw orderOutcomeUnknown("aster", `POST ${path}`, new Error(`unrecognized response ${JSON.stringify(raw).slice(0, 160)}`), "Do NOT resubmit — the withdrawal may have been accepted. Check the Aster app → Assets → History");
1353
+ }
1354
+ return {
1355
+ withdrawId: String(res.withdrawId), hash: typeof res.hash === "string" ? res.hash : "",
1356
+ chain, chainId: pins.chainId, asset, amount, fee: limits.fee, receiver: user,
1357
+ };
509
1358
  }
510
1359
  // ── Private: signer resolution ────────────────────────────────────────────
511
1360
  /**
@@ -517,23 +1366,20 @@ export class AsterAdapter {
517
1366
  _resolveSigner() {
518
1367
  // Tier 1: agent (when registered, not expired, --no-agent NOT set)
519
1368
  if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
1369
+ // An expired agent record is an explicit error — never a fall-through to the master tier
1370
+ // (as on Hyperliquid / Pacifica; the master cannot sign Aster requests anyway).
520
1371
  if (isExpired(this._agentMeta)) {
521
- // Skip Tier 1 only if Tier 2/3 available; otherwise throw AGENT_EXPIRED
522
- if (!this._masterSigner && !this._pkSigner) {
523
- throw new PerpError("AGENT_EXPIRED", "Agent wallet has expired", {
524
- remediation: "perp wallet agent approve aster --rotate",
525
- });
526
- }
527
- // fall through to Tier 2/3
528
- }
529
- else {
530
- return {
531
- tier: "agent",
532
- signer: this._agentSigner,
533
- signerAddress: this._agentMeta.agentEvmAddress,
534
- userAddress: this._agentMeta.userEvmAddress,
535
- };
1372
+ throw new PerpError("AGENT_EXPIRED", `Aster agent wallet ${this._agentMeta.agentEvmAddress} expired at ${this._agentMeta.expiresAt}`, {
1373
+ exchange: "aster",
1374
+ remediation: "perp wallet agent approve aster --rotate",
1375
+ });
536
1376
  }
1377
+ return {
1378
+ tier: "agent",
1379
+ signer: this._agentSigner,
1380
+ signerAddress: this._agentMeta.agentEvmAddress,
1381
+ userAddress: this._agentMeta.userEvmAddress,
1382
+ };
537
1383
  }
538
1384
  // Tier 2/3: master self-signing is not supported by the venue. Per Aster
539
1385
  // V3 spec the `signer` field MUST be a registered API_WALLET (agent);
@@ -555,9 +1401,9 @@ export class AsterAdapter {
555
1401
  });
556
1402
  }
557
1403
  // ── Private: nonce ────────────────────────────────────────────────────────
558
- _nextNonce() {
559
- const ms = Date.now() * 1000 + (++this._nonceCounter % 1000);
560
- return String(ms);
1404
+ /** Monotonic µs nonce per signer, persisted across processes (aster-nonce.ts). */
1405
+ _nextNonce(signer) {
1406
+ return nextAsterNonce(signer);
561
1407
  }
562
1408
  // ── Private: EIP-712 signed HTTP helpers ─────────────────────────────────
563
1409
  /**
@@ -586,7 +1432,7 @@ export class AsterAdapter {
586
1432
  // Insertion order (matches Python reference: nonce, user, signer last).
587
1433
  const fullParams = {
588
1434
  ...params,
589
- nonce: this._nextNonce(),
1435
+ nonce: this._nextNonce(resolved.signerAddress),
590
1436
  user: resolved.userAddress,
591
1437
  signer: resolved.signerAddress,
592
1438
  };
@@ -603,14 +1449,54 @@ export class AsterAdapter {
603
1449
  * Sign and POST to a path using EIP-712 Domain B.
604
1450
  */
605
1451
  async _signedPostEip712(path, params, resolved) {
606
- const qs = await this._buildSignedQueryString(params, resolved);
607
- const url = `${this._baseUrl}${path}?${qs}`;
608
- const res = await fetch(url, {
609
- method: "POST",
610
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
611
- body: "",
612
- });
613
- return this._handleAsterResponse(res, "POST", path);
1452
+ return this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, params, resolved), path === "/fapi/v3/order");
1453
+ }
1454
+ /**
1455
+ * A perp account WRITE (order / modify / cancel / margin type / leverage). Once the request is
1456
+ * on the wire, a transport failure, an HTTP 5xx or an unreadable 2xx body leaves the outcome
1457
+ * unknown → ORDER_PENDING (never the retryable EXCHANGE_ERROR / EXCHANGE_UNREACHABLE); no
1458
+ * connection at all → not sent. Venue refusals (4xx, error envelopes) pass through unchanged.
1459
+ */
1460
+ async _perpWrite(what, send, orderPlacement = false) {
1461
+ try {
1462
+ return await send();
1463
+ }
1464
+ catch (e) {
1465
+ if (e instanceof PerpError) {
1466
+ const d = e.structured.details;
1467
+ const http = typeof d?.httpStatus === "number" ? d.httpStatus : null;
1468
+ if (d?.responseUnreadable === true || (http !== null && http >= 500))
1469
+ throw orderOutcomeUnknown("aster", what, e);
1470
+ // Order placement / modify: a documented 4xx business code is a definite refusal (asterOrderRejection).
1471
+ throw orderPlacement ? asterOrderRejection(e) : e;
1472
+ }
1473
+ throw isTransportFailure(e) ? sendFailure("aster", what, e) : e;
1474
+ }
1475
+ }
1476
+ /** Sign and POST (or PUT) to `host + path` (perp `fapi` or spot `sapi` — same Domain B signing). */
1477
+ async _signedPostTo(host, path, params, resolved, method = "POST") {
1478
+ let qs;
1479
+ try {
1480
+ qs = await this._buildSignedQueryString(params, resolved);
1481
+ }
1482
+ catch (e) {
1483
+ // Signing failed before the request was sent: definitely not placed (never ORDER_PENDING).
1484
+ throw orderNotSent("aster", `${method} ${path}`, e);
1485
+ }
1486
+ const url = `${host}${path}?${qs}`;
1487
+ // Any order write (place / modify) makes the open-orders snapshot stale — before and after the request.
1488
+ this.invalidateOpenOrdersCache();
1489
+ try {
1490
+ const res = await fetch(url, {
1491
+ method,
1492
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1493
+ body: "",
1494
+ });
1495
+ return await this._handleAsterResponse(res, method, path);
1496
+ }
1497
+ finally {
1498
+ this.invalidateOpenOrdersCache();
1499
+ }
614
1500
  }
615
1501
  /**
616
1502
  * Sign and GET from a path using EIP-712 Domain B.
@@ -625,27 +1511,40 @@ export class AsterAdapter {
625
1511
  * one would itself be rejected. Non-429 venue errors throw immediately.
626
1512
  */
627
1513
  async _signedGetEip712(path, params, resolved) {
628
- return this._signedRequestWithRetry("GET", path, params, resolved);
1514
+ return this._signedRequestWithRetry(this._baseUrl, "GET", path, params, resolved);
629
1515
  }
630
1516
  /**
631
1517
  * Sign and DELETE from a path using EIP-712 Domain B.
632
1518
  * Same retry behavior as _signedGetEip712.
633
1519
  */
634
1520
  async _signedDeleteEip712(path, params, resolved) {
635
- return this._signedRequestWithRetry("DELETE", path, params, resolved);
1521
+ return this._perpWrite(`DELETE ${path}`, () => this._signedRequestWithRetry(this._baseUrl, "DELETE", path, params, resolved));
636
1522
  }
637
1523
  /** Shared GET/DELETE retry loop with fresh-nonce per attempt.
638
1524
  *
639
1525
  * Total 4 attempts: initial + 3 retries with backoffs 2s, 4s, 8s.
640
1526
  * Codex v0.12.12 final QA #3: previously MAX_ATTEMPTS = 3 truncated the
641
1527
  * documented curve so the 8s backoff was never reachable in practice. */
642
- async _signedRequestWithRetry(method, path, params, resolved) {
1528
+ async _signedRequestWithRetry(host, method, path, params, resolved) {
1529
+ if (method === "DELETE") {
1530
+ // A cancel makes the open-orders snapshot stale — before and after the request.
1531
+ this.invalidateOpenOrdersCache();
1532
+ try {
1533
+ return await this._signedRequestLoop(host, method, path, params, resolved);
1534
+ }
1535
+ finally {
1536
+ this.invalidateOpenOrdersCache();
1537
+ }
1538
+ }
1539
+ return this._signedRequestLoop(host, method, path, params, resolved);
1540
+ }
1541
+ async _signedRequestLoop(host, method, path, params, resolved) {
643
1542
  const MAX_ATTEMPTS = 4;
644
1543
  let lastErr;
645
1544
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
646
1545
  // Rebuild signed query string with fresh nonce on each attempt.
647
1546
  const qs = await this._buildSignedQueryString(params, resolved);
648
- const url = `${this._baseUrl}${path}?${qs}`;
1547
+ const url = `${host}${path}?${qs}`;
649
1548
  const res = await fetch(url, method === "DELETE" ? { method: "DELETE" } : undefined);
650
1549
  // 429 → backoff + retry. All other paths (including non-429 venue
651
1550
  // errors wrapped in HTTP 200 with code≠0) throw immediately via
@@ -685,32 +1584,77 @@ export class AsterAdapter {
685
1584
  */
686
1585
  async _handleAsterResponse(res, method, path) {
687
1586
  if (res.status === 429) {
688
- const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10);
689
- await new Promise(r => setTimeout(r, Math.min(retryAfter * 1000, 30000)));
690
- throw new Error(`${method} ${path} rate limited`);
1587
+ // HTTP 429 = refused before processing: the request was definitely NOT executed
1588
+ // (details.sent === false → isDefiniteOrderFailure), and RATE_LIMITED stays retryable —
1589
+ // after Retry-After (carried as retryAfterMs when the venue sends it).
1590
+ const retryAfterSec = Number(res.headers.get("Retry-After"));
1591
+ throw new PerpError("RATE_LIMITED", `Aster ${method} ${path} rate limited (HTTP 429); nothing was executed`, {
1592
+ exchange: "aster", httpStatus: 429, sent: false,
1593
+ ...(Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? { retryAfterMs: retryAfterSec * 1000 } : {}),
1594
+ remediation: "Nothing was sent to the book. Wait and retry, or reduce request frequency",
1595
+ });
691
1596
  }
692
1597
  if (!res.ok) {
693
- const text = await res.text().catch(() => "");
1598
+ const text = await res.text();
1599
+ // Aster returns non-2xx with a JSON {code, msg} body for venue errors (e.g. HTTP 401
1600
+ // -2015 / -1002). Carry the venue code so callers (spot permission mapping) can key on it.
1601
+ let venueCode = null;
1602
+ try {
1603
+ const j = JSON.parse(text);
1604
+ if (typeof j.code === "string" || typeof j.code === "number")
1605
+ venueCode = j.code;
1606
+ }
1607
+ catch {
1608
+ // Non-JSON body (HTML error page): no venue code — the HTTP status + text are reported.
1609
+ }
694
1610
  const s = classifyError(new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`), "aster");
695
- throw new PerpError(s.code, s.message, { exchange: s.exchange });
1611
+ if (res.status >= 500) {
1612
+ // A 5xx can arrive after the venue accepted the request: never a definite code
1613
+ // (substring classification could otherwise yield e.g. SIGNATURE_FAILED / INSUFFICIENT_BALANCE).
1614
+ throw new PerpError("EXCHANGE_ERROR", s.message, {
1615
+ exchange: "aster",
1616
+ httpStatus: res.status,
1617
+ ...(venueCode !== null ? { venueCode } : {}),
1618
+ });
1619
+ }
1620
+ throw new PerpError(s.code, s.message, {
1621
+ exchange: s.exchange,
1622
+ httpStatus: res.status,
1623
+ ...(venueCode !== null ? { venueCode } : {}),
1624
+ ...(s.remediation ? { remediation: s.remediation } : {}),
1625
+ });
696
1626
  }
697
- // Parse JSON, then validate venue error envelope. Aster success returns
698
- // either an object whose `code` is "000000"/200 (or absent) or an array
699
- // (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades).
1627
+ // Parse JSON, then validate venue error envelope. Aster success returns an array
1628
+ // (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades) or an object whose `code`
1629
+ // is absent or one of the documented success values (asterdex/api-docs):
1630
+ // 200 — futures/spot V3, e.g. Change Margin Type: {"code": 200, "msg": "success"}
1631
+ // "200" — futures V3 "Cancel All Open Orders (TRADE)":
1632
+ // {"code": "200", "msg": "The operation of cancel all open order is done."}
1633
+ // "000000" — bapi envelope (bapi/aster-bapi-en.md): {"code": "000000", "message": null, ...}
1634
+ // Anything else (incl. 0, which the docs only list as a WebSocket error code) is an error.
700
1635
  const text = await res.text();
701
1636
  let parsed;
702
1637
  try {
703
1638
  parsed = JSON.parse(text);
704
1639
  }
705
1640
  catch {
706
- throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster" });
1641
+ // httpStatus + responseUnreadable: the request WAS accepted (2xx) — order callers map this to "pending".
1642
+ throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster", httpStatus: res.status, responseUnreadable: true });
707
1643
  }
708
1644
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
709
1645
  const json = parsed;
710
- if (json.code !== undefined && json.code !== "000000" && json.code !== 200 && json.code !== 0) {
1646
+ if (json.code !== undefined && json.code !== 200 && json.code !== "200" && json.code !== "000000") {
711
1647
  const rawMsg = typeof json.msg === "string" ? json.msg : JSON.stringify(json);
712
- const s = classifyError(new Error(rawMsg), "aster");
713
- throw new PerpError(s.code, s.message, { exchange: s.exchange });
1648
+ // Classify on the venue code AND the message: Aster's terse messages
1649
+ // ("This function can only be used after deposit.") are the only signal
1650
+ // for some codes, but the numeric code is the stable key — feed both so
1651
+ // a message reword upstream does not silently drop the mapping.
1652
+ const s = classifyError(new Error(`[${json.code}] ${rawMsg}`), "aster");
1653
+ throw new PerpError(s.code, rawMsg, {
1654
+ exchange: s.exchange,
1655
+ venueCode: json.code,
1656
+ ...(s.remediation ? { remediation: s.remediation } : {}),
1657
+ });
714
1658
  }
715
1659
  }
716
1660
  return parsed;