perp-cli 0.12.17 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +142 -55
  348. package/skills/perp-cli/references/agent-operations.md +110 -56
  349. package/skills/perp-cli/references/commands.md +266 -71
  350. package/skills/perp-cli/references/strategies.md +34 -31
  351. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  352. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -18,9 +18,139 @@
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 ──────────────────────────────────────────────────────────────
25
155
  export class AsterAdapter {
26
156
  name = "aster";
@@ -40,18 +170,25 @@ export class AsterAdapter {
40
170
  _marketsCache = null;
41
171
  _marketsCacheTime = 0;
42
172
  _fundingHoursCache = new Map();
173
+ /** When the venue-wide funding-interval table was last loaded (0 = never). */
174
+ _fundingInfoLoadedAt = 0;
175
+ /** API symbol → price filters; null = listed but PRICE_FILTER / PERCENT_PRICE unreadable. */
176
+ _priceFilters = new Map();
177
+ _priceFiltersLoadedAt = 0;
43
178
  _accountCache = null;
44
179
  _positionsCache = null;
45
180
  _ordersCache = null;
46
181
  static CACHE_TTL = 30_000;
47
182
  static ACCOUNT_CACHE_TTL = 5_000;
48
- // Nonce counter — ensures microsecond-precision uniqueness within process
49
- _nonceCounter = 0;
183
+ /**
184
+ * Builder-fee gate (settings builder.aster + confirmed master approveBuilder); null = not
185
+ * configured. Set by createAster — the adapter never reads settings.
186
+ */
187
+ builderFee = null;
50
188
  constructor(privateKey, testnet = false) {
51
189
  this._testnet = testnet;
52
- this._baseUrl = testnet
53
- ? (process.env.ASTER_TESTNET_URL || "https://testnet.asterdex.com")
54
- : "https://fapi.asterdex.com";
190
+ // SSOT: REST host from the venue definition (venues/aster.ts networks).
191
+ this._baseUrl = networkConfig(asterVenue, testnet ? "testnet" : "mainnet").restUrl;
55
192
  if (privateKey) {
56
193
  this._pendingPk = privateKey;
57
194
  }
@@ -59,7 +196,7 @@ export class AsterAdapter {
59
196
  // ── Lifecycle ──
60
197
  async init() {
61
198
  // Verify connectivity by fetching server time
62
- await this._publicGet("/fapi/v1/time");
199
+ await this._publicGet("/fapi/v3/time");
63
200
  // Build LocalEvmSigner from ctor PK now that we're in an async context
64
201
  if (this._pendingPk) {
65
202
  const { LocalEvmSigner } = await import("../signer/index.js");
@@ -81,6 +218,48 @@ export class AsterAdapter {
81
218
  setNoAgent(noAgent) {
82
219
  this._useNoAgent = noAgent;
83
220
  }
221
+ // ── Builder fee ──
222
+ /** Builder decision for the next perp order (cached approval read; never throws). */
223
+ async builderDecision() {
224
+ const gate = builderGateOf(this);
225
+ return gate === null ? NO_BUILDER : gate.decide({ hip3: false });
226
+ }
227
+ /** `builder` + `feeRate` (decimal) order params, or none (not configured / not approved). */
228
+ async _builderParams() {
229
+ const d = await this.builderDecision();
230
+ return d.applied === null ? {} : { builder: d.applied.identity, feeRate: bpsToRate(requireFeeBps(d.applied)) };
231
+ }
232
+ /** Owner (`user`) address of the active signer — the account a builder approval belongs to. */
233
+ get userAddress() {
234
+ return this._resolveSigner().userAddress;
235
+ }
236
+ /**
237
+ * Builder approvals of this account: GET /fapi/v3/builder (asterdex/api-docs demo
238
+ * `getBuilders`, signed by the API wallet). The response shape is not documented, so
239
+ * anything but a list of rows carrying an address + maxFeeRate is "unknown" (never guessed).
240
+ */
241
+ async builderApprovalOf(target) {
242
+ const raw = await this.signedRequest(this._baseUrl, "GET", "/fapi/v3/builder", {});
243
+ const rec = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
244
+ const list = Array.isArray(raw) ? raw : rec !== null && Array.isArray(rec.rows) ? rec.rows : rec !== null && Array.isArray(rec.data) ? rec.data : null;
245
+ if (list === null)
246
+ return { status: "unknown", reason: `unrecognized GET /fapi/v3/builder response ${JSON.stringify(raw).slice(0, 120)}` };
247
+ for (const r of list) {
248
+ if (r === null || typeof r !== "object")
249
+ return { status: "unknown", reason: "GET /fapi/v3/builder row is not an object" };
250
+ const row = r;
251
+ const addr = typeof row.builder === "string" ? row.builder : typeof row.builderAddress === "string" ? row.builderAddress : null;
252
+ if (addr === null)
253
+ return { status: "unknown", reason: "GET /fapi/v3/builder row has no builder address" };
254
+ if (addr.toLowerCase() !== target.identity.toLowerCase())
255
+ continue;
256
+ const max = rateToBps(row.maxFeeRate);
257
+ if (max === null)
258
+ return { status: "unknown", reason: `unreadable maxFeeRate ${JSON.stringify(row.maxFeeRate)}` };
259
+ return approvalFromMax(max, requireFeeBps(target));
260
+ }
261
+ return approvalFromMax(null, requireFeeBps(target));
262
+ }
84
263
  // ── Status accessors ──
85
264
  get isReadOnly() {
86
265
  return !this._agentSigner && !this._masterSigner && !this._pkSigner;
@@ -93,6 +272,38 @@ export class AsterAdapter {
93
272
  return null;
94
273
  }
95
274
  }
275
+ // ── Spot host access (design §12, used by AsterSpotAdapter) ──
276
+ /**
277
+ * Aster spot REST host. Spot shares the perp EIP-712 Domain B signing
278
+ * (chainId 1666 mainnet / 714 testnet) and the per-agent nonce space.
279
+ * Hosts per the official spot V3 docs (mainnet + testnet variants).
280
+ */
281
+ get spotBaseUrl() {
282
+ return this._testnet ? "https://sapi.asterdex-testnet.com" : "https://sapi.asterdex.com";
283
+ }
284
+ /**
285
+ * Signed request against an arbitrary Aster host with the active signer.
286
+ * POST is single-shot; GET/DELETE use the same 429 retry loop as perp.
287
+ */
288
+ async signedRequest(host, method, path, params) {
289
+ const r = this._resolveSigner();
290
+ if (method === "POST")
291
+ return this._signedPostTo(host, path, params, r);
292
+ return this._signedRequestWithRetry(host, method, path, params, r);
293
+ }
294
+ /**
295
+ * Agent meta of the active signer, or null when the active signer is not an
296
+ * agent. Throws exactly like signed calls when no signer is usable.
297
+ */
298
+ activeAgentMeta() {
299
+ const r = this._resolveSigner();
300
+ if (r.tier !== "agent")
301
+ return null;
302
+ if (!this._agentMeta) {
303
+ throw new PerpError("AGENT_NOT_REGISTERED", "Aster agent signer is active but its metadata is missing", { exchange: "aster" });
304
+ }
305
+ return this._agentMeta;
306
+ }
96
307
  // ── Symbol helpers ──
97
308
  /** CLI symbol → Aster API symbol (ETH → ETHUSDT) */
98
309
  _toApi(symbol) {
@@ -106,32 +317,56 @@ export class AsterAdapter {
106
317
  return symbol.replace(/USDT$/, "").replace(/BUSD$/, "");
107
318
  }
108
319
  // ── Market Data ──
109
- /** Get funding interval for a symbol (lazy bootstrap). */
320
+ /**
321
+ * Get funding interval for a symbol.
322
+ *
323
+ * Source of truth is `GET /fapi/v3/fundingInfo` (weight 1), which states
324
+ * `fundingIntervalHours` per symbol for the whole venue in a single call.
325
+ * This previously inferred the interval by differencing the last two
326
+ * `fundingRate` timestamps and bucketing to 1/4/8h — an estimate that is
327
+ * wrong for a newly listed symbol (fewer than two settlements), across a
328
+ * schedule change, or whenever a settlement is skipped/delayed, and Aster
329
+ * really does mix 1h, 4h and 8h symbols. Funding-interval misconfiguration is
330
+ * exactly what produced the v0.7.23 Aster funding bug, so it is read, not
331
+ * guessed.
332
+ */
110
333
  async getFundingHours(symbol) {
111
334
  const key = symbol.toUpperCase();
112
335
  const cached = this._fundingHoursCache.get(key);
113
336
  if (cached !== undefined)
114
337
  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
- }
338
+ await this._loadFundingInfo();
339
+ return this._fundingHoursCache.get(key);
340
+ }
341
+ /** Populate the funding-interval cache for every symbol in one call. */
342
+ async _loadFundingInfo() {
343
+ if (this._fundingInfoLoadedAt && Date.now() - this._fundingInfoLoadedAt < AsterAdapter.CACHE_TTL)
344
+ return;
345
+ const rows = await this._publicGet("/fapi/v3/fundingInfo", {});
346
+ if (!Array.isArray(rows)) {
347
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/fundingInfo did not return an array", { exchange: "aster" });
124
348
  }
125
- catch { /* non-critical */ }
126
- return undefined;
349
+ for (const row of rows) {
350
+ const hours = Number(row?.fundingIntervalHours);
351
+ if (!row?.symbol || !Number.isFinite(hours) || hours <= 0)
352
+ continue;
353
+ this._fundingHoursCache.set(this._fromApi(String(row.symbol)), hours);
354
+ }
355
+ this._fundingInfoLoadedAt = Date.now();
127
356
  }
128
357
  async getMarkets() {
129
358
  if (this._marketsCache && Date.now() - this._marketsCacheTime < AsterAdapter.CACHE_TTL) {
130
359
  return this._marketsCache;
131
360
  }
132
361
  const [info, tickers] = await Promise.all([
133
- this._publicGet("/fapi/v1/exchangeInfo"),
134
- this._publicGet("/fapi/v1/ticker/24hr"),
362
+ this._publicGet("/fapi/v3/exchangeInfo"),
363
+ this._publicGet("/fapi/v3/ticker/24hr"),
364
+ // Populate the funding-interval table (weight 1, whole venue in one call).
365
+ // The per-symbol read below consulted this cache but nothing ever filled
366
+ // it during getMarkets, so `fundingHours` came back undefined for every
367
+ // market — and Aster genuinely mixes 1h / 4h / 8h symbols, which funding
368
+ // math depends on.
369
+ this._loadFundingInfo(),
135
370
  ]);
136
371
  const tickerMap = new Map();
137
372
  for (const t of tickers ?? []) {
@@ -139,10 +374,11 @@ export class AsterAdapter {
139
374
  }
140
375
  let premiumMap = new Map();
141
376
  try {
142
- const premiums = await this._publicGet("/fapi/v1/premiumIndex");
377
+ const premiums = await this._publicGet("/fapi/v3/premiumIndex");
143
378
  premiumMap = new Map(premiums.map(p => [String(p.symbol), p]));
144
379
  }
145
380
  catch { /* non-critical */ }
381
+ this._ingestPriceFilters(info?.symbols ?? []);
146
382
  const tradingSymbols = (info?.symbols ?? [])
147
383
  .filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING");
148
384
  const result = tradingSymbols.map((s) => {
@@ -156,29 +392,136 @@ export class AsterAdapter {
156
392
  return {
157
393
  symbol: this._fromApi(sym),
158
394
  markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
159
- indexPrice: String(premium?.indexPrice ?? "0"),
395
+ indexPrice: premium?.indexPrice != null ? String(premium.indexPrice) : null,
160
396
  fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
161
- volume24h: String(ticker?.quoteVolume ?? ticker?.volume ?? "0"),
162
- openInterest: "0",
397
+ volume24h: ticker?.quoteVolume != null ? String(ticker.quoteVolume) : null,
398
+ // /fapi/v3/exchangeInfo and ticker/24hr carry no open interest.
399
+ openInterest: null,
163
400
  maxLeverage: maxLev,
164
- sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : undefined,
165
- stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : undefined,
166
- fundingHours,
401
+ sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : null,
402
+ stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : null,
403
+ fundingHours: fundingHours ?? null,
404
+ marginTiers: null,
167
405
  };
168
406
  });
169
407
  this._marketsCache = result;
170
408
  this._marketsCacheTime = Date.now();
171
409
  return result;
172
410
  }
411
+ /** Parse PRICE_FILTER + PERCENT_PRICE for every exchangeInfo symbol into the cache. */
412
+ _ingestPriceFilters(symbols) {
413
+ this._priceFilters.clear();
414
+ for (const s of symbols) {
415
+ const filters = (Array.isArray(s.filters) ? s.filters : []);
416
+ const pf = filters.find(f => f.filterType === "PRICE_FILTER");
417
+ const pp = filters.find(f => f.filterType === "PERCENT_PRICE");
418
+ let entry = null;
419
+ const tick = pf?.tickSize, minP = pf?.minPrice, maxP = pf?.maxPrice;
420
+ if (typeof tick === "string" && Number(tick) > 0 && typeof minP === "string" && typeof maxP === "string") {
421
+ if (!pp) {
422
+ entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: null };
423
+ }
424
+ else {
425
+ const up = Number(pp.multiplierUp), down = Number(pp.multiplierDown);
426
+ if (up > 0 && down > 0 && Number.isFinite(up) && Number.isFinite(down)) {
427
+ entry = { tickSize: tick, minPrice: minP, maxPrice: maxP, percent: { up, down } };
428
+ }
429
+ }
430
+ }
431
+ this._priceFilters.set(String(s.symbol), entry);
432
+ }
433
+ this._priceFiltersLoadedAt = Date.now();
434
+ }
435
+ /** PRICE_FILTER / PERCENT_PRICE of an API symbol (exchangeInfo, cached CACHE_TTL). */
436
+ async _symbolPriceFilters(apiSymbol) {
437
+ if (!this._priceFiltersLoadedAt || Date.now() - this._priceFiltersLoadedAt >= AsterAdapter.CACHE_TTL) {
438
+ const info = await this._publicGet("/fapi/v3/exchangeInfo");
439
+ if (!Array.isArray(info?.symbols)) {
440
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/exchangeInfo returned no symbols", { exchange: "aster" });
441
+ }
442
+ this._ingestPriceFilters(info.symbols);
443
+ }
444
+ if (!this._priceFilters.has(apiSymbol)) {
445
+ throw new PerpError("SYMBOL_NOT_FOUND", `Aster market ${apiSymbol} not found in exchangeInfo`, { exchange: "aster" });
446
+ }
447
+ const f = this._priceFilters.get(apiSymbol);
448
+ if (!f) {
449
+ throw new PerpError("EXCHANGE_ERROR", `Aster ${apiSymbol}: PRICE_FILTER or PERCENT_PRICE missing/unreadable in exchangeInfo`, { exchange: "aster" });
450
+ }
451
+ return f;
452
+ }
453
+ /**
454
+ * Declared protection-price reference (§13.10 #5): the venue mark price from
455
+ * GET /fapi/v3/premiumIndex — the same price PERCENT_PRICE is evaluated against.
456
+ * No runtime substitute (no mid / last fallback): unavailable → PRICE_STALE.
457
+ */
458
+ async referencePrice(symbol) {
459
+ const apiSymbol = this._toApi(symbol);
460
+ let mark;
461
+ try {
462
+ const res = await this._publicGet("/fapi/v3/premiumIndex", { symbol: apiSymbol });
463
+ mark = res?.markPrice;
464
+ }
465
+ catch (e) {
466
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} mark price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
467
+ }
468
+ const str = typeof mark === "number" ? String(mark) : mark;
469
+ if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
470
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} premiumIndex returned no usable markPrice (${String(mark)})`, { exchange: "aster" });
471
+ }
472
+ return str;
473
+ }
474
+ /**
475
+ * Reject (never clamp) a price outside PRICE_FILTER min/max or the side's PERCENT_PRICE
476
+ * bound around `mark` — checked before anything is sent.
477
+ */
478
+ _checkPriceBand(apiSymbol, f, price, side, mark) {
479
+ const p = Number(price);
480
+ if (Number(f.minPrice) > 0 && p < Number(f.minPrice)) {
481
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} below PRICE_FILTER minPrice ${f.minPrice}`, { exchange: "aster" });
482
+ }
483
+ if (Number(f.maxPrice) > 0 && p > Number(f.maxPrice)) {
484
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: price ${price} above PRICE_FILTER maxPrice ${f.maxPrice}`, { exchange: "aster" });
485
+ }
486
+ if (f.percent === null)
487
+ return;
488
+ const m = Number(mark);
489
+ const bound = side === "buy" ? m * f.percent.up : m * f.percent.down;
490
+ if (side === "buy" ? p > bound : p < bound) {
491
+ const bandBps = Math.round(Math.abs((side === "buy" ? f.percent.up : f.percent.down) - 1) * 10_000);
492
+ 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})`, {
493
+ exchange: "aster",
494
+ remediation: `Use --slippage-bps below ${bandBps} for ${this._fromApi(apiSymbol)} (PERCENT_PRICE band)`,
495
+ });
496
+ }
497
+ }
498
+ /**
499
+ * Protection price an IOC market order on `symbol` would be sent with now:
500
+ * mark ± slippage.bps, aligned toward crossing on PRICE_FILTER.tickSize, validated
501
+ * against PRICE_FILTER / PERCENT_PRICE. appliedBps = the bound after tick alignment.
502
+ */
503
+ async computeProtectionPrice(symbol, side, slippage) {
504
+ const apiSymbol = this._toApi(symbol);
505
+ const f = await this._symbolPriceFilters(apiSymbol);
506
+ const ref = await this.referencePrice(symbol);
507
+ const protectionPrice = alignProtectionPrice(slippagePrice(ref, side, slippage.bps), f.tickSize, side);
508
+ this._checkPriceBand(apiSymbol, f, protectionPrice, side, ref);
509
+ const appliedBps = Math.abs(Number(protectionPrice) - Number(ref)) / Number(ref) * 10_000;
510
+ return { referencePrice: ref, protectionPrice, appliedBps };
511
+ }
512
+ /** Definite IOC outcome of a raw order response (see asterIocOutcome). */
513
+ iocOutcomeOf(raw) {
514
+ return asterIocOutcome(raw);
515
+ }
173
516
  async getOrderbook(symbol) {
174
- const res = await this._publicGet("/fapi/v1/depth", { symbol: this._toApi(symbol), limit: "50" });
517
+ const res = await this._publicGet("/fapi/v3/depth", { symbol: this._toApi(symbol), limit: "50" });
175
518
  return {
176
519
  bids: res?.bids ?? [],
177
520
  asks: res?.asks ?? [],
178
521
  };
179
522
  }
180
523
  async getRecentTrades(symbol, limit = 20) {
181
- const trades = await this._publicGet("/fapi/v1/trades", {
524
+ const trades = await this._publicGet("/fapi/v3/trades", {
182
525
  symbol: this._toApi(symbol),
183
526
  limit: String(limit),
184
527
  });
@@ -192,18 +535,18 @@ export class AsterAdapter {
192
535
  }));
193
536
  }
194
537
  async getFundingHistory(symbol, limit = 10) {
195
- const data = await this._publicGet("/fapi/v1/fundingRate", {
538
+ const data = await this._publicGet("/fapi/v3/fundingRate", {
196
539
  symbol: this._toApi(symbol),
197
540
  limit: String(limit),
198
541
  });
199
542
  return (data ?? []).map((d) => ({
200
543
  time: Number(d.fundingTime ?? 0),
201
- rate: String(d.fundingRate ?? "0"),
544
+ rate: venueRateField(d.fundingRate, "fundingRate fundingRate", "aster"),
202
545
  price: d.markPrice ? String(d.markPrice) : null,
203
546
  }));
204
547
  }
205
548
  async getKlines(symbol, interval, startTime, endTime) {
206
- const data = await this._publicGet("/fapi/v1/klines", {
549
+ const data = await this._publicGet("/fapi/v3/klines", {
207
550
  symbol: this._toApi(symbol),
208
551
  interval,
209
552
  startTime: String(startTime),
@@ -222,66 +565,138 @@ export class AsterAdapter {
222
565
  }
223
566
  // ── Account ──
224
567
  async getBalance() {
225
- if (this._accountCache && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
568
+ if (this._accountCache && this._accountCache.epoch === accountCacheEpoch() && Date.now() - this._accountCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
226
569
  return this._accountCache.data;
227
570
  }
228
571
  const r = this._resolveSigner();
229
- // v3: /fapi/v2/account is HMAC-only; v3 EIP-712 uses /fapi/v3/accountWithJoinMargin.
572
+ // v3 EIP-712 account endpoint. (The legacy /fapi/v2/account was HMAC-only and
573
+ // is no longer documented in the Aster V3 spec.)
230
574
  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);
575
+ const totalWallet = parseFiniteVenueNumber(account.totalWalletBalance, "totalWalletBalance", "aster");
576
+ const unrealizedPnl = parseFiniteVenueNumber(account.totalUnrealizedProfit, "totalUnrealizedProfit", "aster");
577
+ const available = parseFiniteVenueNumber(account.availableBalance, "availableBalance", "aster");
578
+ const marginUsed = parseFiniteVenueNumber(account.totalInitialMargin, "totalInitialMargin", "aster");
235
579
  const result = {
236
580
  equity: String(totalWallet + unrealizedPnl),
237
581
  available: String(available),
238
582
  marginUsed: String(marginUsed),
239
583
  unrealizedPnl: String(unrealizedPnl),
240
584
  };
241
- this._accountCache = { data: result, time: Date.now() };
585
+ this._accountCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
242
586
  return result;
243
587
  }
588
+ /**
589
+ * How much of ONE asset the perp account can transfer out: that asset's row of
590
+ * GET /fapi/v3/accountWithJoinMargin `assets[]` — `maxWithdrawAmount` ("maximum amount for
591
+ * transfer out", Aster futures API v3 "Account Information v3"). The account-level
592
+ * `availableBalance` (getBalance) is margin across ALL collateral assets in multi-asset mode
593
+ * and is not an amount of any one asset. Read uncached. A missing row / field is an error,
594
+ * never 0.
595
+ */
596
+ async getPerpAssetTransferable(asset) {
597
+ const r = this._resolveSigner();
598
+ const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
599
+ if (!Array.isArray(account.assets)) {
600
+ throw new PerpError("EXCHANGE_ERROR", "aster /fapi/v3/accountWithJoinMargin: response has no assets list", { exchange: "aster" });
601
+ }
602
+ const row = account.assets.find(a => a.asset === asset);
603
+ if (row === undefined || row.maxWithdrawAmount === undefined || row.maxWithdrawAmount === null) {
604
+ throw new PerpError("EXCHANGE_ERROR", `aster /fapi/v3/accountWithJoinMargin: no ${asset} maxWithdrawAmount in the assets list`, { exchange: "aster" });
605
+ }
606
+ return parseFiniteVenueNumber(row.maxWithdrawAmount, `assets[${asset}].maxWithdrawAmount`, "aster");
607
+ }
244
608
  async getPositions() {
245
- if (this._positionsCache && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
609
+ if (this._positionsCache && this._positionsCache.epoch === accountCacheEpoch() && Date.now() - this._positionsCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
246
610
  return this._positionsCache.data;
247
611
  }
248
612
  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.
613
+ // Positions are enumerated from /fapi/v3/accountWithJoinMargin (`positions`
614
+ // array); that payload omits both mark and liquidation price. The signed
615
+ // Position Information v3 endpoint (GET /fapi/v3/positionRisk) supplies both
616
+ // per open symbol, so it replaces the older public premiumIndex mark-price probe.
252
617
  const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
253
618
  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();
619
+ const open = positions.filter((p) => parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0);
620
+ // Hedge mode returns TWO rows per symbol (LONG and SHORT). This adapter
621
+ // keys everything by symbol alone — the riskMap below, and the mapped
622
+ // result — so the two legs collide and one silently inherits the other's
623
+ // mark/liquidation price. Orders are safe (the venue rejects them with
624
+ // -4061 because we never send positionSide), but positions and liquidation
625
+ // distance would be quietly wrong, so fail honestly instead.
626
+ const bySymbol = new Map();
627
+ for (const p of open) {
628
+ const k = String(p.symbol ?? "");
629
+ bySymbol.set(k, (bySymbol.get(k) ?? 0) + 1);
630
+ }
631
+ const dupes = [...bySymbol.entries()].filter(([, n]) => n > 1).map(([s]) => s);
632
+ if (dupes.length > 0) {
633
+ 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).`, {
634
+ exchange: "aster",
635
+ remediation: "Switch the account to One-way Mode (POST /fapi/v3/positionSide/dual with dualSidePosition=false) or close one side, then retry.",
636
+ });
637
+ }
638
+ // Enrich open positions with mark + liquidation price from /fapi/v3/positionRisk
639
+ // (best-effort; a transient failure leaves them "0" rather than failing the whole
640
+ // positions read). Response fields per Aster V3 spec: markPrice, liquidationPrice.
641
+ const riskMap = new Map();
257
642
  if (open.length > 0) {
258
643
  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"));
644
+ const risk = await this._signedGetEip712("/fapi/v3/positionRisk", {}, r);
645
+ for (const pr of risk ?? []) {
646
+ // Aster V3 spec (userPositionRisk notes, same field): "`liquidationPrice` is reported as `0`
647
+ // whenever the underlying value would be negative" — i.e. the position has no liquidation
648
+ // price (e.g. a small cross long the margin fully covers). 0 is not a price → null.
649
+ const liq = pr.liquidationPrice != null ? String(pr.liquidationPrice) : null;
650
+ riskMap.set(String(pr.symbol), {
651
+ mark: String(pr.markPrice ?? "0"),
652
+ liq: liq !== null && liq.trim() !== "" && Number(liq) === 0 ? null : liq,
653
+ });
262
654
  }
263
655
  }
264
- catch { /* non-critical */ }
656
+ catch (e) {
657
+ // NOT non-critical. A "0" liquidation price is not a neutral default:
658
+ // risk.ts filters positions on `liquidationPrice !== null && > 0` and
659
+ // event-stream.ts gates the liquidation warning on `liq > 0`, so a
660
+ // silent "0" drops EVERY Aster position out of liquidation monitoring —
661
+ // and the likeliest cause of this call failing (rate limiting)
662
+ // correlates with the volatility that makes those alerts matter most.
663
+ // Surface it, and report the values as unknown rather than as a price.
664
+ process.stderr.write(`[aster] positionRisk fetch failed — mark and liquidation prices are UNAVAILABLE this cycle, ` +
665
+ `so liquidation warnings are suppressed for Aster positions: ` +
666
+ `${e instanceof Error ? e.message : String(e)}\n`);
667
+ }
265
668
  }
266
669
  const result = open.map((p) => {
267
- const amt = Number(p.positionAmt ?? 0);
670
+ const amt = parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster");
268
671
  const apiSym = String(p.symbol ?? "");
672
+ const risk = riskMap.get(apiSym);
269
673
  return {
270
674
  symbol: this._fromApi(apiSym),
271
675
  side: amt > 0 ? "long" : "short",
272
676
  size: String(Math.abs(amt)),
273
677
  entryPrice: String(p.entryPrice ?? "0"),
274
- markPrice: markMap.get(apiSym) ?? "0",
275
- liquidationPrice: "0", // v3 accountWithJoinMargin does not expose this
678
+ // Unknown mark is "N/A" (markPrice is a required string); unknown liq is null. Never "0" — a zero reads downstream as a real price.
679
+ // Matches the Pacifica adapter's convention for an absent liq price.
680
+ markPrice: risk?.mark ?? "N/A",
681
+ liquidationPrice: risk ? risk.liq : null,
276
682
  unrealizedPnl: String(p.unrealizedProfit ?? "0"),
277
- leverage: Number(p.leverage ?? 1),
683
+ leverage: parseFiniteVenueNumber(p.leverage, "position.leverage", "aster", { defaultValue: 1 }),
278
684
  };
279
685
  });
280
- this._positionsCache = { data: result, time: Date.now() };
686
+ this._positionsCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
281
687
  return result;
282
688
  }
689
+ /**
690
+ * Drop the in-process open-orders snapshot (after any order write, or before a verifying
691
+ * re-list). An order write also changes positions and balances, so those snapshots go too.
692
+ */
693
+ invalidateOpenOrdersCache() {
694
+ this._ordersCache = null;
695
+ this._positionsCache = null;
696
+ this._accountCache = null;
697
+ }
283
698
  async getOpenOrders() {
284
- if (this._ordersCache && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
699
+ if (this._ordersCache && this._ordersCache.epoch === accountCacheEpoch() && Date.now() - this._ordersCache.time < AsterAdapter.ACCOUNT_CACHE_TTL) {
285
700
  return this._ordersCache.data;
286
701
  }
287
702
  const r = this._resolveSigner();
@@ -295,8 +710,9 @@ export class AsterAdapter {
295
710
  filled: String(o.executedQty ?? "0"),
296
711
  status: String(o.status ?? ""),
297
712
  type: String(o.type ?? ""),
713
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
298
714
  }));
299
- this._ordersCache = { data: result, time: Date.now() };
715
+ this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
300
716
  return result;
301
717
  }
302
718
  async getOrderHistory(limit = 30) {
@@ -325,6 +741,7 @@ export class AsterAdapter {
325
741
  filled: String(o.executedQty ?? "0"),
326
742
  status: String(o.status ?? ""),
327
743
  type: String(o.type ?? ""),
744
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
328
745
  });
329
746
  }
330
747
  }
@@ -372,24 +789,71 @@ export class AsterAdapter {
372
789
  payment: String(f.income ?? "0"),
373
790
  }));
374
791
  }
792
+ /**
793
+ * Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
794
+ * Information v3", one row per symbol, position or not — docs example:
795
+ * `"marginType": "isolated"`, `"leverage": "10"`, `"positionAmt": "0.000"`; rows[].marginType
796
+ * "Margin type: `isolated` or `cross`"). An unreadable row throws — never a default.
797
+ */
798
+ async getMarketSettings() {
799
+ const rows = await this._signedGetEip712("/fapi/v3/positionRisk", {}, this._resolveSigner());
800
+ if (!Array.isArray(rows)) {
801
+ throw new PerpError("EXCHANGE_ERROR", "Aster /fapi/v3/positionRisk did not return an array — per-market settings unknown", { exchange: "aster" });
802
+ }
803
+ const seen = new Map();
804
+ for (const row of rows) {
805
+ const leverage = Number(row.leverage);
806
+ const type = typeof row.marginType === "string" ? row.marginType.toLowerCase() : "";
807
+ // "crossed" is the request enum spelling (CROSSED); the documented read value is "cross".
808
+ const marginMode = type === "isolated" ? "isolated" : type === "cross" || type === "crossed" ? "cross" : null;
809
+ if (typeof row.symbol !== "string" || !(leverage > 0) || marginMode === null) {
810
+ throw new PerpError("EXCHANGE_ERROR", `Aster positionRisk row without a readable symbol / leverage / marginType: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
811
+ }
812
+ // Hedge mode lists a symbol once per position side; leverage / margin type are per symbol.
813
+ seen.set(row.symbol, { symbol: this._fromApi(row.symbol), leverage, marginMode });
814
+ }
815
+ return [...seen.values()];
816
+ }
375
817
  // ── Trading ──
376
818
  async marketOrder(symbol, side, size, opts) {
819
+ // Design §13.2 / §13.10 #6: never native MARKET (no price cap) — an IOC LIMIT at the
820
+ // protection price (mark ± opts.slippage.bps, tick-aligned toward crossing, inside
821
+ // PRICE_FILTER / PERCENT_PRICE). newOrderRespType=RESULT makes the response carry the
822
+ // final IOC status (FILLED / EXPIRED) and executedQty.
377
823
  const r = this._resolveSigner();
378
824
  const apiSymbol = this._toApi(symbol);
825
+ const clientOrderId = opts.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
826
+ let protectionPrice;
827
+ let protection;
828
+ try {
829
+ protection = await this.computeProtectionPrice(symbol, side, opts.slippage);
830
+ ({ protectionPrice } = protection);
831
+ }
832
+ catch (e) {
833
+ throw orderNotSent("aster", `market ${side} ${size} ${symbol}`, e);
834
+ }
379
835
  const params = {
380
836
  symbol: apiSymbol,
381
837
  side: side.toUpperCase(),
382
- type: "MARKET",
838
+ type: "LIMIT",
839
+ timeInForce: "IOC",
840
+ price: protectionPrice,
383
841
  quantity: size,
842
+ newOrderRespType: "RESULT",
384
843
  };
385
- if (opts?.reduceOnly)
844
+ if (clientOrderId !== null)
845
+ params.newClientOrderId = clientOrderId;
846
+ if (opts.reduceOnly)
386
847
  params.reduceOnly = "true";
848
+ Object.assign(params, await this._builderParams());
387
849
  const result = await this._signedPostEip712("/fapi/v3/order", params, r);
388
850
  const ro = result;
389
851
  const executedQty = Number(ro.executedQty ?? 0);
390
852
  const orderId = String(ro.orderId ?? "");
853
+ // The protection actually sent rides along with the venue response (`protection`), read by
854
+ // the TradingV2 wrapper's execution record (the venue does not echo the reference).
391
855
  if (executedQty > 0 && ro.status === "FILLED")
392
- return result;
856
+ return { ...ro, protection };
393
857
  if (ro.status === "NEW" && orderId) {
394
858
  for (let i = 0; i < 3; i++) {
395
859
  await new Promise(res => setTimeout(res, 1000));
@@ -400,9 +864,10 @@ export class AsterAdapter {
400
864
  }, r);
401
865
  const filledQty = Number(check.executedQty ?? 0);
402
866
  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})`);
867
+ return { ...check, protection };
868
+ if ((check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") && check.executedQty !== undefined && check.executedQty !== null) {
869
+ // Terminal status with a REPORTED 0 executed: nothing traded (definite).
870
+ throw new PerpError("ORDER_REJECTED", `Market ${side} ${symbol}: order ${check.status} (orderId: ${orderId})`, { exchange: "aster", orderId });
406
871
  }
407
872
  }
408
873
  catch (e) {
@@ -410,45 +875,165 @@ export class AsterAdapter {
410
875
  throw e;
411
876
  }
412
877
  }
878
+ let cancelledQty = null;
879
+ let cancelRow = null;
413
880
  try {
414
- await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
881
+ const c = await this._signedDeleteEip712("/fapi/v3/order", { symbol: apiSymbol, orderId }, r);
882
+ const q = Number(c?.executedQty);
883
+ cancelledQty = Number.isFinite(q) ? q : null;
884
+ cancelRow = c;
415
885
  }
416
- catch { /* best effort cancel */ }
417
- throw new Error(`Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`);
886
+ catch { /* cancel failed: the order may still be live — outcome stays unknown below */ }
887
+ // The cancel answered with executedQty > 0: the order PARTIALLY filled before the cancel —
888
+ // report that fill (the cancel row carries executedQty / status CANCELED; asterIocOutcome
889
+ // reads it), never "not filled".
890
+ if (cancelledQty !== null && cancelledQty > 0 && cancelRow !== null) {
891
+ console.error(`[aster] market ${side} ${symbol}: order ${orderId} partially filled (${String(cancelRow.executedQty)}) before the remainder was cancelled`);
892
+ return { ...cancelRow, protection };
893
+ }
894
+ const msg = `Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`;
895
+ // Definite only when the cancel was confirmed with 0 executed; otherwise it may have filled / be live.
896
+ if (cancelledQty === 0)
897
+ throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
898
+ throw new Error(msg);
418
899
  }
419
900
  if (executedQty === 0) {
420
- throw new Error(`Market ${side} ${symbol}: order accepted but 0 filled (status: ${ro.status}, orderId: ${orderId})`);
901
+ const msg = `Market ${side} ${symbol}: order accepted but 0 filled (status: ${ro.status}, orderId: ${orderId})`;
902
+ // A terminal status (EXPIRED IOC / CANCELED / REJECTED) with 0 executed is a definite no-fill.
903
+ if ((ro.status === "EXPIRED" || ro.status === "CANCELED" || ro.status === "REJECTED") && ro.executedQty !== undefined && ro.executedQty !== null) {
904
+ throw new PerpError("ORDER_REJECTED", msg, { exchange: "aster", orderId });
905
+ }
906
+ throw new Error(msg);
421
907
  }
422
- return result;
908
+ return { ...ro, protection };
423
909
  }
424
910
  async limitOrder(symbol, side, price, size, opts) {
425
911
  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";
912
+ const apiSymbol = this._toApi(symbol);
913
+ const clientOrderId = opts?.clientOrderId === undefined ? null : requireAsterClientOrderId(opts.clientOrderId);
914
+ let params;
915
+ try {
916
+ // Common TIF names (§13.3): GTC / IOC / ALO→GTX (GTX accepted as alias); FOK unsupported.
917
+ const tif = asterTif(opts?.tif ?? "GTC");
918
+ // Price must already be on PRICE_FILTER.tickSize — validated, never rounded (INVALID_TICK).
919
+ const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
920
+ params = {
921
+ symbol: apiSymbol,
922
+ side: side.toUpperCase(),
923
+ type: "LIMIT",
924
+ price: px,
925
+ quantity: size,
926
+ timeInForce: tif,
927
+ };
928
+ // IOC / GTX: ask for the final status + executedQty in the response (iocOutcomeOf). Docs (futures
929
+ // V3, New Order): with `newOrderRespType` RESULT, "`LIMIT` order with special `timeInForce`: the
930
+ // final status result of the order(FILLED or EXPIRED) will be returned directly". The default ACK
931
+ // answers NEW for a post-only order that then expires because it would cross (live mainnet 2026-10-05).
932
+ if (tif === "IOC" || tif === "GTX")
933
+ params.newOrderRespType = "RESULT";
934
+ if (clientOrderId !== null)
935
+ params.newClientOrderId = clientOrderId;
936
+ if (opts?.reduceOnly)
937
+ params.reduceOnly = "true";
938
+ Object.assign(params, await this._builderParams());
939
+ }
940
+ catch (e) {
941
+ throw orderNotSent("aster", `limit ${side} ${size} ${symbol} @ ${price}`, e);
942
+ }
436
943
  return this._signedPostEip712("/fapi/v3/order", params, r);
437
944
  }
945
+ /**
946
+ * Native modify (PUT /fapi/v3/order, V3 futures docs "Modify Order"): LIMIT orders
947
+ * only; TIF (incl. GTX) and reduceOnly stay on the order. `size` is the NEW UNFILLED
948
+ * quantity (§13.10 #10); the venue's `quantity` is the order total, so
949
+ * quantity = size + executedQty (read just before). A fill landing between the read
950
+ * and the PUT can only shrink the remainder, never over-size it. A venue rejection
951
+ * leaves the original order untouched (docs).
952
+ */
438
953
  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);
954
+ const r = this._resolveSigner();
955
+ const apiSymbol = this._toApi(symbol);
956
+ const rawOrders = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
957
+ const existing = (rawOrders ?? []).find(o => String(o.orderId ?? "") === orderId);
958
+ if (!existing) {
959
+ // Rule #2: never guess the order — fail honestly if it is no longer open.
960
+ throw new PerpError("ORDER_NOT_FOUND", `Aster order ${orderId} is not among the open orders; refusing to edit (cannot guess its side).`, { exchange: "aster" });
961
+ }
962
+ const type = String(existing.type ?? "").toUpperCase();
963
+ if (type !== "LIMIT") {
964
+ // Venue modify supports LIMIT only; a STOP/TP must be cancelled and re-placed explicitly.
965
+ 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" });
966
+ }
967
+ const executed = plainQty(existing.executedQty);
968
+ if (executed === null) {
969
+ throw new PerpError("EXCHANGE_ERROR", `Aster order ${orderId}: openOrders row has no readable executedQty (${String(existing.executedQty)})`, { exchange: "aster" });
970
+ }
971
+ requirePositive(size, "size");
972
+ const px = requireOnTick(price, (await this._symbolPriceFilters(apiSymbol)).tickSize);
973
+ return this._perpWrite("PUT /fapi/v3/order", () => this._signedPostTo(this._baseUrl, "/fapi/v3/order", {
974
+ symbol: apiSymbol,
975
+ orderId,
976
+ side: String(existing.side ?? "").toUpperCase(),
977
+ quantity: subDecimal(size, `-${executed}`),
978
+ price: px,
979
+ }, r, "PUT"), true);
445
980
  }
446
981
  async cancelOrder(symbol, orderId) {
982
+ return this._cancelOne(symbol, { orderId }, orderId);
983
+ }
984
+ /**
985
+ * Cancel one order by client order id — V3 futures "Cancel Order" (DELETE /fapi/v3/order):
986
+ * "Either `orderId` or `origClientOrderId` must be sent."
987
+ */
988
+ async cancelOrderByClientId(symbol, clientOrderId) {
989
+ const id = requireAsterClientOrderId(clientOrderId);
990
+ return this._cancelOne(symbol, { origClientOrderId: id }, `c:${id}`);
991
+ }
992
+ /**
993
+ * One order's row — V3 futures "Query Order" (GET /fapi/v3/order, symbol + orderId or
994
+ * origClientOrderId). Docs: CANCELED / EXPIRED orders with no fill older than 7 days "will not
995
+ * be found". -2013 NO_SUCH_ORDER → ORDER_NOT_FOUND; any other failure throws as is.
996
+ */
997
+ async queryOrderRow(symbol, ref) {
998
+ const key = ref.kind === "orderId" ? { orderId: ref.value } : { origClientOrderId: requireAsterClientOrderId(ref.value) };
999
+ const label = ref.kind === "orderId" ? ref.value : `c:${ref.value}`;
1000
+ let row;
1001
+ try {
1002
+ row = await this._signedGetEip712("/fapi/v3/order", { symbol: this._toApi(symbol), ...key }, this._resolveSigner());
1003
+ }
1004
+ catch (e) {
1005
+ if (e instanceof PerpError && e.structured.details?.venueCode === -2013) {
1006
+ throw new PerpError("ORDER_NOT_FOUND", `Aster has no order ${label} on ${symbol.toUpperCase()} (-2013 NO_SUCH_ORDER)`, {
1007
+ exchange: "aster", venueCode: -2013, remediation: "List orders: perp account orders -e aster / perp history orders -e aster",
1008
+ });
1009
+ }
1010
+ throw e;
1011
+ }
1012
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
1013
+ throw new PerpError("EXCHANGE_ERROR", `Aster GET /fapi/v3/order ${label}: unexpected response shape`, { exchange: "aster" });
1014
+ }
1015
+ return row;
1016
+ }
1017
+ async _cancelOne(symbol, key, orderId) {
447
1018
  const r = this._resolveSigner();
448
- return this._signedDeleteEip712("/fapi/v3/order", {
449
- symbol: this._toApi(symbol),
450
- orderId,
451
- }, r);
1019
+ try {
1020
+ return await this._signedDeleteEip712("/fapi/v3/order", {
1021
+ symbol: this._toApi(symbol),
1022
+ ...key,
1023
+ }, r);
1024
+ }
1025
+ catch (e) {
1026
+ // Docs (futures V3 error codes): "-2011 CANCEL_REJECTED" — the venue refused the cancel; the
1027
+ // order is not open (already filled / cancelled, or unknown). Live mainnet 2026-10-05, cancel of
1028
+ // a filled order: 400 {"code":-2011,"msg":"Unknown order sent."}. Definite: nothing was cancelled.
1029
+ if (e instanceof PerpError && e.structured.details?.venueCode === -2011) {
1030
+ throw new PerpError("ORDER_REJECTED", `Aster refused to cancel order ${orderId} on ${symbol} (-2011 CANCEL_REJECTED): it is not an open order`, {
1031
+ exchange: "aster", orderId, venueCode: -2011,
1032
+ remediation: `Check its final state: perp trade status ${orderId} --symbol ${symbol} -e aster`,
1033
+ });
1034
+ }
1035
+ throw e;
1036
+ }
452
1037
  }
453
1038
  async cancelAllOrders(symbol) {
454
1039
  const r = this._resolveSigner();
@@ -473,39 +1058,229 @@ export class AsterAdapter {
473
1058
  marginType: marginMode === "cross" ? "CROSSED" : "ISOLATED",
474
1059
  }, r);
475
1060
  }
476
- catch { /* may fail if already set */ }
1061
+ catch (e) {
1062
+ // Rule #2: only -4046 NO_NEED_TO_CHANGE_MARGIN_TYPE ("No need to change
1063
+ // margin type") is a benign no-op. Every other rejection — notably a
1064
+ // refusal because the symbol has an open position or open orders —
1065
+ // means the account is STILL on the previous margin mode. Swallowing
1066
+ // those left the user believing they had switched to isolated while the
1067
+ // position stayed cross, and the leverage change below then applied to
1068
+ // a cross position.
1069
+ const msg = e instanceof Error ? e.message : String(e);
1070
+ const benign = msg.includes("-4046") || /no need to change margin type/i.test(msg);
1071
+ if (benign) { /* already on the requested margin type */ }
1072
+ else if (e instanceof PerpError && typeof e.structured.details?.venueCode === "number" && [-4047, -4048, -4050, -4051].includes(e.structured.details.venueCode)) {
1073
+ // The venue refused the margin-type change; the leverage request below is NOT sent, so
1074
+ // nothing changed. Docs ("Error Codes"): -4047 "Margin type cannot be changed if there
1075
+ // exists open orders." · -4048 "Margin type cannot be changed if there exists position."
1076
+ // · -4050 "Cross balance insufficient." · -4051 "Isolated balance insufficient." The
1077
+ // "Change Margin Type" section states no balance or Multi-Assets-mode condition, so
1078
+ // for -4050 / -4051 the venue's own text is reported as is (live mainnet 2026-10-05:
1079
+ // ISOLATED on a flat SOL → 400 -4050) with the two account facts to check.
1080
+ const code = e.structured.details.venueCode;
1081
+ const why = {
1082
+ [-4047]: ["THERE_EXISTS_OPEN_ORDERS", `Cancel the ${symbol} open orders first: perp trade cancel ${symbol} -e aster`],
1083
+ [-4048]: ["THERE_EXISTS_QUANTITY", `Close the ${symbol} position first: perp trade close ${symbol} -e aster`],
1084
+ [-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`],
1085
+ [-4051]: ["ISOLATED_BALANCE_INSUFFICIENT", `The venue docs do not state the condition. Check the account's margin-asset balance: perp account balance -e aster`],
1086
+ };
1087
+ throw new PerpError("ACTION_REJECTED", `Aster refused to switch ${symbol} to ${marginMode} margin (${code} ${why[code][0]}: ${msg}) — margin mode and leverage are unchanged`, {
1088
+ exchange: "aster", venueCode: code, httpStatus: e.structured.details.httpStatus, remediation: why[code][1],
1089
+ });
1090
+ }
1091
+ else
1092
+ throw e;
1093
+ }
477
1094
  }
478
1095
  return this._signedPostEip712("/fapi/v3/leverage", {
479
1096
  symbol: this._toApi(symbol),
480
1097
  leverage: String(leverage),
481
1098
  }, r);
482
1099
  }
1100
+ /**
1101
+ * Trigger order. Type follows the venue trigger rules (V3 docs, "Condition orders"):
1102
+ * STOP*: BUY fires at price >= stopPrice, SELL at <= ; TAKE_PROFIT*: the reverse. So the
1103
+ * type is chosen from the trigger vs the current trigger-source price and the order side
1104
+ * (the side closing a long is SELL): BUY above / SELL below → STOP, otherwise
1105
+ * TAKE_PROFIT. (Always STOP_MARKET made every TP fail with -2021.)
1106
+ * Trigger source: workingType MARK_PRICE compared with the premiumIndex mark (default),
1107
+ * or CONTRACT_PRICE with the last price when triggerType is "last".
1108
+ * size "0" = whole position → closePosition=true (STOP_MARKET / TAKE_PROFIT_MARKET only;
1109
+ * the venue forbids quantity and reduceOnly with it).
1110
+ * `opts.kind` (TP/SL legs): sl → STOP(_MARKET), tp → TAKE_PROFIT(_MARKET) as given, with
1111
+ * no price read; a trigger already crossed is rejected by the venue (-2021).
1112
+ */
483
1113
  async stopOrder(symbol, side, size, triggerPrice, opts) {
484
1114
  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";
1115
+ const apiSymbol = this._toApi(symbol);
1116
+ const what = `stop ${side} ${size} ${symbol} @ ${triggerPrice}`;
1117
+ let params;
1118
+ try {
1119
+ if (opts?.triggerType === "mid") {
1120
+ throw unsupported("Aster", "triggerType", "stop orders trigger on MARK_PRICE or CONTRACT_PRICE only", "Use triggerType mark or last");
1121
+ }
1122
+ const f = await this._symbolPriceFilters(apiSymbol);
1123
+ const stopPrice = requireOnTick(triggerPrice, f.tickSize);
1124
+ const useLast = opts?.triggerType === "last";
1125
+ let isStop;
1126
+ if (opts?.kind !== undefined) {
1127
+ isStop = opts.kind === "sl";
1128
+ }
1129
+ else {
1130
+ const ref = useLast ? await this._lastPrice(apiSymbol) : await this.referencePrice(symbol);
1131
+ const t = Number(stopPrice), p = Number(ref);
1132
+ if (t === p) {
1133
+ throw new PerpError("INVALID_PARAMS", `Aster ${apiSymbol}: trigger ${stopPrice} equals the current ${useLast ? "last" : "mark"} price — it would trigger immediately`, { exchange: "aster" });
1134
+ }
1135
+ isStop = side === "buy" ? t > p : t < p;
1136
+ }
1137
+ const closeAll = Number(size) === 0;
1138
+ if (!closeAll)
1139
+ requirePositive(size, "size");
1140
+ params = {
1141
+ symbol: apiSymbol,
1142
+ side: side.toUpperCase(),
1143
+ stopPrice,
1144
+ workingType: useLast ? "CONTRACT_PRICE" : "MARK_PRICE",
1145
+ };
1146
+ if (opts?.limitPrice) {
1147
+ if (closeAll) {
1148
+ 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" });
1149
+ }
1150
+ params.type = isStop ? "STOP" : "TAKE_PROFIT";
1151
+ params.price = requireOnTick(opts.limitPrice, f.tickSize);
1152
+ params.timeInForce = "GTC";
1153
+ }
1154
+ else {
1155
+ params.type = isStop ? "STOP_MARKET" : "TAKE_PROFIT_MARKET";
1156
+ }
1157
+ if (closeAll) {
1158
+ params.closePosition = "true";
1159
+ }
1160
+ else {
1161
+ params.quantity = size;
1162
+ if (opts?.reduceOnly)
1163
+ params.reduceOnly = "true";
1164
+ }
1165
+ Object.assign(params, await this._builderParams());
1166
+ }
1167
+ catch (e) {
1168
+ throw orderNotSent("aster", what, e);
495
1169
  }
496
- if (opts?.reduceOnly)
497
- params.reduceOnly = "true";
498
1170
  return this._signedPostEip712("/fapi/v3/order", params, r);
499
1171
  }
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",
1172
+ /** Last traded price (GET /fapi/v3/ticker/price) — the CONTRACT_PRICE trigger source. */
1173
+ async _lastPrice(apiSymbol) {
1174
+ let price;
1175
+ try {
1176
+ price = (await this._publicGet("/fapi/v3/ticker/price", { symbol: apiSymbol }))?.price;
1177
+ }
1178
+ catch (e) {
1179
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} last price unavailable: ${e instanceof Error ? e.message : String(e)}`, { exchange: "aster" });
1180
+ }
1181
+ const str = typeof price === "number" ? String(price) : price;
1182
+ if (typeof str !== "string" || !(Number(str) > 0) || !Number.isFinite(Number(str))) {
1183
+ throw new PerpError("PRICE_STALE", `Aster ${apiSymbol} ticker/price returned no usable price (${String(price)})`, { exchange: "aster" });
1184
+ }
1185
+ return str;
1186
+ }
1187
+ // ── Withdraw (mainnet only): POST /fapi/v3/aster/user-withdraw ──
1188
+ /** Asset withdrawn by `withdraw`: `requested` (must be in capabilities.withdrawAssets), else withdrawAssets[0]. */
1189
+ _withdrawAsset(requested) {
1190
+ const assets = asterVenue.capabilities?.withdrawAssets;
1191
+ if (assets === null || assets === undefined || assets.length === 0)
1192
+ throw new Error("aster venue definition declares no withdrawAssets");
1193
+ if (requested === undefined)
1194
+ return assets[0];
1195
+ const a = requested.toUpperCase();
1196
+ if (!assets.includes(a)) {
1197
+ throw new PerpError("INVALID_PARAMS", `Aster withdraws ${assets.join(" / ")} only (got "${requested}")`, { exchange: "aster", remediation: `Use --asset ${assets.join(" or --asset ")}` });
1198
+ }
1199
+ return a;
1200
+ }
1201
+ /**
1202
+ * Withdrawable perp balance + fee for the withdraw asset on `chain`
1203
+ * (POST /fapi/v3/aster/user-withdraw-info, signed by the API wallet). Read-only.
1204
+ */
1205
+ async withdrawLimits(chain, asset) {
1206
+ requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
1207
+ const raw = await this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/user-withdraw-info", {});
1208
+ return parseAsterWithdrawLimits(raw, this._withdrawAsset(asset), ASTER_FUNDS_CHAINS[chain].chainId);
1209
+ }
1210
+ /** Deposit / withdraw history (POST /fapi/v3/aster/deposit-withdraw-history, signed by the API wallet). Read-only. */
1211
+ async depositWithdrawHistory() {
1212
+ return this.signedRequest(this._baseUrl, "POST", "/fapi/v3/aster/deposit-withdraw-history", {});
1213
+ }
1214
+ /**
1215
+ * Withdraw the venue collateral (USDT) from the perp account to the account owner's OWN address
1216
+ * on BNB Chain (default) or Arbitrum. Two signatures (asterdex/api-docs
1217
+ * demo/aster-deposit-withdrawal.md, API-demo futures-v3-demo/58_withdrawEvm.js):
1218
+ * 1. `userSignature` — the master wallet's EIP-712 "Aster" Action over receiver / chain / token /
1219
+ * amount / fee / nonce, recovered locally and compared to the account owner before sending;
1220
+ * 2. the V3 envelope signed by the API wallet, which must have canWithdraw (and an IP whitelist).
1221
+ * The fee and the maximum come from user-withdraw-info; the identical amount / fee strings go
1222
+ * into the signature and the request. Once the request left, an unreadable result is
1223
+ * ORDER_PENDING (never resubmit).
1224
+ */
1225
+ async withdraw(amount, destination, opts) {
1226
+ requireAsterFundsNetwork(this._testnet ? "testnet" : "mainnet", "withdraw");
1227
+ if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
1228
+ throw new PerpError("INVALID_PARAMS", "Aster withdraw takes no assetId/routeType (use --asset USDT|USDC and --chain bsc|arbitrum)");
1229
+ }
1230
+ const chain = opts?.chain === undefined ? "bsc" : opts.chain;
1231
+ const pins = ASTER_FUNDS_CHAINS[chain];
1232
+ const asset = this._withdrawAsset(opts?.asset);
1233
+ const r = this._resolveSigner();
1234
+ if (this._agentMeta === undefined || this._agentMeta.permissions.canWithdraw !== true) {
1235
+ throw new PerpError("POLICY_DENIED", "The active Aster API wallet was approved without the withdraw permission", {
1236
+ exchange: "aster",
1237
+ 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",
1238
+ });
1239
+ }
1240
+ const user = r.userAddress;
1241
+ if (!/^0x[0-9a-fA-F]{40}$/.test(destination) || destination.toLowerCase() !== user.toLowerCase()) {
1242
+ throw new PerpError("INVALID_PARAMS", `Aster only withdraws to the account's own address ${user} (got "${destination}")`, {
1243
+ exchange: "aster", remediation: `Omit --to (defaults to ${user})`,
1244
+ });
1245
+ }
1246
+ const master = this._masterSigner ?? this._pkSigner;
1247
+ if (master === undefined) {
1248
+ throw new PerpError("NO_SIGNER_AVAILABLE", "Aster withdraw needs the master wallet to sign the withdraw message; only the API wallet is loaded", {
1249
+ exchange: "aster", remediation: "Run with the master wallet: perp --ows <wallet> funds withdraw aster <amount> — or withdraw in the Aster web app",
1250
+ });
1251
+ }
1252
+ if (master.getAddress().toLowerCase() !== user.toLowerCase()) {
1253
+ throw new PerpError("INVALID_PARAMS", `The loaded master wallet ${master.getAddress()} is not the Aster account owner ${user}`, {
1254
+ exchange: "aster", remediation: "Select the wallet that owns the Aster account (--ows <wallet>)",
505
1255
  });
506
1256
  }
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.");
1257
+ const limits = await this.withdrawLimits(chain, asset);
1258
+ assertAsterWithdrawAmount(amount, limits, asset);
1259
+ const userNonce = (BigInt(Date.now()) * 1000n).toString();
1260
+ const typed = buildWithdrawTypedData({
1261
+ chainId: pins.chainId, chainName: pins.withdrawChainName, receiver: user, asset, amount, fee: limits.fee, userNonce,
1262
+ });
1263
+ let userSignature;
1264
+ try {
1265
+ userSignature = await master.signTypedData(typed.domain, typed.types, typed.message);
1266
+ }
1267
+ catch (e) {
1268
+ throw orderNotSent("aster", "withdraw", e);
1269
+ }
1270
+ assertAsterWithdrawSignature(typed, userSignature, user);
1271
+ const path = "/fapi/v3/aster/user-withdraw";
1272
+ const raw = await this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, {
1273
+ chainId: String(pins.chainId), asset, amount, fee: limits.fee, receiver: user, userNonce, userSignature,
1274
+ }, r));
1275
+ const res = (raw !== null && typeof raw === "object" ? raw : {});
1276
+ if (typeof res.withdrawId !== "string" && typeof res.withdrawId !== "number") {
1277
+ // 2xx without the documented withdrawId: the request was accepted on the wire — outcome unknown.
1278
+ 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");
1279
+ }
1280
+ return {
1281
+ withdrawId: String(res.withdrawId), hash: typeof res.hash === "string" ? res.hash : "",
1282
+ chain, chainId: pins.chainId, asset, amount, fee: limits.fee, receiver: user,
1283
+ };
509
1284
  }
