From 7d47eec1f2a610164d31d5ac4aeec226928bd1fa Mon Sep 17 00:00:00 2001 From: Sosthene Date: Mon, 15 Sep 2025 04:34:04 +0200 Subject: [PATCH] Rename signer-improved to simply signer --- src/controllers/process.controller.ts | 136 ++++++-- src/server.ts | 12 +- src/services/signer-improved/index.ts | 482 -------------------------- src/services/signer/index.ts | 480 ++++++++++++++++++++++++- 4 files changed, 589 insertions(+), 521 deletions(-) delete mode 100644 src/services/signer-improved/index.ts diff --git a/src/controllers/process.controller.ts b/src/controllers/process.controller.ts index 2b060cd..c7d1ec0 100644 --- a/src/controllers/process.controller.ts +++ b/src/controllers/process.controller.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { SignerImprovedService } from '../services/signer'; +import { SignerService } from '../services/signer'; import { SessionManager } from '../utils/session-manager'; import { authTokens } from '../utils/auth-tokens'; import { ProcessInfo, ProcessData, ProcessRoles, EOfficeStatus } from '../types'; @@ -32,7 +32,7 @@ export class ProcessController { const { pairingId } = req.query; // Execute signer operations with retry logic - const processResult = await SignerImprovedService.executeWithRetry( + const processResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot); }, @@ -91,7 +91,7 @@ export class ProcessController { privateFields.splice(privateFields.indexOf('idNot'), 1); // Get pairing ID with retry - const pairingResult = await SignerImprovedService.executeWithRetry( + const pairingResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.getPairingId(); }, @@ -125,7 +125,7 @@ export class ProcessController { }; // Create process with retry - const createResult = await SignerImprovedService.executeWithRetry( + const createResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.createProcess(processData, privateFields, roles); }, @@ -155,7 +155,7 @@ export class ProcessController { } // Check if process is committed and handle role updates - const processManagementResult = await SignerImprovedService.executeWithRetry( + const processManagementResult = await SignerService.executeWithRetry( async (signerClient) => { const allProcesses = await signerClient.getOwnedProcesses(); @@ -202,6 +202,12 @@ export class ProcessController { const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; await signerClient.notifyUpdate(processId, stateId); await signerClient.validateState(processId, stateId); + } else { + Logger.info('PairingId already in owner role', { + requestId, + pairingId: req.query.pairingId, + roles: roles['owner'].members + }); } } @@ -243,7 +249,7 @@ export class ProcessController { } // Get office process with retry - const processResult = await SignerImprovedService.executeWithRetry( + const processResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot); }, @@ -264,7 +270,7 @@ export class ProcessController { }); // Get validator ID with retry - const pairingResult = await SignerImprovedService.executeWithRetry( + const pairingResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.getPairingId(); }, @@ -311,6 +317,11 @@ export class ProcessController { // No need for public fields? const roles: ProcessRoles = { + collaborator: { + members: [], + validation_rules: [], + storages: [] + }, owner: { members: [validatorId], validation_rules: [{ @@ -328,7 +339,7 @@ export class ProcessController { }; // Create office process with retry - const createResult = await SignerImprovedService.executeWithRetry( + const createResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.createProcess(processData, privateFields, roles); }, @@ -341,30 +352,105 @@ export class ProcessController { } Logger.info('Created new office process', { process: createResult.data }); - - // Use the idnot number to identify all active members of the office - const officeCollaborators = await IdNotController.getOfficeRattachements(userAuth.idNotUser.office.idNot); - Logger.debug('Office collaborators', { officeCollaborators }); - - // Logger.info('Created new office process', { - // requestId, - // processId: createResult.data!.processId - // }); - process = { - processId: createResult.data!.processCreated.processId || '', + processId: createResult.data!.processCreated.processId, processData: createResult.data!.processData }; } + Logger.info('Using office process', { + requestId, + processId: process.processId, + processData: process.processData + }); + + // Check if process is committed and handle role updates + const processManagementResult = await SignerService.executeWithRetry( + async (signerClient) => { + const allProcesses = await signerClient.getOwnedProcesses(); + + if (allProcesses && process) { + const processStates = allProcesses.processes[process.processId].states; + const isNotCommited = processStates.length === 2 + && processStates[1].commited_in === processStates[0].commited_in; + + if (isNotCommited) { + Logger.info('Process not committed, committing it', { + requestId, + processId: process.processId + }); + await signerClient.validateState(process.processId, processStates[0].state_id); + } + } + + // // Use the idnot number to identify all active members of the office + // const officeCollaborators = await IdNotController.getOfficeRattachements(userAuth.idNotUser.office.idNot); + // Logger.debug('Office collaborators', { officeCollaborators }); + + // let roles: ProcessRoles; + // if (isNotCommited) { + // const firstState = processStates[0]; + // roles = firstState.roles; + // } else { + // const tip = processStates[processStates.length - 1].commited_in; + // const lastState = processStates.findLast((state: any) => state.commited_in !== tip); + // roles = lastState.roles; + // } + + // if (!roles) { + // throw new Error('No roles found'); + // } else if (!roles['owner'] || !roles['collaborator']) { + // throw new Error('No owner or collaborator role found'); + // } + + // for (const collaborator of officeCollaborators) { + // if (collaborator.idNot === userAuth.idNotUser.idNot) { + // // We add ourselves regardless of the activity status + // // We should have a collaborator process + + // continue; + // } + // if (collaborator.activite === 'En exercice') { + // // TODO we check if the collaborator has a process + // // If not, we create a new process for them + // // if yes, we add the collaborator process in role `collaborator` + // // we also lookup the pairing ids in the collaborator process and put them all in the owner role + // } + // } + + // if (!roles['owner'].members.includes(req.query.pairingId as string)) { + // Logger.info('Adding new pairingId to owner role', { + // requestId, + // pairingId: req.query.pairingId, + // processId: process.processId + // }); + + // roles['owner'].members.push(req.query.pairingId as string); + // const updatedProcessReturn = await signerClient.updateProcess(process.processId, {}, [], roles); + // const processId = updatedProcessReturn.updatedProcess.process_id; + // const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; + // await signerClient.notifyUpdate(processId, stateId); + // await signerClient.validateState(processId, stateId); + // } + return process; + }, + 'processManagement', + 2 + ); + + console.log('processManagementResult', processManagementResult); + if (!processManagementResult.success) { + throw new ExternalServiceError('Signer', processManagementResult.error?.message || 'Failed to manage office process', requestId); + } + Logger.info('Office process request completed successfully', { requestId, - processId: process?.processId + processId: processManagementResult.data?.processId }); res.json({ success: true, - data: process + data: processManagementResult.data }); }); @@ -401,7 +487,7 @@ export class ProcessController { Logger.info('Phone number lookup initiated', { requestId, email }); - const phoneResult = await SignerImprovedService.executeWithRetry( + const phoneResult = await SignerService.executeWithRetry( async (signerClient) => { return await signerClient.getPhoneNumberForEmail(email); }, @@ -430,7 +516,7 @@ export class ProcessController { // Health check endpoint for signer service static getSignerHealth = asyncHandler(async (req: Request, res: Response): Promise => { - const healthStatus = SignerImprovedService.getHealthStatus(); + const healthStatus = SignerService.getHealthStatus(); res.json({ success: true, @@ -447,7 +533,7 @@ export class ProcessController { Logger.info('Force signer reconnection requested', { requestId }); - const reconnectResult = await SignerImprovedService.forceReconnect(); + const reconnectResult = await SignerService.forceReconnect(); if (!reconnectResult.success) { throw new ExternalServiceError('Signer', reconnectResult.error?.message || 'Failed to reconnect', requestId); @@ -458,7 +544,7 @@ export class ProcessController { res.json({ success: true, message: 'Signer reconnection initiated', - data: SignerImprovedService.getHealthStatus() + data: SignerService.getHealthStatus() }); }); } diff --git a/src/server.ts b/src/server.ts index f63a2c5..2f10618 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import express from 'express'; import cors from 'cors'; import { config } from './config'; import { routes } from './routes'; -import { SignerImprovedService } from './services/signer-improved'; +import { SignerService } from './services/signer'; import { SessionManager } from './utils/session-manager'; import { EmailService } from './services/email'; import { authTokens } from './utils/auth-tokens'; @@ -41,7 +41,7 @@ app.use(errorHandler); // Initialize signer service with enhanced reconnection logic (async () => { try { - const result = await SignerImprovedService.initialize(); + const result = await SignerService.initialize(); if (result.success) { Logger.info('Signer service initialized'); } else { @@ -57,7 +57,7 @@ app.use(errorHandler); })(); // Set up signer connection monitoring -SignerImprovedService.onConnectionChange((connected) => { +SignerService.onConnectionChange((connected) => { if (connected) { Logger.info('Signer connected'); } else { @@ -110,13 +110,13 @@ async function startServer(): Promise { // Graceful shutdown handling process.on('SIGTERM', () => { Logger.info('SIGTERM received, shutting down gracefully'); - SignerImprovedService.cleanup(); + SignerService.cleanup(); process.exit(0); }); process.on('SIGINT', () => { Logger.info('SIGINT received, shutting down gracefully'); - SignerImprovedService.cleanup(); + SignerService.cleanup(); process.exit(0); }); @@ -126,7 +126,7 @@ process.on('uncaughtException', (error) => { error: error.message, stack: error.stack }); - SignerImprovedService.cleanup(); + SignerService.cleanup(); process.exit(1); }); diff --git a/src/services/signer-improved/index.ts b/src/services/signer-improved/index.ts deleted file mode 100644 index 0ae44c7..0000000 --- a/src/services/signer-improved/index.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { SDKSignerClient, ClientConfig } from 'sdk-signer-client'; -import { signerConfig } from '../../config/signer'; -import { Logger } from '../../utils/logger'; -import { Result, ServiceResult } from '../../utils/result'; - -export enum SignerConnectionState { - DISCONNECTED = 'disconnected', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting', - FAILED = 'failed' -} - -export interface SignerHealthCheck { - state: SignerConnectionState; - lastConnected?: number; - lastError?: string; - reconnectAttempts: number; - nextReconnectAt?: number; -} - -export class SignerImprovedService { - private static instance: SDKSignerClient | null = null; - private static connectionState: SignerConnectionState = SignerConnectionState.DISCONNECTED; - private static reconnectAttempts: number = 0; - private static lastConnected: number | null = null; - private static lastError: string | null = null; - private static reconnectTimeout: NodeJS.Timeout | null = null; - private static maxReconnectAttempts: number = 10; // More attempts than the SDK default - private static reconnectInterval: number = 5000; // 5 seconds - private static backoffMultiplier: number = 1.5; // Exponential backoff - private static maxReconnectInterval: number = 60000; // Max 60 seconds between attempts - private static healthCheckInterval: NodeJS.Timeout | null = null; - private static connectionCallbacks: Array<(connected: boolean) => void> = []; - - /** - * Initialize the signer service with enhanced reconnection logic - */ - static async initialize(): Promise> { - try { - Logger.info('Initializing Signer service'); - - // Create client instance - this.instance = new SDKSignerClient(signerConfig); - - // Set up event listeners for connection monitoring - this.setupEventListeners(); - - // Start periodic health checks - this.startHealthChecks(); - - // Attempt initial connection - const connectResult = await this.connect(); - - return connectResult; - } catch (error) { - Logger.error('Failed to initialize Signer service', { - error: error instanceof Error ? error.message : 'Unknown error' - }); - return Result.fromError(error instanceof Error ? error : new Error('Initialization failed'), 'SIGNER_INIT_ERROR'); - } - } - - /** - * Get the signer client instance with connection validation - */ - static async getInstance(): Promise> { - if (!this.instance) { - const initResult = await this.initialize(); - if (!initResult.success) { - return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer service not initialized', initResult.error); - } - } - - // Check if we're connected - if (this.connectionState !== SignerConnectionState.CONNECTED) { - // Try to reconnect if not already attempting - if (this.connectionState !== SignerConnectionState.CONNECTING && - this.connectionState !== SignerConnectionState.RECONNECTING) { - Logger.warn('Signer not connected, attempting reconnection'); - const reconnectResult = await this.connect(); - if (!reconnectResult.success) { - return Result.failure('SIGNER_CONNECTION_FAILED', 'Failed to connect to signer', reconnectResult.error); - } - } else { - return Result.failure('SIGNER_CONNECTING', 'Signer is currently connecting, please retry'); - } - } - - return Result.success(this.instance!); - } - - /** - * Connect to the signer service with retry logic - */ - private static async connect(): Promise> { - if (!this.instance) { - return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer client not initialized'); - } - - this.connectionState = this.reconnectAttempts === 0 ? SignerConnectionState.CONNECTING : SignerConnectionState.RECONNECTING; - - try { - // Only log connection attempts if we've been trying for a while - if (this.reconnectAttempts > 2) { - Logger.info('Signer connection attempt', { - attempt: this.reconnectAttempts + 1, - maxAttempts: this.maxReconnectAttempts - }); - } - - await this.instance.connect(); - - // Connection successful - state will be updated by the 'open' event handler - return Result.success(undefined); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'; - this.lastError = errorMessage; - - // Only log detailed errors after several attempts or if we've reached the limit - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - this.connectionState = SignerConnectionState.FAILED; - Logger.error('Signer connection failed after all attempts', { - attempts: this.maxReconnectAttempts, - lastError: errorMessage - }); - return Result.failure('SIGNER_CONNECTION_FAILED', `Failed to connect after ${this.maxReconnectAttempts} attempts: ${errorMessage}`); - } else if (this.reconnectAttempts > 3) { - Logger.warn('Signer connection failing', { - attempt: this.reconnectAttempts + 1, - error: errorMessage - }); - } - - // Schedule reconnection with exponential backoff - this.scheduleReconnection(); - - return Result.failure('SIGNER_CONNECTION_FAILED', errorMessage); - } - } - - /** - * Schedule a reconnection attempt with exponential backoff - */ - private static scheduleReconnection(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - } - - // Calculate delay with exponential backoff - const baseDelay = this.reconnectInterval; - const delay = Math.min( - baseDelay * Math.pow(this.backoffMultiplier, this.reconnectAttempts), - this.maxReconnectInterval - ); - - this.reconnectAttempts++; - - // Only log scheduling if this is taking a while - if (this.reconnectAttempts > 2) { - Logger.debug('Next reconnection in', { delayMs: delay }); - } - - this.reconnectTimeout = setTimeout(async () => { - await this.connect(); - }, delay); - } - - /** - * Set up event listeners for the signer client - */ - private static setupEventListeners(): void { - if (!this.instance) return; - - try { - // Listen for WebSocket close events - this.instance.on('close', () => { - // Don't log here - let handleDisconnection() do the logging - this.handleDisconnection(); - }); - - // Listen for WebSocket error events - this.instance.on('error', (error: Error) => { - // Only log if it's a new error - if (this.lastError !== error.message) { - Logger.error('Signer WebSocket error', { error: error.message }); - } - this.handleError(error); - }); - - // Listen for successful reconnection events - this.instance.on('reconnect', () => { - Logger.info('Signer reconnected via SDK'); - this.handleSDKReconnection(); - }); - - // Listen for connection open events - this.instance.on('open', () => { - Logger.info('Signer connected'); - this.handleSDKConnection(); - }); - - Logger.debug('Signer event listeners configured'); - - } catch (error) { - Logger.warn('Could not set up SDK event listeners', { - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - - /** - * Handle disconnection events from WebSocket - */ - private static handleDisconnection(): void { - // Only handle if we were actually connected (avoid duplicate handling) - if (this.connectionState === SignerConnectionState.CONNECTED) { - Logger.warn('Signer disconnected - reconnecting...'); - this.connectionState = SignerConnectionState.DISCONNECTED; - this.notifyConnectionCallbacks(false); - - // Cancel any pending health checks since we know we're disconnected - this.cancelScheduledOperations(); - - // Initiate immediate reconnection (but respect ongoing attempts) - if (!this.reconnectTimeout) { - this.scheduleReconnection(); - } - } - } - - /** - * Handle connection errors from WebSocket - */ - private static handleError(error: Error): void { - this.lastError = error.message; - - // Only log detailed error if we were connected (avoid startup error spam) - if (this.connectionState === SignerConnectionState.CONNECTED) { - Logger.error('Signer connection error', { error: error.message }); - this.handleDisconnection(); - } - } - - /** - * Handle successful SDK reconnection - */ - private static handleSDKReconnection(): void { - // The SDK handled the reconnection, update our state - this.connectionState = SignerConnectionState.CONNECTED; - this.lastConnected = Date.now(); - this.lastError = null; - this.reconnectAttempts = 0; - - // Clear any pending reconnection timeout since SDK handled it - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - // Notify callbacks (they will handle the logging) - this.notifyConnectionCallbacks(true); - } - - /** - * Handle SDK connection open events - */ - private static handleSDKConnection(): void { - this.connectionState = SignerConnectionState.CONNECTED; - this.lastConnected = Date.now(); - this.lastError = null; - this.reconnectAttempts = 0; - - // Clear any pending operations - this.cancelScheduledOperations(); - - // Notify callbacks (they will handle the logging) - this.notifyConnectionCallbacks(true); - } - - /** - * Cancel scheduled operations (reconnection, health checks during known disconnection) - */ - private static cancelScheduledOperations(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - } - - /** - * Start periodic health checks - */ - private static startHealthChecks(): void { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - } - - this.healthCheckInterval = setInterval(async () => { - await this.performHealthCheck(); - }, 30000); // Check every 30 seconds - } - - /** - * Perform a health check on the signer connection - */ - private static async performHealthCheck(): Promise { - // Only perform health checks if we think we're connected - if (!this.instance || this.connectionState !== SignerConnectionState.CONNECTED) { - Logger.debug('Skipping health check - not in connected state', { - state: this.connectionState, - hasInstance: !!this.instance - }); - return; - } - - try { - // Try a simple operation to verify connection is actually working - await this.instance.getPairingId(); - - Logger.debug('Signer health check passed'); - - } catch (error) { - Logger.warn('Signer health check failed - connection may be stale', { - error: error instanceof Error ? error.message : 'Unknown error' - }); - - // Health check failed, but we haven't received a close/error event - // This indicates a stale connection - treat as disconnection - this.handleDisconnection(); - } - } - - /** - * Execute a signer operation with automatic retry on connection failure - */ - static async executeWithRetry( - operation: (client: SDKSignerClient) => Promise, - operationName: string, - maxRetries: number = 3 - ): Promise> { - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const clientResult = await this.getInstance(); - if (!clientResult.success) { - lastError = new Error(clientResult.error?.message || 'Failed to get signer instance'); - - if (attempt < maxRetries) { - // Only log operation retries if it's the final attempt or taking too long - if (attempt === maxRetries - 1) { - Logger.warn(`${operationName} final retry attempt`, { - error: lastError.message - }); - } - - // Wait before retry - await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); - continue; - } - break; - } - - const result = await operation(clientResult.data!); - - // Only log success if it took multiple attempts - if (attempt > 1) { - Logger.info(`${operationName} succeeded after ${attempt} attempts`); - } - - return Result.success(result); - - } catch (error) { - lastError = error instanceof Error ? error : new Error('Unknown error'); - - // Only log operation failures if it's taking multiple attempts - if (attempt > 1) { - Logger.warn(`${operationName} attempt ${attempt} failed`, { - error: lastError.message - }); - } - - // If it's a connection error, force reconnection - if (lastError.message.includes('connection') || lastError.message.includes('websocket')) { - this.connectionState = SignerConnectionState.DISCONNECTED; - } - - if (attempt < maxRetries) { - // Wait before retry with exponential backoff - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); - } - } - } - - Logger.error(`${operationName} failed after ${maxRetries} attempts`, { - error: lastError?.message || 'Unknown error' - }); - - return Result.fromError(lastError || new Error('Operation failed'), 'SIGNER_OPERATION_FAILED'); - } - - /** - * Get connection health status - */ - static getHealthStatus(): SignerHealthCheck { - return { - state: this.connectionState, - lastConnected: this.lastConnected || undefined, - lastError: this.lastError || undefined, - reconnectAttempts: this.reconnectAttempts, - nextReconnectAt: this.reconnectTimeout ? Date.now() + this.reconnectInterval : undefined - }; - } - - /** - * Force reconnection (useful for manual recovery) - */ - static async forceReconnect(): Promise> { - Logger.info('Force reconnection requested'); - - // Reset state - this.connectionState = SignerConnectionState.DISCONNECTED; - this.reconnectAttempts = 0; - - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - return await this.connect(); - } - - /** - * Register a callback for connection state changes - */ - static onConnectionChange(callback: (connected: boolean) => void): () => void { - this.connectionCallbacks.push(callback); - - // Return unsubscribe function - return () => { - const index = this.connectionCallbacks.indexOf(callback); - if (index > -1) { - this.connectionCallbacks.splice(index, 1); - } - }; - } - - /** - * Notify all connection callbacks - */ - private static notifyConnectionCallbacks(connected: boolean): void { - this.connectionCallbacks.forEach(callback => { - try { - callback(connected); - } catch (error) { - Logger.error('Error in connection callback', { - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - } - - /** - * Cleanup resources - */ - static cleanup(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - } - - this.connectionCallbacks = []; - this.connectionState = SignerConnectionState.DISCONNECTED; - } -} diff --git a/src/services/signer/index.ts b/src/services/signer/index.ts index 393456d..7095c80 100644 --- a/src/services/signer/index.ts +++ b/src/services/signer/index.ts @@ -1,18 +1,482 @@ -import { SDKSignerClient } from 'sdk-signer-client'; +import { SDKSignerClient, ClientConfig } from 'sdk-signer-client'; import { signerConfig } from '../../config/signer'; +import { Logger } from '../../utils/logger'; +import { Result, ServiceResult } from '../../utils/result'; + +export enum SignerConnectionState { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + FAILED = 'failed' +} + +export interface SignerHealthCheck { + state: SignerConnectionState; + lastConnected?: number; + lastError?: string; + reconnectAttempts: number; + nextReconnectAt?: number; +} export class SignerService { - private static instance: SDKSignerClient; + private static instance: SDKSignerClient | null = null; + private static connectionState: SignerConnectionState = SignerConnectionState.DISCONNECTED; + private static reconnectAttempts: number = 0; + private static lastConnected: number | null = null; + private static lastError: string | null = null; + private static reconnectTimeout: NodeJS.Timeout | null = null; + private static maxReconnectAttempts: number = 10; // More attempts than the SDK default + private static reconnectInterval: number = 5000; // 5 seconds + private static backoffMultiplier: number = 1.5; // Exponential backoff + private static maxReconnectInterval: number = 60000; // Max 60 seconds between attempts + private static healthCheckInterval: NodeJS.Timeout | null = null; + private static connectionCallbacks: Array<(connected: boolean) => void> = []; - static getInstance(): SDKSignerClient { - if (!this.instance) { + /** + * Initialize the signer service with enhanced reconnection logic + */ + static async initialize(): Promise> { + try { + Logger.info('Initializing Signer service'); + + // Create client instance this.instance = new SDKSignerClient(signerConfig); + + // Set up event listeners for connection monitoring + this.setupEventListeners(); + + // Start periodic health checks + this.startHealthChecks(); + + // Attempt initial connection + const connectResult = await this.connect(); + + return connectResult; + } catch (error) { + Logger.error('Failed to initialize Signer service', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + return Result.fromError(error instanceof Error ? error : new Error('Initialization failed'), 'SIGNER_INIT_ERROR'); } - return this.instance; } - static async connect(): Promise { - const client = this.getInstance(); - await client.connect(); + /** + * Get the signer client instance with connection validation + */ + static async getInstance(): Promise> { + if (!this.instance) { + const initResult = await this.initialize(); + if (!initResult.success) { + return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer service not initialized', initResult.error); + } + } + + // Check if we're connected + if (this.connectionState !== SignerConnectionState.CONNECTED) { + // Try to reconnect if not already attempting + if (this.connectionState !== SignerConnectionState.CONNECTING && + this.connectionState !== SignerConnectionState.RECONNECTING) { + Logger.warn('Signer not connected, attempting reconnection'); + const reconnectResult = await this.connect(); + if (!reconnectResult.success) { + return Result.failure('SIGNER_CONNECTION_FAILED', 'Failed to connect to signer', reconnectResult.error); + } + } else { + return Result.failure('SIGNER_CONNECTING', 'Signer is currently connecting, please retry'); + } + } + + return Result.success(this.instance!); + } + + /** + * Connect to the signer service with retry logic + */ + private static async connect(): Promise> { + if (!this.instance) { + return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer client not initialized'); + } + + this.connectionState = this.reconnectAttempts === 0 ? SignerConnectionState.CONNECTING : SignerConnectionState.RECONNECTING; + + try { + // Only log connection attempts if we've been trying for a while + if (this.reconnectAttempts > 2) { + Logger.info('Signer connection attempt', { + attempt: this.reconnectAttempts + 1, + maxAttempts: this.maxReconnectAttempts + }); + } + + await this.instance.connect(); + + // Connection successful - state will be updated by the 'open' event handler + return Result.success(undefined); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'; + this.lastError = errorMessage; + + // Only log detailed errors after several attempts or if we've reached the limit + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.connectionState = SignerConnectionState.FAILED; + Logger.error('Signer connection failed after all attempts', { + attempts: this.maxReconnectAttempts, + lastError: errorMessage + }); + return Result.failure('SIGNER_CONNECTION_FAILED', `Failed to connect after ${this.maxReconnectAttempts} attempts: ${errorMessage}`); + } else if (this.reconnectAttempts > 3) { + Logger.warn('Signer connection failing', { + attempt: this.reconnectAttempts + 1, + error: errorMessage + }); + } + + // Schedule reconnection with exponential backoff + this.scheduleReconnection(); + + return Result.failure('SIGNER_CONNECTION_FAILED', errorMessage); + } + } + + /** + * Schedule a reconnection attempt with exponential backoff + */ + private static scheduleReconnection(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + // Calculate delay with exponential backoff + const baseDelay = this.reconnectInterval; + const delay = Math.min( + baseDelay * Math.pow(this.backoffMultiplier, this.reconnectAttempts), + this.maxReconnectInterval + ); + + this.reconnectAttempts++; + + // Only log scheduling if this is taking a while + if (this.reconnectAttempts > 2) { + Logger.debug('Next reconnection in', { delayMs: delay }); + } + + this.reconnectTimeout = setTimeout(async () => { + await this.connect(); + }, delay); + } + + /** + * Set up event listeners for the signer client + */ + private static setupEventListeners(): void { + if (!this.instance) return; + + try { + // Listen for WebSocket close events + this.instance.on('close', () => { + // Don't log here - let handleDisconnection() do the logging + this.handleDisconnection(); + }); + + // Listen for WebSocket error events + this.instance.on('error', (error: Error) => { + // Only log if it's a new error + if (this.lastError !== error.message) { + Logger.error('Signer WebSocket error', { error: error.message }); + } + this.handleError(error); + }); + + // Listen for successful reconnection events + this.instance.on('reconnect', () => { + Logger.info('Signer reconnected via SDK'); + this.handleSDKReconnection(); + }); + + // Listen for connection open events + this.instance.on('open', () => { + Logger.info('Signer connected'); + this.handleSDKConnection(); + }); + + Logger.debug('Signer event listeners configured'); + + } catch (error) { + Logger.warn('Could not set up SDK event listeners', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + /** + * Handle disconnection events from WebSocket + */ + private static handleDisconnection(): void { + // Only handle if we were actually connected (avoid duplicate handling) + if (this.connectionState === SignerConnectionState.CONNECTED) { + Logger.warn('Signer disconnected - reconnecting...'); + this.connectionState = SignerConnectionState.DISCONNECTED; + this.notifyConnectionCallbacks(false); + + // Cancel any pending health checks since we know we're disconnected + this.cancelScheduledOperations(); + + // Initiate immediate reconnection (but respect ongoing attempts) + if (!this.reconnectTimeout) { + this.scheduleReconnection(); + } + } + } + + /** + * Handle connection errors from WebSocket + */ + private static handleError(error: Error): void { + this.lastError = error.message; + + // Only log detailed error if we were connected (avoid startup error spam) + if (this.connectionState === SignerConnectionState.CONNECTED) { + Logger.error('Signer connection error', { error: error.message }); + this.handleDisconnection(); + } + } + + /** + * Handle successful SDK reconnection + */ + private static handleSDKReconnection(): void { + // The SDK handled the reconnection, update our state + this.connectionState = SignerConnectionState.CONNECTED; + this.lastConnected = Date.now(); + this.lastError = null; + this.reconnectAttempts = 0; + + // Clear any pending reconnection timeout since SDK handled it + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + // Notify callbacks (they will handle the logging) + this.notifyConnectionCallbacks(true); + } + + /** + * Handle SDK connection open events + */ + private static handleSDKConnection(): void { + this.connectionState = SignerConnectionState.CONNECTED; + this.lastConnected = Date.now(); + this.lastError = null; + this.reconnectAttempts = 0; + + // Clear any pending operations + this.cancelScheduledOperations(); + + // Notify callbacks (they will handle the logging) + this.notifyConnectionCallbacks(true); + } + + /** + * Cancel scheduled operations (reconnection, health checks during known disconnection) + */ + private static cancelScheduledOperations(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + } + + /** + * Start periodic health checks + */ + private static startHealthChecks(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + + this.healthCheckInterval = setInterval(async () => { + await this.performHealthCheck(); + }, 30000); // Check every 30 seconds + } + + /** + * Perform a health check on the signer connection + */ + private static async performHealthCheck(): Promise { + // Only perform health checks if we think we're connected + if (!this.instance || this.connectionState !== SignerConnectionState.CONNECTED) { + Logger.debug('Skipping health check - not in connected state', { + state: this.connectionState, + hasInstance: !!this.instance + }); + return; + } + + try { + // Try a simple operation to verify connection is actually working + await this.instance.getPairingId(); + + Logger.debug('Signer health check passed'); + + } catch (error) { + Logger.warn('Signer health check failed - connection may be stale', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + + // Health check failed, but we haven't received a close/error event + // This indicates a stale connection - treat as disconnection + this.handleDisconnection(); + } + } + + /** + * Execute a signer operation with automatic retry on connection failure + */ + static async executeWithRetry( + operation: (client: SDKSignerClient) => Promise, + operationName: string, + maxRetries: number = 3 + ): Promise> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const clientResult = await this.getInstance(); + if (!clientResult.success) { + lastError = new Error(clientResult.error?.message || 'Failed to get signer instance'); + + if (attempt < maxRetries) { + // Only log operation retries if it's the final attempt or taking too long + if (attempt === maxRetries - 1) { + Logger.warn(`${operationName} final retry attempt`, { + error: lastError.message + }); + } + + // Wait before retry + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + continue; + } + break; + } + + const result = await operation(clientResult.data!); + + // Only log success if it took multiple attempts + if (attempt > 1) { + Logger.info(`${operationName} succeeded after ${attempt} attempts`); + } + + return Result.success(result); + + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + + // Only log operation failures if it's taking multiple attempts + if (attempt > 1) { + Logger.warn(`${operationName} attempt ${attempt} failed`, { + error: lastError.message + }); + } + + // If it's a connection error, force reconnection + if (lastError.message.includes('connection') || lastError.message.includes('websocket')) { + this.connectionState = SignerConnectionState.DISCONNECTED; + } + + if (attempt < maxRetries) { + // Wait before retry with exponential backoff + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); + } + } + } + + Logger.error(`${operationName} failed after ${maxRetries} attempts`, { + error: lastError?.message || 'Unknown error' + }); + + return Result.fromError(lastError || new Error('Operation failed'), 'SIGNER_OPERATION_FAILED'); + } + + /** + * Get connection health status + */ + static getHealthStatus(): SignerHealthCheck { + return { + state: this.connectionState, + lastConnected: this.lastConnected || undefined, + lastError: this.lastError || undefined, + reconnectAttempts: this.reconnectAttempts, + nextReconnectAt: this.reconnectTimeout ? Date.now() + this.reconnectInterval : undefined + }; + } + + /** + * Force reconnection (useful for manual recovery) + */ + static async forceReconnect(): Promise> { + Logger.info('Force reconnection requested'); + + // Reset state + this.connectionState = SignerConnectionState.DISCONNECTED; + this.reconnectAttempts = 0; + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + return await this.connect(); + } + + /** + * Register a callback for connection state changes + */ + static onConnectionChange(callback: (connected: boolean) => void): () => void { + this.connectionCallbacks.push(callback); + + // Return unsubscribe function + return () => { + const index = this.connectionCallbacks.indexOf(callback); + if (index > -1) { + this.connectionCallbacks.splice(index, 1); + } + }; + } + + /** + * Notify all connection callbacks + */ + private static notifyConnectionCallbacks(connected: boolean): void { + this.connectionCallbacks.forEach(callback => { + try { + callback(connected); + } catch (error) { + Logger.error('Error in connection callback', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + } + + /** + * Cleanup resources + */ + static cleanup(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + + this.connectionCallbacks = []; + this.connectionState = SignerConnectionState.DISCONNECTED; } }