From 8eb40f4eef74f64ed27d580d7a13f4fffabbe9cd Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 23 Feb 2021 13:04:09 +0100 Subject: [PATCH] Create Tendermint34Client --- CHANGELOG.md | 3 + packages/tendermint-rpc/src/index.ts | 2 + .../src/tendermint34/adaptor.ts | 58 ++ .../src/tendermint34/adaptors/index.ts | 2 + .../src/tendermint34/adaptors/v0-34/index.ts | 12 + .../tendermint34/adaptors/v0-34/requests.ts | 159 ++++ .../tendermint34/adaptors/v0-34/responses.ts | 831 ++++++++++++++++++ .../src/tendermint34/encodings.spec.ts | 90 ++ .../src/tendermint34/encodings.ts | 210 +++++ .../src/tendermint34/hasher.spec.ts | 91 ++ .../tendermint-rpc/src/tendermint34/hasher.ts | 67 ++ .../tendermint-rpc/src/tendermint34/index.ts | 74 ++ .../src/tendermint34/requests.spec.ts | 41 + .../src/tendermint34/requests.ts | 184 ++++ .../src/tendermint34/responses.ts | 337 +++++++ .../tendermint34/tendermint34client.spec.ts | 727 +++++++++++++++ .../src/tendermint34/tendermint34client.ts | 312 +++++++ 17 files changed, 3200 insertions(+) create mode 100644 packages/tendermint-rpc/src/tendermint34/adaptor.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/adaptors/index.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/index.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/requests.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/responses.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/encodings.spec.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/encodings.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/hasher.spec.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/hasher.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/index.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/requests.spec.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/requests.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/responses.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/tendermint34client.spec.ts create mode 100644 packages/tendermint-rpc/src/tendermint34/tendermint34client.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cce41c..9aec14cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ `MsgFundCommunityPool`, `MsgSetWithdrawAddress`, `MsgWithdrawDelegatorReward`, `MsgWithdrawValidatorCommission` and type checker helper functions. - @cosmjs/utils: Added `assertDefinedAndNotNull`. +- @cosmjs/tendermint-rpc: The new `Tendermint34Client` is a copy of the old + `Client` but without the automatic version detection. Its usage is encouraged + over `Client` if you connect to a Tendermint 0.34 backend. ### Changed diff --git a/packages/tendermint-rpc/src/index.ts b/packages/tendermint-rpc/src/index.ts index 8ab2c890..10c7e62e 100644 --- a/packages/tendermint-rpc/src/index.ts +++ b/packages/tendermint-rpc/src/index.ts @@ -80,3 +80,5 @@ export { } from "./dates"; export { HttpClient, WebsocketClient } from "./rpcclients"; // TODO: Why do we export those outside of this package? export { BlockIdFlag, CommitSignature, ValidatorEd25519Pubkey, ValidatorPubkey } from "./types"; +export * as tendermint34 from "./tendermint34"; +export { Tendermint34Client } from "./tendermint34"; diff --git a/packages/tendermint-rpc/src/tendermint34/adaptor.ts b/packages/tendermint-rpc/src/tendermint34/adaptor.ts new file mode 100644 index 00000000..7248942d --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/adaptor.ts @@ -0,0 +1,58 @@ +import { JsonRpcRequest, JsonRpcSuccessResponse } from "@cosmjs/json-rpc"; + +import { SubscriptionEvent } from "../rpcclients"; +import * as requests from "./requests"; +import * as responses from "./responses"; + +export interface Adaptor { + readonly params: Params; + readonly responses: Responses; + readonly hashTx: (tx: Uint8Array) => Uint8Array; + readonly hashBlock: (header: responses.Header) => Uint8Array; +} + +// Encoder is a generic that matches all methods of Params +export type Encoder = (req: T) => JsonRpcRequest; + +// Decoder is a generic that matches all methods of Responses +export type Decoder = (res: JsonRpcSuccessResponse) => T; + +export interface Params { + readonly encodeAbciInfo: (req: requests.AbciInfoRequest) => JsonRpcRequest; + readonly encodeAbciQuery: (req: requests.AbciQueryRequest) => JsonRpcRequest; + readonly encodeBlock: (req: requests.BlockRequest) => JsonRpcRequest; + readonly encodeBlockchain: (req: requests.BlockchainRequest) => JsonRpcRequest; + readonly encodeBlockResults: (req: requests.BlockResultsRequest) => JsonRpcRequest; + readonly encodeBroadcastTx: (req: requests.BroadcastTxRequest) => JsonRpcRequest; + readonly encodeCommit: (req: requests.CommitRequest) => JsonRpcRequest; + readonly encodeGenesis: (req: requests.GenesisRequest) => JsonRpcRequest; + readonly encodeHealth: (req: requests.HealthRequest) => JsonRpcRequest; + readonly encodeStatus: (req: requests.StatusRequest) => JsonRpcRequest; + readonly encodeSubscribe: (req: requests.SubscribeRequest) => JsonRpcRequest; + readonly encodeTx: (req: requests.TxRequest) => JsonRpcRequest; + readonly encodeTxSearch: (req: requests.TxSearchRequest) => JsonRpcRequest; + readonly encodeValidators: (req: requests.ValidatorsRequest) => JsonRpcRequest; +} + +export interface Responses { + readonly decodeAbciInfo: (response: JsonRpcSuccessResponse) => responses.AbciInfoResponse; + readonly decodeAbciQuery: (response: JsonRpcSuccessResponse) => responses.AbciQueryResponse; + readonly decodeBlock: (response: JsonRpcSuccessResponse) => responses.BlockResponse; + readonly decodeBlockResults: (response: JsonRpcSuccessResponse) => responses.BlockResultsResponse; + readonly decodeBlockchain: (response: JsonRpcSuccessResponse) => responses.BlockchainResponse; + readonly decodeBroadcastTxSync: (response: JsonRpcSuccessResponse) => responses.BroadcastTxSyncResponse; + readonly decodeBroadcastTxAsync: (response: JsonRpcSuccessResponse) => responses.BroadcastTxAsyncResponse; + readonly decodeBroadcastTxCommit: (response: JsonRpcSuccessResponse) => responses.BroadcastTxCommitResponse; + readonly decodeCommit: (response: JsonRpcSuccessResponse) => responses.CommitResponse; + readonly decodeGenesis: (response: JsonRpcSuccessResponse) => responses.GenesisResponse; + readonly decodeHealth: (response: JsonRpcSuccessResponse) => responses.HealthResponse; + readonly decodeStatus: (response: JsonRpcSuccessResponse) => responses.StatusResponse; + readonly decodeTx: (response: JsonRpcSuccessResponse) => responses.TxResponse; + readonly decodeTxSearch: (response: JsonRpcSuccessResponse) => responses.TxSearchResponse; + readonly decodeValidators: (response: JsonRpcSuccessResponse) => responses.ValidatorsResponse; + + // events + readonly decodeNewBlockEvent: (response: SubscriptionEvent) => responses.NewBlockEvent; + readonly decodeNewBlockHeaderEvent: (response: SubscriptionEvent) => responses.NewBlockHeaderEvent; + readonly decodeTxEvent: (response: SubscriptionEvent) => responses.TxEvent; +} diff --git a/packages/tendermint-rpc/src/tendermint34/adaptors/index.ts b/packages/tendermint-rpc/src/tendermint34/adaptors/index.ts new file mode 100644 index 00000000..74c16d9b --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/adaptors/index.ts @@ -0,0 +1,2 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export { v0_34 as adaptor34 } from "./v0-34"; diff --git a/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/index.ts b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/index.ts new file mode 100644 index 00000000..a8d43a92 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/index.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Adaptor } from "../../adaptor"; +import { hashBlock, hashTx } from "../../hasher"; +import { Params } from "./requests"; +import { Responses } from "./responses"; + +export const v0_34: Adaptor = { + params: Params, + responses: Responses, + hashTx: hashTx, + hashBlock: hashBlock, +}; diff --git a/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/requests.ts b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/requests.ts new file mode 100644 index 00000000..54436f7a --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/requests.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { toBase64, toHex } from "@cosmjs/encoding"; +import { JsonRpcRequest } from "@cosmjs/json-rpc"; + +import { createJsonRpcRequest } from "../../../jsonrpc"; +import { assertNotEmpty, Integer, may } from "../../encodings"; +import * as requests from "../../requests"; + +interface HeightParam { + readonly height?: number; +} +interface RpcHeightParam { + readonly height?: string; +} +function encodeHeightParam(param: HeightParam): RpcHeightParam { + return { + height: may(Integer.encode, param.height), + }; +} + +interface RpcBlockchainRequestParams { + readonly minHeight?: string; + readonly maxHeight?: string; +} + +function encodeBlockchainRequestParams(param: requests.BlockchainRequestParams): RpcBlockchainRequestParams { + return { + minHeight: may(Integer.encode, param.minHeight), + maxHeight: may(Integer.encode, param.maxHeight), + }; +} + +interface RpcAbciQueryParams { + readonly path: string; + /** hex encoded */ + readonly data: string; + readonly height?: string; + readonly prove?: boolean; +} + +function encodeAbciQueryParams(params: requests.AbciQueryParams): RpcAbciQueryParams { + return { + path: assertNotEmpty(params.path), + data: toHex(params.data), + height: may(Integer.encode, params.height), + prove: params.prove, + }; +} + +interface RpcBroadcastTxParams { + /** base64 encoded */ + readonly tx: string; +} +function encodeBroadcastTxParams(params: requests.BroadcastTxParams): RpcBroadcastTxParams { + return { + tx: toBase64(assertNotEmpty(params.tx)), + }; +} + +interface RpcTxParams { + /** base64 encoded */ + readonly hash: string; + readonly prove?: boolean; +} +function encodeTxParams(params: requests.TxParams): RpcTxParams { + return { + hash: toBase64(assertNotEmpty(params.hash)), + prove: params.prove, + }; +} + +interface RpcTxSearchParams { + readonly query: string; + readonly prove?: boolean; + readonly page?: string; + readonly per_page?: string; +} +function encodeTxSearchParams(params: requests.TxSearchParams): RpcTxSearchParams { + return { + query: params.query, + prove: params.prove, + page: may(Integer.encode, params.page), + per_page: may(Integer.encode, params.per_page), + }; +} + +interface RpcValidatorsParams { + readonly height?: string; + readonly page?: string; + readonly per_page?: string; +} +function encodeValidatorsParams(params: requests.ValidatorsParams): RpcValidatorsParams { + return { + height: may(Integer.encode, params.height), + page: may(Integer.encode, params.page), + per_page: may(Integer.encode, params.per_page), + }; +} + +export class Params { + public static encodeAbciInfo(req: requests.AbciInfoRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method); + } + + public static encodeAbciQuery(req: requests.AbciQueryRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeAbciQueryParams(req.params)); + } + + public static encodeBlock(req: requests.BlockRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeHeightParam(req.params)); + } + + public static encodeBlockchain(req: requests.BlockchainRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeBlockchainRequestParams(req.params)); + } + + public static encodeBlockResults(req: requests.BlockResultsRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeHeightParam(req.params)); + } + + public static encodeBroadcastTx(req: requests.BroadcastTxRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeBroadcastTxParams(req.params)); + } + + public static encodeCommit(req: requests.CommitRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeHeightParam(req.params)); + } + + public static encodeGenesis(req: requests.GenesisRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method); + } + + public static encodeHealth(req: requests.HealthRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method); + } + + public static encodeStatus(req: requests.StatusRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method); + } + + public static encodeSubscribe(req: requests.SubscribeRequest): JsonRpcRequest { + const eventTag = { key: "tm.event", value: req.query.type }; + const query = requests.buildQuery({ tags: [eventTag], raw: req.query.raw }); + return createJsonRpcRequest("subscribe", { query: query }); + } + + public static encodeTx(req: requests.TxRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeTxParams(req.params)); + } + + // TODO: encode params for query string??? + public static encodeTxSearch(req: requests.TxSearchRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeTxSearchParams(req.params)); + } + + public static encodeValidators(req: requests.ValidatorsRequest): JsonRpcRequest { + return createJsonRpcRequest(req.method, encodeValidatorsParams(req.params)); + } +} diff --git a/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/responses.ts b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/responses.ts new file mode 100644 index 00000000..9feb8c2e --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/adaptors/v0-34/responses.ts @@ -0,0 +1,831 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { fromBase64, fromHex } from "@cosmjs/encoding"; +import { JsonRpcSuccessResponse } from "@cosmjs/json-rpc"; +import { assert } from "@cosmjs/utils"; + +import { fromRfc3339WithNanoseconds } from "../../../dates"; +import { SubscriptionEvent } from "../../../rpcclients"; +import { BlockIdFlag, CommitSignature, ValidatorPubkey } from "../../../types"; +import { + assertArray, + assertBoolean, + assertNotEmpty, + assertNumber, + assertObject, + assertSet, + assertString, + dictionaryToStringMap, + Integer, + may, + optional, +} from "../../encodings"; +import { hashTx } from "../../hasher"; +import * as responses from "../../responses"; + +interface AbciInfoResult { + readonly response: RpcAbciInfoResponse; +} + +interface RpcAbciInfoResponse { + readonly data?: string; + readonly last_block_height?: string; + /** base64 encoded */ + readonly last_block_app_hash?: string; +} + +function decodeAbciInfo(data: RpcAbciInfoResponse): responses.AbciInfoResponse { + return { + data: data.data, + lastBlockHeight: may(Integer.parse, data.last_block_height), + lastBlockAppHash: may(fromBase64, data.last_block_app_hash), + }; +} + +interface AbciQueryResult { + readonly response: RpcAbciQueryResponse; +} + +export interface RpcProofOp { + readonly type: string; + /** base64 encoded */ + readonly key: string; + /** base64 encoded */ + readonly data: string; +} + +export interface RpcQueryProof { + readonly ops: readonly RpcProofOp[]; +} + +function decodeQueryProof(data: RpcQueryProof): responses.QueryProof { + return { + ops: data.ops.map((op) => ({ + type: op.type, + key: fromBase64(op.key), + data: fromBase64(op.data), + })), + }; +} + +interface RpcAbciQueryResponse { + /** base64 encoded */ + readonly key: string; + /** base64 encoded */ + readonly value?: string; + readonly proofOps?: RpcQueryProof; + readonly height?: string; + readonly index?: string; + readonly code?: string; // only for errors + readonly log?: string; +} + +function decodeAbciQuery(data: RpcAbciQueryResponse): responses.AbciQueryResponse { + return { + key: fromBase64(optional(data.key, "")), + value: fromBase64(optional(data.value, "")), + proof: may(decodeQueryProof, data.proofOps), + height: may(Integer.parse, data.height), + code: may(Integer.parse, data.code), + index: may(Integer.parse, data.index), + log: data.log, + }; +} + +interface RpcAttribute { + /** base64 encoded */ + readonly key: string; + /** base64 encoded */ + readonly value: string; +} + +function decodeAttribute(attribute: RpcAttribute): responses.Attribute { + return { + key: fromBase64(assertNotEmpty(attribute.key)), + value: fromBase64(optional(attribute.value, "")), + }; +} + +function decodeAttributes(attributes: readonly RpcAttribute[]): responses.Attribute[] { + return assertArray(attributes).map(decodeAttribute); +} + +interface RpcEvent { + readonly type: string; + readonly attributes: readonly RpcAttribute[]; +} + +function decodeEvent(event: RpcEvent): responses.Event { + return { + type: event.type, + attributes: decodeAttributes(event.attributes), + }; +} + +function decodeEvents(events: readonly RpcEvent[]): readonly responses.Event[] { + return assertArray(events).map(decodeEvent); +} + +interface RpcTxData { + readonly code?: number; + readonly log?: string; + /** base64 encoded */ + readonly data?: string; + readonly events: readonly RpcEvent[]; +} + +function decodeTxData(data: RpcTxData): responses.TxData { + return { + data: may(fromBase64, data.data), + log: data.log, + code: Integer.parse(assertNumber(optional(data.code, 0))), + events: decodeEvents(data.events), + }; +} + +// yes, a different format for status and dump consensus state +interface RpcPubkey { + readonly type: string; + /** base64 encoded */ + readonly value: string; +} + +function decodePubkey(data: RpcPubkey): ValidatorPubkey { + if (data.type === "tendermint/PubKeyEd25519") { + // go-amino special code + return { + algorithm: "ed25519", + data: fromBase64(assertNotEmpty(data.value)), + }; + } + throw new Error(`unknown pubkey type: ${data.type}`); +} + +// for evidence, block results, etc. +interface RpcValidatorUpdate { + /** hex encoded */ + readonly address: string; + readonly pub_key: RpcPubkey; + readonly voting_power: string; + readonly proposer_priority: string; +} + +function decodeValidatorUpdate(data: RpcValidatorUpdate): responses.Validator { + return { + pubkey: decodePubkey(assertObject(data.pub_key)), + votingPower: Integer.parse(assertNotEmpty(data.voting_power)), + address: fromHex(assertNotEmpty(data.address)), + proposerPriority: Integer.parse(data.proposer_priority), + }; +} + +interface RpcBlockParams { + readonly max_bytes: string; + readonly max_gas: string; +} + +/** + * Note: we do not parse block.time_iota_ms for now because of this CHANGELOG entry + * + * > Add time_iota_ms to block's consensus parameters (not exposed to the application) + * https://github.com/tendermint/tendermint/blob/master/CHANGELOG.md#v0310 + */ +function decodeBlockParams(data: RpcBlockParams): responses.BlockParams { + return { + maxBytes: Integer.parse(assertNotEmpty(data.max_bytes)), + maxGas: Integer.parse(assertNotEmpty(data.max_gas)), + }; +} + +interface RpcEvidenceParams { + readonly max_age_num_blocks: string; + readonly max_age_duration: string; +} + +function decodeEvidenceParams(data: RpcEvidenceParams): responses.EvidenceParams { + return { + maxAgeNumBlocks: Integer.parse(assertNotEmpty(data.max_age_num_blocks)), + maxAgeDuration: Integer.parse(assertNotEmpty(data.max_age_duration)), + }; +} + +/** + * Example data: + * { + * "block": { + * "max_bytes": "22020096", + * "max_gas": "-1", + * "time_iota_ms": "1000" + * }, + * "evidence": { + * "max_age_num_blocks": "100000", + * "max_age_duration": "172800000000000" + * }, + * "validator": { + * "pub_key_types": [ + * "ed25519" + * ] + * } + * } + */ +interface RpcConsensusParams { + readonly block: RpcBlockParams; + readonly evidence: RpcEvidenceParams; +} + +function decodeConsensusParams(data: RpcConsensusParams): responses.ConsensusParams { + return { + block: decodeBlockParams(assertObject(data.block)), + evidence: decodeEvidenceParams(assertObject(data.evidence)), + }; +} + +interface RpcBlockResultsResponse { + readonly height: string; + readonly txs_results: readonly RpcTxData[] | null; + readonly begin_block_events: readonly RpcEvent[] | null; + readonly end_block_events: readonly RpcEvent[] | null; + readonly validator_updates: readonly RpcValidatorUpdate[] | null; + readonly consensus_param_updates: RpcConsensusParams | null; +} + +function decodeBlockResults(data: RpcBlockResultsResponse): responses.BlockResultsResponse { + return { + height: Integer.parse(assertNotEmpty(data.height)), + results: (data.txs_results || []).map(decodeTxData), + validatorUpdates: (data.validator_updates || []).map(decodeValidatorUpdate), + consensusUpdates: may(decodeConsensusParams, data.consensus_param_updates), + beginBlockEvents: decodeEvents(data.begin_block_events || []), + endBlockEvents: decodeEvents(data.end_block_events || []), + }; +} + +interface RpcBlockId { + /** hex encoded */ + readonly hash: string; + readonly parts: { + readonly total: string; + /** hex encoded */ + readonly hash: string; + }; +} + +function decodeBlockId(data: RpcBlockId): responses.BlockId { + return { + hash: fromHex(assertNotEmpty(data.hash)), + parts: { + total: Integer.parse(assertNotEmpty(data.parts.total)), + hash: fromHex(assertNotEmpty(data.parts.hash)), + }, + }; +} + +interface RpcBlockVersion { + readonly block: string; + readonly app?: string; +} + +function decodeBlockVersion(data: RpcBlockVersion): responses.Version { + return { + block: Integer.parse(data.block), + app: Integer.parse(data.app ?? 0), + }; +} + +interface RpcHeader { + readonly version: RpcBlockVersion; + readonly chain_id: string; + readonly height: string; + readonly time: string; + readonly num_txs: string; + readonly total_txs: string; + + readonly last_block_id: RpcBlockId; + + /** hex encoded */ + readonly last_commit_hash: string; + /** hex encoded */ + readonly data_hash: string; + + /** hex encoded */ + readonly validators_hash: string; + /** hex encoded */ + readonly next_validators_hash: string; + /** hex encoded */ + readonly consensus_hash: string; + /** hex encoded */ + readonly app_hash: string; + /** hex encoded */ + readonly last_results_hash: string; + + /** hex encoded */ + readonly evidence_hash: string; + /** hex encoded */ + readonly proposer_address: string; +} + +function decodeHeader(data: RpcHeader): responses.Header { + return { + version: decodeBlockVersion(data.version), + chainId: assertNotEmpty(data.chain_id), + height: Integer.parse(assertNotEmpty(data.height)), + time: fromRfc3339WithNanoseconds(assertNotEmpty(data.time)), + + lastBlockId: decodeBlockId(data.last_block_id), + + lastCommitHash: fromHex(assertNotEmpty(data.last_commit_hash)), + dataHash: fromHex(assertSet(data.data_hash)), + + validatorsHash: fromHex(assertNotEmpty(data.validators_hash)), + nextValidatorsHash: fromHex(assertNotEmpty(data.next_validators_hash)), + consensusHash: fromHex(assertNotEmpty(data.consensus_hash)), + appHash: fromHex(assertNotEmpty(data.app_hash)), + lastResultsHash: fromHex(assertSet(data.last_results_hash)), + + evidenceHash: fromHex(assertSet(data.evidence_hash)), + proposerAddress: fromHex(assertNotEmpty(data.proposer_address)), + }; +} + +interface RpcBlockMeta { + readonly block_id: RpcBlockId; + readonly header: RpcHeader; +} + +function decodeBlockMeta(data: RpcBlockMeta): responses.BlockMeta { + return { + blockId: decodeBlockId(data.block_id), + header: decodeHeader(data.header), + }; +} + +interface RpcBlockchainResponse { + readonly last_height: string; + readonly block_metas: readonly RpcBlockMeta[]; +} + +function decodeBlockchain(data: RpcBlockchainResponse): responses.BlockchainResponse { + return { + lastHeight: Integer.parse(assertNotEmpty(data.last_height)), + blockMetas: assertArray(data.block_metas).map(decodeBlockMeta), + }; +} + +interface RpcBroadcastTxSyncResponse extends RpcTxData { + /** hex encoded */ + readonly hash: string; +} + +function decodeBroadcastTxSync(data: RpcBroadcastTxSyncResponse): responses.BroadcastTxSyncResponse { + return { + ...decodeTxData(data), + hash: fromHex(assertNotEmpty(data.hash)), + }; +} + +interface RpcBroadcastTxCommitResponse { + readonly height: string; + /** hex encoded */ + readonly hash: string; + readonly check_tx: RpcTxData; + readonly deliver_tx?: RpcTxData; +} + +function decodeBroadcastTxCommit(data: RpcBroadcastTxCommitResponse): responses.BroadcastTxCommitResponse { + return { + height: Integer.parse(data.height), + hash: fromHex(assertNotEmpty(data.hash)), + checkTx: decodeTxData(assertObject(data.check_tx)), + deliverTx: may(decodeTxData, data.deliver_tx), + }; +} + +function decodeBlockIdFlag(blockIdFlag: number): BlockIdFlag { + assert(blockIdFlag in BlockIdFlag); + return blockIdFlag; +} + +type RpcSignature = { + readonly block_id_flag: number; + /** hex encoded */ + readonly validator_address: string; + readonly timestamp: string; + /** bae64 encoded */ + readonly signature: string; +}; + +function decodeCommitSignature(data: RpcSignature): CommitSignature { + return { + blockIdFlag: decodeBlockIdFlag(data.block_id_flag), + validatorAddress: fromHex(data.validator_address), + timestamp: fromRfc3339WithNanoseconds(assertNotEmpty(data.timestamp)), + signature: fromBase64(assertNotEmpty(data.signature)), + }; +} + +interface RpcCommit { + readonly block_id: RpcBlockId; + readonly height: string; + readonly round: string; + readonly signatures: readonly RpcSignature[]; +} + +function decodeCommit(data: RpcCommit): responses.Commit { + return { + blockId: decodeBlockId(assertObject(data.block_id)), + height: Integer.parse(assertNotEmpty(data.height)), + round: Integer.parse(data.round), + signatures: assertArray(data.signatures).map(decodeCommitSignature), + }; +} + +interface RpcCommitResponse { + readonly signed_header: { + readonly header: RpcHeader; + readonly commit: RpcCommit; + }; + readonly canonical: boolean; +} + +function decodeCommitResponse(data: RpcCommitResponse): responses.CommitResponse { + return { + canonical: assertBoolean(data.canonical), + header: decodeHeader(data.signed_header.header), + commit: decodeCommit(data.signed_header.commit), + }; +} + +interface RpcValidatorGenesis { + /** hex-encoded */ + readonly address: string; + readonly pub_key: RpcPubkey; + readonly power: string; + readonly name?: string; +} + +function decodeValidatorGenesis(data: RpcValidatorGenesis): responses.Validator { + return { + address: fromHex(assertNotEmpty(data.address)), + pubkey: decodePubkey(assertObject(data.pub_key)), + votingPower: Integer.parse(assertNotEmpty(data.power)), + }; +} + +interface RpcGenesisResponse { + readonly genesis_time: string; + readonly chain_id: string; + readonly consensus_params: RpcConsensusParams; + // The validators key is used to specify a set of validators for testnets or PoA blockchains. + // PoS blockchains use the app_state.genutil.gentxs field to stake and bond a number of validators in the first block. + readonly validators?: readonly RpcValidatorGenesis[]; + /** hex encoded */ + readonly app_hash: string; + readonly app_state: Record | undefined; +} + +interface GenesisResult { + readonly genesis: RpcGenesisResponse; +} + +function decodeGenesis(data: RpcGenesisResponse): responses.GenesisResponse { + return { + genesisTime: fromRfc3339WithNanoseconds(assertNotEmpty(data.genesis_time)), + chainId: assertNotEmpty(data.chain_id), + consensusParams: decodeConsensusParams(data.consensus_params), + validators: data.validators ? assertArray(data.validators).map(decodeValidatorGenesis) : [], + appHash: fromHex(assertSet(data.app_hash)), // empty string in kvstore app + appState: data.app_state, + }; +} + +// this is in status +interface RpcValidatorInfo { + /** hex encoded */ + readonly address: string; + readonly pub_key: RpcPubkey; + readonly voting_power: string; +} + +function decodeValidatorInfo(data: RpcValidatorInfo): responses.Validator { + return { + pubkey: decodePubkey(assertObject(data.pub_key)), + votingPower: Integer.parse(assertNotEmpty(data.voting_power)), + address: fromHex(assertNotEmpty(data.address)), + }; +} + +interface RpcNodeInfo { + /** hex encoded */ + readonly id: string; + /** IP and port */ + readonly listen_addr: string; + readonly network: string; + readonly version: string; + readonly channels: string; // ??? + readonly moniker: string; + readonly protocol_version: { + readonly p2p: string; + readonly block: string; + readonly app: string; + }; + /** + * Additional information. E.g. + * { + * "tx_index": "on", + * "rpc_address":"tcp://0.0.0.0:26657" + * } + */ + readonly other: Record; +} + +function decodeNodeInfo(data: RpcNodeInfo): responses.NodeInfo { + return { + id: fromHex(assertNotEmpty(data.id)), + listenAddr: assertNotEmpty(data.listen_addr), + network: assertNotEmpty(data.network), + version: assertString(data.version), // Can be empty (https://github.com/cosmos/cosmos-sdk/issues/7963) + channels: assertNotEmpty(data.channels), + moniker: assertNotEmpty(data.moniker), + other: dictionaryToStringMap(data.other), + protocolVersion: { + app: Integer.parse(assertNotEmpty(data.protocol_version.app)), + block: Integer.parse(assertNotEmpty(data.protocol_version.block)), + p2p: Integer.parse(assertNotEmpty(data.protocol_version.p2p)), + }, + }; +} + +interface RpcSyncInfo { + /** hex encoded */ + readonly latest_block_hash: string; + /** hex encoded */ + readonly latest_app_hash: string; + readonly latest_block_height: string; + readonly latest_block_time: string; + readonly catching_up: boolean; +} + +function decodeSyncInfo(data: RpcSyncInfo): responses.SyncInfo { + return { + latestBlockHash: fromHex(assertNotEmpty(data.latest_block_hash)), + latestAppHash: fromHex(assertNotEmpty(data.latest_app_hash)), + latestBlockTime: fromRfc3339WithNanoseconds(assertNotEmpty(data.latest_block_time)), + latestBlockHeight: Integer.parse(assertNotEmpty(data.latest_block_height)), + catchingUp: assertBoolean(data.catching_up), + }; +} + +interface RpcStatusResponse { + readonly node_info: RpcNodeInfo; + readonly sync_info: RpcSyncInfo; + readonly validator_info: RpcValidatorInfo; +} + +function decodeStatus(data: RpcStatusResponse): responses.StatusResponse { + return { + nodeInfo: decodeNodeInfo(data.node_info), + syncInfo: decodeSyncInfo(data.sync_info), + validatorInfo: decodeValidatorInfo(data.validator_info), + }; +} + +/** + * Example data: + * { + * "root_hash": "10A1A17D5F818099B5CAB5B91733A3CC27C0DB6CE2D571AC27FB970C314308BB", + * "data": "ZVlERVhDV2lVNEUwPXhTUjc4Tmp2QkNVSg==", + * "proof": { + * "total": "1", + * "index": "0", + * "leaf_hash": "EKGhfV+BgJm1yrW5FzOjzCfA22zi1XGsJ/uXDDFDCLs=", + * "aunts": [] + * } + * } + */ +interface RpcTxProof { + /** base64 encoded */ + readonly data: string; + /** hex encoded */ + readonly root_hash: string; + readonly proof: { + readonly total: string; + readonly index: string; + /** base64 encoded */ + readonly leaf_hash: string; + /** base64 encoded */ + readonly aunts: readonly string[]; + }; +} + +function decodeTxProof(data: RpcTxProof): responses.TxProof { + return { + data: fromBase64(assertNotEmpty(data.data)), + rootHash: fromHex(assertNotEmpty(data.root_hash)), + proof: { + total: Integer.parse(assertNotEmpty(data.proof.total)), + index: Integer.parse(assertNotEmpty(data.proof.index)), + leafHash: fromBase64(assertNotEmpty(data.proof.leaf_hash)), + aunts: assertArray(data.proof.aunts).map(fromBase64), + }, + }; +} + +interface RpcTxResponse { + /** Raw tx bytes, base64 encoded */ + readonly tx: string; + readonly tx_result: RpcTxData; + readonly height: string; + readonly index: number; + /** hex encoded */ + readonly hash: string; + readonly proof?: RpcTxProof; +} + +function decodeTxResponse(data: RpcTxResponse): responses.TxResponse { + return { + tx: fromBase64(assertNotEmpty(data.tx)), + result: decodeTxData(assertObject(data.tx_result)), + height: Integer.parse(assertNotEmpty(data.height)), + index: Integer.parse(assertNumber(data.index)), + hash: fromHex(assertNotEmpty(data.hash)), + proof: may(decodeTxProof, data.proof), + }; +} + +interface RpcTxSearchResponse { + readonly txs: readonly RpcTxResponse[]; + readonly total_count: string; +} + +function decodeTxSearch(data: RpcTxSearchResponse): responses.TxSearchResponse { + return { + totalCount: Integer.parse(assertNotEmpty(data.total_count)), + txs: assertArray(data.txs).map(decodeTxResponse), + }; +} + +interface RpcTxEvent { + /** Raw tx bytes, base64 encoded */ + readonly tx: string; + readonly result: RpcTxData; + readonly height: string; + /** Not set since Tendermint 0.34 */ + readonly index?: number; +} + +function decodeTxEvent(data: RpcTxEvent): responses.TxEvent { + const tx = fromBase64(assertNotEmpty(data.tx)); + return { + tx: tx, + hash: hashTx(tx), + result: decodeTxData(data.result), + height: Integer.parse(assertNotEmpty(data.height)), + index: may(Integer.parse, data.index), + }; +} + +interface RpcValidatorsResponse { + readonly block_height: string; + readonly validators: readonly RpcValidatorUpdate[]; + readonly count: string; + readonly total: string; +} + +function decodeValidators(data: RpcValidatorsResponse): responses.ValidatorsResponse { + return { + blockHeight: Integer.parse(assertNotEmpty(data.block_height)), + validators: assertArray(data.validators).map(decodeValidatorUpdate), + count: Integer.parse(assertNotEmpty(data.count)), + total: Integer.parse(assertNotEmpty(data.total)), + }; +} + +interface RpcEvidence { + readonly type: string; + readonly validator: RpcValidatorUpdate; + readonly height: string; + readonly time: string; + readonly totalVotingPower: string; +} + +function decodeEvidence(data: RpcEvidence): responses.Evidence { + return { + type: assertNotEmpty(data.type), + height: Integer.parse(assertNotEmpty(data.height)), + time: Integer.parse(assertNotEmpty(data.time)), + totalVotingPower: Integer.parse(assertNotEmpty(data.totalVotingPower)), + validator: decodeValidatorUpdate(data.validator), + }; +} + +function decodeEvidences(ev: readonly RpcEvidence[]): readonly responses.Evidence[] { + return assertArray(ev).map(decodeEvidence); +} + +interface RpcBlock { + readonly header: RpcHeader; + readonly last_commit: RpcCommit; + readonly data: { + /** Raw tx bytes, base64 encoded */ + readonly txs?: readonly string[]; + }; + readonly evidence?: { + readonly evidence?: readonly RpcEvidence[]; + }; +} + +function decodeBlock(data: RpcBlock): responses.Block { + return { + header: decodeHeader(assertObject(data.header)), + lastCommit: decodeCommit(assertObject(data.last_commit)), + txs: data.data.txs ? assertArray(data.data.txs).map(fromBase64) : [], + evidence: data.evidence && may(decodeEvidences, data.evidence.evidence), + }; +} + +interface RpcBlockResponse { + readonly block_id: RpcBlockId; + readonly block: RpcBlock; +} + +function decodeBlockResponse(data: RpcBlockResponse): responses.BlockResponse { + return { + blockId: decodeBlockId(data.block_id), + block: decodeBlock(data.block), + }; +} + +export class Responses { + public static decodeAbciInfo(response: JsonRpcSuccessResponse): responses.AbciInfoResponse { + return decodeAbciInfo(assertObject((response.result as AbciInfoResult).response)); + } + + public static decodeAbciQuery(response: JsonRpcSuccessResponse): responses.AbciQueryResponse { + return decodeAbciQuery(assertObject((response.result as AbciQueryResult).response)); + } + + public static decodeBlock(response: JsonRpcSuccessResponse): responses.BlockResponse { + return decodeBlockResponse(response.result as RpcBlockResponse); + } + + public static decodeBlockResults(response: JsonRpcSuccessResponse): responses.BlockResultsResponse { + return decodeBlockResults(response.result as RpcBlockResultsResponse); + } + + public static decodeBlockchain(response: JsonRpcSuccessResponse): responses.BlockchainResponse { + return decodeBlockchain(response.result as RpcBlockchainResponse); + } + + public static decodeBroadcastTxSync(response: JsonRpcSuccessResponse): responses.BroadcastTxSyncResponse { + return decodeBroadcastTxSync(response.result as RpcBroadcastTxSyncResponse); + } + + public static decodeBroadcastTxAsync(response: JsonRpcSuccessResponse): responses.BroadcastTxAsyncResponse { + return this.decodeBroadcastTxSync(response); + } + + public static decodeBroadcastTxCommit( + response: JsonRpcSuccessResponse, + ): responses.BroadcastTxCommitResponse { + return decodeBroadcastTxCommit(response.result as RpcBroadcastTxCommitResponse); + } + + public static decodeCommit(response: JsonRpcSuccessResponse): responses.CommitResponse { + return decodeCommitResponse(response.result as RpcCommitResponse); + } + + public static decodeGenesis(response: JsonRpcSuccessResponse): responses.GenesisResponse { + return decodeGenesis(assertObject((response.result as GenesisResult).genesis)); + } + + public static decodeHealth(): responses.HealthResponse { + return null; + } + + public static decodeStatus(response: JsonRpcSuccessResponse): responses.StatusResponse { + return decodeStatus(response.result as RpcStatusResponse); + } + + public static decodeNewBlockEvent(event: SubscriptionEvent): responses.NewBlockEvent { + return decodeBlock(event.data.value.block as RpcBlock); + } + + public static decodeNewBlockHeaderEvent(event: SubscriptionEvent): responses.NewBlockHeaderEvent { + return decodeHeader(event.data.value.header as RpcHeader); + } + + public static decodeTxEvent(event: SubscriptionEvent): responses.TxEvent { + return decodeTxEvent(event.data.value.TxResult as RpcTxEvent); + } + + public static decodeTx(response: JsonRpcSuccessResponse): responses.TxResponse { + return decodeTxResponse(response.result as RpcTxResponse); + } + + public static decodeTxSearch(response: JsonRpcSuccessResponse): responses.TxSearchResponse { + return decodeTxSearch(response.result as RpcTxSearchResponse); + } + + public static decodeValidators(response: JsonRpcSuccessResponse): responses.ValidatorsResponse { + return decodeValidators(response.result as RpcValidatorsResponse); + } +} diff --git a/packages/tendermint-rpc/src/tendermint34/encodings.spec.ts b/packages/tendermint-rpc/src/tendermint34/encodings.spec.ts new file mode 100644 index 00000000..afea0600 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/encodings.spec.ts @@ -0,0 +1,90 @@ +import { ReadonlyDate } from "readonly-date"; + +import { encodeBlockId, encodeBytes, encodeInt, encodeString, encodeTime, encodeVersion } from "./encodings"; + +describe("encodings", () => { + describe("encodeString", () => { + it("works", () => { + expect(encodeString("")).toEqual(Uint8Array.from([0])); + const str = "hello iov"; + expect(encodeString(str)).toEqual( + Uint8Array.from([str.length, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x69, 0x6f, 0x76]), + ); + }); + }); + + describe("encodeInt", () => { + it("works", () => { + expect(encodeInt(0)).toEqual(Uint8Array.from([0])); + expect(encodeInt(1)).toEqual(Uint8Array.from([1])); + expect(encodeInt(127)).toEqual(Uint8Array.from([127])); + expect(encodeInt(128)).toEqual(Uint8Array.from([128, 1])); + expect(encodeInt(255)).toEqual(Uint8Array.from([255, 1])); + expect(encodeInt(256)).toEqual(Uint8Array.from([128, 2])); + }); + }); + + describe("encodeTime", () => { + it("works", () => { + const readonlyDateWithNanoseconds = new ReadonlyDate(1464109200); + (readonlyDateWithNanoseconds as any).nanoseconds = 666666; + expect(encodeTime(readonlyDateWithNanoseconds)).toEqual( + Uint8Array.from([0x08, 173, 174, 89, 0x10, 170, 220, 215, 95]), + ); + }); + }); + + describe("encodeBytes", () => { + it("works", () => { + expect(encodeBytes(Uint8Array.from([]))).toEqual(Uint8Array.from([])); + const uint8Array = Uint8Array.from([1, 2, 3, 4, 5, 6, 7]); + expect(encodeBytes(uint8Array)).toEqual(Uint8Array.from([uint8Array.length, 1, 2, 3, 4, 5, 6, 7])); + }); + }); + + describe("encodeVersion", () => { + it("works", () => { + const version = { + block: 666666, + app: 200, + }; + expect(encodeVersion(version)).toEqual(Uint8Array.from([0x08, 170, 216, 40, 0x10, 200, 1])); + }); + }); + + describe("encodeBlockId", () => { + it("works", () => { + const blockId = { + hash: Uint8Array.from([1, 2, 3, 4, 5, 6, 7]), + parts: { + total: 88, + hash: Uint8Array.from([8, 9, 10, 11, 12]), + }, + }; + expect(encodeBlockId(blockId)).toEqual( + Uint8Array.from([ + 0x0a, + blockId.hash.length, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 0x12, + 9, + 0x08, + 88, + 0x12, + 5, + 8, + 9, + 10, + 11, + 12, + ]), + ); + }); + }); +}); diff --git a/packages/tendermint-rpc/src/tendermint34/encodings.ts b/packages/tendermint-rpc/src/tendermint34/encodings.ts new file mode 100644 index 00000000..f950fd17 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/encodings.ts @@ -0,0 +1,210 @@ +import { toUtf8 } from "@cosmjs/encoding"; +import { Int53 } from "@cosmjs/math"; + +import { ReadonlyDateWithNanoseconds } from "../dates"; +import { BlockId, Version } from "./responses"; + +/** + * A runtime checker that ensures a given value is set (i.e. not undefined or null) + * + * This is used when you want to verify that data at runtime matches the expected type. + */ +export function assertSet(value: T): T { + if ((value as unknown) === undefined) { + throw new Error("Value must not be undefined"); + } + + if ((value as unknown) === null) { + throw new Error("Value must not be null"); + } + + return value; +} + +/** + * A runtime checker that ensures a given value is a boolean + * + * This is used when you want to verify that data at runtime matches the expected type. + * This implies assertSet. + */ +export function assertBoolean(value: boolean): boolean { + assertSet(value); + if (typeof (value as unknown) !== "boolean") { + throw new Error("Value must be a boolean"); + } + return value; +} + +/** + * A runtime checker that ensures a given value is a string. + * + * This is used when you want to verify that data at runtime matches the expected type. + * This implies assertSet. + */ +export function assertString(value: string): string { + assertSet(value); + if (typeof (value as unknown) !== "string") { + throw new Error("Value must be a string"); + } + return value; +} + +/** + * A runtime checker that ensures a given value is a number + * + * This is used when you want to verify that data at runtime matches the expected type. + * This implies assertSet. + */ +export function assertNumber(value: number): number { + assertSet(value); + if (typeof (value as unknown) !== "number") { + throw new Error("Value must be a number"); + } + return value; +} + +/** + * A runtime checker that ensures a given value is an array + * + * This is used when you want to verify that data at runtime matches the expected type. + * This implies assertSet. + */ +export function assertArray(value: readonly T[]): readonly T[] { + assertSet(value); + if (!Array.isArray(value as unknown)) { + throw new Error("Value must be a an array"); + } + return value; +} + +/** + * A runtime checker that ensures a given value is an object in the sense of JSON + * (an unordered collection of key–value pairs where the keys are strings) + * + * This is used when you want to verify that data at runtime matches the expected type. + * This implies assertSet. + */ +export function assertObject(value: T): T { + assertSet(value); + if (typeof (value as unknown) !== "object") { + throw new Error("Value must be an object"); + } + + // Exclude special kind of objects like Array, Date or Uint8Array + // Object.prototype.toString() returns a specified value: + // http://www.ecma-international.org/ecma-262/7.0/index.html#sec-object.prototype.tostring + if (Object.prototype.toString.call(value) !== "[object Object]") { + throw new Error("Value must be a simple object"); + } + + return value; +} + +interface Lengther { + readonly length: number; +} + +/** + * Throws an error if value matches the empty value for the + * given type (array/string of length 0, number of value 0, ...) + * + * Otherwise returns the value. + * + * This implies assertSet + */ +export function assertNotEmpty(value: T): T { + assertSet(value); + + if (typeof value === "number" && value === 0) { + throw new Error("must provide a non-zero value"); + } else if (((value as any) as Lengther).length === 0) { + throw new Error("must provide a non-empty value"); + } + return value; +} + +// optional uses the value or provides a default +export function optional(value: T | null | undefined, fallback: T): T { + return value === undefined || value === null ? fallback : value; +} + +// may will run the transform if value is defined, otherwise returns undefined +export function may(transform: (val: T) => U, value: T | null | undefined): U | undefined { + return value === undefined || value === null ? undefined : transform(value); +} + +export function dictionaryToStringMap(obj: Record): Map { + const out = new Map(); + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (typeof value !== "string") { + throw new Error("Found dictionary value of type other than string"); + } + out.set(key, value); + } + return out; +} + +export class Integer { + public static parse(input: string | number): number { + const asInt = typeof input === "number" ? new Int53(input) : Int53.fromString(input); + return asInt.toNumber(); + } + + public static encode(num: number): string { + return new Int53(num).toString(); + } +} + +// Encodings needed for hashing block headers +// Several of these functions are inspired by https://github.com/nomic-io/js-tendermint/blob/tendermint-0.30/src/ + +// See https://github.com/tendermint/go-amino/blob/v0.15.0/encoder.go#L193-L195 +export function encodeString(s: string): Uint8Array { + const utf8 = toUtf8(s); + return Uint8Array.from([utf8.length, ...utf8]); +} + +// See https://github.com/tendermint/go-amino/blob/v0.15.0/encoder.go#L79-L87 +export function encodeInt(n: number): Uint8Array { + // eslint-disable-next-line no-bitwise + return n >= 0x80 ? Uint8Array.from([(n & 0xff) | 0x80, ...encodeInt(n >> 7)]) : Uint8Array.from([n & 0xff]); +} + +// See https://github.com/tendermint/go-amino/blob/v0.15.0/encoder.go#L134-L178 +export function encodeTime(time: ReadonlyDateWithNanoseconds): Uint8Array { + const milliseconds = time.getTime(); + const seconds = Math.floor(milliseconds / 1000); + const secondsArray = seconds ? [0x08, ...encodeInt(seconds)] : new Uint8Array(); + const nanoseconds = (time.nanoseconds || 0) + (milliseconds % 1000) * 1e6; + const nanosecondsArray = nanoseconds ? [0x10, ...encodeInt(nanoseconds)] : new Uint8Array(); + return Uint8Array.from([...secondsArray, ...nanosecondsArray]); +} + +// See https://github.com/tendermint/go-amino/blob/v0.15.0/encoder.go#L180-L187 +export function encodeBytes(bytes: Uint8Array): Uint8Array { + // Since we're only dealing with short byte arrays we don't need a full VarBuffer implementation yet + if (bytes.length >= 0x80) throw new Error("Not implemented for byte arrays of length 128 or more"); + return bytes.length ? Uint8Array.from([bytes.length, ...bytes]) : new Uint8Array(); +} + +export function encodeVersion(version: Version): Uint8Array { + const blockArray = version.block ? Uint8Array.from([0x08, ...encodeInt(version.block)]) : new Uint8Array(); + const appArray = version.app ? Uint8Array.from([0x10, ...encodeInt(version.app)]) : new Uint8Array(); + return Uint8Array.from([...blockArray, ...appArray]); +} + +export function encodeBlockId(blockId: BlockId): Uint8Array { + return Uint8Array.from([ + 0x0a, + blockId.hash.length, + ...blockId.hash, + 0x12, + blockId.parts.hash.length + 4, + 0x08, + blockId.parts.total, + 0x12, + blockId.parts.hash.length, + ...blockId.parts.hash, + ]); +} diff --git a/packages/tendermint-rpc/src/tendermint34/hasher.spec.ts b/packages/tendermint-rpc/src/tendermint34/hasher.spec.ts new file mode 100644 index 00000000..c6644875 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/hasher.spec.ts @@ -0,0 +1,91 @@ +import { fromBase64, fromHex } from "@cosmjs/encoding"; +import { ReadonlyDate } from "readonly-date"; + +import { ReadonlyDateWithNanoseconds } from "../dates"; +import { hashBlock, hashTx } from "./hasher"; + +describe("Hasher", () => { + it("creates transaction hash equal to local test", () => { + // This was taken from a result from /tx_search of some random test transaction + // curl "http://localhost:11127/tx_search?query=\"tx.hash='5CB2CF94A1097A4BC19258BC2353C3E76102B6D528458BE45C855DC5563C1DB2'\"" + const txId = fromHex("5CB2CF94A1097A4BC19258BC2353C3E76102B6D528458BE45C855DC5563C1DB2"); + const txData = fromBase64("YUpxZDY2NURaUDMxPWd2TzBPdnNrVWFWYg=="); + expect(hashTx(txData)).toEqual(txId); + }); + + it("creates block hash equal to local test for empty block", () => { + // This was taken from a result from /block of some random empty block + // curl "http://localhost:11133/block" + const blockId = fromHex("153C484DCBC33633F0616BC019388C93DEA94F7880627976F2BFE83749E062F7"); + const time = new ReadonlyDate("2020-06-23T13:54:15.4638668Z"); + (time as any).nanoseconds = 866800; + const blockData = { + version: { + block: 10, + app: 1, + }, + chainId: "test-chain-2A5rwi", + height: 7795, + time: time as ReadonlyDateWithNanoseconds, + + lastBlockId: { + hash: fromHex("1EC48444E64E7B96585BA518613612E52B976E3DA2F2222B9CD4D1602656C96F"), + parts: { + total: 1, + hash: fromHex("D4E6F1B0EE08D0438C9BB8455D7D3F2FC1883C32D66F7C69C4A0F093B073F6D2"), + }, + }, + + lastCommitHash: fromHex("BA6A5EEA6687ACA8EE4FFE4F5D40EA073CB7397A5336309C3EC824805AF9723E"), + dataHash: fromHex(""), + + validatorsHash: fromHex("0BEEBC6AB3B7D4FE21E22B609CD4AEC7E121A42C07604FF1827651F0173745EB"), + nextValidatorsHash: fromHex("0BEEBC6AB3B7D4FE21E22B609CD4AEC7E121A42C07604FF1827651F0173745EB"), + consensusHash: fromHex("048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F"), + appHash: fromHex("8801000000000000"), + lastResultsHash: fromHex(""), + + evidenceHash: fromHex(""), + proposerAddress: fromHex("614F305502F65C01114F9B8711D9A0AB0AC369F4"), + }; + expect(hashBlock(blockData)).toEqual(blockId); + }); + + it("creates block hash equal to local test for block with a transaction", () => { + // This was taken from a result from /block of some random block with a transaction + // curl "http://localhost:11133/block?height=13575" + const blockId = fromHex("FF2995AF1F38B9A584077E53B5E144778718FB86539A51886A2C55F730403373"); + const time = new ReadonlyDate("2020-06-23T15:34:12.3232688Z"); + (time as any).nanoseconds = 268800; + const blockData = { + version: { + block: 10, + app: 1, + }, + chainId: "test-chain-2A5rwi", + height: 13575, + time: time as ReadonlyDateWithNanoseconds, + + lastBlockId: { + hash: fromHex("046D5441FC4D008FCDBF9F3DD5DC25CF00883763E44CF4FAF3923FB5FEA42D8F"), + parts: { + total: 1, + hash: fromHex("02E4715343625093C717638EAC67FB3A4B24CCC8DA610E0CB324D705E68FEF7B"), + }, + }, + + lastCommitHash: fromHex("AA2B807F3B0ACC866AB58D90C2D0FC70B6C860CFAC440590B4F590CDC178A207"), + dataHash: fromHex("56782879F526889734BA65375CD92A9152C7114B2C91B2D2AD8464FF69E884AA"), + + validatorsHash: fromHex("0BEEBC6AB3B7D4FE21E22B609CD4AEC7E121A42C07604FF1827651F0173745EB"), + nextValidatorsHash: fromHex("0BEEBC6AB3B7D4FE21E22B609CD4AEC7E121A42C07604FF1827651F0173745EB"), + consensusHash: fromHex("048091BC7DDC283F77BFBF91D73C44DA58C3DF8A9CBC867405D8B7F3DAADA22F"), + appHash: fromHex("CC02000000000000"), + lastResultsHash: fromHex("6E340B9CFFB37A989CA544E6BB780A2C78901D3FB33738768511A30617AFA01D"), + + evidenceHash: fromHex(""), + proposerAddress: fromHex("614F305502F65C01114F9B8711D9A0AB0AC369F4"), + }; + expect(hashBlock(blockData)).toEqual(blockId); + }); +}); diff --git a/packages/tendermint-rpc/src/tendermint34/hasher.ts b/packages/tendermint-rpc/src/tendermint34/hasher.ts new file mode 100644 index 00000000..75d9f484 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/hasher.ts @@ -0,0 +1,67 @@ +import { Sha256, sha256 } from "@cosmjs/crypto"; + +import { encodeBlockId, encodeBytes, encodeInt, encodeString, encodeTime, encodeVersion } from "./encodings"; +import { Header } from "./responses"; + +// hash is sha256 +// https://github.com/tendermint/tendermint/blob/master/UPGRADING.md#v0260 +export function hashTx(tx: Uint8Array): Uint8Array { + return sha256(tx); +} + +function getSplitPoint(n: number): number { + if (n < 1) throw new Error("Cannot split an empty tree"); + const largestPowerOf2 = 2 ** Math.floor(Math.log2(n)); + return largestPowerOf2 < n ? largestPowerOf2 : largestPowerOf2 / 2; +} + +function hashLeaf(leaf: Uint8Array): Uint8Array { + const hash = new Sha256(Uint8Array.from([0])); + hash.update(leaf); + return hash.digest(); +} + +function hashInner(left: Uint8Array, right: Uint8Array): Uint8Array { + const hash = new Sha256(Uint8Array.from([1])); + hash.update(left); + hash.update(right); + return hash.digest(); +} + +// See https://github.com/tendermint/tendermint/blob/v0.31.8/docs/spec/blockchain/encoding.md#merkleroot +// Note: the hashes input may not actually be hashes, especially before a recursive call +function hashTree(hashes: readonly Uint8Array[]): Uint8Array { + switch (hashes.length) { + case 0: + throw new Error("Cannot hash empty tree"); + case 1: + return hashLeaf(hashes[0]); + default: { + const slicePoint = getSplitPoint(hashes.length); + const left = hashTree(hashes.slice(0, slicePoint)); + const right = hashTree(hashes.slice(slicePoint)); + return hashInner(left, right); + } + } +} + +export function hashBlock(header: Header): Uint8Array { + const encodedFields: readonly Uint8Array[] = [ + encodeVersion(header.version), + encodeString(header.chainId), + encodeInt(header.height), + encodeTime(header.time), + encodeBlockId(header.lastBlockId), + + encodeBytes(header.lastCommitHash), + encodeBytes(header.dataHash), + encodeBytes(header.validatorsHash), + encodeBytes(header.nextValidatorsHash), + encodeBytes(header.consensusHash), + encodeBytes(header.appHash), + encodeBytes(header.lastResultsHash), + encodeBytes(header.evidenceHash), + encodeBytes(header.proposerAddress), + ]; + return hashTree(encodedFields); +} diff --git a/packages/tendermint-rpc/src/tendermint34/index.ts b/packages/tendermint-rpc/src/tendermint34/index.ts new file mode 100644 index 00000000..a961806a --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/index.ts @@ -0,0 +1,74 @@ +// Note: all exports in this module are publicly available via +// `import { tendermint34 } from "@cosnjs/tendermint-rpc"` + +export { Tendermint34Client } from "./tendermint34client"; +export { + AbciInfoRequest, + AbciQueryParams, + AbciQueryRequest, + BlockRequest, + BlockchainRequest, + BlockResultsRequest, + BroadcastTxRequest, + BroadcastTxParams, + CommitRequest, + GenesisRequest, + HealthRequest, + Method, + Request, + QueryTag, + StatusRequest, + SubscriptionEventType, + TxParams, + TxRequest, + TxSearchParams, + TxSearchRequest, + ValidatorsRequest, + ValidatorsParams, +} from "./requests"; +export { + AbciInfoResponse, + AbciQueryResponse, + Attribute, + Block, + BlockchainResponse, + BlockGossipParams, + BlockId, + BlockMeta, + BlockParams, + BlockResponse, + BlockResultsResponse, + BroadcastTxAsyncResponse, + BroadcastTxCommitResponse, + broadcastTxCommitSuccess, + BroadcastTxSyncResponse, + broadcastTxSyncSuccess, + Commit, + CommitResponse, + ConsensusParams, + Event, + Evidence, + EvidenceParams, + GenesisResponse, + Header, + HealthResponse, + NewBlockEvent, + NewBlockHeaderEvent, + NodeInfo, + ProofOp, + QueryProof, + Response, + StatusResponse, + SyncInfo, + TxData, + TxEvent, + TxProof, + TxResponse, + TxSearchResponse, + TxSizeParams, + Validator, + ValidatorsResponse, + Version, + Vote, + VoteType, +} from "./responses"; diff --git a/packages/tendermint-rpc/src/tendermint34/requests.spec.ts b/packages/tendermint-rpc/src/tendermint34/requests.spec.ts new file mode 100644 index 00000000..f2134ea8 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/requests.spec.ts @@ -0,0 +1,41 @@ +import { buildQuery } from "./requests"; + +describe("Requests", () => { + describe("buildQuery", () => { + it("works for no input", () => { + const query = buildQuery({}); + expect(query).toEqual(""); + }); + + it("works for one tags", () => { + const query = buildQuery({ tags: [{ key: "abc", value: "def" }] }); + expect(query).toEqual("abc='def'"); + }); + + it("works for two tags", () => { + const query = buildQuery({ + tags: [ + { key: "k", value: "9" }, + { key: "L", value: "7" }, + ], + }); + expect(query).toEqual("k='9' AND L='7'"); + }); + + it("works for raw input", () => { + const query = buildQuery({ raw: "aabbCCDD" }); + expect(query).toEqual("aabbCCDD"); + }); + + it("works for mixed input", () => { + const query = buildQuery({ + tags: [ + { key: "k", value: "9" }, + { key: "L", value: "7" }, + ], + raw: "aabbCCDD", + }); + expect(query).toEqual("k='9' AND L='7' AND aabbCCDD"); + }); + }); +}); diff --git a/packages/tendermint-rpc/src/tendermint34/requests.ts b/packages/tendermint-rpc/src/tendermint34/requests.ts new file mode 100644 index 00000000..d985b8e6 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/requests.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * RPC methods as documented in https://docs.tendermint.com/master/rpc/ + * + * Enum raw value must match the spelling in the "shell" example call (snake_case) + */ +export enum Method { + AbciInfo = "abci_info", + AbciQuery = "abci_query", + Block = "block", + /** Get block headers for minHeight <= height <= maxHeight. */ + Blockchain = "blockchain", + BlockResults = "block_results", + BroadcastTxAsync = "broadcast_tx_async", + BroadcastTxSync = "broadcast_tx_sync", + BroadcastTxCommit = "broadcast_tx_commit", + Commit = "commit", + Genesis = "genesis", + Health = "health", + Status = "status", + Subscribe = "subscribe", + Tx = "tx", + TxSearch = "tx_search", + Validators = "validators", + Unsubscribe = "unsubscribe", +} + +export type Request = + | AbciInfoRequest + | AbciQueryRequest + | BlockRequest + | BlockchainRequest + | BlockResultsRequest + | BroadcastTxRequest + | CommitRequest + | GenesisRequest + | HealthRequest + | StatusRequest + | TxRequest + | TxSearchRequest + | ValidatorsRequest; + +/** + * Raw values must match the tendermint event name + * + * @see https://godoc.org/github.com/tendermint/tendermint/types#pkg-constants + */ +export enum SubscriptionEventType { + NewBlock = "NewBlock", + NewBlockHeader = "NewBlockHeader", + Tx = "Tx", +} + +export interface AbciInfoRequest { + readonly method: Method.AbciInfo; +} + +export interface AbciQueryRequest { + readonly method: Method.AbciQuery; + readonly params: AbciQueryParams; +} +export interface AbciQueryParams { + readonly path: string; + readonly data: Uint8Array; + readonly height?: number; + /** + * A flag that defines if proofs are included in the response or not. + * + * Internally this is mapped to the old inverse name `trusted` for Tendermint < 0.26. + * Starting with Tendermint 0.26, the default value changed from true to false. + */ + readonly prove?: boolean; +} + +export interface BlockRequest { + readonly method: Method.Block; + readonly params: { + readonly height?: number; + }; +} + +export interface BlockchainRequest { + readonly method: Method.Blockchain; + readonly params: BlockchainRequestParams; +} + +export interface BlockchainRequestParams { + readonly minHeight?: number; + readonly maxHeight?: number; +} + +export interface BlockResultsRequest { + readonly method: Method.BlockResults; + readonly params: { + readonly height?: number; + }; +} + +export interface BroadcastTxRequest { + readonly method: Method.BroadcastTxAsync | Method.BroadcastTxSync | Method.BroadcastTxCommit; + readonly params: BroadcastTxParams; +} +export interface BroadcastTxParams { + readonly tx: Uint8Array; +} + +export interface CommitRequest { + readonly method: Method.Commit; + readonly params: { + readonly height?: number; + }; +} + +export interface GenesisRequest { + readonly method: Method.Genesis; +} + +export interface HealthRequest { + readonly method: Method.Health; +} + +export interface StatusRequest { + readonly method: Method.Status; +} + +export interface SubscribeRequest { + readonly method: Method.Subscribe; + readonly query: { + readonly type: SubscriptionEventType; + readonly raw?: string; + }; +} + +export interface QueryTag { + readonly key: string; + readonly value: string; +} + +export interface TxRequest { + readonly method: Method.Tx; + readonly params: TxParams; +} +export interface TxParams { + readonly hash: Uint8Array; + readonly prove?: boolean; +} + +// TODO: clarify this type +export interface TxSearchRequest { + readonly method: Method.TxSearch; + readonly params: TxSearchParams; +} + +export interface TxSearchParams { + readonly query: string; + readonly prove?: boolean; + readonly page?: number; + readonly per_page?: number; +} + +export interface ValidatorsRequest { + readonly method: Method.Validators; + readonly params: ValidatorsParams; +} + +export interface ValidatorsParams { + readonly height?: number; + readonly page?: number; + readonly per_page?: number; +} + +export interface BuildQueryComponents { + readonly tags?: readonly QueryTag[]; + readonly raw?: string; +} + +export function buildQuery(components: BuildQueryComponents): string { + const tags = components.tags ? components.tags : []; + const tagComponents = tags.map((tag) => `${tag.key}='${tag.value}'`); + const rawComponents = components.raw ? [components.raw] : []; + + return [...tagComponents, ...rawComponents].join(" AND "); +} diff --git a/packages/tendermint-rpc/src/tendermint34/responses.ts b/packages/tendermint-rpc/src/tendermint34/responses.ts new file mode 100644 index 00000000..55f1dbcd --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/responses.ts @@ -0,0 +1,337 @@ +import { ReadonlyDate } from "readonly-date"; + +import { ReadonlyDateWithNanoseconds } from "../dates"; +import { CommitSignature, ValidatorPubkey } from "../types"; + +export type Response = + | AbciInfoResponse + | AbciQueryResponse + | BlockResponse + | BlockResultsResponse + | BlockchainResponse + | BroadcastTxAsyncResponse + | BroadcastTxSyncResponse + | BroadcastTxCommitResponse + | CommitResponse + | GenesisResponse + | HealthResponse + | StatusResponse + | TxResponse + | TxSearchResponse + | ValidatorsResponse; + +export interface AbciInfoResponse { + readonly data?: string; + readonly lastBlockHeight?: number; + readonly lastBlockAppHash?: Uint8Array; +} + +export interface ProofOp { + readonly type: string; + readonly key: Uint8Array; + readonly data: Uint8Array; +} + +export interface QueryProof { + readonly ops: readonly ProofOp[]; +} + +export interface AbciQueryResponse { + readonly key: Uint8Array; + readonly value: Uint8Array; + readonly proof?: QueryProof; + readonly height?: number; + readonly index?: number; + readonly code?: number; // non-falsy for errors + readonly log?: string; +} + +export interface BlockResponse { + readonly blockId: BlockId; + readonly block: Block; +} + +export interface BlockResultsResponse { + readonly height: number; + readonly results: readonly TxData[]; + readonly validatorUpdates: readonly Validator[]; + readonly consensusUpdates?: ConsensusParams; + readonly beginBlockEvents: readonly Event[]; + readonly endBlockEvents: readonly Event[]; +} + +export interface BlockchainResponse { + readonly lastHeight: number; + readonly blockMetas: readonly BlockMeta[]; +} + +/** No data in here because RPC method BroadcastTxAsync "returns right away, with no response" */ +export interface BroadcastTxAsyncResponse {} + +export interface BroadcastTxSyncResponse extends TxData { + readonly hash: Uint8Array; +} + +/** + * Returns true iff transaction made it sucessfully into the transaction pool + */ +export function broadcastTxSyncSuccess(res: BroadcastTxSyncResponse): boolean { + // code must be 0 on success + return res.code === 0; +} + +export interface BroadcastTxCommitResponse { + readonly height: number; + readonly hash: Uint8Array; + readonly checkTx: TxData; + readonly deliverTx?: TxData; +} + +/** + * Returns true iff transaction made it successfully into a block + * (i.e. success in `check_tx` and `deliver_tx` field) + */ +export function broadcastTxCommitSuccess(response: BroadcastTxCommitResponse): boolean { + // code must be 0 on success + // deliverTx may be present but empty on failure + return response.checkTx.code === 0 && !!response.deliverTx && response.deliverTx.code === 0; +} + +export interface CommitResponse { + readonly header: Header; + readonly commit: Commit; + readonly canonical: boolean; +} + +export interface GenesisResponse { + readonly genesisTime: ReadonlyDate; + readonly chainId: string; + readonly consensusParams: ConsensusParams; + readonly validators: readonly Validator[]; + readonly appHash: Uint8Array; + readonly appState: Record | undefined; +} + +export type HealthResponse = null; + +export interface StatusResponse { + readonly nodeInfo: NodeInfo; + readonly syncInfo: SyncInfo; + readonly validatorInfo: Validator; +} + +/** + * A transaction from RPC calls like search. + * + * Try to keep this compatible to TxEvent + */ +export interface TxResponse { + readonly tx: Uint8Array; + readonly hash: Uint8Array; + readonly height: number; + readonly index: number; + readonly result: TxData; + readonly proof?: TxProof; +} + +export interface TxSearchResponse { + readonly txs: readonly TxResponse[]; + readonly totalCount: number; +} + +export interface ValidatorsResponse { + readonly blockHeight: number; + readonly validators: readonly Validator[]; + readonly count: number; + readonly total: number; +} + +// Events + +export interface NewBlockEvent extends Block {} + +export interface NewBlockHeaderEvent extends Header {} + +export interface TxEvent { + readonly tx: Uint8Array; + readonly hash: Uint8Array; + readonly height: number; + /** @deprecated this value is not set in Tendermint 0.34+ */ + readonly index?: number; + readonly result: TxData; +} + +// Helper items used above + +/** An event attribute */ +export interface Attribute { + readonly key: Uint8Array; + readonly value: Uint8Array; +} + +export interface Event { + readonly type: string; + readonly attributes: readonly Attribute[]; +} + +export interface TxData { + readonly code: number; + readonly log?: string; + readonly data?: Uint8Array; + readonly events: readonly Event[]; + // readonly fees?: any; +} + +export interface TxProof { + readonly data: Uint8Array; + readonly rootHash: Uint8Array; + readonly proof: { + readonly total: number; + readonly index: number; + readonly leafHash: Uint8Array; + readonly aunts: readonly Uint8Array[]; + }; +} + +export interface BlockMeta { + readonly blockId: BlockId; + readonly header: Header; + // TODO: Add blockSize (e.g "block_size": "471") + // TODO: Add numTxs (e.g "num_txs": "0") +} + +export interface BlockId { + readonly hash: Uint8Array; + readonly parts: { + readonly total: number; + readonly hash: Uint8Array; + }; +} + +export interface Block { + readonly header: Header; + readonly lastCommit: Commit; + readonly txs: readonly Uint8Array[]; + readonly evidence?: readonly Evidence[]; +} + +export interface Evidence { + readonly type: string; + readonly validator: Validator; + readonly height: number; + readonly time: number; + readonly totalVotingPower: number; +} + +export interface Commit { + readonly blockId: BlockId; + readonly height: number; + readonly round: number; + readonly signatures: readonly CommitSignature[]; +} + +/** + * raw values from https://github.com/tendermint/tendermint/blob/dfa9a9a30a666132425b29454e90a472aa579a48/types/vote.go#L44 + */ +export enum VoteType { + PreVote = 1, + PreCommit = 2, +} + +export interface Vote { + readonly type: VoteType; + readonly validatorAddress: Uint8Array; + readonly validatorIndex: number; + readonly height: number; + readonly round: number; + readonly timestamp: ReadonlyDate; + readonly blockId: BlockId; + readonly signature: Uint8Array; +} + +export interface Version { + readonly block: number; + readonly app: number; +} + +// https://github.com/tendermint/tendermint/blob/v0.31.8/docs/spec/blockchain/blockchain.md +export interface Header { + // basic block info + readonly version: Version; + readonly chainId: string; + readonly height: number; + readonly time: ReadonlyDateWithNanoseconds; + + // prev block info + readonly lastBlockId: BlockId; + + // hashes of block data + readonly lastCommitHash: Uint8Array; + readonly dataHash: Uint8Array; // empty when number of transaction is 0 + + // hashes from the app output from the prev block + readonly validatorsHash: Uint8Array; + readonly nextValidatorsHash: Uint8Array; + readonly consensusHash: Uint8Array; + readonly appHash: Uint8Array; + readonly lastResultsHash: Uint8Array; + + // consensus info + readonly evidenceHash: Uint8Array; + readonly proposerAddress: Uint8Array; +} + +export interface NodeInfo { + readonly id: Uint8Array; + /** IP and port */ + readonly listenAddr: string; + readonly network: string; + readonly version: string; + readonly channels: string; // ??? + readonly moniker: string; + readonly other: Map; + readonly protocolVersion: { + readonly p2p: number; + readonly block: number; + readonly app: number; + }; +} + +export interface SyncInfo { + readonly latestBlockHash: Uint8Array; + readonly latestAppHash: Uint8Array; + readonly latestBlockHeight: number; + readonly latestBlockTime: ReadonlyDate; + readonly catchingUp: boolean; +} + +export interface Validator { + readonly address: Uint8Array; + readonly pubkey?: ValidatorPubkey; + readonly votingPower: number; + readonly proposerPriority?: number; +} + +export interface ConsensusParams { + readonly block: BlockParams; + readonly evidence: EvidenceParams; +} + +export interface BlockParams { + readonly maxBytes: number; + readonly maxGas: number; +} + +export interface TxSizeParams { + readonly maxBytes: number; + readonly maxGas: number; +} + +export interface BlockGossipParams { + readonly blockPartSizeBytes: number; +} + +export interface EvidenceParams { + readonly maxAgeNumBlocks: number; + readonly maxAgeDuration: number; +} diff --git a/packages/tendermint-rpc/src/tendermint34/tendermint34client.spec.ts b/packages/tendermint-rpc/src/tendermint34/tendermint34client.spec.ts new file mode 100644 index 00000000..cc8c56e2 --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/tendermint34client.spec.ts @@ -0,0 +1,727 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { toAscii } from "@cosmjs/encoding"; +import { firstEvent, toListPromise } from "@cosmjs/stream"; +import { sleep } from "@cosmjs/utils"; +import { ReadonlyDate } from "readonly-date"; +import { Stream } from "xstream"; + +import { ExpectedValues, tendermintInstances } from "../config.spec"; +import { HttpClient, RpcClient, WebsocketClient } from "../rpcclients"; +import { chainIdMatcher } from "../testutil.spec"; +import { adaptor34 } from "./adaptors"; +import { buildQuery } from "./requests"; +import * as responses from "./responses"; +import { Tendermint34Client } from "./tendermint34client"; + +function tendermintEnabled(): boolean { + return !!process.env.TENDERMINT_ENABLED; +} + +function pendingWithoutTendermint(): void { + if (!tendermintEnabled()) { + pending("Set TENDERMINT_ENABLED to enable tendermint-based tests"); + } +} + +async function tendermintSearchIndexUpdated(): Promise { + // Tendermint needs some time before a committed transaction is found in search + return sleep(75); +} + +function buildKvTx(k: string, v: string): Uint8Array { + return toAscii(`${k}=${v}`); +} + +function randomString(): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from({ length: 12 }) + .map(() => alphabet[Math.floor(Math.random() * alphabet.length)]) + .join(""); +} + +function defaultTestSuite(rpcFactory: () => RpcClient, expected: ExpectedValues): void { + describe("create", () => { + it("can auto-discover Tendermint version and communicate", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const info = await client.abciInfo(); + expect(info).toBeTruthy(); + client.disconnect(); + }); + + it("can connect to Tendermint with known version", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + expect(await client.abciInfo()).toBeTruthy(); + client.disconnect(); + }); + }); + + it("can get genesis", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const genesis = await client.genesis(); + expect(genesis).toBeTruthy(); + client.disconnect(); + }); + + it("can broadcast a transaction", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const tx = buildKvTx(randomString(), randomString()); + + const response = await client.broadcastTxCommit({ tx: tx }); + expect(response.height).toBeGreaterThan(2); + expect(response.hash).toBeTruthy(); + // verify success + expect(response.checkTx.code).toBeFalsy(); + expect(response.deliverTx).toBeTruthy(); + if (response.deliverTx) { + expect(response.deliverTx.code).toBeFalsy(); + } + + client.disconnect(); + }); + + it("gets the same tx hash from backend as calculated locally", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const tx = buildKvTx(randomString(), randomString()); + const calculatedTxHash = adaptor34.hashTx(tx); + + const response = await client.broadcastTxCommit({ tx: tx }); + expect(response.hash).toEqual(calculatedTxHash); + + client.disconnect(); + }); + + it("can query the state", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const key = randomString(); + const value = randomString(); + await client.broadcastTxCommit({ tx: buildKvTx(key, value) }); + + const binKey = toAscii(key); + const binValue = toAscii(value); + const queryParams = { path: "/key", data: binKey, prove: true }; + const response = await client.abciQuery(queryParams); + expect(response.key).toEqual(binKey); + expect(response.value).toEqual(binValue); + expect(response.code).toBeFalsy(); + + client.disconnect(); + }); + + it("can get a commit", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const response = await client.commit(4); + + expect(response).toBeTruthy(); + expect(response.commit.signatures.length).toBeGreaterThanOrEqual(1); + expect(response.commit.signatures[0].blockIdFlag).toEqual(2); + expect(response.commit.signatures[0].validatorAddress.length).toEqual(20); + expect(response.commit.signatures[0].timestamp).toBeInstanceOf(Date); + expect(response.commit.signatures[0].signature.length).toEqual(64); + + client.disconnect(); + }); + + it("can get validators", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const response = await client.validators({}); + + expect(response).toBeTruthy(); + expect(response.blockHeight).toBeGreaterThanOrEqual(1); + expect(response.count).toBeGreaterThanOrEqual(1); + expect(response.total).toBeGreaterThanOrEqual(1); + expect(response.validators.length).toBeGreaterThanOrEqual(1); + expect(response.validators[0].address.length).toEqual(20); + expect(response.validators[0].pubkey).toBeDefined(); + expect(response.validators[0].votingPower).toBeGreaterThanOrEqual(0); + expect(response.validators[0].proposerPriority).toBeGreaterThanOrEqual(0); + + client.disconnect(); + }); + + it("can get all validators", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + const response = await client.validatorsAll(); + + expect(response).toBeTruthy(); + expect(response.blockHeight).toBeGreaterThanOrEqual(1); + expect(response.count).toBeGreaterThanOrEqual(1); + expect(response.total).toBeGreaterThanOrEqual(1); + expect(response.validators.length).toBeGreaterThanOrEqual(1); + expect(response.validators[0].address.length).toEqual(20); + expect(response.validators[0].pubkey).toBeDefined(); + expect(response.validators[0].votingPower).toBeGreaterThanOrEqual(0); + expect(response.validators[0].proposerPriority).toBeGreaterThanOrEqual(0); + + client.disconnect(); + }); + + it("can call a bunch of methods", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + expect(await client.block()).toBeTruthy(); + expect(await client.genesis()).toBeTruthy(); + expect(await client.health()).toBeNull(); + + client.disconnect(); + }); + + describe("status", () => { + it("works", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const status = await client.status(); + + // node info + expect(status.nodeInfo.version).toEqual(expected.version); + expect(status.nodeInfo.protocolVersion).toEqual({ + p2p: expected.p2pVersion, + block: expected.blockVersion, + app: expected.appVersion, + }); + expect(status.nodeInfo.network).toMatch(chainIdMatcher); + expect(status.nodeInfo.other.size).toBeGreaterThanOrEqual(2); + expect(status.nodeInfo.other.get("tx_index")).toEqual("on"); + + // sync info + expect(status.syncInfo.catchingUp).toEqual(false); + expect(status.syncInfo.latestBlockHeight).toBeGreaterThanOrEqual(1); + + // validator info + expect(status.validatorInfo.pubkey).toBeTruthy(); + expect(status.validatorInfo.votingPower).toBeGreaterThan(0); + + client.disconnect(); + }); + }); + + describe("blockResults", () => { + it("works", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const height = 3; + const results = await client.blockResults(height); + expect(results.height).toEqual(height); + expect(results.results).toEqual([]); + expect(results.beginBlockEvents).toEqual([]); + expect(results.endBlockEvents).toEqual([]); + + client.disconnect(); + }); + }); + + describe("blockchain", () => { + it("returns latest in descending order by default", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + // Run in parallel to increase chance there is no block between the calls + const [status, blockchain] = await Promise.all([client.status(), client.blockchain()]); + const height = status.syncInfo.latestBlockHeight; + + expect(blockchain.lastHeight).toEqual(height); + expect(blockchain.blockMetas.length).toBeGreaterThanOrEqual(3); + expect(blockchain.blockMetas[0].header.height).toEqual(height); + expect(blockchain.blockMetas[1].header.height).toEqual(height - 1); + expect(blockchain.blockMetas[2].header.height).toEqual(height - 2); + + client.disconnect(); + }); + + it("can limit by maxHeight", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const height = (await client.status()).syncInfo.latestBlockHeight; + const blockchain = await client.blockchain(undefined, height - 1); + expect(blockchain.lastHeight).toEqual(height); + expect(blockchain.blockMetas.length).toBeGreaterThanOrEqual(2); + expect(blockchain.blockMetas[0].header.height).toEqual(height - 1); // upper limit included + expect(blockchain.blockMetas[1].header.height).toEqual(height - 2); + + client.disconnect(); + }); + + it("works with maxHeight in the future", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const height = (await client.status()).syncInfo.latestBlockHeight; + const blockchain = await client.blockchain(undefined, height + 20); + expect(blockchain.lastHeight).toEqual(height); + expect(blockchain.blockMetas.length).toBeGreaterThanOrEqual(2); + expect(blockchain.blockMetas[0].header.height).toEqual(height); + expect(blockchain.blockMetas[1].header.height).toEqual(height - 1); + + client.disconnect(); + }); + + it("can limit by minHeight and maxHeight", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const height = (await client.status()).syncInfo.latestBlockHeight; + const blockchain = await client.blockchain(height - 2, height - 1); + expect(blockchain.lastHeight).toEqual(height); + expect(blockchain.blockMetas.length).toEqual(2); + expect(blockchain.blockMetas[0].header.height).toEqual(height - 1); // upper limit included + expect(blockchain.blockMetas[1].header.height).toEqual(height - 2); // lower limit included + + client.disconnect(); + }); + + it("contains all the info", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const height = (await client.status()).syncInfo.latestBlockHeight; + const blockchain = await client.blockchain(height - 1, height - 1); + + expect(blockchain.lastHeight).toEqual(height); + expect(blockchain.blockMetas.length).toBeGreaterThanOrEqual(1); + const meta = blockchain.blockMetas[0]; + + // TODO: check all the fields + expect(meta).toEqual({ + blockId: jasmine.objectContaining({}), + // block_size: jasmine.stringMatching(nonNegativeIntegerMatcher), + // num_txs: jasmine.stringMatching(nonNegativeIntegerMatcher), + header: jasmine.objectContaining({ + version: { + block: expected.blockVersion, + app: expected.appVersion, + }, + chainId: jasmine.stringMatching(chainIdMatcher), + }), + }); + + client.disconnect(); + }); + }); + + describe("tx", () => { + it("can query a tx properly", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const find = randomString(); + const me = randomString(); + const tx = buildKvTx(find, me); + + const txRes = await client.broadcastTxCommit({ tx: tx }); + expect(responses.broadcastTxCommitSuccess(txRes)).toEqual(true); + expect(txRes.height).toBeTruthy(); + const height: number = txRes.height || 0; // || 0 for type system + expect(txRes.hash.length).not.toEqual(0); + const hash = txRes.hash; + + await tendermintSearchIndexUpdated(); + + // find by hash - does it match? + const r = await client.tx({ hash: hash, prove: true }); + // both values come from rpc, so same type (Buffer/Uint8Array) + expect(r.hash).toEqual(hash); + // force the type when comparing to locally generated value + expect(r.tx).toEqual(tx); + expect(r.height).toEqual(height); + expect(r.proof).toBeTruthy(); + + // txSearch - you must enable the indexer when running + // tendermint, else you get empty results + const query = buildQuery({ tags: [{ key: "app.key", value: find }] }); + + const s = await client.txSearch({ query: query, page: 1, per_page: 30 }); + // should find the tx + expect(s.totalCount).toEqual(1); + // should return same info as querying directly, + // except without the proof + expect(s.txs[0]).toEqual({ ...r, proof: undefined }); + + // ensure txSearchAll works as well + const sall = await client.txSearchAll({ query: query }); + // should find the tx + expect(sall.totalCount).toEqual(1); + // should return same info as querying directly, + // except without the proof + expect(sall.txs[0]).toEqual({ ...r, proof: undefined }); + + // and let's query the block itself to see this transaction + const block = await client.block(height); + expect(block.block.txs.length).toEqual(1); + expect(block.block.txs[0]).toEqual(tx); + + client.disconnect(); + }); + }); + + describe("txSearch", () => { + const key = randomString(); + + beforeAll(async () => { + if (tendermintEnabled()) { + const client = await Tendermint34Client.create(rpcFactory()); + + // eslint-disable-next-line no-inner-declarations + async function sendTx(): Promise { + const me = randomString(); + const tx = buildKvTx(key, me); + + const txRes = await client.broadcastTxCommit({ tx: tx }); + expect(responses.broadcastTxCommitSuccess(txRes)).toEqual(true); + expect(txRes.height).toBeTruthy(); + expect(txRes.hash.length).not.toEqual(0); + } + + // send 3 txs + await sendTx(); + await sendTx(); + await sendTx(); + + client.disconnect(); + + await tendermintSearchIndexUpdated(); + } + }); + + it("can paginate over txSearch results", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const query = buildQuery({ tags: [{ key: "app.key", value: key }] }); + + // expect one page of results + const s1 = await client.txSearch({ query: query, page: 1, per_page: 2 }); + expect(s1.totalCount).toEqual(3); + expect(s1.txs.length).toEqual(2); + + // second page + const s2 = await client.txSearch({ query: query, page: 2, per_page: 2 }); + expect(s2.totalCount).toEqual(3); + expect(s2.txs.length).toEqual(1); + + client.disconnect(); + }); + + it("can get all search results in one call", async () => { + pendingWithoutTendermint(); + const client = await Tendermint34Client.create(rpcFactory()); + + const query = buildQuery({ tags: [{ key: "app.key", value: key }] }); + + const sall = await client.txSearchAll({ query: query, per_page: 2 }); + expect(sall.totalCount).toEqual(3); + expect(sall.txs.length).toEqual(3); + // make sure there are in order from lowest to highest height + const [tx1, tx2, tx3] = sall.txs; + expect(tx2.height).toEqual(tx1.height + 1); + expect(tx3.height).toEqual(tx2.height + 1); + + client.disconnect(); + }); + }); +} + +function websocketTestSuite(rpcFactory: () => RpcClient, expected: ExpectedValues): void { + it("can subscribe to block header events", (done) => { + pendingWithoutTendermint(); + + const testStart = ReadonlyDate.now(); + + (async () => { + const events: responses.NewBlockHeaderEvent[] = []; + const client = await Tendermint34Client.create(rpcFactory()); + const stream = client.subscribeNewBlockHeader(); + expect(stream).toBeTruthy(); + const subscription = stream.subscribe({ + next: (event) => { + expect(event.chainId).toMatch(chainIdMatcher); + expect(event.height).toBeGreaterThan(0); + // seems that tendermint just guarantees within the last second for timestamp + expect(event.time.getTime()).toBeGreaterThan(testStart - 1000); + // Tendermint clock is sometimes ahead of test clock. Add 10ms tolerance + expect(event.time.getTime()).toBeLessThanOrEqual(ReadonlyDate.now() + 10); + expect(event.lastBlockId).toBeTruthy(); + + // merkle roots for proofs + expect(event.appHash).toBeTruthy(); + expect(event.consensusHash).toBeTruthy(); + expect(event.dataHash).toBeTruthy(); + expect(event.evidenceHash).toBeTruthy(); + expect(event.lastCommitHash).toBeTruthy(); + expect(event.lastResultsHash).toBeTruthy(); + expect(event.validatorsHash).toBeTruthy(); + + events.push(event); + + if (events.length === 2) { + subscription.unsubscribe(); + expect(events.length).toEqual(2); + expect(events[1].chainId).toEqual(events[0].chainId); + expect(events[1].height).toEqual(events[0].height + 1); + expect(events[1].time.getTime()).toBeGreaterThan(events[0].time.getTime()); + + expect(events[1].appHash).toEqual(events[0].appHash); + expect(events[1].consensusHash).toEqual(events[0].consensusHash); + expect(events[1].dataHash).toEqual(events[0].dataHash); + expect(events[1].evidenceHash).toEqual(events[0].evidenceHash); + expect(events[1].lastCommitHash).not.toEqual(events[0].lastCommitHash); + expect(events[1].lastResultsHash).not.toEqual(events[0].lastResultsHash); + expect(events[1].validatorsHash).toEqual(events[0].validatorsHash); + + client.disconnect(); + done(); + } + }, + error: done.fail, + complete: () => done.fail("Stream completed before we are done"), + }); + })().catch(done.fail); + }); + + it("can subscribe to block events", async () => { + pendingWithoutTendermint(); + + const testStart = ReadonlyDate.now(); + + const transactionData1 = buildKvTx(randomString(), randomString()); + const transactionData2 = buildKvTx(randomString(), randomString()); + + const events: responses.NewBlockEvent[] = []; + const client = await Tendermint34Client.create(rpcFactory()); + const stream = client.subscribeNewBlock(); + const subscription = stream.subscribe({ + next: (event) => { + expect(event.header.chainId).toMatch(chainIdMatcher); + expect(event.header.height).toBeGreaterThan(0); + // seems that tendermint just guarantees within the last second for timestamp + expect(event.header.time.getTime()).toBeGreaterThan(testStart - 1000); + // Tendermint clock is sometimes ahead of test clock. Add 10ms tolerance + expect(event.header.time.getTime()).toBeLessThanOrEqual(ReadonlyDate.now() + 10); + expect(event.header.lastBlockId).toBeTruthy(); + + // merkle roots for proofs + expect(event.header.appHash).toBeTruthy(); + expect(event.header.consensusHash).toBeTruthy(); + expect(event.header.dataHash).toBeTruthy(); + expect(event.header.evidenceHash).toBeTruthy(); + expect(event.header.lastCommitHash).toBeTruthy(); + expect(event.header.lastResultsHash).toBeTruthy(); + expect(event.header.validatorsHash).toBeTruthy(); + + events.push(event); + + if (events.length === 2) { + subscription.unsubscribe(); + } + }, + error: fail, + }); + + await client.broadcastTxCommit({ tx: transactionData1 }); + await client.broadcastTxCommit({ tx: transactionData2 }); + + // wait for events to be processed + await sleep(100); + + expect(events.length).toEqual(2); + // Block header + expect(events[1].header.height).toEqual(events[0].header.height + 1); + expect(events[1].header.chainId).toEqual(events[0].header.chainId); + expect(events[1].header.time.getTime()).toBeGreaterThan(events[0].header.time.getTime()); + expect(events[1].header.appHash).not.toEqual(events[0].header.appHash); + expect(events[1].header.validatorsHash).toEqual(events[0].header.validatorsHash); + // Block body + expect(events[0].txs.length).toEqual(1); + expect(events[1].txs.length).toEqual(1); + expect(events[0].txs[0]).toEqual(transactionData1); + expect(events[1].txs[0]).toEqual(transactionData2); + + client.disconnect(); + }); + + it("can subscribe to transaction events", async () => { + pendingWithoutTendermint(); + + const events: responses.TxEvent[] = []; + const client = await Tendermint34Client.create(rpcFactory()); + const stream = client.subscribeTx(); + const subscription = stream.subscribe({ + next: (event) => { + expect(event.height).toBeGreaterThan(0); + expect(event.result).toBeTruthy(); + expect(event.result.events.length).toBeGreaterThanOrEqual(1); + + events.push(event); + + if (events.length === 2) { + subscription.unsubscribe(); + } + }, + error: fail, + }); + + const transactionData1 = buildKvTx(randomString(), randomString()); + const transactionData2 = buildKvTx(randomString(), randomString()); + + await client.broadcastTxCommit({ tx: transactionData1 }); + await client.broadcastTxCommit({ tx: transactionData2 }); + + // wait for events to be processed + await sleep(100); + + expect(events.length).toEqual(2); + // Meta + expect(events[1].height).toEqual(events[0].height + 1); + expect(events[1].result.events).not.toEqual(events[0].result.events); + // Content + expect(events[0].tx).toEqual(transactionData1); + expect(events[1].tx).toEqual(transactionData2); + + client.disconnect(); + }); + + it("can subscribe to transaction events filtered by creator", async () => { + pendingWithoutTendermint(); + + const transactionData1 = buildKvTx(randomString(), randomString()); + const transactionData2 = buildKvTx(randomString(), randomString()); + + const events: responses.TxEvent[] = []; + const client = await Tendermint34Client.create(rpcFactory()); + const query = buildQuery({ tags: [{ key: "app.creator", value: expected.appCreator }] }); + const stream = client.subscribeTx(query); + expect(stream).toBeTruthy(); + const subscription = stream.subscribe({ + next: (event) => { + expect(event.height).toBeGreaterThan(0); + expect(event.result).toBeTruthy(); + expect(event.result.events.length).toBeGreaterThanOrEqual(1); + events.push(event); + + if (events.length === 2) { + subscription.unsubscribe(); + } + }, + error: fail, + }); + + await client.broadcastTxCommit({ tx: transactionData1 }); + await client.broadcastTxCommit({ tx: transactionData2 }); + + // wait for events to be processed + await sleep(100); + + expect(events.length).toEqual(2); + // Meta + expect(events[1].height).toEqual(events[0].height + 1); + expect(events[1].result.events).not.toEqual(events[0].result.events); + // Content + expect(events[0].tx).toEqual(transactionData1); + expect(events[1].tx).toEqual(transactionData2); + + client.disconnect(); + }); + + it("can unsubscribe and re-subscribe to the same stream", async () => { + pendingWithoutTendermint(); + + const client = await Tendermint34Client.create(rpcFactory()); + const stream = client.subscribeNewBlockHeader(); + + const event1 = await firstEvent(stream); + expect(event1.height).toBeGreaterThanOrEqual(1); + expect(event1.time.getTime()).toBeGreaterThanOrEqual(1); + + // No sleep: producer will not be stopped in the meantime + + const event2 = await firstEvent(stream); + expect(event2.height).toBeGreaterThan(event1.height); + expect(event2.time.getTime()).toBeGreaterThan(event1.time.getTime()); + + // Very short sleep: just enough to schedule asynchronous producer stopping + await sleep(5); + + const event3 = await firstEvent(stream); + expect(event3.height).toBeGreaterThan(event2.height); + expect(event3.time.getTime()).toBeGreaterThan(event2.time.getTime()); + + // Proper sleep: enough to finish unsubscribing at over the network + await sleep(100); + + const event4 = await firstEvent(stream); + expect(event4.height).toBeGreaterThan(event3.height); + expect(event4.time.getTime()).toBeGreaterThan(event3.time.getTime()); + + client.disconnect(); + }); + + it("can subscribe twice", async () => { + pendingWithoutTendermint(); + + const client = await Tendermint34Client.create(rpcFactory()); + const stream1 = client.subscribeNewBlockHeader(); + const stream2 = client.subscribeNewBlockHeader(); + + const events = await toListPromise(Stream.merge(stream1, stream2), 4); + + expect(new Set(events.map((e) => e.height)).size).toEqual(2); + + client.disconnect(); + }); +} + +describe("Tendermint34Client", () => { + const { url, expected } = tendermintInstances[1]; + + it("can connect to a given url", async () => { + pendingWithoutTendermint(); + + // default connection + { + const client = await Tendermint34Client.connect(url); + const info = await client.abciInfo(); + expect(info).toBeTruthy(); + client.disconnect(); + } + + // http connection + { + const client = await Tendermint34Client.connect("http://" + url); + const info = await client.abciInfo(); + expect(info).toBeTruthy(); + client.disconnect(); + } + + // ws connection + { + const client = await Tendermint34Client.connect("ws://" + url); + const info = await client.abciInfo(); + expect(info).toBeTruthy(); + client.disconnect(); + } + }); + + describe("With HttpClient", () => { + defaultTestSuite(() => new HttpClient(url), expected); + }); + + describe("With WebsocketClient", () => { + // don't print out WebSocket errors if marked pending + const onError = process.env.TENDERMINT_ENABLED ? console.error : () => 0; + const factory = (): WebsocketClient => new WebsocketClient(url, onError); + defaultTestSuite(factory, expected); + websocketTestSuite(factory, expected); + }); +}); diff --git a/packages/tendermint-rpc/src/tendermint34/tendermint34client.ts b/packages/tendermint-rpc/src/tendermint34/tendermint34client.ts new file mode 100644 index 00000000..42d20c4a --- /dev/null +++ b/packages/tendermint-rpc/src/tendermint34/tendermint34client.ts @@ -0,0 +1,312 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Stream } from "xstream"; + +import { createJsonRpcRequest } from "../jsonrpc"; +import { + HttpClient, + instanceOfRpcStreamingClient, + RpcClient, + SubscriptionEvent, + WebsocketClient, +} from "../rpcclients"; +import { Decoder, Encoder, Params, Responses } from "./adaptor"; +import { adaptor34 } from "./adaptors"; +import * as requests from "./requests"; +import * as responses from "./responses"; + +export class Tendermint34Client { + /** + * Creates a new Tendermint client for the given endpoint. + * + * Uses HTTP when the URL schema is http or https. Uses WebSockets otherwise. + * + * If the adaptor is not set an auto-detection is attempted. + */ + public static async connect(url: string): Promise { + const useHttp = url.startsWith("http://") || url.startsWith("https://"); + const rpcClient = useHttp ? new HttpClient(url) : new WebsocketClient(url); + return Tendermint34Client.create(rpcClient); + } + + /** + * Creates a new Tendermint client given an RPC client. + * + * If the adaptor is not set an auto-detection is attempted. + */ + public static async create(rpcClient: RpcClient): Promise { + // For some very strange reason I don't understand, tests start to fail on some systems + // (our CI) when skipping the status call before doing other queries. Sleeping a little + // while did not help. Thus we query the version as a way to say "hi" to the backend, + // even in cases where we don't use the result. + const _version = await this.detectVersion(rpcClient); + return new Tendermint34Client(rpcClient); + } + + private static async detectVersion(client: RpcClient): Promise { + const req = createJsonRpcRequest(requests.Method.Status); + const response = await client.execute(req); + const result = response.result; + + if (!result || !result.node_info) { + throw new Error("Unrecognized format for status response"); + } + + const version = result.node_info.version; + if (typeof version !== "string") { + throw new Error("Unrecognized version format: must be string"); + } + return version; + } + + private readonly client: RpcClient; + private readonly p: Params; + private readonly r: Responses; + + /** + * Use `Client.connect` or `Client.create` to create an instance. + */ + private constructor(client: RpcClient) { + this.client = client; + this.p = adaptor34.params; + this.r = adaptor34.responses; + } + + public disconnect(): void { + this.client.disconnect(); + } + + public async abciInfo(): Promise { + const query: requests.AbciInfoRequest = { method: requests.Method.AbciInfo }; + return this.doCall(query, this.p.encodeAbciInfo, this.r.decodeAbciInfo); + } + + public async abciQuery(params: requests.AbciQueryParams): Promise { + const query: requests.AbciQueryRequest = { params: params, method: requests.Method.AbciQuery }; + return this.doCall(query, this.p.encodeAbciQuery, this.r.decodeAbciQuery); + } + + public async block(height?: number): Promise { + const query: requests.BlockRequest = { method: requests.Method.Block, params: { height: height } }; + return this.doCall(query, this.p.encodeBlock, this.r.decodeBlock); + } + + public async blockResults(height?: number): Promise { + const query: requests.BlockResultsRequest = { + method: requests.Method.BlockResults, + params: { height: height }, + }; + return this.doCall(query, this.p.encodeBlockResults, this.r.decodeBlockResults); + } + + /** + * Queries block headers filtered by minHeight <= height <= maxHeight. + * + * @param minHeight The minimum height to be included in the result. Defaults to 0. + * @param maxHeight The maximum height to be included in the result. Defaults to infinity. + */ + public async blockchain(minHeight?: number, maxHeight?: number): Promise { + const query: requests.BlockchainRequest = { + method: requests.Method.Blockchain, + params: { + minHeight: minHeight, + maxHeight: maxHeight, + }, + }; + return this.doCall(query, this.p.encodeBlockchain, this.r.decodeBlockchain); + } + + /** + * Broadcast transaction to mempool and wait for response + * + * @see https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_sync + */ + public async broadcastTxSync( + params: requests.BroadcastTxParams, + ): Promise { + const query: requests.BroadcastTxRequest = { params: params, method: requests.Method.BroadcastTxSync }; + return this.doCall(query, this.p.encodeBroadcastTx, this.r.decodeBroadcastTxSync); + } + + /** + * Broadcast transaction to mempool and do not wait for result + * + * @see https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_async + */ + public async broadcastTxAsync( + params: requests.BroadcastTxParams, + ): Promise { + const query: requests.BroadcastTxRequest = { params: params, method: requests.Method.BroadcastTxAsync }; + return this.doCall(query, this.p.encodeBroadcastTx, this.r.decodeBroadcastTxAsync); + } + + /** + * Broadcast transaction to mempool and wait for block + * + * @see https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_commit + */ + public async broadcastTxCommit( + params: requests.BroadcastTxParams, + ): Promise { + const query: requests.BroadcastTxRequest = { params: params, method: requests.Method.BroadcastTxCommit }; + return this.doCall(query, this.p.encodeBroadcastTx, this.r.decodeBroadcastTxCommit); + } + + public async commit(height?: number): Promise { + const query: requests.CommitRequest = { method: requests.Method.Commit, params: { height: height } }; + return this.doCall(query, this.p.encodeCommit, this.r.decodeCommit); + } + + public async genesis(): Promise { + const query: requests.GenesisRequest = { method: requests.Method.Genesis }; + return this.doCall(query, this.p.encodeGenesis, this.r.decodeGenesis); + } + + public async health(): Promise { + const query: requests.HealthRequest = { method: requests.Method.Health }; + return this.doCall(query, this.p.encodeHealth, this.r.decodeHealth); + } + + public async status(): Promise { + const query: requests.StatusRequest = { method: requests.Method.Status }; + return this.doCall(query, this.p.encodeStatus, this.r.decodeStatus); + } + + public subscribeNewBlock(): Stream { + const request: requests.SubscribeRequest = { + method: requests.Method.Subscribe, + query: { type: requests.SubscriptionEventType.NewBlock }, + }; + return this.subscribe(request, this.r.decodeNewBlockEvent); + } + + public subscribeNewBlockHeader(): Stream { + const request: requests.SubscribeRequest = { + method: requests.Method.Subscribe, + query: { type: requests.SubscriptionEventType.NewBlockHeader }, + }; + return this.subscribe(request, this.r.decodeNewBlockHeaderEvent); + } + + public subscribeTx(query?: string): Stream { + const request: requests.SubscribeRequest = { + method: requests.Method.Subscribe, + query: { + type: requests.SubscriptionEventType.Tx, + raw: query, + }, + }; + return this.subscribe(request, this.r.decodeTxEvent); + } + + /** + * Get a single transaction by hash + * + * @see https://docs.tendermint.com/master/rpc/#/Info/tx + */ + public async tx(params: requests.TxParams): Promise { + const query: requests.TxRequest = { params: params, method: requests.Method.Tx }; + return this.doCall(query, this.p.encodeTx, this.r.decodeTx); + } + + /** + * Search for transactions that are in a block + * + * @see https://docs.tendermint.com/master/rpc/#/Info/tx_search + */ + public async txSearch(params: requests.TxSearchParams): Promise { + const query: requests.TxSearchRequest = { params: params, method: requests.Method.TxSearch }; + const resp = await this.doCall(query, this.p.encodeTxSearch, this.r.decodeTxSearch); + return { + ...resp, + // make sure we sort by height, as tendermint may be sorting by string value of the height + txs: [...resp.txs].sort((a, b) => a.height - b.height), + }; + } + + // this should paginate through all txSearch options to ensure it returns all results. + // starts with page 1 or whatever was provided (eg. to start on page 7) + public async txSearchAll(params: requests.TxSearchParams): Promise { + let page = params.page || 1; + const txs: responses.TxResponse[] = []; + let done = false; + + while (!done) { + const resp = await this.txSearch({ ...params, page: page }); + txs.push(...resp.txs); + if (txs.length < resp.totalCount) { + page++; + } else { + done = true; + } + } + // make sure we sort by height, as tendermint may be sorting by string value of the height + // and the earlier items may be in a higher page than the later items + txs.sort((a, b) => a.height - b.height); + + return { + totalCount: txs.length, + txs: txs, + }; + } + + public async validators(params: requests.ValidatorsParams): Promise { + const query: requests.ValidatorsRequest = { + method: requests.Method.Validators, + params: params, + }; + return this.doCall(query, this.p.encodeValidators, this.r.decodeValidators); + } + + public async validatorsAll(height?: number): Promise { + const validators: responses.Validator[] = []; + let page = 1; + let done = false; + let blockHeight = height; + + while (!done) { + const response = await this.validators({ + per_page: 50, + height: blockHeight, + page: page, + }); + validators.push(...response.validators); + blockHeight = blockHeight || response.blockHeight; + if (validators.length < response.total) { + page++; + } else { + done = true; + } + } + + return { + // NOTE: Default value is for type safety but this should always be set + blockHeight: blockHeight ?? 0, + count: validators.length, + total: validators.length, + validators: validators, + }; + } + + // doCall is a helper to handle the encode/call/decode logic + private async doCall( + request: T, + encode: Encoder, + decode: Decoder, + ): Promise { + const req = encode(request); + const result = await this.client.execute(req); + return decode(result); + } + + private subscribe(request: requests.SubscribeRequest, decode: (e: SubscriptionEvent) => T): Stream { + if (!instanceOfRpcStreamingClient(this.client)) { + throw new Error("This RPC client type cannot subscribe to events"); + } + + const req = this.p.encodeSubscribe(request); + const eventStream = this.client.listen(req); + return eventStream.map((event) => { + return decode(event); + }); + } +}