510
1285
  // ── Private: signer resolution ────────────────────────────────────────────
511
1286
  /**
@@ -517,23 +1292,20 @@ export class AsterAdapter {
517
1292
  _resolveSigner() {
518
1293
  // Tier 1: agent (when registered, not expired, --no-agent NOT set)
519
1294
  if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
1295
+ // An expired agent record is an explicit error — never a fall-through to the master tier
1296
+ // (as on Hyperliquid / Pacifica; the master cannot sign Aster requests anyway).
520
1297
  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
- };
1298
+ throw new PerpError("AGENT_EXPIRED", `Aster agent wallet ${this._agentMeta.agentEvmAddress} expired at ${this._agentMeta.expiresAt}`, {
1299
+ exchange: "aster",
1300
+ remediation: "perp wallet agent approve aster --rotate",
1301
+ });
536
1302
  }
1303
+ return {
1304
+ tier: "agent",
1305
+ signer: this._agentSigner,
1306
+ signerAddress: this._agentMeta.agentEvmAddress,
1307
+ userAddress: this._agentMeta.userEvmAddress,
1308
+ };
537
1309
  }
538
1310
  // Tier 2/3: master self-signing is not supported by the venue. Per Aster
539
1311
  // V3 spec the `signer` field MUST be a registered API_WALLET (agent);
@@ -555,9 +1327,9 @@ export class AsterAdapter {
555
1327
  });
556
1328
  }
557
1329
  // ── Private: nonce ────────────────────────────────────────────────────────
558
- _nextNonce() {
559
- const ms = Date.now() * 1000 + (++this._nonceCounter % 1000);
560
- return String(ms);
1330
+ /** Monotonic µs nonce per signer, persisted across processes (aster-nonce.ts). */
1331
+ _nextNonce(signer) {
1332
+ return nextAsterNonce(signer);
561
1333
  }
