/** * Service Client Implementation * * HTTP client for communicating with the Gas-Free backend service * * @module ServiceClient */ import http from 'http'; import https from 'https'; import axios, { type AxiosInstance, type AxiosError } from 'axios'; import type { Logger } from '../../../utils/logger'; import type { IServiceClient, ServiceClientConfig } from './IServiceClient'; import { validateServiceClientConfig, mergeServiceClientConfig, } from './IServiceClient'; import type { FeeQuote, FeeQuoteRequest, SubmitResult, SubmitPSBTRequest, VerificationResult, VerifyTransferRequest, } from '../types'; import { validateFeeQuote, validateFeeQuoteRequest, validateSubmitResult, validateVerificationResult, validatePSBT, } from '../utils/validation'; import type { RetryConfig } from '../utils/retry'; import { retryWithBackoff } from '../utils/retry'; import { GasFreeError, GasFreeErrorCode, ServiceUnavailableError, QuoteExpiredError, InvalidPSBTError, } from '../errors'; /** * API endpoints for Gas-Free service */ const API_ENDPOINTS = { GENERATE_QUOTE: '/api/v1/generate-fee-quote', SIGN_PSBT: '/api/v1/sign-psbt', VERIFY_TRANSFER: '/api/v1/verify-transfer', } as const; /** * ServiceClient class * * Implements HTTP client for the Gas-Free backend service with: * - Automatic retries with exponential backoff * - Request/response validation * - Comprehensive error handling * - Timeout management */ export class ServiceClient implements IServiceClient { private config: Required; private axiosInstance: AxiosInstance; private retryConfig: RetryConfig; private logger?: Logger; /** * Create a ServiceClient instance * * @param config - Service client configuration * @param logger - Optional logger instance * @throws {Error} If configuration is invalid */ constructor(config: ServiceClientConfig, logger?: Logger) { // Validate and merge config validateServiceClientConfig(config); this.config = mergeServiceClientConfig(config); // Store logger (create child logger with ServiceClient prefix) this.logger = logger?.child('ServiceClient'); // Setup retry configuration this.retryConfig = { maxRetries: this.config.maxRetries, initialDelay: this.config.retryDelay, onRetry: (error, attempt, delay) => { this.logger?.warn( `Retry attempt ${attempt} after ${delay}ms due to: ${error.message}` ); }, }; // Create Axios instance this.axiosInstance = axios.create({ baseURL: this.config.baseUrl, timeout: this.config.timeout, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.config.apiKey}`, ...this.config.headers, }, // Disable keep-alive to prevent connection reuse issues // This fixes ECONNRESET errors with witness invoice verify calls httpAgent: new http.Agent({ keepAlive: false }), httpsAgent: new https.Agent({ keepAlive: false }), }); // Add request interceptor for logging this.axiosInstance.interceptors.request.use( (config) => { this.logger?.debug(`${config.method?.toUpperCase()} ${config.url}`); return config; }, (error) => { return Promise.reject(error); } ); // Add response interceptor for error handling this.axiosInstance.interceptors.response.use( (response) => { this.logger?.debug( `Response: ${response.status} ${response.statusText}` ); return response; }, (error) => { return Promise.reject(this.transformError(error)); } ); } /** * Generate a fee quote for a Gas-Free transfer * * @param request - Fee quote request parameters * @returns Promise resolving to a fee quote */ async generateFeeQuote(request: FeeQuoteRequest): Promise { // Validate request try { validateFeeQuoteRequest(request); } catch (error) { throw new InvalidPSBTError('Invalid fee quote request', [ error instanceof Error ? error.message : String(error), ]); } // Make API request with retry try { return await retryWithBackoff(async () => { const response = await this.axiosInstance.post( API_ENDPOINTS.GENERATE_QUOTE, request ); // Validate response try { validateFeeQuote(response.data); } catch (error) { throw new GasFreeError( 'Invalid fee quote response from server', GasFreeErrorCode.INVALID_REQUEST, error instanceof Error ? error : new Error(String(error)) ); } return response.data; }, this.retryConfig); } catch (error) { throw this.transformError(error); } } /** * Submit the unsigned PSBT and RGB consignment to the service for co-signing * * @param request - Submit PSBT request parameters * @returns Promise resolving to submission result */ async signPsbt(request: SubmitPSBTRequest): Promise { // Validate inputs if (!request.quoteId || request.quoteId.trim().length === 0) { throw new InvalidPSBTError('Quote ID is required', [ 'quoteId cannot be empty', ]); } try { validatePSBT(request.psbtBase64); } catch (error) { throw new InvalidPSBTError('Invalid PSBT', [ error instanceof Error ? error.message : String(error), ]); } if ( !request.consignmentBase64 || request.consignmentBase64.trim().length === 0 ) { throw new InvalidPSBTError('Consignment is required', [ 'consignmentBase64 cannot be empty', ]); } // Make API request with retry try { return await retryWithBackoff(async () => { const response = await this.axiosInstance.post( API_ENDPOINTS.SIGN_PSBT, request ); // Validate response try { validateSubmitResult(response.data); } catch (error) { throw new GasFreeError( 'Invalid submit result from server', GasFreeErrorCode.INVALID_REQUEST, error instanceof Error ? error : new Error(String(error)) ); } return response.data; }, this.retryConfig); } catch (error) { throw this.transformError(error); } } /** * Allows service to verify the transfer status * * @param request - Verify transfer request parameters * @returns Promise resolving to verification result */ async verifyTransfer( request: VerifyTransferRequest ): Promise { // Validate input if (!request.quoteId || request.quoteId.trim().length === 0) { throw new GasFreeError( 'Quote ID is required', GasFreeErrorCode.INVALID_REQUEST ); } // Validate required fields for success case if (request.transferSuccess) { if (!request.signedPsbtBase64 || request.signedPsbtBase64.trim().length === 0) { throw new GasFreeError( 'Signed PSBT is required when transferSuccess is true', GasFreeErrorCode.INVALID_REQUEST ); } if (!request.txid || request.txid.trim().length === 0) { throw new GasFreeError( 'Transaction ID is required when transferSuccess is true', GasFreeErrorCode.INVALID_REQUEST ); } } // Make API request with retry try { return await retryWithBackoff(async () => { const response = await this.axiosInstance.post( API_ENDPOINTS.VERIFY_TRANSFER, request ); // Validate response try { validateVerificationResult(response.data); } catch (error) { throw new GasFreeError( 'Invalid verification result from server', GasFreeErrorCode.INVALID_REQUEST, error instanceof Error ? error : new Error(String(error)) ); } return response.data; }, this.retryConfig); } catch (error) { throw this.transformError(error); } } /** * Get the current configuration * * @returns Readonly client configuration */ getConfig(): Readonly> { return { ...this.config }; } /** * Update timeout for requests * * @param timeout - New timeout in milliseconds */ setTimeout(timeout: number): void { if (timeout <= 0 || !Number.isFinite(timeout)) { throw new Error('Timeout must be a positive number'); } this.config.timeout = timeout; this.axiosInstance.defaults.timeout = timeout; } /** * Cleanup and release resources */ async cleanup(): Promise { // Axios doesn't require explicit cleanup, but we can cancel pending requests this.logger?.debug('Cleanup completed'); } /** * Transform Axios errors into Gas-Free errors * * @param error - Error from Axios * @returns Transformed Gas-Free error */ private transformError(error: unknown): Error { // If it's already a Gas-Free error, return it if (error instanceof GasFreeError) { return error; } // Handle Axios errors if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; // No response - network error if (!axiosError.response) { if ( axiosError.code === 'ECONNABORTED' || axiosError.code === 'ETIMEDOUT' ) { return new ServiceUnavailableError( 'Request timeout', 'The request took too long to complete' ); } return new ServiceUnavailableError( 'Network error', axiosError.message || 'Failed to connect to service' ); } const { status, data } = axiosError.response; // Parse error response const errorData = data as { error?: string; message?: string; code?: string; details?: string; }; const message = errorData?.error || errorData?.message || 'Unknown server error'; const details = errorData?.details || axiosError.message; // Quote expired (410 Gone) if (status === 410) { // Extract quote ID from error data or use placeholder const quoteId = (errorData as { quote_id?: string })?.quote_id || 'unknown'; return new QuoteExpiredError( quoteId, Date.now() - 60000, // Assume expired 1 minute ago { details: errorData?.details } ); } // Invalid PSBT (400 Bad Request) if (status === 400) { return new InvalidPSBTError(message, [details]); } // Unauthorized (401) if (status === 401) { return new GasFreeError( 'Authentication failed', GasFreeErrorCode.INVALID_REQUEST, new Error('Invalid or missing API key') ); } // Rate limit (429) if (status === 429) { return new ServiceUnavailableError( 'Rate limit exceeded', 'Too many requests, please try again later' ); } // Server error (5xx) if (status >= 500) { return new ServiceUnavailableError( 'Server error', `Service returned ${status}: ${message}` ); } // Other client errors (4xx) if (status >= 400) { return new GasFreeError( message, GasFreeErrorCode.INVALID_REQUEST, new Error(details) ); } // Unknown HTTP error return new GasFreeError( 'HTTP error', GasFreeErrorCode.UNKNOWN, new Error(`Unexpected status code: ${status}`) ); } // Unknown error type if (error instanceof Error) { return new GasFreeError(error.message, GasFreeErrorCode.UNKNOWN, error); } return new GasFreeError( 'Unknown error', GasFreeErrorCode.UNKNOWN, new Error(String(error)) ); } } /** * Create a service client instance * * Factory function for creating a ServiceClient with validated configuration. * * @param config - Service client configuration * @returns ServiceClient instance * @throws {Error} If configuration is invalid * * @example * ```typescript * const client = createServiceClient({ * apiKey: 'your-api-key', * baseUrl: 'https://api.orbis1.com', * timeout: 30000, * maxRetries: 3, * }); * * const quote = await client.generateFeeQuote({...}); * ``` */ export function createServiceClient( config: ServiceClientConfig ): ServiceClient { return new ServiceClient(config); }