@solsdk/jito-ts
Version:
## What is it and why do you need it?
337 lines (316 loc) • 12.2 kB
text/typescript
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
)
);
}
};