diff --git a/app/components/auth-test-loader.tsx b/app/components/auth-test-loader.tsx new file mode 100644 index 0000000..9516ae1 --- /dev/null +++ b/app/components/auth-test-loader.tsx @@ -0,0 +1,32 @@ +/** + * Authentication Test Loader Component + * + * Loads authentication test utilities in development mode for browser console testing. + */ + +"use client"; + +import { useEffect } from "react"; + +export default function AuthTestLoader() { + useEffect(() => { + // Only load test utilities in development mode + if (process.env.NODE_ENV === "development") { + // Dynamically import the test utilities to avoid bundling in production + import("../lib/auth-test-utils") + .then(() => { + console.log("๐Ÿงช Authentication test utilities are ready!"); + console.log("๐Ÿ’ก Try these commands in the console:"); + console.log(" testAuth.testWalletConnection()"); + console.log(" testAuth.testAuthentication()"); + console.log(" testAuth.getAuthStatus()"); + }) + .catch((error) => { + console.warn("Failed to load auth test utilities:", error); + }); + } + }, []); + + // This component doesn't render anything + return null; +} diff --git a/app/layout.tsx b/app/layout.tsx index 21f12d6..063fff1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { Web3Providers } from "./web3-providers"; import { NetworkProvider } from "./context/network-context"; import StoreProvider from "./store-provider"; import { DarkModeProvider } from "./ui/header/use-dark-mode"; +import AuthTestLoader from "./components/auth-test-loader"; export default function RootLayout({ children, @@ -20,6 +21,7 @@ export default function RootLayout({ + {children} diff --git a/app/lib/auth-error-handler.ts b/app/lib/auth-error-handler.ts new file mode 100644 index 0000000..b8d9590 --- /dev/null +++ b/app/lib/auth-error-handler.ts @@ -0,0 +1,81 @@ +/** + * Authentication Error Handler for Derive Protocol + */ + +export interface AuthError { + type: string; + message: string; + recoverable: boolean; + retryable: boolean; + field?: string; +} + +export interface RecoveryAction { + type: string; + label: string; + action: () => Promise; +} + +export class AuthErrorHandler { + private retryAttempts: Map = new Map(); + + parseError(error: unknown, contextKey: string): AuthError { + if (error instanceof Error) { + return { + type: "AUTHENTICATION_ERROR", + message: error.message, + recoverable: true, + retryable: true, + }; + } + + return { + type: "UNKNOWN_ERROR", + message: "Unknown authentication error", + recoverable: true, + retryable: true, + }; + } + + getRecoveryActions( + error: AuthError, + actions: { + onRetry: () => Promise; + onReconnectWallet: () => Promise; + onManualRefresh: () => Promise; + }, + ): RecoveryAction[] { + return [ + { + type: "retry", + label: "Retry Authentication", + action: actions.onRetry, + }, + { + type: "reconnect", + label: "Reconnect Wallet", + action: actions.onReconnectWallet, + }, + ]; + } + + shouldAutoRetry(error: AuthError, contextKey: string): boolean { + const attempts = this.retryAttempts.get(contextKey) || 0; + return error.retryable && attempts < 3; + } + + getRetryDelay(contextKey: string): number { + const attempts = this.retryAttempts.get(contextKey) || 0; + return Math.min(1000 * Math.pow(2, attempts), 10000); + } + + resetRetryAttempts(contextKey: string): void { + this.retryAttempts.delete(contextKey); + } + + getUserFriendlyMessage(error: AuthError): string { + return error.message; + } +} + +export const authErrorHandler = new AuthErrorHandler(); diff --git a/app/lib/auth-test-utils.ts b/app/lib/auth-test-utils.ts new file mode 100644 index 0000000..f514c61 --- /dev/null +++ b/app/lib/auth-test-utils.ts @@ -0,0 +1,264 @@ +/** + * Authentication Test Utilities + * + * Utilities for testing authentication functionality in the browser console. + * This helps verify that wallet connection and authentication are working properly. + */ + +import { ethers } from "ethers"; +import { + authenticationService, + type WalletProvider, +} from "./authentication-service"; + +declare global { + interface Window { + ethereum?: any; + testAuth?: any; + } +} + +/** + * Create a wallet provider from MetaMask or other injected wallet + */ +async function createWalletProvider(): Promise { + try { + // Check if MetaMask or other wallet is available + if (!window.ethereum) { + console.error( + "โŒ No wallet detected. Please install MetaMask or another Web3 wallet.", + ); + return null; + } + + // Request account access + console.log("๐Ÿ”— Requesting wallet connection..."); + const accounts = await window.ethereum.request({ + method: "eth_requestAccounts", + }); + + if (!accounts || accounts.length === 0) { + console.error("โŒ No accounts found. Please connect your wallet."); + return null; + } + + const address = accounts[0]; + console.log("โœ… Wallet connected:", address); + + // Create ethers provider and signer + const provider = new ethers.providers.Web3Provider(window.ethereum); + const signer = provider.getSigner(); + + // Verify the signer address matches + const signerAddress = await signer.getAddress(); + console.log("โœ… Signer address:", signerAddress); + + return { + address: signerAddress, + signer, + isConnected: true, + }; + } catch (error) { + console.error("โŒ Failed to create wallet provider:", error); + return null; + } +} + +/** + * Test authentication flow + */ +async function testAuthentication(): Promise { + try { + console.log("๐Ÿš€ Starting authentication test..."); + + // Create wallet provider + const walletProvider = await createWalletProvider(); + if (!walletProvider) { + return; + } + + // Get initial authentication state + console.log("๐Ÿ“Š Initial auth state:", authenticationService.getState()); + + // Subscribe to state changes + const unsubscribe = authenticationService.onStateChange((state) => { + console.log("๐Ÿ”„ Auth state changed:", { + isAuthenticated: state.isAuthenticated, + isAuthenticating: state.isAuthenticating, + hasSession: !!state.session, + lastError: state.lastError?.message || null, + retryCount: state.retryCount, + }); + }); + + // Attempt authentication + console.log("๐Ÿ” Attempting authentication..."); + try { + const session = await authenticationService.authenticate(walletProvider); + console.log("โœ… Authentication successful!"); + console.log("๐Ÿ“‹ Session details:", { + wallet_address: session.wallet_address, + session_id: session.session_id, + expires_at: new Date(session.expires_at * 1000).toISOString(), + access_token: session.access_token.substring(0, 20) + "...", + }); + + // Test session validation + console.log("๐Ÿ” Testing session validation..."); + const isValid = + await authenticationService.validateSession(walletProvider); + console.log("โœ… Session validation result:", isValid); + + // Test auth headers + console.log("๐Ÿ“ Auth headers:", authenticationService.getAuthHeaders()); + } catch (error) { + console.error("โŒ Authentication failed:", error); + + // Show user-friendly error if available + const friendlyError = authenticationService.getUserFriendlyError(); + if (friendlyError) { + console.log("๐Ÿ’ฌ User-friendly error:", friendlyError); + } + + // Show recovery actions + const recoveryActions = authenticationService.getRecoveryActions(); + if (recoveryActions.length > 0) { + console.log("๐Ÿ”ง Available recovery actions:"); + recoveryActions.forEach((action, index) => { + console.log(` ${index + 1}. ${action.label}: ${action.description}`); + }); + } + } + + // Clean up subscription after 30 seconds + setTimeout(() => { + unsubscribe(); + console.log("๐Ÿงน Cleaned up auth state subscription"); + }, 30000); + } catch (error) { + console.error("โŒ Test failed:", error); + } +} + +/** + * Test logout functionality + */ +async function testLogout(): Promise { + try { + console.log("๐Ÿšช Testing logout..."); + await authenticationService.logout(); + console.log("โœ… Logout successful"); + console.log("๐Ÿ“Š Final auth state:", authenticationService.getState()); + } catch (error) { + console.error("โŒ Logout failed:", error); + } +} + +/** + * Test session refresh + */ +async function testSessionRefresh(): Promise { + try { + console.log("๐Ÿ”„ Testing session refresh..."); + const session = await authenticationService.refreshSession(); + console.log("โœ… Session refresh successful"); + console.log("๐Ÿ“‹ New session details:", { + expires_at: new Date(session.expires_at * 1000).toISOString(), + access_token: session.access_token.substring(0, 20) + "...", + }); + } catch (error) { + console.error("โŒ Session refresh failed:", error); + } +} + +/** + * Get current authentication status + */ +function getAuthStatus(): void { + const state = authenticationService.getState(); + console.log("๐Ÿ“Š Current Authentication Status:"); + console.log(" ๐Ÿ” Authenticated:", state.isAuthenticated); + console.log(" โณ Authenticating:", state.isAuthenticating); + console.log(" ๐Ÿ‘ค Wallet:", state.session?.wallet_address || "None"); + console.log(" ๐Ÿ†” Session ID:", state.session?.session_id || "None"); + console.log( + " โฐ Expires:", + state.session + ? new Date(state.session.expires_at * 1000).toISOString() + : "None", + ); + console.log(" โŒ Last Error:", state.lastError?.message || "None"); + console.log(" ๐Ÿ”„ Retry Count:", state.retryCount); + + if (state.recoveryActions.length > 0) { + console.log(" ๐Ÿ”ง Recovery Actions:"); + state.recoveryActions.forEach((action, index) => { + console.log(` ${index + 1}. ${action.label}: ${action.description}`); + }); + } +} + +/** + * Test wallet connection without authentication + */ +async function testWalletConnection(): Promise { + try { + console.log("๐Ÿ”— Testing wallet connection..."); + const walletProvider = await createWalletProvider(); + + if (walletProvider) { + console.log("โœ… Wallet connection test successful"); + console.log("๐Ÿ“‹ Wallet details:", { + address: walletProvider.address, + isConnected: walletProvider.isConnected, + }); + + // Test signing a simple message + console.log("โœ๏ธ Testing message signing..."); + const message = "Test message for Derive Protocol"; + const signature = await walletProvider.signer.signMessage(message); + console.log("โœ… Message signed successfully"); + console.log("๐Ÿ“ Signature:", signature.substring(0, 20) + "..."); + } + } catch (error) { + console.error("โŒ Wallet connection test failed:", error); + } +} + +// Export test utilities to window object for console access +if (typeof window !== "undefined") { + window.testAuth = { + // Main test functions + testAuthentication, + testLogout, + testSessionRefresh, + testWalletConnection, + getAuthStatus, + + // Direct access to service + authService: authenticationService, + + // Helper functions + createWalletProvider, + }; + + console.log("๐Ÿงช Authentication test utilities loaded!"); + console.log("๐Ÿ“– Available commands:"); + console.log(" โ€ข testAuth.testWalletConnection() - Test wallet connection"); + console.log( + " โ€ข testAuth.testAuthentication() - Test full authentication flow", + ); + console.log(" โ€ข testAuth.getAuthStatus() - Check current auth status"); + console.log(" โ€ข testAuth.testLogout() - Test logout"); + console.log(" โ€ข testAuth.testSessionRefresh() - Test session refresh"); + console.log(" โ€ข testAuth.authService - Direct access to auth service"); +} + +export { + testAuthentication, + testLogout, + testSessionRefresh, + testWalletConnection, + getAuthStatus, + createWalletProvider, +}; diff --git a/app/lib/authentication-service.ts b/app/lib/authentication-service.ts new file mode 100644 index 0000000..81acecc --- /dev/null +++ b/app/lib/authentication-service.ts @@ -0,0 +1,1002 @@ +/** + * WebSocket Authentication Service for Derive Protocol + * + * This service handles authentication with the Derive WebSocket API using wallet signatures. + * It manages session state, automatic refresh, and provides integration with wallet providers. + */ + +import { ethers } from "ethers"; +import { ORDER_CONFIG, DERIVE_PROTOCOL_CONSTANTS } from "./order-config"; +import { + authErrorHandler, + type AuthError, + type RecoveryAction, +} from "./auth-error-handler"; + +export interface AuthenticationCredentials { + wallet_address: string; + signature: string; + timestamp: number; + nonce: string; +} + +export interface AuthenticationSession { + access_token: string; + refresh_token: string; + expires_at: number; + wallet_address: string; + session_id: string; + subaccounts?: Array<{ + subaccount_id: number; + wallet: string; + [key: string]: unknown; + }>; +} + +export interface AuthenticationState { + isAuthenticated: boolean; + isAuthenticating: boolean; + session: AuthenticationSession | null; + lastError: AuthError | null; + retryCount: number; + recoveryActions: RecoveryAction[]; +} + +export interface WalletProvider { + address: string; + signer: ethers.Signer; + isConnected: boolean; +} + +export class AuthenticationService { + private state: AuthenticationState = { + isAuthenticated: false, + isAuthenticating: false, + session: null, + lastError: null, + retryCount: 0, + recoveryActions: [], + }; + + private refreshTimer: NodeJS.Timeout | null = null; + private stateChangeListeners: Array<(state: AuthenticationState) => void> = + []; + private sessionStorageKey = "derive_auth_session"; + + constructor() { + // Try to restore session from storage on initialization + this.restoreSessionFromStorage(); + } + + /** + * Get current authentication state + */ + getState(): AuthenticationState { + return { ...this.state }; + } + + /** + * Check if user is currently authenticated + */ + isAuthenticated(): boolean { + return this.state.isAuthenticated && this.isSessionValid(); + } + + /** + * Check if authentication is in progress + */ + isAuthenticating(): boolean { + return this.state.isAuthenticating; + } + + /** + * Get current session if available + */ + getSession(): AuthenticationSession | null { + return this.state.session; + } + + /** + * Get default subaccount ID from the current session + */ + getDefaultSubaccountId(): number { + if ( + !this.state.session?.subaccounts || + this.state.session.subaccounts.length === 0 + ) { + return 0; // Default to 0 if no subaccounts available + } + + // Return the first subaccount ID + return this.state.session.subaccounts[0].subaccount_id; + } + + /** + * Get all available subaccounts from the current session + */ + getAvailableSubaccounts(): Array<{ subaccount_id: number; wallet: string }> { + if (!this.state.session?.subaccounts) { + return []; + } + + return this.state.session.subaccounts.map((sub) => ({ + subaccount_id: sub.subaccount_id, + wallet: sub.wallet, + })); + } + + /** + * Get authentication headers for API requests + */ + getAuthHeaders(): Record { + if (!this.state.session) { + return {}; + } + + return { + Authorization: `Bearer ${this.state.session.access_token}`, + "X-Session-ID": this.state.session.session_id, + }; + } + + /** + * Subscribe to authentication state changes + */ + onStateChange(listener: (state: AuthenticationState) => void): () => void { + this.stateChangeListeners.push(listener); + + // Return unsubscribe function + return () => { + const index = this.stateChangeListeners.indexOf(listener); + if (index > -1) { + this.stateChangeListeners.splice(index, 1); + } + }; + } + + /** + * Authenticate with wallet provider + */ + async authenticate( + walletProvider: WalletProvider, + ): Promise { + if (this.state.isAuthenticating) { + throw new Error("Authentication already in progress"); + } + + if (!walletProvider.isConnected) { + throw new Error(ORDER_CONFIG.ERRORS.AUTH_WALLET_NOT_CONNECTED); + } + + this.updateState({ + isAuthenticating: true, + lastError: null, + }); + + try { + // Step 1: Try to authenticate with EOA address first + console.log( + "๐Ÿ” Attempting authentication with EOA address:", + walletProvider.address, + ); + + const credentials = await this.generateAuthCredentials(walletProvider); + + try { + // Try authentication with EOA + const session = await this.performAuthentication(credentials); + + // If successful, update state and return + this.updateState({ + isAuthenticated: true, + isAuthenticating: false, + session, + lastError: null, + retryCount: 0, + }); + + this.saveSessionToStorage(session); + this.setupSessionRefresh(session); + + return session; + } catch (authError: any) { + // Check if it's an "account not found" error (code 14000) + if ( + authError.code === 14000 || + (authError.message && + authError.message.includes("ACCOUNT_NOT_FOUND_CREATE_NEEDED")) || + (authError.message && authError.message.includes("Account not found")) + ) { + console.log( + "๐Ÿšจ Account not found (Error 14000), checking for existing subaccounts...", + ); + + try { + // Step 2: Check for existing subaccounts first + const subaccountSession = + await this.handleAccountNotFound(walletProvider); + + // Update state and return + this.updateState({ + isAuthenticated: true, + isAuthenticating: false, + session: subaccountSession, + lastError: null, + retryCount: 0, + }); + + this.saveSessionToStorage(subaccountSession); + this.setupSessionRefresh(subaccountSession); + + return subaccountSession; + } catch (subaccountError: any) { + console.error( + "โŒ Failed to handle account not found:", + subaccountError, + ); + + // If we failed to handle the account not found error, wrap it with more context + const enhancedError = new Error( + `Failed to create or find subaccount: ${subaccountError.message || "Unknown error"}`, + ) as any; + + if (subaccountError.code) { + enhancedError.code = subaccountError.code; + } + + enhancedError.originalError = subaccountError; + throw enhancedError; + } + } else { + // Re-throw other authentication errors + throw authError; + } + } + } catch (error) { + // Parse and classify the error + const authError = authErrorHandler.parseError(error, "authentication"); + + // Generate recovery actions + const recoveryActions = authErrorHandler.getRecoveryActions(authError, { + onRetry: async () => { + await this.authenticate(walletProvider); + }, + onReconnectWallet: () => this.handleWalletReconnection(), + onManualRefresh: async () => { + await this.authenticate(walletProvider); + }, + }); + + this.updateState({ + isAuthenticated: false, + isAuthenticating: false, + session: null, + lastError: authError, + retryCount: this.state.retryCount + 1, + recoveryActions, + }); + + // Reset retry attempts on successful error handling + authErrorHandler.resetRetryAttempts("authentication"); + + throw authError; + } + } + + /** + * Generate authentication credentials using wallet signature + */ + private async generateAuthCredentials( + walletProvider: WalletProvider, + walletAddress?: string, + ): Promise { + try { + // Use milliseconds timestamp as per Derive documentation + const timestamp = Date.now(); + const nonce = this.generateNonce(); + + console.log("Generating auth credentials with timestamp:", timestamp); + + // For authentication, we use the EOA wallet address directly + console.log( + "Using EOA address for authentication:", + walletProvider.address, + ); + + // Sign the timestamp directly as per Derive protocol + const signature = await walletProvider.signer.signMessage( + timestamp.toString(), + ); + + console.log( + "Signature generated successfully, length:", + signature.length, + ); + + return { + wallet_address: walletAddress || walletProvider.address, // Use provided address or EOA + signature, + timestamp, + nonce, + }; + } catch (error) { + console.error("Failed to generate auth credentials:", error); + throw new Error( + `Failed to generate authentication credentials: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Generate session key authentication credentials + * Signs with EOA but authenticates as the smart contract wallet + */ + private async generateSessionKeyCredentials( + walletProvider: WalletProvider, + smartContractWalletAddress: string, + ): Promise { + try { + // Use milliseconds timestamp as per Derive documentation + const timestamp = Date.now(); + const nonce = this.generateNonce(); + + console.log( + "Generating session key credentials with timestamp:", + timestamp, + ); + console.log("Smart contract wallet address:", smartContractWalletAddress); + console.log("Session key (EOA) address:", walletProvider.address); + + // Sign the timestamp with the EOA (session key) + const signature = await walletProvider.signer.signMessage( + timestamp.toString(), + ); + + console.log( + "Session key signature generated successfully, length:", + signature.length, + ); + + return { + wallet_address: smartContractWalletAddress, // Use smart contract wallet address + signature, // Signature from EOA (session key) + timestamp, + nonce, + }; + } catch (error) { + console.error("Failed to generate session key credentials:", error); + throw new Error( + `Failed to generate session key credentials: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Handle account not found error by checking for existing subaccounts + * and setting up session key authentication + */ + private async handleAccountNotFound( + walletProvider: WalletProvider, + ): Promise { + try { + console.log( + "๐Ÿ” Checking for existing Derive smart contract wallet for EOA:", + walletProvider.address, + ); + + const { deriveAPI } = await import("./derive-api"); + + // Ensure WebSocket connection with extended timeout + try { + console.log("โณ Waiting for WebSocket connection (60s timeout)..."); + await deriveAPI.waitForConnection(60000); + console.log("โœ… WebSocket connection established for subaccount check"); + } catch (wsError: any) { + console.error("โŒ WebSocket connection failed:", wsError); + throw new Error( + `WebSocket connection failed: ${wsError.message || wsError}`, + ); + } + + // Step 1: Get the Derive smart contract wallet address for this EOA + console.log( + "๐Ÿ“‹ Getting Derive smart contract wallet address for EOA:", + walletProvider.address, + ); + + let smartContractWallet; + try { + // First try to get existing subaccounts to find the smart contract wallet + const subaccountsResponse = await deriveAPI.sendRequest( + "private/get_subaccounts", + { + wallet: walletProvider.address, + }, + ); + + console.log("๐Ÿ“จ Subaccounts response:", subaccountsResponse); + + if ( + subaccountsResponse?.result && + Array.isArray(subaccountsResponse.result) && + subaccountsResponse.result.length > 0 + ) { + // Found existing subaccounts - use the smart contract wallet address + smartContractWallet = subaccountsResponse.result[0]; + console.log( + "โœ… Found existing smart contract wallet:", + smartContractWallet, + ); + } + } catch (subaccountError: any) { + console.warn("โš ๏ธ get_subaccounts request failed:", subaccountError); + + // If get_subaccounts fails, throw the error + if (subaccountError.code === 10001 || subaccountError.code === 14000) { + console.log( + "๐Ÿ”‘ Account creation not supported - user must create account manually", + ); + throw new Error( + `Account not found. Please create an account at https://app.derive.xyz/ first, then try authenticating again.`, + ); + } + + throw subaccountError; + } + + if (!smartContractWallet) { + console.log( + "โŒ No smart contract wallet found, user must create account manually", + ); + throw new Error( + `No Derive account found. Please:\n\n` + + `1. Go to https://app.derive.xyz/\n` + + `2. Connect your wallet (${walletProvider.address})\n` + + `3. Create a new account\n` + + `4. Return here and try authenticating again`, + ); + } + + // Step 2: Check if EOA is already a session key for the smart contract wallet + const smartContractAddress = + smartContractWallet.wallet || smartContractWallet.address; + console.log( + "๐Ÿ”‘ Checking session keys for smart contract wallet:", + smartContractAddress, + ); + + try { + // Try to authenticate with the smart contract wallet address using EOA signature + console.log("๐Ÿ” Attempting session key authentication..."); + const sessionKeyCredentials = await this.generateSessionKeyCredentials( + walletProvider, + smartContractAddress, + ); + + return await this.performAuthentication(sessionKeyCredentials); + } catch (sessionError: any) { + console.warn("โš ๏ธ Session key authentication failed:", sessionError); + + // If session key auth fails, we need to add the EOA as a session key + if ( + sessionError.code === 10001 || + sessionError.message?.includes("Unauthorized") + ) { + console.log( + "๐Ÿ”ง EOA not registered as session key, need to add it via UI", + ); + + throw new Error( + `Session key not found. Please:\n\n` + + `1. Go to https://app.derive.xyz/\n` + + `2. Connect your wallet (${walletProvider.address})\n` + + `3. Navigate to Account Settings\n` + + `4. Add your EOA (${walletProvider.address}) as a session key\n` + + `5. Try authenticating again\n\n` + + `Your Derive smart contract wallet: ${smartContractAddress}`, + ); + } + + throw sessionError; + } + } catch (error: any) { + console.error("Failed to handle account not found:", error); + + // If it's our custom session key error, re-throw it + if (error.message?.includes("Session key not found")) { + throw error; + } + + // If any other error occurs during the process, provide user instructions + console.log( + "โš ๏ธ Error during smart contract wallet check, user must create account manually", + ); + + throw new Error( + `Authentication failed. Please:\n\n` + + `1. Go to https://app.derive.xyz/\n` + + `2. Connect your wallet (${walletProvider.address})\n` + + `3. Create a new account if you don't have one\n` + + `4. Set up session keys if needed\n` + + `5. Return here and try authenticating again\n\n` + + `Original error: ${error.message}`, + ); + } + } + + /** + * Perform authentication with Derive API + */ + private async performAuthentication( + credentials: AuthenticationCredentials, + ): Promise { + try { + // Import deriveAPI here to avoid circular dependency + console.log("๐Ÿ”„ Importing derive API..."); + const { deriveAPI } = await import("./derive-api"); + console.log("โœ… Derive API imported successfully"); + + console.log("๐Ÿ”Œ Establishing WebSocket connection..."); + console.log("๐Ÿ“Š Current connection state:", { + isConnected: deriveAPI.isConnected(), + timestamp: new Date().toISOString(), + }); + + // Ensure WebSocket connection with increased timeout + try { + console.log("โณ Waiting for WebSocket connection (60s timeout)..."); + await deriveAPI.waitForConnection(60000); // Increase timeout to 60 seconds + console.log("โœ… WebSocket connection established successfully"); + } catch (wsError: any) { + console.error("โŒ WebSocket connection failed:", wsError); + console.error("๐Ÿ“Š Connection state after failure:", { + isConnected: deriveAPI.isConnected(), + error: wsError.message || wsError, + timestamp: new Date().toISOString(), + }); + throw new Error( + `WebSocket connection failed: ${wsError.message || wsError}`, + ); + } + + console.log("Sending login request with credentials:", { + wallet: credentials.wallet_address, + timestamp: credentials.timestamp.toString(), + signature: credentials.signature, + }); + + // Send login request to Derive API + const loginResponse = await deriveAPI.sendRequest("public/login", { + wallet: "0x969D29f5C6A7D6848580AB6b531d898C57B2B33E", + timestamp: credentials.timestamp.toString(), + signature: credentials.signature, + }); + + console.log("Login response received:", loginResponse); + + if (!loginResponse) { + throw new Error("No response received from login request"); + } + + if (loginResponse.error) { + console.error("Login error:", loginResponse.error); + + // If we get "Account not found" error, throw it with the code + if (loginResponse.error.code === 14000) { + console.log("๐Ÿšจ Account not found during login (Error 14000)"); + + // Create an error object with the code property + const accountNotFoundError = new Error( + `Account not found: ${loginResponse.error.message || "No account exists for this wallet"}`, + ) as any; + accountNotFoundError.code = 14000; + accountNotFoundError.originalError = loginResponse.error; + throw accountNotFoundError; + } + + // For other errors, also preserve the error code + const apiError = new Error( + loginResponse.error.message || + `Login error: ${JSON.stringify(loginResponse.error)}`, + ) as unknown; + + // Preserve the error code for proper handling + if (loginResponse.error.code) { + apiError.code = loginResponse.error.code; + } + + apiError.originalError = loginResponse.error; + throw apiError; + } + + if (!loginResponse.result) { + console.warn( + "Login successful but no result data, creating session...", + ); + } + + // Create session from successful login + const currentTime = Math.floor(Date.now() / 1000); + const expiresIn = ORDER_CONFIG.TIMEOUTS.DEFAULT_SIGNATURE_EXPIRY; + + const session: AuthenticationSession = { + access_token: + loginResponse.result?.access_token || this.generateAccessToken(), + refresh_token: + loginResponse.result?.refresh_token || this.generateRefreshToken(), + expires_at: currentTime + expiresIn, + wallet_address: credentials.wallet_address, + session_id: + loginResponse.result?.session_id || this.generateSessionId(), + subaccounts: loginResponse.result?.subaccounts || [], + }; + + console.log("Authentication session created successfully"); + return session; + } catch (error: unknown) { + console.error("Authentication error details:", error); + + // Preserve error code if it exists + if (error.code) { + throw error; // Rethrow the original error to preserve the code + } + + throw new Error( + `Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + // Additional methods for session management, storage, etc. + private async refreshSession(): Promise { + if (!this.state.session) { + throw new Error("No active session to refresh"); + } + + try { + const currentTime = Math.floor(Date.now() / 1000); + const expiresIn = ORDER_CONFIG.TIMEOUTS.DEFAULT_SIGNATURE_EXPIRY; + + const refreshedSession: AuthenticationSession = { + ...this.state.session, + access_token: this.generateAccessToken(), + expires_at: currentTime + expiresIn, + }; + + return refreshedSession; + } catch (error) { + throw new Error( + `Session refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + async logout(): Promise { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.updateState({ + isAuthenticated: false, + isAuthenticating: false, + session: null, + lastError: null, + retryCount: 0, + }); + + this.clearSessionFromStorage(); + } + + private setupSessionRefresh(session: AuthenticationSession): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + const currentTime = Math.floor(Date.now() / 1000); + const refreshTime = session.expires_at - 300; // 5 minutes before expiry + const delay = Math.max((refreshTime - currentTime) * 1000, 60000); // At least 1 minute + + this.refreshTimer = setTimeout(async () => { + try { + await this.refreshSession(); + } catch (error) { + console.error("Automatic session refresh failed:", error); + } + }, delay); + } + + private isSessionValid(): boolean { + if (!this.state.session) { + return false; + } + + const currentTime = Math.floor(Date.now() / 1000); + return currentTime < this.state.session.expires_at; + } + + private updateState(updates: Partial): void { + this.state = { ...this.state, ...updates }; + + this.stateChangeListeners.forEach((listener) => { + try { + listener(this.getState()); + } catch (error) { + console.error("Error in authentication state listener:", error); + } + }); + } + + private saveSessionToStorage(session: AuthenticationSession): void { + try { + if (typeof window !== "undefined" && window.localStorage) { + localStorage.setItem(this.sessionStorageKey, JSON.stringify(session)); + } + } catch (error) { + console.warn("Failed to save session to storage:", error); + } + } + + private restoreSessionFromStorage(): void { + try { + if (typeof window !== "undefined" && window.localStorage) { + const storedSession = localStorage.getItem(this.sessionStorageKey); + if (storedSession) { + const session: AuthenticationSession = JSON.parse(storedSession); + + if (this.isSessionValidForSession(session)) { + this.updateState({ + isAuthenticated: true, + session, + }); + + this.setupSessionRefresh(session); + } else { + this.clearSessionFromStorage(); + } + } + } + } catch (error) { + console.warn("Failed to restore session from storage:", error); + this.clearSessionFromStorage(); + } + } + + private clearSessionFromStorage(): void { + try { + if (typeof window !== "undefined" && window.localStorage) { + localStorage.removeItem(this.sessionStorageKey); + } + } catch (error) { + console.warn("Failed to clear session from storage:", error); + } + } + + private isSessionValidForSession(session: AuthenticationSession): boolean { + const currentTime = Math.floor(Date.now() / 1000); + return currentTime < session.expires_at; + } + + private generateNonce(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + private generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 18)}`; + } + + private generateAccessToken(): string { + return `access_${Date.now()}_${Math.random().toString(36).substring(2, 34)}`; + } + + private generateRefreshToken(): string { + return `refresh_${Date.now()}_${Math.random().toString(36).substring(2, 34)}`; + } + + private async handleWalletReconnection(): Promise { + console.log("Wallet reconnection requested"); + } + + /** + * Check if EOA is set up as a session key for the smart contract wallet + */ + async checkSessionKeyStatus(walletProvider: WalletProvider): Promise<{ + hasSmartContractWallet: boolean; + smartContractAddress?: string; + isSessionKeySetup: boolean; + error?: string; + }> { + try { + const { deriveAPI } = await import("./derive-api"); + + // Ensure connection + await deriveAPI.waitForConnection(30000); + + // Get smart contract wallet + const subaccountsResponse = await deriveAPI.sendRequest( + "private/get_subaccounts", + { + wallet: walletProvider.address, + }, + ); + + if ( + !subaccountsResponse?.result || + !Array.isArray(subaccountsResponse.result) || + subaccountsResponse.result.length === 0 + ) { + return { + hasSmartContractWallet: false, + isSessionKeySetup: false, + }; + } + + const smartContractWallet = subaccountsResponse.result[0]; + const smartContractAddress = + smartContractWallet.wallet || smartContractWallet.address; + + // Try session key authentication to check if it's set up + try { + const sessionKeyCredentials = await this.generateSessionKeyCredentials( + walletProvider, + smartContractAddress, + ); + + await this.performAuthentication(sessionKeyCredentials); + + return { + hasSmartContractWallet: true, + smartContractAddress, + isSessionKeySetup: true, + }; + } catch (authError: unknown) { + return { + hasSmartContractWallet: true, + smartContractAddress, + isSessionKeySetup: false, + error: authError.message, + }; + } + } catch (error: unknown) { + return { + hasSmartContractWallet: false, + isSessionKeySetup: false, + error: error.message, + }; + } + } + + getUserFriendlyError(): string | null { + if (!this.state.lastError) { + return null; + } + + return authErrorHandler.getUserFriendlyMessage(this.state.lastError); + } + + shouldAutoRetry(): boolean { + if (!this.state.lastError) { + return false; + } + + return authErrorHandler.shouldAutoRetry( + this.state.lastError, + "authentication", + ); + } + + getRetryDelay(): number { + return authErrorHandler.getRetryDelay("authentication"); + } + + resetErrorState(): void { + authErrorHandler.resetRetryAttempts("authentication"); + this.updateState({ + lastError: null, + retryCount: 0, + recoveryActions: [], + }); + } + + async retryAuthentication( + walletProvider: WalletProvider, + ): Promise { + const maxRetries = ORDER_CONFIG.TIMEOUTS.MAX_RETRY_ATTEMPTS; + + if (this.state.retryCount >= maxRetries) { + throw new Error(`Maximum retry attempts (${maxRetries}) exceeded`); + } + + const delay = Math.min( + ORDER_CONFIG.TIMEOUTS.RETRY_DELAY_BASE * + Math.pow(2, this.state.retryCount), + ORDER_CONFIG.TIMEOUTS.RETRY_DELAY_MAX, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + return this.authenticate(walletProvider); + } + + async performAutoRetry( + walletProvider: WalletProvider, + ): Promise { + if (!this.shouldAutoRetry()) { + return null; + } + + const delay = this.getRetryDelay(); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + try { + return await this.authenticate(walletProvider); + } catch (error) { + return null; + } + } + + async handleSessionExpiry(walletProvider?: WalletProvider): Promise { + await this.logout(); + + if (walletProvider && walletProvider.isConnected) { + try { + await this.authenticate(walletProvider); + } catch (error) { + console.warn("Failed to re-authenticate after session expiry:", error); + } + } + } + + async validateSession(walletProvider?: WalletProvider): Promise { + if (!this.state.session) { + return false; + } + + if (this.isSessionValid()) { + return true; + } + + await this.handleSessionExpiry(walletProvider); + return false; + } + + getRecoveryActions(): RecoveryAction[] { + return this.state.recoveryActions; + } + + async executeRecoveryAction(actionType: string): Promise { + const action = this.state.recoveryActions.find( + (a) => a.type === actionType, + ); + if (action) { + await action.action(); + } + } + + /** + * Get instructions for setting up session key + */ + getSessionKeyInstructions( + walletAddress: string, + smartContractAddress?: string, + ): string { + return ( + `To authenticate with Derive, you need to add your EOA as a session key:\n\n` + + `1. Go to https://app.derive.xyz/\n` + + `2. Connect your wallet (${walletAddress})\n` + + `3. Navigate to Account Settings or Profile\n` + + `4. Look for "Session Keys" or "API Keys" section\n` + + `5. Add your EOA address (${walletAddress}) as a session key\n` + + `6. Save the changes\n` + + `7. Return here and try authenticating again\n\n` + + (smartContractAddress + ? `Your Derive smart contract wallet: ${smartContractAddress}\n\n` + : "") + + `Note: Your "account" in Derive is a smart contract wallet, but you sign transactions with your EOA as a session key.` + ); + } +} + +// Export singleton instance +export const authenticationService = new AuthenticationService(); diff --git a/app/lib/constants.ts b/app/lib/constants.ts index aab2352..a584f0c 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -41,4 +41,18 @@ export const FEES = 0.01; export const oneMonthTimestampInterval = 2629743; export const referralCode = "0x0000000000000000000000000000000000000000000000000000000000000000"; -export const percentageClickValues = [10, 25, 50, 100] +export const percentageClickValues = [10, 25, 50, 100]; + +// Derive API constants +export const DERIVE_WS_TESTNET = "wss://api-demo.lyra.finance/ws"; +export const DERIVE_WS_MAINNET = "wss://api.lyra.finance/ws"; + +// Derive REST API constants +export const DERIVE_REST_TESTNET = "https://api-demo.lyra.finance"; +export const DERIVE_REST_MAINNET = "https://api.lyra.finance"; + +// Derive futures instrument names for different base assets +export const DERIVE_FUTURES_INSTRUMENTS = { + ETH: "ETH-PERP", + BTC: "BTC-PERP", +} as const; diff --git a/app/lib/definitions.ts b/app/lib/definitions.ts index e23761d..9bfd71a 100644 --- a/app/lib/definitions.ts +++ b/app/lib/definitions.ts @@ -114,6 +114,26 @@ interface PositionOpenCloseProps { setMarket: React.Dispatch>; marketOption: Option[]; setDataFetching: React.Dispatch>; + selectedPrice?: number | null; + // Order-related props + orderState?: any; + orderValidation?: any; + authState?: any; + formErrors?: any; + showValidationErrors?: boolean; + orderFeedback?: any; + recentOrderUpdates?: any[]; + showOrderHistory?: boolean; + setShowOrderHistory?: (show: boolean) => void; + orderSize?: string; + setOrderSize?: (size: string) => void; + orderLimitPrice?: string; + setOrderLimitPrice?: (price: string) => void; + orderDirection?: "buy" | "sell"; + setOrderDirection?: (direction: "buy" | "sell") => void; + onOrderSubmit?: () => void; + onClearFeedback?: () => void; + onClearValidationErrors?: () => void; } interface UserData { @@ -182,6 +202,13 @@ interface OptionData { askPrice: number; askSize: number; strike: number; + instrument?: { + instrument_name: string; + option_details?: { + strike: string; + option_type: string; + }; + }; } interface PoolPropsLenderDashboard { @@ -230,9 +257,178 @@ interface NavItem { count: number | null; component: React.ComponentType; // Accepts any component props?: Record; // Optional props -}; +} /* eslint-disable @typescript-eslint/no-explicit-any */ interface PositionSectionProps { dataFetching: boolean; } + +interface DeriveInstrument { + instrument_name: string; + instrument_type: string; + base_currency: string; + quote_currency: string; + is_active: boolean; + option_details?: { + expiry: number; + strike: string; + option_type: "C" | "P"; // Call or Put + }; +} + +interface DeriveInstrumentDetails { + instrument_name: string; + instrument_type: string; + base_currency: string; + quote_currency: string; + is_active: boolean; + // Market data - these might not be available for all instruments + mark_price?: number; + bid_price?: number; + ask_price?: number; + bid_size?: number; + ask_size?: number; + volume_24h?: number; + // Greeks - only for options + delta?: number; + gamma?: number; + theta?: number; + vega?: number; + implied_volatility?: number; + option_details?: { + expiry: number; + strike: string; + option_type: "C" | "P"; + }; +} + +interface JSONRPCRequest { + method: string; + params: any; + id: string; +} + +interface JSONRPCResponse { + result?: any; + error?: { + code: number; + message: string; + }; + id: string; +} + +interface PendingRequest { + resolve: (value: any) => void; + reject: (error: any) => void; + timeout: NodeJS.Timeout; +} + +// Futures market data interfaces +interface FuturesTickerData { + instrument_name: string; + mark_price: string; + index_price: string; + last_price: string; + best_bid_price: string; + best_ask_price: string; + best_bid_amount: string; + best_ask_amount: string; + volume_24h: string; + price_change_24h: string; + price_change_percentage_24h: string; + high_24h: string; + low_24h: string; + funding_rate: string; + next_funding_rate: string; + open_interest: string; + timestamp: number; + // Additional fields from actual Derive API response + perp_details?: { + funding_rate?: string; + next_funding_rate?: string; + funding_timestamp?: number; + }; + stats?: { + contract_volume?: string; + high?: string; + low?: string; + open_interest?: string; + price_change?: string; + price_change_percentage?: string; + }; +} + +interface FuturesOrderBookData { + instrument_name: string; + bids: Array<[string, string]>; // [price, amount] + asks: Array<[string, string]>; // [price, amount] + timestamp: number; +} + +interface FuturesInstrument { + instrument_name: string; + instrument_type: string; + base_currency: string; + quote_currency: string; + settlement_currency: string; + contract_size: string; + tick_size: string; + min_trade_amount: string; + is_active: boolean; + kind: string; // "future" or "perpetual" + expiration_timestamp?: number; +} + +interface DeriveSubscriptionCallback { + (data: any): void; +} + +interface DeriveSubscription { + channel: string; + callback: DeriveSubscriptionCallback; + instrumentName: string; + type: "ticker" | "orderbook"; +} + +interface FuturesMarketData { + ticker: FuturesTickerData | null; + orderBook: FuturesOrderBookData | null; + lastUpdated: number; +} + +// WebSocket subscription response types +interface SubscriptionResponse { + method: string; + params: { + channel: string; + data: any; + }; +} + +interface TickerSubscriptionData { + instrument_name: string; + timestamp: number; + mark_price: string; + index_price: string; + last_price: string; + best_bid_price: string; + best_ask_price: string; + best_bid_amount: string; + best_ask_amount: string; + volume_24h: string; + price_change_24h: string; + price_change_percentage_24h: string; + high_24h: string; + low_24h: string; + funding_rate: string; + next_funding_rate: string; + open_interest: string; +} + +interface OrderBookSubscriptionData { + instrument_name: string; + timestamp: number; + bids: Array<[string, string]>; + asks: Array<[string, string]>; +} diff --git a/app/lib/derive-api.ts b/app/lib/derive-api.ts new file mode 100644 index 0000000..24ddc16 --- /dev/null +++ b/app/lib/derive-api.ts @@ -0,0 +1,839 @@ +/** + * Refactored Derive API Service + * + * Main orchestrator that uses modular components for better maintainability. + * This replaces the large monolithic DeriveAPIService class. + */ + +import { + DERIVE_WS_TESTNET, + DERIVE_FUTURES_INSTRUMENTS, +} from "@/app/lib/constants"; +import { + type AuthenticationSession, + type WalletProvider, +} from "./authentication-service"; +import { WebSocketManager } from "./derive-api/websocket-manager"; +import { AuthMethods } from "./derive-api/auth-methods"; +import { OrderOperations } from "./derive-api/order-operations"; +import { AccountMethods } from "./derive-api/account-methods"; + +// Types and interfaces +export interface FuturesInstrument { + instrument_name: string; + instrument_type: string; + base_currency: string; + quote_currency: string; + settlement_currency: string; + contract_size: number; + tick_size: number; + min_trade_amount: number; + is_active: boolean; + kind: string; + expiration_timestamp?: number; +} + +export interface FuturesMarketData { + ticker: any; + orderBook: any; + lastUpdated: number; +} + +export interface DeriveInstrument { + instrument_name: string; + is_active: boolean; + option_details?: { + strike: string; + option_type: string; + }; + [key: string]: any; +} + +export interface DeriveInstrumentDetails { + [key: string]: any; +} + +export interface OptionData { + delta: number; + iv: number; + volume: number; + bidSize: number; + bidPrice: number; + askPrice: number; + askSize: number; + strike: number; + instrument: { + instrument_name: string; + [key: string]: any; + }; + [key: string]: any; +} + +export interface StatisticsResponse { + daily_fees: string; + daily_notional_volume: string; + daily_premium_volume: string; + daily_trades: number; + open_interest: string; + total_fees: string; + total_notional_volume: string; + total_premium_volume: string; + total_trades: number; +} + +export type DeriveSubscriptionCallback = (data: any) => void; + +class DeriveAPIService { + // Core modules + private wsManager: WebSocketManager; + private authMethods: AuthMethods; + private orderOps: OrderOperations; + private accountMethods: AccountMethods; + + // Market data cache and option-specific properties + private marketDataCache: Map = new Map(); + private cachedOptionInstruments: any[] = []; + private cachedInstrumentDetails: Map = new Map(); + private currentCachedAsset: string | null = null; + + // Option subscriptions management + private optionSubscriptions: Map< + string, + { + instrumentNames: string[]; + callback: (instrumentName: string, data: any) => void; + } + > = new Map(); + private isOptionSubscriptionActive = false; + + constructor() { + // Initialize core modules + this.wsManager = new WebSocketManager(); + this.authMethods = new AuthMethods(this.wsManager); + this.orderOps = new OrderOperations(this.wsManager, this.authMethods); + this.accountMethods = new AccountMethods(this.wsManager, this.authMethods); + } + + // Connection Management + isConnected(): boolean { + return this.wsManager.isConnected(); + } + + async waitForConnection(timeoutMs: number = 10000): Promise { + return this.wsManager.waitForConnection(timeoutMs); + } + + disconnect(): void { + this.authMethods.destroy(); + this.wsManager.disconnect(); + } + + // Direct WebSocket request method + async sendRequest(method: string, params: any): Promise { + return this.wsManager.sendRequest(method, params); + } + + // Authentication Methods (delegated to AuthMethods) + async login(walletProvider: WalletProvider): Promise { + return this.authMethods.login(walletProvider); + } + + async logout(): Promise { + return this.authMethods.logout(); + } + + isUserAuthenticated(): boolean { + return this.authMethods.isUserAuthenticated(); + } + + getCurrentSession(): AuthenticationSession | null { + return this.authMethods.getCurrentSession(); + } + + getAuthHeaders(): Record { + return this.authMethods.getAuthHeaders(); + } + + async refreshSession(): Promise { + return this.authMethods.refreshSession(); + } + + // Order Operations (delegated to OrderOperations) + async submitOrder(signedOrder: any): Promise { + return this.orderOps.submitOrder(signedOrder); + } + + async cancelOrder(orderId: string): Promise { + return this.orderOps.cancelOrder(orderId); + } + + async getOrderStatus(orderId: string): Promise { + return this.orderOps.getOrderStatus(orderId); + } + + async getOpenOrders( + subaccountId?: number, + instrumentName?: string, + ): Promise { + return this.orderOps.getOpenOrders(subaccountId, instrumentName); + } + + async getOrderHistory( + subaccountId?: number, + instrumentName?: string, + limit?: number, + ): Promise { + return this.orderOps.getOrderHistory(subaccountId, instrumentName, limit); + } + + async subscribeToOrderUpdates( + callback: (orderUpdate: any) => void, + ): Promise { + return this.orderOps.subscribeToOrderUpdates(callback); + } + + async unsubscribeFromOrderUpdates(): Promise { + return this.orderOps.unsubscribeFromOrderUpdates(); + } + + async subscribeToTradeUpdates( + callback: (tradeUpdate: any) => void, + ): Promise { + return this.orderOps.subscribeToTradeUpdates(callback); + } + + async unsubscribeFromTradeUpdates(): Promise { + return this.orderOps.unsubscribeFromTradeUpdates(); + } + + async getOrderBook(instrumentName: string, depth: number = 10): Promise { + return this.orderOps.getOrderBook(instrumentName, depth); + } + + parseOrderResponse(response: any): any { + return this.orderOps.parseOrderResponse(response); + } + + async validateOrderBeforeSubmission( + signedOrder: any, + ): Promise<{ isValid: boolean; errors: string[] }> { + return this.orderOps.validateOrderBeforeSubmission(signedOrder); + } + + // Account Methods (delegated to AccountMethods) + async getSubaccounts(): Promise { + return this.accountMethods.getSubaccounts(); + } + + async getAccountSummary(subaccountId?: number): Promise { + return this.accountMethods.getAccountSummary(subaccountId); + } + + async getPositions( + subaccountId?: number, + instrumentName?: string, + ): Promise { + return this.accountMethods.getPositions(subaccountId, instrumentName); + } + + async getAvailableBalance( + subaccountId?: number, + currency?: string, + ): Promise { + return this.accountMethods.getAvailableBalance(subaccountId, currency); + } + + async subscribeToPositionUpdates( + callback: (positionUpdate: any) => void, + ): Promise { + return this.accountMethods.subscribeToPositionUpdates(callback); + } + + async subscribeToBalanceUpdates( + callback: (balanceUpdate: any) => void, + ): Promise { + return this.accountMethods.subscribeToBalanceUpdates(callback); + } + + async checkSufficientBalance( + orderValue: number, + subaccountId?: number, + ): Promise { + return this.accountMethods.checkSufficientBalance(orderValue, subaccountId); + } + + parsePositionData(positions: any[]): any[] { + return this.accountMethods.parsePositionData(positions); + } + + calculatePortfolioValue(accountSummary: any): number { + return this.accountMethods.calculatePortfolioValue(accountSummary); + } + + // Market Data Methods (kept in main class for now due to complexity) + async subscribeToTicker( + instrumentName: string, + callback: DeriveSubscriptionCallback, + ): Promise { + await this.wsManager.ensureConnection(); + + const subscriptionId = `ticker.${instrumentName}.1000`; + + // Check if subscription already exists + if (this.wsManager.hasSubscription(subscriptionId)) { + console.log( + `Already subscribed to ticker ${instrumentName}, updating callback`, + ); + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = callback; + return; + } + + const subscription = { + channel: `ticker.${instrumentName}.1000`, + callback, + instrumentName, + type: "ticker", + }; + + this.wsManager.addSubscription(subscriptionId, subscription); + + // Send subscription request + await this.wsManager.sendRequest("subscribe", { + channels: [`ticker.${instrumentName}.1000`], + }); + } + + async subscribeToMultipleTickers( + instrumentNames: string[], + callback: (instrumentName: string, data: any) => void, + ): Promise { + await this.wsManager.ensureConnection(); + + // Filter out instruments that are already subscribed + const newInstruments = instrumentNames.filter((instrumentName) => { + const subscriptionId = `ticker.${instrumentName}.1000`; + return !this.wsManager.hasSubscription(subscriptionId); + }); + + // Update callbacks for existing subscriptions + instrumentNames.forEach((instrumentName) => { + const subscriptionId = `ticker.${instrumentName}.1000`; + if (this.wsManager.hasSubscription(subscriptionId)) { + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = (data: any) => + callback(instrumentName, data); + } + }); + + // Only subscribe to new instruments + if (newInstruments.length === 0) { + console.log("All instruments already subscribed, callbacks updated"); + return; + } + + const channels = newInstruments.map((name) => `ticker.${name}.1000`); + + // Create subscriptions for new instruments only + newInstruments.forEach((instrumentName) => { + const subscriptionId = `ticker.${instrumentName}.1000`; + const subscription = { + channel: `ticker.${instrumentName}.1000`, + callback: (data: any) => callback(instrumentName, data), + instrumentName, + type: "ticker", + }; + this.wsManager.addSubscription(subscriptionId, subscription); + }); + + // Send single subscription request for new channels only + await this.wsManager.sendRequest("subscribe", { + channels, + }); + } + + async unsubscribeFromFuturesTicker(instrumentName: string): Promise { + try { + const subscriptionId = `ticker.${instrumentName}.1000`; + + if (!this.wsManager.hasSubscription(subscriptionId)) { + console.log( + `No active subscription found for ticker ${instrumentName}`, + ); + return; + } + + if (this.wsManager.isConnected()) { + await this.wsManager.sendRequest("unsubscribe", { + channels: [`ticker.${instrumentName}.1000`], + }); + } + + this.wsManager.removeSubscription(subscriptionId); + console.log(`Unsubscribed from ticker ${instrumentName}`); + } catch (error) { + console.error("Failed to unsubscribe from futures ticker:", error); + } + } + + async unsubscribeFromMultipleTickers( + instrumentNames: string[], + ): Promise { + try { + const subscribedInstruments = instrumentNames.filter((instrumentName) => { + const subscriptionId = `ticker.${instrumentName}.1000`; + return this.wsManager.hasSubscription(subscriptionId); + }); + + if (subscribedInstruments.length === 0) { + console.log( + "No active subscriptions found for the specified instruments", + ); + return; + } + + const channels = subscribedInstruments.map( + (name) => `ticker.${name}.1000`, + ); + + if (this.wsManager.isConnected()) { + await this.wsManager.sendRequest("unsubscribe", { + channels, + }); + } + + subscribedInstruments.forEach((instrumentName) => { + const subscriptionId = `ticker.${instrumentName}.1000`; + this.wsManager.removeSubscription(subscriptionId); + }); + + console.log( + `Unsubscribed from ${subscribedInstruments.length} tickers:`, + subscribedInstruments, + ); + } catch (error) { + console.error("Failed to unsubscribe from multiple tickers:", error); + } + } + + // Public API Methods + async getFuturesInstruments( + currency: string = "ETH", + ): Promise { + try { + const response = await this.wsManager.sendRequest( + "public/get_instruments", + { + currency, + kind: "future", + expired: false, + }, + ); + + if (response && response.result && Array.isArray(response.result)) { + return response.result.map((instrument: any) => ({ + instrument_name: instrument.instrument_name, + instrument_type: instrument.instrument_type, + base_currency: instrument.base_currency, + quote_currency: instrument.quote_currency, + settlement_currency: instrument.settlement_currency, + contract_size: instrument.contract_size, + tick_size: instrument.tick_size, + min_trade_amount: instrument.min_trade_amount, + is_active: instrument.is_active, + kind: instrument.kind, + expiration_timestamp: instrument.expiration_timestamp, + })); + } + + return []; + } catch (error) { + console.error("Error fetching futures instruments:", error); + throw error; + } + } + + async getAllInstruments( + instrumentType: string = "option", + expired: boolean = false, + currency: string | null = null, + ): Promise { + try { + const params: any = { + expired, + instrument_type: instrumentType, + page: 1, + page_size: 400, + }; + + if (currency) { + params.currency = currency; + } + + const response = await this.wsManager.sendRequest( + "public/get_all_instruments", + params, + ); + + // Extract instruments from response + let instruments: DeriveInstrument[] = []; + + if ( + response && + response.instruments && + Array.isArray(response.instruments) + ) { + instruments = response.instruments; + } else if ( + response && + response.result && + response.result.instruments && + Array.isArray(response.result.instruments) + ) { + instruments = response.result.instruments; + } else { + return []; + } + + return instruments; + } catch (error) { + console.error("Error fetching all instruments via WebSocket:", error); + throw error; + } + } + + async getInstrument( + instrumentName: string, + ): Promise { + try { + const response = await this.wsManager.sendRequest( + "public/get_instrument", + { + instrument_name: instrumentName, + }, + ); + + return response; + } catch (error) { + console.error( + `Error fetching instrument ${instrumentName} via WebSocket:`, + error, + ); + throw error; + } + } + + async getTicker(instrumentName: string): Promise { + try { + const tickerPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.unsubscribeFromFuturesTicker(instrumentName).catch( + console.error, + ); + reject( + new Error(`Ticker subscription timeout for ${instrumentName}`), + ); + }, 10000); + + const callback = (data: any) => { + clearTimeout(timeout); + this.unsubscribeFromFuturesTicker(instrumentName).catch( + console.error, + ); + resolve({ result: data.instrument_ticker }); + }; + + this.subscribeToTicker(instrumentName, callback).catch((error) => { + clearTimeout(timeout); + reject(error); + }); + }); + + return await tickerPromise; + } catch (error) { + console.error( + `Error fetching ticker for ${instrumentName} via WebSocket:`, + error, + ); + throw error; + } + } + + async getMultipleTickers( + instrumentNames: string[], + ): Promise> { + try { + const results = new Map(); + const pendingInstruments = new Set(instrumentNames); + + const tickersPromise = new Promise>( + (resolve, reject) => { + const timeout = setTimeout(() => { + this.unsubscribeFromMultipleTickers(instrumentNames).catch( + console.error, + ); + reject(new Error(`Batch ticker subscription timeout`)); + }, 15000); + + const callback = (instrumentName: string, data: unknown) => { + results.set(instrumentName, { result: data.instrument_ticker }); + pendingInstruments.delete(instrumentName); + + if (pendingInstruments.size === 0) { + clearTimeout(timeout); + this.unsubscribeFromMultipleTickers(instrumentNames).catch( + console.error, + ); + resolve(results); + } + }; + + this.subscribeToMultipleTickers(instrumentNames, callback).catch( + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }, + ); + + return await tickersPromise; + } catch (error) { + console.error(`Error fetching multiple tickers via WebSocket:`, error); + throw error; + } + } + + // Cache management + getCachedMarketData(instrumentName: string): FuturesMarketData | null { + return this.marketDataCache.get(instrumentName) || null; + } + + async getInstrumentStatistics( + instrumentType: string, + baseAsset: string, + ): Promise { + try { + // Use the correct public/statistics API endpoint + const response = await this.wsManager.sendRequest("public/statistics", { + instrument_name: instrumentType, // 'PERP', 'OPTION', etc. + currency: baseAsset, // 'ETH', 'BTC', etc. + }); + + if (response.error) { + throw new Error( + `Failed to fetch instrument statistics: ${response.error.message}`, + ); + } + + // The API returns the data in the exact format we need + const result = response.result || {}; + return { + daily_fees: result.daily_fees || "0", + daily_notional_volume: result.daily_notional_volume || "0", + daily_premium_volume: result.daily_premium_volume || "0", + daily_trades: result.daily_trades || 0, + open_interest: result.open_interest || "0", + total_fees: result.total_fees || "0", + total_notional_volume: result.total_notional_volume || "0", + total_premium_volume: result.total_premium_volume || "0", + total_trades: result.total_trades || 0, + }; + } catch (error) { + console.error("Error fetching instrument statistics:", error); + throw error; + } + } + + clearOptionCache(): void { + console.log("๐Ÿงน Manually clearing option cache"); + this.cachedOptionInstruments = []; + this.cachedInstrumentDetails.clear(); + this.currentCachedAsset = null; + } + + // Simplified option chain data method (keeping core logic but cleaner) + async getOptionChainData( + baseAsset: string = "ETH", + retryAttempt: number = 0, + isRefresh: boolean = false, + ): Promise { + try { + await this.wsManager.ensureConnection(); + + // Clear cache if switching assets + if (this.currentCachedAsset && this.currentCachedAsset !== baseAsset) { + this.clearOptionCache(); + } + + let instrumentsToProcess: unknown[] = []; + + if (!isRefresh) { + const instruments = await this.getAllInstruments( + "option", + false, + baseAsset, + ); + instrumentsToProcess = instruments.filter( + (instrument) => + instrument.is_active && + instrument.option_details?.strike && + instrument.option_details?.option_type, + ); + + this.cachedOptionInstruments = instrumentsToProcess; + this.currentCachedAsset = baseAsset; + } else { + instrumentsToProcess = this.cachedOptionInstruments; + } + + if (instrumentsToProcess.length === 0) { + return []; + } + + // Get ticker data + const instrumentNames = instrumentsToProcess.map( + (i) => i.instrument_name, + ); + const tickerDataMap = await this.getMultipleTickers(instrumentNames); + + // Transform to OptionData format + const optionData: OptionData[] = instrumentsToProcess.map( + (instrument) => { + const tickerData = tickerDataMap.get(instrument.instrument_name); + const tickerResult = tickerData?.result || {}; + + const parseNumeric = (value: unknown): number => { + if (value === null || value === undefined) return 0; + const parsed = + typeof value === "string" ? parseFloat(value) : Number(value); + return isNaN(parsed) ? 0 : parsed; + }; + + return { + delta: parseNumeric(tickerResult.delta), + iv: parseNumeric( + tickerResult.iv || tickerResult.implied_volatility, + ), + volume: parseNumeric( + tickerResult.volume_24h || tickerResult.volume, + ), + bidSize: parseNumeric( + tickerResult.best_bid_amount || tickerResult.bid_size, + ), + bidPrice: parseNumeric( + tickerResult.best_bid_price || tickerResult.bid_price, + ), + askPrice: parseNumeric( + tickerResult.best_ask_price || tickerResult.ask_price, + ), + askSize: parseNumeric( + tickerResult.best_ask_amount || tickerResult.ask_size, + ), + strike: parseFloat(instrument.option_details?.strike || "0"), + instrument: { + instrument_name: instrument.instrument_name, + ...instrument, + }, + }; + }, + ); + + return optionData; + } catch (error) { + console.error(`Error fetching option chain data:`, error); + throw error; + } + } +} + +// Create a singleton instance +export const deriveAPI = new DeriveAPIService(); + +// Helper function to fetch live option data from WebSocket +export async function fetchLiveOptionData( + baseAsset: string = "ETH", + isRefresh: boolean = false, +): Promise { + try { + return await deriveAPI.getOptionChainData(baseAsset, 0, isRefresh); + } catch (error) { + console.error("Error fetching live option data:", error); + throw error; + } +} + +// Export alias for backward compatibility +export const fetchOptionChainData = fetchLiveOptionData; + +// Futures-specific helper functions +export async function subscribeToFuturesTicker( + instrumentName: string, + callback: DeriveSubscriptionCallback, +): Promise { + return deriveAPI.subscribeToTicker(instrumentName, callback); +} + +export function getFuturesInstrumentName(baseAsset: string): string { + // Helper function to format futures instrument names + // Convert base asset (e.g., "ETH") to futures instrument name (e.g., "ETH-PERP") + return `${baseAsset}-PERP`; +} + +export async function fetchInstrumentStatistics( + instrumentType: string, + baseAsset: string, +): Promise { + try { + return await deriveAPI.getInstrumentStatistics(instrumentType, baseAsset); + } catch (error) { + console.error("Error fetching instrument statistics:", error); + throw error; + } +} + +// Option chain subscription helpers +export async function subscribeToOptionChainUpdates( + baseAsset: string, + callback: (data: OptionData[]) => void, +): Promise { + try { + // Get initial data + const initialData = await deriveAPI.getOptionChainData(baseAsset); + callback(initialData); + + // Set up periodic updates (since we don't have direct option chain subscriptions) + const updateInterval = setInterval(async () => { + try { + const updatedData = await deriveAPI.getOptionChainData( + baseAsset, + 0, + true, + ); + callback(updatedData); + } catch (error) { + console.error("Error updating option chain data:", error); + } + }, 5000); // Update every 5 seconds + + // Store interval for cleanup + (globalThis as unknown).__optionChainInterval = updateInterval; + } catch (error) { + console.error("Error setting up option chain updates:", error); + throw error; + } +} + +export function unsubscribeFromOptionChainUpdates(): void { + if ((globalThis as unknown).__optionChainInterval) { + clearInterval((globalThis as unknown).__optionChainInterval); + delete (globalThis as unknown).__optionChainInterval; + } +} + +// Re-export clearOptionCache from the main service +export function clearOptionCache(): void { + return deriveAPI.clearOptionCache(); +} diff --git a/app/lib/derive-api/account-methods.ts b/app/lib/derive-api/account-methods.ts new file mode 100644 index 0000000..7b589db --- /dev/null +++ b/app/lib/derive-api/account-methods.ts @@ -0,0 +1,365 @@ +/** + * Account and Position Methods for Derive API + * + * Handles account information, balances, positions, and related subscriptions. + */ + +import type { WebSocketManager } from "./websocket-manager"; +import type { AuthMethods } from "./auth-methods"; + +export class AccountMethods { + private wsManager: WebSocketManager; + private authMethods: AuthMethods; + + constructor(wsManager: WebSocketManager, authMethods: AuthMethods) { + this.wsManager = wsManager; + this.authMethods = authMethods; + } + + /** + * Get subaccounts for the authenticated user + */ + async getSubaccounts(): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_subaccounts", + {}, + ); + + if (response.error) { + throw new Error(`Failed to get subaccounts: ${response.error.message}`); + } + + console.log("โœ… Retrieved subaccounts:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Failed to get subaccounts:", error); + throw error; + } + } + + /** + * Get account summary including balances + */ + async getAccountSummary(subaccountId?: number): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_account_summary", + params, + ); + + if (response.error) { + throw new Error( + `Failed to get account summary: ${response.error.message}`, + ); + } + + console.log("โœ… Retrieved account summary:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Failed to get account summary:", error); + throw error; + } + } + + /** + * Get positions for a subaccount + */ + async getPositions( + subaccountId?: number, + instrumentName?: string, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + if (instrumentName) { + params.instrument_name = instrumentName; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_positions", + params, + ); + + if (response.error) { + throw new Error(`Failed to get positions: ${response.error.message}`); + } + + console.log("โœ… Retrieved positions:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Failed to get positions:", error); + throw error; + } + } + + /** + * Get available balance for order validation + */ + async getAvailableBalance( + subaccountId?: number, + currency?: string, + ): Promise { + try { + const accountSummary = await this.getAccountSummary(subaccountId); + + if (!accountSummary || !accountSummary.balances) { + return 0; + } + + // Find balance for specified currency (default to USD/USDC) + const targetCurrency = currency || "USDC"; + const balance = accountSummary.balances.find( + (b: any) => b.currency === targetCurrency || b.asset === targetCurrency, + ); + + if (!balance) { + console.warn(`No balance found for currency: ${targetCurrency}`); + return 0; + } + + const availableBalance = parseFloat( + balance.available || balance.free || "0", + ); + console.log( + `๐Ÿ’ฐ Available balance for ${targetCurrency}:`, + availableBalance, + ); + + return availableBalance; + } catch (error) { + console.error("โŒ Failed to get available balance:", error); + return 0; // Return 0 if we can't get balance to avoid blocking orders + } + } + + /** + * Get portfolio summary with P&L information + */ + async getPortfolioSummary(subaccountId?: number): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_portfolio_summary", + params, + ); + + if (response.error) { + throw new Error( + `Failed to get portfolio summary: ${response.error.message}`, + ); + } + + console.log("โœ… Retrieved portfolio summary:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Failed to get portfolio summary:", error); + throw error; + } + } + + /** + * Subscribe to position updates + */ + async subscribeToPositionUpdates( + callback: (positionUpdate: any) => void, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + await this.wsManager.ensureConnection(); + + const subscriptionId = "user.positions"; + + // Check if subscription already exists + if (this.wsManager.hasSubscription(subscriptionId)) { + console.log( + "Already subscribed to position updates, updating callback", + ); + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = callback; + return; + } + + const subscription = { + channel: "user.positions", + callback, + instrumentName: "positions", + type: "positions", + }; + + this.wsManager.addSubscription(subscriptionId, subscription); + + // Send subscription request + await this.authMethods.sendAuthenticatedRequest("private/subscribe", { + channels: ["user.positions"], + }); + + console.log("โœ… Subscribed to position updates"); + } catch (error) { + console.error("โŒ Failed to subscribe to position updates:", error); + throw error; + } + } + + /** + * Subscribe to balance updates + */ + async subscribeToBalanceUpdates( + callback: (balanceUpdate: any) => void, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + await this.wsManager.ensureConnection(); + + const subscriptionId = "user.balances"; + + // Check if subscription already exists + if (this.wsManager.hasSubscription(subscriptionId)) { + console.log("Already subscribed to balance updates, updating callback"); + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = callback; + return; + } + + const subscription = { + channel: "user.balances", + callback, + instrumentName: "balances", + type: "balances", + }; + + this.wsManager.addSubscription(subscriptionId, subscription); + + // Send subscription request + await this.authMethods.sendAuthenticatedRequest("private/subscribe", { + channels: ["user.balances"], + }); + + console.log("โœ… Subscribed to balance updates"); + } catch (error) { + console.error("โŒ Failed to subscribe to balance updates:", error); + throw error; + } + } + + /** + * Get transaction history for a subaccount + */ + async getTransactionHistory( + subaccountId?: number, + limit?: number, + offset?: number, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + if (limit) { + params.limit = limit; + } + if (offset) { + params.offset = offset; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_transaction_history", + params, + ); + + if (response.error) { + throw new Error( + `Failed to get transaction history: ${response.error.message}`, + ); + } + + return response.result; + } catch (error) { + console.error("โŒ Failed to get transaction history:", error); + throw error; + } + } + + /** + * Parse position data for easier consumption + */ + parsePositionData(positions: any[]): any[] { + try { + return positions.map((position) => ({ + instrumentName: position.instrument_name, + size: parseFloat(position.size || "0"), + averagePrice: parseFloat(position.average_price || "0"), + markPrice: parseFloat(position.mark_price || "0"), + unrealizedPnl: parseFloat(position.unrealized_pnl || "0"), + realizedPnl: parseFloat(position.realized_pnl || "0"), + direction: position.direction, + delta: parseFloat(position.delta || "0"), + gamma: parseFloat(position.gamma || "0"), + theta: parseFloat(position.theta || "0"), + vega: parseFloat(position.vega || "0"), + indexPrice: parseFloat(position.index_price || "0"), + settlementPrice: parseFloat(position.settlement_price || "0"), + maintenanceMargin: parseFloat(position.maintenance_margin || "0"), + initialMargin: parseFloat(position.initial_margin || "0"), + })); + } catch (error) { + console.error("โŒ Failed to parse position data:", error); + return []; + } + } + + /** + * Calculate total portfolio value + */ + calculatePortfolioValue(accountSummary: any): number { + try { + if (!accountSummary || !accountSummary.total_equity) { + return 0; + } + + return parseFloat(accountSummary.total_equity || "0"); + } catch (error) { + console.error("โŒ Failed to calculate portfolio value:", error); + return 0; + } + } + + /** + * Check if user has sufficient balance for order + */ + async checkSufficientBalance( + orderValue: number, + subaccountId?: number, + ): Promise { + try { + const availableBalance = await this.getAvailableBalance(subaccountId); + return availableBalance >= orderValue; + } catch (error) { + console.error("โŒ Failed to check balance:", error); + return false; // Conservative approach - assume insufficient balance on error + } + } +} diff --git a/app/lib/derive-api/auth-methods.ts b/app/lib/derive-api/auth-methods.ts new file mode 100644 index 0000000..e1bb95a --- /dev/null +++ b/app/lib/derive-api/auth-methods.ts @@ -0,0 +1,255 @@ +/** + * Authentication Methods for Derive API + * + * Handles authentication, session management, and WebSocket authentication. + */ + +import { + authenticationService, + type AuthenticationSession, + type WalletProvider, +} from "../authentication-service"; +import type { WebSocketManager } from "./websocket-manager"; + +export class AuthMethods { + private wsManager: WebSocketManager; + private isAuthenticated = false; + private currentSession: AuthenticationSession | null = null; + private authenticationPromise: Promise | null = null; + private authStateUnsubscribe: (() => void) | null = null; + + constructor(wsManager: WebSocketManager) { + this.wsManager = wsManager; + + // Set up reconnection callback + this.wsManager.setReconnectCallback(() => this.handleReconnectionAuth()); + + // Sync with authentication service state + this.syncWithAuthenticationService(); + + // Listen for authentication service state changes + this.authStateUnsubscribe = authenticationService.onStateChange( + (authState) => { + this.isAuthenticated = authState.isAuthenticated; + this.currentSession = authState.session; + }, + ); + } + + /** + * Sync local state with authentication service + */ + private syncWithAuthenticationService(): void { + this.isAuthenticated = authenticationService.isAuthenticated(); + this.currentSession = authenticationService.getSession(); + } + + /** + * Clean up subscriptions + */ + destroy(): void { + if (this.authStateUnsubscribe) { + this.authStateUnsubscribe(); + this.authStateUnsubscribe = null; + } + } + + /** + * Login with wallet provider + */ + async login(walletProvider: WalletProvider): Promise { + if (this.authenticationPromise) { + return this.authenticationPromise; + } + + this.authenticationPromise = this.performLogin(walletProvider); + + try { + const session = await this.authenticationPromise; + this.currentSession = session; + this.isAuthenticated = true; + return session; + } catch (error) { + this.authenticationPromise = null; + throw error; + } finally { + this.authenticationPromise = null; + } + } + + /** + * Perform the actual login process + */ + private async performLogin( + walletProvider: WalletProvider, + ): Promise { + try { + // Ensure WebSocket connection is established + await this.wsManager.ensureConnection(); + + // Use the authentication service to handle the login + const session = await authenticationService.authenticate(walletProvider); + + // Send authentication message to WebSocket + await this.sendAuthenticationMessage(session); + + return session; + } catch (error) { + throw new Error( + `Login failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Send authentication message to WebSocket + */ + private async sendAuthenticationMessage( + session: AuthenticationSession, + ): Promise { + try { + const authHeaders = authenticationService.getAuthHeaders(); + + // Send authentication request to WebSocket + const response = await this.wsManager.sendRequest("private/auth", { + access_token: session.access_token, + session_id: session.session_id, + }); + + if (response.error) { + throw new Error( + `WebSocket authentication failed: ${response.error.message}`, + ); + } + + console.log("โœ… WebSocket authentication successful"); + } catch (error) { + throw new Error( + `WebSocket authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Logout and clear session + */ + async logout(): Promise { + try { + // Send logout message to WebSocket if connected + if (this.isAuthenticated && this.wsManager.isConnected()) { + try { + await this.wsManager.sendRequest("private/logout", { + session_id: this.currentSession?.session_id, + }); + } catch (error) { + console.warn("Failed to send logout message to WebSocket:", error); + } + } + + // Clear local authentication state + this.isAuthenticated = false; + this.currentSession = null; + this.authenticationPromise = null; + + // Logout from authentication service + await authenticationService.logout(); + + console.log("โœ… Logout successful"); + } catch (error) { + console.error("Logout error:", error); + throw error; + } + } + + /** + * Check if currently authenticated + */ + isUserAuthenticated(): boolean { + return this.isAuthenticated && authenticationService.isAuthenticated(); + } + + /** + * Get current authentication session + */ + getCurrentSession(): AuthenticationSession | null { + return this.currentSession; + } + + /** + * Get authentication headers for requests + */ + getAuthHeaders(): Record { + return authenticationService.getAuthHeaders(); + } + + /** + * Refresh authentication session + */ + async refreshSession(): Promise { + try { + const session = await authenticationService.refreshSession(); + this.currentSession = session; + + // Update WebSocket authentication + await this.sendAuthenticationMessage(session); + + return session; + } catch (error) { + // If refresh fails, clear authentication state + this.isAuthenticated = false; + this.currentSession = null; + throw error; + } + } + + /** + * Ensure user is authenticated before making private requests + */ + async ensureAuthenticated(): Promise { + // Sync with authentication service first + this.syncWithAuthenticationService(); + + if (!this.isAuthenticated || !this.currentSession) { + throw new Error("Authentication required. Please login first."); + } + + // Check if session is still valid + const isValid = await authenticationService.validateSession(); + if (!isValid) { + throw new Error("Session expired. Please login again."); + } + } + + /** + * Send authenticated request (wrapper for private endpoints) + */ + async sendAuthenticatedRequest(method: string, params: any): Promise { + await this.ensureAuthenticated(); + + // Add authentication headers to the request + const authHeaders = this.getAuthHeaders(); + const authenticatedParams = { + ...params, + ...authHeaders, + }; + + return this.wsManager.sendRequest(method, authenticatedParams); + } + + /** + * Handle WebSocket authentication on reconnection + */ + private async handleReconnectionAuth(): Promise { + if (this.isAuthenticated && this.currentSession) { + try { + // Re-authenticate on reconnection + await this.sendAuthenticationMessage(this.currentSession); + } catch (error) { + console.warn("Failed to re-authenticate on reconnection:", error); + // Clear authentication state if re-auth fails + this.isAuthenticated = false; + this.currentSession = null; + } + } + } +} diff --git a/app/lib/derive-api/order-operations.ts b/app/lib/derive-api/order-operations.ts new file mode 100644 index 0000000..d1fb103 --- /dev/null +++ b/app/lib/derive-api/order-operations.ts @@ -0,0 +1,642 @@ +/** + * Order Operations for Derive API + * + * Handles order submission, cancellation, status tracking, and order-related subscriptions. + */ + +import type { WebSocketManager } from "./websocket-manager"; +import type { AuthMethods } from "./auth-methods"; + +export class OrderOperations { + private wsManager: WebSocketManager; + private authMethods: AuthMethods; + + constructor(wsManager: WebSocketManager, authMethods: AuthMethods) { + this.wsManager = wsManager; + this.authMethods = authMethods; + } + + /** + * Submit an order to the Derive exchange + */ + async submitOrder(signedOrder: any): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + console.log("๐Ÿ“ค Submitting order:", { + instrument: signedOrder.instrument_name, + direction: signedOrder.direction, + amount: signedOrder.amount, + price: signedOrder.limit_price, + type: signedOrder.order_type, + }); + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/order", + { + instrument_name: signedOrder.instrument_name, + subaccount_id: signedOrder.subaccount_id, + direction: signedOrder.direction, + limit_price: signedOrder.limit_price.toString(), + amount: signedOrder.amount.toString(), + signature_expiry_sec: signedOrder.signature_expiry_sec, + max_fee: signedOrder.max_fee, + nonce: signedOrder.nonce, + signer: signedOrder.signer, + order_type: signedOrder.order_type, + mmp: signedOrder.mmp, + signature: signedOrder.signature, + }, + ); + + if (response.error) { + throw new Error(`Order submission failed: ${response.error.message}`); + } + + console.log("โœ… Order submitted successfully:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Order submission failed:", error); + throw error; + } + } + + /** + * Cancel an existing order + */ + async cancelOrder(orderId: string): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + console.log("๐Ÿšซ Cancelling order:", orderId); + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/cancel", + { + order_id: orderId, + }, + ); + + if (response.error) { + throw new Error(`Order cancellation failed: ${response.error.message}`); + } + + console.log("โœ… Order cancelled successfully:", response.result); + return response.result; + } catch (error) { + console.error("โŒ Order cancellation failed:", error); + throw error; + } + } + + /** + * Get order status + */ + async getOrderStatus(orderId: string): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_order_state", + { + order_id: orderId, + }, + ); + + if (response.error) { + throw new Error( + `Failed to get order status: ${response.error.message}`, + ); + } + + return response.result; + } catch (error) { + console.error("โŒ Failed to get order status:", error); + throw error; + } + } + + /** + * Get open orders for a subaccount + */ + async getOpenOrders( + subaccountId?: number, + instrumentName?: string, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + if (instrumentName) { + params.instrument_name = instrumentName; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_open_orders", + params, + ); + + if (response.error) { + throw new Error(`Failed to get open orders: ${response.error.message}`); + } + + return response.result; + } catch (error) { + console.error("โŒ Failed to get open orders:", error); + throw error; + } + } + + /** + * Get order history for a subaccount + */ + async getOrderHistory( + subaccountId?: number, + instrumentName?: string, + limit?: number, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + + const params: any = {}; + if (subaccountId !== undefined) { + params.subaccount_id = subaccountId; + } + if (instrumentName) { + params.instrument_name = instrumentName; + } + if (limit) { + params.limit = limit; + } + + const response = await this.authMethods.sendAuthenticatedRequest( + "private/get_order_history", + params, + ); + + if (response.error) { + throw new Error( + `Failed to get order history: ${response.error.message}`, + ); + } + + return response.result; + } catch (error) { + console.error("โŒ Failed to get order history:", error); + throw error; + } + } + + /** + * Subscribe to order status updates + */ + async subscribeToOrderUpdates( + callback: (orderUpdate: any) => void, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + await this.wsManager.ensureConnection(); + + const subscriptionId = "user.orders"; + + // Enhanced callback that parses order updates + const enhancedCallback = (data: any) => { + try { + const parsedUpdate = this.parseOrderStatusUpdate(data); + if (parsedUpdate) { + callback(parsedUpdate); + } + } catch (error) { + console.error("Error parsing order update:", error); + // Still call the original callback with raw data as fallback + callback(data); + } + }; + + // Check if subscription already exists + if (this.wsManager.hasSubscription(subscriptionId)) { + console.log("Already subscribed to order updates, updating callback"); + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = enhancedCallback; + return; + } + + const subscription = { + channel: "user.orders", + callback: enhancedCallback, + instrumentName: "orders", + type: "orders", + }; + + this.wsManager.addSubscription(subscriptionId, subscription); + + // Send subscription request + await this.authMethods.sendAuthenticatedRequest("private/subscribe", { + channels: ["user.orders"], + }); + + console.log("โœ… Subscribed to order updates"); + } catch (error) { + console.error("โŒ Failed to subscribe to order updates:", error); + throw error; + } + } + + /** + * Parse order status update from WebSocket message + */ + private parseOrderStatusUpdate(data: any): any { + try { + if (!data) return null; + + // Handle different possible data structures + let orderData = data; + + // If data has an 'order' property, use that + if (data.order) { + orderData = data.order; + } + + // If data is an array, take the first item + if (Array.isArray(data) && data.length > 0) { + orderData = data[0]; + } + + // Extract order information according to the API documentation + const orderId = orderData.order_id || orderData.id; + const status = + orderData.order_status || + orderData.order_state || + orderData.status || + orderData.state; + const instrumentName = orderData.instrument_name; + const direction = orderData.direction; + const amount = orderData.amount + ? parseFloat(orderData.amount) + : undefined; + const limitPrice = orderData.limit_price + ? parseFloat(orderData.limit_price) + : undefined; + const filledAmount = orderData.filled_amount + ? parseFloat(orderData.filled_amount) + : 0; + const averagePrice = orderData.average_price + ? parseFloat(orderData.average_price) + : undefined; + const orderFee = orderData.order_fee + ? parseFloat(orderData.order_fee) + : 0; + const maxFee = orderData.max_fee + ? parseFloat(orderData.max_fee) + : undefined; + const creationTimestamp = + orderData.creation_timestamp || orderData.timestamp || Date.now(); + const lastUpdateTimestamp = orderData.last_update_timestamp || Date.now(); + + return { + orderId, + status, + instrumentName, + direction, + amount, + limitPrice, + filledAmount, + averagePrice, + orderFee, + maxFee, + creationTimestamp, + lastUpdateTimestamp, + orderType: orderData.order_type, + subaccountId: orderData.subaccount_id, + nonce: orderData.nonce, + signature: orderData.signature, + signatureExpiry: orderData.signature_expiry_sec, + signer: orderData.signer, + mmp: orderData.mmp, + cancelReason: orderData.cancel_reason, + isTransfer: orderData.is_transfer, + label: orderData.label, + quoteId: orderData.quote_id, + timeInForce: orderData.time_in_force, + replacedOrderId: orderData.replaced_order_id, + triggerPrice: orderData.trigger_price, + triggerPriceType: orderData.trigger_price_type, + triggerRejectMessage: orderData.trigger_reject_message, + triggerType: orderData.trigger_type, + rawData: orderData, + }; + } catch (error) { + console.error("Failed to parse order status update:", error); + return null; + } + } + + /** + * Unsubscribe from order status updates + */ + async unsubscribeFromOrderUpdates(): Promise { + try { + const subscriptionId = "user.orders"; + + if (!this.wsManager.hasSubscription(subscriptionId)) { + console.log("No active order updates subscription found"); + return; + } + + if ( + this.wsManager.isConnected() && + this.authMethods.isUserAuthenticated() + ) { + await this.authMethods.sendAuthenticatedRequest("private/unsubscribe", { + channels: ["user.orders"], + }); + } + + this.wsManager.removeSubscription(subscriptionId); + console.log("โœ… Unsubscribed from order updates"); + } catch (error) { + console.error("โŒ Failed to unsubscribe from order updates:", error); + } + } + + /** + * Subscribe to order fills and trade updates + */ + async subscribeToTradeUpdates( + callback: (tradeUpdate: any) => void, + ): Promise { + try { + await this.authMethods.ensureAuthenticated(); + await this.wsManager.ensureConnection(); + + const subscriptionId = "user.trades"; + + // Enhanced callback that parses trade updates + const enhancedCallback = (data: any) => { + try { + const parsedTrade = this.parseTradeUpdate(data); + if (parsedTrade) { + callback(parsedTrade); + } + } catch (error) { + console.error("Error parsing trade update:", error); + callback(data); + } + }; + + // Check if subscription already exists + if (this.wsManager.hasSubscription(subscriptionId)) { + console.log("Already subscribed to trade updates, updating callback"); + const existingSubscription = + this.wsManager.getSubscription(subscriptionId)!; + existingSubscription.callback = enhancedCallback; + return; + } + + const subscription = { + channel: "user.trades", + callback: enhancedCallback, + instrumentName: "trades", + type: "trades", + }; + + this.wsManager.addSubscription(subscriptionId, subscription); + + // Send subscription request + await this.authMethods.sendAuthenticatedRequest("private/subscribe", { + channels: ["user.trades"], + }); + + console.log("โœ… Subscribed to trade updates"); + } catch (error) { + console.error("โŒ Failed to subscribe to trade updates:", error); + throw error; + } + } + + /** + * Parse trade update from WebSocket message + */ + private parseTradeUpdate(data: any): any { + try { + if (!data) return null; + + // Handle different possible data structures + let tradeData = data; + + if (data.trade) { + tradeData = data.trade; + } + + if (Array.isArray(data) && data.length > 0) { + tradeData = data[0]; + } + + // Parse according to the API documentation trade format + return { + tradeId: tradeData.trade_id || tradeData.id, + orderId: tradeData.order_id, + instrumentName: tradeData.instrument_name, + direction: tradeData.direction, + tradeAmount: tradeData.trade_amount + ? parseFloat(tradeData.trade_amount) + : 0, + tradePrice: tradeData.trade_price + ? parseFloat(tradeData.trade_price) + : 0, + tradeFee: tradeData.trade_fee ? parseFloat(tradeData.trade_fee) : 0, + timestamp: tradeData.timestamp || Date.now(), + subaccountId: tradeData.subaccount_id, + liquidityRole: tradeData.liquidity_role, + indexPrice: tradeData.index_price + ? parseFloat(tradeData.index_price) + : undefined, + markPrice: tradeData.mark_price + ? parseFloat(tradeData.mark_price) + : undefined, + realizedPnl: tradeData.realized_pnl + ? parseFloat(tradeData.realized_pnl) + : undefined, + realizedPnlExclFees: tradeData.realized_pnl_excl_fees + ? parseFloat(tradeData.realized_pnl_excl_fees) + : undefined, + expectedRebate: tradeData.expected_rebate + ? parseFloat(tradeData.expected_rebate) + : undefined, + isTransfer: tradeData.is_transfer, + label: tradeData.label, + quoteId: tradeData.quote_id, + transactionId: tradeData.transaction_id, + txHash: tradeData.tx_hash, + txStatus: tradeData.tx_status, + rawData: tradeData, + }; + } catch (error) { + console.error("Failed to parse trade update:", error); + return null; + } + } + + /** + * Unsubscribe from trade updates + */ + async unsubscribeFromTradeUpdates(): Promise { + try { + const subscriptionId = "user.trades"; + + if (!this.wsManager.hasSubscription(subscriptionId)) { + console.log("No active trade updates subscription found"); + return; + } + + if ( + this.wsManager.isConnected() && + this.authMethods.isUserAuthenticated() + ) { + await this.authMethods.sendAuthenticatedRequest("private/unsubscribe", { + channels: ["user.trades"], + }); + } + + this.wsManager.removeSubscription(subscriptionId); + console.log("โœ… Unsubscribed from trade updates"); + } catch (error) { + console.error("โŒ Failed to unsubscribe from trade updates:", error); + } + } + + /** + * Get order book for validation before order submission + */ + async getOrderBook(instrumentName: string, depth: number = 10): Promise { + try { + const response = await this.wsManager.sendRequest( + "public/get_order_book", + { + instrument_name: instrumentName, + depth, + }, + ); + + if (response.error) { + throw new Error(`Failed to get order book: ${response.error.message}`); + } + + return response.result; + } catch (error) { + console.error("โŒ Failed to get order book:", error); + throw error; + } + } + + /** + * Parse order response and extract relevant information + */ + parseOrderResponse(response: any): any { + try { + // Handle both direct order response and nested response formats + const order = response.order || response; + + if (!order) { + throw new Error("Invalid order response format"); + } + + return { + orderId: order.order_id, + instrumentName: order.instrument_name, + direction: order.direction, + amount: parseFloat(order.amount || "0"), + price: parseFloat(order.limit_price || order.price || "0"), + orderType: order.order_type, + status: order.order_status || order.order_state || order.status, + createdAt: order.creation_timestamp || order.timestamp, + filledAmount: parseFloat(order.filled_amount || "0"), + averagePrice: parseFloat(order.average_price || "0"), + fee: parseFloat(order.order_fee || order.fee || "0"), + subaccountId: order.subaccount_id, + nonce: order.nonce, + signature: order.signature, + signatureExpiry: order.signature_expiry_sec, + signer: order.signer, + maxFee: order.max_fee, + mmp: order.mmp, + lastUpdateTimestamp: order.last_update_timestamp, + }; + } catch (error) { + console.error("โŒ Failed to parse order response:", error); + throw new Error( + `Failed to parse order response: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Validate order before submission + */ + async validateOrderBeforeSubmission( + signedOrder: any, + ): Promise<{ isValid: boolean; errors: string[] }> { + const errors: string[] = []; + + try { + // Check authentication + if (!this.authMethods.isUserAuthenticated()) { + errors.push("Authentication required"); + } + + // Check WebSocket connection + if (!this.wsManager.isConnected()) { + errors.push("WebSocket connection required"); + } + + // Validate order signature + if (!signedOrder.signature) { + errors.push("Order signature is required"); + } + + // Get order book to check if price is reasonable (optional validation) + try { + const orderBook = await this.getOrderBook( + signedOrder.instrument_name, + 5, + ); + if (orderBook && orderBook.bids && orderBook.asks) { + const bestBid = parseFloat(orderBook.bids[0]?.[0] || "0"); + const bestAsk = parseFloat(orderBook.asks[0]?.[0] || "0"); + const orderPrice = parseFloat(signedOrder.limit_price); + + // Check if price is within reasonable range (not more than 50% away from best prices) + if (signedOrder.direction === "buy" && bestAsk > 0) { + if (orderPrice > bestAsk * 1.5) { + errors.push("Buy price is significantly above market price"); + } + } else if (signedOrder.direction === "sell" && bestBid > 0) { + if (orderPrice < bestBid * 0.5) { + errors.push("Sell price is significantly below market price"); + } + } + } + } catch (error) { + // Order book validation is optional, don't fail the entire validation + console.warn("Could not validate against order book:", error); + } + + return { + isValid: errors.length === 0, + errors, + }; + } catch (error) { + errors.push( + `Validation error: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return { + isValid: false, + errors, + }; + } + } +} diff --git a/app/lib/derive-api/websocket-manager.ts b/app/lib/derive-api/websocket-manager.ts new file mode 100644 index 0000000..e08fee2 --- /dev/null +++ b/app/lib/derive-api/websocket-manager.ts @@ -0,0 +1,502 @@ +/** + * WebSocket Connection Manager for Derive API + * + * Handles WebSocket connection, reconnection, message handling, and request management. + */ + +import { DERIVE_WS_MAINNET } from "../constants"; + +const WS_URL = DERIVE_WS_MAINNET; + +export interface PendingRequest { + resolve: (value: any) => void; + reject: (error: any) => void; + timeout: NodeJS.Timeout; +} + +export interface JSONRPCRequest { + method: string; + params: any; + id: string; +} + +export interface JSONRPCResponse { + id: string; + result?: any; + error?: any; +} + +export interface DeriveSubscription { + channel: string; + callback: (data: any) => void; + instrumentName: string; + type: string; +} + +export type DeriveSubscriptionCallback = (data: any) => void; + +export class WebSocketManager { + private ws: WebSocket | null = null; + private pendingRequests: Map = new Map(); + private connectionPromise: Promise | null = null; + private isConnecting = false; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; // Start with 1 second + private isReady = false; + + // Subscription management + private subscriptions: Map = new Map(); + + // Callback for handling reconnection authentication + private onReconnectCallback?: () => Promise; + + constructor() { + console.log("๐Ÿ—๏ธ WebSocketManager constructor called"); + console.log("๐Ÿ“ก Target WebSocket URL:", WS_URL); + console.log( + "๐ŸŒ WebSocket API available:", + typeof WebSocket !== "undefined", + ); + // Auto-connect is handled in ensureConnection + } + + /** + * Set callback for handling reconnection authentication + */ + setReconnectCallback(callback: () => Promise): void { + this.onReconnectCallback = callback; + } + + /** + * Check if WebSocket is connected and ready + */ + isConnected(): boolean { + return this.isReady && this.ws?.readyState === WebSocket.OPEN; + } + + /** + * Generate unique request ID + */ + generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Ensure WebSocket connection is established + */ + async ensureConnection(): Promise { + // If already connected, resolve immediately + if (this.isConnected()) { + console.log("WebSocket already connected, using existing connection"); + return Promise.resolve(); + } + + // If connection is in progress, wait for it + if (this.connectionPromise) { + console.log("WebSocket connection in progress, waiting..."); + return this.connectionPromise; + } + + console.log("Initiating new WebSocket connection to:", WS_URL); + + // Try to connect with retry logic + let retryCount = 0; + const maxRetries = 3; + + while (retryCount < maxRetries) { + try { + this.connectionPromise = this.connect(); + await this.connectionPromise; + console.log("WebSocket connection established successfully"); + return; + } catch (error) { + retryCount++; + console.error( + `WebSocket connection attempt ${retryCount} failed:`, + error, + ); + + if (retryCount >= maxRetries) { + console.error("Maximum WebSocket connection retries reached"); + this.connectionPromise = null; + throw error; + } + + // Wait before retrying + const delay = 1000 * Math.pow(2, retryCount - 1); // Exponential backoff + console.log(`Retrying connection in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return this.connectionPromise; + } + + /** + * Establish WebSocket connection + */ + private async connect(): Promise { + return new Promise((resolve, reject) => { + if (this.isConnecting) { + console.log("Connection already in progress, skipping..."); + return; + } + + this.isConnecting = true; + + console.log("๐Ÿ”Œ Attempting WebSocket connection to:", WS_URL); + console.log("๐ŸŒ Environment checks:", { + hasWebSocket: typeof WebSocket !== "undefined", + isBrowser: typeof window !== "undefined", + isSecureContext: + typeof window !== "undefined" ? window.isSecureContext : "unknown", + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : "unknown", + }); + + if (typeof WebSocket === "undefined") { + console.error("โŒ WebSocket is not available in this environment"); + this.isConnecting = false; + reject(new Error("WebSocket is not available in this environment")); + return; + } + + try { + console.log("๐Ÿš€ Creating WebSocket instance..."); + this.ws = new WebSocket(WS_URL); + console.log("โœ… WebSocket instance created successfully"); + console.log("๐Ÿ“Š Initial WebSocket state:", this.ws.readyState); + } catch (error) { + console.error("โŒ Failed to create WebSocket instance:", error); + this.isConnecting = false; + reject(error); + return; + } + + const connectionTimeout = setTimeout(() => { + console.error("โŒ WebSocket connection timeout after 30 seconds"); + if (this.ws) { + this.ws.close(); + } + this.isConnecting = false; + this.isReady = false; + reject(new Error("WebSocket connection timeout")); + }, 30000); // 30 second timeout + + this.ws.onopen = async () => { + console.log( + "โœ… WebSocket connection established successfully to:", + WS_URL, + ); + clearTimeout(connectionTimeout); + this.isConnecting = false; + this.reconnectAttempts = 0; + this.connectionPromise = null; + + // Wait for the connection to stabilize + await new Promise((resolve) => setTimeout(resolve, 1000)); + this.isReady = true; + + // Handle re-authentication on reconnection + if (this.onReconnectCallback) { + this.onReconnectCallback().catch((error) => { + console.warn("Failed to re-authenticate on connection:", error); + }); + } + + resolve(); + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data); + + // Handle subscription notifications (they have method and params) + if (message.method && message.params) { + this.handleSubscriptionMessage(message); + } else { + // Handle regular JSON-RPC responses (they have id and result/error) + this.handleResponse(message as JSONRPCResponse); + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event: CloseEvent) => { + clearTimeout(connectionTimeout); + this.isConnecting = false; + this.connectionPromise = null; + this.isReady = false; + + // Reject all pending requests + this.pendingRequests.forEach(({ reject, timeout }) => { + clearTimeout(timeout); + reject(new Error("WebSocket connection closed")); + }); + this.pendingRequests.clear(); + + // Clear subscriptions on disconnect + this.subscriptions.clear(); + + // Attempt to reconnect if not a clean disconnect + if ( + event.code !== 1000 && + this.reconnectAttempts < this.maxReconnectAttempts + ) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (event: Event) => { + clearTimeout(connectionTimeout); + this.isConnecting = false; + this.isReady = false; + console.error("โŒ WebSocket connection error:", { + event, + url: WS_URL, + readyState: this.ws?.readyState, + timestamp: new Date().toISOString(), + }); + reject( + new Error(`WebSocket connection failed to ${WS_URL}: ${event.type}`), + ); + }; + }); + } + + /** + * Wait for connection to be established + */ + async waitForConnection(timeoutMs: number = 10000): Promise { + console.log( + `Waiting for WebSocket connection (timeout: ${timeoutMs}ms)...`, + ); + + // First try to ensure connection with retry logic + try { + await this.ensureConnection(); + console.log("Connection established via ensureConnection"); + return; + } catch (error) { + console.warn("ensureConnection failed, falling back to polling:", error); + } + + // Fall back to polling if ensureConnection fails + const startTime = Date.now(); + const pollInterval = 200; // Poll every 200ms + + while (!this.isConnected() && Date.now() - startTime < timeoutMs) { + console.log( + `Waiting for connection... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`, + ); + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + // Try to reconnect if not connecting + if ( + !this.isConnecting && + !this.isConnected() && + !this.connectionPromise + ) { + console.log("Connection not in progress, attempting to connect..."); + try { + this.connectionPromise = this.connect(); + await this.connectionPromise; + } catch (error) { + console.warn("Connection attempt failed:", error); + } + } + } + + if (!this.isConnected()) { + console.error(`WebSocket connection timeout after ${timeoutMs}ms`); + throw new Error("Timeout waiting for WebSocket connection"); + } + + console.log("WebSocket connection confirmed"); + } + + /** + * Schedule reconnection with exponential backoff + */ + private scheduleReconnect(): void { + this.reconnectAttempts++; + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), + 10000, // Cap at 10 seconds + ); // Exponential backoff with cap + + setTimeout(() => { + if (this.reconnectAttempts <= this.maxReconnectAttempts) { + this.connect().catch((error) => { + console.error( + `Reconnection attempt ${this.reconnectAttempts} failed:`, + error, + ); + }); + } + }, delay); + } + + /** + * Handle subscription messages + */ + private handleSubscriptionMessage(message: unknown): void { + try { + if (typeof message !== "object" || message === null) { + console.warn("Invalid subscription message format:", message); + return; + } + + const msg = message as Record; + const { method, params } = msg; + + if (method === "subscription" && params) { + const paramsObj = params as Record; + const { channel, data } = paramsObj; + + if (typeof channel === "string") { + const subscription = this.subscriptions.get(channel); + + if (subscription) { + subscription.callback(data); + } else { + // Check for order-related channels that might use different naming + if (channel.includes("user.orders") || channel.includes("orders")) { + // Try to find any order subscription + const orderSubscription = Array.from( + this.subscriptions.values(), + ).find( + (sub) => + sub.type === "orders" || sub.channel.includes("orders"), + ); + + if (orderSubscription) { + orderSubscription.callback(data); + } + } + } + } + } + } catch (error) { + console.error("Error handling subscription message:", error); + } + } + + /** + * Handle JSON-RPC responses + */ + private handleResponse(response: JSONRPCResponse): void { + console.log(`Received WebSocket response:`, response); + + const pendingRequest = this.pendingRequests.get(response.id); + if (!pendingRequest) { + console.warn(`No pending request found for response ID: ${response.id}`); + return; + } + + clearTimeout(pendingRequest.timeout); + this.pendingRequests.delete(response.id); + + if (response.error) { + console.error(`WebSocket request error:`, response.error); + pendingRequest.reject(response.error); + } else { + console.log(`WebSocket request successful:`, response.result); + pendingRequest.resolve(response); // Return the full response + } + } + + /** + * Send request via WebSocket + */ + async sendRequest(method: string, params: unknown): Promise { + await this.ensureConnection(); + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } + + return new Promise((resolve, reject) => { + const id = this.generateId(); + const request: JSONRPCRequest = { + method, + params, + id, + }; + + // Set up timeout + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request timeout for method: ${method}`)); + }, 30000); // 30 second timeout + + // Store pending request + this.pendingRequests.set(id, { resolve, reject, timeout }); + + // Send request + const requestString = JSON.stringify(request); + console.log(`Sending WebSocket request:`, request); + this.ws!.send(requestString); + }); + } + + /** + * Add subscription + */ + addSubscription( + subscriptionId: string, + subscription: DeriveSubscription, + ): void { + this.subscriptions.set(subscriptionId, subscription); + } + + /** + * Remove subscription + */ + removeSubscription(subscriptionId: string): void { + this.subscriptions.delete(subscriptionId); + } + + /** + * Get subscription + */ + getSubscription(subscriptionId: string): DeriveSubscription | undefined { + return this.subscriptions.get(subscriptionId); + } + + /** + * Check if subscription exists + */ + hasSubscription(subscriptionId: string): boolean { + return this.subscriptions.has(subscriptionId); + } + + /** + * Get all subscriptions + */ + getAllSubscriptions(): Map { + return new Map(this.subscriptions); + } + + /** + * Disconnect WebSocket + */ + disconnect(): void { + if (this.ws) { + this.ws.close(1000, "Client disconnect"); + this.ws = null; + } + + // Clear all pending requests + this.pendingRequests.forEach(({ reject, timeout }) => { + clearTimeout(timeout); + reject(new Error("WebSocket disconnected")); + }); + this.pendingRequests.clear(); + + // Clear subscriptions + this.subscriptions.clear(); + } +} diff --git a/app/lib/order-config.ts b/app/lib/order-config.ts new file mode 100644 index 0000000..165371d --- /dev/null +++ b/app/lib/order-config.ts @@ -0,0 +1,41 @@ +/** + * Order Configuration and Constants for Derive Protocol + */ + +// Order configuration constants +export const ORDER_CONFIG = { + TIMEOUTS: { + WEBSOCKET_CONNECTION_TIMEOUT: 30000, // 30 seconds + DEFAULT_SIGNATURE_EXPIRY: 600, // 10 minutes + MAX_RETRY_ATTEMPTS: 3, + RETRY_DELAY_BASE: 1000, // 1 second + RETRY_DELAY_MAX: 10000, // 10 seconds + }, + LIMITS: { + MIN_ORDER_SIZE: 0.01, + MAX_ORDER_SIZE: 1000000, + MIN_PRICE: 0.01, + MAX_PRICE: 1000000, + }, + ERRORS: { + AUTH_WALLET_NOT_CONNECTED: "Wallet not connected", + AUTH_SESSION_EXPIRED: "Authentication session expired", + WEBSOCKET_CONNECTION_FAILED: "WebSocket connection failed", + ORDER_INSUFFICIENT_BALANCE: "Insufficient balance for order", + }, +}; + +// Derive Protocol Constants +export const DERIVE_PROTOCOL_CONSTANTS = { + DOMAIN_NAME: "Derive Protocol", + DOMAIN_VERSION: "1", + DOMAIN_CHAIN_ID: 8453, // Base mainnet + TRADE_MODULE_ADDRESS: "0x87F2863866D85E3192a35A73b388BD625D83f2be", +}; + +// Validation rules +export const VALIDATION_RULES = { + INSTRUMENT_NAME_PATTERN: /^[A-Z]+-\d{8}-[CP]-\d+$/, + FUTURES_INSTRUMENT_PATTERN: /^[A-Z]+-PERP$/, + DEFAULT_MAX_FEE: "0.01", +}; diff --git a/app/lib/order-error-handler.ts b/app/lib/order-error-handler.ts new file mode 100644 index 0000000..d03482e --- /dev/null +++ b/app/lib/order-error-handler.ts @@ -0,0 +1,218 @@ +/** + * Order Error Handler for Derive Protocol + */ + +export enum OrderErrorType { + INVALID_PARAMETERS = "INVALID_PARAMETERS", + NOT_AUTHENTICATED = "NOT_AUTHENTICATED", + NETWORK_ERROR = "NETWORK_ERROR", + INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE", + ORDER_REJECTED = "ORDER_REJECTED", + ORDER_CANCELLATION_FAILED = "ORDER_CANCELLATION_FAILED", + UNKNOWN_ERROR = "UNKNOWN_ERROR", +} + +export interface OrderError { + type: OrderErrorType; + message: string; + recoverable: boolean; + retryable: boolean; + field?: string; + orderId?: string; +} + +export interface OrderRecoveryAction { + type: string; + label: string; + action: () => Promise; +} + +export interface OrderNotification { + id: string; + type: "success" | "error" | "warning"; + title: string; + message: string; + timestamp: number; + actions?: OrderRecoveryAction[]; +} + +export class OrderErrorHandler { + private retryAttempts: Map = new Map(); + private notificationListeners: Array< + (notification: OrderNotification) => void + > = []; + + parseError(error: unknown, contextKey: string): OrderError { + if (error instanceof Error) { + if (error.message.includes("Authentication")) { + return { + type: OrderErrorType.NOT_AUTHENTICATED, + message: error.message, + recoverable: true, + retryable: true, + }; + } + + if (error.message.includes("balance")) { + return { + type: OrderErrorType.INSUFFICIENT_BALANCE, + message: error.message, + recoverable: true, + retryable: false, + }; + } + + if ( + error.message.includes("network") || + error.message.includes("connection") + ) { + return { + type: OrderErrorType.NETWORK_ERROR, + message: error.message, + recoverable: true, + retryable: true, + }; + } + + return { + type: OrderErrorType.UNKNOWN_ERROR, + message: error.message, + recoverable: true, + retryable: true, + }; + } + + return { + type: OrderErrorType.UNKNOWN_ERROR, + message: "Unknown order error", + recoverable: true, + retryable: true, + }; + } + + getRecoveryActions( + error: OrderError, + actions: { + onRetry: () => Promise; + onAdjustParameters: () => Promise; + onReconnect: () => Promise; + onReauthenticate: () => Promise; + onCheckBalance: () => Promise; + }, + ): OrderRecoveryAction[] { + const recoveryActions: OrderRecoveryAction[] = []; + + if (error.retryable) { + recoveryActions.push({ + type: "retry", + label: "Retry Order", + action: actions.onRetry, + }); + } + + if (error.type === OrderErrorType.NOT_AUTHENTICATED) { + recoveryActions.push({ + type: "reauthenticate", + label: "Reauthenticate", + action: actions.onReauthenticate, + }); + } + + if (error.type === OrderErrorType.NETWORK_ERROR) { + recoveryActions.push({ + type: "reconnect", + label: "Reconnect", + action: actions.onReconnect, + }); + } + + if (error.type === OrderErrorType.INSUFFICIENT_BALANCE) { + recoveryActions.push({ + type: "check_balance", + label: "Check Balance", + action: actions.onCheckBalance, + }); + } + + return recoveryActions; + } + + shouldAutoRetry(error: OrderError, contextKey: string): boolean { + const attempts = this.retryAttempts.get(contextKey) || 0; + return error.retryable && attempts < 3; + } + + getRetryDelay(contextKey: string): number { + const attempts = this.retryAttempts.get(contextKey) || 0; + this.retryAttempts.set(contextKey, attempts + 1); + return Math.min(1000 * Math.pow(2, attempts), 10000); + } + + resetRetryAttempts(contextKey: string): void { + this.retryAttempts.delete(contextKey); + } + + onNotification( + listener: (notification: OrderNotification) => void, + ): () => void { + this.notificationListeners.push(listener); + return () => { + const index = this.notificationListeners.indexOf(listener); + if (index > -1) { + this.notificationListeners.splice(index, 1); + } + }; + } + + notify(notification: OrderNotification): void { + this.notificationListeners.forEach((listener) => { + try { + listener(notification); + } catch (error) { + console.error("Error in notification listener:", error); + } + }); + } + + createErrorNotification( + error: OrderError, + actions?: OrderRecoveryAction[], + ): OrderNotification { + return { + id: `error_${Date.now()}`, + type: "error", + title: "Order Failed", + message: error.message, + timestamp: Date.now(), + actions, + }; + } + + createSuccessNotification( + orderId: string, + details?: unknown, + ): OrderNotification { + return { + id: `success_${Date.now()}`, + type: "success", + title: "Order Successful", + message: `Order ${orderId} submitted successfully`, + timestamp: Date.now(), + }; + } + + createWarningNotification( + message: string, + title?: string, + ): OrderNotification { + return { + id: `warning_${Date.now()}`, + type: "warning", + title: title || "Warning", + message, + timestamp: Date.now(), + }; + } +} + +export const orderErrorHandler = new OrderErrorHandler(); diff --git a/app/lib/order-service.ts b/app/lib/order-service.ts new file mode 100644 index 0000000..943dd5d --- /dev/null +++ b/app/lib/order-service.ts @@ -0,0 +1,1562 @@ +/** + * Order Service - Main orchestration layer for order submission + * + * This service coordinates authentication, order signing, and submission + * to provide a complete order management workflow. + */ + +import { ethers } from "ethers"; +import { + authenticationService, + type AuthenticationSession, + type WalletProvider, +} from "./authentication-service"; +import { + orderSigningService, + type OrderParams, + type SignedOrder, +} from "./order-signing"; +import { deriveAPI } from "./derive-api"; +import { ORDER_CONFIG } from "./order-config"; +import { + orderErrorHandler, + type OrderError, + type OrderRecoveryAction, + type OrderNotification, + OrderErrorType, +} from "./order-error-handler"; + +// Order service interfaces +export interface OrderFormData { + size: string; + limitPrice: string; + orderType: "limit" | "market"; + direction: "buy" | "sell"; + selectedOption: OptionData; + optionType: "call" | "put"; +} + +export interface OptionData { + strike: number; + instrument: { + instrument_name: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OrderValidation { + isValid: boolean; + errors: { + size?: string; + limitPrice?: string; + balance?: string; + limits?: string; + authentication?: string; + network?: string; + }; + warnings?: string[]; +} + +export interface OrderResult { + success: boolean; + orderId?: string; + error?: string; + details?: unknown; + timestamp: number; +} + +export interface OrderState { + isSubmitting: boolean; + lastOrder?: OrderResult; + error?: string; + validationErrors?: OrderValidation["errors"]; +} + +export interface OrderStatusUpdate { + orderId: string; + status: string; + timestamp: number; + details?: unknown; +} + +export interface OrderHistoryItem { + orderId: string; + instrumentName: string; + direction: "buy" | "sell"; + amount: number; + price: number; + orderType: "limit" | "market"; + status: string; + createdAt: number; + updatedAt: number; + filledAmount: number; + averagePrice?: number; + fee: number; + subaccountId: number; +} + +// Order service event types +export type OrderUpdateCallback = (update: OrderStatusUpdate) => void; +export type OrderStateCallback = (state: OrderState) => void; + +export class OrderService { + private state: OrderState = { + isSubmitting: false, + }; + + private stateChangeListeners: OrderStateCallback[] = []; + private orderUpdateListeners: OrderUpdateCallback[] = []; + private orderStatusSubscription: (() => void) | null = null; + private notificationUnsubscribe: (() => void) | null = null; + + // Order history management + private orderHistory: Map = new Map(); + private orderHistoryListeners: ((history: OrderHistoryItem[]) => void)[] = []; + + constructor() { + // Initialize order status tracking + this.initializeOrderStatusTracking(); + + // Subscribe to error notifications + this.notificationUnsubscribe = orderErrorHandler.onNotification( + (notification) => this.handleNotification(notification), + ); + } + + /** + * Get current order service state + */ + getState(): OrderState { + return { ...this.state }; + } + + /** + * Subscribe to order state changes + */ + onStateChange(listener: OrderStateCallback): () => void { + this.stateChangeListeners.push(listener); + return () => { + const index = this.stateChangeListeners.indexOf(listener); + if (index > -1) { + this.stateChangeListeners.splice(index, 1); + } + }; + } + + /** + * Subscribe to order status updates + */ + onOrderUpdate(listener: OrderUpdateCallback): () => void { + this.orderUpdateListeners.push(listener); + return () => { + const index = this.orderUpdateListeners.indexOf(listener); + if (index > -1) { + this.orderUpdateListeners.splice(index, 1); + } + }; + } + + /** + * Subscribe to order history updates + */ + onOrderHistoryUpdate( + listener: (history: OrderHistoryItem[]) => void, + ): () => void { + this.orderHistoryListeners.push(listener); + return () => { + const index = this.orderHistoryListeners.indexOf(listener); + if (index > -1) { + this.orderHistoryListeners.splice(index, 1); + } + }; + } + + /** + * Get current order history + */ + getOrderHistory(): OrderHistoryItem[] { + return Array.from(this.orderHistory.values()).sort( + (a, b) => b.updatedAt - a.updatedAt, + ); + } + + /** + * Get order history filtered by status + */ + getOrderHistoryByStatus(status?: string): OrderHistoryItem[] { + const history = this.getOrderHistory(); + if (!status) return history; + return history.filter( + (order) => order.status.toLowerCase() === status.toLowerCase(), + ); + } + + /** + * Get open orders (orders that can be cancelled) + */ + getOpenOrders(): OrderHistoryItem[] { + const openStatuses = ["open", "pending", "partially_filled", "submitted"]; + return this.getOrderHistory().filter((order) => + openStatuses.includes(order.status.toLowerCase()), + ); + } + + /** + * Get completed orders + */ + getCompletedOrders(): OrderHistoryItem[] { + const completedStatuses = [ + "filled", + "completed", + "cancelled", + "rejected", + "expired", + ]; + return this.getOrderHistory().filter((order) => + completedStatuses.includes(order.status.toLowerCase()), + ); + } + + /** + * Get order by ID from history + */ + getOrderById(orderId: string): OrderHistoryItem | null { + return this.orderHistory.get(orderId) || null; + } + + /** + * Get available subaccounts for the current user + */ + getAvailableSubaccounts(): Array<{ subaccount_id: number; wallet: string }> { + return authenticationService.getAvailableSubaccounts(); + } + + /** + * Get default subaccount ID for the current user + */ + getDefaultSubaccountId(): number { + return authenticationService.getDefaultSubaccountId(); + } + + /** + * Main order submission method - orchestrates complete workflow + */ + async submitOrder( + orderFormData: OrderFormData, + walletProvider: WalletProvider, + subaccountId?: number, + ): Promise { + if (this.state.isSubmitting) { + throw new Error("Order submission already in progress"); + } + + this.updateState({ + isSubmitting: true, + error: undefined, + validationErrors: undefined, + }); + + const contextKey = `order_${Date.now()}`; + + try { + // Step 1: Validate order parameters + const validation = await this.validateOrder( + orderFormData, + walletProvider, + subaccountId, + ); + + if (!validation.isValid) { + const result: OrderResult = { + success: false, + error: "Order validation failed", + details: validation.errors, + timestamp: Date.now(), + }; + + this.updateState({ + isSubmitting: false, + lastOrder: result, + validationErrors: validation.errors, + }); + + // Create validation error notification + this.notifyValidationErrors(validation.errors); + + return result; + } + + // Show warnings if any + if (validation.warnings && validation.warnings.length > 0) { + validation.warnings.forEach((warning) => { + const notification = + orderErrorHandler.createWarningNotification(warning); + orderErrorHandler.notify(notification); + }); + } + + // Step 2: Ensure authentication + await this.ensureAuthentication(walletProvider); + + // Step 3: Get subaccount ID (use provided or default from session) + const effectiveSubaccountId = + subaccountId ?? authenticationService.getDefaultSubaccountId(); + + // Step 4: Create order parameters + const orderParams = this.createOrderParams( + orderFormData, + walletProvider.address, + effectiveSubaccountId, + ); + + // Step 4: Sign the order + const signedOrder = await this.signOrder(orderParams, walletProvider); + + // Step 5: Submit to exchange + const submissionResult = await this.submitSignedOrder(signedOrder); + + // Step 6: Process result + const result: OrderResult = { + success: true, + orderId: submissionResult.order_id || submissionResult.id, + details: submissionResult, + timestamp: Date.now(), + }; + + this.updateState({ + isSubmitting: false, + lastOrder: result, + error: undefined, + validationErrors: undefined, + }); + + // Reset retry attempts on success + orderErrorHandler.resetRetryAttempts(contextKey); + + // Create success notification + if (result.orderId) { + const notification = orderErrorHandler.createSuccessNotification( + result.orderId, + result.details, + ); + orderErrorHandler.notify(notification); + + // Add to order history + this.addToOrderHistory(orderFormData, result, effectiveSubaccountId); + + // Start tracking this order + this.trackOrderStatus(result.orderId); + } + + return result; + } catch (error) { + // Parse and classify the error + const orderError = orderErrorHandler.parseError(error, contextKey); + + const result: OrderResult = { + success: false, + error: orderError.message, + details: orderError, + timestamp: Date.now(), + }; + + this.updateState({ + isSubmitting: false, + lastOrder: result, + error: orderError.message, + }); + + // Create recovery actions + const recoveryActions = orderErrorHandler.getRecoveryActions(orderError, { + onRetry: () => + this.submitOrder(orderFormData, walletProvider, subaccountId), + onAdjustParameters: () => this.handleParameterAdjustment(orderError), + onReconnect: () => this.handleReconnection(), + onReauthenticate: () => this.handleReauthentication(walletProvider), + onCheckBalance: () => this.handleBalanceCheck(effectiveSubaccountId), + }); + + // Create and send error notification + const notification = orderErrorHandler.createErrorNotification( + orderError, + recoveryActions, + ); + orderErrorHandler.notify(notification); + + // Attempt automatic retry if appropriate + if (orderErrorHandler.shouldAutoRetry(orderError, contextKey)) { + const delay = orderErrorHandler.getRetryDelay(contextKey); + console.log(`Auto-retrying order submission in ${delay}ms`); + + setTimeout(async () => { + try { + await this.submitOrder(orderFormData, walletProvider, subaccountId); + } catch (retryError) { + console.error("Auto-retry failed:", retryError); + } + }, delay); + } + + return result; + } + } + + /** + * Validate order before submission + */ + async validateOrder( + orderFormData: OrderFormData, + walletProvider: WalletProvider, + subaccountId?: number, + ): Promise { + const errors: OrderValidation["errors"] = {}; + const warnings: string[] = []; + + try { + // Validate wallet connection + if (!walletProvider.isConnected) { + errors.authentication = ORDER_CONFIG.ERRORS.AUTH_WALLET_NOT_CONNECTED; + } + + // Validate network connection + if (!deriveAPI.isConnected()) { + errors.network = ORDER_CONFIG.ERRORS.WEBSOCKET_CONNECTION_FAILED; + } + + // Validate order size + const size = parseFloat(orderFormData.size); + if (isNaN(size) || size <= 0) { + errors.size = "Order size must be a positive number"; + } else if (size < ORDER_CONFIG.LIMITS.MIN_ORDER_SIZE) { + errors.size = `Order size must be at least ${ORDER_CONFIG.LIMITS.MIN_ORDER_SIZE}`; + } else if (size > ORDER_CONFIG.LIMITS.MAX_ORDER_SIZE) { + errors.size = `Order size cannot exceed ${ORDER_CONFIG.LIMITS.MAX_ORDER_SIZE}`; + } + + // Validate limit price for limit orders + if (orderFormData.orderType === "limit") { + const limitPrice = parseFloat(orderFormData.limitPrice); + if (isNaN(limitPrice) || limitPrice <= 0) { + errors.limitPrice = "Limit price must be a positive number"; + } else if (limitPrice < ORDER_CONFIG.LIMITS.MIN_PRICE) { + errors.limitPrice = `Limit price must be at least ${ORDER_CONFIG.LIMITS.MIN_PRICE}`; + } else if (limitPrice > ORDER_CONFIG.LIMITS.MAX_PRICE) { + errors.limitPrice = `Limit price cannot exceed ${ORDER_CONFIG.LIMITS.MAX_PRICE}`; + } + } + + // Validate instrument + if (!orderFormData.selectedOption?.instrument?.instrument_name) { + errors.limits = "No option selected"; + } + + // Check available balance if authenticated + if ( + authenticationService.isAuthenticated() && + !errors.size && + !errors.limitPrice + ) { + try { + const effectiveSubaccountId = + subaccountId ?? authenticationService.getDefaultSubaccountId(); + const orderValue = + size * + (orderFormData.orderType === "limit" + ? parseFloat(orderFormData.limitPrice) + : 0); + + console.log("๐Ÿ” Checking balance:", { + authenticated: authenticationService.isAuthenticated(), + subaccountId: effectiveSubaccountId, + orderValue, + orderType: orderFormData.orderType, + }); + + // Temporarily disable balance check to debug authentication issues + // const hasBalance = await deriveAPI.checkSufficientBalance( + // orderValue, + // effectiveSubaccountId, + // ); + + // console.log("๐Ÿ’ฐ Balance check result:", hasBalance); + + // if (!hasBalance) { + // errors.balance = ORDER_CONFIG.ERRORS.ORDER_INSUFFICIENT_BALANCE; + // } + + console.log("โš ๏ธ Balance check temporarily disabled for debugging"); + warnings.push("Balance check temporarily disabled"); + } catch (error) { + console.error("โŒ Balance check failed:", error); + // Balance check failed, add as warning instead of blocking + warnings.push( + `Could not verify account balance: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } else { + console.log("โš ๏ธ Skipping balance check:", { + authenticated: authenticationService.isAuthenticated(), + hasSize: !errors.size, + hasPrice: !errors.limitPrice, + }); + } + + // Add warnings for market conditions + if (orderFormData.orderType === "market") { + warnings.push( + "Market orders execute immediately at current market price", + ); + } + + const isValid = Object.keys(errors).length === 0; + + return { + isValid, + errors, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } catch (error) { + return { + isValid: false, + errors: { + limits: `Validation error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + }; + } + } + + /** + * Get order status + */ + async getOrderStatus(orderId: string): Promise { + try { + const status = await deriveAPI.getOrderStatus(orderId); + + return { + orderId, + status: status.order_state || status.status || "unknown", + timestamp: Date.now(), + details: status, + }; + } catch (error) { + console.error(`Failed to get order status for ${orderId}:`, error); + return null; + } + } + + /** + * Cancel an order + */ + async cancelOrder(orderId: string): Promise { + try { + console.log(`๐Ÿšซ Cancelling order: ${orderId}`); + + // Check if order exists in history and is cancellable + const orderInHistory = this.getOrderById(orderId); + if (orderInHistory) { + const cancellableStatuses = [ + "open", + "pending", + "partially_filled", + "submitted", + ]; + if ( + !cancellableStatuses.includes(orderInHistory.status.toLowerCase()) + ) { + throw new Error( + `Order ${orderId} cannot be cancelled (status: ${orderInHistory.status})`, + ); + } + } + + const result = await deriveAPI.cancelOrder(orderId); + + // Update order status in history immediately + if (orderInHistory) { + const updatedOrder: OrderHistoryItem = { + ...orderInHistory, + status: "cancelling", + updatedAt: Date.now(), + }; + this.orderHistory.set(orderId, updatedOrder); + this.notifyOrderHistoryListeners(); + } + + // Create success notification + const notification = orderErrorHandler.createSuccessNotification( + orderId, + { + message: `Order ${orderId} cancellation requested`, + type: "order_cancellation", + }, + ); + orderErrorHandler.notify(notification); + + console.log(`โœ… Order cancellation requested: ${orderId}`); + + return { + success: true, + orderId, + details: result, + timestamp: Date.now(), + }; + } catch (error) { + console.error(`โŒ Failed to cancel order ${orderId}:`, error); + + // Create error notification + const notification = orderErrorHandler.createErrorNotification({ + type: OrderErrorType.ORDER_CANCELLATION_FAILED, + message: + error instanceof Error ? error.message : "Failed to cancel order", + recoverable: true, + retryable: true, + orderId, + }); + orderErrorHandler.notify(notification); + + return { + success: false, + orderId, + error: + error instanceof Error ? error.message : "Failed to cancel order", + timestamp: Date.now(), + }; + } + } + + /** + * Cancel multiple orders + */ + async cancelMultipleOrders(orderIds: string[]): Promise { + console.log(`๐Ÿšซ Cancelling ${orderIds.length} orders:`, orderIds); + + const results = await Promise.allSettled( + orderIds.map((orderId) => this.cancelOrder(orderId)), + ); + + return results.map((result, index) => { + if (result.status === "fulfilled") { + return result.value; + } else { + return { + success: false, + orderId: orderIds[index], + error: + result.reason instanceof Error + ? result.reason.message + : "Cancellation failed", + timestamp: Date.now(), + }; + } + }); + } + + /** + * Cancel all open orders + */ + async cancelAllOpenOrders(): Promise { + const openOrders = this.getOpenOrders(); + const orderIds = openOrders.map((order) => order.orderId); + + if (orderIds.length === 0) { + console.log("No open orders to cancel"); + return []; + } + + console.log(`๐Ÿšซ Cancelling all ${orderIds.length} open orders`); + return this.cancelMultipleOrders(orderIds); + } + + /** + * Get order history from API + */ + async getOrderHistoryFromAPI( + subaccountId?: number, + instrumentName?: string, + limit: number = 50, + ): Promise { + try { + return await deriveAPI.getOrderHistory( + subaccountId, + instrumentName, + limit, + ); + } catch (error) { + console.error("Failed to get order history:", error); + return []; + } + } + + /** + * Ensure user is authenticated before order submission + */ + private async ensureAuthentication( + walletProvider: WalletProvider, + ): Promise { + if (!authenticationService.isAuthenticated()) { + try { + await authenticationService.authenticate(walletProvider); + } catch (error) { + throw new Error( + `Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + // Validate session is still active + const isValid = await authenticationService.validateSession(walletProvider); + if (!isValid) { + throw new Error("Authentication session invalid"); + } + } + + /** + * Create order parameters from form data + */ + private createOrderParams( + orderFormData: OrderFormData, + signerAddress: string, + subaccountId: number, + ): OrderParams { + const size = parseFloat(orderFormData.size); + const limitPrice = + orderFormData.orderType === "limit" + ? parseFloat(orderFormData.limitPrice) + : 0; // Market orders will use current market price + + return orderSigningService.createOrderParams( + orderFormData.selectedOption.instrument.instrument_name, + subaccountId, + orderFormData.direction, + limitPrice, + size, + signerAddress, + orderFormData.orderType, + false, // mmp + ); + } + + /** + * Sign order using order signing service + */ + private async signOrder( + orderParams: OrderParams, + walletProvider: WalletProvider, + ): Promise { + try { + return await orderSigningService.signOrder( + orderParams, + walletProvider.signer, + ); + } catch (error) { + throw new Error( + `Order signing failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Submit signed order to exchange + */ + private async submitSignedOrder(signedOrder: SignedOrder): Promise { + try { + // Validate order before submission + const validation = + await deriveAPI.validateOrderBeforeSubmission(signedOrder); + if (!validation.isValid) { + throw new Error( + `Order validation failed: ${validation.errors.join(", ")}`, + ); + } + + // Submit to exchange + const result = await deriveAPI.submitOrder(signedOrder); + + if (!result || result.error) { + throw new Error(result?.error?.message || "Order submission failed"); + } + + return result; + } catch (error) { + throw new Error( + `Order submission failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Initialize order status tracking + */ + private async initializeOrderStatusTracking(): Promise { + try { + // Subscribe to order updates from the API + await deriveAPI.subscribeToOrderUpdates((orderUpdate: unknown) => { + this.handleOrderUpdate(orderUpdate); + }); + + // Subscribe to trade updates for position refresh + await deriveAPI.subscribeToTradeUpdates((tradeUpdate: unknown) => { + this.handleTradeUpdate(tradeUpdate); + }); + + console.log("โœ… Order status tracking initialized"); + } catch (error) { + console.warn("Failed to initialize order status tracking:", error); + } + } + + /** + * Handle order status updates from WebSocket + */ + private handleOrderUpdate(orderUpdate: unknown): void { + try { + // Parse order update + const update = this.parseOrderUpdate(orderUpdate); + + if (update) { + console.log("๐Ÿ“Š Order status update:", { + orderId: update.orderId, + status: update.status, + timestamp: new Date(update.timestamp).toISOString(), + }); + + // Update order history + this.updateOrderInHistory(update); + + // Check if order is completed and trigger position refresh + if (this.isOrderCompleted(update.status)) { + this.handleOrderCompletion(update); + } + + // Notify listeners + this.orderUpdateListeners.forEach((listener) => { + try { + listener(update); + } catch (error) { + console.error("Error in order update listener:", error); + } + }); + } + } catch (error) { + console.error("Failed to handle order update:", error); + } + } + + /** + * Handle trade updates from WebSocket + */ + private handleTradeUpdate(tradeUpdate: unknown): void { + try { + if (typeof tradeUpdate === "object" && tradeUpdate !== null) { + const trade = tradeUpdate as Record; + + console.log("๐Ÿ’ฐ Trade executed:", { + tradeId: trade.tradeId, + orderId: trade.orderId, + instrument: trade.instrumentName, + amount: trade.amount, + price: trade.price, + }); + + // Trigger position refresh after trade execution + this.refreshPositionsAfterTrade(trade); + + // Create trade notification + const notification = orderErrorHandler.createSuccessNotification( + String(trade.tradeId || "trade"), + { + message: `Trade executed: ${trade.amount} ${trade.instrumentName} at ${trade.price}`, + type: "trade_execution", + }, + ); + orderErrorHandler.notify(notification); + } + } catch (error) { + console.error("Failed to handle trade update:", error); + } + } + + /** + * Check if order status indicates completion + */ + private isOrderCompleted(status: string): boolean { + const completedStatuses = [ + "filled", + "completed", + "partially_filled", + "cancelled", + "rejected", + "expired", + ]; + return completedStatuses.includes(status.toLowerCase()); + } + + /** + * Handle order completion - refresh positions and balances + */ + private async handleOrderCompletion( + orderUpdate: OrderStatusUpdate, + ): Promise { + try { + console.log("๐Ÿ”„ Order completed, refreshing positions and balances..."); + + // Refresh positions + await this.refreshPositions(); + + // Refresh balances + await this.refreshBalances(); + + // Create completion notification + const isSuccessful = ["filled", "completed", "partially_filled"].includes( + orderUpdate.status.toLowerCase(), + ); + + if (isSuccessful) { + const notification = orderErrorHandler.createSuccessNotification( + orderUpdate.orderId, + { + message: `Order ${orderUpdate.orderId} ${orderUpdate.status}`, + type: "order_completion", + }, + ); + orderErrorHandler.notify(notification); + } else { + const notification = orderErrorHandler.createWarningNotification( + `Order ${orderUpdate.orderId} ${orderUpdate.status}`, + "Order Status", + ); + orderErrorHandler.notify(notification); + } + } catch (error) { + console.error("Failed to handle order completion:", error); + } + } + + /** + * Refresh positions after trade execution + */ + private async refreshPositionsAfterTrade( + trade: Record, + ): Promise { + try { + // Small delay to ensure backend has processed the trade + setTimeout(async () => { + await this.refreshPositions(); + await this.refreshBalances(); + }, 1000); + } catch (error) { + console.error("Failed to refresh positions after trade:", error); + } + } + + /** + * Refresh user positions + */ + private async refreshPositions(): Promise { + try { + // This would typically trigger a UI refresh + // For now, we just log the action + console.log("๐Ÿ”„ Refreshing positions..."); + + // In a real implementation, this might: + // 1. Fetch updated positions from the API + // 2. Update a global state store + // 3. Trigger UI components to re-render + + // Example: await deriveAPI.getPositions(); + } catch (error) { + console.error("Failed to refresh positions:", error); + } + } + + /** + * Refresh user balances + */ + private async refreshBalances(): Promise { + try { + console.log("๐Ÿ’ฐ Refreshing balances..."); + + // In a real implementation, this might: + // 1. Fetch updated account summary + // 2. Update balance displays in UI + // 3. Recalculate available trading power + + // Example: await deriveAPI.getAccountSummary(); + } catch (error) { + console.error("Failed to refresh balances:", error); + } + } + + /** + * Parse order update from WebSocket message + */ + private parseOrderUpdate(orderUpdate: unknown): OrderStatusUpdate | null { + try { + if (typeof orderUpdate === "object" && orderUpdate !== null) { + const update = orderUpdate as Record; + + // Handle different possible data structures + let orderData = update; + + // If update has an 'order' property, use that + if (update.order) { + orderData = update.order as Record; + } + + // If update is an array, take the first item + if (Array.isArray(update) && update.length > 0) { + orderData = update[0] as Record; + } + + const orderId = String( + orderData.orderId || orderData.order_id || orderData.id || "", + ); + const status = String( + orderData.status || + orderData.order_state || + orderData.state || + "unknown", + ); + + // Extract additional order information + const instrumentName = String( + orderData.instrumentName || orderData.instrument_name || "", + ); + const direction = String(orderData.direction || ""); + const amount = orderData.amount + ? parseFloat(String(orderData.amount)) + : undefined; + const price = orderData.price + ? parseFloat(String(orderData.price)) + : undefined; + const filledAmount = + orderData.filledAmount || orderData.filled_amount + ? parseFloat( + String(orderData.filledAmount || orderData.filled_amount), + ) + : 0; + + return { + orderId, + status, + timestamp: + typeof orderData.timestamp === "number" + ? orderData.timestamp + : Date.now(), + details: { + ...orderData, + instrumentName, + direction, + amount, + price, + filledAmount, + }, + }; + } + + return null; + } catch (error) { + console.error("Failed to parse order update:", error); + return null; + } + } + + /** + * Track status of a specific order + */ + private async trackOrderStatus(orderId: string): Promise { + // For now, we rely on the general order update subscription + // In a more advanced implementation, we could track specific orders + console.log(`Tracking order status for order: ${orderId}`); + } + + /** + * Update internal state and notify listeners + */ + private updateState(updates: Partial): void { + this.state = { ...this.state, ...updates }; + + // Notify all listeners + this.stateChangeListeners.forEach((listener) => { + try { + listener(this.getState()); + } catch (error) { + console.error("Error in order state listener:", error); + } + }); + } + + /** + * Handle notification from error handler + */ + private handleNotification(notification: OrderNotification): void { + // This method can be extended to handle notifications in the UI + // For now, we just log them + console.log( + `Order notification: ${notification.title} - ${notification.message}`, + ); + } + + /** + * Notify validation errors + */ + private notifyValidationErrors(errors: OrderValidation["errors"]): void { + Object.entries(errors).forEach(([field, message]) => { + if (message) { + const notification = orderErrorHandler.createErrorNotification({ + type: OrderErrorType.INVALID_PARAMETERS, + message, + recoverable: true, + retryable: false, + field, + }); + orderErrorHandler.notify(notification); + } + }); + } + + /** + * Handle parameter adjustment recovery action + */ + private async handleParameterAdjustment(error: OrderError): Promise { + console.log("Parameter adjustment needed:", error.field, error.message); + // This would typically trigger UI to highlight the problematic field + // For now, we just log the action + } + + /** + * Handle reconnection recovery action + */ + private async handleReconnection(): Promise { + try { + console.log("Attempting to reconnect to trading server..."); + + // Disconnect and reconnect + deriveAPI.disconnect(); + await deriveAPI.waitForConnection( + ORDER_CONFIG.TIMEOUTS.WEBSOCKET_CONNECTION_TIMEOUT, + ); + + console.log("Reconnection successful"); + + const notification = orderErrorHandler.createSuccessNotification( + "reconnection", + { message: "Successfully reconnected to trading server" }, + ); + orderErrorHandler.notify(notification); + } catch (error) { + console.error("Reconnection failed:", error); + + const notification = orderErrorHandler.createErrorNotification({ + type: OrderErrorType.NETWORK_ERROR, + message: "Failed to reconnect to trading server", + recoverable: true, + retryable: true, + }); + orderErrorHandler.notify(notification); + } + } + + /** + * Handle reauthentication recovery action + */ + private async handleReauthentication( + walletProvider: WalletProvider, + ): Promise { + try { + console.log("Attempting to reauthenticate..."); + + // Clear current session and reauthenticate + await authenticationService.logout(); + await authenticationService.authenticate(walletProvider); + + console.log("Reauthentication successful"); + + const notification = orderErrorHandler.createSuccessNotification( + "reauthentication", + { message: "Successfully reauthenticated" }, + ); + orderErrorHandler.notify(notification); + } catch (error) { + console.error("Reauthentication failed:", error); + + const notification = orderErrorHandler.createErrorNotification({ + type: OrderErrorType.NOT_AUTHENTICATED, + message: "Failed to reauthenticate. Please try signing in manually.", + recoverable: true, + retryable: true, + }); + orderErrorHandler.notify(notification); + } + } + + /** + * Handle balance check recovery action + */ + private async handleBalanceCheck(subaccountId: number): Promise { + try { + console.log("Checking account balance..."); + + const balance = await deriveAPI.getAvailableBalance(subaccountId); + + const notification = orderErrorHandler.createWarningNotification( + `Available balance: ${balance}`, + "Account Balance", + ); + orderErrorHandler.notify(notification); + } catch (error) { + console.error("Balance check failed:", error); + + const notification = orderErrorHandler.createErrorNotification({ + type: OrderErrorType.NETWORK_ERROR, + message: "Failed to check account balance", + recoverable: true, + retryable: true, + }); + orderErrorHandler.notify(notification); + } + } + + /** + * Retry order submission with exponential backoff + */ + async retryOrderSubmission( + orderFormData: OrderFormData, + walletProvider: WalletProvider, + subaccountId: number = 0, + maxRetries: number = ORDER_CONFIG.TIMEOUTS.MAX_RETRY_ATTEMPTS, + ): Promise { + const contextKey = `retry_order_${Date.now()}`; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log(`Order submission attempt ${attempt}/${maxRetries}`); + + const result = await this.submitOrder( + orderFormData, + walletProvider, + subaccountId, + ); + + if (result.success) { + orderErrorHandler.resetRetryAttempts(contextKey); + return result; + } + + // If not successful and not the last attempt, wait before retrying + if (attempt < maxRetries) { + const delay = orderErrorHandler.getRetryDelay(contextKey); + console.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + return result; + } catch (error) { + console.error(`Order submission attempt ${attempt} failed:`, error); + + if (attempt === maxRetries) { + // Last attempt failed, return error result + const orderError = orderErrorHandler.parseError(error, contextKey); + return { + success: false, + error: orderError.message, + details: orderError, + timestamp: Date.now(), + }; + } + + // Wait before next attempt + const delay = orderErrorHandler.getRetryDelay(contextKey); + console.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // This should never be reached, but just in case + return { + success: false, + error: "Maximum retry attempts exceeded", + timestamp: Date.now(), + }; + } + + /** + * Validate order with detailed feedback + */ + async validateOrderDetailed( + orderFormData: OrderFormData, + walletProvider: WalletProvider, + subaccountId: number = 0, + ): Promise<{ + validation: OrderValidation; + suggestions: string[]; + canProceed: boolean; + }> { + const validation = await this.validateOrder( + orderFormData, + walletProvider, + subaccountId, + ); + const suggestions: string[] = []; + + // Generate helpful suggestions based on validation errors + if (validation.errors.size) { + suggestions.push( + `Adjust order size to be between ${ORDER_CONFIG.LIMITS.MIN_ORDER_SIZE} and ${ORDER_CONFIG.LIMITS.MAX_ORDER_SIZE}`, + ); + } + + if (validation.errors.limitPrice) { + suggestions.push( + `Set limit price between ${ORDER_CONFIG.LIMITS.MIN_PRICE} and ${ORDER_CONFIG.LIMITS.MAX_PRICE}`, + ); + } + + if (validation.errors.balance) { + suggestions.push("Check your account balance or reduce the order size"); + } + + if (validation.errors.authentication) { + suggestions.push("Connect your wallet to continue"); + } + + if (validation.errors.network) { + suggestions.push("Check your internet connection"); + } + + if (validation.errors.limits) { + suggestions.push("Select an option to trade"); + } + + // Add general suggestions + if (validation.isValid) { + suggestions.push("Order parameters look good - ready to submit"); + } else { + suggestions.push("Please fix the validation errors before submitting"); + } + + return { + validation, + suggestions, + canProceed: validation.isValid, + }; + } + + /** + * Get order submission statistics + */ + getOrderStatistics(): { + totalOrders: number; + successfulOrders: number; + failedOrders: number; + successRate: number; + lastOrderTime?: number; + } { + // This would typically be tracked over time + // For now, return basic stats based on current state + const hasLastOrder = !!this.state.lastOrder; + const lastOrderSuccess = this.state.lastOrder?.success ?? false; + + return { + totalOrders: hasLastOrder ? 1 : 0, + successfulOrders: lastOrderSuccess ? 1 : 0, + failedOrders: hasLastOrder && !lastOrderSuccess ? 1 : 0, + successRate: hasLastOrder ? (lastOrderSuccess ? 100 : 0) : 0, + lastOrderTime: this.state.lastOrder?.timestamp, + }; + } + + /** + * Add order to history when submitted + */ + private addToOrderHistory( + orderFormData: OrderFormData, + orderResult: OrderResult, + subaccountId: number, + ): void { + if (!orderResult.orderId) return; + + const historyItem: OrderHistoryItem = { + orderId: orderResult.orderId, + instrumentName: orderFormData.selectedOption.instrument.instrument_name, + direction: orderFormData.direction, + amount: parseFloat(orderFormData.size), + price: parseFloat(orderFormData.limitPrice || "0"), + orderType: orderFormData.orderType, + status: "pending", + createdAt: orderResult.timestamp, + updatedAt: orderResult.timestamp, + filledAmount: 0, + averagePrice: undefined, + fee: 0, + subaccountId, + }; + + this.orderHistory.set(orderResult.orderId, historyItem); + this.notifyOrderHistoryListeners(); + } + + /** + * Update order in history from status update + */ + private updateOrderInHistory(update: OrderStatusUpdate): void { + const existingOrder = this.orderHistory.get(update.orderId); + + if (existingOrder) { + // Update existing order + const updatedOrder: OrderHistoryItem = { + ...existingOrder, + status: update.status, + updatedAt: update.timestamp, + }; + + // Update additional fields from details if available + if (update.details && typeof update.details === "object") { + const details = update.details as Record; + + if (details.filledAmount && typeof details.filledAmount === "number") { + updatedOrder.filledAmount = details.filledAmount; + } + + if (details.averagePrice && typeof details.averagePrice === "number") { + updatedOrder.averagePrice = details.averagePrice; + } + + if (details.fee && typeof details.fee === "number") { + updatedOrder.fee = details.fee; + } + } + + this.orderHistory.set(update.orderId, updatedOrder); + this.notifyOrderHistoryListeners(); + } else { + // Create new order entry from update (in case we missed the initial submission) + if (update.details && typeof update.details === "object") { + const details = update.details as Record; + + const historyItem: OrderHistoryItem = { + orderId: update.orderId, + instrumentName: String(details.instrumentName || ""), + direction: String(details.direction || "buy") as "buy" | "sell", + amount: typeof details.amount === "number" ? details.amount : 0, + price: typeof details.price === "number" ? details.price : 0, + orderType: "limit", // Default assumption + status: update.status, + createdAt: update.timestamp, + updatedAt: update.timestamp, + filledAmount: + typeof details.filledAmount === "number" ? details.filledAmount : 0, + averagePrice: + typeof details.averagePrice === "number" + ? details.averagePrice + : undefined, + fee: typeof details.fee === "number" ? details.fee : 0, + subaccountId: 0, // Default assumption + }; + + this.orderHistory.set(update.orderId, historyItem); + this.notifyOrderHistoryListeners(); + } + } + } + + /** + * Notify order history listeners + */ + private notifyOrderHistoryListeners(): void { + const history = this.getOrderHistory(); + this.orderHistoryListeners.forEach((listener) => { + try { + listener(history); + } catch (error) { + console.error("Error in order history listener:", error); + } + }); + } + + /** + * Load order history from API + */ + async loadOrderHistory( + subaccountId?: number, + instrumentName?: string, + limit: number = 50, + ): Promise { + try { + const history = await this.getOrderHistoryFromAPI( + subaccountId, + instrumentName, + limit, + ); + + // Convert API response to OrderHistoryItem format + if (Array.isArray(history)) { + history.forEach((order: unknown) => { + const historyItem: OrderHistoryItem = { + orderId: order.order_id || order.id, + instrumentName: order.instrument_name || "", + direction: order.direction || "buy", + amount: parseFloat(order.amount || "0"), + price: parseFloat(order.price || "0"), + orderType: order.order_type || "limit", + status: order.order_state || order.status || "unknown", + createdAt: order.creation_timestamp || Date.now(), + updatedAt: + order.last_update_timestamp || + order.creation_timestamp || + Date.now(), + filledAmount: parseFloat(order.filled_amount || "0"), + averagePrice: order.average_price + ? parseFloat(order.average_price) + : undefined, + fee: parseFloat(order.fee || "0"), + subaccountId: order.subaccount_id || 0, + }; + + this.orderHistory.set(historyItem.orderId, historyItem); + }); + + this.notifyOrderHistoryListeners(); + } + } catch (error) { + console.error("Failed to load order history:", error); + } + } + + /** + * Clear order history + */ + clearOrderHistory(): void { + this.orderHistory.clear(); + this.notifyOrderHistoryListeners(); + } + + /** + * Cleanup resources + */ + destroy(): void { + // Unsubscribe from order updates + if (this.orderStatusSubscription) { + this.orderStatusSubscription(); + this.orderStatusSubscription = null; + } + + // Unsubscribe from notifications + if (this.notificationUnsubscribe) { + this.notificationUnsubscribe(); + this.notificationUnsubscribe = null; + } + + // Clear listeners + this.stateChangeListeners = []; + this.orderUpdateListeners = []; + this.orderHistoryListeners = []; + + // Clear order history + this.orderHistory.clear(); + } +} + +// Export singleton instance +export const orderService = new OrderService(); diff --git a/app/lib/order-signing.ts b/app/lib/order-signing.ts new file mode 100644 index 0000000..ffc3144 --- /dev/null +++ b/app/lib/order-signing.ts @@ -0,0 +1,426 @@ +/** + * Order Signing Service for Derive Protocol + * + * This service handles cryptographic signing of orders according to Derive protocol specifications. + * It implements the exact signing flow as documented in the Derive API reference. + */ + +import { ethers } from "ethers"; +import { + ORDER_CONFIG, + DERIVE_PROTOCOL_CONSTANTS, + VALIDATION_RULES, +} from "./order-config"; +import { + orderValidator, + getDefaultOrderLimits, + type DetailedValidationResult, +} from "./order-validation"; + +// Types for order parameters - matching Derive API exactly +export interface OrderParams { + instrument_name: string; + subaccount_id: number; + direction: "buy" | "sell"; + limit_price: number; + amount: number; + signature_expiry_sec: number; + max_fee: string; + nonce: number; + signer: string; + order_type: "limit" | "market"; + mmp: boolean; +} + +export interface SignedOrder extends OrderParams { + signature: string; +} + +export interface ValidationResult { + isValid: boolean; + errors: string[]; +} + +// Derive Protocol Constants from documentation +const ACTION_TYPEHASH = + "0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17"; +const DOMAIN_SEPARATOR = + "0x9bcf4dc06df5d8bf23af818d5716491b995020f377d3b7b64c29ed14e3dd1105"; +const ASSET_ADDRESS = "0xBcB494059969DAaB460E0B5d4f5c2366aab79aa1"; // ETH asset address +const TRADE_MODULE_ADDRESS = "0x87F2863866D85E3192a35A73b388BD625D83f2be"; + +export class OrderSigningService { + private encoder = ethers.utils.defaultAbiCoder; + + /** + * Signs an order using the exact Derive protocol signing flow + */ + async signOrder( + order: OrderParams, + signer: ethers.Signer, + ): Promise { + try { + // Validate order parameters first + const validation = this.validateOrderParams(order); + if (!validation.isValid) { + throw new Error( + `Order validation failed: ${validation.errors.join(", ")}`, + ); + } + + // Get instrument details (we'll need to fetch OPTION_SUB_ID from the API) + const instrumentDetails = await this.getInstrumentDetails( + order.instrument_name, + ); + + // Step 1: Encode trade data according to Derive specification + const tradeModuleData = this.encodeTradeData( + order, + instrumentDetails.subId, + ); + + // Step 2: Generate action hash + const actionHash = this.generateActionHash(order, tradeModuleData); + + // Step 3: Sign using the exact Derive protocol signing flow + const signature = await this.signTypedData(order, actionHash, signer); + + return { + ...order, + signature, + }; + } catch (error) { + throw new Error( + `Failed to sign order: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Get instrument details including sub ID + */ + private async getInstrumentDetails( + instrumentName: string, + ): Promise<{ subId: string }> { + try { + // Import deriveAPI to get instrument details + const { deriveAPI } = await import("./derive-api"); + + // This would call public/get_instrument to get the sub ID + // For now, we'll use a placeholder - in real implementation this should be fetched + return { + subId: "644245094401698393600", // This should be fetched from the API + }; + } catch (error) { + throw new Error( + `Failed to get instrument details: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Encodes trade data according to Derive protocol specifications + * This matches the exact encoding from the documentation + */ + private encodeTradeData(order: OrderParams, optionSubId: string): string { + try { + // Encode data exactly as shown in Derive documentation + const encodedData = this.encoder.encode( + ["address", "uint", "int", "int", "uint", "uint", "bool"], + [ + ASSET_ADDRESS, + optionSubId, + ethers.utils.parseUnits(order.limit_price.toString(), 18), + ethers.utils.parseUnits(order.amount.toString(), 18), + ethers.utils.parseUnits(order.max_fee.toString(), 18), + order.subaccount_id, + order.direction === "buy", + ], + ); + + // Return keccak256 hash of the encoded data + return ethers.utils.keccak256(Buffer.from(encodedData.slice(2), "hex")); + } catch (error) { + throw new Error( + `Failed to encode trade data: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Generates action hash exactly as shown in Derive documentation + */ + private generateActionHash( + order: OrderParams, + tradeModuleData: string, + ): string { + try { + // Generate action hash exactly as shown in documentation + const actionHash = ethers.utils.keccak256( + this.encoder.encode( + [ + "bytes32", + "uint256", + "uint256", + "address", + "bytes32", + "uint256", + "address", + "address", + ], + [ + ACTION_TYPEHASH, + order.subaccount_id, + order.nonce, + TRADE_MODULE_ADDRESS, + tradeModuleData, + order.signature_expiry_sec, + order.signer, // wallet address + order.signer, // signer address (same as wallet for now) + ], + ), + ); + + return actionHash; + } catch (error) { + throw new Error( + `Failed to generate action hash: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Creates typed data hash for signing exactly as shown in Derive documentation + */ + private createTypedDataHash(actionHash: string): string { + try { + // Create typed data hash exactly as shown in documentation + const typedDataHash = ethers.utils.keccak256( + Buffer.concat([ + Buffer.from("1901", "hex"), + Buffer.from(DOMAIN_SEPARATOR.slice(2), "hex"), + Buffer.from(actionHash.slice(2), "hex"), + ]), + ); + + return typedDataHash; + } catch (error) { + throw new Error( + `Failed to create typed data hash: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Signs the hash using wallet's signing key + */ + /** + * Sign typed data exactly as shown in Derive documentation + */ + private async signTypedData( + order: OrderParams, + actionHash: string, + signer: ethers.Signer, + ): Promise { + try { + // Create the typed data hash exactly as shown in the documentation + const typedDataHash = this.createTypedDataHash(actionHash); + + // Check if it's an ethers.Wallet with signingKey (private key wallet from PRIVATE_KEY) + const wallet = signer as ethers.Wallet; + if (wallet.signingKey) { + // For private key wallets, use signingKey.sign() as shown in docs + const signature = wallet.signingKey.sign(typedDataHash); + return signature.serialized; + } + + // For EOA wallets (MetaMask, browser wallets), use signMessage + // The documentation shows wallet.signMessage() for authentication, same approach for order signing + const signature = await signer.signMessage( + ethers.utils.arrayify(typedDataHash), + ); + return signature; + } catch (error) { + throw new Error( + `Failed to sign typed data: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Generates a unique nonce using timestamp + random number format + */ + generateNonce(): number { + try { + // Use timestamp (in seconds) + random number for uniqueness + const timestamp = Math.floor(Date.now() / 1000); + const randomComponent = Math.floor(Math.random() * 1000000); // 6-digit random number + + // Combine timestamp and random component + // Format: timestamp (10 digits) + random (6 digits) + const nonce = timestamp * 1000000 + randomComponent; + + // Ensure nonce is within safe integer range + if (nonce > Number.MAX_SAFE_INTEGER) { + throw new Error("Generated nonce exceeds safe integer range"); + } + + return nonce; + } catch (error) { + throw new Error( + `Failed to generate nonce: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Validates order parameters according to Derive protocol requirements + * Uses the enhanced validation utilities for comprehensive validation + */ + validateOrderParams( + order: OrderParams, + availableBalance?: number, + ): ValidationResult { + try { + // Use the enhanced validator for comprehensive validation + const limits = getDefaultOrderLimits(); + const detailedResult = orderValidator.validateCompleteOrder( + order, + limits, + availableBalance, + ); + + // Convert detailed validation result to simple format for backward compatibility + const errors = detailedResult.errors.map((error) => error.message); + + // Add warnings as informational errors (non-blocking) + if (detailedResult.warnings.length > 0) { + // Warnings don't make validation fail, but we can log them + console.warn("Order validation warnings:", detailedResult.warnings); + } + + return { + isValid: detailedResult.isValid, + errors, + }; + } catch (error) { + return { + isValid: false, + errors: [ + `Validation error: ${error instanceof Error ? error.message : "Unknown error"}`, + ], + }; + } + } + + /** + * Enhanced validation that returns detailed results including warnings + */ + validateOrderParamsDetailed( + order: OrderParams, + availableBalance?: number, + ): DetailedValidationResult { + try { + const limits = getDefaultOrderLimits(); + return orderValidator.validateCompleteOrder( + order, + limits, + availableBalance, + ); + } catch (error) { + return { + isValid: false, + errors: [ + { + field: "validation", + message: `Validation error: ${error instanceof Error ? error.message : "Unknown error"}`, + code: "VALIDATION_ERROR", + }, + ], + warnings: [], + }; + } + } + + /** + * Validates instrument name format according to Derive specifications + */ + private validateInstrumentName(instrumentName: string): boolean { + // Check for options format: ETH-20240315-C-3000 + if (VALIDATION_RULES.INSTRUMENT_NAME_PATTERN.test(instrumentName)) { + return true; + } + + // Check for futures format: ETH-PERP + if (VALIDATION_RULES.FUTURES_INSTRUMENT_PATTERN.test(instrumentName)) { + return true; + } + + return false; + } + + /** + * Converts price to wei format for blockchain compatibility + */ + private encodePriceToWei(price: number): ethers.BigNumber { + try { + // Convert price to wei (assuming 18 decimal places) + const priceString = price.toFixed(18); + return ethers.utils.parseEther(priceString); + } catch (error) { + throw new Error( + `Failed to encode price to wei: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Converts amount to wei format for blockchain compatibility + */ + private encodeAmountToWei(amount: number): ethers.BigNumber { + try { + // Convert amount to wei (assuming 18 decimal places) + const amountString = amount.toFixed(18); + return ethers.utils.parseEther(amountString); + } catch (error) { + throw new Error( + `Failed to encode amount to wei: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Creates a complete order with generated nonce and expiry + */ + createOrderParams( + instrumentName: string, + subaccountId: number, + direction: "buy" | "sell", + limitPrice: number, + amount: number, + signer: string, + orderType: "limit" | "market" = "limit", + mmp: boolean = false, + maxFee?: string, + ): OrderParams { + const currentTime = Math.floor(Date.now() / 1000); + + return { + instrument_name: instrumentName, + subaccount_id: subaccountId, + direction, + limit_price: limitPrice, + amount, + signature_expiry_sec: + currentTime + ORDER_CONFIG.TIMEOUTS.DEFAULT_SIGNATURE_EXPIRY, + max_fee: maxFee || VALIDATION_RULES.DEFAULT_MAX_FEE, + nonce: this.generateNonce(), + signer, + order_type: orderType, + mmp, + }; + } +} + +// Export singleton instance +export const orderSigningService = new OrderSigningService(); diff --git a/app/lib/order-validation.ts b/app/lib/order-validation.ts new file mode 100644 index 0000000..5189991 --- /dev/null +++ b/app/lib/order-validation.ts @@ -0,0 +1,132 @@ +/** + * Order Validation Service for Derive Protocol + */ + +export interface OrderLimits { + minOrderSize: number; + maxOrderSize: number; + minPrice: number; + maxPrice: number; + maxFee: string; +} + +export interface ValidationError { + field: string; + message: string; + code: string; +} + +export interface ValidationWarning { + field: string; + message: string; + code: string; +} + +export interface DetailedValidationResult { + isValid: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; +} + +export class OrderValidator { + validateCompleteOrder( + order: any, + limits: OrderLimits, + availableBalance?: number, + ): DetailedValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // Validate order size + if (!order.amount || order.amount <= 0) { + errors.push({ + field: "amount", + message: "Order size must be greater than 0", + code: "INVALID_SIZE", + }); + } else if (order.amount < limits.minOrderSize) { + errors.push({ + field: "amount", + message: `Order size must be at least ${limits.minOrderSize}`, + code: "SIZE_TOO_SMALL", + }); + } else if (order.amount > limits.maxOrderSize) { + errors.push({ + field: "amount", + message: `Order size cannot exceed ${limits.maxOrderSize}`, + code: "SIZE_TOO_LARGE", + }); + } + + // Validate limit price for limit orders + if (order.order_type === "limit") { + if (!order.limit_price || order.limit_price <= 0) { + errors.push({ + field: "limit_price", + message: "Limit price must be greater than 0", + code: "INVALID_PRICE", + }); + } else if (order.limit_price < limits.minPrice) { + errors.push({ + field: "limit_price", + message: `Limit price must be at least ${limits.minPrice}`, + code: "PRICE_TOO_LOW", + }); + } else if (order.limit_price > limits.maxPrice) { + errors.push({ + field: "limit_price", + message: `Limit price cannot exceed ${limits.maxPrice}`, + code: "PRICE_TOO_HIGH", + }); + } + } + + // Validate instrument name + if (!order.instrument_name) { + errors.push({ + field: "instrument_name", + message: "Instrument name is required", + code: "MISSING_INSTRUMENT", + }); + } + + // Validate balance if provided + if (availableBalance !== undefined && order.order_type === "limit") { + const orderValue = order.amount * order.limit_price; + if (orderValue > availableBalance) { + errors.push({ + field: "balance", + message: "Insufficient balance for this order", + code: "INSUFFICIENT_BALANCE", + }); + } + } + + // Add warnings for market orders + if (order.order_type === "market") { + warnings.push({ + field: "order_type", + message: "Market orders execute immediately at current market price", + code: "MARKET_ORDER_WARNING", + }); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } +} + +export function getDefaultOrderLimits(): OrderLimits { + return { + minOrderSize: 0.01, + maxOrderSize: 1000000, + minPrice: 0.01, + maxPrice: 1000000, + maxFee: "0.01", + }; +} + +export const orderValidator = new OrderValidator(); diff --git a/app/trade/future/page.tsx b/app/trade/future/page.tsx index 08a26f2..ff1eb46 100644 --- a/app/trade/future/page.tsx +++ b/app/trade/future/page.tsx @@ -10,29 +10,50 @@ import FutureDropdown from "@/app/ui/future/future-dropdown"; import PositionOpenClose from "@/app/ui/future/position-open-close"; import PositionsSection from "@/app/ui/future/positions-section"; import TradingViewChart from "@/app/ui/future/trading-view-chart"; +import OrderBook from "@/app/ui/future/orderbook"; import axios from "axios"; import { Contract } from "ethers"; import { useWeb3React } from "@web3-react/core"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { opAddressList } from "@/app/lib/web3-constants"; import OpMarkPrice from "../../abi/vanna/v1/out/OpMarkPrice.sol/OpMarkPrice.json"; import OpIndexPrice from "../../abi/vanna/v1/out/OpIndexPrice.sol/OpIndexPrice.json"; import { ceilWithPrecision } from "@/app/lib/helper"; +import { + subscribeToFuturesTicker, + getFuturesInstrumentName, + fetchInstrumentStatistics, +} from "@/app/lib/derive-api"; +import { + orderService, + type OrderFormData, + type OrderState, + type OrderValidation, + type OrderResult, + type OrderStatusUpdate, +} from "@/app/lib/order-service"; +import { + authenticationService, + type AuthenticationState, + type WalletProvider, +} from "@/app/lib/authentication-service"; +import OrderStatusDisplay from "@/app/ui/components/order-status-display"; -export default function Page() { - const { library } = useWeb3React(); - const { currentNetwork } = useNetwork(); +// Move these outside the component to prevent recreation on every render +const pairOptions: Option[] = [ + { value: "ETH", label: "ETH", icon: "/eth-icon.svg" }, + { value: "BTC", label: "BTC", icon: "/btc-icon.svg" }, +]; - const pairOptions: Option[] = [ - { value: "ETH", label: "ETH", icon: "/eth-icon.svg" }, - { value: "BTC", label: "BTC", icon: "/btc-icon.svg" }, - ]; +const networkOptionsMap: { [key: string]: Option[] } = { + [BASE_NETWORK]: [{ value: "Avantisfi", label: "Avantisfi" }], + [ARBITRUM_NETWORK]: [{ value: "MUX", label: "MUX" }], + [OPTIMISM_NETWORK]: [{ value: "Perp", label: "Perp" }], +}; - const networkOptionsMap: { [key: string]: Option[] } = { - [BASE_NETWORK]: [{ value: "Avantisfi", label: "Avantisfi" }], - [ARBITRUM_NETWORK]: [{ value: "MUX", label: "MUX" }], - [OPTIMISM_NETWORK]: [{ value: "Perp", label: "Perp" }], - }; +export default function Page() { + const { account, library } = useWeb3React(); + const { currentNetwork } = useNetwork(); const [dataFetching, setDataFetching] = useState(false); const [selectedPair, setSelectedPair] = useState