Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/perps-controller/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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.
91 changes: 91 additions & 0 deletions packages/perps-controller/e2e/account-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* eslint-disable no-restricted-globals */
/**
* 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<void> {
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',
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.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((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);
});
60 changes: 60 additions & 0 deletions packages/perps-controller/e2e/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const result: Record<string, string> = {};
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;
}
107 changes: 107 additions & 0 deletions packages/perps-controller/e2e/error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
PERPS_CONSTANTS,
MARGIN_ADJUSTMENT_CONFIG,
} from '../src/constants/perpsConfig';
/* eslint-disable no-restricted-globals */
/**
* 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 { createClient, E2ERunner } from './helpers';

async function main(): Promise<void> {
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',
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',
);
}

// 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 {
runner.assert('zero address produces error or empty', true);
}

const result = runner.finish();
process.exit(result.status === 'pass' ? 0 : 1);
}

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);
});
Loading
Loading