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