From 17a8f47a623c3b3cb3c8c70120c967db15faaac8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 25 May 2026 23:51:05 +0800 Subject: [PATCH 01/10] feat(perps): add e2e validation scripts for real HyperLiquid API testing --- .../perps-controller/e2e/account-state.ts | 53 +++++++++++ packages/perps-controller/e2e/error-codes.ts | 63 ++++++++++++ packages/perps-controller/e2e/helpers.ts | 66 +++++++++++++ packages/perps-controller/e2e/market-data.ts | 77 +++++++++++++++ .../perps-controller/e2e/order-validation.ts | 93 ++++++++++++++++++ .../e2e/subscription-stream.ts | 95 +++++++++++++++++++ 6 files changed, 447 insertions(+) create mode 100644 packages/perps-controller/e2e/account-state.ts create mode 100644 packages/perps-controller/e2e/error-codes.ts create mode 100644 packages/perps-controller/e2e/helpers.ts create mode 100644 packages/perps-controller/e2e/market-data.ts create mode 100644 packages/perps-controller/e2e/order-validation.ts create mode 100644 packages/perps-controller/e2e/subscription-stream.ts diff --git a/packages/perps-controller/e2e/account-state.ts b/packages/perps-controller/e2e/account-state.ts new file mode 100644 index 0000000000..4a2b72a2c2 --- /dev/null +++ b/packages/perps-controller/e2e/account-state.ts @@ -0,0 +1,53 @@ +/** + * E2E: Account State + * Queries clearinghouseState for a known public address on HyperLiquid mainnet. + * Validates the response shape matches what the controller expects. + */ +import { createClient, E2ERunner } from './helpers'; + +const KNOWN_PUBLIC_ADDRESS = '0x0000000000000000000000000000000000000001'; + +async function main(): Promise { + const runner = new E2ERunner('account-state'); + const client = createClient(); + + // 1. Fetch clearinghouseState for a public address (empty account is fine — validates shape) + console.error('[e2e] Fetching clearinghouseState...'); + const state = await client.clearinghouseState({ user: KNOWN_PUBLIC_ADDRESS }); + + runner.assertType('state is object', state, 'object'); + runner.assert('state has marginSummary', 'marginSummary' in state); + runner.assert('state has crossMarginSummary', 'crossMarginSummary' in state); + runner.assert('state has assetPositions', 'assetPositions' in state); + + if (state.marginSummary) { + runner.assertType('marginSummary.accountValue is string', state.marginSummary.accountValue, 'string'); + runner.assertType('marginSummary.totalRawUsd is string', state.marginSummary.totalRawUsd, 'string'); + } + + runner.assertArray('assetPositions', state.assetPositions, 0); + + // 2. Fetch frontendOpenOrders (should be empty for this address) + console.error('[e2e] Fetching frontendOpenOrders...'); + const orders = await client.frontendOpenOrders({ user: KNOWN_PUBLIC_ADDRESS }); + runner.assertArray('frontendOpenOrders', orders, 0); + + // 3. Validate predictedFundings shape + console.error('[e2e] Fetching predictedFundings...'); + const fundings = await client.predictedFundings(); + runner.assertArray('predictedFundings', fundings, 1); + + if (fundings.length > 0) { + const first = fundings[0]; + runner.assertArray('funding entry is tuple', first, 2); + } + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + console.log(JSON.stringify({ scenario: 'account-state', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); + process.exit(1); +}); diff --git a/packages/perps-controller/e2e/error-codes.ts b/packages/perps-controller/e2e/error-codes.ts new file mode 100644 index 0000000000..08d525729d --- /dev/null +++ b/packages/perps-controller/e2e/error-codes.ts @@ -0,0 +1,63 @@ +/** + * E2E: Error Codes + * Validates that PERPS_ERROR_CODES are structured correctly and that + * validation functions produce correct error codes for malformed inputs. + */ +import { PERPS_ERROR_CODES } from '../src/perpsErrorCodes'; +import { + PERPS_CONSTANTS, + MARGIN_ADJUSTMENT_CONFIG, +} from '../src/constants/perpsConfig'; +import { createClient, E2ERunner } from './helpers'; + +async function main(): Promise { + const runner = new E2ERunner('error-codes'); + + // 1. Validate error code structure + const codes = Object.entries(PERPS_ERROR_CODES); + runner.assertGt('error code count', codes.length, 5); + + for (const [key, value] of codes) { + runner.assert(`${key} is string`, typeof value === 'string', `got ${typeof value}`); + runner.assert(`${key} is non-empty`, (value as string).length > 0); + } + + // 2. Validate constants that error paths depend on + runner.assertGt('DefaultMaxLeverage > 0', PERPS_CONSTANTS.DefaultMaxLeverage, 0); + runner.assertGt('FallbackMaxLeverage > 0', MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage, 0); + runner.assert('FallbackMaxLeverage <= 200', MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage <= 200, + `got ${MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage}`); + + // 3. Test that the API returns meaningful errors for bad inputs + const client = createClient(); + console.error('[e2e] Testing clearinghouseState with invalid address...'); + try { + const state = await client.clearinghouseState({ user: '0xinvalid' }); + // HyperLiquid may return empty state for invalid addresses rather than error + runner.assert('invalid address returns object', typeof state === 'object'); + runner.assert('invalid address has marginSummary', 'marginSummary' in state); + } catch (err: unknown) { + // API error is also acceptable — validates error handling path + runner.assert('invalid address produces error', true); + const message = err instanceof Error ? err.message : String(err); + runner.assert('error is descriptive', message.length > 0, 'empty error message'); + } + + // 4. Test frontendOpenOrders with empty address + console.error('[e2e] Testing frontendOpenOrders with zero address...'); + try { + const orders = await client.frontendOpenOrders({ user: '0x0000000000000000000000000000000000000000' }); + runner.assertArray('zero address orders', orders, 0); + } catch (err: unknown) { + runner.assert('zero address produces error or empty', true); + } + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + console.log(JSON.stringify({ scenario: 'error-codes', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); + process.exit(1); +}); diff --git a/packages/perps-controller/e2e/helpers.ts b/packages/perps-controller/e2e/helpers.ts new file mode 100644 index 0000000000..4fcc49fc9d --- /dev/null +++ b/packages/perps-controller/e2e/helpers.ts @@ -0,0 +1,66 @@ +import type { InfoClient } from '@nktkas/hyperliquid'; + +import { createStandaloneInfoClient } from '../src/utils/standaloneInfoClient'; + +export type E2EResult = { + scenario: string; + status: 'pass' | 'fail'; + assertions: number; + failed: number; + duration_ms: number; + details: { name: string; ok: boolean; error?: string }[]; +}; + +export function createClient(isTestnet = false): InfoClient { + return createStandaloneInfoClient({ isTestnet, timeout: 30000 }); +} + +export class E2ERunner { + readonly scenario: string; + + #details: E2EResult['details'] = []; + + #startTime = Date.now(); + + constructor(scenario: string) { + this.scenario = scenario; + } + + assert(name: string, condition: boolean, error?: string): void { + this.#details.push({ name, ok: condition, error: condition ? undefined : error }); + if (!condition) { + console.error(` FAIL: ${name}${error ? ` — ${error}` : ''}`); + } + } + + assertType(name: string, value: unknown, expected: string): void { + const actual = typeof value; + this.assert(name, actual === expected, `expected ${expected}, got ${actual}`); + } + + assertGt(name: string, value: number, min: number): void { + this.assert(name, value > min, `expected > ${min}, got ${value}`); + } + + assertArray(name: string, value: unknown, minLength = 0): void { + const isArr = Array.isArray(value); + this.assert(`${name} is array`, isArr, `got ${typeof value}`); + if (isArr) { + this.assert(`${name} length >= ${minLength}`, value.length >= minLength, `got ${value.length}`); + } + } + + finish(): E2EResult { + const failed = this.#details.filter((d) => !d.ok).length; + const result: E2EResult = { + scenario: this.scenario, + status: failed > 0 ? 'fail' : 'pass', + assertions: this.#details.length, + failed, + duration_ms: Date.now() - this.#startTime, + details: this.#details, + }; + console.log(JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/packages/perps-controller/e2e/market-data.ts b/packages/perps-controller/e2e/market-data.ts new file mode 100644 index 0000000000..af5af20394 --- /dev/null +++ b/packages/perps-controller/e2e/market-data.ts @@ -0,0 +1,77 @@ +/** + * E2E: Market Data + * Fetches real market metadata and mid prices from HyperLiquid mainnet. + * Validates structure, types, and basic sanity (BTC exists, prices > 0). + */ +import { createClient, E2ERunner } from './helpers'; + +async function main(): Promise { + const runner = new E2ERunner('market-data'); + const client = createClient(); + + // 1. Fetch meta (market universe) + console.error('[e2e] Fetching meta...'); + const meta = await client.meta(); + + runner.assertArray('meta.universe', meta.universe, 1); + runner.assertGt('market count', meta.universe.length, 10); + + const btc = meta.universe.find((m) => m.name === 'BTC'); + runner.assert('BTC market exists', btc !== undefined); + if (btc) { + runner.assertType('BTC szDecimals', btc.szDecimals, 'number'); + runner.assertGt('BTC szDecimals > 0', btc.szDecimals, 0); + } + + const eth = meta.universe.find((m) => m.name === 'ETH'); + runner.assert('ETH market exists', eth !== undefined); + + // 2. Fetch metaAndAssetCtxs (meta + live context) + console.error('[e2e] Fetching metaAndAssetCtxs...'); + const [metaResult, assetCtxs] = await client.metaAndAssetCtxs(); + + runner.assertArray('metaAndAssetCtxs[0].universe', metaResult.universe, 1); + runner.assertArray('assetCtxs', assetCtxs, 1); + runner.assert( + 'meta and assetCtxs same length', + metaResult.universe.length === assetCtxs.length, + `meta=${metaResult.universe.length} assetCtxs=${assetCtxs.length}`, + ); + + const btcIdx = metaResult.universe.findIndex((m) => m.name === 'BTC'); + if (btcIdx >= 0) { + const btcCtx = assetCtxs[btcIdx]; + runner.assertType('BTC markPx is string', btcCtx?.markPx, 'string'); + const markPx = parseFloat(btcCtx?.markPx ?? '0'); + runner.assertGt('BTC markPx > 1000', markPx, 1000); + } + + // 3. Fetch allMids (mid prices) + console.error('[e2e] Fetching allMids...'); + const mids = await client.allMids(); + + runner.assertType('allMids is object', mids, 'object'); + const btcMid = parseFloat(mids.BTC ?? '0'); + runner.assertGt('BTC mid price > 1000', btcMid, 1000); + + const ethMid = parseFloat(mids.ETH ?? '0'); + runner.assertGt('ETH mid price > 100', ethMid, 100); + + // 4. Fetch spotMeta + console.error('[e2e] Fetching spotMeta...'); + const spotMeta = await client.spotMeta(); + runner.assertArray('spotMeta.tokens', spotMeta.tokens, 1); + runner.assertArray('spotMeta.universe', spotMeta.universe, 1); + + const usdcToken = spotMeta.tokens.find((t) => t.name === 'USDC'); + runner.assert('USDC token exists in spotMeta', usdcToken !== undefined); + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + console.log(JSON.stringify({ scenario: 'market-data', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); + process.exit(1); +}); diff --git a/packages/perps-controller/e2e/order-validation.ts b/packages/perps-controller/e2e/order-validation.ts new file mode 100644 index 0000000000..3f5f6b01e3 --- /dev/null +++ b/packages/perps-controller/e2e/order-validation.ts @@ -0,0 +1,93 @@ +/** + * E2E: Order Validation + * Tests order parameter validation logic against real market data. + * Builds order params and validates them without submitting. + */ +import { + ORDER_SLIPPAGE_CONFIG, + PERPS_CONSTANTS, + TP_SL_CONFIG, +} from '../src/constants/perpsConfig'; +import { + TRADING_DEFAULTS, + USDC_DECIMALS, +} from '../src/constants/hyperLiquidConfig'; +import { PERPS_ERROR_CODES } from '../src/perpsErrorCodes'; +import { createClient, E2ERunner } from './helpers'; + +async function main(): Promise { + const runner = new E2ERunner('order-validation'); + const client = createClient(); + + // 1. Fetch live market data for validation context + console.error('[e2e] Fetching meta for validation context...'); + const meta = await client.meta(); + const mids = await client.allMids(); + + const btc = meta.universe.find((m) => m.name === 'BTC'); + runner.assert('BTC market exists for validation', btc !== undefined); + + const btcPrice = parseFloat(mids.BTC ?? '0'); + runner.assertGt('BTC price > 0', btcPrice, 0); + + // 2. Validate constants are sane + runner.assertGt('DefaultMaxLeverage > 0', PERPS_CONSTANTS.DefaultMaxLeverage, 0); + runner.assertGt('TRADING_DEFAULTS.leverage > 0', TRADING_DEFAULTS.leverage, 0); + runner.assertGt('USDC_DECIMALS', USDC_DECIMALS, 0); + runner.assert('USDC_DECIMALS is 6', USDC_DECIMALS === 6, `got ${USDC_DECIMALS}`); + + // 3. Validate slippage config + runner.assertGt('DefaultMarketSlippageBps > 0', ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, 0); + runner.assertGt('DefaultTpslSlippageBps > market', + ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps, + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, + ); + runner.assertGt('DefaultLimitSlippageBps > 0', ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps, 0); + + // 4. Validate TP/SL config + runner.assert('TP_SL UsePositionBoundTpsl is boolean', + typeof TP_SL_CONFIG.UsePositionBoundTpsl === 'boolean', + `got ${typeof TP_SL_CONFIG.UsePositionBoundTpsl}`, + ); + + // 5. Validate error codes exist and are structured + runner.assertType('PERPS_ERROR_CODES is object', PERPS_ERROR_CODES, 'object'); + const errorKeys = Object.keys(PERPS_ERROR_CODES); + runner.assertGt('error codes count > 5', errorKeys.length, 5); + + const expectedCodes = [ + 'INSUFFICIENT_BALANCE', + 'ORDER_LEVERAGE_INVALID', + 'ORDER_SIZE_MIN', + 'ORDER_UNKNOWN_COIN', + 'ORDER_REJECTED', + 'SLIPPAGE_EXCEEDED', + ]; + for (const code of expectedCodes) { + runner.assert( + `error code ${code} exists`, + code in PERPS_ERROR_CODES, + `missing from PERPS_ERROR_CODES`, + ); + } + + // 6. Validate szDecimals from live meta match expectations for major assets + if (btc) { + runner.assert('BTC szDecimals is reasonable (1-8)', btc.szDecimals >= 1 && btc.szDecimals <= 8, + `got ${btc.szDecimals}`); + } + const eth = meta.universe.find((m) => m.name === 'ETH'); + if (eth) { + runner.assert('ETH szDecimals is reasonable (1-8)', eth.szDecimals >= 1 && eth.szDecimals <= 8, + `got ${eth.szDecimals}`); + } + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + console.log(JSON.stringify({ scenario: 'order-validation', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); + process.exit(1); +}); diff --git a/packages/perps-controller/e2e/subscription-stream.ts b/packages/perps-controller/e2e/subscription-stream.ts new file mode 100644 index 0000000000..9ac3cdcb20 --- /dev/null +++ b/packages/perps-controller/e2e/subscription-stream.ts @@ -0,0 +1,95 @@ +/** + * E2E: Subscription Stream + * Opens a WebSocket to HyperLiquid mainnet, subscribes to allMids, + * receives N price updates, validates shape, then closes. + */ +import WebSocket from 'ws'; + +import { getWebSocketEndpoint } from '../src/constants/hyperLiquidConfig'; +import { E2ERunner } from './helpers'; + +const TARGET_UPDATES = 3; +const TIMEOUT_MS = 30000; + +async function main(): Promise { + const runner = new E2ERunner('subscription-stream'); + const endpoint = getWebSocketEndpoint(false); + + runner.assertType('WS endpoint is string', endpoint, 'string'); + runner.assert('WS endpoint starts with wss://', endpoint.startsWith('wss://')); + + console.error(`[e2e] Connecting to ${endpoint}...`); + + const updates: Record[] = []; + + await new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`Timeout: only received ${updates.length}/${TARGET_UPDATES} updates in ${TIMEOUT_MS}ms`)); + }, TIMEOUT_MS); + + ws.on('open', () => { + console.error('[e2e] WebSocket connected, subscribing to allMids...'); + ws.send(JSON.stringify({ + method: 'subscribe', + subscription: { type: 'allMids' }, + })); + }); + + ws.on('message', (data: WebSocket.Data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.channel === 'allMids' && msg.data?.mids) { + updates.push(msg.data.mids); + console.error(`[e2e] Received allMids update ${updates.length}/${TARGET_UPDATES}`); + if (updates.length >= TARGET_UPDATES) { + clearTimeout(timer); + ws.close(); + resolve(); + } + } + } catch { + // ignore non-JSON or non-allMids messages + } + }); + + ws.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + runner.assertGt('received updates', updates.length, TARGET_UPDATES - 1); + + const firstUpdate = updates[0]; + if (firstUpdate) { + runner.assertType('mids is object', firstUpdate, 'object'); + const keys = Object.keys(firstUpdate); + runner.assertGt('mids has markets', keys.length, 10); + runner.assert('BTC in mids', 'BTC' in firstUpdate); + runner.assert('ETH in mids', 'ETH' in firstUpdate); + + const btcMid = parseFloat(firstUpdate.BTC ?? '0'); + runner.assertGt('BTC WS mid > 1000', btcMid, 1000); + } + + // Check prices change between updates (market is live) + if (updates.length >= 2) { + const keys1 = Object.keys(updates[0] ?? {}); + const keys2 = Object.keys(updates[1] ?? {}); + runner.assert('consistent market count across updates', + Math.abs(keys1.length - keys2.length) <= 5, + `update1=${keys1.length} update2=${keys2.length}`, + ); + } + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + console.log(JSON.stringify({ scenario: 'subscription-stream', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); + process.exit(1); +}); From 0135d896a9a27ba0186f6bc33c2a06d7bd57539d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 07:27:15 +0800 Subject: [PATCH 02/10] chore(perps): include e2e directory in tsconfig --- packages/perps-controller/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/tsconfig.json b/packages/perps-controller/tsconfig.json index 324879cf43..80a709aa8c 100644 --- a/packages/perps-controller/tsconfig.json +++ b/packages/perps-controller/tsconfig.json @@ -38,5 +38,5 @@ "path": "../transaction-controller" } ], - "include": ["../../types", "./src", "./tests"] + "include": ["../../types", "./src", "./tests", "./e2e"] } From cee9282c1551c7f33412d791c950c0d0bd1f316c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 07:34:42 +0800 Subject: [PATCH 03/10] fix(perps): resolve eslint errors in e2e scripts --- .../perps-controller/e2e/account-state.ts | 13 ++++---- packages/perps-controller/e2e/error-codes.ts | 15 +++++---- packages/perps-controller/e2e/helpers.ts | 10 +++--- packages/perps-controller/e2e/market-data.ts | 15 +++++---- .../perps-controller/e2e/order-validation.ts | 13 ++++---- .../e2e/subscription-stream.ts | 33 +++++++++---------- 6 files changed, 51 insertions(+), 48 deletions(-) diff --git a/packages/perps-controller/e2e/account-state.ts b/packages/perps-controller/e2e/account-state.ts index 4a2b72a2c2..f2ca63b26d 100644 --- a/packages/perps-controller/e2e/account-state.ts +++ b/packages/perps-controller/e2e/account-state.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals */ /** * E2E: Account State * Queries clearinghouseState for a known public address on HyperLiquid mainnet. @@ -16,9 +17,9 @@ async function main(): Promise { const state = await client.clearinghouseState({ user: KNOWN_PUBLIC_ADDRESS }); runner.assertType('state is object', state, 'object'); - runner.assert('state has marginSummary', 'marginSummary' in state); - runner.assert('state has crossMarginSummary', 'crossMarginSummary' in state); - runner.assert('state has assetPositions', 'assetPositions' in state); + runner.assert('state has marginSummary', Object.hasOwn(state, 'marginSummary')); + runner.assert('state has crossMarginSummary', Object.hasOwn(state, 'crossMarginSummary')); + runner.assert('state has assetPositions', Object.hasOwn(state, 'assetPositions')); if (state.marginSummary) { runner.assertType('marginSummary.accountValue is string', state.marginSummary.accountValue, 'string'); @@ -46,8 +47,8 @@ async function main(): Promise { process.exit(result.status === 'pass' ? 0 : 1); } -main().catch((err) => { - console.error(err); - console.log(JSON.stringify({ scenario: 'account-state', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); +main().catch((caughtError) => { + console.error(caughtError); + console.log(JSON.stringify({ scenario: 'account-state', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); process.exit(1); }); diff --git a/packages/perps-controller/e2e/error-codes.ts b/packages/perps-controller/e2e/error-codes.ts index 08d525729d..85db539091 100644 --- a/packages/perps-controller/e2e/error-codes.ts +++ b/packages/perps-controller/e2e/error-codes.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals */ /** * E2E: Error Codes * Validates that PERPS_ERROR_CODES are structured correctly and that @@ -35,11 +36,11 @@ async function main(): Promise { const state = await client.clearinghouseState({ user: '0xinvalid' }); // HyperLiquid may return empty state for invalid addresses rather than error runner.assert('invalid address returns object', typeof state === 'object'); - runner.assert('invalid address has marginSummary', 'marginSummary' in state); - } catch (err: unknown) { + runner.assert('invalid address has marginSummary', Object.hasOwn(state, 'marginSummary')); + } catch (caughtError: unknown) { // API error is also acceptable — validates error handling path runner.assert('invalid address produces error', true); - const message = err instanceof Error ? err.message : String(err); + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); runner.assert('error is descriptive', message.length > 0, 'empty error message'); } @@ -48,7 +49,7 @@ async function main(): Promise { try { const orders = await client.frontendOpenOrders({ user: '0x0000000000000000000000000000000000000000' }); runner.assertArray('zero address orders', orders, 0); - } catch (err: unknown) { + } catch { runner.assert('zero address produces error or empty', true); } @@ -56,8 +57,8 @@ async function main(): Promise { process.exit(result.status === 'pass' ? 0 : 1); } -main().catch((err) => { - console.error(err); - console.log(JSON.stringify({ scenario: 'error-codes', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); +main().catch((caughtError) => { + console.error(caughtError); + console.log(JSON.stringify({ scenario: 'error-codes', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); process.exit(1); }); diff --git a/packages/perps-controller/e2e/helpers.ts b/packages/perps-controller/e2e/helpers.ts index 4fcc49fc9d..76eeed39a9 100644 --- a/packages/perps-controller/e2e/helpers.ts +++ b/packages/perps-controller/e2e/helpers.ts @@ -7,7 +7,7 @@ export type E2EResult = { status: 'pass' | 'fail'; assertions: number; failed: number; - duration_ms: number; + durationMs: number; details: { name: string; ok: boolean; error?: string }[]; }; @@ -18,9 +18,9 @@ export function createClient(isTestnet = false): InfoClient { export class E2ERunner { readonly scenario: string; - #details: E2EResult['details'] = []; + readonly #details: E2EResult['details'] = []; - #startTime = Date.now(); + readonly #startTime = Date.now(); constructor(scenario: string) { this.scenario = scenario; @@ -51,13 +51,13 @@ export class E2ERunner { } finish(): E2EResult { - const failed = this.#details.filter((d) => !d.ok).length; + const failed = this.#details.filter((detail) => !detail.ok).length; const result: E2EResult = { scenario: this.scenario, status: failed > 0 ? 'fail' : 'pass', assertions: this.#details.length, failed, - duration_ms: Date.now() - this.#startTime, + durationMs: Date.now() - this.#startTime, details: this.#details, }; console.log(JSON.stringify(result, null, 2)); diff --git a/packages/perps-controller/e2e/market-data.ts b/packages/perps-controller/e2e/market-data.ts index af5af20394..30be97dcff 100644 --- a/packages/perps-controller/e2e/market-data.ts +++ b/packages/perps-controller/e2e/market-data.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals */ /** * E2E: Market Data * Fetches real market metadata and mid prices from HyperLiquid mainnet. @@ -16,14 +17,14 @@ async function main(): Promise { runner.assertArray('meta.universe', meta.universe, 1); runner.assertGt('market count', meta.universe.length, 10); - const btc = meta.universe.find((m) => m.name === 'BTC'); + const btc = meta.universe.find((market) => market.name === 'BTC'); runner.assert('BTC market exists', btc !== undefined); if (btc) { runner.assertType('BTC szDecimals', btc.szDecimals, 'number'); runner.assertGt('BTC szDecimals > 0', btc.szDecimals, 0); } - const eth = meta.universe.find((m) => m.name === 'ETH'); + const eth = meta.universe.find((market) => market.name === 'ETH'); runner.assert('ETH market exists', eth !== undefined); // 2. Fetch metaAndAssetCtxs (meta + live context) @@ -38,7 +39,7 @@ async function main(): Promise { `meta=${metaResult.universe.length} assetCtxs=${assetCtxs.length}`, ); - const btcIdx = metaResult.universe.findIndex((m) => m.name === 'BTC'); + const btcIdx = metaResult.universe.findIndex((market) => market.name === 'BTC'); if (btcIdx >= 0) { const btcCtx = assetCtxs[btcIdx]; runner.assertType('BTC markPx is string', btcCtx?.markPx, 'string'); @@ -63,15 +64,15 @@ async function main(): Promise { runner.assertArray('spotMeta.tokens', spotMeta.tokens, 1); runner.assertArray('spotMeta.universe', spotMeta.universe, 1); - const usdcToken = spotMeta.tokens.find((t) => t.name === 'USDC'); + const usdcToken = spotMeta.tokens.find((token) => token.name === 'USDC'); runner.assert('USDC token exists in spotMeta', usdcToken !== undefined); const result = runner.finish(); process.exit(result.status === 'pass' ? 0 : 1); } -main().catch((err) => { - console.error(err); - console.log(JSON.stringify({ scenario: 'market-data', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); +main().catch((caughtError) => { + console.error(caughtError); + console.log(JSON.stringify({ scenario: 'market-data', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); process.exit(1); }); diff --git a/packages/perps-controller/e2e/order-validation.ts b/packages/perps-controller/e2e/order-validation.ts index 3f5f6b01e3..d8778ebbbb 100644 --- a/packages/perps-controller/e2e/order-validation.ts +++ b/packages/perps-controller/e2e/order-validation.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals */ /** * E2E: Order Validation * Tests order parameter validation logic against real market data. @@ -24,7 +25,7 @@ async function main(): Promise { const meta = await client.meta(); const mids = await client.allMids(); - const btc = meta.universe.find((m) => m.name === 'BTC'); + const btc = meta.universe.find((market) => market.name === 'BTC'); runner.assert('BTC market exists for validation', btc !== undefined); const btcPrice = parseFloat(mids.BTC ?? '0'); @@ -66,7 +67,7 @@ async function main(): Promise { for (const code of expectedCodes) { runner.assert( `error code ${code} exists`, - code in PERPS_ERROR_CODES, + Object.hasOwn(PERPS_ERROR_CODES, code), `missing from PERPS_ERROR_CODES`, ); } @@ -76,7 +77,7 @@ async function main(): Promise { runner.assert('BTC szDecimals is reasonable (1-8)', btc.szDecimals >= 1 && btc.szDecimals <= 8, `got ${btc.szDecimals}`); } - const eth = meta.universe.find((m) => m.name === 'ETH'); + const eth = meta.universe.find((market) => market.name === 'ETH'); if (eth) { runner.assert('ETH szDecimals is reasonable (1-8)', eth.szDecimals >= 1 && eth.szDecimals <= 8, `got ${eth.szDecimals}`); @@ -86,8 +87,8 @@ async function main(): Promise { process.exit(result.status === 'pass' ? 0 : 1); } -main().catch((err) => { - console.error(err); - console.log(JSON.stringify({ scenario: 'order-validation', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); +main().catch((caughtError) => { + console.error(caughtError); + console.log(JSON.stringify({ scenario: 'order-validation', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); process.exit(1); }); diff --git a/packages/perps-controller/e2e/subscription-stream.ts b/packages/perps-controller/e2e/subscription-stream.ts index 9ac3cdcb20..7541ff39eb 100644 --- a/packages/perps-controller/e2e/subscription-stream.ts +++ b/packages/perps-controller/e2e/subscription-stream.ts @@ -1,10 +1,9 @@ +/* eslint-disable no-restricted-globals */ /** * E2E: Subscription Stream * Opens a WebSocket to HyperLiquid mainnet, subscribes to allMids, * receives N price updates, validates shape, then closes. */ -import WebSocket from 'ws'; - import { getWebSocketEndpoint } from '../src/constants/hyperLiquidConfig'; import { E2ERunner } from './helpers'; @@ -29,19 +28,19 @@ async function main(): Promise { reject(new Error(`Timeout: only received ${updates.length}/${TARGET_UPDATES} updates in ${TIMEOUT_MS}ms`)); }, TIMEOUT_MS); - ws.on('open', () => { + ws.onopen = (): void => { console.error('[e2e] WebSocket connected, subscribing to allMids...'); ws.send(JSON.stringify({ method: 'subscribe', subscription: { type: 'allMids' }, })); - }); + }; - ws.on('message', (data: WebSocket.Data) => { + ws.onmessage = (event: MessageEvent): void => { try { - const msg = JSON.parse(data.toString()); - if (msg.channel === 'allMids' && msg.data?.mids) { - updates.push(msg.data.mids); + const message = JSON.parse(String(event.data)) as { channel?: string; data?: { mids?: Record } }; + if (message.channel === 'allMids' && message.data?.mids) { + updates.push(message.data.mids); console.error(`[e2e] Received allMids update ${updates.length}/${TARGET_UPDATES}`); if (updates.length >= TARGET_UPDATES) { clearTimeout(timer); @@ -52,12 +51,12 @@ async function main(): Promise { } catch { // ignore non-JSON or non-allMids messages } - }); + }; - ws.on('error', (err) => { + ws.onerror = (event: ErrorEvent): void => { clearTimeout(timer); - reject(err); - }); + reject(new Error(`WebSocket error: ${event.message}`)); + }; }); runner.assertGt('received updates', updates.length, TARGET_UPDATES - 1); @@ -67,8 +66,8 @@ async function main(): Promise { runner.assertType('mids is object', firstUpdate, 'object'); const keys = Object.keys(firstUpdate); runner.assertGt('mids has markets', keys.length, 10); - runner.assert('BTC in mids', 'BTC' in firstUpdate); - runner.assert('ETH in mids', 'ETH' in firstUpdate); + runner.assert('BTC in mids', Object.hasOwn(firstUpdate, 'BTC')); + runner.assert('ETH in mids', Object.hasOwn(firstUpdate, 'ETH')); const btcMid = parseFloat(firstUpdate.BTC ?? '0'); runner.assertGt('BTC WS mid > 1000', btcMid, 1000); @@ -88,8 +87,8 @@ async function main(): Promise { process.exit(result.status === 'pass' ? 0 : 1); } -main().catch((err) => { - console.error(err); - console.log(JSON.stringify({ scenario: 'subscription-stream', status: 'fail', assertions: 0, failed: 1, duration_ms: 0, details: [{ name: 'unhandled', ok: false, error: err.message }] })); +main().catch((caughtError) => { + console.error(caughtError); + console.log(JSON.stringify({ scenario: 'subscription-stream', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); process.exit(1); }); From 9ace003f0d02aeacb5e347b7e02797c4075163b5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 07:38:36 +0800 Subject: [PATCH 04/10] style(perps): format e2e scripts with prettier --- .../perps-controller/e2e/account-state.ts | 51 ++++++++++++-- packages/perps-controller/e2e/error-codes.ts | 63 ++++++++++++++--- packages/perps-controller/e2e/helpers.ts | 18 ++++- packages/perps-controller/e2e/market-data.ts | 24 ++++++- .../perps-controller/e2e/order-validation.ts | 70 +++++++++++++++---- .../e2e/subscription-stream.ts | 53 +++++++++++--- 6 files changed, 235 insertions(+), 44 deletions(-) diff --git a/packages/perps-controller/e2e/account-state.ts b/packages/perps-controller/e2e/account-state.ts index f2ca63b26d..4d05ad6996 100644 --- a/packages/perps-controller/e2e/account-state.ts +++ b/packages/perps-controller/e2e/account-state.ts @@ -17,20 +17,39 @@ async function main(): Promise { const state = await client.clearinghouseState({ user: KNOWN_PUBLIC_ADDRESS }); runner.assertType('state is object', state, 'object'); - runner.assert('state has marginSummary', Object.hasOwn(state, 'marginSummary')); - runner.assert('state has crossMarginSummary', Object.hasOwn(state, 'crossMarginSummary')); - runner.assert('state has assetPositions', Object.hasOwn(state, 'assetPositions')); + runner.assert( + 'state has marginSummary', + Object.hasOwn(state, 'marginSummary'), + ); + runner.assert( + 'state has crossMarginSummary', + Object.hasOwn(state, 'crossMarginSummary'), + ); + runner.assert( + 'state has assetPositions', + Object.hasOwn(state, 'assetPositions'), + ); if (state.marginSummary) { - runner.assertType('marginSummary.accountValue is string', state.marginSummary.accountValue, 'string'); - runner.assertType('marginSummary.totalRawUsd is string', state.marginSummary.totalRawUsd, 'string'); + runner.assertType( + 'marginSummary.accountValue is string', + state.marginSummary.accountValue, + 'string', + ); + runner.assertType( + 'marginSummary.totalRawUsd is string', + state.marginSummary.totalRawUsd, + 'string', + ); } runner.assertArray('assetPositions', state.assetPositions, 0); // 2. Fetch frontendOpenOrders (should be empty for this address) console.error('[e2e] Fetching frontendOpenOrders...'); - const orders = await client.frontendOpenOrders({ user: KNOWN_PUBLIC_ADDRESS }); + const orders = await client.frontendOpenOrders({ + user: KNOWN_PUBLIC_ADDRESS, + }); runner.assertArray('frontendOpenOrders', orders, 0); // 3. Validate predictedFundings shape @@ -49,6 +68,24 @@ async function main(): Promise { main().catch((caughtError) => { console.error(caughtError); - console.log(JSON.stringify({ scenario: 'account-state', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); + console.log( + JSON.stringify({ + scenario: 'account-state', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); process.exit(1); }); diff --git a/packages/perps-controller/e2e/error-codes.ts b/packages/perps-controller/e2e/error-codes.ts index 85db539091..c53c40f524 100644 --- a/packages/perps-controller/e2e/error-codes.ts +++ b/packages/perps-controller/e2e/error-codes.ts @@ -19,15 +19,30 @@ async function main(): Promise { runner.assertGt('error code count', codes.length, 5); for (const [key, value] of codes) { - runner.assert(`${key} is string`, typeof value === 'string', `got ${typeof value}`); + runner.assert( + `${key} is string`, + typeof value === 'string', + `got ${typeof value}`, + ); runner.assert(`${key} is non-empty`, (value as string).length > 0); } // 2. Validate constants that error paths depend on - runner.assertGt('DefaultMaxLeverage > 0', PERPS_CONSTANTS.DefaultMaxLeverage, 0); - runner.assertGt('FallbackMaxLeverage > 0', MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage, 0); - runner.assert('FallbackMaxLeverage <= 200', MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage <= 200, - `got ${MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage}`); + runner.assertGt( + 'DefaultMaxLeverage > 0', + PERPS_CONSTANTS.DefaultMaxLeverage, + 0, + ); + runner.assertGt( + 'FallbackMaxLeverage > 0', + MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage, + 0, + ); + runner.assert( + 'FallbackMaxLeverage <= 200', + MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage <= 200, + `got ${MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage}`, + ); // 3. Test that the API returns meaningful errors for bad inputs const client = createClient(); @@ -36,18 +51,28 @@ async function main(): Promise { const state = await client.clearinghouseState({ user: '0xinvalid' }); // HyperLiquid may return empty state for invalid addresses rather than error runner.assert('invalid address returns object', typeof state === 'object'); - runner.assert('invalid address has marginSummary', Object.hasOwn(state, 'marginSummary')); + runner.assert( + 'invalid address has marginSummary', + Object.hasOwn(state, 'marginSummary'), + ); } catch (caughtError: unknown) { // API error is also acceptable — validates error handling path runner.assert('invalid address produces error', true); - const message = caughtError instanceof Error ? caughtError.message : String(caughtError); - runner.assert('error is descriptive', message.length > 0, 'empty error message'); + const message = + caughtError instanceof Error ? caughtError.message : String(caughtError); + runner.assert( + 'error is descriptive', + message.length > 0, + 'empty error message', + ); } // 4. Test frontendOpenOrders with empty address console.error('[e2e] Testing frontendOpenOrders with zero address...'); try { - const orders = await client.frontendOpenOrders({ user: '0x0000000000000000000000000000000000000000' }); + const orders = await client.frontendOpenOrders({ + user: '0x0000000000000000000000000000000000000000', + }); runner.assertArray('zero address orders', orders, 0); } catch { runner.assert('zero address produces error or empty', true); @@ -59,6 +84,24 @@ async function main(): Promise { main().catch((caughtError) => { console.error(caughtError); - console.log(JSON.stringify({ scenario: 'error-codes', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); + console.log( + JSON.stringify({ + scenario: 'error-codes', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); process.exit(1); }); diff --git a/packages/perps-controller/e2e/helpers.ts b/packages/perps-controller/e2e/helpers.ts index 76eeed39a9..a683ab8e21 100644 --- a/packages/perps-controller/e2e/helpers.ts +++ b/packages/perps-controller/e2e/helpers.ts @@ -27,7 +27,11 @@ export class E2ERunner { } assert(name: string, condition: boolean, error?: string): void { - this.#details.push({ name, ok: condition, error: condition ? undefined : error }); + this.#details.push({ + name, + ok: condition, + error: condition ? undefined : error, + }); if (!condition) { console.error(` FAIL: ${name}${error ? ` — ${error}` : ''}`); } @@ -35,7 +39,11 @@ export class E2ERunner { assertType(name: string, value: unknown, expected: string): void { const actual = typeof value; - this.assert(name, actual === expected, `expected ${expected}, got ${actual}`); + this.assert( + name, + actual === expected, + `expected ${expected}, got ${actual}`, + ); } assertGt(name: string, value: number, min: number): void { @@ -46,7 +54,11 @@ export class E2ERunner { const isArr = Array.isArray(value); this.assert(`${name} is array`, isArr, `got ${typeof value}`); if (isArr) { - this.assert(`${name} length >= ${minLength}`, value.length >= minLength, `got ${value.length}`); + this.assert( + `${name} length >= ${minLength}`, + value.length >= minLength, + `got ${value.length}`, + ); } } diff --git a/packages/perps-controller/e2e/market-data.ts b/packages/perps-controller/e2e/market-data.ts index 30be97dcff..da92ea1218 100644 --- a/packages/perps-controller/e2e/market-data.ts +++ b/packages/perps-controller/e2e/market-data.ts @@ -39,7 +39,9 @@ async function main(): Promise { `meta=${metaResult.universe.length} assetCtxs=${assetCtxs.length}`, ); - const btcIdx = metaResult.universe.findIndex((market) => market.name === 'BTC'); + const btcIdx = metaResult.universe.findIndex( + (market) => market.name === 'BTC', + ); if (btcIdx >= 0) { const btcCtx = assetCtxs[btcIdx]; runner.assertType('BTC markPx is string', btcCtx?.markPx, 'string'); @@ -73,6 +75,24 @@ async function main(): Promise { main().catch((caughtError) => { console.error(caughtError); - console.log(JSON.stringify({ scenario: 'market-data', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); + console.log( + JSON.stringify({ + scenario: 'market-data', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); process.exit(1); }); diff --git a/packages/perps-controller/e2e/order-validation.ts b/packages/perps-controller/e2e/order-validation.ts index d8778ebbbb..b1cb432afe 100644 --- a/packages/perps-controller/e2e/order-validation.ts +++ b/packages/perps-controller/e2e/order-validation.ts @@ -32,21 +32,43 @@ async function main(): Promise { runner.assertGt('BTC price > 0', btcPrice, 0); // 2. Validate constants are sane - runner.assertGt('DefaultMaxLeverage > 0', PERPS_CONSTANTS.DefaultMaxLeverage, 0); - runner.assertGt('TRADING_DEFAULTS.leverage > 0', TRADING_DEFAULTS.leverage, 0); + runner.assertGt( + 'DefaultMaxLeverage > 0', + PERPS_CONSTANTS.DefaultMaxLeverage, + 0, + ); + runner.assertGt( + 'TRADING_DEFAULTS.leverage > 0', + TRADING_DEFAULTS.leverage, + 0, + ); runner.assertGt('USDC_DECIMALS', USDC_DECIMALS, 0); - runner.assert('USDC_DECIMALS is 6', USDC_DECIMALS === 6, `got ${USDC_DECIMALS}`); + runner.assert( + 'USDC_DECIMALS is 6', + USDC_DECIMALS === 6, + `got ${USDC_DECIMALS}`, + ); // 3. Validate slippage config - runner.assertGt('DefaultMarketSlippageBps > 0', ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, 0); - runner.assertGt('DefaultTpslSlippageBps > market', + runner.assertGt( + 'DefaultMarketSlippageBps > 0', + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, + 0, + ); + runner.assertGt( + 'DefaultTpslSlippageBps > market', ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps, ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, ); - runner.assertGt('DefaultLimitSlippageBps > 0', ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps, 0); + runner.assertGt( + 'DefaultLimitSlippageBps > 0', + ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps, + 0, + ); // 4. Validate TP/SL config - runner.assert('TP_SL UsePositionBoundTpsl is boolean', + runner.assert( + 'TP_SL UsePositionBoundTpsl is boolean', typeof TP_SL_CONFIG.UsePositionBoundTpsl === 'boolean', `got ${typeof TP_SL_CONFIG.UsePositionBoundTpsl}`, ); @@ -74,13 +96,19 @@ async function main(): Promise { // 6. Validate szDecimals from live meta match expectations for major assets if (btc) { - runner.assert('BTC szDecimals is reasonable (1-8)', btc.szDecimals >= 1 && btc.szDecimals <= 8, - `got ${btc.szDecimals}`); + runner.assert( + 'BTC szDecimals is reasonable (1-8)', + btc.szDecimals >= 1 && btc.szDecimals <= 8, + `got ${btc.szDecimals}`, + ); } const eth = meta.universe.find((market) => market.name === 'ETH'); if (eth) { - runner.assert('ETH szDecimals is reasonable (1-8)', eth.szDecimals >= 1 && eth.szDecimals <= 8, - `got ${eth.szDecimals}`); + runner.assert( + 'ETH szDecimals is reasonable (1-8)', + eth.szDecimals >= 1 && eth.szDecimals <= 8, + `got ${eth.szDecimals}`, + ); } const result = runner.finish(); @@ -89,6 +117,24 @@ async function main(): Promise { main().catch((caughtError) => { console.error(caughtError); - console.log(JSON.stringify({ scenario: 'order-validation', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); + console.log( + JSON.stringify({ + scenario: 'order-validation', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); process.exit(1); }); diff --git a/packages/perps-controller/e2e/subscription-stream.ts b/packages/perps-controller/e2e/subscription-stream.ts index 7541ff39eb..478c773a77 100644 --- a/packages/perps-controller/e2e/subscription-stream.ts +++ b/packages/perps-controller/e2e/subscription-stream.ts @@ -15,7 +15,10 @@ async function main(): Promise { const endpoint = getWebSocketEndpoint(false); runner.assertType('WS endpoint is string', endpoint, 'string'); - runner.assert('WS endpoint starts with wss://', endpoint.startsWith('wss://')); + runner.assert( + 'WS endpoint starts with wss://', + endpoint.startsWith('wss://'), + ); console.error(`[e2e] Connecting to ${endpoint}...`); @@ -25,23 +28,34 @@ async function main(): Promise { const ws = new WebSocket(endpoint); const timer = setTimeout(() => { ws.close(); - reject(new Error(`Timeout: only received ${updates.length}/${TARGET_UPDATES} updates in ${TIMEOUT_MS}ms`)); + reject( + new Error( + `Timeout: only received ${updates.length}/${TARGET_UPDATES} updates in ${TIMEOUT_MS}ms`, + ), + ); }, TIMEOUT_MS); ws.onopen = (): void => { console.error('[e2e] WebSocket connected, subscribing to allMids...'); - ws.send(JSON.stringify({ - method: 'subscribe', - subscription: { type: 'allMids' }, - })); + ws.send( + JSON.stringify({ + method: 'subscribe', + subscription: { type: 'allMids' }, + }), + ); }; ws.onmessage = (event: MessageEvent): void => { try { - const message = JSON.parse(String(event.data)) as { channel?: string; data?: { mids?: Record } }; + const message = JSON.parse(String(event.data)) as { + channel?: string; + data?: { mids?: Record }; + }; if (message.channel === 'allMids' && message.data?.mids) { updates.push(message.data.mids); - console.error(`[e2e] Received allMids update ${updates.length}/${TARGET_UPDATES}`); + console.error( + `[e2e] Received allMids update ${updates.length}/${TARGET_UPDATES}`, + ); if (updates.length >= TARGET_UPDATES) { clearTimeout(timer); ws.close(); @@ -77,7 +91,8 @@ async function main(): Promise { if (updates.length >= 2) { const keys1 = Object.keys(updates[0] ?? {}); const keys2 = Object.keys(updates[1] ?? {}); - runner.assert('consistent market count across updates', + runner.assert( + 'consistent market count across updates', Math.abs(keys1.length - keys2.length) <= 5, `update1=${keys1.length} update2=${keys2.length}`, ); @@ -89,6 +104,24 @@ async function main(): Promise { main().catch((caughtError) => { console.error(caughtError); - console.log(JSON.stringify({ scenario: 'subscription-stream', status: 'fail', assertions: 0, failed: 1, durationMs: 0, details: [{ name: 'unhandled', ok: false, error: caughtError instanceof Error ? caughtError.message : String(caughtError) }] })); + console.log( + JSON.stringify({ + scenario: 'subscription-stream', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); process.exit(1); }); From 6893f4d17ac30394726876fe73dd12ebc5ecb582 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 07:43:52 +0800 Subject: [PATCH 05/10] style(perps): format e2e with oxfmt --- packages/perps-controller/e2e/error-codes.ts | 8 ++++---- packages/perps-controller/e2e/order-validation.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/e2e/error-codes.ts b/packages/perps-controller/e2e/error-codes.ts index c53c40f524..6ccfaf83e3 100644 --- a/packages/perps-controller/e2e/error-codes.ts +++ b/packages/perps-controller/e2e/error-codes.ts @@ -1,3 +1,7 @@ +import { + PERPS_CONSTANTS, + MARGIN_ADJUSTMENT_CONFIG, +} from '../src/constants/perpsConfig'; /* eslint-disable no-restricted-globals */ /** * E2E: Error Codes @@ -5,10 +9,6 @@ * validation functions produce correct error codes for malformed inputs. */ import { PERPS_ERROR_CODES } from '../src/perpsErrorCodes'; -import { - PERPS_CONSTANTS, - MARGIN_ADJUSTMENT_CONFIG, -} from '../src/constants/perpsConfig'; import { createClient, E2ERunner } from './helpers'; async function main(): Promise { diff --git a/packages/perps-controller/e2e/order-validation.ts b/packages/perps-controller/e2e/order-validation.ts index b1cb432afe..e754136ed3 100644 --- a/packages/perps-controller/e2e/order-validation.ts +++ b/packages/perps-controller/e2e/order-validation.ts @@ -1,3 +1,7 @@ +import { + TRADING_DEFAULTS, + USDC_DECIMALS, +} from '../src/constants/hyperLiquidConfig'; /* eslint-disable no-restricted-globals */ /** * E2E: Order Validation @@ -9,10 +13,6 @@ import { PERPS_CONSTANTS, TP_SL_CONFIG, } from '../src/constants/perpsConfig'; -import { - TRADING_DEFAULTS, - USDC_DECIMALS, -} from '../src/constants/hyperLiquidConfig'; import { PERPS_ERROR_CODES } from '../src/perpsErrorCodes'; import { createClient, E2ERunner } from './helpers'; From f3b9ab37e94b7c4f4d44152a72b26b53c3f366ad Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 08:22:23 +0800 Subject: [PATCH 06/10] feat(perps): add parameterized trading e2e scripts with viem wallet support --- packages/perps-controller/e2e/README.md | 96 ++++++ packages/perps-controller/e2e/config.ts | 60 ++++ packages/perps-controller/e2e/limit-orders.ts | 180 +++++++++++ .../perps-controller/e2e/trading-client.ts | 30 ++ .../perps-controller/e2e/trading-lifecycle.ts | 289 ++++++++++++++++++ packages/perps-controller/package.json | 3 +- yarn.lock | 58 ++++ 7 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 packages/perps-controller/e2e/README.md create mode 100644 packages/perps-controller/e2e/config.ts create mode 100644 packages/perps-controller/e2e/limit-orders.ts create mode 100644 packages/perps-controller/e2e/trading-client.ts create mode 100644 packages/perps-controller/e2e/trading-lifecycle.ts diff --git a/packages/perps-controller/e2e/README.md b/packages/perps-controller/e2e/README.md new file mode 100644 index 0000000000..ce364f3c02 --- /dev/null +++ b/packages/perps-controller/e2e/README.md @@ -0,0 +1,96 @@ +# Perps Controller E2E Tests + +Standalone validation scripts that call real HyperLiquid APIs. No mocks. + +## Read-Only Scenarios (no wallet needed) + +```bash +npx tsx e2e/market-data.ts # meta, prices, spotMeta +npx tsx e2e/account-state.ts # clearinghouseState, orders, fundings +npx tsx e2e/order-validation.ts # constants + live meta cross-check +npx tsx e2e/subscription-stream.ts # WebSocket allMids stream +npx tsx e2e/error-codes.ts # error code structure + edge cases +``` + +## Trading Scenarios (testnet wallet required) + +Set environment variables: + +```bash +export HL_E2E_PRIVATE_KEY=0x... # funded testnet wallet +export HL_TESTNET=true # default: true +``` + +Fund the wallet via [HyperLiquid Testnet Faucet](https://app.hyperliquid-testnet.xyz/drip). + +### Trading Lifecycle + +Opens a position, optionally sets TP/SL, then closes and verifies flat. + +```bash +# BTC long with TP/SL +npx tsx e2e/trading-lifecycle.ts --coin BTC --size 0.001 --leverage 5 --side long --tp-pct 5 --sl-pct 3 + +# ETH short with TP/SL +npx tsx e2e/trading-lifecycle.ts --coin ETH --size 0.01 --leverage 3 --side short --tp-pct 10 --sl-pct 5 + +# Minimal $10 position, no TP/SL +npx tsx e2e/trading-lifecycle.ts --coin BTC --size 0.0001 --leverage 10 --side long +``` + +### Limit Orders + +Places a limit order, verifies it's resting, then cancels. + +```bash +# Buy limit 2% below market +npx tsx e2e/limit-orders.ts --coin BTC --size 0.001 --offset-pct -2 --leverage 5 --side long + +# Sell limit 3% above market +npx tsx e2e/limit-orders.ts --coin ETH --size 0.01 --offset-pct 3 --leverage 3 --side short +``` + +## Parameters + +### Common + +| Flag | Default | Description | +| ------------ | ------- | ---------------------------- | +| `--coin` | BTC | Asset symbol | +| `--size` | 0.001 | Position size in asset units | +| `--leverage` | 5 | Leverage multiplier | +| `--side` | long | `long` or `short` | + +### Trading Lifecycle + +| Flag | Default | Description | +| ---------- | ------- | ----------------------------------- | +| `--tp-pct` | none | Take profit distance (% from entry) | +| `--sl-pct` | none | Stop loss distance (% from entry) | + +### Limit Orders + +| Flag | Default | Description | +| -------------- | ------- | ---------------------------------------- | +| `--offset-pct` | -2 | Price offset from mid (negative = below) | + +## Output + +Each script outputs structured JSON to stdout: + +```json +{ + "scenario": "trading-lifecycle-BTC-long", + "status": "pass", + "assertions": 12, + "failed": 0, + "durationMs": 5200, + "details": [...] +} +``` + +Exit code 0 = pass, non-zero = fail. Diagnostic logs go to stderr. + +## Farmslot Integration + +These scripts are wrapped by recipes in `projects/core-farm/fixtures/agentic/recipes/` and executed via the headless recipe runner (`validate-recipe.js`). The runner captures stdout as log artifacts per the Recipe Runner Protocol. diff --git a/packages/perps-controller/e2e/config.ts b/packages/perps-controller/e2e/config.ts new file mode 100644 index 0000000000..b9f21afa40 --- /dev/null +++ b/packages/perps-controller/e2e/config.ts @@ -0,0 +1,60 @@ +/* eslint-disable no-restricted-globals */ +/** + * E2E configuration — reads from environment variables. + * + * Required env vars for trading scenarios: + * HL_E2E_PRIVATE_KEY — hex private key for testnet wallet (0x-prefixed) + * + * Optional: + * HL_TESTNET — "true" to use testnet (default: true for safety) + */ + +export type E2EConfig = { + isTestnet: boolean; + privateKey: `0x${string}` | undefined; + minPositionUsd: number; +}; + +export function loadConfig(): E2EConfig { + const isTestnet = process.env.HL_TESTNET !== 'false'; + const rawKey = process.env.HL_E2E_PRIVATE_KEY; + let privateKey: `0x${string}` | undefined; + if (rawKey?.startsWith('0x')) { + privateKey = rawKey as `0x${string}`; + } else if (rawKey) { + privateKey = `0x${rawKey}`; + } + + return { + isTestnet, + privateKey, + minPositionUsd: 10, + }; +} + +export function requirePrivateKey( + config: E2EConfig, +): asserts config is E2EConfig & { privateKey: `0x${string}` } { + if (!config.privateKey) { + console.error( + '[e2e] HL_E2E_PRIVATE_KEY not set — trading scenarios require a funded testnet wallet', + ); + process.exit(1); + } +} + +export function parseArgs(argv: string[]): Record { + const result: Record = {}; + for (let idx = 0; idx < argv.length; idx++) { + if (argv[idx].startsWith('--')) { + const key = argv[idx].slice(2); + if (idx + 1 < argv.length && !argv[idx + 1].startsWith('--')) { + idx += 1; + result[key] = argv[idx]; + } else { + result[key] = 'true'; + } + } + } + return result; +} diff --git a/packages/perps-controller/e2e/limit-orders.ts b/packages/perps-controller/e2e/limit-orders.ts new file mode 100644 index 0000000000..13a9e1395f --- /dev/null +++ b/packages/perps-controller/e2e/limit-orders.ts @@ -0,0 +1,180 @@ +import { loadConfig, parseArgs } from './config'; +/* eslint-disable no-restricted-globals */ +/** + * E2E: Limit Orders (parameterized) + * + * Places a limit order, verifies it's resting, then cancels. Validates each step. + * + * Usage: + * npx tsx e2e/limit-orders.ts --coin BTC --size 0.001 --offset-pct -2 --leverage 5 --side long + * npx tsx e2e/limit-orders.ts --coin ETH --size 0.01 --offset-pct 3 --leverage 3 --side short + * + * Required env: HL_E2E_PRIVATE_KEY (testnet wallet) + * Optional env: HL_TESTNET=true (default) + */ +import { E2ERunner } from './helpers'; +import { createTradingClients } from './trading-client'; + +type Params = { + coin: string; + size: number; + offsetPct: number; + leverage: number; + side: 'long' | 'short'; +}; + +function getParams(): Params { + const args = parseArgs(process.argv.slice(2)); + return { + coin: args.coin ?? 'BTC', + size: parseFloat(args.size ?? '0.001'), + offsetPct: parseFloat(args['offset-pct'] ?? '-2'), + leverage: parseInt(args.leverage ?? '5', 10), + side: (args.side ?? 'long') as 'long' | 'short', + }; +} + +async function main(): Promise { + const params = getParams(); + const config = loadConfig(); + const runner = new E2ERunner(`limit-orders-${params.coin}-${params.side}`); + const { exchange, info, address } = createTradingClients(config); + + console.error( + `[e2e] Limit order: ${params.side} ${params.size} ${params.coin} @ offset ${params.offsetPct}%`, + ); + console.error(`[e2e] Wallet: ${address}, Testnet: ${config.isTestnet}`); + + // 1. Get current mid price and compute limit price + const mids = await info.allMids(); + const midPrice = parseFloat(mids[params.coin] ?? '0'); + runner.assertGt(`${params.coin} mid price > 0`, midPrice, 0); + + const isBuy = params.side === 'long'; + const limitPrice = midPrice * (1 + params.offsetPct / 100); + console.error( + `[e2e] Mid: $${midPrice}, Limit: $${limitPrice.toFixed(2)} (${params.offsetPct}% offset)`, + ); + + // 2. Get asset index and set leverage + const meta = await info.meta(); + const assetIndex = meta.universe.findIndex( + (market) => market.name === params.coin, + ); + runner.assert(`${params.coin} found in meta`, assetIndex >= 0); + + await exchange.updateLeverage({ + asset: assetIndex, + isCross: true, + leverage: params.leverage, + }); + runner.assert('leverage set', true); + + // 3. Place limit order (should NOT fill immediately due to offset) + console.error('[e2e] Placing limit order...'); + const orderResult = await exchange.order({ + orders: [ + { + a: assetIndex, + b: isBuy, + p: limitPrice.toFixed(1), + s: params.size.toString(), + r: false, + t: { limit: { tif: 'Gtc' } }, + }, + ], + grouping: 'na', + }); + runner.assert( + 'limit order placed', + orderResult.status === 'ok', + `status: ${orderResult.status}`, + ); + + let orderId: number | null = null; + if (orderResult.status === 'ok' && orderResult.response.type === 'order') { + const { statuses } = orderResult.response.data; + const resting = statuses.find((st: unknown) => { + const statusObj = st as Record; + return Object.hasOwn(statusObj, 'resting'); + }); + runner.assert( + 'order is resting (not immediately filled)', + resting !== undefined, + `statuses: ${JSON.stringify(statuses)}`, + ); + if (resting) { + orderId = (resting as { resting: { oid: number } }).resting.oid; + } + } + + // 4. Verify order appears in open orders + console.error('[e2e] Verifying order in open orders...'); + const openOrders = await info.frontendOpenOrders({ user: address }); + const ourOrder = openOrders.find( + (order) => order.coin === params.coin && order.oid === orderId, + ); + runner.assert( + 'order found in open orders', + ourOrder !== undefined, + `oid=${orderId}, orders: ${openOrders.length}`, + ); + + if (ourOrder) { + runner.assert('order coin matches', ourOrder.coin === params.coin); + runner.assert( + 'order side matches', + ourOrder.side === (isBuy ? 'B' : 'A'), + `expected ${isBuy ? 'B' : 'A'}, got ${ourOrder.side}`, + ); + } + + // 5. Cancel the order + if (orderId !== null) { + console.error(`[e2e] Canceling order ${orderId}...`); + const cancelResult = await exchange.cancel({ + cancels: [{ a: assetIndex, o: orderId }], + }); + runner.assert( + 'cancel succeeded', + cancelResult.status === 'ok', + `status: ${cancelResult.status}`, + ); + } + + // 6. Verify order removed + console.error('[e2e] Verifying order removed...'); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + const finalOrders = await info.frontendOpenOrders({ user: address }); + const stillExists = finalOrders.find((order) => order.oid === orderId); + runner.assert('order no longer in open orders', stillExists === undefined); + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((caughtError) => { + console.error(caughtError); + console.log( + JSON.stringify({ + scenario: 'limit-orders', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); + process.exit(1); +}); diff --git a/packages/perps-controller/e2e/trading-client.ts b/packages/perps-controller/e2e/trading-client.ts new file mode 100644 index 0000000000..3b2bceca22 --- /dev/null +++ b/packages/perps-controller/e2e/trading-client.ts @@ -0,0 +1,30 @@ +import { ExchangeClient, HttpTransport, InfoClient } from '@nktkas/hyperliquid'; +/** + * Creates an ExchangeClient + InfoClient pair for trading e2e tests. + * Uses viem's privateKeyToAccount for EIP-712 signing. + */ +import { privateKeyToAccount } from 'viem/accounts'; + +import type { E2EConfig } from './config'; +import { requirePrivateKey } from './config'; + +export type TradingClients = { + exchange: ExchangeClient; + info: InfoClient; + address: `0x${string}`; +}; + +export function createTradingClients(config: E2EConfig): TradingClients { + requirePrivateKey(config); + + const wallet = privateKeyToAccount(config.privateKey); + const transport = new HttpTransport({ + isTestnet: config.isTestnet, + timeout: 30000, + }); + + const exchange = new ExchangeClient({ wallet, transport }); + const info = new InfoClient({ transport }); + + return { exchange, info, address: wallet.address }; +} diff --git a/packages/perps-controller/e2e/trading-lifecycle.ts b/packages/perps-controller/e2e/trading-lifecycle.ts new file mode 100644 index 0000000000..84e1ab5370 --- /dev/null +++ b/packages/perps-controller/e2e/trading-lifecycle.ts @@ -0,0 +1,289 @@ +import { loadConfig, parseArgs } from './config'; +/* eslint-disable no-restricted-globals */ +/** + * E2E: Trading Lifecycle (parameterized) + * + * Opens a position, optionally sets TP/SL, then closes. Validates each step. + * + * Usage: + * npx tsx e2e/trading-lifecycle.ts --coin BTC --size 0.001 --leverage 5 --side long --tp-pct 5 --sl-pct 3 + * npx tsx e2e/trading-lifecycle.ts --coin ETH --size 0.01 --leverage 3 --side short --tp-pct 10 --sl-pct 5 + * + * Required env: HL_E2E_PRIVATE_KEY (testnet wallet) + * Optional env: HL_TESTNET=true (default) + */ +import { E2ERunner } from './helpers'; +import { createTradingClients } from './trading-client'; + +type Params = { + coin: string; + size: number; + leverage: number; + side: 'long' | 'short'; + tpPct: number | null; + slPct: number | null; +}; + +function getParams(): Params { + const args = parseArgs(process.argv.slice(2)); + return { + coin: args.coin ?? 'BTC', + size: parseFloat(args.size ?? '0.001'), + leverage: parseInt(args.leverage ?? '5', 10), + side: (args.side ?? 'long') as 'long' | 'short', + tpPct: args['tp-pct'] ? parseFloat(args['tp-pct']) : null, + slPct: args['sl-pct'] ? parseFloat(args['sl-pct']) : null, + }; +} + +async function main(): Promise { + const params = getParams(); + const config = loadConfig(); + const runner = new E2ERunner( + `trading-lifecycle-${params.coin}-${params.side}`, + ); + const { exchange, info, address } = createTradingClients(config); + + console.error( + `[e2e] Trading lifecycle: ${params.side} ${params.size} ${params.coin} @ ${params.leverage}x`, + ); + console.error( + `[e2e] TP: ${params.tpPct ?? 'none'}%, SL: ${params.slPct ?? 'none'}%`, + ); + console.error(`[e2e] Wallet: ${address}, Testnet: ${config.isTestnet}`); + + // 1. Get current mid price + const mids = await info.allMids(); + const midPrice = parseFloat(mids[params.coin] ?? '0'); + runner.assertGt(`${params.coin} mid price > 0`, midPrice, 0); + console.error(`[e2e] ${params.coin} mid: $${midPrice}`); + + // 2. Set leverage + console.error(`[e2e] Setting leverage to ${params.leverage}x...`); + const meta = await info.meta(); + const assetIndex = meta.universe.findIndex( + (market) => market.name === params.coin, + ); + runner.assert(`${params.coin} found in meta`, assetIndex >= 0, `not found`); + + await exchange.updateLeverage({ + asset: assetIndex, + isCross: true, + leverage: params.leverage, + }); + runner.assert('leverage set', true); + + // 3. Place market order + const isBuy = params.side === 'long'; + const slippagePrice = isBuy ? midPrice * 1.03 : midPrice * 0.97; + + console.error( + `[e2e] Placing ${params.side} market order: ${params.size} ${params.coin}...`, + ); + const orderResult = await exchange.order({ + orders: [ + { + a: assetIndex, + b: isBuy, + p: slippagePrice.toFixed(1), + s: params.size.toString(), + r: false, + t: { limit: { tif: 'Ioc' } }, + }, + ], + grouping: 'na', + }); + runner.assert( + 'order placed', + orderResult.status === 'ok', + `status: ${orderResult.status}`, + ); + + if (orderResult.status === 'ok') { + const { response } = orderResult; + runner.assert( + 'order response type', + response.type === 'order', + `got ${response.type}`, + ); + if (response.type === 'order') { + const { statuses } = response.data; + const filled = statuses.some((st: unknown) => { + const statusObj = st as Record; + return Object.hasOwn(statusObj, 'filled'); + }); + runner.assert( + 'order filled', + filled, + `statuses: ${JSON.stringify(statuses)}`, + ); + } + } + + // 4. Verify position exists + console.error('[e2e] Verifying position...'); + const state = await info.clearinghouseState({ user: address }); + const position = state.assetPositions.find((pos) => { + const item = pos.position; + return item.coin === params.coin && parseFloat(item.szi) !== 0; + }); + runner.assert( + 'position exists', + position !== undefined, + 'no open position found', + ); + + if (position) { + const posSize = parseFloat(position.position.szi); + const expectedSign = isBuy ? 1 : -1; + runner.assert( + 'position side correct', + Math.sign(posSize) === expectedSign, + `expected ${params.side}, got size=${posSize}`, + ); + } + + // 5. Set TP/SL if requested + if (params.tpPct !== null || params.slPct !== null) { + console.error( + `[e2e] Setting TP/SL: tp=${params.tpPct}% sl=${params.slPct}%...`, + ); + const entryPrice = position + ? parseFloat(position.position.entryPx) + : midPrice; + + let tpPrice: number | null = null; + if (params.tpPct !== null) { + tpPrice = isBuy + ? entryPrice * (1 + params.tpPct / 100) + : entryPrice * (1 - params.tpPct / 100); + } + let slPrice: number | null = null; + if (params.slPct !== null) { + slPrice = isBuy + ? entryPrice * (1 - params.slPct / 100) + : entryPrice * (1 + params.slPct / 100); + } + + const tpSlOrders: { + a: number; + b: boolean; + p: string; + s: string; + r: boolean; + t: { trigger: { triggerPx: string; isMarket: boolean; tpsl: string } }; + }[] = []; + + if (tpPrice !== null) { + tpSlOrders.push({ + a: assetIndex, + b: !isBuy, + p: tpPrice.toFixed(1), + s: params.size.toString(), + r: true, + t: { + trigger: { + triggerPx: tpPrice.toFixed(1), + isMarket: true, + tpsl: 'tp', + }, + }, + }); + } + if (slPrice !== null) { + tpSlOrders.push({ + a: assetIndex, + b: !isBuy, + p: slPrice.toFixed(1), + s: params.size.toString(), + r: true, + t: { + trigger: { + triggerPx: slPrice.toFixed(1), + isMarket: true, + tpsl: 'sl', + }, + }, + }); + } + + if (tpSlOrders.length > 0) { + const tpslResult = await exchange.order({ + orders: tpSlOrders, + grouping: 'na', + }); + runner.assert( + 'tp/sl orders placed', + tpslResult.status === 'ok', + `status: ${tpslResult.status}`, + ); + } + } + + // 6. Close position + console.error('[e2e] Closing position...'); + const closePrice = isBuy ? midPrice * 0.97 : midPrice * 1.03; + const closeResult = await exchange.order({ + orders: [ + { + a: assetIndex, + b: !isBuy, + p: closePrice.toFixed(1), + s: params.size.toString(), + r: true, + t: { limit: { tif: 'Ioc' } }, + }, + ], + grouping: 'na', + }); + runner.assert( + 'close order placed', + closeResult.status === 'ok', + `status: ${closeResult.status}`, + ); + + // 7. Verify position closed + console.error('[e2e] Verifying position closed...'); + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + const finalState = await info.clearinghouseState({ user: address }); + const remainingPosition = finalState.assetPositions.find((pos) => { + const item = pos.position; + return item.coin === params.coin && parseFloat(item.szi) !== 0; + }); + runner.assert( + 'position closed', + remainingPosition === undefined, + remainingPosition + ? `remaining size: ${remainingPosition.position.szi}` + : undefined, + ); + + const result = runner.finish(); + process.exit(result.status === 'pass' ? 0 : 1); +} + +main().catch((caughtError) => { + console.error(caughtError); + console.log( + JSON.stringify({ + scenario: 'trading-lifecycle', + status: 'fail', + assertions: 0, + failed: 1, + durationMs: 0, + details: [ + { + name: 'unhandled', + ok: false, + error: + caughtError instanceof Error + ? caughtError.message + : String(caughtError), + }, + ], + }), + ); + process.exit(1); +}); diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 47c864cb4f..256a1f7449 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -125,7 +125,8 @@ "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~5.3.3" + "typescript": "~5.3.3", + "viem": "^2.21.0" }, "optionalDependencies": { "@myx-trade/sdk": "^0.1.265" diff --git a/yarn.lock b/yarn.lock index d0a699c938..bbb9b2f1c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5009,6 +5009,7 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" + viem: "npm:^2.21.0" dependenciesMeta: "@myx-trade/sdk": optional: true @@ -12945,6 +12946,27 @@ __metadata: languageName: node linkType: hard +"ox@npm:0.14.22": + version: 0.14.22 + resolution: "ox@npm:0.14.22" + dependencies: + "@adraffy/ens-normalize": "npm:^1.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/bip32": "npm:^1.7.0" + "@scure/bip39": "npm:^1.6.0" + abitype: "npm:^1.2.3" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/22c0dbf6bba25bcec23b478a80ee604aed0e76cd6f992d7b7b15466fa2e35ba1251d560349b2d2f88e8a660b020992613a55ed1b39a074b56301f7ac1ce88926 + languageName: node + linkType: hard + "oxfmt@npm:^0.44.0": version: 0.44.0 resolution: "oxfmt@npm:0.44.0" @@ -15091,6 +15113,27 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.21.0": + version: 2.50.4 + resolution: "viem@npm:2.50.4" + dependencies: + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:1.8.0" + "@scure/bip32": "npm:1.7.0" + "@scure/bip39": "npm:1.6.0" + abitype: "npm:1.2.3" + isows: "npm:1.0.7" + ox: "npm:0.14.22" + ws: "npm:8.20.1" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/1ceaae456fef86d2c86c59c7fadbca79dfeacffb55c0c298bda7c32fdcffb821fdbb62532a0e9bb556fc80668c13bf53bde2045fb1a8be418d35405f2c080813 + languageName: node + linkType: hard + "viem@npm:^2.36.0": version: 2.48.4 resolution: "viem@npm:2.48.4" @@ -15401,6 +15444,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.20.1": + version: 8.20.1 + resolution: "ws@npm:8.20.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/8c4d2b06dc65381b6bfab1f2e584275dabd30a99a5ce058b4dc76f3d03fad1921cef3a21d8f53127d30a808cfd1864aa2fe6890a5d43359f682457315baec873 + languageName: node + linkType: hard + "ws@npm:^8.11.0, ws@npm:^8.18.3": version: 8.19.0 resolution: "ws@npm:8.19.0" From 241d242d5829e4f1568d25a91932d43538988cc6 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 08:26:42 +0800 Subject: [PATCH 07/10] chore: dedupe yarn.lock after viem addition --- yarn.lock | 76 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/yarn.lock b/yarn.lock index bbb9b2f1c0..77c2458ed2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12925,27 +12925,6 @@ __metadata: languageName: node linkType: hard -"ox@npm:0.14.20": - version: 0.14.20 - resolution: "ox@npm:0.14.20" - dependencies: - "@adraffy/ens-normalize": "npm:^1.11.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:1.9.1" - "@noble/hashes": "npm:^1.8.0" - "@scure/bip32": "npm:^1.7.0" - "@scure/bip39": "npm:^1.6.0" - abitype: "npm:^1.2.3" - eventemitter3: "npm:5.0.1" - peerDependencies: - typescript: ">=5.4.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed - languageName: node - linkType: hard - "ox@npm:0.14.22": version: 0.14.22 resolution: "ox@npm:0.14.22" @@ -15113,7 +15092,7 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.21.0": +"viem@npm:^2.21.0, viem@npm:^2.36.0": version: 2.50.4 resolution: "viem@npm:2.50.4" dependencies: @@ -15134,27 +15113,6 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.36.0": - version: 2.48.4 - resolution: "viem@npm:2.48.4" - dependencies: - "@noble/curves": "npm:1.9.1" - "@noble/hashes": "npm:1.8.0" - "@scure/bip32": "npm:1.7.0" - "@scure/bip39": "npm:1.6.0" - abitype: "npm:1.2.3" - isows: "npm:1.0.7" - ox: "npm:0.14.20" - ws: "npm:8.18.3" - peerDependencies: - typescript: ">=5.0.4" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/79ab1c8941013e1b4d12ef0bd7fcca6108cfc078b669cc02ae5a08c94d4e3b6de182071cfb40fb4e33ddc40b3aa997f3ebb50d269c85512cefcefdce49b193a0 - languageName: node - linkType: hard - "vscode-oniguruma@npm:^1.7.0": version: 1.7.0 resolution: "vscode-oniguruma@npm:1.7.0" @@ -15429,22 +15387,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:8.18.3": - version: 8.18.3 - resolution: "ws@npm:8.18.3" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 - languageName: node - linkType: hard - -"ws@npm:8.20.1": +"ws@npm:8.20.1, ws@npm:^8.11.0, ws@npm:^8.18.3": version: 8.20.1 resolution: "ws@npm:8.20.1" peerDependencies: @@ -15459,21 +15402,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.18.3": - version: 8.19.0 - resolution: "ws@npm:8.19.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b - languageName: node - linkType: hard - "xhr2@npm:0.2.1": version: 0.2.1 resolution: "xhr2@npm:0.2.1" From 9e4489df50682223278f80f3324af45bf05f0703 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 08:33:49 +0800 Subject: [PATCH 08/10] docs(perps): remove farmslot reference from e2e README --- packages/perps-controller/e2e/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/perps-controller/e2e/README.md b/packages/perps-controller/e2e/README.md index ce364f3c02..3cf58f3cd5 100644 --- a/packages/perps-controller/e2e/README.md +++ b/packages/perps-controller/e2e/README.md @@ -89,8 +89,4 @@ Each script outputs structured JSON to stdout: } ``` -Exit code 0 = pass, non-zero = fail. Diagnostic logs go to stderr. - -## Farmslot Integration - -These scripts are wrapped by recipes in `projects/core-farm/fixtures/agentic/recipes/` and executed via the headless recipe runner (`validate-recipe.js`). The runner captures stdout as log artifacts per the Recipe Runner Protocol. +Exit code 0 = pass, non-zero = fail. Diagnostic logs go to stderr. \ No newline at end of file From 195475635eae0f73fdd8c77bd83fe5eaa8fc572b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 26 May 2026 08:36:11 +0800 Subject: [PATCH 09/10] style(perps): format README --- packages/perps-controller/e2e/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/e2e/README.md b/packages/perps-controller/e2e/README.md index 3cf58f3cd5..9c298ae899 100644 --- a/packages/perps-controller/e2e/README.md +++ b/packages/perps-controller/e2e/README.md @@ -89,4 +89,4 @@ Each script outputs structured JSON to stdout: } ``` -Exit code 0 = pass, non-zero = fail. Diagnostic logs go to stderr. \ No newline at end of file +Exit code 0 = pass, non-zero = fail. Diagnostic logs go to stderr. From 67e4e023fca82ea97287a2e4519197c19f4f089a Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 27 May 2026 11:31:05 +0800 Subject: [PATCH 10/10] fix(perps): use correct grouping for TP/SL orders and refresh price before close - Change TP/SL order grouping from 'na' to 'positionTpsl' so HyperLiquid properly associates trigger orders with the open position. - Re-fetch mid price immediately before computing close order price to avoid stale price from script start causing IOC close to not fill. --- packages/perps-controller/e2e/trading-lifecycle.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/e2e/trading-lifecycle.ts b/packages/perps-controller/e2e/trading-lifecycle.ts index 84e1ab5370..45302f3032 100644 --- a/packages/perps-controller/e2e/trading-lifecycle.ts +++ b/packages/perps-controller/e2e/trading-lifecycle.ts @@ -210,7 +210,7 @@ async function main(): Promise { if (tpSlOrders.length > 0) { const tpslResult = await exchange.order({ orders: tpSlOrders, - grouping: 'na', + grouping: 'positionTpsl', }); runner.assert( 'tp/sl orders placed', @@ -222,7 +222,9 @@ async function main(): Promise { // 6. Close position console.error('[e2e] Closing position...'); - const closePrice = isBuy ? midPrice * 0.97 : midPrice * 1.03; + const closeMids = await info.allMids(); + const closeMidPrice = parseFloat(closeMids[params.coin] ?? '0'); + const closePrice = isBuy ? closeMidPrice * 0.97 : closeMidPrice * 1.03; const closeResult = await exchange.order({ orders: [ {