UNPKG

@solsdk/jito-ts

Version:

## What is it and why do you need it?

337 lines (316 loc) 12.2 kB
import {Keypair} from '@solana/web3.js'; import { ChannelCredentials, ChannelOptions, ClientReadableStream, ServiceError, status, } from '@grpc/grpc-js'; import {AuthServiceClient} from '../../gen/block-engine/auth'; import {BundleResult} from '../../gen/block-engine/bundle'; import { ConnectedLeadersResponse, GetTipAccountsResponse, NextScheduledLeaderResponse, SearcherServiceClient, SendBundleRequest, SendBundleResponse, SlotList, } from '../../gen/block-engine/searcher'; import {authInterceptor, AuthProvider} from './auth'; import {Bundle} from './types'; import {Result, Ok, Err} from './utils'; export class SearcherClientError extends Error { constructor(public code: status, message: string, public details: string) { super(`${message}${details ? `: ${details}` : ''}`); this.name = 'SearcherClientError'; } } export class SearcherClient { private client: SearcherServiceClient; private readonly retryOptions: Readonly<{ maxRetries: number; baseDelay: number; maxDelay: number; factor: number; }>; constructor(client: SearcherServiceClient) { this.client = client; this.retryOptions = Object.freeze({ maxRetries: 3, baseDelay: 1000, maxDelay: 10000, factor: 2, }); } private handleError(e: ServiceError): SearcherClientError { const errorDetails = e.details || 'No additional details provided'; switch (e.code) { case status.OK: return new SearcherClientError(e.code, 'Unexpected OK status in error', errorDetails); case status.CANCELLED: return new SearcherClientError(e.code, 'The operation was cancelled', errorDetails); case status.UNKNOWN: return new SearcherClientError(e.code, 'Unknown error', errorDetails); case status.INVALID_ARGUMENT: return new SearcherClientError(e.code, 'Invalid argument provided', errorDetails); case status.DEADLINE_EXCEEDED: return new SearcherClientError(e.code, 'Deadline exceeded', errorDetails); case status.NOT_FOUND: return new SearcherClientError(e.code, 'Requested entity not found', errorDetails); case status.ALREADY_EXISTS: return new SearcherClientError(e.code, 'The entity already exists', errorDetails); case status.PERMISSION_DENIED: return new SearcherClientError(e.code, 'Lacking required permission', errorDetails); case status.RESOURCE_EXHAUSTED: return new SearcherClientError(e.code, 'Resource has been exhausted', errorDetails); case status.FAILED_PRECONDITION: return new SearcherClientError(e.code, 'Operation rejected, system not in correct state', errorDetails); case status.ABORTED: return new SearcherClientError(e.code, 'The operation was aborted', errorDetails); case status.OUT_OF_RANGE: return new SearcherClientError(e.code, 'Operation attempted past the valid range', errorDetails); case status.UNIMPLEMENTED: return new SearcherClientError(e.code, 'Operation not implemented or supported', errorDetails); case status.INTERNAL: return new SearcherClientError(e.code, 'Internal error', errorDetails); case status.UNAVAILABLE: let unavailableMessage = 'The service is currently unavailable'; if (errorDetails.includes('upstream connect error or disconnect/reset before headers')) { unavailableMessage = 'Service unavailable: Envoy overloaded'; } else if (errorDetails.toLowerCase().includes('dns resolution failed')) { unavailableMessage = 'Service unavailable: DNS resolution failed'; } else if (errorDetails.toLowerCase().includes('ssl handshake failed')) { unavailableMessage = 'Service unavailable: SSL handshake failed'; } else if (errorDetails.toLowerCase().includes('connection refused')) { unavailableMessage = 'Service unavailable: Connection refused'; } else if (errorDetails.toLowerCase().includes('network unreachable')) { unavailableMessage = 'Service unavailable: Network is unreachable'; } else if (errorDetails.toLowerCase().includes('timeout')) { unavailableMessage = 'Service unavailable: Connection timed out'; } return new SearcherClientError(e.code, unavailableMessage, errorDetails); case status.DATA_LOSS: return new SearcherClientError(e.code, 'Unrecoverable data loss or corruption', errorDetails); case status.UNAUTHENTICATED: return new SearcherClientError(e.code, 'Request not authenticated', errorDetails); default: return new SearcherClientError(status.UNKNOWN, `Unexpected error: ${e.message}`, errorDetails); } } private async retryWithBackoff<T>( operation: () => Promise<Result<T, SearcherClientError>>, retries = 0 ): Promise<Result<T, SearcherClientError>> { try { return await operation(); } catch (error) { if (retries >= this.retryOptions.maxRetries || !this.isRetryableError(error)) { return Err(error as SearcherClientError); } const delay = Math.min( this.retryOptions.baseDelay * Math.pow(this.retryOptions.factor, retries), this.retryOptions.maxDelay ); console.warn(`Operation failed. Retrying in ${delay}ms... (Attempt ${retries + 1} of ${this.retryOptions.maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); return this.retryWithBackoff(operation, retries + 1); } } private isRetryableError(error: any): boolean { if (error instanceof SearcherClientError) { if (error.code === status.UNAVAILABLE) { const nonRetryableMessages = [ 'Service unavailable: DNS resolution failed', 'Service unavailable: SSL handshake failed' ]; return !nonRetryableMessages.some(msg => error.message.includes(msg)); } const retryableCodes = [ status.UNAVAILABLE, status.RESOURCE_EXHAUSTED, status.DEADLINE_EXCEEDED, ]; return retryableCodes.includes(error.code); } return false; } /** * Submits a bundle to the block-engine. * * @param bundle - The Bundle object to be sent. * @returns A Promise that resolves to the bundle's UUID (string) on successful submission. * @throws A ServiceError if there's an issue with the server while sending the bundle. */ async sendBundle(bundle: Bundle): Promise<Result<string, SearcherClientError>> { return this.retryWithBackoff(() => { return new Promise<Result<string, SearcherClientError>>((resolve) => { this.client.sendBundle( { bundle } as SendBundleRequest, (e: ServiceError | null, resp: SendBundleResponse) => { if (e) { resolve(Err(this.handleError(e))); } else { resolve(Ok(resp.uuid)); } } ); }); }); } /** * Retrieves tip accounts from the server. * * @returns A Promise that resolves to an array of account strings (usually public keys). * @throws A ServiceError if there's an issue with the server while fetching tip accounts. */ async getTipAccounts(): Promise<Result<string[], SearcherClientError>> { return this.retryWithBackoff(() => { return new Promise<Result<string[], SearcherClientError>>((resolve) => { this.client.getTipAccounts( {}, (e: ServiceError | null, resp: GetTipAccountsResponse) => { if (e) { resolve(Err(this.handleError(e))); } else { resolve(Ok(resp.accounts)); } } ); }); }); } /** * Retrieves connected leaders (validators) from the server. * * @returns A Promise that resolves to a map, where keys are validator identity keys (usually public keys), and values are SlotList objects. * @throws A ServiceError if there's an issue with the server while fetching connected leaders. */ async getConnectedLeaders(): Promise<Result<{[key: string]: SlotList}, SearcherClientError>> { return this.retryWithBackoff(() => { return new Promise<Result<{[key: string]: SlotList}, SearcherClientError>>((resolve) => { this.client.getConnectedLeaders( {}, async (e: ServiceError | null, resp: ConnectedLeadersResponse) => { if (e) { resolve(Err(this.handleError(e))); } else { resolve(Ok(resp.connectedValidators)); } } ); }); }); } /** * Returns the next scheduled leader connected to the block-engine. * * @returns A Promise that resolves with an object containing: * - currentSlot: The current slot number the backend is on * - nextLeaderSlot: The slot number of the next scheduled leader * - nextLeaderIdentity: The identity of the next scheduled leader * @throws A ServiceError if there's an issue with the server while fetching the next scheduled leader. */ async getNextScheduledLeader(): Promise<Result<{ currentSlot: number; nextLeaderSlot: number; nextLeaderIdentity: string; }, SearcherClientError>> { return this.retryWithBackoff(() => { return new Promise<Result<{ currentSlot: number; nextLeaderSlot: number; nextLeaderIdentity: string; }, SearcherClientError>>((resolve) => { this.client.getNextScheduledLeader( { regions: [], }, async (e: ServiceError | null, resp: NextScheduledLeaderResponse) => { if (e) { resolve(Err(this.handleError(e))); } else { resolve(Ok(resp)); } } ); }); }); } /** * Triggers the provided callback on BundleResult updates. * * @param successCallback - A callback function that receives the BundleResult updates * @param errorCallback - A callback function that receives the stream error (Error) * @returns A function to cancel the subscription */ onBundleResult( successCallback: (bundleResult: BundleResult) => void, errorCallback: (e: Error) => void ): () => void { const stream: ClientReadableStream<BundleResult> = this.client.subscribeBundleResults({}); stream.on('readable', () => { const msg = stream.read(1); if (msg) { successCallback(msg); } }); stream.on('error', e => { errorCallback(new Error(`Stream error: ${e.message}`)); }); return () => stream.cancel(); } /** * Yields on bundle results. * * @param onError - A callback function that receives the stream error (Error) * @returns An async generator that yields BundleResult updates */ async *bundleResults( onError: (e: Error) => void ): AsyncGenerator<BundleResult> { const stream: ClientReadableStream<BundleResult> = this.client.subscribeBundleResults({}); stream.on('error', e => { onError(e); }); for await (const bundleResult of stream) { yield bundleResult; } } } /** * Creates and returns a SearcherClient instance. * * @param url - The URL of the SearcherService * @param authKeypair - Optional Keypair authorized for the block engine * @param grpcOptions - Optional configuration options for the gRPC client * @returns SearcherClient - An instance of the SearcherClient */ export const searcherClient = ( url: string, authKeypair?: Keypair, grpcOptions?: Partial<ChannelOptions> ): SearcherClient => { if (authKeypair) { const authProvider = new AuthProvider( new AuthServiceClient(url, ChannelCredentials.createSsl()), authKeypair ); const client = new SearcherServiceClient( url, ChannelCredentials.createSsl(), {interceptors: [authInterceptor(authProvider)], ...grpcOptions} ); return new SearcherClient(client); } else { return new SearcherClient( new SearcherServiceClient( url, ChannelCredentials.createSsl(), grpcOptions ) ); } };