From 7acb3c3b827409f435483eb0b82c48152ecd9179 Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 29 Jul 2026 20:27:40 +0800 Subject: [PATCH] fix(exchanges): restore type-check safety net and repair dead balance guards tsconfig compiled docs/ (vendored ccxt samples) and @types/react was missing, so 269 tsc errors buried the real ones. Scoping the project and adding the missing type packages left 42 genuine errors in src/, which exposed two bugs: - lighter: assertSpotBalance's buy branch and createOrder's spot-sell guard compared the {available, wallet} object against a number, so both guards were dead. Introduce SpotAssetBalance with a precomputed 'effective' field (Extract Class) and route all three call sites through it. - offset-maker: the below-min-sell branch logged 'skip sell' but pushed the SELL order anyway, and pushed a possibly-null price. Both branches now match their working sibling. Also: widen LighterOrder's is_ask/reduce_only to BooleanFlag (the wire format flags.ts already parses), extract sellableBase (Extract Function, 3 copies), delete two scripts importing a module that does not exist, add a typecheck script. tsc --noEmit: 269 -> 0 errors; 218 tests still pass. --- bun.lock | 8 +++ package.json | 3 + scripts/derive-public.ts | 16 ------ scripts/lighter-public-key.ts | 21 ------- src/exchanges/lighter/gateway.ts | 75 ++++++++++++++++--------- src/exchanges/lighter/mappers.ts | 2 +- src/exchanges/lighter/types.ts | 13 ++++- src/exchanges/paradex/gateway.ts | 2 +- src/strategy/offset-maker-engine.ts | 50 +++++++++++------ tests/lighter/signer.test.ts | 2 +- tests/maker-points-defense-rest.test.ts | 5 +- tests/order-coordinator.test.ts | 2 +- tsconfig.json | 7 ++- 13 files changed, 116 insertions(+), 90 deletions(-) delete mode 100644 scripts/derive-public.ts delete mode 100644 scripts/lighter-public-key.ts diff --git a/bun.lock b/bun.lock index 209d2d0..4331a1e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,8 @@ }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/react": "^19", + "@types/ws": "^8.18.1", "oxlint": "^1.54.0", "vitest": "^4.0.18", }, @@ -208,6 +210,10 @@ "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -260,6 +266,8 @@ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], diff --git a/package.json b/package.json index 96b9aae..f7b5e35 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start": "bun run index.ts", "lint": "oxlint", "lint:fix": "oxlint --fix", + "typecheck": "tsc --noEmit", "test": "bun x vitest run", "test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts", "test:watch": "bun x vitest", @@ -29,6 +30,8 @@ }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/react": "^19", + "@types/ws": "^8.18.1", "oxlint": "^1.54.0", "vitest": "^4.0.18" }, diff --git a/scripts/derive-public.ts b/scripts/derive-public.ts deleted file mode 100644 index 54b62a5..0000000 --- a/scripts/derive-public.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr"; -import { bytesToHex } from "../src/exchanges/lighter/bytes"; - -function derive(keyHex: string): string { - const normalized = keyHex.startsWith("0x") ? keyHex.slice(2) : keyHex; - const key = LighterPrivateKey.fromHex(normalized); - return bytesToHex(key.publicKey().toBytes()); -} - -const input = process.argv[2]; -if (!input) { - console.error("usage: bun run scripts/derive-public.ts "); - process.exit(1); -} - -console.log(derive(input)); diff --git a/scripts/lighter-public-key.ts b/scripts/lighter-public-key.ts deleted file mode 100644 index 7f56d08..0000000 --- a/scripts/lighter-public-key.ts +++ /dev/null @@ -1,21 +0,0 @@ -import "dotenv/config"; -import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr"; -import { bytesToHex } from "../src/exchanges/lighter/bytes"; - -function main(): void { - const raw = process.env.LIGHTER_API_PRIVATE_KEY; - if (!raw) { - throw new Error("LIGHTER_API_PRIVATE_KEY env var is required"); - } - const normalized = raw.startsWith("0x") ? raw.slice(2) : raw; - const key = LighterPrivateKey.fromHex(normalized); - const publicKeyHex = bytesToHex(key.publicKey().toBytes()); - const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ?? "(not set)"; - - console.log(JSON.stringify({ - apiKeyIndex, - publicKeyHex, - }, null, 2)); -} - -main(); diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index c31d2c8..6324aea 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -122,6 +122,31 @@ interface Pollers { klines: Map>; } +/** + * Spot balance of a single asset. `effective` is what every balance guard compares + * against: an unknown or unparseable asset collapses to 0 so guards fail closed + * instead of waving an order through. + */ +interface SpotAssetBalance { + available: number | null; + wallet: number | null; + effective: number; +} + +function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance { + return { + available, + wallet, + effective: Math.max( + available != null && Number.isFinite(available) ? available : 0, + wallet != null && Number.isFinite(wallet) ? wallet : 0 + ), + }; +} + +/** Tolerance that absorbs float drift when comparing a balance against an order size. */ +const BALANCE_EPSILON = 1e-9; + const KLINE_DEFAULT_COUNT = 120; const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_KLINE_POLL_MS = 15000; @@ -917,7 +942,7 @@ export class LighterGateway { return 0; }); - return candidates[0]; + return candidates[0] ?? null; } private scheduleReconnect(): void { @@ -1756,8 +1781,9 @@ export class LighterGateway { (matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase(); list.push({ asset: assetSymbol, - walletBalance: asset.balance ?? "0", - availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0", + walletBalance: String(asset.balance ?? "0"), + availableBalance: + available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"), updateTime: now, assetId: Number.isFinite(assetId) ? assetId : undefined, }); @@ -1796,7 +1822,7 @@ export class LighterGateway { return (hasBase && hasQuote) || idMatch; }); if (!matches.length) return null; - return matches[0]; + return matches[0] ?? null; } async getPrecision(): Promise<{ @@ -1846,8 +1872,7 @@ export class LighterGateway { return qty; } - private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } { - if (!this.assets.size) return null; + private getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance { const normalizedSymbol = symbol ? symbol.toUpperCase() : null; for (const asset of this.assets.values()) { const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId; @@ -1857,29 +1882,23 @@ export class LighterGateway { const locked = parseNumber(asset.locked_balance ?? 0); if (balance == null) continue; const available = locked != null ? balance - locked : balance; - return { - available: Number.isFinite(available) ? available : null, - wallet: Number.isFinite(balance) ? balance : null, - }; + return makeSpotAssetBalance( + Number.isFinite(available) ? available : null, + Number.isFinite(balance) ? balance : null + ); } - return { available: null, wallet: null }; + return makeSpotAssetBalance(null, null); } private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void { const qty = Number(params.quantity); if (!Number.isFinite(qty) || qty <= 0) return; if (params.isAsk) { - const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol); - const availableBase = baseAmounts?.available ?? null; - const walletBase = baseAmounts?.wallet ?? null; - const effective = Math.max( - availableBase != null && Number.isFinite(availableBase) ? availableBase : 0, - walletBase != null && Number.isFinite(walletBase) ? walletBase : 0 - ); - if (effective + 1e-9 < qty) { + const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol); + if (base.effective + BALANCE_EPSILON < qty) { throw new Error( - `Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${ - walletBase != null ? ` wallet ${walletBase}` : "" + `Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${ + base.wallet != null ? ` wallet ${base.wallet}` : "" }) for spot sell ${qty}` ); } @@ -1888,10 +1907,12 @@ export class LighterGateway { const price = Number(params.price); if (!Number.isFinite(price) || price <= 0) return; const requiredQuote = qty * price; - const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol); - if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) { + const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol); + if (quote.effective + BALANCE_EPSILON < requiredQuote) { throw new Error( - `Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}` + `Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${ + quote.available ?? 0 + }) for spot buy requiring ${requiredQuote}` ); } } @@ -1932,10 +1953,10 @@ export class LighterGateway { const isAsk = side === "SELL" ? 1 : 0; const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null); if (this.isSpotMarket() && isAsk === 1) { - const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol); - if (availableBase != null && availableBase + 1e-9 < enforcedQty) { + const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol); + if (base.effective + BALANCE_EPSILON < enforcedQty) { throw new Error( - `Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)` + `Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (min trade size may be higher than balance)` ); } } diff --git a/src/exchanges/lighter/mappers.ts b/src/exchanges/lighter/mappers.ts index 56625c0..eba65a6 100644 --- a/src/exchanges/lighter/mappers.ts +++ b/src/exchanges/lighter/mappers.ts @@ -105,7 +105,7 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): Order symbol, side, type: mapOrderType(order.type), - status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"), + status: normalizeOrderStatus(String(order.status ?? order.trigger_status ?? "UNKNOWN")), price: order.price ?? "0", origQty: order.initial_base_amount ?? "0", executedQty: computeExecutedQty(order), diff --git a/src/exchanges/lighter/types.ts b/src/exchanges/lighter/types.ts index 7b36909..aa1f1f2 100644 --- a/src/exchanges/lighter/types.ts +++ b/src/exchanges/lighter/types.ts @@ -10,6 +10,12 @@ export type LighterOrderType = type StrOrNum = string | number; +/** + * Lighter reports boolean fields inconsistently across endpoints — `true`, `1`, `"Yes"`. + * `normalizeBooleanFlag` in ./flags is what turns any of these into a real boolean. + */ +type BooleanFlag = boolean | string | number | bigint; + export interface LighterOrder { order_index: StrOrNum; client_order_index: StrOrNum; @@ -23,12 +29,12 @@ export interface LighterOrder { filled_quote_amount?: string; price: string; nonce?: number; - is_ask?: boolean; + is_ask?: BooleanFlag; side?: LighterSide; type?: LighterOrderType; time_in_force?: string; trigger_price?: string; - reduce_only?: boolean; + reduce_only?: BooleanFlag; status?: string | number; trigger_status?: string | number; trigger_time?: number; @@ -68,7 +74,8 @@ export interface LighterAccountDetails { } export interface LighterAccountAsset { - symbol: string; + /** Optional: the account endpoint omits it for assets it only knows by id. */ + symbol?: string; asset_id?: number; balance: string | number; locked_balance?: string | number; diff --git a/src/exchanges/paradex/gateway.ts b/src/exchanges/paradex/gateway.ts index 6de5c3c..b7e3af7 100644 --- a/src/exchanges/paradex/gateway.ts +++ b/src/exchanges/paradex/gateway.ts @@ -579,7 +579,7 @@ export class ParadexGateway { const isClosePosition = (extraParams as any).closePosition === true; if (isClosePosition) { const posAbs = this.getCurrentPositionAbs(); - if (Number.isFinite(posAbs) && posAbs > 0) { + if (posAbs != null && Number.isFinite(posAbs) && posAbs > 0) { amount = posAbs; } const current = Number(amount); diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index f6b7fc6..7cc2a9f 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -38,6 +38,13 @@ interface DesiredOrder { reduceOnly: boolean; } +/** Spot wallet view the quoting logic reads; `baseWallet` may lag `baseAvailable` after a fill. */ +export interface SpotBalances { + baseAvailable: number; + quoteAvailable: number; + baseWallet: number; +} + export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot { buyDepthSum10: number; sellDepthSum10: number; @@ -269,6 +276,7 @@ export class OffsetMakerEngine { (klines) => { if (!Array.isArray(klines) || !klines.length) return; const latest = klines[klines.length - 1]; + if (!latest) return; this.lastKline = latest; const open = Number(latest.open); const close = Number(latest.close); @@ -340,7 +348,9 @@ export class OffsetMakerEngine { const position = this.getPositionSnapshot(); const isSpotMarket = this.marketType === "spot"; const spotBalances = isSpotMarket ? this.getSpotBalances() : null; - const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances; + const balancesForSpot = isSpotMarket + ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0, baseWallet: 0 } + : spotBalances; this.updateLiveCandle(); const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum); if (handledImbalance) { @@ -392,9 +402,7 @@ export class OffsetMakerEngine { if (absPosition < EPS && isSpotMarket) { this.entryPricePendingLogged = false; - const baseAvail = balancesForSpot?.baseAvailable ?? 0; - const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; - const maxBase = Math.max(baseAvail, baseWallet); + const maxBase = this.sellableBase(balancesForSpot); if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) { // 无法卖出,跳过卖单,允许买单累计 this.lastSellPriceViable = false; @@ -429,9 +437,7 @@ export class OffsetMakerEngine { } } if (!skipSellSide && canEnter) { - const baseAvail = balancesForSpot?.baseAvailable ?? 0; - const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; - const maxBase = Math.max(baseAvail, baseWallet); + const maxBase = this.sellableBase(balancesForSpot); if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) { // 持仓低于最小卖单量,跳过卖单,等待累积 if (this.lastSellPriceViable) { @@ -468,20 +474,22 @@ export class OffsetMakerEngine { this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单"); this.lastBuyPriceViable = false; } - } else { + } else if (bidPrice != null) { desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); } } if (!skipSellSide && canEnter) { - if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) { - const baseAvail = balancesForSpot?.baseAvailable ?? 0; - const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; - if (Math.max(baseAvail, baseWallet) + EPS < minSell) { - this.lastSellPriceViable = false; - this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单"); - } + const belowMinSell = + isSpotMarket && + minSell > 0 && + this.minBaseAmount != null && + this.sellableBase(balancesForSpot) + EPS < minSell; + if (belowMinSell) { + this.lastSellPriceViable = false; + this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单"); + } else if (askPrice != null) { + desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); } - desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); } } else { const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; @@ -1012,6 +1020,16 @@ export class OffsetMakerEngine { return this.spotKlineUp === true || this.isLiveCandleUp(); } + /** + * Base asset the venue will actually let us sell. Wallet balance can exceed the + * available figure right after a fill settles, so the larger of the two wins. + */ + private sellableBase(balances: SpotBalances | null): number { + const available = balances?.baseAvailable ?? 0; + const wallet = balances?.baseWallet ?? available; + return Math.max(available, wallet); + } + private isLiveCandleUp(): boolean { if (!this.liveCandle) return false; return this.liveCandle.close > this.liveCandle.open; diff --git a/tests/lighter/signer.test.ts b/tests/lighter/signer.test.ts index 7b3b72c..209f153 100644 --- a/tests/lighter/signer.test.ts +++ b/tests/lighter/signer.test.ts @@ -50,6 +50,6 @@ describe("LighterSigner", () => { expect(signed.txHash.length).toBeGreaterThan(0); } expect(typeof signed.signature).toBe("string"); - expect(signed.signature.length).toBeGreaterThan(0); + expect(signed.signature?.length ?? 0).toBeGreaterThan(0); }); }); diff --git a/tests/maker-points-defense-rest.test.ts b/tests/maker-points-defense-rest.test.ts index 73f3ea6..36b37c7 100644 --- a/tests/maker-points-defense-rest.test.ts +++ b/tests/maker-points-defense-rest.test.ts @@ -60,10 +60,11 @@ describe("MakerPointsEngine defense-mode REST polling", () => { price: "100", origQty: "1", executedQty: "0", + stopPrice: "0", time: Date.now(), updateTime: Date.now(), - reduceOnly: "false", - closePosition: "false", + reduceOnly: false, + closePosition: false, }, ]; diff --git a/tests/order-coordinator.test.ts b/tests/order-coordinator.test.ts index e539e03..3c21f91 100644 --- a/tests/order-coordinator.test.ts +++ b/tests/order-coordinator.test.ts @@ -89,7 +89,7 @@ describe("order-coordinator", () => { timers, pending, "BUY", - 100, + "100", 1, log, false diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..6478941 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,10 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + + // docs/ holds vendored third-party samples (ccxt examples); they are reference + // material, not compilation units, and their errors mask real ones in src/. + "include": ["index.ts", "src/**/*", "tests/**/*", "scripts/**/*"], + "exclude": ["node_modules", "docs"] }