From efab6c80190b44620cdce8aa20fed4fef213de79 Mon Sep 17 00:00:00 2001 From: Bojan Angjelkoski Date: Mon, 13 Jun 2022 23:17:38 +0300 Subject: [PATCH] feat: txClient --- packages/sdk-ts/src/utils/crypto.ts | 16 + packages/sdk-ts/src/utils/index.ts | 1 + packages/tx-ts/package.json | 1 + packages/tx-ts/src/client/TxClient.ts | 30 ++ .../src/client/{grpc.ts => TxGrpcClient.ts} | 0 packages/tx-ts/src/client/TxRestClient.ts | 281 ++++++++++++++++++ packages/tx-ts/src/client/index.ts | 5 +- packages/tx-ts/src/client/rest.ts | 33 -- packages/tx-ts/src/types/block.ts | 68 +++++ packages/tx-ts/src/types/rest-client.ts | 20 ++ packages/tx-ts/src/types/tx-rest-client.ts | 133 +++++++++ packages/tx-ts/src/utils/crypto.ts | 16 + packages/wallet-ts/src/Cosmos/CosmosClient.ts | 9 + yarn.lock | 2 +- 14 files changed, 579 insertions(+), 36 deletions(-) create mode 100644 packages/sdk-ts/src/utils/crypto.ts create mode 100644 packages/tx-ts/src/client/TxClient.ts rename packages/tx-ts/src/client/{grpc.ts => TxGrpcClient.ts} (100%) create mode 100644 packages/tx-ts/src/client/TxRestClient.ts delete mode 100644 packages/tx-ts/src/client/rest.ts create mode 100644 packages/tx-ts/src/types/block.ts create mode 100644 packages/tx-ts/src/types/rest-client.ts create mode 100644 packages/tx-ts/src/types/tx-rest-client.ts create mode 100644 packages/tx-ts/src/utils/crypto.ts diff --git a/packages/sdk-ts/src/utils/crypto.ts b/packages/sdk-ts/src/utils/crypto.ts new file mode 100644 index 000000000..01b6b08f0 --- /dev/null +++ b/packages/sdk-ts/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import { SHA256 } from 'jscrypto/SHA256' +import { RIPEMD160 } from 'jscrypto/RIPEMD160' +import { Base64 } from 'jscrypto/Base64' +import { Word32Array } from 'jscrypto' + +export function hashToHex(data: string): string { + return SHA256.hash(Base64.parse(data)).toString().toUpperCase() +} + +export function sha256(data: Uint8Array): Uint8Array { + return SHA256.hash(new Word32Array(data)).toUint8Array() +} + +export function ripemd160(data: Uint8Array): Uint8Array { + return RIPEMD160.hash(new Word32Array(data)).toUint8Array() +} diff --git a/packages/sdk-ts/src/utils/index.ts b/packages/sdk-ts/src/utils/index.ts index 8fd98a26b..ea26559fd 100644 --- a/packages/sdk-ts/src/utils/index.ts +++ b/packages/sdk-ts/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './numbers' export * from './pagination' export * from './address' export * from './utf8' +export * from './crypto' diff --git a/packages/tx-ts/package.json b/packages/tx-ts/package.json index 7c77e1b15..303af161e 100644 --- a/packages/tx-ts/package.json +++ b/packages/tx-ts/package.json @@ -37,6 +37,7 @@ "@injectivelabs/chain-api": "^1.8.0-rc2", "@injectivelabs/utils": "^1.0.2", "google-protobuf": "^3.20.1", + "jscrypto": "^1.0.3", "link-module-alias": "^1.2.0", "sha3": "^2.1.4", "shx": "^0.3.2" diff --git a/packages/tx-ts/src/client/TxClient.ts b/packages/tx-ts/src/client/TxClient.ts new file mode 100644 index 000000000..542fa4b4c --- /dev/null +++ b/packages/tx-ts/src/client/TxClient.ts @@ -0,0 +1,30 @@ +import { TxRaw } from '@injectivelabs/chain-api/cosmos/tx/v1beta1/tx_pb' +import { hashToHex } from '../utils/crypto' + +export class TxClient { + /** + * Encode a transaction to base64-encoded protobuf + * @param tx transaction to encode + */ + public static encode(tx: TxRaw): string { + return Buffer.from(tx.serializeBinary()).toString('base64') + } + + /** + * Decode a transaction from base64-encoded protobuf + * @param tx transaction string to decode + */ + public static decode(encodedTx: string): TxRaw { + return TxRaw.deserializeBinary(Buffer.from(encodedTx, 'base64')) + } + + /** + * Get the transaction's hash + * @param tx transaction to hash + */ + public static async hash(tx: TxRaw): Promise { + const txBytes = await TxClient.encode(tx) + + return hashToHex(txBytes) + } +} diff --git a/packages/tx-ts/src/client/grpc.ts b/packages/tx-ts/src/client/TxGrpcClient.ts similarity index 100% rename from packages/tx-ts/src/client/grpc.ts rename to packages/tx-ts/src/client/TxGrpcClient.ts diff --git a/packages/tx-ts/src/client/TxRestClient.ts b/packages/tx-ts/src/client/TxRestClient.ts new file mode 100644 index 000000000..1de8d105c --- /dev/null +++ b/packages/tx-ts/src/client/TxRestClient.ts @@ -0,0 +1,281 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable camelcase */ +import { TxRaw } from '@injectivelabs/chain-api/cosmos/tx/v1beta1/tx_pb' +import { HttpClient } from '@injectivelabs/utils' +import { + Wait, + Block, + Sync, + TxSuccess, + TxBroadcastResult, + TxError, + TxInfo, + TxResult, + BroadcastMode, + SimulationResponse, + WaitTxBroadcastResult, + SyncTxBroadcastResult, + BlockTxBroadcastResult, + TxSearchResult, +} from '../types/tx-rest-client' +import { APIParams, TxSearchOptions } from '../types/rest-client' +import { BlockInfo } from '../types/block' +import { TxClient } from './TxClient' +import { hashToHex } from '../utils/crypto' + +export function isTxError< + C extends TxSuccess | TxError | {}, + B extends Wait | Block | Sync, + T extends TxBroadcastResult, +>(x: T): x is T & TxBroadcastResult { + return ( + (x as T & TxError).code !== undefined && + (x as T & TxError).code !== 0 && + (x as T & TxError).code !== '0' + ) +} + +export class TxRestClient { + public httpClient: HttpClient + + constructor(endpoint: string) { + this.httpClient = new HttpClient(endpoint) + } + + public async simulate(txRaw: TxRaw): Promise { + try { + return await this.httpClient.post('cosmos/tx/v1beta1/simulate', { + tx_bytes: Buffer.from(txRaw.serializeBinary()).toString('base64'), + }) + } catch (e: any) { + throw new Error(e.message) + } + } + + public async txInfo(txHash: string, params: APIParams = {}): Promise { + try { + const response = await this.getRaw( + `/cosmos/tx/v1beta1/txs/${txHash}`, + params, + ) + + return response.tx + } catch (e) { + throw new Error((e as any).message) + } + } + + public async txInfosByHeight(height: number | undefined): Promise { + const endpoint = + height !== undefined + ? `/cosmos/base/tendermint/v1beta1/blocks/${height}` + : `/cosmos/base/tendermint/v1beta1/blocks/latest` + + const blockInfo = await this.getRaw(endpoint) + const { txs } = blockInfo.block.data + + if (!txs) { + return [] + } + + const txHashes = txs.map((txData) => hashToHex(txData)) + const txInfos: TxInfo[] = [] + + for (const txhash of txHashes) { + txInfos.push(await this.txInfo(txhash)) + } + + return txInfos + } + + public async simulateTx(txRaw: TxRaw): Promise { + try { + txRaw.clearSignaturesList() + + const response = await this.postRaw( + '/cosmos/tx/v1beta1/simulate', + { + tx_bytes: TxClient.encode(txRaw), + }, + ) + + return response + } catch (e: any) { + throw new Error(e.message) + } + } + + private async broadcastTx( + txRaw: TxRaw, + mode: BroadcastMode = BroadcastMode.Sync, + ): Promise { + try { + const response = await this.postRaw('cosmos/tx/v1beta1/txs', { + tx_bytes: TxClient.encode(txRaw), + mode, + }) + + return response + } catch (e: any) { + throw new Error(e.message) + } + } + + public async broadcast( + tx: TxRaw, + timeout = 30000, + ): Promise { + const POLL_INTERVAL = 500 + const { tx_response: txResponse } = await this.broadcastTx<{ + tx_response: SyncTxBroadcastResult + }>(tx, BroadcastMode.Sync) + + if ((txResponse as TxError).code !== 0) { + const result: WaitTxBroadcastResult = { + height: txResponse.height, + txhash: txResponse.txhash, + raw_log: txResponse.raw_log, + code: (txResponse as TxError).code, + codespace: (txResponse as TxError).codespace, + gas_used: 0, + gas_wanted: 0, + timestamp: '', + logs: [], + } + + return result + } + + for (let i = 0; i <= timeout / POLL_INTERVAL; i += 1) { + try { + const txInfo = await this.txInfo(txResponse.txhash) + + return { + txhash: txInfo.txhash, + raw_log: txInfo.raw_log, + gas_wanted: parseInt(txInfo.gas_wanted, 10), + gas_used: parseInt(txInfo.gas_used, 10), + height: parseInt(txInfo.height, 10), + logs: txInfo.logs, + code: txInfo.code, + codespace: txInfo.codespace, + timestamp: txInfo.timestamp, + } + } catch (error) { + // Errors when transaction is not found. + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)) + } + + throw new Error( + `Transaction was not included in a block before timeout of ${timeout}ms`, + ) + } + + /** + * Broadcast the transaction using the "block" mode, waiting for its inclusion in the blockchain. + * @param tx transaction to broadcast + */ + public async broadcastBlock(tx: TxRaw): Promise { + const response = await this.broadcastTx<{ + tx_response: BlockTxBroadcastResult + }>(tx, BroadcastMode.Block) + + const { tx_response: txResponse } = response + + return { + txhash: txResponse.txhash, + raw_log: txResponse.raw_log, + gas_wanted: txResponse.gas_wanted, + gas_used: txResponse.gas_used, + height: txResponse.height, + logs: txResponse.logs, + code: (txResponse as TxError).code, + codespace: (txResponse as TxError).codespace, + data: txResponse.data, + info: txResponse.info, + timestamp: txResponse.timestamp, + } + } + + /** + * NOTE: This is not a synchronous function and is unconventionally named. This function + * can be await as it returns a `Promise`. + * + * Broadcast the transaction using the "sync" mode, returning after CheckTx() is performed. + * @param tx transaction to broadcast + */ + public async broadcastSync(tx: TxRaw): Promise { + const response = await this.broadcastTx<{ + tx_response: BlockTxBroadcastResult + }>(tx, BroadcastMode.Sync) + + const { tx_response: txResponse } = response + + const blockResult: any = { + height: txResponse.height, + txhash: txResponse.txhash, + raw_log: txResponse.raw_log, + } + + if ((txResponse as TxError).code) { + blockResult.code = (txResponse as TxError).code + } + + if ((txResponse as TxError).codespace) { + blockResult.codespace = (txResponse as TxError).codespace + } + + return blockResult + } + + /** + * Search for transactions based on event attributes. + * @param options + */ + public async search( + options: Partial, + ): Promise { + const params = new URLSearchParams() + + // build search params + options.events?.forEach((v) => + params.append( + 'events', + v.key === 'tx.height' ? `${v.key}=${v.value}` : `${v.key}='${v.value}'`, + ), + ) + + delete options.events + + Object.entries(options).forEach((v) => { + params.append(v[0], v[1] as string) + }) + + const response = await this.getRaw( + `cosmos/tx/v1beta1/txs`, + params, + ) + + return response + } + + private async postRaw( + endpoint: string, + params: URLSearchParams | APIParams = {}, + ): Promise { + return this.httpClient + .post(endpoint, params) + .then((d) => d.data) + } + + private async getRaw( + endpoint: string, + params: URLSearchParams | APIParams = {}, + ): Promise { + return this.httpClient + .get(endpoint, params) + .then((d) => d.data) + } +} diff --git a/packages/tx-ts/src/client/index.ts b/packages/tx-ts/src/client/index.ts index 27d483f50..dbe6d8450 100644 --- a/packages/tx-ts/src/client/index.ts +++ b/packages/tx-ts/src/client/index.ts @@ -1,2 +1,3 @@ -export * from './grpc' -export * from './rest' +export * from './TxGrpcClient' +export * from './TxRestClient' +export * from './TxClient' diff --git a/packages/tx-ts/src/client/rest.ts b/packages/tx-ts/src/client/rest.ts deleted file mode 100644 index 8ffbb4663..000000000 --- a/packages/tx-ts/src/client/rest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { TxRaw } from '@injectivelabs/chain-api/cosmos/tx/v1beta1/tx_pb' -import { HttpClient } from '@injectivelabs/utils' - -export class TxRestClient { - public httpClient: HttpClient - - constructor(endpoint: string) { - this.httpClient = new HttpClient(endpoint) - } - - public async simulate(txRaw: TxRaw): Promise { - try { - return await this.httpClient.post('cosmos/tx/v1beta1/simulate', { - tx_bytes: Buffer.from(txRaw.serializeBinary()).toString('base64'), - }) - } catch (e: any) { - throw new Error(e.message) - } - } - - public async broadcast(txRaw: TxRaw): Promise { - try { - const response = await this.httpClient.post('cosmos/tx/v1beta1/txs', { - tx_bytes: Buffer.from(txRaw.serializeBinary()).toString('base64'), - mode: 'BROADCAST_MODE_SYNC', - }) - - return (response as any).data - } catch (e: any) { - throw new Error(e.message) - } - } -} diff --git a/packages/tx-ts/src/types/block.ts b/packages/tx-ts/src/types/block.ts new file mode 100644 index 000000000..1c2bb420b --- /dev/null +++ b/packages/tx-ts/src/types/block.ts @@ -0,0 +1,68 @@ +/* eslint-disable camelcase */ +export interface Parts { + total: string + hash: string +} + +export interface Version { + block: string + app: string +} + +export interface BlockID { + hash: string + part_set_header: Parts +} + +export interface Header { + version: Version + + /** blockchain ID */ + chain_id: string + + /** block's height */ + height: string + + /** time the block was included */ + time: string + last_block_id: BlockID + last_commit_hash: string + data_hash: string + validators_hash: string + next_validators_hash: string + consensus_hash: string + app_hash: string + last_results_hash: string + evidence_hash: string + proposer_address: string +} + +export interface Signature { + block_id_flag: number + validator_address: string + timestamp: string + signature: string +} + +export interface LastCommit { + height: string + round: number + block_id: BlockID + signatures: Signature[] +} + +export interface Evidence { + evidence: string[] +} + +export interface Block { + header: Header + data: { txs: string[] | null } + evidence: Evidence + last_commit: LastCommit +} + +export interface BlockInfo { + block_id: BlockID + block: Block +} diff --git a/packages/tx-ts/src/types/rest-client.ts b/packages/tx-ts/src/types/rest-client.ts new file mode 100644 index 000000000..20e06d865 --- /dev/null +++ b/packages/tx-ts/src/types/rest-client.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +export type APIParams = Record + +export interface Pagination { + next_key: string | null + total: number +} + +export interface PaginationOptions { + 'pagination.limit': string + 'pagination.offset': string + 'pagination.key': string + 'pagination.count_total': 'true' | 'false' + 'pagination.reverse': 'true' | 'false' + order_by: string +} + +export interface TxSearchOptions extends PaginationOptions { + events: { key: string; value: string }[] +} diff --git a/packages/tx-ts/src/types/tx-rest-client.ts b/packages/tx-ts/src/types/tx-rest-client.ts new file mode 100644 index 000000000..c810f7a1d --- /dev/null +++ b/packages/tx-ts/src/types/tx-rest-client.ts @@ -0,0 +1,133 @@ +import { Fee, ModeInfo } from '@injectivelabs/chain-api/cosmos/tx/v1beta1/tx_pb' +import { PublicKey } from '@injectivelabs/chain-api/tendermint/crypto/keys_pb' + +/* eslint-disable camelcase */ +export interface SignerInfo { + public_key: PublicKey | null + mode_info: ModeInfo + sequence: string +} + +export interface AuthInfo { + signer_infos: SignerInfo[] + fee: Fee +} + +export interface TxBody { + messages: any[] + memo: string + timeout_height: string +} + +export interface Tx { + body: TxBody + auth_info: AuthInfo + signatures: string[] +} + +export interface TxLog { + msg_index: number + log: string + events: { type: string; attributes: { key: string; value: string }[] }[] +} + +export interface TxInfo { + height: string + txhash: string + codespace: string + code: number + data: string + raw_log: string + logs: TxLog[] + info: string + gas_wanted: string + gas_used: string + tx: Tx + timestamp: string +} + +export interface Wait { + height: number + txhash: string + raw_log: string + gas_wanted: number + gas_used: number + logs: TxLog[] + timestamp: string +} + +export interface Block extends Wait { + info: string + data: string +} + +export interface Sync { + height: number + txhash: string + raw_log: string +} + +export interface Async { + height: number + txhash: string +} + +export interface TxSuccess { + logs: TxLog[] +} + +export interface TxError { + code: number | string + codespace?: string +} + +export type TxBroadcastResult< + B extends Wait | Block | Sync | Async, + C extends TxSuccess | TxError | {}, +> = B & C + +export type WaitTxBroadcastResult = TxBroadcastResult +export type BlockTxBroadcastResult = TxBroadcastResult< + Block, + TxSuccess | TxError +> +export type SyncTxBroadcastResult = TxBroadcastResult +export type AsyncTxBroadcastResult = TxBroadcastResult + +export enum BroadcastMode { + Sync = 'BROADCAST_MODE_SYNC', + Async = 'BROADCAST_MODE_ASYNC', + Block = 'BROADCAST_MODE_BLOCK', +} + +export interface TxResult { + tx: TxInfo +} + +export interface TxResultParams { + tx: Tx + tx_response: TxInfo +} + +export interface TxSearchResult { + pagination: any + txs: TxInfo[] +} + +export interface TxSearchResultParams { + txs: Tx + tx_responses: TxInfo + pagination: any +} + +export interface SimulationResponse { + gas_info: { + gas_wanted: string + gas_used: string + } + result: { + data: string + log: string + events: { type: string; attributes: { key: string; value: string }[] }[] + } +} diff --git a/packages/tx-ts/src/utils/crypto.ts b/packages/tx-ts/src/utils/crypto.ts new file mode 100644 index 000000000..01b6b08f0 --- /dev/null +++ b/packages/tx-ts/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import { SHA256 } from 'jscrypto/SHA256' +import { RIPEMD160 } from 'jscrypto/RIPEMD160' +import { Base64 } from 'jscrypto/Base64' +import { Word32Array } from 'jscrypto' + +export function hashToHex(data: string): string { + return SHA256.hash(Base64.parse(data)).toString().toUpperCase() +} + +export function sha256(data: Uint8Array): Uint8Array { + return SHA256.hash(new Word32Array(data)).toUint8Array() +} + +export function ripemd160(data: Uint8Array): Uint8Array { + return RIPEMD160.hash(new Word32Array(data)).toUint8Array() +} diff --git a/packages/wallet-ts/src/Cosmos/CosmosClient.ts b/packages/wallet-ts/src/Cosmos/CosmosClient.ts index b2cc300d4..16d442fd1 100644 --- a/packages/wallet-ts/src/Cosmos/CosmosClient.ts +++ b/packages/wallet-ts/src/Cosmos/CosmosClient.ts @@ -36,6 +36,15 @@ export class CosmosClient { ) } + async simulateTransaction(txRaw: TxRaw): Promise { + const client = await this.getStargateClient() + + return client.broadcastTx( + txRaw.serializeBinary(), + DEFAULT_TIMESTAMP_TIMEOUT_MS, + ) + } + private async getStargateClient() { const { rpc } = this diff --git a/yarn.lock b/yarn.lock index 7c8eec7fe..bc37102eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9784,7 +9784,7 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -jscrypto@^1.0.0, jscrypto@^1.0.1: +jscrypto@^1.0.0, jscrypto@^1.0.1, jscrypto@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/jscrypto/-/jscrypto-1.0.3.tgz#598febca2a939d6f679c54f56e1fe364cef30cc9" integrity sha512-lryZl0flhodv4SZHOqyb1bx5sKcJxj0VBo0Kzb4QMAg3L021IC9uGpl0RCZa+9KJwlRGSK2C80ITcwbe19OKLQ==