Merge pull request #83 from confio/improve-search

Improve search implementation
This commit is contained in:
Simon Warta 2020-02-12 14:29:50 +01:00 committed by GitHub
commit 0bfb5d28c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 308 additions and 122 deletions

View File

@ -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();
});

View File

@ -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<BankToken & { readonly name: string }>;
@ -79,6 +58,10 @@ export interface TokenConfiguration {
readonly erc20Tokens?: ReadonlyArray<Erc20Token & { readonly name: string }>;
}
function isDefined<X>(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<readonly (ConfirmedTransaction<UnsignedTransaction> | FailedTransaction)[]> {
const queryString = buildQueryString(query);
public async searchTx({
height,
id,
maxHeight,
minHeight,
sentFromOrTo,
signedBy,
tags,
}: TransactionQuery): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | 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)));
}

View File

@ -72,9 +72,15 @@ export declare class CosmWasmConnection implements BlockchainConnection {
watchBlockHeaders(): Stream<BlockHeader>;
getTx(id: TransactionId): Promise<ConfirmedAndSignedTransaction<UnsignedTransaction> | FailedTransaction>;
postTx(tx: PostableBytes): Promise<PostTxResponse>;
searchTx(
query: TransactionQuery,
): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | FailedTransaction)[]>;
searchTx({
height,
id,
maxHeight,
minHeight,
sentFromOrTo,
signedBy,
tags,
}: TransactionQuery): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | FailedTransaction)[]>;
listenTx(_query: TransactionQuery): Stream<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction>;
liveTx(_query: TransactionQuery): Stream<ConfirmedTransaction<UnsignedTransaction> | FailedTransaction>;
getFeeQuote(tx: UnsignedTransaction): Promise<Fee>;

View File

@ -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();

View File

@ -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<readonly TxsResponse[]> {
// 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<PostTxResult> {
const result = await this.restClient.postTx(tx);
if (result.code) {

View File

@ -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,

View File

@ -1,4 +1,5 @@
import { Log } from "./logs";
import { TxsResponse } from "./restclient";
import { Coin, CosmosSdkTx, StdSignature } from "./types";
export interface SigningCallback {
(signBytes: Uint8Array): Promise<StdSignature>;
@ -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<GetNonceResult>;
searchTx(query: SearchTxQuery): Promise<readonly TxsResponse[]>;
postTx(tx: Uint8Array): Promise<PostTxResult>;
/** Uploads code and returns a code ID */
upload(wasmCode: Uint8Array, memo?: string): Promise<number>;

View File

@ -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,