diff --git a/packages/launchpad/src/cosmosclient.ts b/packages/launchpad/src/cosmosclient.ts index a369f8b5..87d1a138 100644 --- a/packages/launchpad/src/cosmosclient.ts +++ b/packages/launchpad/src/cosmosclient.ts @@ -91,19 +91,19 @@ export type SearchTxQuery = | SearchBySentFromOrToQuery | SearchByTagsQuery; -function isSearchByIdQuery(query: SearchTxQuery): query is SearchByIdQuery { +export function isSearchByIdQuery(query: SearchTxQuery): query is SearchByIdQuery { return (query as SearchByIdQuery).id !== undefined; } -function isSearchByHeightQuery(query: SearchTxQuery): query is SearchByHeightQuery { +export function isSearchByHeightQuery(query: SearchTxQuery): query is SearchByHeightQuery { return (query as SearchByHeightQuery).height !== undefined; } -function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySentFromOrToQuery { +export function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySentFromOrToQuery { return (query as SearchBySentFromOrToQuery).sentFromOrTo !== undefined; } -function isSearchByTagsQuery(query: SearchTxQuery): query is SearchByTagsQuery { +export function isSearchByTagsQuery(query: SearchTxQuery): query is SearchByTagsQuery { return (query as SearchByTagsQuery).tags !== undefined; } diff --git a/packages/launchpad/src/index.ts b/packages/launchpad/src/index.ts index 6f912c8b..a457ceac 100644 --- a/packages/launchpad/src/index.ts +++ b/packages/launchpad/src/index.ts @@ -23,6 +23,10 @@ export { SearchByTagsQuery, SearchTxQuery, SearchTxFilter, + isSearchByHeightQuery, + isSearchByIdQuery, + isSearchBySentFromOrToQuery, + isSearchByTagsQuery, } from "./cosmosclient"; export { makeSignBytes } from "./encoding"; export { diff --git a/packages/launchpad/types/cosmosclient.d.ts b/packages/launchpad/types/cosmosclient.d.ts index 02577e85..67e817ef 100644 --- a/packages/launchpad/types/cosmosclient.d.ts +++ b/packages/launchpad/types/cosmosclient.d.ts @@ -61,6 +61,10 @@ export declare type SearchTxQuery = | SearchByHeightQuery | SearchBySentFromOrToQuery | SearchByTagsQuery; +export declare function isSearchByIdQuery(query: SearchTxQuery): query is SearchByIdQuery; +export declare function isSearchByHeightQuery(query: SearchTxQuery): query is SearchByHeightQuery; +export declare function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySentFromOrToQuery; +export declare function isSearchByTagsQuery(query: SearchTxQuery): query is SearchByTagsQuery; export interface SearchTxFilter { readonly minHeight?: number; readonly maxHeight?: number; diff --git a/packages/launchpad/types/index.d.ts b/packages/launchpad/types/index.d.ts index 3798f46b..f6bca7d8 100644 --- a/packages/launchpad/types/index.d.ts +++ b/packages/launchpad/types/index.d.ts @@ -21,6 +21,10 @@ export { SearchByTagsQuery, SearchTxQuery, SearchTxFilter, + isSearchByHeightQuery, + isSearchByIdQuery, + isSearchBySentFromOrToQuery, + isSearchByTagsQuery, } from "./cosmosclient"; export { makeSignBytes } from "./encoding"; export { diff --git a/packages/stargate/src/stargateclient.searchtx.spec.ts b/packages/stargate/src/stargateclient.searchtx.spec.ts new file mode 100644 index 00000000..1f10a41b --- /dev/null +++ b/packages/stargate/src/stargateclient.searchtx.spec.ts @@ -0,0 +1,254 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Bech32, fromBase64 } from "@cosmjs/encoding"; +import { Coin, coins, Secp256k1Wallet } from "@cosmjs/launchpad"; +import { makeSignBytes, omitDefaults, Registry } from "@cosmjs/proto-signing"; +import { assert } from "@cosmjs/utils"; + +import { cosmos } from "./generated/codecimpl"; +import { + BroadcastTxResponse, + isBroadcastTxFailure, + isBroadcastTxSuccess, + StargateClient, +} from "./stargateclient"; +import { faucet, makeRandomAddress, pendingWithoutSimapp, simapp, simappEnabled } from "./testutils.spec"; + +const { AuthInfo, SignDoc, Tx, TxBody } = cosmos.tx; +const { PublicKey } = cosmos.crypto; + +interface TestTxSend { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + readonly height: number; + readonly tx: Uint8Array; +} + +async function sendTokens( + client: StargateClient, + registry: Registry, + wallet: Secp256k1Wallet, + recipient: string, + amount: readonly Coin[], + memo: string, +): Promise<{ + readonly broadcastResponse: BroadcastTxResponse; + readonly tx: Uint8Array; +}> { + const [{ address: walletAddress, pubkey: pubkeyBytes }] = await wallet.getAccounts(); + const publicKey = PublicKey.create({ secp256k1: pubkeyBytes }); + const txBodyFields = { + typeUrl: "/cosmos.tx.TxBody", + value: { + messages: [ + { + typeUrl: "/cosmos.bank.MsgSend", + value: { + fromAddress: Bech32.decode(walletAddress).data, + toAddress: Bech32.decode(recipient).data, + amount: amount, + }, + }, + ], + memo: memo, + }, + }; + const txBodyBytes = registry.encode(txBodyFields); + const txBody = TxBody.decode(txBodyBytes); + + const authInfo = { + signerInfos: [ + { + publicKey: publicKey, + modeInfo: { + single: { + mode: cosmos.tx.signing.SignMode.SIGN_MODE_DIRECT, + }, + }, + }, + ], + fee: { + gasLimit: 200000, + }, + }; + const authInfoBytes = Uint8Array.from(AuthInfo.encode(authInfo).finish()); + + const { accountNumber, sequence } = (await client.getSequence(walletAddress))!; + const chainId = await client.getChainId(); + const signDoc = SignDoc.create( + omitDefaults({ + bodyBytes: txBodyBytes, + authInfoBytes: authInfoBytes, + chainId: chainId, + accountNumber: accountNumber, + accountSequence: sequence, + }), + ); + const signDocBytes = makeSignBytes(signDoc); + const signature = await wallet.sign(walletAddress, signDocBytes); + const txRaw = Tx.create({ + body: txBody, + authInfo: authInfo, + signatures: [fromBase64(signature.signature)], + }); + const txRawBytes = Uint8Array.from(Tx.encode(txRaw).finish()); + const broadcastResponse = await client.broadcastTx(txRawBytes); + return { + broadcastResponse: broadcastResponse, + tx: txRawBytes, + }; +} + +describe("StargateClient.searchTx", () => { + const registry = new Registry(); + + let sendUnsuccessful: TestTxSend | undefined; + let sendSuccessful: TestTxSend | undefined; + + beforeAll(async () => { + if (simappEnabled()) { + const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const client = await StargateClient.connect(simapp.tendermintUrl); + const unsuccessfulRecipient = makeRandomAddress(); + const successfulRecipient = makeRandomAddress(); + + const unsuccessfulResult = await sendTokens( + client, + registry, + wallet, + unsuccessfulRecipient, + coins(123456700000000, "ucosm"), + "Sending more than I can afford", + ); + if (isBroadcastTxFailure(unsuccessfulResult.broadcastResponse)) { + sendUnsuccessful = { + sender: faucet.address0, + recipient: unsuccessfulRecipient, + hash: unsuccessfulResult.broadcastResponse.transactionHash, + height: unsuccessfulResult.broadcastResponse.height, + tx: unsuccessfulResult.tx, + }; + } + const successfulResult = await sendTokens( + client, + registry, + wallet, + successfulRecipient, + coins(1234567, "ucosm"), + "Something I can afford", + ); + if (isBroadcastTxSuccess(successfulResult.broadcastResponse)) { + sendSuccessful = { + sender: faucet.address0, + recipient: successfulRecipient, + hash: successfulResult.broadcastResponse.transactionHash, + height: successfulResult.broadcastResponse.height, + tx: successfulResult.tx, + }; + } + } + }); + + describe("with SearchByIdQuery", () => { + it("can search successful tx by ID", async () => { + pendingWithoutSimapp(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = await StargateClient.connect(simapp.tendermintUrl); + const result = await client.searchTx({ id: sendSuccessful.hash }); + expect(result.length).toEqual(1); + expect(result[0]).toEqual( + jasmine.objectContaining({ + height: sendSuccessful.height, + hash: sendSuccessful.hash, + code: 0, + tx: sendSuccessful.tx, + }), + ); + }); + + it("can search unsuccessful tx by ID", async () => { + pendingWithoutSimapp(); + assert(sendUnsuccessful, "value must be set in beforeAll()"); + const client = await StargateClient.connect(simapp.tendermintUrl); + const result = await client.searchTx({ id: sendUnsuccessful.hash }); + expect(result.length).toEqual(1); + expect(result[0]).toEqual( + jasmine.objectContaining({ + height: sendUnsuccessful.height, + hash: sendUnsuccessful.hash, + code: 5, + tx: sendUnsuccessful.tx, + }), + ); + }); + + it("can search by ID (non existent)", async () => { + pendingWithoutSimapp(); + const client = await StargateClient.connect(simapp.tendermintUrl); + const nonExistentId = "0000000000000000000000000000000000000000000000000000000000000000"; + const result = await client.searchTx({ id: nonExistentId }); + expect(result.length).toEqual(0); + }); + + it("can search by ID and filter by minHeight", async () => { + pendingWithoutSimapp(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = await StargateClient.connect(simapp.tendermintUrl); + const query = { id: sendSuccessful.hash }; + + { + const result = await client.searchTx(query, { minHeight: 0 }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { minHeight: sendSuccessful.height - 1 }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { minHeight: sendSuccessful.height }); + expect(result.length).toEqual(1); + } + + { + const result = await client.searchTx(query, { minHeight: sendSuccessful.height + 1 }); + expect(result.length).toEqual(0); + } + }); + }); + + describe("with SearchByHeightQuery", () => { + it("can search successful tx by height", async () => { + pendingWithoutSimapp(); + assert(sendSuccessful, "value must be set in beforeAll()"); + const client = await StargateClient.connect(simapp.tendermintUrl); + const result = await client.searchTx({ height: sendSuccessful.height }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result).toContain( + jasmine.objectContaining({ + height: sendSuccessful.height, + hash: sendSuccessful.hash, + code: 0, + tx: sendSuccessful.tx, + }), + ); + }); + + it("can search unsuccessful tx by height", async () => { + pendingWithoutSimapp(); + assert(sendUnsuccessful, "value must be set in beforeAll()"); + const client = await StargateClient.connect(simapp.tendermintUrl); + const result = await client.searchTx({ height: sendUnsuccessful.height }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result).toContain( + jasmine.objectContaining({ + height: sendUnsuccessful.height, + hash: sendUnsuccessful.hash, + code: 5, + tx: sendUnsuccessful.tx, + }), + ); + }); + }); +}); diff --git a/packages/stargate/src/stargateclient.spec.ts b/packages/stargate/src/stargateclient.spec.ts index 8bb302d5..a4bf8c4b 100644 --- a/packages/stargate/src/stargateclient.spec.ts +++ b/packages/stargate/src/stargateclient.spec.ts @@ -147,7 +147,7 @@ describe("StargateClient", () => { header: jasmine.objectContaining({ chainId: await client.getChainId(), }), - txs: [], + txs: jasmine.arrayContaining([]), }), ); @@ -171,7 +171,7 @@ describe("StargateClient", () => { height: height - 1, chainId: await client.getChainId(), }), - txs: [], + txs: jasmine.arrayContaining([]), }), ); diff --git a/packages/stargate/src/stargateclient.ts b/packages/stargate/src/stargateclient.ts index cda62e28..75b18ec9 100644 --- a/packages/stargate/src/stargateclient.ts +++ b/packages/stargate/src/stargateclient.ts @@ -1,14 +1,34 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Bech32, toAscii, toHex } from "@cosmjs/encoding"; -import { Block, Coin, decodeAminoPubkey, PubKey } from "@cosmjs/launchpad"; +import { + Block, + Coin, + decodeAminoPubkey, + isSearchByHeightQuery, + isSearchByIdQuery, + PubKey, + SearchTxFilter, + SearchTxQuery, +} from "@cosmjs/launchpad"; import { Uint53, Uint64 } from "@cosmjs/math"; import { decodeAny } from "@cosmjs/proto-signing"; -import { broadcastTxCommitSuccess, Client as TendermintClient } from "@cosmjs/tendermint-rpc"; +import { broadcastTxCommitSuccess, Client as TendermintClient, QueryString } from "@cosmjs/tendermint-rpc"; import { arrayContentEquals, assert, assertDefined } from "@cosmjs/utils"; import Long from "long"; import { cosmos } from "./generated/codecimpl"; +/** A transaction that is indexed as part of the transaction history */ +export interface IndexedTx { + readonly height: number; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly hash: string; + /** Transaction execution error code. 0 on success. */ + readonly code: number; + readonly rawLog: string; + readonly tx: Uint8Array; +} + export interface Account { /** Bech32 account address */ readonly address: string; @@ -206,25 +226,57 @@ export class StargateClient { return response.balances.map(coinFromProto); } + public async searchTx(query: SearchTxQuery, filter: SearchTxFilter = {}): Promise { + const minHeight = filter.minHeight || 0; + const maxHeight = filter.maxHeight || Number.MAX_SAFE_INTEGER; + + if (maxHeight < minHeight) return []; // optional optimization + + let txs: readonly IndexedTx[]; + + if (isSearchByIdQuery(query)) { + txs = await this.txsQuery(`tx.hash='${query.id}'`); + } else if (isSearchByHeightQuery(query)) { + txs = + query.height >= minHeight && query.height <= maxHeight + ? await this.txsQuery(`tx.height=${query.height}`) + : []; + } else { + throw new Error("Unknown query type"); + } + + const filtered = txs.filter((tx) => tx.height >= minHeight && tx.height <= maxHeight); + return filtered; + } + public disconnect(): void { this.tmClient.disconnect(); } public async broadcastTx(tx: Uint8Array): Promise { const response = await this.tmClient.broadcastTxCommit({ tx }); - return broadcastTxCommitSuccess(response) + if (broadcastTxCommitSuccess(response)) { + return { + height: response.height, + transactionHash: toHex(response.hash).toUpperCase(), + rawLog: response.deliverTx?.log, + data: response.deliverTx?.data, + }; + } + return response.checkTx.code !== 0 ? { - height: response.height, - transactionHash: toHex(response.hash).toUpperCase(), - rawLog: response.deliverTx?.log, - data: response.deliverTx?.data, - } - : { height: response.height, code: response.checkTx.code, transactionHash: toHex(response.hash).toUpperCase(), rawLog: response.checkTx.log, data: response.checkTx.data, + } + : { + height: response.height, + code: response.deliverTx?.code, + transactionHash: toHex(response.hash).toUpperCase(), + rawLog: response.deliverTx?.log, + data: response.deliverTx?.data, }; } @@ -264,4 +316,20 @@ export class StargateClient { return response.value; } + + private async txsQuery(query: string): Promise { + const params = { + query: query as QueryString, + }; + const results = await this.tmClient.txSearchAll(params); + return results.txs.map((tx) => { + return { + height: tx.height, + hash: toHex(tx.hash).toUpperCase(), + code: tx.result.code, + rawLog: tx.result.log || "", + tx: tx.tx, + }; + }); + } } diff --git a/packages/stargate/src/testutils.spec.ts b/packages/stargate/src/testutils.spec.ts index efb85a5e..25d985b9 100644 --- a/packages/stargate/src/testutils.spec.ts +++ b/packages/stargate/src/testutils.spec.ts @@ -1,7 +1,12 @@ import { Random } from "@cosmjs/crypto"; +import { Bech32 } from "@cosmjs/encoding"; + +export function simappEnabled(): boolean { + return !!process.env.SIMAPP_ENABLED; +} export function pendingWithoutSimapp(): void { - if (!process.env.SIMAPP_ENABLED) { + if (!simappEnabled()) { return pending("Set SIMAPP_ENABLED to enable Simapp based tests"); } } @@ -10,6 +15,10 @@ export function makeRandomAddressBytes(): Uint8Array { return Random.getBytes(20); } +export function makeRandomAddress(): string { + return Bech32.encode("cosmos", makeRandomAddressBytes()); +} + export const simapp = { tendermintUrl: "localhost:26657", chainId: "simd-testing", diff --git a/packages/stargate/types/stargateclient.d.ts b/packages/stargate/types/stargateclient.d.ts index e3c01a43..d641352f 100644 --- a/packages/stargate/types/stargateclient.d.ts +++ b/packages/stargate/types/stargateclient.d.ts @@ -1,5 +1,15 @@ -import { Block, Coin, PubKey } from "@cosmjs/launchpad"; +import { Block, Coin, PubKey, SearchTxFilter, SearchTxQuery } from "@cosmjs/launchpad"; import { Client as TendermintClient } from "@cosmjs/tendermint-rpc"; +/** A transaction that is indexed as part of the transaction history */ +export interface IndexedTx { + readonly height: number; + /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ + readonly hash: string; + /** Transaction execution error code. 0 on success. */ + readonly code: number; + readonly rawLog: string; + readonly tx: Uint8Array; +} export interface Account { /** Bech32 account address */ readonly address: string; @@ -55,8 +65,10 @@ export declare class StargateClient { * proofs from such a method. */ getAllBalancesUnverified(address: string): Promise; + searchTx(query: SearchTxQuery, filter?: SearchTxFilter): Promise; disconnect(): void; broadcastTx(tx: Uint8Array): Promise; private queryVerified; private queryUnverified; + private txsQuery; }