From 2f41f93d967d1b9dc73c1fbacc35172c62f04c03 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 18 Feb 2020 10:36:58 +0100 Subject: [PATCH 1/5] Add minHeight/maxHeight support to searchTx --- packages/bcp/src/cosmwasmconnection.spec.ts | 211 +++++++++++++++++++- packages/bcp/src/cosmwasmconnection.ts | 26 ++- packages/bcp/types/cosmwasmconnection.d.ts | 4 +- 3 files changed, 229 insertions(+), 12 deletions(-) diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 6272c09a..269b08af 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -496,7 +496,7 @@ describe("CosmWasmConnection", () => { }); }); - describe("integration tests", () => { + describe("searchTx", () => { it("can post and search for a transaction", async () => { pendingWithoutWasmd(); const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); @@ -587,6 +587,215 @@ describe("CosmWasmConnection", () => { connection.disconnect(); }); + it("can search by minHeight and maxHeight", async () => { + pendingWithoutWasmd(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucet.mnemonic)); + const sender = await profile.createIdentity(wallet.id, defaultChainId, faucet.path); + const senderAddress = connection.codec.identityToAddress(sender); + + const recipient = makeRandomAddress(); + const unsigned = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + sender: senderAddress, + recipient: recipient, + memo: "My first payment", + amount: { + quantity: "75000", + fractionalDigits: 6, + tokenTicker: cosm, + }, + }); + const nonce = await connection.getNonce({ address: senderAddress }); + const signed = await profile.signTransaction(sender, unsigned, connection.codec, nonce); + const postableBytes = connection.codec.bytesToPost(signed); + const response = await connection.postTx(postableBytes); + const { transactionId } = response; + const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info)); + assert(isBlockInfoSucceeded(blockInfo)); + const { height } = blockInfo; + + // search by ID + { + const results = await connection.searchTx({ id: transactionId }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ id: transactionId, minHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ id: transactionId, minHeight: height - 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ id: transactionId, maxHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ id: transactionId, maxHeight: height + 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ + id: transactionId, + minHeight: height, + maxHeight: height, + }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ id: transactionId, minHeight: height + 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ id: transactionId, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + id: transactionId, + minHeight: height + 1, + maxHeight: Number.MAX_SAFE_INTEGER, + }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ id: transactionId, minHeight: 0, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + + // search by recipient + { + const results = await connection.searchTx({ sentFromOrTo: recipient }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, minHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, minHeight: height - 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, maxHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, maxHeight: height + 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, minHeight: height + 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + sentFromOrTo: recipient, + minHeight: height, + maxHeight: height, + }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, minHeight: height + 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ sentFromOrTo: recipient, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + sentFromOrTo: recipient, + minHeight: height + 1, + maxHeight: Number.MAX_SAFE_INTEGER, + }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + sentFromOrTo: recipient, + minHeight: 0, + maxHeight: height - 1, + }); + expect(results.length).toEqual(0); + } + + // search by height + { + const results = await connection.searchTx({ height: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, minHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, minHeight: height - 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, maxHeight: height }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, maxHeight: height + 2 }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, minHeight: height + 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ height: height, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + height: height, + minHeight: height, + maxHeight: height, + }); + expect(results.length).toEqual(1); + } + { + const results = await connection.searchTx({ height: height, minHeight: height + 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ height: height, maxHeight: height - 1 }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + height: height, + minHeight: height + 1, + maxHeight: Number.MAX_SAFE_INTEGER, + }); + expect(results.length).toEqual(0); + } + { + const results = await connection.searchTx({ + height: height, + minHeight: 0, + maxHeight: height - 1, + }); + expect(results.length).toEqual(0); + } + + connection.disconnect(); + }); + }); + + describe("integration tests", () => { it("can send ERC20 tokens", async () => { pendingWithoutWasmd(); const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index db8e0a67..d27d102c 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -304,8 +304,8 @@ export class CosmWasmConnection implements BlockchainConnection { public async searchTx({ height, id, - maxHeight, - minHeight, + maxHeight: maxHeightOptional, + minHeight: minHeightOptional, sentFromOrTo, signedBy, tags, @@ -314,30 +314,38 @@ export class CosmWasmConnection implements BlockchainConnection { 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.", ); } + const minHeight = minHeightOptional || 0; + const maxHeight = maxHeightOptional || Number.MAX_SAFE_INTEGER; + + if (maxHeight < minHeight) return []; // optional optimization + let txs: readonly TxsResponse[]; if (id) { txs = await this.cosmWasmClient.searchTx({ id: id }); } else if (height) { + if (height < minHeight) return []; // optional optimization + if (height > maxHeight) return []; // optional optimization txs = await this.cosmWasmClient.searchTx({ height: height }); } else if (sentFromOrTo) { + // TODO: pass minHeight/maxHeight to server once we have + // https://github.com/cosmwasm/wasmd/issues/73 txs = await this.cosmWasmClient.searchTx({ sentFromOrTo: sentFromOrTo }); } else { throw new Error("Unsupported query"); } - return txs.map(tx => this.parseAndPopulateTxResponseUnsigned(tx)); + const filtered = txs.filter(tx => { + const txHeight = parseInt(tx.height, 10); + return txHeight >= minHeight && txHeight <= maxHeight; + }); + + return filtered.map(tx => this.parseAndPopulateTxResponseUnsigned(tx)); } public listenTx( diff --git a/packages/bcp/types/cosmwasmconnection.d.ts b/packages/bcp/types/cosmwasmconnection.d.ts index 1f4ca33e..a141324d 100644 --- a/packages/bcp/types/cosmwasmconnection.d.ts +++ b/packages/bcp/types/cosmwasmconnection.d.ts @@ -74,8 +74,8 @@ export declare class CosmWasmConnection implements BlockchainConnection { searchTx({ height, id, - maxHeight, - minHeight, + maxHeight: maxHeightOptional, + minHeight: minHeightOptional, sentFromOrTo, signedBy, tags, From a9c605de51bfd3a7c01f7d371fb257fe27a8109c Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 18 Feb 2020 11:23:41 +0100 Subject: [PATCH 2/5] Throw early if transaction fee is missing --- packages/bcp/src/encode.spec.ts | 30 +++--------------------------- packages/bcp/src/encode.ts | 16 ++++------------ 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/packages/bcp/src/encode.spec.ts b/packages/bcp/src/encode.spec.ts index 5c63bfe9..b0738993 100644 --- a/packages/bcp/src/encode.spec.ts +++ b/packages/bcp/src/encode.spec.ts @@ -230,7 +230,8 @@ describe("encode", () => { ); }); - it("builds a send transaction without fee", () => { + it("throws for a send transaction without fee", () => { + // This will be rejected by the REST server. Better throw early to avoid hard to debug errors. const tx = { kind: "bcp/send", chainId: defaultChainId, @@ -239,32 +240,7 @@ describe("encode", () => { recipient: defaultRecipient, memo: defaultMemo, }; - expect(buildUnsignedTx(tx, defaultTokens)).toEqual({ - type: "cosmos-sdk/StdTx", - value: { - msg: [ - { - type: "cosmos-sdk/MsgSend", - value: { - from_address: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", - to_address: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", - amount: [ - { - denom: "uatom", - amount: "11657995", - }, - ], - }, - }, - ], - signatures: [], - memo: defaultMemo, - fee: { - amount: [], - gas: "", - }, - }, - }); + expect(() => buildUnsignedTx(tx, defaultTokens)).toThrowError(/transaction fee must be set/i); }); it("builds a send transaction with fee", () => { diff --git a/packages/bcp/src/encode.ts b/packages/bcp/src/encode.ts index 49056033..85a83e31 100644 --- a/packages/bcp/src/encode.ts +++ b/packages/bcp/src/encode.ts @@ -91,6 +91,8 @@ export function buildUnsignedTx( const matchingBankToken = bankTokens.find(t => t.ticker === tx.amount.tokenTicker); const matchingErc20Token = erc20Tokens.find(t => t.ticker === tx.amount.tokenTicker); + if (!tx.fee) throw new Error("Transaction fee must be set"); + if (matchingBankToken) { return { type: "cosmos-sdk/StdTx", @@ -107,12 +109,7 @@ export function buildUnsignedTx( ], memo: tx.memo || "", signatures: [], - fee: tx.fee - ? encodeFee(tx.fee, bankTokens) - : { - amount: [], - gas: "", - }, + fee: encodeFee(tx.fee, bankTokens), }, }; } else if (matchingErc20Token) { @@ -137,12 +134,7 @@ export function buildUnsignedTx( ], memo: tx.memo || "", signatures: [], - fee: tx.fee - ? encodeFee(tx.fee, bankTokens) - : { - amount: [], - gas: "", - }, + fee: encodeFee(tx.fee, bankTokens), }, }; } else { From af2bfbef1cbc37ee8a3bdc6f0e22ca962ab456bd Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 18 Feb 2020 12:08:11 +0100 Subject: [PATCH 3/5] Expose BroadcastMode in CosmWasmClient --- packages/sdk/src/cosmwasmclient.ts | 6 +++--- packages/sdk/src/index.ts | 2 +- packages/sdk/src/restclient.ts | 18 ++++++++++++++---- packages/sdk/src/signingcosmwasmclient.ts | 4 +++- packages/sdk/types/cosmwasmclient.d.ts | 4 ++-- packages/sdk/types/index.d.ts | 2 +- packages/sdk/types/restclient.d.ts | 14 +++++++++++++- packages/sdk/types/signingcosmwasmclient.d.ts | 2 ++ 8 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/cosmwasmclient.ts b/packages/sdk/src/cosmwasmclient.ts index abf0ff21..3bf21e7b 100644 --- a/packages/sdk/src/cosmwasmclient.ts +++ b/packages/sdk/src/cosmwasmclient.ts @@ -2,7 +2,7 @@ import { Sha256 } from "@iov/crypto"; import { Encoding } from "@iov/encoding"; import { Log, parseLogs } from "./logs"; -import { BlockResponse, RestClient, TxsResponse } from "./restclient"; +import { BlockResponse, BroadcastMode, RestClient, TxsResponse } from "./restclient"; import { CosmosSdkAccount, CosmosSdkTx } from "./types"; export interface GetNonceResult { @@ -46,8 +46,8 @@ function isSearchBySentFromOrToQuery(query: SearchTxQuery): query is SearchBySen export class CosmWasmClient { protected readonly restClient: RestClient; - public constructor(url: string) { - this.restClient = new RestClient(url); + public constructor(url: string, broadcastMode = BroadcastMode.Block) { + this.restClient = new RestClient(url, broadcastMode); } public async chainId(): Promise { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 371c4cbe..2a269008 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,7 +5,7 @@ export { logs, types }; export { CosmosAddressBech32Prefix, encodeAddress, isValidAddress } from "./address"; export { unmarshalTx } from "./decoding"; export { makeSignBytes, marshalTx } from "./encoding"; -export { RestClient, TxsResponse } from "./restclient"; +export { BroadcastMode, RestClient, TxsResponse } from "./restclient"; export { CosmWasmClient, GetNonceResult, diff --git a/packages/sdk/src/restclient.ts b/packages/sdk/src/restclient.ts index 3abdab3b..476dc85a 100644 --- a/packages/sdk/src/restclient.ts +++ b/packages/sdk/src/restclient.ts @@ -152,7 +152,19 @@ type RestClientResponse = | WasmResponse | WasmResponse; -type BroadcastMode = "block" | "sync" | "async"; +/** + * The mode used to send transaction + * + * @see https://cosmos.network/rpc/#/Transactions/post_txs + */ +export enum BroadcastMode { + /** Return after tx commit */ + Block = "block", + /** Return afer CheckTx */ + Sync = "sync", + /** Return right away */ + Async = "async", +} function isWasmError(resp: WasmResponse): resp is WasmError { return (resp as WasmError).error !== undefined; @@ -194,11 +206,9 @@ function parseAxios500error(err: AxiosError): never { export class RestClient { private readonly client: AxiosInstance; - // From https://cosmos.network/rpc/#/ICS0/post_txs - // The supported broadcast modes include "block"(return after tx commit), "sync"(return afer CheckTx) and "async"(return right away). private readonly mode: BroadcastMode; - public constructor(url: string, mode: BroadcastMode = "block") { + public constructor(url: string, mode = BroadcastMode.Block) { const headers = { post: { "Content-Type": "application/json" }, }; diff --git a/packages/sdk/src/signingcosmwasmclient.ts b/packages/sdk/src/signingcosmwasmclient.ts index ca56e863..3e2c888a 100644 --- a/packages/sdk/src/signingcosmwasmclient.ts +++ b/packages/sdk/src/signingcosmwasmclient.ts @@ -3,6 +3,7 @@ import { Encoding } from "@iov/encoding"; import { CosmWasmClient, GetNonceResult, PostTxResult } from "./cosmwasmclient"; import { makeSignBytes, marshalTx } from "./encoding"; import { findAttribute, Log } from "./logs"; +import { BroadcastMode } from "./restclient"; import { Coin, CosmosSdkAccount, @@ -63,8 +64,9 @@ export class SigningCosmWasmClient extends CosmWasmClient { senderAddress: string, signCallback: SigningCallback, customFees?: Partial, + broadcastMode = BroadcastMode.Block, ) { - super(url); + super(url, broadcastMode); this.senderAddress = senderAddress; this.signCallback = signCallback; this.fees = { ...defaultFees, ...(customFees || {}) }; diff --git a/packages/sdk/types/cosmwasmclient.d.ts b/packages/sdk/types/cosmwasmclient.d.ts index c9f93543..b7d0f3bf 100644 --- a/packages/sdk/types/cosmwasmclient.d.ts +++ b/packages/sdk/types/cosmwasmclient.d.ts @@ -1,5 +1,5 @@ import { Log } from "./logs"; -import { BlockResponse, RestClient, TxsResponse } from "./restclient"; +import { BlockResponse, BroadcastMode, RestClient, TxsResponse } from "./restclient"; import { CosmosSdkAccount, CosmosSdkTx } from "./types"; export interface GetNonceResult { readonly accountNumber: number; @@ -23,7 +23,7 @@ export interface SearchBySentFromOrToQuery { export declare type SearchTxQuery = SearchByIdQuery | SearchByHeightQuery | SearchBySentFromOrToQuery; export declare class CosmWasmClient { protected readonly restClient: RestClient; - constructor(url: string); + constructor(url: string, broadcastMode?: BroadcastMode); chainId(): Promise; /** * Returns a 32 byte upper-case hex transaction hash (typically used as the transaction ID) diff --git a/packages/sdk/types/index.d.ts b/packages/sdk/types/index.d.ts index 4b2c4d8d..ffe33888 100644 --- a/packages/sdk/types/index.d.ts +++ b/packages/sdk/types/index.d.ts @@ -4,7 +4,7 @@ export { logs, types }; export { CosmosAddressBech32Prefix, encodeAddress, isValidAddress } from "./address"; export { unmarshalTx } from "./decoding"; export { makeSignBytes, marshalTx } from "./encoding"; -export { RestClient, TxsResponse } from "./restclient"; +export { BroadcastMode, RestClient, TxsResponse } from "./restclient"; export { CosmWasmClient, GetNonceResult, diff --git a/packages/sdk/types/restclient.d.ts b/packages/sdk/types/restclient.d.ts index b200337a..d629c7bc 100644 --- a/packages/sdk/types/restclient.d.ts +++ b/packages/sdk/types/restclient.d.ts @@ -105,7 +105,19 @@ declare type RestClientResponse = | EncodeTxResponse | WasmResponse | WasmResponse; -declare type BroadcastMode = "block" | "sync" | "async"; +/** + * The mode used to send transaction + * + * @see https://cosmos.network/rpc/#/Transactions/post_txs + */ +export declare enum BroadcastMode { + /** Return after tx commit */ + Block = "block", + /** Return afer CheckTx */ + Sync = "sync", + /** Return right away */ + Async = "async", +} export declare class RestClient { private readonly client; private readonly mode; diff --git a/packages/sdk/types/signingcosmwasmclient.d.ts b/packages/sdk/types/signingcosmwasmclient.d.ts index 0393683c..70a20ee6 100644 --- a/packages/sdk/types/signingcosmwasmclient.d.ts +++ b/packages/sdk/types/signingcosmwasmclient.d.ts @@ -1,5 +1,6 @@ import { CosmWasmClient, GetNonceResult, PostTxResult } from "./cosmwasmclient"; import { Log } from "./logs"; +import { BroadcastMode } from "./restclient"; import { Coin, CosmosSdkAccount, StdFee, StdSignature } from "./types"; export interface SigningCallback { (signBytes: Uint8Array): Promise; @@ -22,6 +23,7 @@ export declare class SigningCosmWasmClient extends CosmWasmClient { senderAddress: string, signCallback: SigningCallback, customFees?: Partial, + broadcastMode?: BroadcastMode, ); getNonce(address?: string): Promise; getAccount(address?: string): Promise; From 3f7567bdb94d6ab03b84771bb4cf943078be746f Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 18 Feb 2020 12:28:32 +0100 Subject: [PATCH 4/5] Don't call parseLogs if result.logs is falsy --- packages/sdk/src/cosmwasmclient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/cosmwasmclient.ts b/packages/sdk/src/cosmwasmclient.ts index 3bf21e7b..97de555a 100644 --- a/packages/sdk/src/cosmwasmclient.ts +++ b/packages/sdk/src/cosmwasmclient.ts @@ -136,7 +136,7 @@ export class CosmWasmClient { } return { - logs: parseLogs(result.logs) || [], + logs: result.logs ? parseLogs(result.logs) : [], rawLog: result.raw_log || "", transactionHash: result.txhash, }; From 1f0b6845c83d8f16e831cb9c185043e1f2b11113 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 18 Feb 2020 12:48:09 +0100 Subject: [PATCH 5/5] Implement liveTx --- packages/bcp/src/cosmwasmconnection.spec.ts | 186 ++++++++++++++++++++ packages/bcp/src/cosmwasmconnection.ts | 91 +++++++++- packages/bcp/types/cosmwasmconnection.d.ts | 3 +- 3 files changed, 276 insertions(+), 4 deletions(-) diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 269b08af..c02fc489 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -5,6 +5,7 @@ import { Algorithm, Amount, ChainId, + ConfirmedTransaction, isBlockInfoPending, isBlockInfoSucceeded, isConfirmedTransaction, @@ -14,6 +15,7 @@ import { TokenTicker, TransactionId, TransactionState, + UnsignedTransaction, } from "@iov/bcp"; import { Random, Secp256k1, Secp256k1Signature, Sha256 } from "@iov/crypto"; import { Bech32, Encoding } from "@iov/encoding"; @@ -795,6 +797,190 @@ describe("CosmWasmConnection", () => { }); }); + describe("liveTx", () => { + it("can listen to transactions by recipient address (transactions in history and updates)", done => { + pendingWithoutWasmd(); + + (async () => { + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucet.mnemonic)); + const sender = await profile.createIdentity(wallet.id, defaultChainId, faucet.path); + + // send transactions + + const recipientAddress = makeRandomAddress(); + const sendA = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + senderPubkey: sender.pubkey, + sender: connection.codec.identityToAddress(sender), + recipient: recipientAddress, + amount: defaultAmount, + memo: `liveTx() test A ${Math.random()}`, + }); + + const sendB = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + senderPubkey: sender.pubkey, + sender: connection.codec.identityToAddress(sender), + recipient: recipientAddress, + amount: defaultAmount, + memo: `liveTx() test B ${Math.random()}`, + }); + + const sendC = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + senderPubkey: sender.pubkey, + sender: connection.codec.identityToAddress(sender), + recipient: recipientAddress, + amount: defaultAmount, + memo: `liveTx() test C ${Math.random()}`, + }); + + const [nonceA, nonceB, nonceC] = await connection.getNonces({ pubkey: sender.pubkey }, 3); + const signedA = await profile.signTransaction(sender, sendA, connection.codec, nonceA); + const signedB = await profile.signTransaction(sender, sendB, connection.codec, nonceB); + const signedC = await profile.signTransaction(sender, sendC, connection.codec, nonceC); + const bytesToPostA = connection.codec.bytesToPost(signedA); + const bytesToPostB = connection.codec.bytesToPost(signedB); + const bytesToPostC = connection.codec.bytesToPost(signedC); + + // Post A and B. Unfortunately the REST server API does not support sending them in parallel because the sequence check fails. + const postResultA = await connection.postTx(bytesToPostA); + await postResultA.blockInfo.waitFor(info => !isBlockInfoPending(info)); + const postResultB = await connection.postTx(bytesToPostB); + await postResultB.blockInfo.waitFor(info => !isBlockInfoPending(info)); + + // setup listener after A and B are in block + const events = new Array>(); + const subscription = connection.liveTx({ sentFromOrTo: recipientAddress }).subscribe({ + next: event => { + assert(isConfirmedTransaction(event), "Confirmed transaction expected"); + events.push(event); + + assert(isSendTransaction(event.transaction), "Unexpected transaction type"); + expect(event.transaction.recipient).toEqual(recipientAddress); + + if (events.length === 3) { + expect(events[1].height).toEqual(events[0].height + 1); + expect(events[2].height).toBeGreaterThan(events[1].height); + + subscription.unsubscribe(); + connection.disconnect(); + done(); + } + }, + }); + + // Post C + await connection.postTx(bytesToPostC); + })().catch(done.fail); + }); + + it("can listen to transactions by ID (transaction in history)", done => { + pendingWithoutWasmd(); + + (async () => { + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucet.mnemonic)); + const sender = await profile.createIdentity(wallet.id, defaultChainId, faucet.path); + + const recipientAddress = makeRandomAddress(); + const send = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + senderPubkey: sender.pubkey, + sender: connection.codec.identityToAddress(sender), + recipient: recipientAddress, + amount: defaultAmount, + memo: `liveTx() test ${Math.random()}`, + }); + + const nonce = await connection.getNonce({ pubkey: sender.pubkey }); + const signed = await profile.signTransaction(sender, send, connection.codec, nonce); + const bytesToPost = connection.codec.bytesToPost(signed); + + const postResult = await connection.postTx(bytesToPost); + const transactionId = postResult.transactionId; + + // Wait for a block + await postResult.blockInfo.waitFor(info => !isBlockInfoPending(info)); + + // setup listener after transaction is in block + const events = new Array>(); + const subscription = connection.liveTx({ id: transactionId }).subscribe({ + next: event => { + assert(isConfirmedTransaction(event), "Confirmed transaction expected"); + events.push(event); + + assert(isSendTransaction(event.transaction), "Unexpected transaction type"); + expect(event.transaction.recipient).toEqual(recipientAddress); + expect(event.transactionId).toEqual(transactionId); + + subscription.unsubscribe(); + connection.disconnect(); + done(); + }, + }); + })().catch(done.fail); + }); + + it("can listen to transactions by ID (transaction in updates)", done => { + pendingWithoutWasmd(); + + (async () => { + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucet.mnemonic)); + const sender = await profile.createIdentity(wallet.id, defaultChainId, faucet.path); + + // send transactions + + const recipientAddress = makeRandomAddress(); + const send = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + senderPubkey: sender.pubkey, + sender: connection.codec.identityToAddress(sender), + recipient: recipientAddress, + amount: defaultAmount, + memo: `liveTx() test ${Math.random()}`, + }); + + const nonce = await connection.getNonce({ pubkey: sender.pubkey }); + const signed = await profile.signTransaction(sender, send, connection.codec, nonce); + const bytesToPost = connection.codec.bytesToPost(signed); + + const postResult = await connection.postTx(bytesToPost); + const transactionId = postResult.transactionId; + + // setup listener before transaction is in block + const events = new Array>(); + const subscription = connection.liveTx({ id: transactionId }).subscribe({ + next: event => { + assert(isConfirmedTransaction(event), "Confirmed transaction expected"); + events.push(event); + + assert(isSendTransaction(event.transaction), "Unexpected transaction type"); + expect(event.transaction.recipient).toEqual(recipientAddress); + expect(event.transactionId).toEqual(transactionId); + + subscription.unsubscribe(); + connection.disconnect(); + done(); + }, + }); + })().catch(done.fail); + }); + }); + describe("integration tests", () => { it("can send ERC20 tokens", async () => { pendingWithoutWasmd(); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index d27d102c..c0b57339 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -37,7 +37,7 @@ import { UnsignedTransaction, } from "@iov/bcp"; import { Encoding, Uint53 } from "@iov/encoding"; -import { DefaultValueProducer, ValueAndUpdates } from "@iov/stream"; +import { concat, DefaultValueProducer, ValueAndUpdates } from "@iov/stream"; import BN from "bn.js"; import equal from "fast-deep-equal"; import { ReadonlyDate } from "readonly-date"; @@ -355,9 +355,55 @@ export class CosmWasmConnection implements BlockchainConnection { } public liveTx( - _query: TransactionQuery, + query: TransactionQuery, ): Stream | FailedTransaction> { - throw new Error("not implemented"); + if ([query.height, query.signedBy, query.tags].some(isDefined)) { + throw new Error("Transaction query by height, signedBy or tags not yet supported"); + } + + if (query.id) { + if (query.minHeight || query.maxHeight) { + throw new Error("Query by minHeight/maxHeight not supported together with ID"); + } + + // concat never() because we want non-completing streams consistently + return concat(this.waitForTransaction(query.id), Stream.never()); + } else if (query.sentFromOrTo) { + let pollInternal: NodeJS.Timeout | undefined; + const producer: Producer | FailedTransaction> = { + start: async listener => { + let minHeight = query.minHeight || 0; + const maxHeight = query.maxHeight || Number.MAX_SAFE_INTEGER; + + const poll = async (): Promise => { + const result = await this.searchTx({ + sentFromOrTo: query.sentFromOrTo, + minHeight: minHeight, + maxHeight: maxHeight, + }); + for (const item of result) { + listener.next(item); + if (item.height >= minHeight) { + // we assume we got all matching transactions from block `item.height` now + minHeight = item.height + 1; + } + } + }; + + await poll(); + pollInternal = setInterval(poll, defaultPollInterval); + }, + stop: () => { + if (pollInternal) { + clearInterval(pollInternal); + pollInternal = undefined; + } + }, + }; + return Stream.create(producer); + } else { + throw new Error("Unsupported query."); + } } public async getFeeQuote(tx: UnsignedTransaction): Promise { @@ -433,4 +479,43 @@ export class CosmWasmConnection implements BlockchainConnection { this.erc20Tokens, ); } + + private waitForTransaction( + id: TransactionId, + ): Stream | FailedTransaction> { + let pollInternal: NodeJS.Timeout | undefined; + const producer: Producer | FailedTransaction> = { + start: listener => { + setInterval(async () => { + try { + const results = await this.searchTx({ id: id }); + switch (results.length) { + case 0: + // okay, we'll try again + break; + case 1: + listener.next(results[0]); + listener.complete(); + break; + default: + throw new Error(`Got unexpected number of search results: ${results.length}`); + } + } catch (error) { + if (pollInternal) { + clearTimeout(pollInternal); + pollInternal = undefined; + } + listener.error(error); + } + }, defaultPollInterval); + }, + stop: () => { + if (pollInternal) { + clearTimeout(pollInternal); + pollInternal = undefined; + } + }, + }; + return Stream.create(producer); + } } diff --git a/packages/bcp/types/cosmwasmconnection.d.ts b/packages/bcp/types/cosmwasmconnection.d.ts index a141324d..2cf15164 100644 --- a/packages/bcp/types/cosmwasmconnection.d.ts +++ b/packages/bcp/types/cosmwasmconnection.d.ts @@ -81,9 +81,10 @@ export declare class CosmWasmConnection implements BlockchainConnection { tags, }: TransactionQuery): Promise | FailedTransaction)[]>; listenTx(_query: TransactionQuery): Stream | FailedTransaction>; - liveTx(_query: TransactionQuery): Stream | FailedTransaction>; + liveTx(query: TransactionQuery): Stream | FailedTransaction>; getFeeQuote(tx: UnsignedTransaction): Promise; withDefaultFee(tx: T): Promise; private parseAndPopulateTxResponseUnsigned; private parseAndPopulateTxResponseSigned; + private waitForTransaction; }