This commit is contained in:
2026-01-11 12:38:39 +05:30
commit 7c6219e8ce
9 changed files with 3206 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Database Configuration
DB_HOST=vps.playpoolstudios.com
DB_PORT=3306
DB_USER=root
DB_PASSWORD=3kcu087vIDyx)0gg
DB_NAME=hyperliquid_tracker
# Server Configuration
PORT=3000

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
*.log
.env
.env.local
.DS_Store
wallets.json
wallets.json.backup

126
README.md Normal file
View File

@@ -0,0 +1,126 @@
# Hyperliquid Wallet PnL Tracker
Automatically track wallet addresses from Hyperliquid trades and sort them by highest PnL.
## Features
- 🔄 **Automatic Trade Tracking**: Real-time monitoring of trades on Hyperliquid
- 💾 **MySQL Database Storage**: Persistent storage of tracked wallets
- 📊 **PnL Sorting**: Automatically sorts wallets by highest profit/loss
- 🚀 **REST API**: Query tracked wallets and their PnL data
- 📈 **Historical Tracking**: Optional PnL snapshot history
## Prerequisites
- Node.js >= 20.16.0
- MySQL 5.7+ or MariaDB 10.3+
- npm or yarn
## Installation
1. Clone the repository and install dependencies:
```bash
npm install
```
2. Set up MySQL database:
```bash
# Create database
mysql -u root -p < database/schema.sql
# Or manually:
# CREATE DATABASE hyperliquid_tracker;
# USE hyperliquid_tracker;
# source database/schema.sql;
```
3. Configure environment variables:
```bash
cp .env.example .env
# Edit .env with your database credentials
```
## Environment Variables
Create a `.env` file in the root directory:
```env
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=hyperliquid_tracker
PORT=3000
```
## Usage
### Development
```bash
npm run dev
```
### Production
```bash
npm run build
npm start
```
## API Endpoints
### Get Tracked Wallets Sorted by PnL
```bash
GET /api/wallets/tracked
```
Returns all automatically tracked wallets sorted by PnL (highest first).
### Get List of Tracked Wallets
```bash
GET /api/wallets/list
```
Returns list of all tracked wallet addresses.
### Manually Add Wallets
```bash
POST /api/wallets/track
Content-Type: application/json
{
"wallets": ["0x123...", "0x456..."]
}
```
### Query Specific Wallets PnL
```bash
GET /api/wallets/pnl?wallets=0x123...,0x456...
```
Or POST:
```bash
POST /api/wallets/pnl
Content-Type: application/json
{
"wallets": ["0x123...", "0x456..."]
}
```
## Database Schema
The application creates two main tables:
- **wallets**: Stores tracked wallet addresses with metadata
- **wallet_pnl_snapshots**: Optional historical PnL snapshots
See `database/schema.sql` for full schema details.
## Migration from JSON
If you have an existing `wallets.json` file, the application will automatically migrate wallets to the database on first run. The original file will be backed up to `wallets.json.backup`.
## License
ISC

56
database/schema.sql Normal file
View File