562
1334
  // ── Private: EIP-712 signed HTTP helpers ─────────────────────────────────
563
1335
  /**
@@ -586,7 +1358,7 @@ export class AsterAdapter {
586
1358
  // Insertion order (matches Python reference: nonce, user, signer last).
587
1359
  const fullParams = {
588
1360
  ...params,
589
- nonce: this._nextNonce(),
1361
+ nonce: this._nextNonce(resolved.signerAddress),
590
1362
  user: resolved.userAddress,
591
1363
  signer: resolved.signerAddress,
592
1364
  };
@@ -603,14 +1375,54 @@ export class AsterAdapter {
603
1375
  * Sign and POST to a path using EIP-712 Domain B.
604
1376
  */
605
1377
  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);
1378
+ return this._perpWrite(`POST ${path}`, () => this._signedPostTo(this._baseUrl, path, params, resolved), path === "/fapi/v3/order");
1379
+ }
1380
+ /**
1381
+ * A perp account WRITE (order / modify / cancel / margin type / leverage). Once the request is
1382
+ * on the wire, a transport failure, an HTTP 5xx or an unreadable 2xx body leaves the outcome
1383
+ * unknown → ORDER_PENDING (never the retryable EXCHANGE_ERROR / EXCHANGE_UNREACHABLE); no
1384
+ * connection at all → not sent. Venue refusals (4xx, error envelopes) pass through unchanged.
1385
+ */
1386
+ async _perpWrite(what, send, orderPlacement = false) {
1387
+ try {
1388
+ return await send();
1389
+ }
1390
+ catch (e) {
1391
+ if (e instanceof PerpError) {
1392
+ const d = e.structured.details;
1393
+ const http = typeof d?.httpStatus === "number" ? d.httpStatus : null;
1394
+ if (d?.responseUnreadable === true || (http !== null && http >= 500))
1395
+ throw orderOutcomeUnknown("aster", what, e);
1396
+ // Order placement / modify: a documented 4xx business code is a definite refusal (asterOrderRejection).
1397
+ throw orderPlacement ? asterOrderRejection(e) : e;
1398
+ }
1399
+ throw isTransportFailure(e) ? sendFailure("aster", what, e) : e;
1400
+ }
1401
+ }
1402
+ /** Sign and POST (or PUT) to `host + path` (perp `fapi` or spot `sapi` — same Domain B signing). */
1403
+ async _signedPostTo(host, path, params, resolved, method = "POST") {
1404
+ let qs;
1405
+ try {
1406
+ qs = await this._buildSignedQueryString(params, resolved);
1407
+ }
1408
+ catch (e) {
1409
+ // Signing failed before the request was sent: definitely not placed (never ORDER_PENDING).
1410
+ throw orderNotSent("aster", `${method} ${path}`, e);
1411
+ }
1412
+ const url = `${host}${path}?${qs}`;
1413
+ // Any order write (place / modify) makes the open-orders snapshot stale — before and after the request.
1414
+ this.invalidateOpenOrdersCache();
1415
+ try {
1416
+ const res = await fetch(url, {
1417
+ method,
1418
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1419
+ body: "",
1420
+ });
1421
+ return await this._handleAsterResponse(res, method, path);
1422
+ }
1423
+ finally {
1424
+ this.invalidateOpenOrdersCache();
1425
+ }
614
1426
  }
