diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 74c4f977..d1777a4a 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -4,6 +4,7 @@ import { Algorithm, ChainId, isBlockInfoPending, + isConfirmedTransaction, isFailedTransaction, isSendTransaction, PubkeyBytes, @@ -357,102 +358,63 @@ describe("CosmWasmConnection", () => { expect(blockInfo.state).toEqual(TransactionState.Succeeded); // search by id - - const idSearchResponse = await connection.searchTx({ id: transactionId }); - expect(idSearchResponse.length).toEqual(1); - - const idResult = idSearchResponse[0]; - expect(idResult.transactionId).toEqual(transactionId); - if (isFailedTransaction(idResult)) { - throw new Error("Expected transaction to succeed"); - } - assert(idResult.log, "Log must be available"); - const [firstIdlog] = JSON.parse(idResult.log); - expect(firstIdlog.events.length).toEqual(2); - - const { transaction: idTransaction } = idResult; - if (!isSendTransaction(idTransaction)) { - throw new Error("Expected send transaction"); - } - expect(idTransaction.kind).toEqual(unsigned.kind); - expect(idTransaction.sender).toEqual(unsigned.sender); - expect(idTransaction.recipient).toEqual(unsigned.recipient); - expect(idTransaction.memo).toEqual(unsigned.memo); - expect(idTransaction.amount).toEqual(unsigned.amount); + const byIdResults = await connection.searchTx({ id: transactionId }); + expect(byIdResults.length).toEqual(1); + const byIdResult = byIdResults[0]; + expect(byIdResult.transactionId).toEqual(transactionId); + assert(isConfirmedTransaction(byIdResult), "Expected transaction to succeed"); + assert(byIdResult.log, "Log must be available"); + const [firstByIdlog] = JSON.parse(byIdResult.log); + expect(firstByIdlog.events.length).toEqual(2); + expect(firstByIdlog.events[0].type).toEqual("message"); + expect(firstByIdlog.events[1].type).toEqual("transfer"); + const byIdTransaction = byIdResult.transaction; + assert(isSendTransaction(byIdTransaction), "Expected send transaction"); + expect(byIdTransaction).toEqual(unsigned); // search by sender address - - const senderAddressSearchResponse = await connection.searchTx({ sentFromOrTo: faucetAddress }); - expect(senderAddressSearchResponse).toBeTruthy(); - expect(senderAddressSearchResponse.length).toBeGreaterThanOrEqual(1); - - const senderAddressResult = senderAddressSearchResponse[senderAddressSearchResponse.length - 1]; - expect(senderAddressResult.transactionId).toEqual(transactionId); - if (isFailedTransaction(senderAddressResult)) { - throw new Error("Expected transaction to succeed"); - } - assert(senderAddressResult.log, "Log must be available"); - const [firstSenderLog] = JSON.parse(senderAddressResult.log); - expect(firstSenderLog.events.length).toEqual(2); - - const { transaction: senderAddressTransaction } = senderAddressResult; - if (!isSendTransaction(senderAddressTransaction)) { - throw new Error("Expected send transaction"); - } - expect(senderAddressTransaction.kind).toEqual(unsigned.kind); - expect(senderAddressTransaction.sender).toEqual(unsigned.sender); - expect(senderAddressTransaction.recipient).toEqual(unsigned.recipient); - expect(senderAddressTransaction.memo).toEqual(unsigned.memo); - expect(senderAddressTransaction.amount).toEqual(unsigned.amount); + const bySenderResults = await connection.searchTx({ sentFromOrTo: faucetAddress }); + expect(bySenderResults).toBeTruthy(); + expect(bySenderResults.length).toBeGreaterThanOrEqual(1); + const bySenderResult = bySenderResults[bySenderResults.length - 1]; + expect(bySenderResult.transactionId).toEqual(transactionId); + assert(isConfirmedTransaction(bySenderResult), "Expected transaction to succeed"); + assert(bySenderResult.log, "Log must be available"); + const [firstBySenderLog] = JSON.parse(bySenderResult.log); + expect(firstBySenderLog.events.length).toEqual(2); + expect(firstBySenderLog.events[0].type).toEqual("message"); + expect(firstBySenderLog.events[1].type).toEqual("transfer"); + const bySenderTransaction = bySenderResult.transaction; + assert(isSendTransaction(bySenderTransaction), "Expected send transaction"); + expect(bySenderTransaction).toEqual(unsigned); // search by recipient address - // TODO: Support searching by recipient - - // const recipientAddressSearchResponse = await connection.searchTx({ sentFromOrTo: defaultRecipient }); - // expect(recipientAddressSearchResponse).toBeTruthy(); - // expect(recipientAddressSearchResponse.length).toBeGreaterThanOrEqual(1); - - // const recipientAddressResult = - // recipientAddressSearchResponse[recipientAddressSearchResponse.length - 1]; - // expect(recipientAddressResult.transactionId).toEqual(transactionId); - // if (isFailedTransaction(recipientAddressResult)) { - // throw new Error("Expected transaction to succeed"); - // } - // expect(recipientAddressResult.log).toMatch(/success/i); - // const { transaction: recipientAddressTransaction } = recipientAddressResult; - // if (!isSendTransaction(recipientAddressTransaction)) { - // throw new Error("Expected send transaction"); - // } - // expect(recipientAddressTransaction.kind).toEqual(unsigned.kind); - // expect(recipientAddressTransaction.sender).toEqual(unsigned.sender); - // expect(recipientAddressTransaction.recipient).toEqual(unsigned.recipient); - // expect(recipientAddressTransaction.memo).toEqual(unsigned.memo); - // expect(recipientAddressTransaction.amount).toEqual(unsigned.amount); + const byRecipientResults = await connection.searchTx({ sentFromOrTo: defaultRecipient }); + expect(byRecipientResults.length).toBeGreaterThanOrEqual(1); + const byRecipientResult = byRecipientResults[byRecipientResults.length - 1]; + expect(byRecipientResult.transactionId).toEqual(transactionId); + assert(isConfirmedTransaction(byRecipientResult), "Expected transaction to succeed"); + assert(byRecipientResult.log, "Log must be available"); + const [firstByRecipientLog] = JSON.parse(bySenderResult.log); + expect(firstByRecipientLog.events.length).toEqual(2); + expect(firstByRecipientLog.events[0].type).toEqual("message"); + expect(firstByRecipientLog.events[1].type).toEqual("transfer"); + const byRecipeintTransaction = byRecipientResult.transaction; + assert(isSendTransaction(byRecipeintTransaction), "Expected send transaction"); + expect(byRecipeintTransaction).toEqual(unsigned); // search by height - - const heightSearchResponse = await connection.searchTx({ height: idResult.height }); - expect(heightSearchResponse).toBeTruthy(); - expect(heightSearchResponse.length).toEqual(1); - - const heightResult = heightSearchResponse[0]; + const heightResults = await connection.searchTx({ height: byIdResult.height }); + expect(heightResults.length).toEqual(1); + const heightResult = heightResults[0]; expect(heightResult.transactionId).toEqual(transactionId); - if (isFailedTransaction(heightResult)) { - throw new Error("Expected transaction to succeed"); - } + assert(isConfirmedTransaction(heightResult), "Expected transaction to succeed"); assert(heightResult.log, "Log must be available"); const [firstHeightLog] = JSON.parse(heightResult.log); expect(firstHeightLog.events.length).toEqual(2); - - const { transaction: heightTransaction } = heightResult; - if (!isSendTransaction(heightTransaction)) { - throw new Error("Expected send transaction"); - } - expect(heightTransaction.kind).toEqual(unsigned.kind); - expect(heightTransaction.sender).toEqual(unsigned.sender); - expect(heightTransaction.recipient).toEqual(unsigned.recipient); - expect(heightTransaction.memo).toEqual(unsigned.memo); - expect(heightTransaction.amount).toEqual(unsigned.amount); + const heightTransaction = heightResult.transaction; + assert(isSendTransaction(heightTransaction), "Expected send transaction"); + expect(heightTransaction).toEqual(unsigned); connection.disconnect(); }); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index 1f8affbf..09aea072 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -51,27 +51,6 @@ interface ChainData { // poll every 0.5 seconds (block time 1s) const defaultPollInterval = 500; -function buildQueryString({ - height, - id, - maxHeight, - minHeight, - sentFromOrTo, - signedBy, - tags, -}: TransactionQuery): string { - if ([maxHeight, minHeight, signedBy, tags].some(component => component !== undefined)) { - throw new Error("Transaction query by maxHeight, minHeight, signedBy or tags not yet supported"); - } - const heightComponent = height !== undefined ? `tx.height=${height}` : null; - const hashComponent = id !== undefined ? `tx.hash=${id}` : null; - const sentFromOrToComponent = sentFromOrTo !== undefined ? `message.sender=${sentFromOrTo}` : null; - // TODO: Support senders and recipients - // const sentFromOrToComponent = sentFromOrTo !== undefined ? `transfer.recipient=${sentFromOrTo}` : null; - const components: readonly (string | null)[] = [heightComponent, hashComponent, sentFromOrToComponent]; - return components.filter(Boolean).join("&"); -} - export interface TokenConfiguration { /** Supported tokens of the Cosmos SDK bank module */ readonly bankTokens: ReadonlyArray; @@ -79,6 +58,10 @@ export interface TokenConfiguration { readonly erc20Tokens?: ReadonlyArray; } +function isDefined(value: X | undefined): value is X { + return value !== undefined; +} + export class CosmWasmConnection implements BlockchainConnection { // we must know prefix and tokens a priori to understand the chain public static async establish( @@ -300,15 +283,43 @@ export class CosmWasmConnection implements BlockchainConnection { }; } - public async searchTx( - query: TransactionQuery, - ): Promise | FailedTransaction)[]> { - const queryString = buildQueryString(query); + public async searchTx({ + height, + id, + maxHeight, + minHeight, + sentFromOrTo, + signedBy, + tags, + }: TransactionQuery): Promise | FailedTransaction)[]> { + if ([signedBy, tags].some(isDefined)) { + throw new Error("Transaction query by signedBy or tags not yet supported"); + } + + if ([maxHeight, minHeight].some(isDefined)) { + throw new Error( + "Transaction query by minHeight/maxHeight not yet supported. This is due to missing flexibility of the Gaia REST API, see https://github.com/cosmos/gaia/issues/75", + ); + } + + if ([id, height, sentFromOrTo].filter(isDefined).length !== 1) { + throw new Error( + "Transaction query by id, height and sentFromOrTo is mutually exclusive. Exactly one must be set.", + ); + } + + let txs: readonly TxsResponse[]; + if (id) { + txs = await this.cosmWasmClient.searchTx({ id: id }); + } else if (height) { + txs = await this.cosmWasmClient.searchTx({ height: height }); + } else if (sentFromOrTo) { + txs = await this.cosmWasmClient.searchTx({ sentFromOrTo: sentFromOrTo }); + } else { + throw new Error("Unsupported query"); + } + const chainId = this.chainId(); - // TODO: we need pagination support - // tslint:disable-next-line: deprecation - const response = await this.restClient.txs(queryString + "&limit=50"); - const { txs } = response; return Promise.all(txs.map(tx => this.parseAndPopulateTxResponse(tx, chainId))); } diff --git a/packages/bcp/types/cosmwasmconnection.d.ts b/packages/bcp/types/cosmwasmconnection.d.ts index 66305e8c..e5afc441 100644 --- a/packages/bcp/types/cosmwasmconnection.d.ts +++ b/packages/bcp/types/cosmwasmconnection.d.ts @@ -72,9 +72,15 @@ export declare class CosmWasmConnection implements BlockchainConnection { watchBlockHeaders(): Stream; getTx(id: TransactionId): Promise | FailedTransaction>; postTx(tx: PostableBytes): Promise; - searchTx( - query: TransactionQuery, - ): Promise | FailedTransaction)[]>; + searchTx({ + height, + id, + maxHeight, + minHeight, + sentFromOrTo, + signedBy, + tags, + }: TransactionQuery): Promise | FailedTransaction)[]>; listenTx(_query: TransactionQuery): Stream | FailedTransaction>; liveTx(_query: TransactionQuery): Stream | FailedTransaction>; getFeeQuote(tx: UnsignedTransaction): Promise; diff --git a/packages/sdk/src/cosmwasmclient.spec.ts b/packages/sdk/src/cosmwasmclient.spec.ts index 80a5b9a2..69cb70b4 100644 --- a/packages/sdk/src/cosmwasmclient.spec.ts +++ b/packages/sdk/src/cosmwasmclient.spec.ts @@ -1,3 +1,5 @@ +import { assert } from "@iov/utils"; + import { CosmWasmClient } from "./cosmwasmclient"; import { makeSignBytes, marshalTx } from "./encoding"; import { findAttribute } from "./logs"; @@ -5,7 +7,7 @@ import { Secp256k1Pen } from "./pen"; import { RestClient } from "./restclient"; import cosmoshub from "./testdata/cosmoshub.json"; import { getRandomizedHackatom, makeRandomAddress } from "./testutils.spec"; -import { Coin, MsgSend, StdFee } from "./types"; +import { Coin, CosmosSdkTx, MsgSend, StdFee } from "./types"; const httpUrl = "http://localhost:1317"; @@ -118,6 +120,134 @@ describe("CosmWasmClient", () => { }); }); + describe("searchTx", () => { + let posted: + | { + readonly sender: string; + readonly recipient: string; + readonly hash: string; + readonly height: number; + readonly tx: CosmosSdkTx; + } + | undefined; + + beforeAll(async () => { + if (cosmosEnabled()) { + pendingWithoutCosmos(); + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = CosmWasmClient.makeReadOnly(httpUrl); + + const memo = "My first contract on chain"; + const sendMsg: MsgSend = { + type: "cosmos-sdk/MsgSend", + value: { + // eslint-disable-next-line @typescript-eslint/camelcase + from_address: faucet.address, + // eslint-disable-next-line @typescript-eslint/camelcase + to_address: makeRandomAddress(), + amount: [ + { + denom: "ucosm", + amount: "1234567", + }, + ], + }, + }; + + const fee: StdFee = { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "890000", + }; + + const chainId = await client.chainId(); + const { accountNumber, sequence } = await client.getNonce(faucet.address); + const signBytes = makeSignBytes([sendMsg], fee, chainId, memo, accountNumber, sequence); + const signature = await pen.sign(signBytes); + const signedTx = { + msg: [sendMsg], + fee: fee, + memo: memo, + signatures: [signature], + }; + + const result = await client.postTx(marshalTx(signedTx)); + const txDetails = await new RestClient(httpUrl).txsById(result.transactionHash); + posted = { + sender: sendMsg.value.from_address, + recipient: sendMsg.value.to_address, + hash: result.transactionHash, + height: Number.parseInt(txDetails.height, 10), + tx: txDetails.tx, + }; + } + }); + + it("can search by ID", async () => { + pendingWithoutCosmos(); + assert(posted, "value must be set in beforeAll()"); + const client = CosmWasmClient.makeReadOnly(httpUrl); + const result = await client.searchTx({ id: posted.hash }); + expect(result.length).toEqual(1); + expect(result[0]).toEqual( + jasmine.objectContaining({ + height: posted.height.toString(), + txhash: posted.hash, + tx: posted.tx, + }), + ); + }); + + it("can search by height", async () => { + pendingWithoutCosmos(); + assert(posted, "value must be set in beforeAll()"); + const client = CosmWasmClient.makeReadOnly(httpUrl); + const result = await client.searchTx({ height: posted.height }); + expect(result.length).toEqual(1); + expect(result[0]).toEqual( + jasmine.objectContaining({ + height: posted.height.toString(), + txhash: posted.hash, + tx: posted.tx, + }), + ); + }); + + it("can search by sender", async () => { + pendingWithoutCosmos(); + assert(posted, "value must be set in beforeAll()"); + const client = CosmWasmClient.makeReadOnly(httpUrl); + const result = await client.searchTx({ sentFromOrTo: posted.sender }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[result.length - 1]).toEqual( + jasmine.objectContaining({ + height: posted.height.toString(), + txhash: posted.hash, + tx: posted.tx, + }), + ); + }); + + it("can search by recipient", async () => { + pendingWithoutCosmos(); + assert(posted, "value must be set in beforeAll()"); + const client = CosmWasmClient.makeReadOnly(httpUrl); + const result = await client.searchTx({ sentFromOrTo: posted.recipient }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[result.length - 1]).toEqual( + jasmine.objectContaining({ + height: posted.height.toString(), + txhash: posted.hash, + tx: posted.tx, + }), + ); + }); + }); + describe("upload", () => { it("works", async () => { pendingWithoutCosmos(); diff --git a/packages/sdk/src/cosmwasmclient.ts b/packages/sdk/src/cosmwasmclient.ts index d0ff8a94..130f8949 100644 --- a/packages/sdk/src/cosmwasmclient.ts +++ b/packages/sdk/src/cosmwasmclient.ts @@ -3,7 +3,7 @@ import { Encoding } from "@iov/encoding"; import { makeSignBytes, marshalTx } from "./encoding"; import { findAttribute, Log, parseLogs } from "./logs"; -import { RestClient } from "./restclient"; +import { RestClient, TxsResponse } from "./restclient"; import { Coin, CosmosSdkTx, @@ -65,6 +65,32 @@ export interface PostTxResult { readonly transactionHash: string; } +export interface SearchByIdQuery { + readonly id: string; +} + +export interface SearchByHeightQuery { + readonly height: number; +} + +export interface SearchBySentFromOrToQuery { + readonly sentFromOrTo: string; +} + +export type SearchTxQuery = SearchByIdQuery | SearchByHeightQuery | SearchBySentFromOrToQuery; + +function isSearchByIdQuery(query: SearchTxQuery): query is SearchByIdQuery { + return (query as SearchByIdQuery).id !== undefined; +} + +function isSearchByHeightQuery(query: SearchTxQuery): query is SearchByHeightQuery { + return (query as SearchByHeightQuery).height !== undefined; +} + +function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySentFromOrToQuery { + return (query as SearchBySentFromOrToQuery).sentFromOrTo !== undefined; +} + export interface ExecuteResult { readonly logs: readonly Log[]; } @@ -131,6 +157,27 @@ export class CosmWasmClient { }; } + public async searchTx(query: SearchTxQuery): Promise { + // TODO: we need proper pagination support + function limited(originalQuery: string): string { + return `${originalQuery}&limit=75`; + } + + if (isSearchByIdQuery(query)) { + return [await this.restClient.txsById(query.id)]; + } else if (isSearchByHeightQuery(query)) { + return (await this.restClient.txs(`tx.height=${query.height}`)).txs; + } else if (isSearchBySentFromOrToQuery(query)) { + // We cannot get both in one request (see https://github.com/cosmos/gaia/issues/75) + const sent = (await this.restClient.txs(limited(`message.sender=${query.sentFromOrTo}`))).txs; + const received = (await this.restClient.txs(limited(`transfer.recipient=${query.sentFromOrTo}`))).txs; + const sentHashes = sent.map(t => t.txhash); + return [...sent, ...received.filter(t => !sentHashes.includes(t.txhash))]; + } else { + throw new Error("Unknown query type"); + } + } + public async postTx(tx: Uint8Array): Promise { const result = await this.restClient.postTx(tx); if (result.code) { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 294c81f8..e144a09e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -7,7 +7,16 @@ export { unmarshalTx } from "./decoding"; export { makeSignBytes, marshalTx } from "./encoding"; export { RestClient, TxsResponse } from "./restclient"; export { encodeSecp256k1Signature } from "./signature"; -export { CosmWasmClient, ExecuteResult, GetNonceResult, PostTxResult } from "./cosmwasmclient"; +export { + CosmWasmClient, + ExecuteResult, + GetNonceResult, + PostTxResult, + SearchByHeightQuery, + SearchByIdQuery, + SearchBySentFromOrToQuery, + SearchTxQuery, +} from "./cosmwasmclient"; export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen"; export { CosmosPubkeyBech32Prefix, diff --git a/packages/sdk/types/cosmwasmclient.d.ts b/packages/sdk/types/cosmwasmclient.d.ts index 4fae10bd..14be80a8 100644 --- a/packages/sdk/types/cosmwasmclient.d.ts +++ b/packages/sdk/types/cosmwasmclient.d.ts @@ -1,4 +1,5 @@ import { Log } from "./logs"; +import { TxsResponse } from "./restclient"; import { Coin, CosmosSdkTx, StdSignature } from "./types"; export interface SigningCallback { (signBytes: Uint8Array): Promise; @@ -13,6 +14,16 @@ export interface PostTxResult { /** Transaction hash (might be used as transaction ID). Guaranteed to be non-exmpty upper-case hex */ readonly transactionHash: string; } +export interface SearchByIdQuery { + readonly id: string; +} +export interface SearchByHeightQuery { + readonly height: number; +} +export interface SearchBySentFromOrToQuery { + readonly sentFromOrTo: string; +} +export declare type SearchTxQuery = SearchByIdQuery | SearchByHeightQuery | SearchBySentFromOrToQuery; export interface ExecuteResult { readonly logs: readonly Log[]; } @@ -35,6 +46,7 @@ export declare class CosmWasmClient { * @param address returns data for this address. When unset, the client's sender adddress is used. */ getNonce(address?: string): Promise; + searchTx(query: SearchTxQuery): Promise; postTx(tx: Uint8Array): Promise; /** Uploads code and returns a code ID */ upload(wasmCode: Uint8Array, memo?: string): Promise; diff --git a/packages/sdk/types/index.d.ts b/packages/sdk/types/index.d.ts index ec6bdaca..0a4c02d7 100644 --- a/packages/sdk/types/index.d.ts +++ b/packages/sdk/types/index.d.ts @@ -6,7 +6,16 @@ export { unmarshalTx } from "./decoding"; export { makeSignBytes, marshalTx } from "./encoding"; export { RestClient, TxsResponse } from "./restclient"; export { encodeSecp256k1Signature } from "./signature"; -export { CosmWasmClient, ExecuteResult, GetNonceResult, PostTxResult } from "./cosmwasmclient"; +export { + CosmWasmClient, + ExecuteResult, + GetNonceResult, + PostTxResult, + SearchByHeightQuery, + SearchByIdQuery, + SearchBySentFromOrToQuery, + SearchTxQuery, +} from "./cosmwasmclient"; export { makeCosmoshubPath, Pen, PrehashType, Secp256k1Pen } from "./pen"; export { CosmosPubkeyBech32Prefix,