@@ -0,0 +1,56 @@
-- Hyperliquid Wallet Tracker Database Schema
-- Create database (uncomment if needed)
CREATE DATABASE IF NOT EXISTS hyperliquid_tracker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyperliquid_tracker;
-- Wallets table to store tracked wallet addresses
CREATE TABLE IF NOT EXISTS wallets (
id INT AUTO_INCREMENT PRIMARY KEY,
address VARCHAR(66) NOT NULL UNIQUE COMMENT 'Wallet address (normalized to lowercase)',
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'When this wallet was first tracked',
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last time this wallet was seen in trades',
trade_count INT DEFAULT 1 COMMENT 'Number of times this wallet has been seen in trades',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_address (address),
INDEX idx_last_seen (last_seen_at),
INDEX idx_trade_count (trade_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracked wallet addresses from Hyperliquid trades';
-- Wallet PnL snapshots table (optional - for historical tracking)
CREATE TABLE IF NOT EXISTS wallet_pnl_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
wallet_id INT NOT NULL,
wallet_address VARCHAR(66) NOT NULL,
pnl DECIMAL(30, 8) COMMENT 'Profit and Loss value',
account_value DECIMAL(30, 8) COMMENT 'Total account value',
unrealized_pnl DECIMAL(30, 8) COMMENT 'Unrealized PnL',
snapshot_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE,
INDEX idx_wallet_id (wallet_id),
INDEX idx_wallet_address (wallet_address),
INDEX idx_snapshot_at (snapshot_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Historical PnL snapshots for tracked wallets';
-- View for wallets with latest PnL
CREATE OR REPLACE VIEW wallets_with_latest_pnl AS
SELECT
w.id,
w.address,
w.first_seen_at,
w.last_seen_at,
w.trade_count,
wps.pnl,
wps.account_value,
wps.unrealized_pnl,
wps.snapshot_at as last_pnl_snapshot
FROM wallets w
LEFT JOIN wallet_pnl_snapshots wps ON w.id = wps.wallet_id
LEFT JOIN (
SELECT wallet_id, MAX(snapshot_at) as max_snapshot
FROM wallet_pnl_snapshots
GROUP BY wallet_id
) latest ON w.id = latest.wallet_id AND wps.snapshot_at = latest.max_snapshot;

1973
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "hypr_tracker",
"version": "1.0.0",
"description": "TypeScript Express Hello World",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"watch": "tsx watch src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@nktkas/hyperliquid": "^0.30.2",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"mysql2": "^3.16.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/ws": "^8.18.1",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.3.3"
}
}

379
src/database.ts Normal file
View File

@@ -0,0 +1,379 @@
// Load environment variables first
import * as dotenv from 'dotenv';
import * as path from 'path';
// Load .env.local first (higher priority), then .env
const envLocalPath = path.join(process.cwd(), '.env.local');
const envPath = path.join(process.cwd(), '.env');
dotenv.config({ path: envLocalPath });
dotenv.config({ path: envPath }); // .env.local values will override .env
import mysql from 'mysql2/promise';
// Database configuration
const dbConfig: mysql.PoolOptions = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hyperliquid_tracker',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// Connection timeout settings
connectTimeout: 60000, // 60 seconds
// Enable keep-alive to prevent connection from being closed
enableKeepAlive: true,
keepAliveInitialDelay: 0,
// For remote connections, often need SSL or allow insecure connections
...(process.env.DB_SSL === 'true' ? {
ssl: {
rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false'
}
} : {}),
// Additional connection options
timezone: 'Z', // Use UTC
charset: 'utf8mb4',
// Multiple statements can help with some connection issues
multipleStatements: false,
// Flags for better compatibility
flags: [
'-FOUND_ROWS',
'-IGNORE_SPACE',
'-LONG_PASSWORD',
'-LONG_FLAG',
'-TRANSACTIONS',
'-PROTOCOL_41',
'-SECURE_CONNECTION',
],
};
console.log('[DB] Database config loaded:', {
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database,
user: dbConfig.user,
passwordSet: !!dbConfig.password,
ssl: dbConfig.ssl ? 'enabled' : 'disabled',
});
// Create connection pool
let pool: mysql.Pool | null = null;
/**
* Initialize database connection pool
*/
export function initDatabase(): mysql.Pool {
if (!pool) {
pool = mysql.createPool(dbConfig);
console.log('[DB] ✅ Database connection pool created');
console.log('[DB] Config:', {
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database,
user: dbConfig.user,
});
// Handle connection events
pool.on('connection', (connection: any) => {
console.log('[DB] 🔌 New connection established');
connection.on('error', (err: any) => {
console.error('[DB] ❌ Connection error:', err);
if (err.code === 'PROTOCOL_CONNECTION_LOST') {
console.log('[DB] ⚠️ Connection lost, pool will attempt to reconnect');
}
});
});
}
return pool;
}
/**
* Get database connection pool
*/
export function getPool(): mysql.Pool {
if (!pool) {
return initDatabase();
}
return pool;
}
/**
* Test database connection with retry logic
*/
export async function testConnection(maxRetries: number = 3): Promise<boolean> {
// First, try a direct connection (not pool) to diagnose issues
console.log('[DB] 🔍 Testing direct connection first...');
try {
// Create a direct connection for testing (not using pool)
const directConnection = await mysql.createConnection({
...dbConfig,
// Reduce timeout for faster failure detection
connectTimeout: 10000,
});
console.log('[DB] 🔌 Direct connection established');
// Immediately test the connection
const [rows] = await directConnection.query('SELECT 1 as test, DATABASE() as current_db, USER() as current_user, VERSION() as version');
console.log('[DB] ✅ Test query successful:', rows);
await directConnection.ping();
console.log('[DB] ✅ Ping successful');
await directConnection.end();
console.log('[DB] ✅ Direct connection test successful - pool should work now');
// If direct connection works, test pool connection
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[DB] 🔄 Testing pool connection (${attempt}/${maxRetries})...`);
const connectionPool = getPool();
// Try to get a connection from pool
const connection = await connectionPool.getConnection();
console.log('[DB] 🔌 Connection obtained from pool');
// Test with a simple query immediately
const [poolRows] = await connection.query('SELECT 1 as test');
console.log('[DB] ✅ Pool query successful:', poolRows);
await connection.ping();
console.log('[DB] ✅ Pool ping successful');
connection.release();
console.log('[DB] ✅ Connection released back to pool');
console.log('[DB] ✅ Database connection test successful');
return true;
} catch (poolError: any) {
console.error(`[DB] ❌ Pool connection failed (attempt ${attempt}/${maxRetries}):`, poolError.message);
if (attempt < maxRetries) {
const delay = attempt * 2000;
console.log(`[DB] ⏳ Retrying pool connection in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
return false;
} catch (error: any) {
const errorDetails = {
message: error.message,
code: error.code,
errno: error.errno,
sqlState: error.sqlState,
sqlMessage: error.sqlMessage,
fatal: error.fatal,
};
console.error('[DB] ❌ Direct connection failed');
console.error('[DB] Error details:', JSON.stringify(errorDetails, null, 2));
// Try to connect without database first to check if it's a database/user issue
if (error.code === 'PROTOCOL_CONNECTION_LOST' || error.code === 'ECONNREFUSED') {
try {
console.log('[DB] 🔍 Attempting connection without database to diagnose issue...');
const testConfig: any = {
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.user,
password: dbConfig.password,
connectTimeout: 10000,
};
const testConnection = await mysql.createConnection(testConfig);
const [testRows] = await testConnection.query('SELECT 1 as test, USER() as current_user');
console.log('[DB] ✅ Connection without database works:', testRows);
console.log('[DB] 💡 Issue may be with database name or permissions on that database');
// Try to create database if it doesn't exist
try {
await testConnection.query('CREATE DATABASE IF NOT EXISTS ??', [dbConfig.database]);
console.log(`[DB] ✅ Verified/created database: ${dbConfig.database}`);
} catch (dbError: any) {
console.error(`[DB] ❌ Could not create database:`, dbError.message);
}
await testConnection.end();
} catch (testError: any) {
console.error('[DB] ❌ Connection without database also failed:', testError.message);
console.error('[DB] 💡 This suggests a fundamental connection/auth issue');
console.error('[DB] 💡 Troubleshooting steps:');
console.error(' 1. Verify MySQL user and password are correct');
console.error(' 2. Check if user can connect from your IP:');
console.error(' SELECT user, host FROM mysql.user WHERE user = ?;', [dbConfig.user]);
console.error(' 3. Grant permissions:');
console.error(` GRANT ALL ON ${dbConfig.database}.* TO '${dbConfig.user}'@'%';`);
console.error(' FLUSH PRIVILEGES;');
console.error(' 4. Check MySQL bind-address in my.cnf (should be 0.0.0.0 or your server IP)');
}
}
return false;
}
}
/**
* Load all tracked wallets from database
*/
export async function loadWalletsFromDB(): Promise<string[]> {
try {
const connectionPool = getPool();
const [rows] = await connectionPool.execute<mysql.RowDataPacket[]>(
'SELECT address FROM wallets ORDER BY last_seen_at DESC'
);
return rows.map(row => row.address);
} catch (error) {
console.error('[DB] Error loading wallets:', error);
return [];
}
}
/**
* Add or update wallet in database
*/
export async function addWalletToDB(wallet: string): Promise<boolean> {
try {
const normalizedWallet = wallet.toLowerCase();
const connectionPool = getPool();
// Use INSERT ... ON DUPLICATE KEY UPDATE to handle existing wallets
await connectionPool.execute(
`INSERT INTO wallets (address, last_seen_at, trade_count)
VALUES (?, NOW(), 1)
ON DUPLICATE KEY UPDATE
last_seen_at = NOW(),
trade_count = trade_count + 1`
, [normalizedWallet]);
return true;
} catch (error) {
console.error('[DB] Error adding wallet:', error);
return false;
}
}
/**
* Get wallet by address
*/
export async function getWalletFromDB(wallet: string): Promise<any | null> {
try {
const normalizedWallet = wallet.toLowerCase();
const connectionPool = getPool();
const [rows] = await connectionPool.execute<mysql.RowDataPacket[]>(
'SELECT * FROM wallets WHERE address = ?',
[normalizedWallet]
);
return rows.length > 0 ? rows[0] : null;
} catch (error) {
console.error('[DB] Error getting wallet:', error);
return null;
}
}
/**
* Get all wallets with optional limit
*/
export async function getAllWalletsFromDB(limit?: number): Promise<any[]> {
try {
const connectionPool = getPool();
const query = limit
? 'SELECT * FROM wallets ORDER BY last_seen_at DESC LIMIT ?'
: 'SELECT * FROM wallets ORDER BY last_seen_at DESC';
const [rows] = limit
? await connectionPool.execute<mysql.RowDataPacket[]>(query, [limit])
: await connectionPool.execute<mysql.RowDataPacket[]>(query);
return rows as any[];
} catch (error) {
console.error('[DB] Error getting all wallets:', error);
return [];
}
}
/**
* Save PnL snapshot for a wallet
*/
export async function savePnLSnapshot(
walletAddress: string,
pnl: string,
accountValue: string,
unrealizedPnl: string
): Promise<boolean> {
try {
const normalizedWallet = walletAddress.toLowerCase();
const connectionPool = getPool();
// Get wallet ID
const wallet = await getWalletFromDB(normalizedWallet);
if (!wallet) {
console.error(`[DB] Wallet not found for PnL snapshot: ${normalizedWallet}`);
return false;
}
await connectionPool.execute(
`INSERT INTO wallet_pnl_snapshots (wallet_id, wallet_address, pnl, account_value, unrealized_pnl)
VALUES (?, ?, ?, ?, ?)`,
[wallet.id, normalizedWallet, pnl, accountValue, unrealizedPnl]
);
return true;
} catch (error) {
console.error('[DB] Error saving PnL snapshot:', error);
return false;
}
}
/**
* Get wallets with latest PnL (using the view)
*/
export async function getWalletsWithLatestPnL(): Promise<any[]> {
try {
const connectionPool = getPool();
const [rows] = await connectionPool.execute<mysql.RowDataPacket[]>(
'SELECT * FROM wallets_with_latest_pnl ORDER BY COALESCE(pnl, 0) DESC'
);
return rows as any[];
} catch (error) {
console.error('[DB] Error getting wallets with PnL:', error);
return [];
}
}
/**
* Migrate wallets from JSON file to database
*/
export async function migrateWalletsFromJSON(wallets: string[]): Promise<number> {
let migrated = 0;
try {
const connectionPool = getPool();
for (const wallet of wallets) {
const normalizedWallet = wallet.toLowerCase();
try {
await connectionPool.execute(
`INSERT INTO wallets (address, first_seen_at, last_seen_at)
VALUES (?, NOW(), NOW())
ON DUPLICATE KEY UPDATE last_seen_at = NOW()`,
[normalizedWallet]
);
migrated++;
} catch (error) {
console.error(`[DB] Error migrating wallet ${normalizedWallet}:`, error);
}
}
console.log(`[DB] ✅ Migrated ${migrated} wallets to database`);
return migrated;
} catch (error) {
console.error('[DB] Error during migration:', error);
return migrated;
}
}

