diff --git a/.env.example b/.env.example index 7cf7848..8945c0d 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,12 @@ REDIS_URL= # example: http://127.0.0.1:50051 WALLET_SERVICE_URL= +#HYPERLIQUID +HYPERLIQUID_PRIVATE_KEY= +HYPERLIQUID_TESTNET=false +HYPERLIQUID_API_PORT=3100 +HYPERLIQUID_WALLET_SERVICE_URL=http://127.0.0.1:50052 + #REST CLIENT TESTING BASE_URL= WALLET_ADDRESS= @@ -47,4 +53,8 @@ TRACKED_WALLET_ADDRESS= PUMP_FUN_TOKEN_ADDRESS= RAYDIUM_TOKEN_ADDRESS= JUPITER_TOKEN_ADDRESS= -WATCHLIST_ID= \ No newline at end of file +WATCHLIST_ID= + +#HYPERLIQUID REST CLIENT TESTING +HYPERLIQUID_BASE_URL=http://localhost:3100 +HYPERLIQUID_TEST_ASSET=ETH \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2ffce3b..9e08b81 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ trading-common/src/generated/wallet.rs CLAUDE.md -supabase/ \ No newline at end of file +supabase/ + +.vscode/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 1200c94..56e50ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ members = [ "trading-wallet", "trading-price-feed", "trading-sol-price-feed", + "hyperliquid-common", + "hyperliquid-api", + "hyperliquid-wallet", ] resolver = "2" [workspace.dependencies] @@ -13,7 +16,7 @@ tokio = { version = "1.46.1", features = ["full"] } tokio-tungstenite = { version = "0.27.0", features = ["native-tls"] } tokio-stream = "0.1.17" serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" +serde_json = "1.0.141" uuid = { version = "1.17.0", features = ["serde", "v4"] } chrono = { version = "0.4.41", features = ["serde"] } postgrest = "1.6.0" @@ -29,15 +32,15 @@ base64 = "0.22.1" arrayref = "0.3.9" futures-util = "0.3.31" solana-sdk = "2.3.1" -solana-client = "2.3.3" -solana-account-decoder = "2.3.3" +solana-client = "2.3.5" +solana-account-decoder = "2.3.5" solana-program = "2.3.0" spl-token = "8.0.0" surf = "2.3.2" once_cell = "1.21.3" spl-associated-token-account = "7.0.0" arc-swap = "1.7.1" -solana-transaction-status = "2.3.3" +solana-transaction-status = "2.3.5" borsh = "1.5.7" bs58 = "0.5.1" # parking_lot = "0.12.3" # REMOVED - using tokio::sync instead @@ -46,7 +49,7 @@ scopeguard = "1.2.0" backoff = "0.4.0" tokio-native-tls = "0.3.1" reqwest = { version = "0.12.22", features = ["json"] } -redis = { version = "0.32.3", features = [ +redis = { version = "0.32.4", features = [ "tokio-comp", "connection-manager", "aio", @@ -59,3 +62,4 @@ rust_decimal = "1.37.2" hex = "0.4.3" validator = { version = "0.20.0", features = ["derive"] } tower = "0.5.2" +tonic-build = "0.13.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..45531e0 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Trading Platform + +A multi-chain cryptocurrency trading platform supporting Solana DEXs and Hyperliquid perpetual futures. + +## Architecture + +This platform consists of multiple microservices organized into Solana and Hyperliquid ecosystems: + +### Solana Services + +#### Core Trading Services +- **trading-common**: Shared library with models, database client, DEX integrations (pump.fun, Raydium, Jupiter), Redis pool, WebSocket server, and gRPC protocol definitions +- **trading-api**: REST API server with CRUD operations and trade execution endpoints (port 3000) +- **trading-bot**: Core trading engine with WebSocket wallet monitoring and copy trading functionality (port 3001) +- **trading-wallet**: gRPC wallet management service for centralized wallet operations (port 50051) + +#### Price Feed Services +- **trading-price-feed**: Real-time price monitoring for any Solana token using Raydium DEX vault subscriptions (port 3005) + - Zero-RPC polling approach via WebSocket subscriptions to vault account changes + - Automatic pool discovery for any token address + - Multi-layer caching (in-memory + Redis) with health monitoring + - Endpoints: `/health`, `/status`, `/ws` for real-time updates + - Publishes price updates to Redis and WebSocket clients + +- **trading-sol-price-feed**: Dedicated SOL/USD price monitoring with dual data sources (port 3006) + - Primary: Pyth Network oracle (`7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE`) with confidence intervals + - Fallback: Raydium USDC/SOL pool (`58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2`) + - 1-second polling with automatic failover between sources + - Endpoints: `/price` (JSON), `/ws` for real-time SOL price streaming + +### Hyperliquid Services + +#### Core Trading Services +- **hyperliquid-common**: Shared types, error handling, and SDK wrapper for Hyperliquid API integration +- **hyperliquid-api**: REST API for Hyperliquid perpetual futures trading (port 3100) + - Market orders (long/short with slippage protection) + - Limit orders (GTC, IOC, ALO time-in-force options) + - Stop loss creation, modification, and cancellation + - Position and account management + - Order cancellation by order ID + - Asset universe querying +- **hyperliquid-wallet**: gRPC service for secure Hyperliquid wallet operations (port 50052) + - Private key management and transaction signing + - All trading operations routed through secure gRPC interface + +## Service Dependencies + +Services should be started in this order due to dependencies: + +1. **Infrastructure**: `docker-compose up -d redis` +2. **Wallet Services**: + - `cargo run --bin trading-wallet` + - `cargo run --bin hyperliquid-wallet` +3. **API Servers**: + - `cargo run --bin trading-api` + - `cargo run --bin hyperliquid-api` +4. **Trading Bots**: `cargo run --bin trading-bot` +5. **Price Feeds** (optional): + - `cargo run --bin trading-price-feed` + - `cargo run --bin trading-sol-price-feed` + +## Getting Started + +### Prerequisites +- Rust 1.70+ with Cargo +- Docker and Docker Compose (for Redis) +- Solana RPC access (Helius, QuickNode, etc.) +- Hyperliquid account with API access + +### Setup +1. Copy `.env.example` to `.env` and configure: + ```bash + # Solana Configuration + SOLANA_RPC_HTTP_URL="your-solana-rpc-endpoint" + SOLANA_RPC_WS_URL="your-solana-websocket-endpoint" + + # Hyperliquid Configuration + HYPERLIQUID_PRIVATE_KEY="your-hyperliquid-private-key" + HYPERLIQUID_TESTNET=false # Set to true for testnet + + # Database (Supabase) + SUPABASE_URL="your-supabase-url" + SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" + + # Redis + REDIS_URL=redis://localhost:6379 + + # Wallet Management + SERVER_WALLET_SECRET_KEY="your-solana-wallet-private-key" + ``` + +2. Start Redis: `docker-compose up -d redis` +3. Build the workspace: `cargo build --workspace` +4. Start services in dependency order (see above) + +## Development + +### Build Commands +```bash +# Build entire workspace +cargo build --workspace + +# Build specific services +cargo build --bin trading-api +cargo build --bin hyperliquid-api +cargo build --bin trading-bot + +# Release builds +cargo build --workspace --release +``` + +### Development Tools +```bash +# Watch and rebuild on changes +cargo watch -x "build --workspace" + +# Run tests +cargo test --workspace + +# Format code +cargo fmt --all + +# Run linter +cargo clippy --workspace --all-targets + +# Check for compilation errors +cargo check --workspace +``` + +### Protocol Buffers +- Definitions in `trading-common/proto/wallet.proto` and `hyperliquid-common/proto/wallet.proto` +- Auto-generated during build via build scripts +- Force recompilation: `cargo clean -p trading-common && cargo build` + +## API Documentation + +### Solana Trading +See `rest-client.http` for complete Solana API examples including: +- Wallet management: `/api/wallets/*` +- Copy trade settings: `/api/copy-trade-settings/*` +- Trade execution: `/api/trade/pump`, `/api/trade/raydium`, `/api/trade/jupiter` +- Transaction history: `/api/transactions/*` +- Watchlist management: `/api/watchlist/*` +- Token metadata: `/api/token/*` + +### Hyperliquid Trading +See `hyperliquid-rest-client.http` for complete Hyperliquid API examples including: +- Market orders: `POST /api/trade/market` +- Limit orders: `POST /api/trade/limit` +- Position management: `GET /api/positions` +- Account info: `GET /api/account` +- Order cancellation: `POST /api/orders/{order_id}/cancel` +- Stop loss management: `/api/stop-loss/*` +- Asset universe: `GET /api/universe` + +### Price Feed APIs +- **General tokens**: `ws://localhost:3005/ws` for real-time price updates +- **SOL price**: `GET http://localhost:3006/price` or `ws://localhost:3006/ws` + +## Key Features + +### Solana Integration +- Support for pump.fun, Raydium, and Jupiter DEX protocols +- Copy trading with configurable parameters +- Wallet-based authentication and multi-wallet support +- Real-time price feeds with automatic pool discovery +- Transaction history and watchlist management + +### Hyperliquid Integration +- Perpetual futures trading (long/short positions) +- Advanced order types (market, limit, stop loss) +- Real-time position and account monitoring +- Risk management with stop loss automation +- Secure wallet operations via gRPC + +### Infrastructure +- Redis-based event broadcasting and caching +- WebSocket real-time updates for frontend integration +- Microservice architecture with service discovery +- Comprehensive error handling and logging +- Docker support for easy deployment \ No newline at end of file diff --git a/hyperliquid-api/Cargo.toml b/hyperliquid-api/Cargo.toml new file mode 100644 index 0000000..8084c95 --- /dev/null +++ b/hyperliquid-api/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "hyperliquid-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +hyperliquid-common = { path = "../hyperliquid-common" } +trading-common = { path = "../trading-common" } +tokio = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenv = { workspace = true } +redis = { workspace = true } +tower-http = { workspace = true } +tower = { workspace = true } +validator = { workspace = true } +rust_decimal = { workspace = true } \ No newline at end of file diff --git a/hyperliquid-api/src/handlers.rs b/hyperliquid-api/src/handlers.rs new file mode 100644 index 0000000..711e37e --- /dev/null +++ b/hyperliquid-api/src/handlers.rs @@ -0,0 +1,219 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use hyperliquid_common::{ + types::{SimpleOrderRequest, OrderResponse, Position, Account, OrderSide, OrderType, LimitOrderType, TimeInForce, TriggerOrderType, TpSl, OpenOrder, StopLossRequest, StopLossModifyRequest}, + HyperliquidClient, +}; +use serde::{Deserialize, Serialize}; +use rust_decimal::Decimal; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct MarketOrderRequest { + pub asset: String, + pub side: String, // "long" or "short" + pub size: Decimal, + pub reduce_only: Option, + pub slippage_tolerance: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LimitOrderRequest { + pub asset: String, + pub side: String, // "long" or "short" + pub size: Decimal, + pub price: Decimal, + pub reduce_only: Option, + pub time_in_force: Option, // "GTC", "IOC", "ALO" +} + +#[derive(Debug, Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } + + pub fn error(error: String) -> Self { + Self { + success: false, + data: None, + error: Some(error), + } + } +} + +pub async fn place_market_order( + State(client): State>, + Json(request): Json, +) -> Result>, StatusCode> { + // Convert to SimpleOrderRequest + let order_request = SimpleOrderRequest { + asset: request.asset, + side: if request.side.to_lowercase() == "long" { + OrderSide::Buy + } else { + OrderSide::Sell + }, + order_type: OrderType::Trigger { + trigger: TriggerOrderType { + is_market: true, + trigger_px: "0".to_string(), // Market order + tpsl: TpSl::Tp, // Default, not used for market orders + }, + }, + size: request.size, + price: None, + reduce_only: request.reduce_only.unwrap_or(false), + client_order_id: None, + slippage_tolerance: request.slippage_tolerance, + }; + + match client.place_order(order_request).await { + Ok(response) => Ok(Json(ApiResponse::success(response))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn place_limit_order( + State(client): State>, + Json(request): Json, +) -> Result>, StatusCode> { + // Convert to SimpleOrderRequest + let tif = match request.time_in_force.as_deref() { + Some("IOC") => TimeInForce::Ioc, + Some("ALO") => TimeInForce::Alo, + _ => TimeInForce::Gtc, // Default to GTC + }; + + let order_request = SimpleOrderRequest { + asset: request.asset, + side: if request.side.to_lowercase() == "long" { + OrderSide::Buy + } else { + OrderSide::Sell + }, + order_type: OrderType::Limit { + limit: LimitOrderType { tif }, + }, + size: request.size, + price: Some(request.price), + reduce_only: request.reduce_only.unwrap_or(false), + client_order_id: None, + slippage_tolerance: None, + }; + + match client.place_order(order_request).await { + Ok(response) => Ok(Json(ApiResponse::success(response))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn get_positions( + State(client): State>, +) -> Result>>, StatusCode> { + + let wallet_address = client.get_wallet_address(); + + match client.get_positions(&wallet_address).await { + Ok(positions) => Ok(Json(ApiResponse::success(positions))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn get_account_info( + State(client): State>, +) -> Result>, StatusCode> { + + let wallet_address = client.get_wallet_address(); + + match client.get_account_info(&wallet_address).await { + Ok(account) => Ok(Json(ApiResponse::success(account))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn cancel_order( + State(client): State>, + Path(order_id): Path, +) -> Result>, StatusCode> { + match client.cancel_order(&order_id).await { + Ok(()) => Ok(Json(ApiResponse::success(()))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn get_universe( + State(client): State>, +) -> Result>>, StatusCode> { + match client.get_universe().await { + Ok(assets) => Ok(Json(ApiResponse::success(assets))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn get_open_orders( + State(client): State>, +) -> Result>>, StatusCode> { + match client.get_open_orders(None).await { + Ok(orders) => Ok(Json(ApiResponse::success(orders))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn place_stop_loss( + State(client): State>, + Json(request): Json, +) -> Result>, StatusCode> { + match client.place_stop_loss(request).await { + Ok(response) => Ok(Json(ApiResponse::success(response))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn modify_stop_loss( + State(client): State>, + Json(request): Json, +) -> Result>, StatusCode> { + match client.modify_stop_loss(request).await { + Ok(response) => Ok(Json(ApiResponse::success(response))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +#[derive(Debug, Deserialize)] +pub struct CancelStopLossRequest { + pub asset: String, +} + +pub async fn cancel_stop_loss( + State(client): State>, + Path(order_id): Path, + Json(request): Json, +) -> Result>, StatusCode> { + match client.cancel_stop_loss(&request.asset, &order_id).await { + Ok(()) => Ok(Json(ApiResponse::success(()))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} + +pub async fn get_stop_losses( + State(client): State>, +) -> Result>>, StatusCode> { + match client.get_stop_losses(None).await { + Ok(stop_losses) => Ok(Json(ApiResponse::success(stop_losses))), + Err(e) => Ok(Json(ApiResponse::error(e.to_string()))), + } +} \ No newline at end of file diff --git a/hyperliquid-api/src/main.rs b/hyperliquid-api/src/main.rs new file mode 100644 index 0000000..3015bac --- /dev/null +++ b/hyperliquid-api/src/main.rs @@ -0,0 +1,76 @@ +mod handlers; + +use axum::{ + routing::{get, post}, + Router, +}; +use hyperliquid_common::HyperliquidClient; +use std::{net::SocketAddr, sync::Arc}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + + // Initialize tracing + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .finish(); + tracing::subscriber::set_global_default(subscriber)?; + + info!("Starting Hyperliquid API server..."); + + // Initialize Hyperliquid client + let private_key = std::env::var("HYPERLIQUID_PRIVATE_KEY") + .expect("HYPERLIQUID_PRIVATE_KEY must be set"); + let testnet = std::env::var("HYPERLIQUID_TESTNET") + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + let client = Arc::new( + HyperliquidClient::new(&private_key, testnet) + .await + .expect("Failed to initialize Hyperliquid client") + ); + + // Build our application with routes + let app = Router::new() + .route("/health", get(health_check)) + .route("/api/trade/market", post(handlers::place_market_order)) + .route("/api/trade/limit", post(handlers::place_limit_order)) + .route("/api/positions", get(handlers::get_positions)) + .route("/api/account", get(handlers::get_account_info)) + .route("/api/orders/{order_id}/cancel", post(handlers::cancel_order)) + .route("/api/orders", get(handlers::get_open_orders)) + .route("/api/stop-loss", post(handlers::place_stop_loss)) + .route("/api/stop-loss", get(handlers::get_stop_losses)) + .route("/api/stop-loss/modify", post(handlers::modify_stop_loss)) + .route("/api/stop-loss/{order_id}/cancel", post(handlers::cancel_stop_loss)) + .route("/api/universe", get(handlers::get_universe)) + .with_state(client) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ); + + let port = std::env::var("HYPERLIQUID_API_PORT") + .unwrap_or_else(|_| "3100".to_string()) + .parse::()?; + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + info!("Hyperliquid API listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn health_check() -> &'static str { + "Hyperliquid API is healthy" +} \ No newline at end of file diff --git a/hyperliquid-common/Cargo.toml b/hyperliquid-common/Cargo.toml new file mode 100644 index 0000000..3d525bd --- /dev/null +++ b/hyperliquid-common/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "hyperliquid-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +reqwest = { workspace = true } +redis = { workspace = true } +rust_decimal = { workspace = true } +hex = { workspace = true } +base64 = { workspace = true } +hyperliquid_rust_sdk = "0.6.0" +ethers = "2.0.14" +postgrest = { workspace = true } +validator = { workspace = true } +once_cell = { workspace = true } +tonic = { workspace = true } +prost = { workspace = true } + +[build-dependencies] +tonic-build = { workspace = true } \ No newline at end of file diff --git a/hyperliquid-common/build.rs b/hyperliquid-common/build.rs new file mode 100644 index 0000000..2bd0d55 --- /dev/null +++ b/hyperliquid-common/build.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), Box> { + tonic_build::compile_protos("proto/wallet.proto")?; + Ok(()) +} \ No newline at end of file diff --git a/hyperliquid-common/proto/wallet.proto b/hyperliquid-common/proto/wallet.proto new file mode 100644 index 0000000..7a20337 --- /dev/null +++ b/hyperliquid-common/proto/wallet.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package hyperliquid.wallet; + +service WalletService { + rpc GetWalletAddress(GetWalletAddressRequest) returns (GetWalletAddressResponse); + rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse); + rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse); + rpc GetPositions(GetPositionsRequest) returns (GetPositionsResponse); + rpc GetAccountInfo(GetAccountInfoRequest) returns (GetAccountInfoResponse); + rpc GetUniverse(GetUniverseRequest) returns (GetUniverseResponse); +} + +// Request/Response messages +message GetWalletAddressRequest {} + +message GetWalletAddressResponse { + string address = 1; +} + +message PlaceOrderRequest { + string asset = 1; + string side = 2; // "buy" or "sell" + string order_type = 3; // "market" or "limit" + double size = 4; + optional double price = 5; + optional bool reduce_only = 6; + optional string client_order_id = 7; + optional double slippage_tolerance = 8; + optional string time_in_force = 9; // "GTC", "IOC", "ALO" +} + +message PlaceOrderResponse { + bool success = 1; + optional OrderInfo order = 2; + optional string error = 3; +} + +message CancelOrderRequest { + string order_id = 1; + optional string asset = 2; // Required for client order ID cancellation +} + +message CancelOrderResponse { + bool success = 1; + optional string error = 2; +} + +message GetPositionsRequest { + optional string address = 1; // If not provided, uses wallet address +} + +message GetPositionsResponse { + bool success = 1; + repeated Position positions = 2; + optional string error = 3; +} + +message GetAccountInfoRequest { + optional string address = 1; // If not provided, uses wallet address +} + +message GetAccountInfoResponse { + bool success = 1; + optional Account account = 2; + optional string error = 3; +} + +message GetUniverseRequest {} + +message GetUniverseResponse { + bool success = 1; + repeated string assets = 2; + optional string error = 3; +} + +// Data structures +message OrderInfo { + string order_id = 1; + optional string client_order_id = 2; + string status = 3; + double filled_size = 4; + double remaining_size = 5; + optional double average_fill_price = 6; +} + +message Position { + string asset = 1; + string side = 2; // "long" or "short" + double size = 3; + double entry_price = 4; + double mark_price = 5; + double unrealized_pnl = 6; + double margin = 7; + uint32 leverage = 8; +} + +message Account { + string address = 1; + double equity = 2; + double margin_used = 3; + double available_margin = 4; + repeated Position positions = 5; +} \ No newline at end of file diff --git a/hyperliquid-common/src/assets.rs b/hyperliquid-common/src/assets.rs new file mode 100644 index 0000000..e6564d2 --- /dev/null +++ b/hyperliquid-common/src/assets.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; +use once_cell::sync::Lazy; + +// Common perpetual assets and their indices +// This should be updated based on the actual Hyperliquid universe +pub static ASSET_INDICES: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + // Major perpetuals - placeholders for actual indices + m.insert("BTC", 0); + m.insert("ETH", 1); + m.insert("SOL", 2); + m.insert("MATIC", 3); + m.insert("ARB", 4); + m.insert("OP", 5); + m.insert("AVAX", 6); + m.insert("BNB", 7); + m.insert("DOGE", 8); + m.insert("LINK", 9); + m.insert("ATOM", 10); + m.insert("DOT", 11); + m.insert("UNI", 12); + m.insert("CRV", 13); + m.insert("LDO", 14); + m.insert("SUI", 15); + m.insert("APT", 16); + m.insert("INJ", 17); + m.insert("BLUR", 18); + m.insert("XRP", 19); + m.insert("AAVE", 20); + m.insert("COMP", 21); + m.insert("MKR", 22); + m.insert("WLD", 23); + m.insert("SEI", 24); + m.insert("TIA", 25); + // Add more as needed + m +}); + +pub fn get_asset_index(symbol: &str) -> Option { + ASSET_INDICES.get(symbol.to_uppercase().as_str()).copied() +} + +pub fn is_spot_asset(symbol: &str) -> bool { + // Spot assets have indices starting at 10000 + symbol.contains('/') || symbol.contains('-') +} + +pub fn get_spot_asset_index(symbol: &str) -> Option { + // For spot assets, need the actual spot universe indices + // This is a placeholder implementation + if is_spot_asset(symbol) { + // Extract base asset and find its index + let parts: Vec<&str> = symbol.split('/').collect(); + if parts.len() == 2 { + // Return 10000 + index for spot assets + // This needs to be implemented based on actual Hyperliquid spot universe + Some(10000) + } else { + None + } + } else { + None + } +} \ No newline at end of file diff --git a/hyperliquid-common/src/client.rs b/hyperliquid-common/src/client.rs new file mode 100644 index 0000000..7aac133 --- /dev/null +++ b/hyperliquid-common/src/client.rs @@ -0,0 +1,493 @@ +use crate::{errors::Result, types::*}; +use hyperliquid_rust_sdk::{ + BaseUrl, ExchangeClient, InfoClient, MarketOrderParams, MarketCloseParams, + ClientOrderRequest, ClientOrder, ClientLimit, ClientCancelRequestCloid, + ClientCancelRequest, ClientTrigger, ExchangeResponseStatus, ExchangeDataStatus, +}; +use ethers::signers::{LocalWallet, Signer}; +use ethers::types::H160; +use std::str::FromStr; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use uuid::Uuid; + +pub struct HyperliquidClient { + exchange_client: ExchangeClient, + info_client: InfoClient, + wallet: LocalWallet, +} + +impl HyperliquidClient { + pub async fn new(private_key: &str, testnet: bool) -> Result { + let base_url = if testnet { + BaseUrl::Testnet + } else { + BaseUrl::Mainnet + }; + + // Parse the private key into a wallet + let wallet = LocalWallet::from_str(private_key) + .map_err(|e| crate::HyperliquidError::AuthError(format!("Invalid private key: {}", e)))?; + + let exchange_client = ExchangeClient::new(None, wallet.clone(), Some(base_url), None, None) + .await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + let info_client = InfoClient::new(None, Some(base_url)) + .await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + Ok(Self { + exchange_client, + info_client, + wallet, + }) + } + + pub async fn place_order(&self, request: SimpleOrderRequest) -> Result { + let is_buy = matches!(request.side, OrderSide::Buy); + + // Validate asset exists in universe + if self.get_asset_index(&request.asset).await?.is_none() { + return Err(crate::HyperliquidError::InvalidOrder( + format!("Asset '{}' not found in Hyperliquid universe", request.asset) + )); + } + + // Generate a client order ID if none provided + let cloid = request.client_order_id.map(|id| + Uuid::parse_str(&id).unwrap_or_else(|_| Uuid::new_v4()) + ).unwrap_or_else(Uuid::new_v4); + + let response = match request.order_type { + // Handle market orders + OrderType::Trigger { trigger } if trigger.is_market => { + let market_params = MarketOrderParams { + asset: &request.asset, + is_buy, + sz: request.size.to_f64().unwrap_or(0.0), + px: None, // Market order, no price limit + slippage: request.slippage_tolerance.map(|s| s.to_f64().unwrap_or(0.01)), + cloid: Some(cloid), + wallet: None, + }; + + if request.reduce_only { + // Use market close for reduce-only orders + let close_params = MarketCloseParams { + asset: &request.asset, + sz: Some(request.size.to_f64().unwrap_or(0.0)), + px: None, + slippage: request.slippage_tolerance.map(|s| s.to_f64().unwrap_or(0.01)), + cloid: Some(cloid), + wallet: None, + }; + + self.exchange_client.market_close(close_params).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))? + } else { + self.exchange_client.market_open(market_params).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))? + } + }, + + // Handle trigger orders (stop loss/take profit) + OrderType::Trigger { trigger } if !trigger.is_market => { + let price = request.price + .ok_or_else(|| crate::HyperliquidError::InvalidOrder("Trigger order requires price".to_string()))?; + + let order_request = ClientOrderRequest { + asset: request.asset.clone(), + is_buy, + reduce_only: request.reduce_only, + limit_px: price.to_f64().unwrap_or(0.0), + sz: request.size.to_f64().unwrap_or(0.0), + cloid: Some(cloid), + order_type: ClientOrder::Trigger(ClientTrigger { + trigger_px: trigger.trigger_px.parse().unwrap_or(0.0), + is_market: trigger.is_market, + tpsl: match trigger.tpsl { + TpSl::Tp => "tp".to_string(), + TpSl::Sl => "sl".to_string(), + }, + }), + }; + + self.exchange_client.order(order_request, None).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))? + }, + + // Handle limit orders + OrderType::Limit { limit } => { + let price = request.price + .ok_or_else(|| crate::HyperliquidError::InvalidOrder("Limit order requires price".to_string()))?; + + let order_request = ClientOrderRequest { + asset: request.asset.clone(), + is_buy, + reduce_only: request.reduce_only, + limit_px: price.to_f64().unwrap_or(0.0), + sz: request.size.to_f64().unwrap_or(0.0), + cloid: Some(cloid), + order_type: ClientOrder::Limit(ClientLimit { + tif: match limit.tif { + TimeInForce::Gtc => "Gtc".to_string(), + TimeInForce::Ioc => "Ioc".to_string(), + TimeInForce::Alo => "Alo".to_string(), + }, + }), + }; + + self.exchange_client.order(order_request, None).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))? + }, + + _ => { + return Err(crate::HyperliquidError::InvalidOrder( + "Unsupported order type".to_string() + )); + } + }; + + // Handle the response + match response { + ExchangeResponseStatus::Ok(exchange_response) => { + if let Some(data) = exchange_response.data { + if let Some(status) = data.statuses.first() { + match status { + ExchangeDataStatus::Filled(order) => { + Ok(OrderResponse { + order_id: order.oid.to_string(), + client_order_id: Some(cloid.to_string()), + status: "filled".to_string(), + filled_size: Decimal::from_str(&order.total_sz).unwrap_or(Decimal::ZERO), + remaining_size: Decimal::ZERO, + average_fill_price: Some(Decimal::from_str(&order.avg_px).unwrap_or(Decimal::ZERO)), + }) + }, + ExchangeDataStatus::Resting(order) => { + Ok(OrderResponse { + order_id: order.oid.to_string(), + client_order_id: Some(cloid.to_string()), + status: "resting".to_string(), + filled_size: Decimal::ZERO, + remaining_size: Decimal::ZERO, // Resting order doesn't have size info available + average_fill_price: None, + }) + }, + _ => { + Err(crate::HyperliquidError::ApiError(format!("Unexpected order status: {:?}", status))) + } + } + } else { + Err(crate::HyperliquidError::ApiError("No order status in response".to_string())) + } + } else { + Err(crate::HyperliquidError::ApiError("No data in exchange response".to_string())) + } + }, + ExchangeResponseStatus::Err(e) => { + Err(crate::HyperliquidError::ApiError(format!("Exchange error: {}", e))) + } + } + } + + pub async fn get_positions(&self, address: &str) -> Result> { + let h160_address = address.parse::() + .map_err(|_| crate::HyperliquidError::InvalidOrder("Invalid address format".to_string()))?; + + let user_state = self.info_client.user_state(h160_address).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + let mut positions = Vec::new(); + + // Convert SDK positions to Position type + for asset_position in user_state.asset_positions { + if let Ok(size) = asset_position.position.szi.parse::() { + if size.abs() > 0.0 { // Only include non-zero positions + let position = Position { + asset: asset_position.position.coin.clone(), + side: if size > 0.0 { PositionSide::Long } else { PositionSide::Short }, + size: Decimal::from_f64_retain(size.abs()).unwrap_or(Decimal::ZERO), + entry_price: asset_position.position.entry_px + .as_ref() + .and_then(|s| Decimal::from_str(s).ok()) + .unwrap_or(Decimal::ZERO), + mark_price: Decimal::ZERO, // SDK doesn't seem to have mark_px field + unrealized_pnl: Decimal::from_str(&asset_position.position.unrealized_pnl).unwrap_or(Decimal::ZERO), + margin: Decimal::from_str(&asset_position.position.margin_used).unwrap_or(Decimal::ZERO), + leverage: { + let hyperliquid_rust_sdk::Leverage { value, .. } = &asset_position.position.leverage; + *value as u8 + }, + }; + positions.push(position); + } + } + } + + Ok(positions) + } + + pub async fn cancel_order(&self, order_id: &str) -> Result<()> { + if let Ok(_cloid) = Uuid::parse_str(order_id) { + return Err(crate::HyperliquidError::InvalidOrder( + "For client order ID cancellation, use cancel_order_by_cloid with asset parameter".to_string() + )); + } + + let oid: u64 = order_id.parse() + .map_err(|_| crate::HyperliquidError::InvalidOrder("Invalid order ID format".to_string()))?; + + let wallet_address = self.wallet.address(); + let open_orders = self.info_client.open_orders(wallet_address).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + for order in open_orders { + if order.oid == oid { + let cancel_request = ClientCancelRequest { + asset: order.coin.clone(), + oid, + }; + + let response = self.exchange_client.cancel(cancel_request, None).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + return match response { + ExchangeResponseStatus::Ok(_) => Ok(()), + ExchangeResponseStatus::Err(e) => Err(crate::HyperliquidError::ApiError( + format!("Cancel failed: {}", e) + )), + }; + } + } + + Err(crate::HyperliquidError::InvalidOrder( + "Order with ID not found in open orders".to_string() + )) + } + + pub async fn cancel_order_by_cloid(&self, asset: &str, cloid: &str) -> Result<()> { + let uuid_cloid = Uuid::parse_str(cloid) + .map_err(|_| crate::HyperliquidError::InvalidOrder("Invalid client order ID format".to_string()))?; + + let cancel_request = ClientCancelRequestCloid { + asset: asset.to_string(), + cloid: uuid_cloid, + }; + + let response = self.exchange_client.cancel_by_cloid(cancel_request, None).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + match response { + ExchangeResponseStatus::Ok(_) => Ok(()), + ExchangeResponseStatus::Err(e) => { + Err(crate::HyperliquidError::ApiError(format!("Cancel failed: {}", e))) + } + } + } + + pub async fn get_account_info(&self, address: &str) -> Result { + let h160_address = address.parse::() + .map_err(|_| crate::HyperliquidError::InvalidOrder("Invalid address format".to_string()))?; + + let user_state = self.info_client.user_state(h160_address).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + // Get positions (reuse the logic from get_positions) + let positions = self.get_positions(address).await?; + + // Extract account balance information + let account_value = Decimal::from_str(&user_state.cross_margin_summary.account_value).unwrap_or(Decimal::ZERO); + let margin_used = Decimal::from_str(&user_state.cross_margin_summary.total_margin_used).unwrap_or(Decimal::ZERO); + + let account = Account { + address: address.to_string(), + equity: account_value, + margin_used, + available_margin: account_value - margin_used, + positions, + }; + + Ok(account) + } + + pub fn get_wallet_address(&self) -> String { + format!("0x{:x}", self.wallet.address()) + } + + pub async fn get_universe(&self) -> Result> { + // Get the current universe (list of available assets) + let meta = self.info_client.meta().await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + let assets: Vec = meta.universe.iter() + .map(|asset| asset.name.clone()) + .collect(); + + Ok(assets) + } + + pub async fn get_asset_index(&self, asset_name: &str) -> Result> { + let meta = self.info_client.meta().await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + for (index, asset) in meta.universe.iter().enumerate() { + if asset.name == asset_name { + return Ok(Some(index as u32)); + } + } + + Ok(None) + } + + pub async fn get_open_orders(&self, address: Option<&str>) -> Result> { + let wallet_address = if let Some(addr) = address { + addr.parse::() + .map_err(|_| crate::HyperliquidError::InvalidOrder("Invalid address format".to_string()))? + } else { + self.wallet.address() + }; + + let open_orders = self.info_client.open_orders(wallet_address).await + .map_err(|e| crate::HyperliquidError::SdkError(e.to_string()))?; + + let mut orders = Vec::new(); + for order in open_orders { + orders.push(OpenOrder { + order_id: order.oid.to_string(), + asset: order.coin.clone(), + side: if order.side == "B" { OrderSide::Buy } else { OrderSide::Sell }, + size: Decimal::from_str(&order.sz).unwrap_or(Decimal::ZERO), + price: Decimal::from_str(&order.limit_px).unwrap_or(Decimal::ZERO), + timestamp: order.timestamp, + reduce_only: false, // Not available in the SDK response + }); + } + + Ok(orders) + } + + pub async fn place_stop_loss(&self, request: StopLossRequest) -> Result { + // Validate asset exists in universe + if self.get_asset_index(&request.asset).await?.is_none() { + return Err(crate::HyperliquidError::InvalidOrder( + format!("Asset '{}' not found in Hyperliquid universe", request.asset) + )); + } + + // Get current position to determine size and side + let wallet_address = self.get_wallet_address(); + let positions = self.get_positions(&wallet_address).await?; + + let position = positions.iter() + .find(|p| p.asset == request.asset) + .ok_or_else(|| crate::HyperliquidError::InvalidOrder( + format!("No open position found for asset '{}'", request.asset) + ))?; + + // Determine position size if not specified + let size = request.size.unwrap_or(position.size); + + // Determine stop loss side based on current position: + // Long position -> Stop loss is a sell (to close long) + // Short position -> Stop loss is a buy (to close short) + let stop_loss_side = match position.side { + PositionSide::Long => OrderSide::Sell, + PositionSide::Short => OrderSide::Buy, + }; + + // Generate a client order ID if none provided + let cloid = request.client_order_id.map(|id| + Uuid::parse_str(&id).unwrap_or_else(|_| Uuid::new_v4()) + ).unwrap_or_else(Uuid::new_v4); + + // Create stop loss order - this is a trigger order with stop loss type + let order_request = SimpleOrderRequest { + asset: request.asset.clone(), + side: stop_loss_side, + order_type: OrderType::Trigger { + trigger: TriggerOrderType { + is_market: false, // Stop loss is a trigger order, not immediate market order + trigger_px: request.trigger_price.to_string(), + tpsl: TpSl::Sl, // Stop Loss type + }, + }, + size, + price: Some(request.trigger_price), // Set the trigger price as the order price + reduce_only: true, // Stop loss is always reduce-only + client_order_id: Some(cloid.to_string()), + slippage_tolerance: None, // No slippage for trigger orders + }; + + self.place_order(order_request).await + } + + pub async fn modify_stop_loss(&self, request: StopLossModifyRequest) -> Result { + // First cancel the existing stop loss order + self.cancel_order(&request.order_id).await?; + + // Get the current position to determine size + let wallet_address = self.get_wallet_address(); + let positions = self.get_positions(&wallet_address).await?; + + let position = positions.iter() + .find(|p| p.asset == request.asset) + .ok_or_else(|| crate::HyperliquidError::InvalidOrder( + format!("No open position found for asset '{}'", request.asset) + ))?; + + // Place new stop loss with updated trigger price + let stop_loss_request = StopLossRequest { + asset: request.asset, + trigger_price: request.new_trigger_price, + size: Some(position.size), + client_order_id: None, + }; + + self.place_stop_loss(stop_loss_request).await + } + + pub async fn cancel_stop_loss(&self, _asset: &str, order_id: &str) -> Result<()> { + // Cancel the stop loss order (same as canceling any order) + self.cancel_order(order_id).await + } + + pub async fn get_stop_losses(&self, asset: Option<&str>) -> Result> { + let open_orders = self.get_open_orders(None).await?; + + // Get current positions to determine expected stop loss sides + let wallet_address = self.get_wallet_address(); + let positions = self.get_positions(&wallet_address).await?; + + let stop_losses: Vec = open_orders.into_iter() + .filter(|order| { + // Filter by asset if specified + if let Some(a) = asset { + if order.asset != a { + return false; + } + } + + // Find the position for this asset + if let Some(position) = positions.iter().find(|p| p.asset == order.asset) { + // Stop loss orders have opposite side to position: + // - Long position -> Stop loss is a sell order + // - Short position -> Stop loss is a buy order + // Note: We can't rely on reduce_only flag as SDK doesn't provide it + let expected_stop_loss_side = match position.side { + PositionSide::Long => OrderSide::Sell, + PositionSide::Short => OrderSide::Buy, + }; + + order.side == expected_stop_loss_side + } else { + // No position found for this asset, so it's not a stop loss for our current positions + false + } + }) + .collect(); + + Ok(stop_losses) + } +} \ No newline at end of file diff --git a/hyperliquid-common/src/errors.rs b/hyperliquid-common/src/errors.rs new file mode 100644 index 0000000..75e21d3 --- /dev/null +++ b/hyperliquid-common/src/errors.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum HyperliquidError { + #[error("API error: {0}")] + ApiError(String), + + #[error("Authentication error: {0}")] + AuthError(String), + + #[error("Invalid order: {0}")] + InvalidOrder(String), + + #[error("Network error: {0}")] + NetworkError(#[from] reqwest::Error), + + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("SDK error: {0}")] + SdkError(String), + + #[error("Unknown error: {0}")] + Unknown(String), +} + +pub type Result = std::result::Result; \ No newline at end of file diff --git a/hyperliquid-common/src/generated/hyperliquid.wallet.rs b/hyperliquid-common/src/generated/hyperliquid.wallet.rs new file mode 100644 index 0000000..6ea44bc --- /dev/null +++ b/hyperliquid-common/src/generated/hyperliquid.wallet.rs @@ -0,0 +1,843 @@ +// This file is @generated by prost-build. +/// Request/Response messages +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct GetWalletAddressRequest {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetWalletAddressResponse { + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PlaceOrderRequest { + #[prost(string, tag = "1")] + pub asset: ::prost::alloc::string::String, + /// "buy" or "sell" + #[prost(string, tag = "2")] + pub side: ::prost::alloc::string::String, + /// "market" or "limit" + #[prost(string, tag = "3")] + pub order_type: ::prost::alloc::string::String, + #[prost(double, tag = "4")] + pub size: f64, + #[prost(double, optional, tag = "5")] + pub price: ::core::option::Option, + #[prost(bool, optional, tag = "6")] + pub reduce_only: ::core::option::Option, + #[prost(string, optional, tag = "7")] + pub client_order_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(double, optional, tag = "8")] + pub slippage_tolerance: ::core::option::Option, + /// "GTC", "IOC", "ALO" + #[prost(string, optional, tag = "9")] + pub time_in_force: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PlaceOrderResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(message, optional, tag = "2")] + pub order: ::core::option::Option, + #[prost(string, optional, tag = "3")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CancelOrderRequest { + #[prost(string, tag = "1")] + pub order_id: ::prost::alloc::string::String, + /// Required for client order ID cancellation + #[prost(string, optional, tag = "2")] + pub asset: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CancelOrderResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, optional, tag = "2")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPositionsRequest { + /// If not provided, uses wallet address + #[prost(string, optional, tag = "1")] + pub address: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPositionsResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(message, repeated, tag = "2")] + pub positions: ::prost::alloc::vec::Vec, + #[prost(string, optional, tag = "3")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountInfoRequest { + /// If not provided, uses wallet address + #[prost(string, optional, tag = "1")] + pub address: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountInfoResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(message, optional, tag = "2")] + pub account: ::core::option::Option, + #[prost(string, optional, tag = "3")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct GetUniverseRequest {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetUniverseResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, repeated, tag = "2")] + pub assets: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, optional, tag = "3")] + pub error: ::core::option::Option<::prost::alloc::string::String>, +} +/// Data structures +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OrderInfo { + #[prost(string, tag = "1")] + pub order_id: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub client_order_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "3")] + pub status: ::prost::alloc::string::String, + #[prost(double, tag = "4")] + pub filled_size: f64, + #[prost(double, tag = "5")] + pub remaining_size: f64, + #[prost(double, optional, tag = "6")] + pub average_fill_price: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Position { + #[prost(string, tag = "1")] + pub asset: ::prost::alloc::string::String, + /// "long" or "short" + #[prost(string, tag = "2")] + pub side: ::prost::alloc::string::String, + #[prost(double, tag = "3")] + pub size: f64, + #[prost(double, tag = "4")] + pub entry_price: f64, + #[prost(double, tag = "5")] + pub mark_price: f64, + #[prost(double, tag = "6")] + pub unrealized_pnl: f64, + #[prost(double, tag = "7")] + pub margin: f64, + #[prost(uint32, tag = "8")] + pub leverage: u32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Account { + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, + #[prost(double, tag = "2")] + pub equity: f64, + #[prost(double, tag = "3")] + pub margin_used: f64, + #[prost(double, tag = "4")] + pub available_margin: f64, + #[prost(message, repeated, tag = "5")] + pub positions: ::prost::alloc::vec::Vec, +} +/// Generated client implementations. +pub mod wallet_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct WalletServiceClient { + inner: tonic::client::Grpc, + } + impl WalletServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl WalletServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> WalletServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + WalletServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn get_wallet_address( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/GetWalletAddress", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "hyperliquid.wallet.WalletService", + "GetWalletAddress", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn place_order( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/PlaceOrder", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("hyperliquid.wallet.WalletService", "PlaceOrder"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn cancel_order( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/CancelOrder", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("hyperliquid.wallet.WalletService", "CancelOrder"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_positions( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/GetPositions", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("hyperliquid.wallet.WalletService", "GetPositions"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_account_info( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/GetAccountInfo", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("hyperliquid.wallet.WalletService", "GetAccountInfo"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_universe( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/hyperliquid.wallet.WalletService/GetUniverse", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("hyperliquid.wallet.WalletService", "GetUniverse"), + ); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod wallet_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with WalletServiceServer. + #[async_trait] + pub trait WalletService: std::marker::Send + std::marker::Sync + 'static { + async fn get_wallet_address( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn place_order( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn cancel_order( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_positions( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_account_info( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_universe( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct WalletServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl WalletServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for WalletServiceServer + where + T: WalletService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/hyperliquid.wallet.WalletService/GetWalletAddress" => { + #[allow(non_camel_case_types)] + struct GetWalletAddressSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for GetWalletAddressSvc { + type Response = super::GetWalletAddressResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_wallet_address(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetWalletAddressSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/hyperliquid.wallet.WalletService/PlaceOrder" => { + #[allow(non_camel_case_types)] + struct PlaceOrderSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for PlaceOrderSvc { + type Response = super::PlaceOrderResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::place_order(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PlaceOrderSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/hyperliquid.wallet.WalletService/CancelOrder" => { + #[allow(non_camel_case_types)] + struct CancelOrderSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for CancelOrderSvc { + type Response = super::CancelOrderResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::cancel_order(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CancelOrderSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/hyperliquid.wallet.WalletService/GetPositions" => { + #[allow(non_camel_case_types)] + struct GetPositionsSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for GetPositionsSvc { + type Response = super::GetPositionsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_positions(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetPositionsSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/hyperliquid.wallet.WalletService/GetAccountInfo" => { + #[allow(non_camel_case_types)] + struct GetAccountInfoSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for GetAccountInfoSvc { + type Response = super::GetAccountInfoResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_account_info(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetAccountInfoSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/hyperliquid.wallet.WalletService/GetUniverse" => { + #[allow(non_camel_case_types)] + struct GetUniverseSvc(pub Arc); + impl< + T: WalletService, + > tonic::server::UnaryService + for GetUniverseSvc { + type Response = super::GetUniverseResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_universe(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetUniverseSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for WalletServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "hyperliquid.wallet.WalletService"; + impl tonic::server::NamedService for WalletServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/hyperliquid-common/src/lib.rs b/hyperliquid-common/src/lib.rs new file mode 100644 index 0000000..b6bb6f1 --- /dev/null +++ b/hyperliquid-common/src/lib.rs @@ -0,0 +1,18 @@ +pub mod models; +pub mod types; +pub mod client; +pub mod errors; +pub mod assets; + +// Generated gRPC code +pub mod generated { + pub mod wallet { + tonic::include_proto!("hyperliquid.wallet"); + } +} + +pub use models::*; +pub use types::*; +pub use client::*; +pub use errors::*; +pub use assets::*; \ No newline at end of file diff --git a/hyperliquid-common/src/models.rs b/hyperliquid-common/src/models.rs new file mode 100644 index 0000000..7251052 --- /dev/null +++ b/hyperliquid-common/src/models.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperliquidTransaction { + pub id: Uuid, + pub user_id: Uuid, + pub order_id: String, + pub asset: String, + pub side: String, // "long" or "short" + pub size: Decimal, + pub price: Decimal, + pub transaction_type: String, // "open", "close", "liquidation" + pub status: String, + pub fee: Decimal, + pub realized_pnl: Option, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperliquidTradeSettings { + pub id: Uuid, + pub user_id: Uuid, + pub max_position_size: Decimal, + pub default_leverage: u8, + pub max_leverage: u8, + pub default_slippage: Decimal, + pub stop_loss_percentage: Option, + pub take_profit_percentage: Option, + pub is_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperliquidApiKey { + pub id: Uuid, + pub user_id: Uuid, + pub address: String, + pub encrypted_private_key: String, + pub is_active: bool, + pub created_at: DateTime, +} \ No newline at end of file diff --git a/hyperliquid-common/src/types.rs b/hyperliquid-common/src/types.rs new file mode 100644 index 0000000..fca5f1d --- /dev/null +++ b/hyperliquid-common/src/types.rs @@ -0,0 +1,160 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OrderSide { + Buy, + Sell, +} + +// Hyperliquid specific order structures based on API docs +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LimitOrderType { + pub tif: TimeInForce, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TriggerOrderType { + #[serde(rename = "isMarket")] + pub is_market: bool, + #[serde(rename = "triggerPx")] + pub trigger_px: String, + pub tpsl: TpSl, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum OrderType { + Limit { limit: LimitOrderType }, + Trigger { trigger: TriggerOrderType }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TimeInForce { + Alo, // Add Liquidity Only (Post Only) + Ioc, // Immediate or Cancel + Gtc, // Good Till Canceled +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TpSl { + Tp, // Take Profit + Sl, // Stop Loss +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PositionSide { + Long, + Short, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Position { + pub asset: String, + pub side: PositionSide, + pub size: Decimal, + pub entry_price: Decimal, + pub mark_price: Decimal, + pub unrealized_pnl: Decimal, + pub margin: Decimal, + pub leverage: u8, +} + +// Hyperliquid API order structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HyperliquidOrder { + pub a: u32, // Asset index + pub b: bool, // Buy = true, Sell = false + pub p: String, // Price + pub s: String, // Size + pub r: bool, // Reduce only + pub t: OrderType, // Order type + #[serde(skip_serializing_if = "Option::is_none")] + pub c: Option, // Client order ID (128-bit hex) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderAction { + #[serde(rename = "type")] + pub action_type: String, // "order" + pub orders: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub builder: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuilderInfo { + pub b: String, // Builder address + pub f: u32, // Fee in tenths of basis point +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderRequest { + pub action: OrderAction, + pub nonce: u64, + pub signature: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub vault_address: Option, +} + +// Simplified order request for our API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimpleOrderRequest { + pub asset: String, + pub side: OrderSide, + pub order_type: OrderType, + pub size: Decimal, + pub price: Option, + pub reduce_only: bool, + pub client_order_id: Option, + pub slippage_tolerance: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderResponse { + pub order_id: String, + pub client_order_id: Option, + pub status: String, + pub filled_size: Decimal, + pub remaining_size: Decimal, + pub average_fill_price: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub address: String, + pub equity: Decimal, + pub margin_used: Decimal, + pub available_margin: Decimal, + pub positions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenOrder { + pub order_id: String, + pub asset: String, + pub side: OrderSide, + pub size: Decimal, + pub price: Decimal, + pub timestamp: u64, + pub reduce_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopLossRequest { + pub asset: String, + pub trigger_price: Decimal, + pub size: Option, // If None, closes entire position + pub client_order_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopLossModifyRequest { + pub asset: String, + pub order_id: String, + pub new_trigger_price: Decimal, +} \ No newline at end of file diff --git a/hyperliquid-rest-client.http b/hyperliquid-rest-client.http new file mode 100644 index 0000000..8ee9a52 --- /dev/null +++ b/hyperliquid-rest-client.http @@ -0,0 +1,101 @@ +@hyperliquidBaseUrl = http://localhost:3100 +@testAsset = ETH +# Replace with actual order ID from open orders or stop loss placement +@orderId = 35895628002 +@stopLossTriggerPrice = 3400.0 +@stopLossModifiedPrice = 3450.0 + + +### Health Check +GET {{hyperliquidBaseUrl}}/health + +### Place Market Order - Long +POST {{hyperliquidBaseUrl}}/api/trade/market +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "side": "long", + "size": 0.1, + "slippage_tolerance": 0.01 +} + +### Place Market Order - Short +POST {{hyperliquidBaseUrl}}/api/trade/market +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "side": "short", + "size": 0.1, + "slippage_tolerance": 0.01 +} + +### Place Limit Order - Long +POST {{hyperliquidBaseUrl}}/api/trade/limit +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "side": "long", + "size": 0.1, + "price": 3000.0, + "time_in_force": "GTC" +} + +### Place Limit Order - Short +POST {{hyperliquidBaseUrl}}/api/trade/limit +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "side": "short", + "size": 0.1, + "price": 3500.0, + "time_in_force": "GTC" +} + +### Get Current Positions +GET {{hyperliquidBaseUrl}}/api/positions + +### Get Account Info +GET {{hyperliquidBaseUrl}}/api/account + +### Cancel Order +POST {{hyperliquidBaseUrl}}/api/orders/{{orderId}}/cancel + +### Get Available Assets (Universe) +GET {{hyperliquidBaseUrl}}/api/universe + +### Get Open Orders +GET {{hyperliquidBaseUrl}}/api/orders + +### Place Stop Loss +POST {{hyperliquidBaseUrl}}/api/stop-loss +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "trigger_price": {{stopLossTriggerPrice}} +} + +### Modify Stop Loss +POST {{hyperliquidBaseUrl}}/api/stop-loss/modify +Content-Type: application/json + +{ + "asset": "{{testAsset}}", + "order_id": "{{orderId}}", + "new_trigger_price": {{stopLossModifiedPrice}} +} + +### Cancel Stop Loss +POST {{hyperliquidBaseUrl}}/api/stop-loss/{{orderId}}/cancel +Content-Type: application/json + +{ + "asset": "{{testAsset}}" +} + +### Get Stop Losses +GET {{hyperliquidBaseUrl}}/api/stop-loss \ No newline at end of file diff --git a/hyperliquid-wallet/Cargo.toml b/hyperliquid-wallet/Cargo.toml new file mode 100644 index 0000000..e4953e5 --- /dev/null +++ b/hyperliquid-wallet/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "hyperliquid-wallet" +version = "0.1.0" +edition = "2021" + +[dependencies] +hyperliquid-common = { path = "../hyperliquid-common" } +trading-common = { path = "../trading-common" } +tokio = { workspace = true } +tonic = { workspace = true } +prost = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenv = { workspace = true } +rust_decimal = { workspace = true } +hyperliquid_rust_sdk = "0.6.0" +ethers = "2.0.14" \ No newline at end of file diff --git a/hyperliquid-wallet/src/main.rs b/hyperliquid-wallet/src/main.rs new file mode 100644 index 0000000..b8fd1e4 --- /dev/null +++ b/hyperliquid-wallet/src/main.rs @@ -0,0 +1,58 @@ +mod service; + +use anyhow::Result; +use hyperliquid_common::{HyperliquidClient, generated::wallet::wallet_service_server::WalletServiceServer}; +use service::HyperliquidWalletService; +use std::{env, net::SocketAddr, sync::Arc}; +use tonic::transport::Server; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + + // Initialize tracing + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .finish(); + tracing::subscriber::set_global_default(subscriber)?; + + info!("Starting Hyperliquid Wallet service..."); + + // Get configuration + let private_key = env::var("HYPERLIQUID_PRIVATE_KEY") + .expect("HYPERLIQUID_PRIVATE_KEY must be set"); + let testnet = env::var("HYPERLIQUID_TESTNET") + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .unwrap_or(false); + + // Initialize client + let client = Arc::new(HyperliquidClient::new(&private_key, testnet).await?); + + info!("Hyperliquid Wallet service initialized"); + + // Set up gRPC server + let wallet_service = HyperliquidWalletService::new(client); + let wallet_server = WalletServiceServer::new(wallet_service); + + let port = env::var("HYPERLIQUID_WALLET_PORT") + .unwrap_or_else(|_| "50052".to_string()) + .parse::()?; + + let addr: SocketAddr = format!("0.0.0.0:{}", port).parse()?; + + info!("Hyperliquid Wallet gRPC service listening on {}", addr); + + // Start the gRPC server + Server::builder() + .add_service(wallet_server) + .serve_with_shutdown(addr, async { + tokio::signal::ctrl_c().await.ok(); + info!("Shutting down Hyperliquid Wallet service..."); + }) + .await?; + + Ok(()) +} \ No newline at end of file diff --git a/hyperliquid-wallet/src/service.rs b/hyperliquid-wallet/src/service.rs new file mode 100644 index 0000000..b269f8a --- /dev/null +++ b/hyperliquid-wallet/src/service.rs @@ -0,0 +1,251 @@ +use hyperliquid_common::{ + generated::wallet::{ + wallet_service_server::WalletService, + GetWalletAddressRequest, GetWalletAddressResponse, + PlaceOrderRequest, PlaceOrderResponse, + CancelOrderRequest, CancelOrderResponse, + GetPositionsRequest, GetPositionsResponse, + GetAccountInfoRequest, GetAccountInfoResponse, + GetUniverseRequest, GetUniverseResponse, + OrderInfo, Position as ProtoPosition, Account as ProtoAccount, + }, + HyperliquidClient, SimpleOrderRequest, OrderSide, OrderType, TimeInForce, + LimitOrderType, TriggerOrderType, TpSl, +}; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use std::{str::FromStr, sync::Arc}; +use tonic::{Request, Response, Status}; + +pub struct HyperliquidWalletService { + client: Arc, +} + +impl HyperliquidWalletService { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[tonic::async_trait] +impl WalletService for HyperliquidWalletService { + async fn get_wallet_address( + &self, + _request: Request, + ) -> Result, Status> { + let address = self.client.get_wallet_address(); + + Ok(Response::new(GetWalletAddressResponse { address })) + } + + async fn place_order( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + // Convert side + let side = match req.side.to_lowercase().as_str() { + "buy" | "long" => OrderSide::Buy, + "sell" | "short" => OrderSide::Sell, + _ => return Ok(Response::new(PlaceOrderResponse { + success: false, + order: None, + error: Some("Invalid side, must be 'buy', 'sell', 'long', or 'short'".to_string()), + })), + }; + + // Convert order type + let order_type = match req.order_type.to_lowercase().as_str() { + "market" => OrderType::Trigger { + trigger: TriggerOrderType { + is_market: true, + trigger_px: "0".to_string(), + tpsl: TpSl::Tp, + }, + }, + "limit" => { + let tif = match req.time_in_force.as_deref() { + Some("IOC") => TimeInForce::Ioc, + Some("ALO") => TimeInForce::Alo, + _ => TimeInForce::Gtc, + }; + OrderType::Limit { + limit: LimitOrderType { tif }, + } + }, + _ => return Ok(Response::new(PlaceOrderResponse { + success: false, + order: None, + error: Some("Invalid order type, must be 'market' or 'limit'".to_string()), + })), + }; + + let order_request = SimpleOrderRequest { + asset: req.asset, + side, + order_type, + size: Decimal::from_f64_retain(req.size).unwrap_or(Decimal::ZERO), + price: req.price.map(|p| Decimal::from_f64_retain(p).unwrap_or(Decimal::ZERO)), + reduce_only: req.reduce_only.unwrap_or(false), + client_order_id: req.client_order_id, + slippage_tolerance: req.slippage_tolerance.map(|s| Decimal::from_f64_retain(s).unwrap_or(Decimal::ZERO)), + }; + + match self.client.place_order(order_request).await { + Ok(response) => { + let order_info = OrderInfo { + order_id: response.order_id, + client_order_id: response.client_order_id, + status: response.status, + filled_size: response.filled_size.to_f64().unwrap_or(0.0), + remaining_size: response.remaining_size.to_f64().unwrap_or(0.0), + average_fill_price: response.average_fill_price.map(|p| p.to_f64().unwrap_or(0.0)), + }; + + Ok(Response::new(PlaceOrderResponse { + success: true, + order: Some(order_info), + error: None, + })) + }, + Err(e) => Ok(Response::new(PlaceOrderResponse { + success: false, + order: None, + error: Some(e.to_string()), + })), + } + } + + async fn cancel_order( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let result = if let Some(asset) = req.asset { + // Try cancel by client order ID + self.client.cancel_order_by_cloid(&asset, &req.order_id).await + } else { + // Try cancel by order ID + self.client.cancel_order(&req.order_id).await + }; + + match result { + Ok(()) => Ok(Response::new(CancelOrderResponse { + success: true, + error: None, + })), + Err(e) => Ok(Response::new(CancelOrderResponse { + success: false, + error: Some(e.to_string()), + })), + } + } + + async fn get_positions( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let address = req.address.unwrap_or_else(|| self.client.get_wallet_address()); + + match self.client.get_positions(&address).await { + Ok(positions) => { + let proto_positions: Vec = positions + .into_iter() + .map(|pos| ProtoPosition { + asset: pos.asset, + side: match pos.side { + hyperliquid_common::PositionSide::Long => "long".to_string(), + hyperliquid_common::PositionSide::Short => "short".to_string(), + }, + size: pos.size.to_f64().unwrap_or(0.0), + entry_price: pos.entry_price.to_f64().unwrap_or(0.0), + mark_price: pos.mark_price.to_f64().unwrap_or(0.0), + unrealized_pnl: pos.unrealized_pnl.to_f64().unwrap_or(0.0), + margin: pos.margin.to_f64().unwrap_or(0.0), + leverage: pos.leverage as u32, + }) + .collect(); + + Ok(Response::new(GetPositionsResponse { + success: true, + positions: proto_positions, + error: None, + })) + }, + Err(e) => Ok(Response::new(GetPositionsResponse { + success: false, + positions: vec![], + error: Some(e.to_string()), + })), + } + } + + async fn get_account_info( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let address = req.address.unwrap_or_else(|| self.client.get_wallet_address()); + + match self.client.get_account_info(&address).await { + Ok(account) => { + let proto_positions: Vec = account.positions + .into_iter() + .map(|pos| ProtoPosition { + asset: pos.asset, + side: match pos.side { + hyperliquid_common::PositionSide::Long => "long".to_string(), + hyperliquid_common::PositionSide::Short => "short".to_string(), + }, + size: pos.size.to_f64().unwrap_or(0.0), + entry_price: pos.entry_price.to_f64().unwrap_or(0.0), + mark_price: pos.mark_price.to_f64().unwrap_or(0.0), + unrealized_pnl: pos.unrealized_pnl.to_f64().unwrap_or(0.0), + margin: pos.margin.to_f64().unwrap_or(0.0), + leverage: pos.leverage as u32, + }) + .collect(); + + let proto_account = ProtoAccount { + address: account.address, + equity: account.equity.to_f64().unwrap_or(0.0), + margin_used: account.margin_used.to_f64().unwrap_or(0.0), + available_margin: account.available_margin.to_f64().unwrap_or(0.0), + positions: proto_positions, + }; + + Ok(Response::new(GetAccountInfoResponse { + success: true, + account: Some(proto_account), + error: None, + })) + }, + Err(e) => Ok(Response::new(GetAccountInfoResponse { + success: false, + account: None, + error: Some(e.to_string()), + })), + } + } + + async fn get_universe( + &self, + _request: Request, + ) -> Result, Status> { + match self.client.get_universe().await { + Ok(assets) => Ok(Response::new(GetUniverseResponse { + success: true, + assets, + error: None, + })), + Err(e) => Ok(Response::new(GetUniverseResponse { + success: false, + assets: vec![], + error: Some(e.to_string()), + })), + } + } +} \ No newline at end of file