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