606
src/index.ts Normal file
View File

@@ -0,0 +1,606 @@
// Load environment variables first (before any other imports)
import * as dotenv from 'dotenv';
import * as path from 'path';
// Load .env.local first (higher priority), then .env
const envLocalPath = path.join(process.cwd(), '.env.local');
const envPath = path.join(process.cwd(), '.env');
dotenv.config({ path: envLocalPath });
dotenv.config({ path: envPath }); // .env.local values will override .env
console.log('[ENV] Environment variables loaded');
console.log('[ENV] DB_HOST:', process.env.DB_HOST || 'not set');
console.log('[ENV] DB_PORT:', process.env.DB_PORT || 'not set');
console.log('[ENV] DB_NAME:', process.env.DB_NAME || 'not set');
import express, { Request, Response } from 'express';
import { HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport } from '@nktkas/hyperliquid';
import * as fs from 'fs';
import WebSocket from 'ws';
import {
initDatabase,
testConnection,
loadWalletsFromDB,
addWalletToDB,
getAllWalletsFromDB,
savePnLSnapshot,
migrateWalletsFromJSON,
} from './database';
// Set WebSocket as global for @nktkas/rews to use in Node.js environment
(global as any).WebSocket = WebSocket;
const app = express();
const PORT = process.env.PORT || 3000;
// Enable JSON parsing
app.use(express.json());
const client = new InfoClient({ transport: new HttpTransport() });
// Create WebSocket transport with logging
const wsTransport = new WebSocketTransport();
// Log WebSocket connection events
const socket = (wsTransport as any).socket;
if (socket) {
socket.addEventListener('open', () => {
console.log('[WEBSOCKET] ✅ Connection opened');
});
socket.addEventListener('close', (event: any) => {
console.log('[WEBSOCKET] ❌ Connection closed:', event.code, event.reason);
});
socket.addEventListener('error', (error: any) => {
console.error('[WEBSOCKET] ❌ Connection error:', error);
});
socket.addEventListener('message', (event: any) => {
try {
const data = JSON.parse(event.data);
// console.log('[WEBSOCKET] 📨 Received message:', JSON.stringify(data, null, 2));
} catch (e) {
console.log('[WEBSOCKET] 📨 Received raw message:', event.data);
}
});
} else {
console.log('[WEBSOCKET] ⚠️ Socket not yet available');
}
const subscriptionClient = new SubscriptionClient({
transport: wsTransport
});
console.log('[INIT] Subscription client created:', {
hasTransport: !!wsTransport,
transportType: wsTransport.constructor?.name,
});
// Storage for tracked wallet addresses (in-memory cache)
const trackedWallets = new Set<string>();
// Initialize database and load wallets
async function initializeDatabase(): Promise<void> {
try {
console.log('[INIT] Initializing database...');
initDatabase();
// Test connection
const connected = await testConnection();
if (!connected) {
console.error('[INIT] ⚠️ Database connection failed, but continuing...');
return;
}
// Load wallets from database
console.log('[INIT] Loading wallets from database...');
const wallets = await loadWalletsFromDB();
wallets.forEach(wallet => trackedWallets.add(wallet.toLowerCase()));
console.log(`[INIT] ✅ Loaded ${trackedWallets.size} wallets from database`);
// Migrate from JSON file if it exists (one-time migration)
const WALLETS_FILE = path.join(process.cwd(), 'wallets.json');
if (fs.existsSync(WALLETS_FILE)) {
console.log('[INIT] Found wallets.json, attempting migration...');
try {
const data = fs.readFileSync(WALLETS_FILE, 'utf-8');
const jsonWallets = JSON.parse(data) as string[];
const migrated = await migrateWalletsFromJSON(jsonWallets);
console.log(`[INIT] ✅ Migrated ${migrated} wallets from JSON to database`);
// Reload from database after migration
const updatedWallets = await loadWalletsFromDB();
trackedWallets.clear();
updatedWallets.forEach(wallet => trackedWallets.add(wallet.toLowerCase()));
console.log(`[INIT] ✅ Total wallets after migration: ${trackedWallets.size}`);
// Optionally backup the old file
const backupFile = `${WALLETS_FILE}.backup`;
fs.copyFileSync(WALLETS_FILE, backupFile);
console.log(`[INIT] ✅ Backed up wallets.json to ${backupFile}`);
} catch (error) {
console.error('[INIT] Error during migration:', error);
}
}
} catch (error) {
console.error('[INIT] Error initializing database:', error);
}
}
// Add wallet to tracking (both in-memory and database)
async function addWallet(wallet: string): Promise<void> {
if (!wallet || typeof wallet !== 'string' || !wallet.startsWith('0x')) {
console.log(`[WALLET] ⚠️ Invalid wallet format, skipping:`, wallet);
return;
}
const normalizedWallet = wallet.toLowerCase();
// Add to in-memory cache
if (!trackedWallets.has(normalizedWallet)) {
trackedWallets.add(normalizedWallet);
}
// Save to database
try {
const success = await addWalletToDB(normalizedWallet);
if (success) {
console.log(`[WALLET] ✅ Added/updated wallet in database: ${normalizedWallet}`);
console.log(`[WALLET] 📊 Total tracked wallets: ${trackedWallets.size}`);
} else {
console.error(`[WALLET] ❌ Failed to save wallet to database: ${normalizedWallet}`);
}
} catch (error) {
console.error(`[WALLET] ❌ Error saving wallet to database:`, error);
}
}
interface WalletPnL {
wallet: string;
pnl: string;
accountValue?: string;
unrealizedPnl?: string;
realizedPnl?: string;
}
/**
* Get PnL for a single wallet
*/
async function getWalletPnL(wallet: string): Promise<WalletPnL | null> {
try {
const state = await client.clearinghouseState({ user: wallet });
// Extract data from marginSummary
const marginSummary = state.marginSummary;
const accountValue = marginSummary?.accountValue || '0';
// Calculate unrealized PnL from assetPositions
// PnL is typically the difference between current value and entry value
let totalUnrealizedPnl = '0';
if (state.assetPositions && Array.isArray(state.assetPositions)) {
totalUnrealizedPnl = state.assetPositions.reduce((sum, position: any) => {
// Access unrealizedPnl from position structure
// The structure may vary, so we check multiple possible paths
const positionPnl =
position.position?.unrealizedPnl ||
position.unrealizedPnl ||
(position.position?.unrealizedPnl || '0');
const pnlValue = parseFloat(positionPnl) || 0;
return (parseFloat(sum) + pnlValue).toString();
}, '0');
}
// Use totalRawUsd from marginSummary as alternative PnL indicator
// totalRawUsd represents total account value including unrealized PnL
// We can use it to derive PnL if needed
const totalRawUsd = marginSummary?.totalRawUsd || '0';
// Primary PnL metric: use unrealized PnL from positions, fallback to calculated value
const pnl = totalUnrealizedPnl !== '0' ? totalUnrealizedPnl : totalRawUsd;
return {
wallet,
pnl,
accountValue,
unrealizedPnl: totalUnrealizedPnl,
};
} catch (error) {
console.error(`Error fetching PnL for wallet ${wallet}:`, error);
return null;
}
}
/**
* Start tracking trades to automatically collect wallet addresses
*/
async function startTradeTracking(): Promise<void> {
try {
console.log('[TRACKING] Starting trade tracking...');
console.log('[TRACKING] WebSocket transport:', wsTransport);
// Wait for WebSocket connection to be ready
try {
console.log('[TRACKING] Waiting for WebSocket connection to be ready...');
await wsTransport.ready();
console.log('[TRACKING] ✅ WebSocket connection ready!');
console.log('[TRACKING] WebSocket state:', (wsTransport as any).socket?.readyState);
console.log('[TRACKING] Socket info:', {
readyState: (wsTransport as any).socket?.readyState,
url: (wsTransport as any).socket?.url,
});
} catch (error) {
console.error('[TRACKING] ❌ Error waiting for WebSocket connection:', error);
throw error;
}
// Common trading pairs to track
const coinsToTrack = ['ETH', 'BTC', 'SOL', 'ARB', 'AVAX'];
console.log(`[TRACKING] Will subscribe to trades for: ${coinsToTrack.join(', ')}`);
// Subscribe to trades for multiple coins
for (const coin of coinsToTrack) {
try {
console.log(`[TRACKING] Attempting to subscribe to ${coin} trades...`);
const subscription = await subscriptionClient.trades({ coin }, (trade: any) => {
console.log(`[TRADE] Received trade data for ${coin}:`, JSON.stringify(trade, null, 2));
try {
// Extract wallet addresses from trade data
// Trade structure may vary
console.log(`[TRADE] Processing trade data for ${coin}, structure:`, Object.keys(trade));
// Check if trade has a 'trades' array or is a single trade
const trades = trade.trades || (Array.isArray(trade) ? trade : [trade]);
console.log(`[TRADE] Found ${trades.length} trade(s) to process`);
trades.forEach((t: any, index: number) => {
console.log(`[TRADE] Processing trade ${index + 1}/${trades.length}`);
console.log(`[TRADE] Trade ${index + 1} keys:`, Object.keys(t));
// Collect all potential wallet addresses found
const foundWallets: string[] = [];
// Check various possible fields for wallet address
const wallet = t.user || t.userAddress || t.account || t.side?.user || t.oid?.user || t.closedPnl?.user;
console.log(`[TRADE] Extracted wallet from primary fields:`, wallet);
// Scan all fields for potential wallet addresses
const scanForWallets = (obj: any, prefix = ''): void => {
if (!obj || typeof obj !== 'object') return;
Object.keys(obj).forEach(key => {
const value = obj[key];
const fullPath = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string' && value.startsWith('0x') && value.length >= 42) {
console.log(`[TRADE] 🔍 Found potential wallet at '${fullPath}': ${value}`);
foundWallets.push(value);
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
scanForWallets(value, fullPath);
} else if (Array.isArray(value)) {
value.forEach((item, idx) => {
if (typeof item === 'object' && item !== null) {
scanForWallets(item, `${fullPath}[${idx}]`);
} else if (typeof item === 'string' && item.startsWith('0x') && item.length >= 42) {
console.log(`[TRADE] 🔍 Found potential wallet at '${fullPath}[${idx}]': ${item}`);
foundWallets.push(item);
}
});
}
});
};
// Scan the entire trade object
scanForWallets(t);
// Add all found wallets (remove duplicates)
const uniqueWallets = [...new Set(foundWallets)];
console.log(`[TRADE] 📋 Found ${uniqueWallets.length} unique wallet(s):`, uniqueWallets);
uniqueWallets.forEach(w => {
addWallet(w).catch(err => {
console.error(`[WALLET] Error adding wallet ${w}:`, err);
});
});
// Also check for maker/taker if available
if (t.maker && typeof t.maker === 'string' && t.maker.startsWith('0x')) {
console.log(`[WALLET] Adding wallet from maker field: ${t.maker}`);
addWallet(t.maker).catch(err => {
console.error(`[WALLET] Error adding maker wallet:`, err);
});
}
if (t.taker && typeof t.taker === 'string' && t.taker.startsWith('0x')) {
console.log(`[WALLET] Adding wallet from taker field: ${t.taker}`);
addWallet(t.taker).catch(err => {
console.error(`[WALLET] Error adding taker wallet:`, err);
});
}
if (uniqueWallets.length === 0) {
console.log(`[TRADE] ⚠️ No wallet addresses found in this trade`);
console.log(`[TRADE] Full trade data:`, JSON.stringify(t, null, 2));
}
});
} catch (error) {
console.error('[TRADE] Error processing trade:', error);
console.error('[TRADE] Error stack:', (error as Error).stack);
}
});
console.log(`[TRACKING] Successfully subscribed to ${coin} trades, subscription:`, subscription);
} catch (error) {
console.error(`[TRACKING] Error subscribing to ${coin} trades:`, error);
console.error(`[TRACKING] Error details:`, (error as Error).stack);
}
}
console.log('[TRACKING] Trade tracking initialization complete');
console.log(`[TRACKING] Currently tracking ${trackedWallets.size} wallets`);
} catch (error) {
console.error('[TRACKING] Fatal error starting trade tracking:', error);
console.error('[TRACKING] Error stack:', (error as Error).stack);
}
}
/**
* API endpoint to get tracked wallets sorted by PnL
* No input required - uses automatically tracked wallets
*/
app.get('/api/wallets/tracked', async (req: Request, res: Response) => {
try {
console.log(`[API] GET /api/wallets/tracked called`);
// Get wallets from database
const wallets = await loadWalletsFromDB();
console.log(`[API] Loaded ${wallets.length} wallets from database`);
if (wallets.length === 0) {
console.log(`[API] ⚠️ No wallets found in database`);
return res.json({
success: true,
count: 0,
message: 'No wallets tracked yet. Wallets will be added automatically as trades are detected.',
wallets: [],
});
}
console.log(`[API] Processing ${wallets.length} wallets for PnL calculation`);
// Fetch PnL for all tracked wallets in parallel
const walletPnLs = await Promise.all(
wallets.map(async (wallet) => {
const pnlData = await getWalletPnL(wallet);
if (pnlData) {
// Save PnL snapshot to database
await savePnLSnapshot(
wallet,
pnlData.pnl,
pnlData.accountValue || '0',
pnlData.unrealizedPnl || '0'
).catch(err => {
console.error(`[DB] Error saving PnL snapshot for ${wallet}:`, err);
});
}
return pnlData;
})
);
// Filter out null results (failed requests)
const validWalletPnLs = walletPnLs.filter((w): w is WalletPnL => w !== null);
// Sort by PnL (highest first) - convert string to number for sorting
validWalletPnLs.sort((a, b) => {
const pnlA = parseFloat(a.pnl) || 0;
const pnlB = parseFloat(b.pnl) || 0;
return pnlB - pnlA;
});
res.json({
success: true,
count: validWalletPnLs.length,
totalTracked: wallets.length,
wallets: validWalletPnLs,
});
} catch (error) {
console.error('Error in /api/wallets/tracked:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* API endpoint to manually add wallets to tracking
*/
app.post('/api/wallets/track', async (req: Request, res: Response) => {
try {
const { wallets } = req.body;
if (!wallets || !Array.isArray(wallets)) {
return res.status(400).json({
error: 'Missing or invalid wallets array in request body.'
});
}
const added: string[] = [];
const existing: string[] = [];
for (const wallet of wallets) {
if (typeof wallet === 'string' && wallet.startsWith('0x')) {
const normalizedWallet = wallet.toLowerCase();
const dbWallet = await getAllWalletsFromDB(1000);
const exists = dbWallet.some(w => w.address === normalizedWallet);
if (exists) {
existing.push(normalizedWallet);
} else {
await addWallet(normalizedWallet);
added.push(normalizedWallet);
}
}
}
// Reload count from database
const allWallets = await loadWalletsFromDB();
res.json({
success: true,
added,
existing,
totalTracked: allWallets.length,
});
} catch (error) {
console.error('Error in POST /api/wallets/track:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* API endpoint to get list of tracked wallet addresses (without PnL)
*/
app.get('/api/wallets/list', async (req: Request, res: Response) => {
try {
const wallets = await loadWalletsFromDB();
res.json({
success: true,
count: wallets.length,
wallets: wallets,
});
} catch (error) {
console.error('Error in GET /api/wallets/list:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* API endpoint to get wallets sorted by PnL
* Accepts wallet addresses via query parameter or request body
*
* GET /api/wallets/pnl?wallets=0x123,0x456
* or
* POST /api/wallets/pnl
* Body: { wallets: ["0x123", "0x456"] }
*/
app.get('/api/wallets/pnl', async (req: Request, res: Response) => {
try {
const walletsParam = req.query.wallets as string;
if (!walletsParam) {
return res.status(400).json({
error: 'Missing wallets parameter. Provide comma-separated wallet addresses.'
});
}
const wallets = walletsParam.split(',').map(w => w.trim()).filter(w => w.length > 0);
if (wallets.length === 0) {
return res.status(400).json({ error: 'No valid wallet addresses provided.' });
}
// Fetch PnL for all wallets in parallel
const walletPnLs = await Promise.all(
wallets.map(wallet => getWalletPnL(wallet))
);
// Filter out null results (failed requests)
const validWalletPnLs = walletPnLs.filter((w): w is WalletPnL => w !== null);
// Sort by PnL (highest first) - convert string to number for sorting
validWalletPnLs.sort((a, b) => {
const pnlA = parseFloat(a.pnl) || 0;
const pnlB = parseFloat(b.pnl) || 0;
return pnlB - pnlA;
});
res.json({
success: true,
count: validWalletPnLs.length,
wallets: validWalletPnLs,
});
} catch (error) {
console.error('Error in /api/wallets/pnl:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST endpoint for wallet PnL (alternative to GET)
*/
app.post('/api/wallets/pnl', async (req: Request, res: Response) => {
try {
const { wallets } = req.body;
if (!wallets || !Array.isArray(wallets) || wallets.length === 0) {
return res.status(400).json({
error: 'Missing or invalid wallets array in request body.'
});
}
// Fetch PnL for all wallets in parallel
const walletPnLs = await Promise.all(
wallets.map((wallet: string) => getWalletPnL(wallet))
);
// Filter out null results (failed requests)
const validWalletPnLs = walletPnLs.filter((w): w is WalletPnL => w !== null);
// Sort by PnL (highest first) - convert string to number for sorting
validWalletPnLs.sort((a, b) => {
const pnlA = parseFloat(a.pnl) || 0;
const pnlB = parseFloat(b.pnl) || 0;
return pnlB - pnlA;
});
res.json({
success: true,
count: validWalletPnLs.length,
wallets: validWalletPnLs,
});
} catch (error) {
console.error('Error in POST /api/wallets/pnl:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/', (req: Request, res: Response) => {
res.json({
message: 'Hyperliquid Wallet PnL Tracker API',
endpoints: {
'GET /api/wallets/tracked': 'Get automatically tracked wallets sorted by PnL (no input needed)',
'GET /api/wallets/list': 'Get list of tracked wallet addresses',
'POST /api/wallets/track': 'Manually add wallets to tracking',
'GET /api/wallets/pnl?wallets=0x...': 'Get specific wallets sorted by PnL (query param)',
'POST /api/wallets/pnl': 'Get specific wallets sorted by PnL (request body)',
},
stats: {
trackedWallets: trackedWallets.size,
isTracking: true,
},
example: {
tracked: '/api/wallets/tracked',
manual: '/api/wallets/pnl?wallets=0x123,0x456,0x789'
}
});
});
// Start the server
app.listen(PORT, async () => {
console.log('='.repeat(60));
console.log(`🚀 Server is running on http://localhost:${PORT}`);
console.log(`📊 API endpoint: http://localhost:${PORT}/api/wallets/tracked`);
console.log('='.repeat(60));
// Initialize database first
await initializeDatabase();
console.log(`👛 Currently tracking ${trackedWallets.size} wallets`);
// Start trade tracking after server starts
// Add a small delay to ensure server is fully initialized
setTimeout(async () => {
await startTradeTracking();
}, 1000);
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}