init
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
wallets.json
|
||||||
|
wallets.json.backup
|
||||||
|
|
||||||
126
README.md
Normal file
126
README.md
Normal 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
56
database/schema.sql
Normal 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
1973
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
379
src/database.ts
Normal 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
606
src/index.ts
Normal 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
18
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user