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