615
1427
  /**
616
1428
  * Sign and GET from a path using EIP-712 Domain B.
@@ -625,27 +1437,40 @@ export class AsterAdapter {
625
1437
  * one would itself be rejected. Non-429 venue errors throw immediately.
626
1438
  */
627
1439
  async _signedGetEip712(path, params, resolved) {
628
- return this._signedRequestWithRetry("GET", path, params, resolved);
1440
+ return this._signedRequestWithRetry(this._baseUrl, "GET", path, params, resolved);
629
1441
  }
630
1442
  /**
631
1443
  * Sign and DELETE from a path using EIP-712 Domain B.
632
1444
  * Same retry behavior as _signedGetEip712.
633
1445
  */
634
1446
  async _signedDeleteEip712(path, params, resolved) {
635
- return this._signedRequestWithRetry("DELETE", path, params, resolved);
1447
+ return this._perpWrite(`DELETE ${path}`, () => this._signedRequestWithRetry(this._baseUrl, "DELETE", path, params, resolved));
636
1448
  }
637
1449
  /** Shared GET/DELETE retry loop with fresh-nonce per attempt.
638
1450
  *
639
1451
  * Total 4 attempts: initial + 3 retries with backoffs 2s, 4s, 8s.
640
1452
  * Codex v0.12.12 final QA #3: previously MAX_ATTEMPTS = 3 truncated the
641
1453
  * documented curve so the 8s backoff was never reachable in practice. */
