opnet
Version:
The perfect library for building Bitcoin-based applications.
1,271 lines (1,109 loc) • 45.3 kB
text/typescript
import { Network } from '@btc-vision/bitcoin';
import Long from 'long';
import { Root, Type } from 'protobufjs';
import { OPNetTransactionTypes } from '../interfaces/opnet/OPNetTransactionTypes.js';
import { AbstractRpcProvider } from './AbstractRpcProvider.js';
import { JsonRpcPayload } from './interfaces/JSONRpc.js';
import { JSONRpcMethods } from './interfaces/JSONRpcMethods.js';
import { JsonRpcCallResult, JsonRpcResult } from './interfaces/JSONRpcResult.js';
import {
BlockNotification,
clearProtobufCache,
ConnectionState,
DEFAULT_CONFIG,
EpochNotification,
EventHandler,
getConnectionStateName,
getProtobufType,
InternalError,
InternalPendingRequest,
loadProtobufSchema,
MethodMapping,
OPNetError,
SubscriptionHandler,
SubscriptionType,
WebSocketClientConfig,
WebSocketClientEvent,
WebSocketErrorCode,
WebSocketRequestOpcode,
WebSocketResponseOpcode,
} from './websocket/index.js';
export type {
BlockNotification,
EpochNotification,
EventHandler,
SubscriptionHandler,
WebSocketClientEvent,
};
/**
* JSON-RPC method to WebSocket opcode mapping
*/
const METHOD_MAPPINGS: Partial<Record<JSONRpcMethods, MethodMapping>> = {
[JSONRpcMethods.BLOCK_BY_NUMBER]: {
requestOpcode: WebSocketRequestOpcode.GET_BLOCK_NUMBER,
responseOpcode: WebSocketResponseOpcode.BLOCK_NUMBER,
requestType: 'GetBlockNumberRequest',
responseType: 'GetBlockNumberResponse',
},
[JSONRpcMethods.GET_BLOCK_BY_NUMBER]: {
requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_NUMBER,
responseOpcode: WebSocketResponseOpcode.BLOCK,
requestType: 'GetBlockByNumberRequest',
responseType: 'BlockResponse',
},
[JSONRpcMethods.GET_BLOCK_BY_HASH]: {
requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_HASH,
responseOpcode: WebSocketResponseOpcode.BLOCK,
requestType: 'GetBlockByHashRequest',
responseType: 'BlockResponse',
},
[JSONRpcMethods.GET_BLOCK_BY_CHECKSUM]: {
requestOpcode: WebSocketRequestOpcode.GET_BLOCK_BY_CHECKSUM,
responseOpcode: WebSocketResponseOpcode.BLOCK,
requestType: 'GetBlockByChecksumRequest',
responseType: 'BlockResponse',
},
[JSONRpcMethods.BLOCK_WITNESS]: {
requestOpcode: WebSocketRequestOpcode.GET_BLOCK_WITNESS,
responseOpcode: WebSocketResponseOpcode.BLOCK_WITNESS,
requestType: 'GetBlockWitnessRequest',
responseType: 'BlockWitnessResponse',
},
[JSONRpcMethods.GAS]: {
requestOpcode: WebSocketRequestOpcode.GET_GAS,
responseOpcode: WebSocketResponseOpcode.GAS,
requestType: 'GetGasRequest',
responseType: 'GasResponse',
},
[JSONRpcMethods.GET_TRANSACTION_BY_HASH]: {
requestOpcode: WebSocketRequestOpcode.GET_TRANSACTION_BY_HASH,
responseOpcode: WebSocketResponseOpcode.TRANSACTION,
requestType: 'GetTransactionByHashRequest',
responseType: 'TransactionResponse',
},
[JSONRpcMethods.GET_TRANSACTION_RECEIPT]: {
requestOpcode: WebSocketRequestOpcode.GET_TRANSACTION_RECEIPT,
responseOpcode: WebSocketResponseOpcode.TRANSACTION_RECEIPT,
requestType: 'GetTransactionReceiptRequest',
responseType: 'TransactionReceiptResponse',
},
[JSONRpcMethods.BROADCAST_TRANSACTION]: {
requestOpcode: WebSocketRequestOpcode.BROADCAST_TRANSACTION,
responseOpcode: WebSocketResponseOpcode.BROADCAST_RESULT,
requestType: 'BroadcastTransactionRequest',
responseType: 'BroadcastTransactionResponse',
},
[JSONRpcMethods.TRANSACTION_PREIMAGE]: {
requestOpcode: WebSocketRequestOpcode.GET_PREIMAGE,
responseOpcode: WebSocketResponseOpcode.PREIMAGE,
requestType: 'GetPreimageRequest',
responseType: 'PreimageResponse',
},
[JSONRpcMethods.GET_BALANCE]: {
requestOpcode: WebSocketRequestOpcode.GET_BALANCE,
responseOpcode: WebSocketResponseOpcode.BALANCE,
requestType: 'GetBalanceRequest',
responseType: 'BalanceResponse',
},
[JSONRpcMethods.GET_UTXOS]: {
requestOpcode: WebSocketRequestOpcode.GET_UTXOS,
responseOpcode: WebSocketResponseOpcode.UTXOS,
requestType: 'GetUTXOsRequest',
responseType: 'UTXOsResponse',
},
[JSONRpcMethods.PUBLIC_KEY_INFO]: {
requestOpcode: WebSocketRequestOpcode.GET_PUBLIC_KEY_INFO,
responseOpcode: WebSocketResponseOpcode.PUBLIC_KEY_INFO,
requestType: 'GetPublicKeyInfoRequest',
responseType: 'PublicKeyInfoResponse',
},
[JSONRpcMethods.CHAIN_ID]: {
requestOpcode: WebSocketRequestOpcode.GET_CHAIN_ID,
responseOpcode: WebSocketResponseOpcode.CHAIN_ID,
requestType: 'GetChainIdRequest',
responseType: 'ChainIdResponse',
},
[JSONRpcMethods.REORG]: {
requestOpcode: WebSocketRequestOpcode.GET_REORG,
responseOpcode: WebSocketResponseOpcode.REORG,
requestType: 'GetReorgRequest',
responseType: 'ReorgResponse',
},
[JSONRpcMethods.GET_CODE]: {
requestOpcode: WebSocketRequestOpcode.GET_CODE,
responseOpcode: WebSocketResponseOpcode.CODE,
requestType: 'GetCodeRequest',
responseType: 'CodeResponse',
},
[JSONRpcMethods.GET_STORAGE_AT]: {
requestOpcode: WebSocketRequestOpcode.GET_STORAGE_AT,
responseOpcode: WebSocketResponseOpcode.STORAGE,
requestType: 'GetStorageAtRequest',
responseType: 'StorageResponse',
},
[JSONRpcMethods.CALL]: {
requestOpcode: WebSocketRequestOpcode.CALL,
responseOpcode: WebSocketResponseOpcode.CALL_RESULT,
requestType: 'CallRequest',
responseType: 'CallResponse',
},
[JSONRpcMethods.LATEST_EPOCH]: {
requestOpcode: WebSocketRequestOpcode.GET_LATEST_EPOCH,
responseOpcode: WebSocketResponseOpcode.EPOCH,
requestType: 'GetLatestEpochRequest',
responseType: 'EpochResponse',
},
[JSONRpcMethods.GET_EPOCH_BY_NUMBER]: {
requestOpcode: WebSocketRequestOpcode.GET_EPOCH_BY_NUMBER,
responseOpcode: WebSocketResponseOpcode.EPOCH,
requestType: 'GetEpochByNumberRequest',
responseType: 'EpochResponse',
},
[JSONRpcMethods.GET_EPOCH_BY_HASH]: {
requestOpcode: WebSocketRequestOpcode.GET_EPOCH_BY_HASH,
responseOpcode: WebSocketResponseOpcode.EPOCH,
requestType: 'GetEpochByHashRequest',
responseType: 'EpochResponse',
},
[JSONRpcMethods.GET_EPOCH_TEMPLATE]: {
requestOpcode: WebSocketRequestOpcode.GET_EPOCH_TEMPLATE,
responseOpcode: WebSocketResponseOpcode.EPOCH_TEMPLATE,
requestType: 'GetEpochTemplateRequest',
responseType: 'EpochTemplateResponse',
},
[JSONRpcMethods.SUBMIT_EPOCH]: {
requestOpcode: WebSocketRequestOpcode.SUBMIT_EPOCH,
responseOpcode: WebSocketResponseOpcode.EPOCH_SUBMIT_RESULT,
requestType: 'SubmitEpochRequest',
responseType: 'SubmitEpochResponse',
},
};
/**
* @description WebSocket RPC provider that extends AbstractRpcProvider.
* Uses binary protobuf protocol over WebSocket for efficient communication.
* @class WebSocketRpcProvider
* @category Providers
*/
export class WebSocketRpcProvider extends AbstractRpcProvider {
private readonly config: Required<WebSocketClientConfig>;
private readonly pendingRequests: Map<number, InternalPendingRequest> = new Map();
private readonly subscriptions: Map<SubscriptionType, SubscriptionHandler> = new Map();
private readonly eventHandlers: Map<WebSocketClientEvent, Set<EventHandler>> = new Map();
private socket: WebSocket | null = null;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private requestId: number = 0;
private reconnectAttempt: number = 0;
private pingTimeout: ReturnType<typeof setTimeout> | null = null;
private sessionId: Uint8Array | null = null;
private userRequestedDisconnect: boolean = false;
private protoRoot: Root | null = null;
private protoTypes: Map<string, Type> = new Map();
constructor(
url: string,
network: Network,
config?: Partial<Omit<WebSocketClientConfig, 'url'>>,
) {
super(network);
this.config = { ...DEFAULT_CONFIG, url, ...config };
}
/**
* Get the current connection state
*/
public getState(): ConnectionState {
return this.state;
}
/**
* Check if the provider is ready to send requests
*/
public isReady(): boolean {
return this.state === ConnectionState.READY;
}
/**
* Connect to the WebSocket server
*/
public async connect(): Promise<void> {
if (this.state !== ConnectionState.DISCONNECTED) {
throw new Error(
`Cannot connect: current state is ${getConnectionStateName(this.state)}`,
);
}
this.state = ConnectionState.CONNECTING;
this.userRequestedDisconnect = false;
try {
// Load protobuf schema first
const httpUrl = this.config.url.replace(/^ws/, 'http');
this.protoRoot = await loadProtobufSchema(httpUrl);
this.protoTypes.clear();
// Connect WebSocket
await this.connectWebSocket();
// Perform handshake
await this.performHandshake();
// Start ping loop
this.schedulePing();
this.reconnectAttempt = 0;
this.emit(WebSocketClientEvent.CONNECTED, undefined);
} catch (error) {
this.state = ConnectionState.DISCONNECTED;
throw error;
}
}
/**
* Disconnect from the WebSocket server
*/
public disconnect(): void {
if (this.state === ConnectionState.DISCONNECTED) {
return;
}
this.userRequestedDisconnect = true;
this.state = ConnectionState.CLOSING;
this.cancelPing();
this.cleanupPendingRequests(new Error('Connection closed'));
if (this.socket) {
this.socket.close(1000, 'Client disconnect');
this.socket = null;
}
this.state = ConnectionState.DISCONNECTED;
this.emit(WebSocketClientEvent.DISCONNECTED, undefined);
}
/**
* Register an event handler
*/
public on<T>(event: WebSocketClientEvent, handler: EventHandler<T>): void {
let handlers = this.eventHandlers.get(event);
if (!handlers) {
handlers = new Set();
this.eventHandlers.set(event, handlers);
}
handlers.add(handler as EventHandler);
}
/**
* Remove an event handler
*/
public off<T>(event: WebSocketClientEvent, handler: EventHandler<T>): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(handler as EventHandler);
}
}
/**
* Subscribe to new blocks
*/
public async subscribeBlocks(handler: SubscriptionHandler<BlockNotification>): Promise<void> {
if (this.state !== ConnectionState.READY) {
throw new Error('Not connected');
}
const type = this.getType('SubscribeBlocksRequest');
const message = type.create({});
const encodedPayload = type.encode(message).finish();
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(
WebSocketRequestOpcode.SUBSCRIBE_BLOCKS,
requestId,
encodedPayload,
);
await this.sendRequest(requestId, fullMessage);
this.subscriptions.set(SubscriptionType.BLOCKS, handler as SubscriptionHandler);
}
/**
* Subscribe to new epochs
*/
public async subscribeEpochs(handler: SubscriptionHandler<EpochNotification>): Promise<void> {
if (this.state !== ConnectionState.READY) {
throw new Error('Not connected');
}
const type = this.getType('SubscribeEpochsRequest');
const message = type.create({});
const encodedPayload = type.encode(message).finish();
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(
WebSocketRequestOpcode.SUBSCRIBE_EPOCHS,
requestId,
encodedPayload,
);
await this.sendRequest(requestId, fullMessage);
this.subscriptions.set(SubscriptionType.EPOCHS, handler as SubscriptionHandler);
}
/**
* Unsubscribe from a subscription
*/
public async unsubscribe(subscriptionType: SubscriptionType): Promise<void> {
if (this.state !== ConnectionState.READY) {
throw new Error('Not connected');
}
const type = this.getType('UnsubscribeRequest');
const messageData = this.buildMessageByFieldId(type, {
2: subscriptionType, // subscriptionId field (id=2)
});
const message = type.create(messageData);
const encodedPayload = type.encode(message).finish();
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(
WebSocketRequestOpcode.UNSUBSCRIBE,
requestId,
encodedPayload,
);
await this.sendRequest(requestId, fullMessage);
this.subscriptions.delete(subscriptionType);
}
/**
* Clear the protobuf cache (useful when reconnecting to a different server)
*/
public clearCache(): void {
clearProtobufCache();
this.protoTypes.clear();
this.protoRoot = null;
}
/**
* Implements the abstract _send method from AbstractRpcProvider.
* Translates JSON-RPC payloads to WebSocket binary protocol.
*
* Note: To match JSONRpcProvider behavior for callMultiplePayloads:
* - Single payload returns [result]
* - Array of payloads returns [[result1, result2, ...]] (wrapped in outer array)
* This is because callMultiplePayloads expects the batch results to be wrapped.
*/
public async _send(payload: JsonRpcPayload | JsonRpcPayload[]): Promise<JsonRpcCallResult> {
if (this.state !== ConnectionState.READY) {
throw new Error('WebSocket not connected. Call connect() first.');
}
const isArray = Array.isArray(payload);
const payloads = isArray ? payload : [payload];
const results: JsonRpcResult[] = [];
for (const p of payloads) {
const result = await this.sendJsonRpcRequest(p);
results.push(result);
}
// Match JSONRpcProvider behavior:
// - For single payload: return [result] (callPayloadSingle expects this)
// - For array payload: return [[result1, result2, ...]] (callMultiplePayloads expects this)
// The type cast is needed because the abstract signature doesn't capture this batch behavior
return isArray ? ([results] as unknown as JsonRpcCallResult) : results;
}
protected providerUrl(url: string): string {
url = url.trim();
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
// Ensure it's a WebSocket URL
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = url.replace(/^http/, 'ws');
}
return url;
}
/**
* Translate a JSON-RPC payload to a WebSocket request and send it
*/
private async sendJsonRpcRequest(payload: JsonRpcPayload): Promise<JsonRpcResult> {
const mapping = METHOD_MAPPINGS[payload.method];
if (!mapping) {
throw new Error(`Unsupported method: ${payload.method}`);
}
const requestType = this.getType(mapping.requestType);
const params = Array.isArray(payload.params) ? payload.params : [];
// Get field ID -> value mapping, then convert to field name -> value using proto schema
const fieldIdMap = this.translateJsonRpcParamsToFieldIds(payload.method, params);
const protoPayload = this.buildMessageByFieldId(requestType, fieldIdMap);
const message = requestType.create(protoPayload);
const encodedPayload = requestType.encode(message).finish();
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(mapping.requestOpcode, requestId, encodedPayload);
const responseData = await this.sendRequest(requestId, fullMessage);
const responseType = this.getType(mapping.responseType);
const decoded = responseType.decode(responseData);
const responseObj = responseType.toObject(decoded, {
longs: String,
bytes: String,
defaults: true,
}) as Record<string, unknown>;
// Translate protobuf response back to JSON-RPC format
const result = this.translateProtoResponse(payload.method, responseObj, responseType);
return {
jsonrpc: '2.0',
id: payload.id ?? null,
result,
} as JsonRpcResult;
}
/**
* Translate JSON-RPC params to protobuf field IDs.
* Uses proto field numbers (stable) instead of field names (may change).
* Field numbers are defined in the .proto file and are part of the wire format.
*/
private translateJsonRpcParamsToFieldIds(
method: JSONRpcMethods,
params: unknown[],
): Record<number, unknown> {
switch (method) {
case JSONRpcMethods.BLOCK_BY_NUMBER:
return {};
case JSONRpcMethods.GET_BLOCK_BY_NUMBER:
// GetBlockByNumberRequest: requestId=1, identifier=2, includeTransactions=3
return {
2: { 1: Long.fromString(String(params[0])) }, // identifier.height
3: params[1] ?? false,
};
case JSONRpcMethods.GET_BLOCK_BY_HASH:
// GetBlockByNumberRequest: requestId=1, identifier=2, includeTransactions=3
return {
2: { 2: params[0] }, // identifier.hash
3: params[1] ?? false,
};
case JSONRpcMethods.GET_BLOCK_BY_CHECKSUM:
// GetBlockByNumberRequest: requestId=1, identifier=2, includeTransactions=3
return {
2: { 3: params[0] }, // identifier.checksum
3: params[1] ?? false,
};
case JSONRpcMethods.BLOCK_WITNESS:
// GetBlockWitnessRequest: requestId=1, height=2, trusted=3, limit=4, page=5
return {
2: Long.fromString(String(params[0] ?? -1)),
3: params[1],
4: params[2],
5: params[3],
};
case JSONRpcMethods.GAS:
return {};
case JSONRpcMethods.GET_TRANSACTION_BY_HASH:
// GetTransactionByHashRequest: requestId=1, txHash=2
return { 2: params[0] };
case JSONRpcMethods.GET_TRANSACTION_RECEIPT:
// GetTransactionReceiptRequest: requestId=1, txHash=2
return { 2: params[0] };
case JSONRpcMethods.BROADCAST_TRANSACTION:
// BroadcastTransactionRequest: requestId=1, transaction=2, psbt=3
return {
2: params[0],
3: params[1] ?? false,
};
case JSONRpcMethods.TRANSACTION_PREIMAGE:
return {};
case JSONRpcMethods.GET_BALANCE:
// GetBalanceRequest: requestId=1, address=2, filterOrdinals=3
return {
2: String(params[0]),
3: params[1] ?? true,
};
case JSONRpcMethods.GET_UTXOS:
// GetUTXOsRequest: requestId=1, address=2, optimize=3, pending=4
return {
2: String(params[0]),
3: params[1] ?? true,
};
case JSONRpcMethods.PUBLIC_KEY_INFO:
// GetPublicKeyInfoRequest: requestId=1, addresses=2
return { 2: params[0] };
case JSONRpcMethods.CHAIN_ID:
return {};
case JSONRpcMethods.REORG:
// GetReorgRequest: requestId=1, limit=2
return {
2: params[0],
};
case JSONRpcMethods.GET_CODE:
// GetCodeRequest: requestId=1, contractAddress=2, full=3
return {
2: String(params[0]),
3: params[1] ?? false,
};
case JSONRpcMethods.GET_STORAGE_AT:
// GetStorageAtRequest: requestId=1, contractAddress=2, pointer=3, proofs=4
return {
2: String(params[0]),
3: params[1],
4: params[2] ?? true,
};
case JSONRpcMethods.CALL:
// CallRequest: requestId=1, to=2, calldata=3, from=4, fromLegacy=5
return {
2: String(params[0]),
3: params[1],
4: params[2],
5: params[3],
};
case JSONRpcMethods.LATEST_EPOCH:
return {};
case JSONRpcMethods.GET_EPOCH_BY_NUMBER:
// GetEpochByNumberRequest: requestId=1, epochNumber=2
return {
2: Long.fromString(String(params[0])),
};
case JSONRpcMethods.GET_EPOCH_BY_HASH:
// GetEpochByHashRequest: requestId=1, epochHash=2
return {
2: params[0],
};
case JSONRpcMethods.GET_EPOCH_TEMPLATE:
return {};
case JSONRpcMethods.SUBMIT_EPOCH: {
// SubmitEpochRequest: requestId=1, epochNumber=2, targetHash=3, salt=4, mldsaPublicKey=5, graffiti=6, signature=7
// params[0] is an object with these fields
const submitParams = params[0] as Record<string, unknown>;
return {
2: submitParams.epochNumber,
3: submitParams.targetHash,
4: submitParams.salt,
5: submitParams.mldsaPublicKey,
6: submitParams.graffiti,
7: submitParams.signature,
};
}
default:
return {};
}
}
/**
* Convert OPNetType proto enum value to OPNetTransactionTypes string enum.
* Proto enum: GENERIC=0, DEPLOYMENT=1, INTERACTION=2
*/
private convertOPNetTypeToString(value: number | undefined): OPNetTransactionTypes {
switch (value) {
case 1:
return OPNetTransactionTypes.Deployment;
case 2:
return OPNetTransactionTypes.Interaction;
case 0:
default:
return OPNetTransactionTypes.Generic;
}
}
/**
* Convert transaction object:
* 1. Converts OPNetType from integer to string enum
* 2. Flattens nested type-specific data (interaction/deployment) to top level
*
* Proto uses oneof for type-specific data:
* - field 20: interaction (InteractionTransactionData)
* - field 21: deployment (DeploymentTransactionData)
*/
private convertTransaction(tx: Record<string, unknown>): Record<string, unknown> {
// Get the TransactionForAPI type to find field names by ID
const txType = this.protoRoot?.lookupType('TransactionForAPI');
if (!txType) {
return tx;
}
// Find the field name for OPNetType (field 19)
const opnetTypeField = this.getFieldById(txType, 19);
if (opnetTypeField && tx[opnetTypeField.name] !== undefined) {
tx[opnetTypeField.name] = this.convertOPNetTypeToString(
tx[opnetTypeField.name] as number,
);
}
// Flatten type-specific nested data to top level for client compatibility
// Field 20: interaction (InteractionTransactionData)
const interactionField = this.getFieldById(txType, 20);
if (interactionField && tx[interactionField.name]) {
const interactionData = tx[interactionField.name] as Record<string, unknown>;
// Merge interaction fields to top level
Object.assign(tx, interactionData);
delete tx[interactionField.name];
}
// Field 21: deployment (DeploymentTransactionData)
const deploymentField = this.getFieldById(txType, 21);
if (deploymentField && tx[deploymentField.name]) {
const deploymentData = tx[deploymentField.name] as Record<string, unknown>;
// Merge deployment fields to top level
Object.assign(tx, deploymentData);
delete tx[deploymentField.name];
}
return tx;
}
/**
* Convert block response, converting OPNetType in all transactions.
*/
private convertBlockResponse(block: Record<string, unknown>): Record<string, unknown> {
// Get the BlockResponse type to find the transactions field name
const blockType = this.protoRoot?.lookupType('BlockResponse');
if (!blockType) {
return block;
}
// Find the transactions field (field 22 in proto)
const txField = this.getFieldById(blockType, 22);
if (txField && Array.isArray(block[txField.name])) {
block[txField.name] = (block[txField.name] as Record<string, unknown>[]).map((tx) =>
this.convertTransaction(tx),
);
}
return block;
}
/**
* Translate protobuf response to JSON-RPC result format.
* Uses field IDs to extract values (not hardcoded field names).
*/
private translateProtoResponse(
method: JSONRpcMethods,
response: Record<string, unknown>,
responseType: Type,
): unknown {
// Most responses can be returned as-is since they match the expected format
// Special handling for certain types that need specific field extraction
switch (method) {
case JSONRpcMethods.BLOCK_BY_NUMBER: {
// GetBlockNumberResponse: blockNumber = field 1
const fieldName = this.getFieldNameById(responseType, 1);
return fieldName ? response[fieldName] : undefined;
}
case JSONRpcMethods.CHAIN_ID: {
// GetChainIdResponse: chainId = field 1
const fieldName = this.getFieldNameById(responseType, 1);
return fieldName ? response[fieldName] : undefined;
}
case JSONRpcMethods.GET_BALANCE: {
// GetBalanceResponse: balance = field 1
const fieldName = this.getFieldNameById(responseType, 1);
return fieldName ? response[fieldName] : undefined;
}
case JSONRpcMethods.GET_BLOCK_BY_NUMBER:
case JSONRpcMethods.GET_BLOCK_BY_HASH:
case JSONRpcMethods.GET_BLOCK_BY_CHECKSUM: {
// BlockResponse: convert OPNetType in transactions from int to string
return this.convertBlockResponse(response);
}
case JSONRpcMethods.GET_TRANSACTION_BY_HASH: {
// TransactionResponse has transaction at field 1
const txResponseType = this.protoRoot?.lookupType('TransactionResponse');
if (txResponseType) {
const txField = this.getFieldById(txResponseType, 1);
if (txField && response[txField.name]) {
response[txField.name] = this.convertTransaction(
response[txField.name] as Record<string, unknown>,
);
}
}
return response;
}
default:
return response;
}
}
private connectWebSocket(): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.buildWebSocketUrl();
const timeout = setTimeout(() => {
reject(new Error(`Connection timeout after ${this.config.connectTimeout}ms`));
}, this.config.connectTimeout);
try {
this.socket = new WebSocket(url);
this.socket.binaryType = 'arraybuffer';
this.socket.onopen = () => {
clearTimeout(timeout);
this.state = ConnectionState.CONNECTED;
resolve();
};
this.socket.onerror = (event) => {
clearTimeout(timeout);
reject(new Error(`WebSocket error: ${event}`));
};
this.socket.onclose = (event) => {
this.handleClose(event);
};
this.socket.onmessage = (event) => {
this.handleMessage(event.data as ArrayBuffer);
};
} catch (error) {
clearTimeout(timeout);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
private buildWebSocketUrl(): string {
let url = this.config.url.trim();
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
// Ensure it's a WebSocket URL
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
url = url.replace(/^http/, 'ws');
}
// Add the WebSocket endpoint path
if (!url.includes('/api/v1/ws')) {
url = `${url}/api/v1/ws`;
}
return url;
}
private async performHandshake(): Promise<void> {
this.state = ConnectionState.HANDSHAKING;
const type = this.getType('HandshakeRequest');
// Build message using field numbers from proto schema (not hardcoded names)
const messageData = this.buildMessageByFieldId(type, {
1: 1, // protocolVersion field (id=1)
2: 'opnet-js', // clientName field (id=2)
3: '1.0.0', // clientVersion field (id=3)
});
const message = type.create(messageData);
const encodedPayload = type.encode(message).finish();
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(
WebSocketRequestOpcode.HANDSHAKE,
requestId,
encodedPayload,
);
const responseData = await this.sendRequest(
requestId,
fullMessage,
this.config.handshakeTimeout,
);
const responseType = this.getType('HandshakeResponse');
const decoded = responseType.decode(responseData);
const response = responseType.toObject(decoded, {
longs: String,
bytes: Uint8Array,
defaults: true,
});
// Get session_id using field number lookup
const sessionIdFieldName = this.getFieldNameById(responseType, 2);
if (sessionIdFieldName && response[sessionIdFieldName]) {
this.sessionId = response[sessionIdFieldName] as Uint8Array;
}
this.state = ConnectionState.READY;
}
/**
* Build a message object using proto field IDs instead of hardcoded field names.
* This ensures we use whatever field names the server's proto schema defines.
* Handles nested messages recursively.
*/
private buildMessageByFieldId(
type: Type,
valuesByFieldId: Record<number, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [fieldIdStr, value] of Object.entries(valuesByFieldId)) {
const fieldId = parseInt(fieldIdStr, 10);
const field = this.getFieldById(type, fieldId);
if (field) {
// Check if value is a nested field ID map (object with number keys)
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
!(value instanceof Long)
) {
const keys = Object.keys(value);
const isFieldIdMap = keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
if (isFieldIdMap) {
// Recursively build nested message
const nestedType = this.getNestedType(field.type);
if (nestedType) {
result[field.name] = this.buildMessageByFieldId(
nestedType,
value as Record<number, unknown>,
);
} else {
result[field.name] = value;
}
} else {
result[field.name] = value;
}
} else {
result[field.name] = value;
}
}
}
return result;
}
/**
* Get field from proto Type by field ID number
*/
private getFieldById(type: Type, fieldId: number): { name: string; type: string } | undefined {
for (const field of type.fieldsArray) {
if (field.id === fieldId) {
return { name: field.name, type: field.type };
}
}
return undefined;
}
/**
* Get field name from proto Type by field ID number
*/
private getFieldNameById(type: Type, fieldId: number): string | undefined {
const field = this.getFieldById(type, fieldId);
return field?.name;
}
/**
* Get nested message Type by type name
*/
private getNestedType(typeName: string): Type | undefined {
if (!this.protoRoot) return undefined;
try {
return getProtobufType(this.protoRoot, typeName);
} catch {
return undefined;
}
}
private getType(typeName: string): Type {
let type = this.protoTypes.get(typeName);
if (!type) {
if (!this.protoRoot) {
throw new Error('Protobuf schema not loaded');
}
type = getProtobufType(this.protoRoot, typeName);
this.protoTypes.set(typeName, type);
}
return type;
}
private nextRequestId(): number {
this.requestId = (this.requestId + 1) & 0xffffffff;
return this.requestId;
}
private buildMessage(
opcode: WebSocketRequestOpcode,
requestId: number,
payload: Uint8Array,
): Uint8Array {
// Message format: [opcode (1 byte)] [requestId (4 bytes LE)] [payload]
const message = new Uint8Array(1 + 4 + payload.length);
message[0] = opcode;
// Write request ID as little-endian uint32
message[1] = requestId & 0xff;
message[2] = (requestId >> 8) & 0xff;
message[3] = (requestId >> 16) & 0xff;
message[4] = (requestId >> 24) & 0xff;
message.set(payload, 5);
return message;
}
private sendRequest(
requestId: number,
message: Uint8Array,
timeout: number = this.config.requestTimeout,
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
if (this.pendingRequests.size >= this.config.maxPendingRequests) {
reject(new Error('Too many pending requests'));
return;
}
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);
this.pendingRequests.set(requestId, {
resolve,
reject,
timeout: timeoutHandle,
});
this.send(message);
});
}
private send(data: Uint8Array): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}
this.socket.send(data);
}
private handleMessage(data: ArrayBuffer): void {
const buffer = new Uint8Array(data);
if (buffer.length < 5) {
console.error('Invalid message: too short');
return;
}
const opcode = buffer[0] as WebSocketResponseOpcode;
// Handle notifications (no request ID)
if (opcode === WebSocketResponseOpcode.NEW_BLOCK_NOTIFICATION) {
this.handleBlockNotification(buffer.slice(1));
return;
}
if (opcode === WebSocketResponseOpcode.NEW_EPOCH_NOTIFICATION) {
this.handleEpochNotification(buffer.slice(1));
return;
}
// Extract request ID (little-endian uint32)
const requestId = buffer[1] | (buffer[2] << 8) | (buffer[3] << 16) | (buffer[4] << 24);
const payload = buffer.slice(5);
// Handle error responses
if (opcode === WebSocketResponseOpcode.ERROR) {
this.handleErrorResponse(requestId, payload);
return;
}
// Handle regular responses
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
pending.resolve(payload);
}
}
private handleErrorResponse(requestId: number, payload: Uint8Array): void {
const pending = this.pendingRequests.get(requestId);
if (!pending) return;
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
try {
const type = this.getType('ErrorResponse');
const decoded = type.decode(payload);
const error = type.toObject(decoded, {
longs: Number,
bytes: Uint8Array,
defaults: true,
}) as { code?: number; message?: string; data?: Uint8Array };
pending.reject(
new OPNetError(
(error.code as WebSocketErrorCode) ?? InternalError.INTERNAL_ERROR,
error.message,
error.data,
),
);
} catch (e) {
pending.reject(new Error('Failed to parse error response'));
}
}
private handleBlockNotification(payload: Uint8Array): void {
const handler = this.subscriptions.get(SubscriptionType.BLOCKS);
if (!handler) return;
try {
const type = this.getType('BlockNotification');
const decoded = type.decode(payload);
const block = type.toObject(decoded, {
longs: String,
defaults: true,
}) as {
block_number?: string;
block_hash?: string;
previous_block_hash?: string;
timestamp?: string;
};
const notification: BlockNotification = {
blockNumber: BigInt(block.block_number || '0'),
blockHash: block.block_hash || '',
previousBlockHash: block.previous_block_hash || '',
timestamp: BigInt(block.timestamp || '0'),
};
handler(notification);
this.emit(WebSocketClientEvent.BLOCK, notification);
} catch (e) {
console.error('Failed to parse block notification:', e);
}
}
private handleEpochNotification(payload: Uint8Array): void {
const handler = this.subscriptions.get(SubscriptionType.EPOCHS);
if (!handler) return;
try {
const type = this.getType('EpochNotification');
const decoded = type.decode(payload);
const epoch = type.toObject(decoded, {
longs: String,
defaults: true,
}) as { epoch_number?: string; epoch_hash?: string; timestamp?: string };
const notification: EpochNotification = {
epochNumber: BigInt(epoch.epoch_number || '0'),
epochHash: epoch.epoch_hash || '',
timestamp: BigInt(epoch.timestamp || '0'),
};
handler(notification);
this.emit(WebSocketClientEvent.EPOCH, notification);
} catch (e) {
console.error('Failed to parse epoch notification:', e);
}
}
private handleClose(event: CloseEvent): void {
const wasReady = this.state === ConnectionState.READY;
this.state = ConnectionState.DISCONNECTED;
this.cancelPing();
// Only auto-reconnect if:
// 1. Was in ready state
// 2. autoReconnect is enabled
// 3. User did not explicitly call disconnect()
if (wasReady && this.config.autoReconnect && !this.userRequestedDisconnect) {
void this.reconnect();
} else {
this.cleanupPendingRequests(
new Error(`Connection closed: ${event.code} ${event.reason}`),
);
this.emit(WebSocketClientEvent.DISCONNECTED, {
code: event.code,
reason: event.reason,
});
}
}
private async reconnect(): Promise<void> {
if (this.reconnectAttempt >= this.config.maxReconnectAttempts) {
this.emit(WebSocketClientEvent.ERROR, new Error('Max reconnection attempts exceeded'));
return;
}
this.state = ConnectionState.RECONNECTING;
this.reconnectAttempt++;
// Clear proto cache to ensure we get the latest schema on reconnect
this.clearCache();
const delay = Math.min(
this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt - 1),
this.config.reconnectMaxDelay,
);
const jitter = Math.random() * 0.3 * delay;
await this.sleep(delay + jitter);
try {
this.state = ConnectionState.DISCONNECTED;
await this.connect();
await this.resubscribe();
} catch (e) {
console.warn(`Reconnect attempt ${this.reconnectAttempt} failed:`, e);
void this.reconnect();
}
}
private async resubscribe(): Promise<void> {
const handlers = new Map(this.subscriptions);
this.subscriptions.clear();
for (const [type, handler] of handlers) {
try {
if (type === SubscriptionType.BLOCKS) {
await this.subscribeBlocks(handler as SubscriptionHandler<BlockNotification>);
} else if (type === SubscriptionType.EPOCHS) {
await this.subscribeEpochs(handler as SubscriptionHandler<EpochNotification>);
}
} catch (e) {
console.error(`Failed to resubscribe to ${type}:`, e);
}
}
}
private cleanupPendingRequests(error: Error): void {
for (const [_id, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
}
private schedulePing(): void {
this.cancelPing();
this.pingTimeout = setTimeout(() => {
if (this.state === ConnectionState.READY) {
try {
this.ping();
} catch (e) {
console.warn('Ping failed:', e);
}
// Schedule next ping recursively
this.schedulePing();
}
}, this.config.pingInterval);
}
private cancelPing(): void {
if (this.pingTimeout) {
clearTimeout(this.pingTimeout);
this.pingTimeout = null;
}
}
private ping(): void {
const type = this.getType('PingRequest');
const message = type.create({ timestamp: Long.fromNumber(Date.now()) });
const encodedPayload = type.encode(message).finish();
// Use consistent message format: [opcode][requestId][payload]
const requestId = this.nextRequestId();
const fullMessage = this.buildMessage(
WebSocketRequestOpcode.PING,
requestId,
encodedPayload,
);
this.send(fullMessage);
}
private emit<T>(event: WebSocketClientEvent, data: T): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
for (const handler of handlers) {
try {
handler(data);
} catch (e) {
console.error(`Error in event handler for ${event}:`, e);
}
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}