UNPKG

@keplr-ewallet/ewallet-sdk-cosmos

Version:
422 lines (366 loc) 10.1 kB
import { Buffer } from "buffer"; type Listeners = { [K in keyof TxEventMap]?: TxEventMap[K][]; }; type QueryParams = Record<string, string | number | boolean>; type TxQuery = Uint8Array | QueryParams; interface PendingOperation { resolver: (data?: unknown) => void; rejector: (error: Error) => void; } interface TxSubscription extends PendingOperation { params: QueryParams; } interface PendingQuery extends PendingOperation { method: string; params: QueryParams; } enum WsReadyState { CONNECTING, OPEN, CLOSING, CLOSED, NONE, } interface TxEventMap { close: (e: CloseEvent) => void; error: (e: Event) => void; message: (e: MessageEvent) => void; open: (e: Event) => void; } export class TendermintTxTracer { private ws: WebSocket; private newBlockSubscribes: Array<{ handler: (block: any) => void }> = []; private txSubscribes = new Map<number, TxSubscription>(); private pendingQueries = new Map<number, PendingQuery>(); private listeners: Listeners = {}; constructor( protected readonly url: string, protected readonly wsEndpoint: string, protected readonly options: { wsObject?: new (url: string, protocols?: string | string[]) => WebSocket; } = {}, ) { this.ws = this.options.wsObject ? new this.options.wsObject(this.getWsEndpoint()) : new WebSocket(this.getWsEndpoint()); this.ws.onopen = this.onOpen; this.ws.onmessage = this.onMessage; this.ws.onclose = this.onClose; this.ws.onerror = this.onError; } protected getWsEndpoint(): string { let url = this.url; if (url.startsWith("http")) { url = url.replace("http", "ws"); } if (!url.endsWith(this.wsEndpoint)) { const wsEndpoint = this.wsEndpoint.startsWith("/") ? this.wsEndpoint : "/" + this.wsEndpoint; url = url.endsWith("/") ? url + wsEndpoint.slice(1) : url + wsEndpoint; } return url; } close() { this.ws.close(); } get readyState(): WsReadyState { switch (this.ws.readyState) { case 0: return WsReadyState.CONNECTING; case 1: return WsReadyState.OPEN; case 2: return WsReadyState.CLOSING; case 3: return WsReadyState.CLOSED; default: return WsReadyState.NONE; } } addEventListener<T extends keyof TxEventMap>( type: T, listener: TxEventMap[T], ) { if (!this.listeners[type]) { this.listeners[type] = []; } this.listeners[type]!.push(listener); } protected readonly onOpen = (e: Event) => { if (this.newBlockSubscribes.length > 0) { this.sendSubscribeBlockRpc(); } for (const [id, tx] of this.txSubscribes) { this.sendSubscribeTxRpc(id, tx.params); } for (const [id, query] of this.pendingQueries) { this.sendQueryRpc(id, query.method, query.params); } for (const listener of this.listeners.open ?? []) { listener(e); } }; protected readonly onMessage = (e: MessageEvent) => { for (const listener of this.listeners.message ?? []) { listener(e); } if (e.data) { try { const obj = JSON.parse(e.data); if (obj?.id) { if (this.pendingQueries.has(obj.id)) { if (obj.error) { this.pendingQueries .get(obj.id)! .rejector(new Error(obj.error.data || obj.error.message)); } else { if (obj.result?.tx_result) { this.pendingQueries.get(obj.id)!.resolver(obj.result.tx_result); } else { this.pendingQueries.get(obj.id)!.resolver(obj.result); } } this.pendingQueries.delete(obj.id); } } if (obj?.result?.data?.type === "tendermint/event/NewBlock") { for (const handler of this.newBlockSubscribes) { handler.handler(obj.result.data.value); } } if (obj?.result?.data?.type === "tendermint/event/Tx") { if (obj?.id) { if (this.txSubscribes.has(obj.id)) { if (obj.error) { this.txSubscribes .get(obj.id)! .rejector(new Error(obj.error.data || obj.error.message)); } else { this.txSubscribes .get(obj.id)! .resolver(obj.result.data.value.TxResult.result); } this.txSubscribes.delete(obj.id); } } } } catch { console.log("Tendermint websocket jsonrpc response is not JSON"); } } }; protected readonly onClose = (e: CloseEvent) => { for (const listener of this.listeners.close ?? []) { listener(e); } }; protected readonly onError = (e: Event) => { for (const listener of this.listeners.error ?? []) { listener(e); } this.close(); }; subscribeBlock(handler: (block: any) => void) { this.newBlockSubscribes.push({ handler, }); if (this.newBlockSubscribes.length === 1) { this.sendSubscribeBlockRpc(); } return () => { this.newBlockSubscribes = this.newBlockSubscribes.filter( (s) => s.handler !== handler, ); }; } protected sendSubscribeBlockRpc(): void { if (this.readyState === WsReadyState.OPEN) { this.ws.send( JSON.stringify({ jsonrpc: "2.0", method: "subscribe", params: ["tm.event='NewBlock'"], id: 1, }), ); } } traceTx(query: TxQuery): Promise<any> { let resolved = false; return new Promise<any>((resolve) => { this.queryTx(query) .then((result) => { if (query instanceof Uint8Array) { resolve(result); return; } if (result?.total_count !== "0") { resolve(result); return; } }) .catch(() => { // noop }); (async () => { while (true) { if ( resolved || this.readyState === WsReadyState.CLOSED || this.readyState === WsReadyState.CLOSING ) { break; } await new Promise((resolve) => setTimeout(resolve, 10000)); this.queryTx(query) .then((result) => { if (query instanceof Uint8Array) { resolve(result); return; } if (result?.total_count !== "0") { resolve(result); return; } }) .catch(() => { // noop }); } })(); this.subscribeTx(query).then(resolve); }).then((tx) => { resolved = true; return new Promise((resolve) => { setTimeout(() => resolve(tx), 100); }); }); } subscribeTx(query: TxQuery): Promise<any> { if (query instanceof Uint8Array) { const id = this.createRandomId(); const params = { query: `tm.event='Tx' AND tx.hash='${Buffer.from(query) .toString("hex") .toUpperCase()}'`, }; return new Promise<unknown>((resolve, reject) => { this.txSubscribes.set(id, { params, resolver: resolve, rejector: reject, }); this.sendSubscribeTxRpc(id, params); }); } else { const id = this.createRandomId(); const params = { query: `tm.event='Tx' AND ` + Object.keys(query) .map((key) => { return { key, value: query[key], }; }) .map((obj) => { return `${obj.key}=${ typeof obj.value === "string" ? `'${obj.value}'` : obj.value }`; }) .join(" AND "), page: "1", per_page: "1", order_by: "asc", }; return new Promise<unknown>((resolve, reject) => { this.txSubscribes.set(id, { params, resolver: resolve, rejector: reject, }); this.sendSubscribeTxRpc(id, params); }); } } protected sendSubscribeTxRpc( id: number, params: Record<string, string | number | boolean>, ): void { if (this.readyState === WsReadyState.OPEN) { this.ws.send( JSON.stringify({ jsonrpc: "2.0", method: "subscribe", params: params, id, }), ); } } queryTx(query: TxQuery): Promise<any> { if (query instanceof Uint8Array) { return this.query("tx", { hash: Buffer.from(query).toString("base64"), prove: false, }); } else { const params = { query: Object.keys(query) .map((key) => { return { key, value: query[key], }; }) .map((obj) => { return `${obj.key}=${ typeof obj.value === "string" ? `'${obj.value}'` : obj.value }`; }) .join(" AND "), page: "1", per_page: "1", order_by: "asc", }; return this.query("tx_search", params); } } protected query( method: string, params: Record<string, string | number | boolean>, ): Promise<any> { const id = this.createRandomId(); return new Promise<unknown>((resolve, reject) => { this.pendingQueries.set(id, { method, params, resolver: resolve, rejector: reject, }); this.sendQueryRpc(id, method, params); }); } protected sendQueryRpc( id: number, method: string, params: Record<string, string | number | boolean>, ) { if (this.readyState === WsReadyState.OPEN) { this.ws.send( JSON.stringify({ jsonrpc: "2.0", method, params, id, }), ); } } private createRandomId(): number { return Math.floor(Math.random() * 1000000); } }