642
- async _signedRequestWithRetry(method, path, params, resolved) {
1454
+ async _signedRequestWithRetry(host, method, path, params, resolved) {
1455
+ if (method === "DELETE") {
1456
+ // A cancel makes the open-orders snapshot stale — before and after the request.
1457
+ this.invalidateOpenOrdersCache();
1458
+ try {
1459
+ return await this._signedRequestLoop(host, method, path, params, resolved);
1460
+ }
1461
+ finally {
1462
+ this.invalidateOpenOrdersCache();
1463
+ }
1464
+ }
1465
+ return this._signedRequestLoop(host, method, path, params, resolved);
1466
+ }
1467
+ async _signedRequestLoop(host, method, path, params, resolved) {
643
1468
  const MAX_ATTEMPTS = 4;
644
1469
  let lastErr;
645
1470
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
646
1471
  // Rebuild signed query string with fresh nonce on each attempt.
647
1472
  const qs = await this._buildSignedQueryString(params, resolved);
648
- const url = `${this._baseUrl}${path}?${qs}`;
1473
+ const url = `${host}${path}?${qs}`;
649
1474
  const res = await fetch(url, method === "DELETE" ? { method: "DELETE" } : undefined);
650
1475
  // 429 → backoff + retry. All other paths (including non-429 venue
651
1476
  // errors wrapped in HTTP 200 with code≠0) throw immediately via
@@ -685,32 +1510,77 @@ export class AsterAdapter {
685
1510
  */
686
1511
  async _handleAsterResponse(res, method, path) {
687
1512
  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`);
1513
+ // HTTP 429 = refused before processing: the request was definitely NOT executed
1514
+ // (details.sent === false → isDefiniteOrderFailure), and RATE_LIMITED stays retryable —
1515
+ // after Retry-After (carried as retryAfterMs when the venue sends it).
1516
+ const retryAfterSec = Number(res.headers.get("Retry-After"));
1517
+ throw new PerpError("RATE_LIMITED", `Aster ${method} ${path} rate limited (HTTP 429); nothing was executed`, {
1518
+ exchange: "aster", httpStatus: 429, sent: false,
1519
+ ...(Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? { retryAfterMs: retryAfterSec * 1000 } : {}),
1520
+ remediation: "Nothing was sent to the book. Wait and retry, or reduce request frequency",
1521
+ });
691
1522
  }
692
1523
  if (!res.ok) {
693
- const text = await res.text().catch(() => "");
1524
+ const text = await res.text();
1525
+ // Aster returns non-2xx with a JSON {code, msg} body for venue errors (e.g. HTTP 401
1526
+ // -2015 / -1002). Carry the venue code so callers (spot permission mapping) can key on it.
1527
+ let venueCode = null;
1528
+ try {
1529
+ const j = JSON.parse(text);
1530
+ if (typeof j.code === "string" || typeof j.code === "number")
1531
+ venueCode = j.code;
1532
+ }
1533
+ catch {
1534
+ // Non-JSON body (HTML error page): no venue code — the HTTP status + text are reported.
1535
+ }
694
1536
  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 });
1537
+ if (res.status >= 500) {
1538
+ // A 5xx can arrive after the venue accepted the request: never a definite code
1539
+ // (substring classification could otherwise yield e.g. SIGNATURE_FAILED / INSUFFICIENT_BALANCE).
1540
+ throw new PerpError("EXCHANGE_ERROR", s.message, {
1541
+ exchange: "aster",
1542
+ httpStatus: res.status,
1543
+ ...(venueCode !== null ? { venueCode } : {}),
1544
+ });
1545
+ }
1546
+ throw new PerpError(s.code, s.message, {
1547
+ exchange: s.exchange,
1548
+ httpStatus: res.status,
1549
+ ...(venueCode !== null ? { venueCode } : {}),
1550
+ ...(s.remediation ? { remediation: s.remediation } : {}),
1551
+ });
696
1552
  }
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).
1553
+ // Parse JSON, then validate venue error envelope. Aster success returns an array
1554
+ // (e.g., GET /fapi/v3/openOrders, GET /fapi/v3/userTrades) or an object whose `code`
1555
+ // is absent or one of the documented success values (asterdex/api-docs):
1556
+ // 200 — futures/spot V3, e.g. Change Margin Type: {"code": 200, "msg": "success"}
1557
+ // "200" — futures V3 "Cancel All Open Orders (TRADE)":
1558
+ // {"code": "200", "msg": "The operation of cancel all open order is done."}
1559
+ // "000000" — bapi envelope (bapi/aster-bapi-en.md): {"code": "000000", "message": null, ...}
1560
+ // Anything else (incl. 0, which the docs only list as a WebSocket error code) is an error.
700
1561
  const text = await res.text();
701
1562
  let parsed;
702
1563
  try {
703
1564
  parsed = JSON.parse(text);
704
1565
  }
705
1566
  catch {
706
- throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster" });
1567
+ // httpStatus + responseUnreadable: the request WAS accepted (2xx) — order callers map this to "pending".
1568
+ throw new PerpError("EXCHANGE_ERROR", `${method} ${path}: malformed JSON: ${text.slice(0, 200)}`, { exchange: "aster", httpStatus: res.status, responseUnreadable: true });
707
1569
  }
708
1570
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
709
1571
  const json = parsed;
710
- if (json.code !== undefined && json.code !== "000000" && json.code !== 200 && json.code !== 0) {
1572
+ if (json.code !== undefined && json.code !== 200 && json.code !== "200" && json.code !== "000000") {
711
1573
  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 });
1574
+ // Classify on the venue code AND the message: Aster's terse messages
1575
+ // ("This function can only be used after deposit.") are the only signal
1576
+ // for some codes, but the numeric code is the stable key — feed both so
1577
+ // a message reword upstream does not silently drop the mapping.
1578
+ const s = classifyError(new Error(`[${json.code}] ${rawMsg}`), "aster");
1579
+ throw new PerpError(s.code, rawMsg, {
1580
+ exchange: s.exchange,
1581
+ venueCode: json.code,
1582
+ ...(s.remediation ? { remediation: s.remediation } : {}),
1583
+ });
714
1584
  }
715
1585
  }
716
1586
  return parsed;